From 2562efe585ad2d351dce721808dea9957b3b8a75 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 24 Aug 2023 14:49:01 -0500 Subject: [PATCH 01/28] feat(react): add Listbox component (#1167) --- docs/pages/components/Listbox.mdx | 217 +++++++ packages/react/__tests__/setupTests.js | 12 + .../__tests__/src/components/Listbox/index.js | 611 ++++++++++++++++++ .../react/src/components/Listbox/Listbox.tsx | 219 +++++++ .../src/components/Listbox/ListboxContext.tsx | 60 ++ .../src/components/Listbox/ListboxGroup.tsx | 37 ++ .../src/components/Listbox/ListboxOption.tsx | 121 ++++ .../react/src/components/Listbox/index.ts | 4 + packages/react/src/index.ts | 5 + packages/styles/forms.css | 30 +- 10 files changed, 1306 insertions(+), 10 deletions(-) create mode 100644 docs/pages/components/Listbox.mdx create mode 100644 packages/react/__tests__/src/components/Listbox/index.js create mode 100644 packages/react/src/components/Listbox/Listbox.tsx create mode 100644 packages/react/src/components/Listbox/ListboxContext.tsx create mode 100644 packages/react/src/components/Listbox/ListboxGroup.tsx create mode 100644 packages/react/src/components/Listbox/ListboxOption.tsx create mode 100644 packages/react/src/components/Listbox/index.ts diff --git a/docs/pages/components/Listbox.mdx b/docs/pages/components/Listbox.mdx new file mode 100644 index 000000000..ee985a74b --- /dev/null +++ b/docs/pages/components/Listbox.mdx @@ -0,0 +1,217 @@ +--- +title: Listbox +description: An unstyled component to provide a keyboard-navigable list of options. +source: https://github.com/dequelabs/cauldron/tree/develop/packages/react/src/components/Listbox/Listbox.tsx +--- + +import { useState } from 'react'; +import { Listbox, ListboxOption, ListboxGroup } from '@deque/cauldron-react'; + +```js +import { + Listbox, + ListboxOption, + ListboxGroup +} from '@deque/cauldron-react'; +``` + +## Examples + + + Listbox follows [aria practices](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) for _Listbox_ but is not currently intended to be used by itself. This component's intended usage is to be composed of components that have keyboard-navigable items like Combobox and [OptionsMenu](./OptionsMenu). + + +### Listbox Options + +A listbox can contain a list of options with optional values. + +```jsx example +<> +
Numbers
+ + One + Two + Three + + +``` + +### Disabled Options + +A listbox option can be optionally disabled. + +```jsx example +<> +
Numbers, but two is disabled
+ + One + Two + Three + + +``` + +### Grouped Options + +Listbox options can also be grouped into categories. As a best practice, when using grouped options the `as` property should be set to a generic container for the wrapping `Listbox` component for proper HTML semantics. + +```jsx example +<> +
Colors and Numbers
+ + + Red + Green + Blue + + + One + Two + Three + + + +``` + +### Keyboard Navigation + +By default, keyboard navigation will stop at the first or last option of the list. To wrap around focus to the beginning or the end of the list, `navigation="cycle"` can be set to enable this behavior. + +```jsx example +<> +
Numbers that Cycle
+ + One + Two + Three + + +``` + +### Controlled + +Controlled listboxes require updating the value to reflect the newly selected state. Useful if some form of validation needs to be performed before setting the selected value. + +```jsx example +function ControlledListboxExample() { + const [value, setValue] = useState('Two'); + const handleSelect = ({ value }) => setValue(value); + return ( + <> +
Controlled Listbox
+ + One + Two + Three + + + ); +} +``` + +### Uncontrolled + +Uncontrolled listboxes will automatically set `aria-selected="true"` for the selected option upon selection. The initial value can be set via `defaultValue`. + +```jsx example +<> +
Uncontrolled Listbox
+ + One + Two + Three + + +``` + +## Props + +### Listbox + + + +### ListboxOption + + + +### ListboxGroup + + \ No newline at end of file diff --git a/packages/react/__tests__/setupTests.js b/packages/react/__tests__/setupTests.js index 1e9323ca4..a9b5488a1 100644 --- a/packages/react/__tests__/setupTests.js +++ b/packages/react/__tests__/setupTests.js @@ -3,3 +3,15 @@ import Adapter from 'enzyme-adapter-react-16'; import 'jest-axe/extend-expect'; configure({ adapter: new Adapter() }); + +if ( + !Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, 'innerText') +) { + // JSDOM doesn't fully support innerText, but we can fall back to + // using textContent for now until this gets patched + Object.defineProperty(window.HTMLElement.prototype, 'innerText', { + get() { + return this.textContent; + }, + }); +} diff --git a/packages/react/__tests__/src/components/Listbox/index.js b/packages/react/__tests__/src/components/Listbox/index.js new file mode 100644 index 000000000..02a6ab08c --- /dev/null +++ b/packages/react/__tests__/src/components/Listbox/index.js @@ -0,0 +1,611 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { spy } from 'sinon'; +import { + default as Listbox, + ListboxGroup, + ListboxOption +} from 'src/components/Listbox'; + +// Utility function for checking for active element for the given li index of a Listbox component +const listItemIsActive = (wrapper) => (index) => { + const ul = wrapper.find(Listbox).childAt(0); + const options = ul.find('[role="option"]'); + const activeOption = options.at(index); + expect(ul.prop('aria-activedescendant')).toBeTruthy(); + expect(ul.find('[role="listbox"]').prop('aria-activedescendant')).toEqual( + activeOption.prop('id') + ); + expect(activeOption.hasClass('ListboxOption--active')).toBeTruthy(); + options.forEach( + (option, index) => + index !== index && + expect(option).hasClass('ListboxOption--active').toBeFalsy() + ); +}; + +// Utility function for checking for selected element for the given li index of a Listbox component +const listItemIsSelected = (wrapper) => (index) => { + const options = wrapper.find('[role="option"]'); + const selectedOption = options.at(index); + expect(selectedOption.prop('aria-selected')).toEqual(true); + options.forEach( + (option, index) => + index !== index && expect(option).prop('aria-selected').toEqual(false) + ); +}; + +const simulateKeydown = + (wrapper, key) => + (event = {}) => { + wrapper.simulate('keydown', { key, ...event }); + wrapper.update(); + }; + +test('should render listbox with options', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + expect(wrapper.find(Listbox).exists()).toBeTruthy(); + expect(wrapper.find(ListboxOption).at(0).text()).toEqual('Apple'); + expect(wrapper.find(ListboxOption).at(1).text()).toEqual('Banana'); + expect(wrapper.find(ListboxOption).at(2).text()).toEqual('Cantaloupe'); +}); + +test('should set "as" element for listbox', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + expect( + wrapper.find(Listbox).children('span[role="listbox"]').exists() + ).toBeTruthy(); +}); + +test('should set "as" element for listbox option', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + expect( + wrapper.find(ListboxOption).children('span[role="option"]').exists() + ).toBeTruthy(); +}); + +test('should render listbox with grouped options', () => { + const wrapper = mount( + + + Apple + Banana + Cantaloupe + + + Artichoke + Broccoli + Carrots + + + ); + + const group1 = wrapper.find(ListboxGroup).at(0); + const group2 = wrapper.find(ListboxGroup).at(1); + + expect(wrapper.find(Listbox).exists()).toBeTruthy(); + expect(group1.exists()).toBeTruthy(); + expect(group2.exists()).toBeTruthy(); + expect(group1.find('ul').prop('role')).toEqual('group'); + expect(group2.find('ul').prop('role')).toEqual('group'); + expect(group1.find('ul').prop('aria-labelledby')).toBeTruthy(); + expect(group2.find('ul').prop('aria-labelledby')).toBeTruthy(); + expect(group1.find(ListboxOption).at(0).text()).toEqual('Apple'); + expect(group1.find(ListboxOption).at(1).text()).toEqual('Banana'); + expect(group1.find(ListboxOption).at(2).text()).toEqual('Cantaloupe'); + expect(group2.find(ListboxOption).at(0).text()).toEqual('Artichoke'); + expect(group2.find(ListboxOption).at(1).text()).toEqual('Broccoli'); + expect(group2.find(ListboxOption).at(2).text()).toEqual('Carrots'); +}); + +test('should use prop id for listbox group', () => { + const wrapper = mount( + + + Apple + Banana + Cantaloupe + + + ); + + expect(wrapper.find('ul[role="group"]').prop('aria-labelledby')).toEqual( + 'fruit' + ); + expect(wrapper.find('li[role="presentation"]').prop('id')).toEqual('fruit'); +}); + +test('should set group label props', () => { + const wrapper = mount( + + + Apple + Banana + Cantaloupe + + + ); + + expect(wrapper.find('li[role="presentation"]').prop('data-value')).toEqual( + 'true' + ); +}); + +test('should use prop id for listbox option', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + expect( + wrapper.find(ListboxOption).find('[role="option"]').at(0).prop('id') + ).toEqual('apple'); +}); + +test('should set accessible name of grouped options', () => { + const wrapper = mount( + + + Apple + Banana + Cantaloupe + + + ); + + const group = wrapper.find(ListboxGroup).childAt(0); + const groupLabel = group.find('li[role="presentation"]'); + + expect(groupLabel.text()).toEqual('Fruit'); + expect(groupLabel.prop('id')).not.toBeFalsy(); + expect(group.prop('aria-labelledby')).toEqual(groupLabel.prop('id')); +}); + +test('should set the first non-disabled option as active on focus', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + wrapper.find(Listbox).simulate('focus'); + wrapper.update(); + const listbox = wrapper.find('[role="listbox"]'); + const option = wrapper.find('[role="option"]').at(1); + + expect(option.hasClass('ListboxOption--active')).toBeTruthy(); + expect(listbox.prop('aria-activedescendant')).toEqual(option.prop('id')); +}); + +test('should set selected value with "value" prop when listbox option only has text label', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + wrapper.update(); + + const assertListItemIsSelected = listItemIsSelected(wrapper); + assertListItemIsSelected(1); +}); + +test('should set selected value with "defaultValue" prop when listbox option only has text label', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + wrapper.update(); + + const assertListItemIsSelected = listItemIsSelected(wrapper); + assertListItemIsSelected(1); +}); + +test('should set selected value with "value" prop when listbox option uses value prop', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + wrapper.update(); + + const assertListItemIsSelected = listItemIsSelected(wrapper); + assertListItemIsSelected(1); +}); + +test('should set selected value with "value" prop when listbox option uses defaultValue prop', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + wrapper.update(); + + const assertListItemIsSelected = listItemIsSelected(wrapper); + assertListItemIsSelected(1); +}); + +test('should not change selected value when listbox is controlled', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + wrapper.find('[role="option"]').at(2).simulate('click'); + wrapper.update(); + + const assertListItemIsSelected = listItemIsSelected(wrapper); + assertListItemIsSelected(1); +}); + +test('should change selected value when listbox is uncontrolled', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + + ); + + wrapper.find('[role="option"]').at(2).simulate('click'); + wrapper.update(); + + const assertListItemIsSelected = listItemIsSelected(wrapper); + assertListItemIsSelected(2); +}); + +test('should handle ↓ keypress', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + const listbox = wrapper.find(Listbox).childAt(0); + const simulateDownKeypress = simulateKeydown(listbox, 'ArrowDown'); + listbox.simulate('focus'); + + const assertListItemIsActive = listItemIsActive(wrapper); + + // skips disabled option + simulateDownKeypress(); + assertListItemIsActive(2); + simulateDownKeypress(); + assertListItemIsActive(3); +}); + +test('should handle ↑ keypress', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + const listbox = wrapper.find(Listbox).childAt(0); + const simulateUpKeypress = simulateKeydown(wrapper, 'ArrowUp'); + listbox.simulate('focus'); + + const assertListItemIsActive = listItemIsActive(wrapper); + + simulateUpKeypress(); + assertListItemIsActive(2); + simulateUpKeypress(); + // skips disabled option + assertListItemIsActive(0); +}); + +test('should keep active element bound to first/last when navigation is set to "bound"', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + const listbox = wrapper.find(Listbox).childAt(0); + const simulateUpKeypress = simulateKeydown(wrapper, 'ArrowUp'); + const simulateDownKeypress = simulateKeydown(wrapper, 'ArrowDown'); + listbox.simulate('focus'); + + const assertListItemIsActive = listItemIsActive(wrapper); + + simulateUpKeypress(); + assertListItemIsActive(0); + simulateDownKeypress(); + simulateDownKeypress(); + simulateDownKeypress(); + assertListItemIsActive(3); +}); + +test('should cycle to first/last acive element when navigation is set to "cycle"', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + const listbox = wrapper.find(Listbox).childAt(0); + const simulateUpKeypress = simulateKeydown(wrapper, 'ArrowUp'); + const simulateDownKeypress = simulateKeydown(wrapper, 'ArrowDown'); + listbox.simulate('focus'); + + const assertListItemIsActive = listItemIsActive(wrapper); + + simulateDownKeypress(); + simulateDownKeypress(); + simulateDownKeypress(); + // First item should be active from cycled key navigation + assertListItemIsActive(0); + simulateUpKeypress(); + // Last item should be active from cycled key navigation + assertListItemIsActive(3); +}); + +test('should handle keypress', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + const listbox = wrapper.find(Listbox).childAt(0); + const simulateHomeKeypress = simulateKeydown(wrapper, 'Home'); + listbox.simulate('focus'); + + const assertListItemIsActive = listItemIsActive(wrapper); + + simulateHomeKeypress(); + assertListItemIsActive(0); +}); + +test('should handle keypress', () => { + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + const listbox = wrapper.find(Listbox).childAt(0); + const simulateEndKeyPress = simulateKeydown(wrapper, 'End'); + listbox.simulate('focus'); + + const assertListItemIsActive = listItemIsActive(wrapper); + + simulateEndKeyPress(); + assertListItemIsActive(3); +}); + +test('should handle onActiveChange', () => { + const onActiveChange = spy(); + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + const simulateDownKeypress = simulateKeydown(wrapper, 'ArrowDown'); + + expect(onActiveChange.notCalled).toBeTruthy(); + simulateDownKeypress(); + expect(onActiveChange.lastCall.firstArg.value).toEqual('Apple'); + simulateDownKeypress(); + expect(onActiveChange.lastCall.firstArg.value).toEqual('Cantaloupe'); + simulateDownKeypress(); + expect(onActiveChange.lastCall.firstArg.value).toEqual('Dragon Fruit'); +}); + +test('should handle listbox selection with "enter" keypress', () => { + const onSelectionChange = spy(); + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + const listbox = wrapper.find(Listbox).childAt(0); + const simulateDownKeypress = simulateKeydown(wrapper, 'ArrowDown'); + const simulateEnterKeypress = simulateKeydown(wrapper, 'Enter'); + listbox.simulate('focus'); + + const assertListItemIsSelected = listItemIsSelected(wrapper); + + expect(onSelectionChange.notCalled).toBeTruthy(); + + simulateDownKeypress(); + simulateEnterKeypress(); + assertListItemIsSelected(2); + + expect(onSelectionChange.calledOnce).toBeTruthy(); + expect( + onSelectionChange.calledWithMatch({ value: 'Cantaloupe' }) + ).toBeTruthy(); +}); + +test('should handle listbox selection with "space" keypress', () => { + const onSelectionChange = spy(); + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + const listbox = wrapper.find(Listbox).childAt(0); + const simulateDownKeypress = simulateKeydown(wrapper, 'ArrowDown'); + const simulateSpaceKeypress = simulateKeydown(wrapper, ' '); + listbox.simulate('focus'); + + const assertListItemIsSelected = listItemIsSelected(wrapper); + + expect(onSelectionChange.notCalled).toBeTruthy(); + + simulateDownKeypress(); + simulateSpaceKeypress(); + assertListItemIsSelected(2); + + expect(onSelectionChange.calledOnce).toBeTruthy(); + expect( + onSelectionChange.calledWithMatch({ value: 'Cantaloupe' }) + ).toBeTruthy(); +}); + +test('should not prevent default event with non-navigational keypress', () => { + const event = { preventDefault: spy(), bla: 'bla' }; + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + const listbox = wrapper.find(Listbox).childAt(0); + const simulateTabKeypress = simulateKeydown(wrapper, 'Tab'); + const simulateEscKeypress = simulateKeydown(wrapper, 'Escape'); + const simulateDownKeypress = simulateKeydown(wrapper, 'ArrowDown'); + listbox.simulate('focus'); + + simulateTabKeypress(event); + expect(event.preventDefault.notCalled).toBeTruthy(); + simulateEscKeypress(event); + expect(event.preventDefault.notCalled).toBeTruthy(); + simulateDownKeypress(event); + expect(event.preventDefault.calledOnce).toBeTruthy(); +}); + +test('should handle listbox selection with "click" event', () => { + const onSelectionChange = spy(); + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + + expect(onSelectionChange.notCalled).toBeTruthy(); + + wrapper.find('[role="option"]').at(2).simulate('click'); + + const assertListItemIsSelected = listItemIsSelected(wrapper); + assertListItemIsSelected(2); + + expect(onSelectionChange.calledOnce).toBeTruthy(); + expect( + onSelectionChange.calledWithMatch({ value: 'Cantaloupe' }) + ).toBeTruthy(); +}); + +test('should not invoke selection for disabled elements with "click" event', () => { + const onSelectionChange = spy(); + const wrapper = mount( + + Apple + Banana + Cantaloupe + Dragon Fruit + + ); + wrapper.find('[role="option"]').at(1).simulate('click'); + + expect(onSelectionChange.notCalled).toBeTruthy(); +}); + +test('should retain selected value when options changes with defaultValue', () => { + const options = [ + + Apple + , + + Banana + , + + Cantaloupe + + ]; + const wrapper = mount({options}); + + const assertListItemIsSelected = listItemIsSelected(wrapper); + wrapper.find('[role="option"]').at(2).simulate('click'); + assertListItemIsSelected(2); + + wrapper.setProps({ + children: [ + ...options, + + Dragon Fruit + + ] + }); + wrapper.update(); + + assertListItemIsSelected(2); +}); diff --git a/packages/react/src/components/Listbox/Listbox.tsx b/packages/react/src/components/Listbox/Listbox.tsx new file mode 100644 index 000000000..77ed5fc84 --- /dev/null +++ b/packages/react/src/components/Listbox/Listbox.tsx @@ -0,0 +1,219 @@ +import React, { + forwardRef, + useCallback, + useState, + useLayoutEffect, + useEffect +} from 'react'; +import { ListboxProvider } from './ListboxContext'; +import type { ListboxOption } from './ListboxContext'; +import type { ListboxValue } from './ListboxOption'; +import useSharedRef from '../../utils/useSharedRef'; + +const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' ']; + +interface ListboxProps + extends Omit, 'onSelect'> { + as?: React.ElementType | string; + value?: ListboxValue; + navigation?: 'cycle' | 'bound'; + onSelectionChange?: ({ + value + }: { + target: T; + previousValue: ListboxValue; + value: ListboxValue; + }) => void; + onActiveChange?: (option: ListboxOption) => void; +} + +// id for listbox options should always be defined since it should +// be provide via the author, or auto-generated via the component +const getOptionId = (option: ListboxOption): string => + option.element.getAttribute('id') as string; + +const isDisabledOption = (option: ListboxOption): boolean => + option.element.getAttribute('aria-disabled') === 'true'; + +const optionMatchesValue = (option: ListboxOption, value: unknown): boolean => + typeof option.value !== null && + typeof option.value !== 'undefined' && + option.value === value; + +const Listbox = forwardRef( + ( + { + as: Component = 'ul', + children, + defaultValue, + value, + navigation = 'bound', + onKeyDown, + onFocus, + onSelectionChange, + onActiveChange, + ...props + }, + ref + ): JSX.Element => { + const [options, setOptions] = useState([]); + const [activeOption, setActiveOption] = useState( + null + ); + const [selectedOption, setSelectedOption] = useState( + null + ); + const listboxRef = useSharedRef(ref); + const isControlled = typeof value !== 'undefined'; + + useLayoutEffect(() => { + if (!isControlled && selectedOption) { + return; + } + + const listboxValue = isControlled ? value : defaultValue; + const matchingOption = options.find((option) => + optionMatchesValue(option, listboxValue) + ); + + setSelectedOption(matchingOption || null); + setActiveOption(matchingOption || null); + }, [isControlled, options, value]); + + useEffect(() => { + if (activeOption) { + onActiveChange?.(activeOption); + } + }, [activeOption]); + + const handleSelect = useCallback( + (option: ListboxOption) => { + setActiveOption(option); + // istanbul ignore else + if (!isControlled) { + setSelectedOption(option); + } + onSelectionChange?.({ + target: option.element, + value: option.value, + previousValue: selectedOption?.value + }); + }, + [isControlled, selectedOption] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + onKeyDown?.(event); + + if (!keys.includes(event.key)) { + return; + } + + event.preventDefault(); + const enabledOptions = options.filter( + (option) => !isDisabledOption(option) + ); + + // istanbul ignore next + if (!enabledOptions.length) { + return; + } + + const [up, down, home, end, enter, space] = keys; + const firstOption = enabledOptions[0]; + + if (!activeOption) { + setActiveOption(firstOption); + return; + } + + const lastOption = enabledOptions[enabledOptions.length - 1]; + const currentOption = activeOption; + const currentIndex = enabledOptions.findIndex( + ({ element }) => element === currentOption.element + ); + const allowCyclicalNavigation = navigation === 'cycle'; + + switch (event.key) { + case up: + const previousOption = + currentIndex === 0 && allowCyclicalNavigation + ? lastOption + : enabledOptions[Math.max(currentIndex - 1, 0)]; + setActiveOption(previousOption); + break; + case down: + const nextOption = + currentIndex === enabledOptions.length - 1 && + allowCyclicalNavigation + ? firstOption + : enabledOptions[ + Math.min(currentIndex + 1, enabledOptions.length - 1) + ]; + setActiveOption(nextOption); + break; + case home: + setActiveOption(firstOption); + break; + case end: + setActiveOption(lastOption); + break; + case enter: + case space: + activeOption && handleSelect(activeOption); + break; + } + }, + [options, activeOption, navigation] + ); + + const handleFocus = useCallback( + (event: React.FocusEvent) => { + if (!activeOption && !selectedOption) { + const firstOption = options.find( + (option) => !isDisabledOption(option) + ); + // istanbul ignore else + if (firstOption) { + setActiveOption(firstOption); + } + // istanbul ignore else + } else if (event.target === listboxRef.current) { + setActiveOption(selectedOption); + } + + onFocus?.(event); + }, + [options, activeOption, selectedOption] + ); + + return ( + + + {children} + + + ); + } +); + +Listbox.displayName = 'Listbox'; + +export default Listbox; diff --git a/packages/react/src/components/Listbox/ListboxContext.tsx b/packages/react/src/components/Listbox/ListboxContext.tsx new file mode 100644 index 000000000..c8dba84b3 --- /dev/null +++ b/packages/react/src/components/Listbox/ListboxContext.tsx @@ -0,0 +1,60 @@ +import React, { createContext, useContext, useMemo } from 'react'; + +type UnknownElement = T extends Element ? T : HTMLElement; +type UnknownValue = T extends string ? T : number; +type ListboxOption = { + element: UnknownElement; + value?: UnknownValue; +}; + +type ListboxContext = { + options: T[]; + active: T | null; + selected: T | null; + setOptions: React.Dispatch>; + onSelect: (option: T) => void; +}; + +type ListboxProvider = { + children: React.ReactNode; +} & ListboxContext; + +/* istanbul ignore next */ +const ListboxContext = createContext({ + options: [], + active: null, + selected: null, + setOptions: () => null, + onSelect: () => null +}); + +function ListboxProvider({ + options, + active, + selected, + setOptions, + onSelect, + children +}: ListboxProvider): JSX.Element { + const { Provider } = ListboxContext as unknown as React.Context< + ListboxContext + >; + const value: ListboxContext = useMemo( + () => ({ + options, + active, + selected, + setOptions, + onSelect + }), + [options, active, selected, setOptions] + ); + + return {children}; +} + +function useListboxContext(): ListboxContext { + return useContext(ListboxContext); +} + +export { ListboxProvider, useListboxContext, ListboxOption }; diff --git a/packages/react/src/components/Listbox/ListboxGroup.tsx b/packages/react/src/components/Listbox/ListboxGroup.tsx new file mode 100644 index 000000000..7cb78c8de --- /dev/null +++ b/packages/react/src/components/Listbox/ListboxGroup.tsx @@ -0,0 +1,37 @@ +import { ContentNode } from '../../types'; +import React, { forwardRef } from 'react'; +import { useId } from 'react-id-generator'; + +interface ListboxGroupProps extends React.HTMLAttributes { + as?: React.ElementType | string; + groupLabelProps?: React.HTMLAttributes; + label: ContentNode; +} + +const ListboxGroup = forwardRef( + ( + { + as: Component = 'ul', + children, + id: propId, + label, + groupLabelProps, + ...props + }, + ref + ): JSX.Element => { + const [id] = propId ? [propId] : useId(1, 'listbox-group-label'); + return ( + + + {children} + + ); + } +); + +ListboxGroup.displayName = 'ListboxGroup'; + +export default ListboxGroup; diff --git a/packages/react/src/components/Listbox/ListboxOption.tsx b/packages/react/src/components/Listbox/ListboxOption.tsx new file mode 100644 index 000000000..1591cd3e7 --- /dev/null +++ b/packages/react/src/components/Listbox/ListboxOption.tsx @@ -0,0 +1,121 @@ +import React, { forwardRef, useEffect, useCallback } from 'react'; +import classnames from 'classnames'; +import { useId } from 'react-id-generator'; +import { useListboxContext } from './ListboxContext'; +import useSharedRef from '../../utils/useSharedRef'; + +export type ListboxValue = Readonly; + +interface ListboxOptionsProps extends React.HTMLAttributes { + as?: React.ElementType | string; + value?: ListboxValue; + disabled?: boolean; + activeClass?: string; +} + +function isElementPreceding(a: Element, b: Element) { + return !!(b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_PRECEDING); +} + +const ListboxOption = forwardRef( + ( + { + id: propId, + className, + as: Component = 'li', + children, + value, + disabled, + activeClass = 'ListboxOption--active', + onClick, + ...props + }, + ref + ): JSX.Element => { + 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 isSelected = + selected !== null && selected.element === listboxOptionRef.current; + const optionValue = + typeof value !== 'undefined' + ? value + : listboxOptionRef.current?.innerText; + + useEffect(() => { + const element = listboxOptionRef.current; + + setOptions((options) => { + const option = { element, value: optionValue }; + // istanbul ignore next + if (!element) return options; + + // Elements are frequently appended, so check to see if the newly rendered + // element follows the last element first before any other checks + if ( + !options.length || + isElementPreceding( + options[options.length - 1].element, + option.element + ) + ) { + return [...options, option]; + } + + for (const opt of options) { + if (isElementPreceding(element, opt.element)) { + const index = options.indexOf(opt); + return [ + ...options.slice(0, index), + option, + ...options.slice(index) + ]; + } + } + + // istanbul ignore next + // this should never happen, but just in case fall back to options + return options; + }); + + return () => { + setOptions((opts) => opts.filter((opt) => opt.element !== element)); + }; + }, [optionValue]); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + if (disabled) { + return; + } + + onSelect({ element: listboxOptionRef.current, value: optionValue }); + onClick?.(event); + }, + [optionValue] + ); + + return ( + + {children} + + ); + } +); + +ListboxOption.displayName = 'ListboxOption'; + +export default ListboxOption; diff --git a/packages/react/src/components/Listbox/index.ts b/packages/react/src/components/Listbox/index.ts new file mode 100644 index 000000000..7f29d37cc --- /dev/null +++ b/packages/react/src/components/Listbox/index.ts @@ -0,0 +1,4 @@ +export { default } from './Listbox'; +export { default as ListboxOption } from './ListboxOption'; +export { default as ListboxGroup } from './ListboxGroup'; +export { ListboxProvider, useListboxContext } from './ListboxContext'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6c87ba59a..03ad8e762 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -120,6 +120,11 @@ export { ColumnList } from './components/TwoColumnPanel'; export { default as Notice } from './components/Notice'; +export { + default as Listbox, + ListboxOption, + ListboxGroup +} from './components/Listbox'; /** * Helpers / Utils diff --git a/packages/styles/forms.css b/packages/styles/forms.css index a3e3a1cc2..9aa8fef25 100644 --- a/packages/styles/forms.css +++ b/packages/styles/forms.css @@ -25,6 +25,7 @@ --field-icon-focus-color: var(--focus-light); --field-error-text-color: var(--error); --field-error-border-color: var(--error); + --field-listbox-selected-background-color: var(--accent-secondary-active); --input-min-width: 250px; } @@ -50,6 +51,7 @@ --field-icon-focus-color: var(--focus-dark); --field-error-text-color: var(--error); --field-error-border-color: var(--error); + --field-listbox-selected-background-color: var(--accent-dark); } input, @@ -58,7 +60,6 @@ textarea, [role='menuitemcheckbox'], [role='menuitemradio'], [role='textbox'], -[aria-haspopup='listbox'], [role='listbox'], [role='spinbutton'] { width: 100%; @@ -75,7 +76,6 @@ textarea, [role='menuitemcheckbox'], [role='menuitemradio'], [role='textbox'], -[aria-haspopup='listbox'], [role='listbox'], [role='spinbutton'] { border: 1px solid var(--field-border-color); @@ -87,7 +87,6 @@ textarea:focus, [role='menuitemcheckbox']:focus, [role='menuitemradio']:focus, [role='textbox']:focus, -[aria-haspopup='listbox']:focus, [role='listbox']:focus, [role='spinbutton']:focus { outline: 0; @@ -103,7 +102,6 @@ textarea:hover, [role='menuitemcheckbox']:hover, [role='menuitemradio']:hover, [role='textbox']:hover, -[aria-haspopup='listbox']:hover, [role='listbox']:hover, [role='spinbutton']:hover { border: 1px solid var(--field-border-color-hover); @@ -115,7 +113,6 @@ textarea:focus:hover, [role='menuitemcheckbox']:focus:hover, [role='menuitemradio']:focus:hover, [role='textbox']:focus:hover, -[aria-haspopup='listbox']:focus:hover, [role='listbox']:focus:hover, [role='spinbutton']:focus:hover { border: 1px solid var(--field-border-color-focus-hover); @@ -127,7 +124,6 @@ textarea.Field--has-error, [role='menuitemcheckbox'].Field--has-error, [role='menuitemradio'].Field--has-error, [role='textbox'].Field--has-error, -[aria-haspopup='listbox'].Field--has-error, [role='listbox'].Field--has-error, [role='spinbutton'].Field--has-error { border: 1px solid var(--field-border-color-error); @@ -137,7 +133,9 @@ textarea.Field--has-error, color: var(--field-border-color-error); } -.Icon--checkbox-unchecked.Checkbox__overlay:active.Field--has-error:not(.Checkbox__overlay--disabled) { +.Icon--checkbox-unchecked.Checkbox__overlay:active.Field--has-error:not( + .Checkbox__overlay--disabled + ) { background-color: var(--field-icon-error-active-color); } @@ -147,7 +145,6 @@ textarea.Field--has-error:hover, [role='menuitemcheckbox'].Field--has-error:hover, [role='menuitemradio'].Field--has-error:hover, [role='textbox'].Field--has-error:hover, -[aria-haspopup='listbox'].Field--has-error:hover, [role='listbox'].Field--has-error:hover, [role='spinbutton'].Field--has-error:hover { border-color: var(--field-border-color-error-hover); @@ -159,7 +156,6 @@ textarea.Field--has-error:focus, [role='menuitemcheckbox'].Field--has-error:focus, [role='menuitemradio'].Field--has-error:focus, [role='textbox'].Field--has-error:focus, -[aria-haspopup='listbox'].Field--has-error:focus, [role='listbox'].Field--has-error:focus, [role='spinbutton'].Field--has-error:focus { border: 1px solid var(--field-border-color-error); @@ -174,12 +170,26 @@ textarea.Field--has-error:focus:hover, [role='menuitemcheckbox'].Field--has-error:focus:hover, [role='menuitemradio'].Field--has-error:focus:hover, [role='textbox'].Field--has-error:focus:hover, -[aria-haspopup='listbox'].Field--has-error:focus:hover, [role='listbox'].Field--has-error:focus:hover, [role='spinbutton'].Field--has-error:focus:hover { border-color: var(--field-border-color-error-hover); } +[role='listbox'] > li, +[role='listbox'] > [role='group'] > li { + list-style-type: none; +} + +[role='listbox']:focus-within > li.ListboxOption--active, +[role='listbox']:focus-within > [role='group'] > li.ListboxOption--active { + box-shadow: 0 0 0 2px var(--field-icon-focus-color); +} + +[role='listbox'] + li[role='option']:is([aria-selected='true'], [aria-checked='true']) { + background-color: var(--field-listbox-selected-background-color); +} + .Error { color: var(--field-error-text-color); text-align: left; From e1511621cdf3789b809a21869f501c68ddd60065 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 28 Aug 2023 09:05:21 -0500 Subject: [PATCH 02/28] fix(styles): fix color contrast issue for required text on TextField with error (#1182) --- docs/pages/components/TextField.mdx | 1 + packages/styles/forms.css | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/pages/components/TextField.mdx b/docs/pages/components/TextField.mdx index 8fdd37030..d9243ef4d 100644 --- a/docs/pages/components/TextField.mdx +++ b/docs/pages/components/TextField.mdx @@ -53,6 +53,7 @@ import { TextField } from '@deque/cauldron-react' ``` diff --git a/packages/styles/forms.css b/packages/styles/forms.css index 9aa8fef25..713bd972e 100644 --- a/packages/styles/forms.css +++ b/packages/styles/forms.css @@ -41,6 +41,7 @@ --field-background-color-disabled: #5d676f; --field-required-text-color: var(--white); --field-label-text-color: var(--white); + --field-label-error-text-color: var(--error); --field-label-description-text-color: var(--accent-light); --field-icon-inactive-color: var(--white); --field-icon-active-color: rgba(212, 221, 224, 0.25); From b09c14c1021068985abc35f8a5d946a040ea700c Mon Sep 17 00:00:00 2001 From: orest-s <139442720+orest-s@users.noreply.github.com> Date: Wed, 6 Sep 2023 21:10:08 +0300 Subject: [PATCH 03/28] feat(Popover): add new component (#1171) --- docs/pages/components/Popover.mdx | 176 +++++++++ .../__tests__/src/components/Popover/index.js | 349 ++++++++++++++++++ .../react/src/components/Popover/index.tsx | 287 ++++++++++++++ packages/react/src/index.ts | 3 + packages/styles/index.css | 1 + packages/styles/popover.css | 116 ++++++ 6 files changed, 932 insertions(+) create mode 100644 docs/pages/components/Popover.mdx create mode 100644 packages/react/__tests__/src/components/Popover/index.js create mode 100644 packages/react/src/components/Popover/index.tsx create mode 100644 packages/styles/popover.css diff --git a/docs/pages/components/Popover.mdx b/docs/pages/components/Popover.mdx new file mode 100644 index 000000000..c920255ff --- /dev/null +++ b/docs/pages/components/Popover.mdx @@ -0,0 +1,176 @@ +--- +title: Popover +description: A popover component is a user interface element designed to provide contextual information or actions in a compact and non-intrusive manner. +source: https://github.com/dequelabs/cauldron/tree/develop/packages/react/src/components/Popover/index.tsx +--- + +import { useRef, useState } from 'react' +import { Popover, Button } from '@deque/cauldron-react' + +```js +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. + +### Prompt Popover + +Prompt Popovers are designed to display a popup with two buttons, the first to apply some action, the second to cancel, +also popup contains information text to show some message to the user. +Canceling the action will only close the popup window. + +```jsx example +function PromptPopoverExample() { + const [show, setShow] = useState(false); + const onTogglePopover = () => setShow(!show); + const buttonRef = useRef(); + + return ( + <> + + + + ) +} +``` + +### Custom Popover + +For custom purposes you can use "custom" variant, which is default. + +```jsx example +function CustomPopoverExample() { + const [show, setShow] = useState(false); + const onTogglePopover = () => setShow(!show); + const buttonRef = useRef(); + + return ( + <> + + +
+

Popover Title

+

Popover content

+ + +

Popover content

+
+
+ + ) +} +``` + +## Props + + \ No newline at end of file diff --git a/packages/react/__tests__/src/components/Popover/index.js b/packages/react/__tests__/src/components/Popover/index.js new file mode 100644 index 000000000..746f7a01b --- /dev/null +++ b/packages/react/__tests__/src/components/Popover/index.js @@ -0,0 +1,349 @@ +import React, { useRef } from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import Popover from 'src/components/Popover'; +import axe from '../../../axe'; +import * as AriaIsolate from '../../../../src/utils/aria-isolate'; + +let wrapperNode; +let mountNode; + +beforeEach(() => { + wrapperNode = document.createElement('div'); + wrapperNode.innerHTML = ` + +
+ `; + document.body.appendChild(wrapperNode); + mountNode = document.getElementById('mount'); +}); + +afterEach(() => { + document.body.innerHTML = ''; + wrapperNode = null; + mountNode = null; +}); + +const update = async wrapper => { + await act(async () => { + await new Promise(resolve => setImmediate(resolve)); + wrapper.update(); + }); +}; + +// eslint-disable-next-line react/prop-types +const Wrapper = ({ buttonProps = {}, tooltipProps = {} }) => { + const ref = useRef(); + const onClose = jest.fn(); + return ( + + + + Hello Word + +
+
+ ); +}; + +const WrapperPopoverWithElements = () => { + const ref = useRef(); + const onClose = jest.fn(); + return ( + + + + + + + +
+
+ ); +}; + +// eslint-disable-next-line react/prop-types +const WrapperPrompt = ({ buttonProps = {}, tooltipProps = {} }) => { + const ref = useRef(); + const onClose = jest.fn(); + return ( + + + + + ); +}; + +test('renders without blowing up', async () => { + const wrapper = mount(); + await update(wrapper); + expect(wrapper.find('.Popover').exists()).toBeTruthy(); +}); + +test('should auto-generate id', async () => { + const wrapper = mount(); + await update(wrapper); + const id = wrapper.find('.Popover').props().id; + expect(id).toBeTruthy(); + expect(id).toEqual( + wrapper + .find('button') + .getDOMNode() + .getAttribute('aria-controls') + ); +}); + +test('should attach attribute aria-expanded correctly based on shown state', async () => { + const wrapper = mount(); + await update(wrapper); + expect( + wrapper + .find('button') + .getDOMNode() + .getAttribute('aria-expanded') + ).toBeTruthy(); + + const shownStateFalsy = mount(); + + expect( + shownStateFalsy + .find('button') + .getDOMNode() + .getAttribute('aria-expanded') + ).toBeFalsy(); +}); + +test('should support adding className to tooltip', async () => { + const wrapper = mount(); + await update(wrapper); + expect(wrapper.find('.Popover').hasClass('Popover')).toBeTruthy(); + expect(wrapper.find('.Popover').hasClass('foo')).toBeTruthy(); +}); + +test('should not overwrite user provided id and aria-describedby', async () => { + const buttonProps = { [`aria-describedby`]: 'foo popoverid' }; + const tooltipProps = { id: 'popoverid' }; + const props = { buttonProps, tooltipProps }; + const wrapper = mount(); + await update(wrapper); + expect(wrapper.find('.Popover').props().id).toEqual('popoverid'); + expect( + wrapper + .find('button') + .getDOMNode() + .getAttribute('aria-describedby') + ).toEqual('foo popoverid'); +}); + +test('should call onClose on escape keypress', async () => { + const onClose = jest.fn().mockImplementation(() => {}); + const wrapper = mount(, { + attachTo: mountNode + }); + await update(wrapper); + expect(wrapper.find('.Popover').exists).toBeTruthy(); + await act(async () => { + document.body.dispatchEvent( + new KeyboardEvent('keyup', { + bubbles: true, + key: 'Escape' + }) + ); + }); + + await update(wrapper); + + expect(onClose).toBeCalled(); +}); + +test('should call onClose on clicking outside', async () => { + const onClose = jest.fn().mockImplementation(() => {}); + mount(, { attachTo: mountNode }); + + await act(async () => { + wrapperNode + .querySelector('[data-test]') + .dispatchEvent(new Event('click', { bubbles: true })); + }); + + expect(onClose).toBeCalled(); +}); + +test('first element inside the popover container should have focus', async () => { + const wrapper = mount(, { + attachTo: mountNode + }); + + await update(wrapper); + + const firstInteractableElement = wrapper.find('[data-test="foo1"]'); + + const focusedElement = document.activeElement; + + expect(firstInteractableElement.instance()).toBe(focusedElement); +}); + +test('should render two buttons (Apply/Close) for prompt variant', async () => { + const wrapper = mount(, { + attachTo: mountNode + }); + + await update(wrapper); + + const closeBtn = wrapper.find('.Popover__close'); + const applyBtn = wrapper.find('.Popover__apply'); + + expect(closeBtn.exists()).toBeTruthy(); + expect(applyBtn.exists()).toBeTruthy(); +}); + +test('onClose should be called, when close button in prompt popover is clicked', async () => { + const handleClose = jest.fn(); + + const wrapper = mount( + , + { attachTo: mountNode } + ); + + await act(async () => { + wrapper.find('button.Popover__close').simulate('click'); + }); + + expect(handleClose).toHaveBeenCalled(); +}); + +test('onApply should be called, when apply button in prompt popover is clicked', async () => { + const applyFunc = jest.fn(); + + const wrapper = mount( + , + { attachTo: mountNode } + ); + + await act(async () => { + wrapper.find('button.Popover__apply').simulate('click'); + }); + + expect(applyFunc).toHaveBeenCalled(); +}); + +test('text for apply/close buttons are rendered correct', async () => { + const closeButtonText = 'Specific text to close popover'; + const applyButtonText = 'Specific text to apply popover'; + + const wrapper = mount( + , + { attachTo: mountNode } + ); + + await update(wrapper); + + const closeBtn = wrapper.find('button.Popover__close'); + + const applyBtn = wrapper.find('button.Popover__apply'); + + expect(closeBtn.text()).toBe(closeButtonText); + expect(applyBtn.text()).toBe(applyButtonText); +}); + +test('variant="prompt" should return no axe violations', async () => { + const wrapper = mount(); + await update(wrapper); + expect(await axe(wrapper.html())).toHaveNoViolations(); +}); + +test('should return no axe violations', async () => { + const wrapper = mount(); + await update(wrapper); + expect(await axe(wrapper.html())).toHaveNoViolations(); +}); + +test('should use parent-provided ref', () => { + const parentRef = React.createRef(); + const ref = React.createRef(); + const onClose = jest.fn(); + const wrapper = mount( + + Hello Word + + ); + + const componentNode = wrapper.getDOMNode(); + expect(parentRef.current).toBe(componentNode); +}); + +test('activates aria isolate on show', async () => { + const parentRef = React.createRef(); + const ref = React.createRef(); + const onClose = jest.fn(); + + const activateFn = jest.fn(); + const deactivateFn = jest.fn(); + + jest.spyOn(AriaIsolate, 'default').mockImplementation(() => ({ + activate: activateFn, + deactivate: deactivateFn + })); + + mount( + + Hello Word + + ); + + expect(activateFn).toBeCalled(); +}); + +test('deactivates aria isolate on hide', async () => { + const parentRef = React.createRef(); + const ref = React.createRef(); + const onClose = jest.fn(); + + const activateFn = jest.fn(); + const deactivateFn = jest.fn(); + + jest.spyOn(AriaIsolate, 'default').mockImplementation(() => ({ + activate: activateFn, + deactivate: deactivateFn + })); + + const wrapper = mount( + + Hello Word + + ); + + expect(activateFn).toBeCalled(); + + wrapper.setProps({ show: false }); + + expect(deactivateFn).toBeCalled(); +}); + +test('aria-labelledby is set correctly for prompt variant', async () => { + const wrapper = mount(); + await update(wrapper); + + const id = wrapper.find('.Popover').props().id; + + expect(`${id}-label`).toEqual( + wrapper + .find('.Popover') + .getDOMNode() + .getAttribute('aria-labelledby') + ); +}); diff --git a/packages/react/src/components/Popover/index.tsx b/packages/react/src/components/Popover/index.tsx new file mode 100644 index 000000000..6fdcacffd --- /dev/null +++ b/packages/react/src/components/Popover/index.tsx @@ -0,0 +1,287 @@ +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 ClickOutsideListener from '../ClickOutsideListener'; +import Button from '../Button'; +import FocusTrap from 'focus-trap-react'; +import focusableSelector from '../../utils/focusable-selector'; +import AriaIsolate from '../../utils/aria-isolate'; +import useSharedRef from '../../utils/useSharedRef'; + +export type PopoverVariant = 'prompt' | 'custom'; + +type BaseProps = React.HTMLAttributes & { + target: React.RefObject | HTMLElement; + variant?: PopoverVariant; + show: boolean; + onClose: () => void; + placement?: Placement; + portal?: React.RefObject | HTMLElement; +}; + +type CustomProps = BaseProps & { + variant: 'custom'; + applyButtonText?: string; + onApply?: () => void; + closeButtonText?: string; + infoText?: ReactNode; + children: ReactNode; +} & Cauldron.LabelProps; + +type PromptProps = BaseProps & { + variant: 'prompt'; + applyButtonText?: string; + onApply: () => void; + closeButtonText?: string; + infoText: ReactNode; + children?: ReactNode; +}; + +export type PopoverProps = PromptProps | CustomProps; + +const PromptPopoverContent = ({ + onClose, + applyButtonText = 'Apply', + onApply, + closeButtonText = 'Close', + infoText, + infoTextId +}: Pick< + PopoverProps, + 'onClose' | 'applyButtonText' | 'onApply' | 'closeButtonText' | 'infoText' +> & { infoTextId: string }) => { + return ( + <> + {infoText} + + + + ); +}; + +const Popover = forwardRef( + ( + { + id: propId, + placement: initialPlacement = 'auto', + children, + portal, + target, + variant = 'custom', + show = false, + onClose, + className, + applyButtonText, + closeButtonText, + infoText, + onApply, + ...props + }: PopoverProps, + ref: Ref + ): 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 additionalProps = + variant === 'prompt' ? { 'aria-labelledby': `${id}-label` } : {}; + + // Keep targetElement in sync with target prop + useEffect(() => { + const targetElement = + target && 'current' in target ? target.current : target; + setTargetElement(targetElement); + }, [target]); + + useEffect(() => { + return () => { + isolator?.deactivate(); + }; + }, []); + + useEffect(() => { + if (!isolator) return; + + if (show) { + isolator.activate(); + } else { + isolator.deactivate(); + } + + return () => { + isolator?.deactivate(); + }; + }, [show, isolator]); + + useEffect(() => { + if (popoverRef.current) attachIsolator(); + }, [popoverRef.current]); + + useEffect(() => { + if (show && popoverRef.current) { + // Find the first focusable element inside the container + const firstFocusableElement = popoverRef.current.querySelector( + focusableSelector + ); + + if (firstFocusableElement instanceof HTMLElement) { + firstFocusableElement.focus(); + } + } + + targetElement?.setAttribute('aria-expanded', Boolean(show).toString()); + }, [show, popoverRef.current]); + + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if ( + event.key === 'Escape' || + event.key === 'Esc' || + event.keyCode === 27 + ) { + handleClosePopover(); + } + }; + + if (show) { + document.body.addEventListener('keyup', handleEscape); + } else { + document.body.removeEventListener('keyup', handleEscape); + } + + return () => { + document.body.removeEventListener('keyup', handleEscape); + }; + }, [show]); + + useEffect(() => { + const attrText = targetElement?.getAttribute('aria-controls'); + const hasPopupAttr = targetElement?.getAttribute('aria-haspopup'); + + if (!attrText?.includes(id) && show) { + targetElement?.setAttribute('aria-controls', id); + } + + if (!hasPopupAttr) { + targetElement?.setAttribute('aria-haspopup', 'true'); + } + }, [targetElement, id, show]); + + const handleClickOutside = () => { + if (show) { + handleClosePopover(); + } + }; + + const attachIsolator = () => { + if (popoverRef?.current) { + setIsolator(new AriaIsolate(popoverRef?.current)); + } + }; + + const handleClosePopover = () => { + isolator?.deactivate(); + if (show) { + onClose(); + } + }; + + if (!show || !isBrowser()) return null; + + return createPortal( + + +