From afa8fb4f89d43e453eccfa2c39587e4fee0bc9cf Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 18 Dec 2024 13:52:46 -0600 Subject: [PATCH 1/3] feat(react): add multiselect to Listbox component (#1763) --- docs/pages/components/Listbox.mdx | 26 ++- .../components/Combobox/ComboboxOption.tsx | 7 +- .../react/src/components/Listbox/Listbox.tsx | 137 +++++++++--- .../src/components/Listbox/ListboxContext.tsx | 8 +- .../src/components/Listbox/ListboxOption.tsx | 14 +- .../src/components/Listbox/index.test.tsx | 205 +++++++++++++++++- 6 files changed, 355 insertions(+), 42 deletions(-) diff --git a/docs/pages/components/Listbox.mdx b/docs/pages/components/Listbox.mdx index fe62efec1..f7200b2eb 100644 --- a/docs/pages/components/Listbox.mdx +++ b/docs/pages/components/Listbox.mdx @@ -102,7 +102,7 @@ function ControlledListboxExample() { One Two @@ -128,6 +128,25 @@ Uncontrolled listboxes will automatically set `aria-selected="true"` for the sel ``` +### Multiselect + +Listboxes can also support multiple selection of listbox options. + +```jsx example +<> +
Multiselect Listbox
+ + One + Two + Three + + +``` + + + Multiselect Listbox components will pass in array values for the selected options in `onSelectionChange` and expect an array of values for `value` and `defaultValue` props. + + ## Props ### Listbox @@ -180,6 +199,11 @@ Uncontrolled listboxes will automatically set `aria-selected="true"` for the sel type: 'boolean', description: 'When set, sets the listbox option as "aria-disabled="true" and removes the element from key navigation.' }, + { + name: 'selected', + type: 'boolean', + description: 'When set, sets the listbox option as "aria-selected="true".' + }, { name: 'activeClass', type: 'string', diff --git a/packages/react/src/components/Combobox/ComboboxOption.tsx b/packages/react/src/components/Combobox/ComboboxOption.tsx index cdf9a50ee..ddd01c19b 100644 --- a/packages/react/src/components/Combobox/ComboboxOption.tsx +++ b/packages/react/src/components/Combobox/ComboboxOption.tsx @@ -79,8 +79,11 @@ const ComboboxOption = forwardRef( }); const isActive = !!active?.element && active.element === comboboxOptionRef.current; - const isSelected = - !!selected?.element && selected.element === comboboxOptionRef.current; + const isSelected = !!( + selected && + !!selected[0]?.element && + selected[0].element === comboboxOptionRef.current + ); const isMatching = (typeof matches === 'boolean' && matches) || (typeof matches === 'function' && matches(children)); diff --git a/packages/react/src/components/Listbox/Listbox.tsx b/packages/react/src/components/Listbox/Listbox.tsx index 7be14b7a1..def6c872e 100644 --- a/packages/react/src/components/Listbox/Listbox.tsx +++ b/packages/react/src/components/Listbox/Listbox.tsx @@ -16,20 +16,34 @@ import useSharedRef from '../../utils/useSharedRef'; const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ']; -interface ListboxProps +interface BaseListboxProps extends PolymorphicProps< - Omit, 'onSelect'> + Omit, 'onSelect' | 'defaultValue'> > { - value?: ListboxValue; navigation?: 'cycle' | 'bound'; - onSelectionChange?: ({ - value - }: { + onActiveChange?: (option: ListboxOption) => void; +} + +interface SingleSelectListboxProps extends BaseListboxProps { + multiselect?: false; + value?: ListboxValue; + defaultValue?: ListboxValue; + onSelectionChange?: (props: { target: T; previousValue: ListboxValue; value: ListboxValue; }) => void; - onActiveChange?: (option: ListboxOption) => void; +} + +interface MultiSelectListboxProps extends BaseListboxProps { + multiselect: true; + value?: ListboxValue[]; + defaultValue?: ListboxValue[]; + onSelectionChange?: (props: { + target: T; + previousValue: ListboxValue[]; + value: ListboxValue[]; + }) => void; } // id for listbox options should always be defined since it should @@ -45,7 +59,10 @@ const optionMatchesValue = (option: ListboxOption, value: unknown): boolean => typeof option.value !== 'undefined' && option.value === value; -const Listbox = forwardRef( +const Listbox = forwardRef< + HTMLElement, + SingleSelectListboxProps | MultiSelectListboxProps +>( ( { as: Component = 'ul', @@ -53,6 +70,7 @@ const Listbox = forwardRef( defaultValue, value, navigation = 'bound', + multiselect = false, onKeyDown, onFocus, onSelectionChange, @@ -65,25 +83,36 @@ const Listbox = forwardRef( const [activeOption, setActiveOption] = useState( null ); - const [selectedOption, setSelectedOption] = useState( - null - ); + const [selectedOptions, setSelectedOptions] = useState([]); const listboxRef = useSharedRef(ref); const isControlled = typeof value !== 'undefined'; useLayoutEffect(() => { - if (!isControlled && selectedOption) { + if (!isControlled && selectedOptions.length > 0) { return; } const listboxValue = isControlled ? value : defaultValue; - const matchingOption = options.find((option) => - optionMatchesValue(option, listboxValue) - ); + if (!listboxValue) { + return; + } - setSelectedOption(matchingOption || null); - setActiveOption(matchingOption || null); - }, [isControlled, options, value]); + if (multiselect) { + const matchingOptions = options.filter((option) => + (listboxValue as ListboxValue[]).find((value) => + optionMatchesValue(option, value) + ) + ); + setSelectedOptions(matchingOptions); + setActiveOption(matchingOptions[0] || null); + } else { + const matchingOption = options.find((option) => + optionMatchesValue(option, listboxValue) + ); + setSelectedOptions(matchingOption ? [matchingOption] : []); + setActiveOption(matchingOption || null); + } + }, [isControlled, options, value, defaultValue]); useEffect(() => { if (activeOption) { @@ -94,17 +123,56 @@ const Listbox = forwardRef( const handleSelect = useCallback( (option: ListboxOption) => { setActiveOption(option); + const optionIsSelected = selectedOptions.some( + (selected) => selected.element === option.element + ); + const previousValues = selectedOptions.map( + (selected) => selected.value + ); + // istanbul ignore else if (!isControlled) { - setSelectedOption(option); + if (!multiselect) { + setSelectedOptions([option]); + } else { + setSelectedOptions( + optionIsSelected + ? [ + ...selectedOptions.filter( + (selected) => selected.element !== option.element + ) + ] + : [...selectedOptions, option] + ); + } + } + + if (multiselect) { + (onSelectionChange as MultiSelectListboxProps['onSelectionChange'])?.( + { + target: option.element, + value: optionIsSelected + ? selectedOptions + .filter( + (selectedOption) => + selectedOption.element !== option.element + ) + .map((selectedOption) => selectedOption.value) + : [...previousValues, option.value], + previousValue: previousValues + } + ); + } else { + ( + onSelectionChange as SingleSelectListboxProps['onSelectionChange'] + )?.({ + target: option.element, + value: option.value, + previousValue: selectedOptions[0]?.value + }); } - onSelectionChange?.({ - target: option.element, - value: option.value, - previousValue: selectedOption?.value - }); }, - [isControlled, selectedOption] + [isControlled, selectedOptions, multiselect, onSelectionChange] ); const handleKeyDown = useCallback( @@ -170,12 +238,12 @@ const Listbox = forwardRef( break; } }, - [options, activeOption, navigation] + [options, activeOption, navigation, handleSelect] ); const handleFocus = useCallback( (event: React.FocusEvent) => { - if (!activeOption && !selectedOption) { + if (!activeOption) { const firstOption = options.find( (option) => !isDisabledOption(option) ); @@ -184,13 +252,16 @@ const Listbox = forwardRef( setActiveOption(firstOption); } // istanbul ignore else - } else if (event.target === listboxRef.current) { - setActiveOption(selectedOption); + } else if ( + selectedOptions.length && + event.target === listboxRef.current + ) { + setActiveOption(selectedOptions[selectedOptions.length - 1]); } onFocus?.(event); }, - [options, activeOption, selectedOption] + [options, activeOption, selectedOptions] ); return ( @@ -200,6 +271,7 @@ const Listbox = forwardRef( tabIndex="0" onKeyDown={handleKeyDown} onFocus={handleFocus} + aria-multiselectable={multiselect ? true : undefined} aria-activedescendant={ activeOption ? getOptionId(activeOption) : undefined } @@ -208,7 +280,8 @@ const Listbox = forwardRef( @@ -217,7 +290,7 @@ const Listbox = forwardRef( ); } -) as PolymorphicComponent; +) as PolymorphicComponent; Listbox.displayName = 'Listbox'; diff --git a/packages/react/src/components/Listbox/ListboxContext.tsx b/packages/react/src/components/Listbox/ListboxContext.tsx index df8ed0f3a..69c33ef46 100644 --- a/packages/react/src/components/Listbox/ListboxContext.tsx +++ b/packages/react/src/components/Listbox/ListboxContext.tsx @@ -10,7 +10,8 @@ type ListboxOption = { type ListboxContext = { options: T[]; active: T | null; - selected: T | null; + selected: T[] | null; + multiselect: boolean; setOptions: React.Dispatch>; onSelect: (option: T) => void; }; @@ -24,6 +25,7 @@ const ListboxContext = createContext({ options: [], active: null, selected: null, + multiselect: false, setOptions: () => null, onSelect: () => null }); @@ -32,6 +34,7 @@ function ListboxProvider({ options, active, selected, + multiselect, setOptions, onSelect, children @@ -44,10 +47,11 @@ function ListboxProvider({ options, active, selected, + multiselect, setOptions, onSelect }), - [options, active, selected, setOptions] + [options, active, selected, multiselect, setOptions] ); return {children}; diff --git a/packages/react/src/components/Listbox/ListboxOption.tsx b/packages/react/src/components/Listbox/ListboxOption.tsx index fc0eb46f0..2c353d30d 100644 --- a/packages/react/src/components/Listbox/ListboxOption.tsx +++ b/packages/react/src/components/Listbox/ListboxOption.tsx @@ -14,6 +14,7 @@ interface ListboxOptionProps extends PolymorphicProps> { value?: ListboxValue; disabled?: boolean; + selected?: boolean; activeClass?: string; } @@ -30,6 +31,7 @@ const ListboxOption = forwardRef( children, value, disabled, + selected: selectedProp, activeClass = 'ListboxOption--active', onClick, ...props @@ -39,10 +41,14 @@ const ListboxOption = forwardRef( const { active, selected, setOptions, onSelect } = useListboxContext(); const listboxOptionRef = useSharedRef(ref); const [id] = propId ? [propId] : useId(1, 'listbox-option'); - const isActive = - active !== null && active.element === listboxOptionRef.current; + const isActive = active?.element === listboxOptionRef.current; const isSelected = - selected !== null && selected.element === listboxOptionRef.current; + typeof selectedProp === 'boolean' + ? selectedProp + : selected !== null && + !!selected.find( + (option) => option.element === listboxOptionRef.current + ); const optionValue = typeof value !== 'undefined' ? value @@ -98,7 +104,7 @@ const ListboxOption = forwardRef( onSelect({ element: listboxOptionRef.current, value: optionValue }); onClick?.(event); }, - [optionValue] + [optionValue, onSelect, onClick, disabled] ); return ( diff --git a/packages/react/src/components/Listbox/index.test.tsx b/packages/react/src/components/Listbox/index.test.tsx index 2f97f88a7..7f3a3e699 100644 --- a/packages/react/src/components/Listbox/index.test.tsx +++ b/packages/react/src/components/Listbox/index.test.tsx @@ -568,11 +568,214 @@ test('should retain selected value when options changes with defaultValue', () = assertListItemIsSelected(2); }); +test('should render multiselect listbox', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + expect(screen.getByRole('listbox')).toHaveAttribute( + 'aria-multiselectable', + 'true' + ); +}); + +test('should allow multiple selections in uncontrolled multiselect listbox', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + fireEvent.click(screen.getByRole('option', { name: 'Apple' })); + fireEvent.click(screen.getByRole('option', { name: 'Banana' })); + + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('option', { name: 'Cantaloupe' })).toHaveAttribute( + 'aria-selected', + 'false' + ); +}); + +test('should handle deselection in multiselect listbox', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + const appleOption = screen.getByRole('option', { name: 'Apple' }); + + // Select then deselect + fireEvent.click(appleOption); + expect(appleOption).toHaveAttribute('aria-selected', 'true'); + + fireEvent.click(appleOption); + expect(appleOption).toHaveAttribute('aria-selected', 'false'); +}); + +test('should handle deselection selection with multiple selected options in multiselect listbox', () => { + const handleSelectionChange = jest.fn(); + + render( + + Apple + Banana + Cantaloupe + + ); + + const listbox = screen.getByRole('listbox'); + fireEvent.focus(listbox); + fireEvent.keyDown(listbox, { key: 'Enter' }); + + // the most recently selected item should be the initial active one + expect(handleSelectionChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: ['Apple'], + previousValue: ['Apple', 'Banana'] + }) + ); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute( + 'aria-selected', + 'false' + ); +}); + +test('should handle controlled multiselect selection', () => { + const handleSelectionChange = jest.fn(); + + render( + + Apple + Banana + Cantaloupe + + ); + + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + + fireEvent.click(screen.getByRole('option', { name: 'Cantaloupe' })); + + expect(handleSelectionChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: ['Apple', 'Banana', 'Cantaloupe'], + previousValue: ['Apple', 'Banana'] + }) + ); +}); + +test('should set initial values with defaultValue in multiselect', () => { + render( + + Apple + Banana + Cantaloupe + + ); + + assertListItemIsSelected(0); + assertListItemIsSelected(1); + expect(screen.getByRole('option', { name: 'Cantaloupe' })).toHaveAttribute( + 'aria-selected', + 'false' + ); +}); + +test('should handle keyboard selection in multiselect', () => { + const handleSelectionChange = jest.fn(); + + render( + + Apple + Banana + Cantaloupe + + ); + + const listbox = screen.getByRole('listbox'); + fireEvent.focus(listbox); + + // Move to first item and select + simulateKeypress('ArrowDown'); + fireEvent.keyDown(listbox, { key: 'Enter' }); + + // Move to second item and select + simulateKeypress('ArrowDown'); + fireEvent.keyDown(listbox, { key: ' ' }); + + expect(handleSelectionChange).toHaveBeenCalledWith( + expect.objectContaining({ + value: ['Banana', 'Cantaloupe'], + previousValue: ['Banana'] + }) + ); + assertListItemIsSelected(1); + assertListItemIsSelected(2); +}); + test('should return no axe violations', async () => { const { container } = render( <>
Colors and Numbers
- + + + Red + Green + Blue + + + One + Two + Three + + + + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); + +test('should return no axe violations with multiselect', async () => { + const { container } = render( + <> +
Colors and Numbers
+ Red Green From 67739754b0c6368ec1db878ace917730db102797 Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 18 Dec 2024 14:09:28 -0600 Subject: [PATCH 2/3] feat(react,style): add AnchoredOverlay component, refactor Tooltip and Popover to use AnchoredOverlay (#1760) --- docs/pages/components/AnchoredOverlay.mdx | 189 ++++++++++++++++++ docs/pages/components/Popover.mdx | 2 +- docs/pages/components/Tooltip.mdx | 2 +- packages/react/package.json | 3 +- .../AnchoredOverlay/AnchoredOverlay.test.tsx | 162 +++++++++++++++ .../src/components/AnchoredOverlay/index.tsx | 118 +++++++++++ .../react/src/components/Code/Code.test.tsx | 2 +- .../src/components/Pagination/Pagination.tsx | 4 +- .../react/src/components/Popover/index.tsx | 47 +---- .../TextEllipsis/TextEllipsis.test.tsx | 2 +- .../react/src/components/Tooltip/index.tsx | 92 ++------- packages/react/src/index.ts | 1 + packages/react/yarn.lock | 54 ++--- packages/styles/popover.css | 5 + packages/styles/tooltip.css | 5 + 15 files changed, 546 insertions(+), 142 deletions(-) create mode 100644 docs/pages/components/AnchoredOverlay.mdx create mode 100644 packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx create mode 100644 packages/react/src/components/AnchoredOverlay/index.tsx diff --git a/docs/pages/components/AnchoredOverlay.mdx b/docs/pages/components/AnchoredOverlay.mdx new file mode 100644 index 000000000..13bd6cfd4 --- /dev/null +++ b/docs/pages/components/AnchoredOverlay.mdx @@ -0,0 +1,189 @@ +--- +title: AnchoredOverlay +description: A component that displays an anchored layered element relative to a target element. +source: https://github.com/dequelabs/cauldron/tree/develop/packages/react/src/components/AnchoredOverlay/index.tsx +--- + +import { useRef, useState } from 'react' +import { Select, Button, AnchoredOverlay } from '@deque/cauldron-react' +export const placements = [ + 'top', + 'top-start', + 'top-end', + 'right', + 'right-start', + 'right-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + 'auto', + 'auto-start', + 'auto-end' +] + +```jsx +import { AnchoredOverlay } from '@deque/cauldron-react' +``` + +Under the hood, `AnchoredOverlay` uses [floating-ui](https://floating-ui.com/) to dynamically position an overlay element relative to a target element. It is intentionally un-styled to be composed with other components, such as [Tooltip]('./Tooltip'), [Popover](./Popover), or via more complex overlay components. + + + `AnchoredOverlay` is a positioning component and does not include built-in accessibility features like ARIA attributes, focus management, or keyboard interactions that would be needed for components like tooltips, dialogs, or popovers. When using `AnchoredOverlay`, you'll need to implement these accessibility patterns yourself based on your specific use case. + + +## Examples + +### Placement + +By default, initial placement is set to `auto` when it is not set via props. However the placement can [dynamically change](https://floating-ui.com/docs/autoplacement) when using `auto` or [flip](https://floating-ui.com/docs/flip) when using positional placement. + +If there are presentation elements that are dependent on the position of the `AnchoredOverlay`, you should use `onPlacementChange` to keep these presentation elements in sync with any updated placements. + +```jsx example +function AnchoredOverlayExample() { + const [placement, setPlacement] = useState('top') + const [open, setOpen] = useState(false) + const targetRef = useRef() + const handlePlacementChange = ({ target }) => setPlacement(target.value); + const toggleOpen = () => setOpen(!open) + const handleClose = () => setOpen(false) + + return ( + <> + ({ value: placement }))} + onChange={handlePlacementChange} + /> + + setOpen(openState)} + offset={20} + style={{ + padding: 'var(--space-small)', + backgroundColor: 'var(--panel-background-color)', + display: open ? 'block' : 'none' + }} + > + Anchored Overlay Element with offset placement {placement} + + + ) +} +``` + +## Props + +', 'React.RefObject'], + required: true, + description: 'A target element or ref to attach the overlay anchor element.' + }, + { + name: 'placement', + type: 'string', + defaultValue: 'auto', + description: 'Positional placement value to anchor the overlay element relative to its anchored target.' + }, + { + name: 'open', + type: 'boolean', + defaultValue: 'false', + description: 'Determines if the overlay anchor is currently visible.' + }, + { + name: 'onOpenChange', + type: '(open: boolean) => void', + description: 'A callback function that is called when the overlay state changes.' + }, + { + name: 'onPlacementChange', + type: '(placement: Placement) => void', + description: 'A callback function that is called when the placement of the overlay changes.' + }, + { + name: 'offset', + type: 'number', + description: 'An optional offset number to position the anchor element from its anchored target.' + }, + { + name: 'as', + type: 'React.ElementType', + defaultValue: 'div', + description: 'The element type to render as.' + } + ]} +/> + +## Related Components + +- [Tooltip](./Tooltip) +- [Popover](./Popover) + diff --git a/docs/pages/components/Popover.mdx b/docs/pages/components/Popover.mdx index 5ec2fa27f..4d720a093 100644 --- a/docs/pages/components/Popover.mdx +++ b/docs/pages/components/Popover.mdx @@ -13,7 +13,7 @@ import { Popover, Button } from '@deque/cauldron-react' ## Examples -Cauldron's tooltip relies on [Popper](https://popper.js.org/) to position tooltips dynamically. Popover can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object. +Cauldron's tooltip relies on [Floating UI](https://floating-ui.com/) to position tooltips dynamically. Popover can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object. ### Prompt Popover diff --git a/docs/pages/components/Tooltip.mdx b/docs/pages/components/Tooltip.mdx index 822715511..a15ff5f51 100644 --- a/docs/pages/components/Tooltip.mdx +++ b/docs/pages/components/Tooltip.mdx @@ -13,7 +13,7 @@ import { Tooltip } from '@deque/cauldron-react' ## Examples -Cauldron's tooltip relies on [Popper](https://popper.js.org/) to position tooltips dynamically. Tooltips can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object. +Cauldron's tooltip relies on [Floating UI](https://floating-ui.com/) to position tooltips dynamically. Tooltips can be triggered from any focusable element via a `target` attribute pointed to an HTMLElement or React ref object. diff --git a/packages/react/package.json b/packages/react/package.json index 333ff7361..30f1589b3 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -23,13 +23,12 @@ "test": "jest --maxWorkers=1 --coverage" }, "dependencies": { - "@popperjs/core": "^2.5.4", + "@floating-ui/react-dom": "^2.1.2", "classnames": "^2.2.6", "focus-trap-react": "^10.2.3", "focusable": "^2.3.0", "keyname": "^0.1.0", "react-id-generator": "^3.0.1", - "react-popper": "^2.2.4", "react-syntax-highlighter": "^15.5.0", "tslib": "^2.4.0" }, diff --git a/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx new file mode 100644 index 000000000..4fdbd385b --- /dev/null +++ b/packages/react/src/components/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AnchoredOverlay from './'; +import axe from '../../axe'; + +test('should render children', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Hello World + + ); + expect(screen.getByText('Hello World')).toBeInTheDocument(); +}); + +test('should support className prop', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + expect(screen.getByTestId('overlay')).toHaveClass('custom'); +}); + +test('should support as prop for polymorphic rendering', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + expect(screen.getByTestId('overlay').tagName).toBe('SPAN'); +}); + +test('should support auto placement', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + expect(screen.getByTestId('overlay')).toBeInTheDocument(); +}); + +test('should support auto-start placement', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + expect(screen.getByTestId('overlay')).toBeInTheDocument(); +}); + +test('should support auto-end placement', () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + expect(screen.getByTestId('overlay')).toBeInTheDocument(); +}); + +test('should call onOpenChange when escape is pressed', async () => { + const targetRef = { current: document.createElement('button') }; + const onOpenChange = jest.fn(); + const user = userEvent.setup(); + + render( + + Content + + ); + + await user.keyboard('{Escape}'); + expect(onOpenChange).toHaveBeenCalledWith(false); +}); + +test('should call onPlacementChange with initial placement', () => { + const targetRef = { current: document.createElement('button') }; + const onPlacementChange = jest.fn(); + + render( + + Content + + ); + + expect(onPlacementChange).toHaveBeenCalledWith('top'); +}); + +test('should support ref prop', () => { + const targetRef = { current: document.createElement('button') }; + const ref = React.createRef(); + + render( + + Content + + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toEqual(screen.getByTestId('overlay')); +}); + +test('should return no axe violations when opened', async () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + + const results = await axe(screen.getByTestId('overlay')); + expect(results).toHaveNoViolations(); +}); + +test('should return no axe violations when not open', async () => { + const targetRef = { current: document.createElement('button') }; + render( + + Content + + ); + + const results = await axe(screen.getByTestId('overlay')); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/react/src/components/AnchoredOverlay/index.tsx b/packages/react/src/components/AnchoredOverlay/index.tsx new file mode 100644 index 000000000..74b40379d --- /dev/null +++ b/packages/react/src/components/AnchoredOverlay/index.tsx @@ -0,0 +1,118 @@ +import { autoUpdate, type Placement } from '@floating-ui/dom'; +import React, { forwardRef, useEffect } from 'react'; +import { + useFloating, + offset as offsetMiddleware, + flip as flipMiddleware, + autoPlacement as autoPlacementMiddleware +} from '@floating-ui/react-dom'; +import { type PolymorphicProps } from '../../utils/polymorphicComponent'; +import resolveElement from '../../utils/resolveElement'; +import useSharedRef from '../../utils/useSharedRef'; +import useEscapeKey from '../../utils/useEscapeKey'; + +type AnchoredOverlayProps< + Overlay extends HTMLElement, + Target extends HTMLElement +> = { + /** A target element or ref to attach the overlay anchor element. */ + target: Target | React.MutableRefObject | React.RefObject; + /** Positional placement value to anchor the overlay element relative to its anchored target. */ + placement?: Placement | 'auto' | 'auto-start' | 'auto-end'; + /** Determines if the overlay anchor is currently visible. */ + open?: boolean; + /** A callback function that is called when the overlay state changes. */ + onOpenChange?: (open: boolean) => void; + /** A callback function that is called when the placement of the overlay changes. */ + onPlacementChange?: (placement: Placement) => void; + /** An optional offset number to position the anchor element from its anchored target. */ + offset?: number; + children?: React.ReactNode; +} & PolymorphicProps>; + +function getAutoAlignment( + placement: 'auto' | 'auto-start' | 'auto-end' +): 'start' | 'end' | null { + switch (placement) { + case 'auto-start': + return 'start'; + case 'auto-end': + return 'end'; + default: + return null; + } +} + +const AnchoredOverlay = forwardRef( + < + Overlay extends HTMLElement = HTMLElement, + Target extends HTMLElement = HTMLElement + >( + { + as, + placement: initialPlacement = 'auto', + target, + children, + style, + open = false, + offset, + onOpenChange, + onPlacementChange, + ...props + }: AnchoredOverlayProps, + refProp: React.Ref + ) => { + const ref = useSharedRef(refProp); + const Component = as || 'div'; + const { floatingStyles, placement } = useFloating({ + open, + // default to initial placement on top when placement is auto + // @ts-expect-error auto placement is not a valid placement for floating-ui + placement: initialPlacement.startsWith('auto') ? 'top' : initialPlacement, + middleware: [ + offsetMiddleware(offset ?? 0), + initialPlacement.startsWith('auto') + ? autoPlacementMiddleware({ + alignment: getAutoAlignment(initialPlacement as 'auto') + }) + : flipMiddleware() + ].filter(Boolean), + elements: { + reference: resolveElement(target), + floating: ref.current + }, + whileElementsMounted: autoUpdate + }); + + useEscapeKey({ + active: open, + capture: true, + defaultPrevented: true, + callback: (event: KeyboardEvent) => { + // when an anchored overlay is open, we want to prevent other potential "escape" + // keypress events, like the closing of modals from occurring + event.preventDefault(); + // istanbul ignore else + if (typeof onOpenChange === 'function') { + onOpenChange(!open); + } + } + }); + + useEffect(() => { + if (typeof onPlacementChange === 'function') { + onPlacementChange(placement); + } + }, [placement]); + + return ( + + {children} + + ); + } +); + +AnchoredOverlay.displayName = 'AnchoredOverlay'; + +export default AnchoredOverlay; diff --git a/packages/react/src/components/Code/Code.test.tsx b/packages/react/src/components/Code/Code.test.tsx index fc4947049..819e8f015 100644 --- a/packages/react/src/components/Code/Code.test.tsx +++ b/packages/react/src/components/Code/Code.test.tsx @@ -10,7 +10,7 @@ const sandbox = createSandbox(); beforeEach(() => { global.ResizeObserver = global.ResizeObserver || (() => null); sandbox.stub(global, 'ResizeObserver').callsFake((listener) => { - listener(); + listener([]); return { observe: sandbox.stub(), disconnect: sandbox.stub() diff --git a/packages/react/src/components/Pagination/Pagination.tsx b/packages/react/src/components/Pagination/Pagination.tsx index 65358cdd2..5b8d3098c 100644 --- a/packages/react/src/components/Pagination/Pagination.tsx +++ b/packages/react/src/components/Pagination/Pagination.tsx @@ -1,6 +1,6 @@ import React from 'react'; import classNames from 'classnames'; -import { Placement } from '@popperjs/core'; +import type AnchoredOverlay from '../AnchoredOverlay'; import IconButton from '../IconButton'; import { ContentNode } from '../../types'; @@ -18,7 +18,7 @@ interface Props extends React.HTMLAttributes { onPreviousPageClick?: (event: React.MouseEvent) => void; onFirstPageClick?: (event: React.MouseEvent) => void; onLastPageClick?: (event: React.MouseEvent) => void; - tooltipPlacement?: Placement; + tooltipPlacement?: React.ComponentProps['placement']; thin?: boolean; className?: string; } diff --git a/packages/react/src/components/Popover/index.tsx b/packages/react/src/components/Popover/index.tsx index 21e17097d..abefd4b2b 100644 --- a/packages/react/src/components/Popover/index.tsx +++ b/packages/react/src/components/Popover/index.tsx @@ -1,11 +1,10 @@ import React, { useState, useEffect, ReactNode, forwardRef, Ref } from 'react'; import { createPortal } from 'react-dom'; import { useId } from 'react-id-generator'; -import { Placement } from '@popperjs/core'; -import { usePopper } from 'react-popper'; import { isBrowser } from '../../utils/is-browser'; import { Cauldron } from '../../types'; import classnames from 'classnames'; +import AnchoredOverlay from '../AnchoredOverlay'; import ClickOutsideListener from '../ClickOutsideListener'; import Button from '../Button'; import FocusTrap from 'focus-trap-react'; @@ -21,7 +20,7 @@ type BaseProps = React.HTMLAttributes & { variant?: PopoverVariant; show: boolean; onClose: () => void; - placement?: Placement; + placement?: React.ComponentProps['placement']; portal?: React.RefObject | HTMLElement; }; @@ -95,35 +94,12 @@ const Popover = forwardRef( ref: Ref ): React.JSX.Element | null => { const [id] = propId ? [propId] : useId(1, 'popover'); - const [targetElement, setTargetElement] = useState( null ); - const [isolator, setIsolator] = useState(null); - const popoverRef = useSharedRef(ref); - - const [arrowElement, setArrowElement] = useState(null); - - const { styles, attributes } = usePopper( - targetElement, - popoverRef?.current, - { - placement: initialPlacement, - modifiers: [ - { name: 'preventOverflow', options: { padding: 8 } }, - { name: 'flip' }, - { name: 'offset', options: { offset: [0, 8] } }, - { name: 'arrow', options: { padding: 5, element: arrowElement } } - ] - } - ); - - const placement: Placement = - (attributes.popper && - (attributes.popper['data-popper-placement'] as Placement)) || - initialPlacement; + const [placement, setPlacement] = useState(initialPlacement); const additionalProps = variant === 'prompt' && !props['aria-label'] @@ -228,7 +204,7 @@ const Popover = forwardRef( }} > -
( )} ref={popoverRef} role="dialog" - style={styles.popper} - {...attributes.popper} + target={target} + open={show} + placement={initialPlacement} + onPlacementChange={setPlacement} + offset={8} {...additionalProps} {...props} > -
+
{variant === 'prompt' ? ( ( ) : ( children )} -
+ , (portal && 'current' in portal ? portal.current : portal) || diff --git a/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx index 0807eda57..ff306830c 100644 --- a/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx +++ b/packages/react/src/components/TextEllipsis/TextEllipsis.test.tsx @@ -9,7 +9,7 @@ const sandbox = createSandbox(); beforeEach(() => { global.ResizeObserver = global.ResizeObserver || (() => null); sandbox.stub(global, 'ResizeObserver').callsFake((callback) => { - callback(); + callback([]); return { observe: sandbox.stub(), disconnect: sandbox.stub() diff --git a/packages/react/src/components/Tooltip/index.tsx b/packages/react/src/components/Tooltip/index.tsx index 72a1b638e..5815d2623 100644 --- a/packages/react/src/components/Tooltip/index.tsx +++ b/packages/react/src/components/Tooltip/index.tsx @@ -2,11 +2,10 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import classnames from 'classnames'; import { createPortal } from 'react-dom'; import { useId } from 'react-id-generator'; -import { Placement } from '@popperjs/core'; -import { usePopper } from 'react-popper'; +import AnchoredOverlay from '../AnchoredOverlay'; import { isBrowser } from '../../utils/is-browser'; import { addIdRef, hasIdRef, removeIdRef } from '../../utils/idRefs'; -import useEscapeKey from '../../utils/useEscapeKey'; +import resolveElement from '../../utils/resolveElement'; const TIP_HIDE_DELAY = 100; @@ -18,7 +17,7 @@ export interface TooltipProps extends React.HTMLAttributes { association?: 'aria-labelledby' | 'aria-describedby' | 'none'; show?: boolean | undefined; defaultShow?: boolean; - placement?: Placement; + placement?: React.ComponentProps['placement']; portal?: React.RefObject | HTMLElement; hideElementOnHidden?: boolean; } @@ -57,52 +56,27 @@ export default function Tooltip({ const [id] = propId ? [propId] : useId(1, 'tooltip'); const hideTimeoutRef = useRef | null>(null); const [showTooltip, setShowTooltip] = useState(!!showProp || defaultShow); - const [targetElement, setTargetElement] = useState(null); const [tooltipElement, setTooltipElement] = useState( null ); - const [arrowElement, setArrowElement] = useState(null); + const [placement, setPlacement] = useState(initialPlacement); const hasAriaAssociation = association !== 'none'; - const { styles, attributes, update } = usePopper( - targetElement, - tooltipElement, - { - placement: initialPlacement, - modifiers: [ - { name: 'preventOverflow', options: { padding: 8 } }, - { - name: 'flip', - options: { fallbackPlacements: ['left', 'right', 'top', 'bottom'] } - }, - { name: 'offset', options: { offset: [0, 8] } }, - { name: 'arrow', options: { padding: 5, element: arrowElement } } - ] - } - ); - // Show the tooltip const show: EventListener = useCallback(async () => { + const targetElement = resolveElement(target); // Clear the hide timeout if there was one pending if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } - // Make sure we update the tooltip position when showing - // in case the target's position changed without popper knowing - if (update) { - await update(); - } setShowTooltip(true); fireCustomEvent(true, targetElement); - }, [ - targetElement, - // update starts off as null - update - ]); + }, [target]); // Hide the tooltip const hide: EventListener = useCallback(() => { + const targetElement = resolveElement(target); if (!hideTimeoutRef.current) { hideTimeoutRef.current = setTimeout(() => { hideTimeoutRef.current = null; @@ -114,13 +88,6 @@ export default function Tooltip({ return () => { clearTimeout(hideTimeoutRef.current as unknown as number); }; - }, [targetElement]); - - // Keep targetElement in sync with target prop - useEffect(() => { - const targetElement = - target && 'current' in target ? target.current : target; - setTargetElement(targetElement); }, [target]); useEffect(() => { @@ -129,27 +96,9 @@ export default function Tooltip({ } }, [showProp]); - // Get popper placement - const placement: Placement = - (attributes.popper && - (attributes.popper['data-popper-placement'] as Placement)) || - initialPlacement; - - // Only listen to key ups when the tooltip is visible - useEscapeKey( - { - callback: (event) => { - event.preventDefault(); - setShowTooltip(false); - }, - capture: true, - active: showTooltip && typeof showProp !== 'boolean' - }, - [setShowTooltip] - ); - // Handle hover and focus events for the targetElement useEffect(() => { + const targetElement = resolveElement(target); if (typeof showProp !== 'boolean') { targetElement?.addEventListener('mouseenter', show); targetElement?.addEventListener('mouseleave', hide); @@ -163,7 +112,7 @@ export default function Tooltip({ targetElement?.removeEventListener('focusin', show); targetElement?.removeEventListener('focusout', hide); }; - }, [targetElement, show, hide, showProp]); + }, [target, show, hide, showProp]); // Handle hover events for the tooltipElement useEffect(() => { @@ -180,6 +129,7 @@ export default function Tooltip({ // Keep the target's id in sync useEffect(() => { + const targetElement = resolveElement(target); if (hasAriaAssociation) { const idRefs = targetElement?.getAttribute(association); if (!hasIdRef(idRefs, id)) { @@ -193,14 +143,19 @@ export default function Tooltip({ targetElement.setAttribute(association, removeIdRef(idRefs, id)); } }; - }, [targetElement, id, association]); + }, [target, id, association]); return ( <> {(showTooltip || hideElementOnHidden) && isBrowser() ? createPortal( -