diff --git a/packages/react-aria-components/docs/ComboBox.mdx b/packages/react-aria-components/docs/ComboBox.mdx index 0751df477b3..07fa6696fc1 100644 --- a/packages/react-aria-components/docs/ComboBox.mdx +++ b/packages/react-aria-components/docs/ComboBox.mdx @@ -1178,6 +1178,22 @@ The `description` slot can be used to associate additional help text with a Comb +## Modal + +While the popover is open, ComboBox hides content outside the input and popover from +assistive technologies so that screen readers can easily navigate to the portalled popover. +By default this uses the `aria-hidden` attribute. Set `isNonModal={false}` for a fully modal +experience, where outside content receives the [inert](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inert) +attribute instead, preventing pointer and keyboard interaction with the hidden content. + +```tsx + + Red Panda + Cat + Dog + +``` + ## Props ### ComboBox diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 14502016b6e..987f0b4af99 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -130,6 +130,12 @@ export interface ComboBoxProps formValue?: 'text' | 'key'; /** Whether the combo box allows the menu to be open when the collection is empty. */ allowsEmptyCollection?: boolean; + /** + * Whether the popover is non-modal, i.e. elements outside the popover may be interacted with by assistive technologies. + * + * @default true + */ + isNonModal?: boolean; } export const ComboBoxContext = diff --git a/packages/react-aria-components/stories/ComboBox.stories.tsx b/packages/react-aria-components/stories/ComboBox.stories.tsx index 0afe1168d49..24aebea6414 100644 --- a/packages/react-aria-components/stories/ComboBox.stories.tsx +++ b/packages/react-aria-components/stories/ComboBox.stories.tsx @@ -590,3 +590,61 @@ export const InModal: ComboBoxStory = () => ( ); + +export const ModalComboBox: ComboBoxStory = () => ( +
+

+ Content before — inert while the list is open +

+ + +
+ + +
+ + + Foo + Bar + Baz + + +
+

+ Content after — inert while the list is open +

+
+); + +export const NonModalComboBox: ComboBoxStory = () => ( +
+

+ Content before — hidden (but interactive) while the list is open +

+ + +
+ + +
+ + + Foo + Bar + Baz + + +
+

+ Content after — hidden (but interactive) while the list is open +

+
+); diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index dfa993ecef8..df402224a68 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -114,6 +114,48 @@ describe('ComboBox', () => { expect(combobox).toHaveAttribute('aria-label', 'test'); }); + // While the popover is open, content outside the input and popover is hidden. + // Note: jsdom does not support the `inert` property, so `isNonModal={false}` falls back + // to `aria-hidden` here. + describe('isNonModal', () => { + let renderWithOutside = props => + render( + <> + + Outside link + + + + ); + + it('hides outside elements with aria-hidden by default when open', async () => { + let {getByRole, getByTestId} = renderWithOutside(); + let outside = getByTestId('outside'); + let input = getByRole('combobox'); + + await user.click(getByRole('button')); + + expect(getByRole('listbox')).toBeInTheDocument(); + expect(outside).toHaveAttribute('aria-hidden', 'true'); + expect(input).not.toHaveAttribute('aria-hidden'); + + await user.keyboard('{Escape}'); + expect(outside).not.toHaveAttribute('aria-hidden'); + }); + + it('hides outside elements when isNonModal is false', async () => { + let {getByRole, getByTestId} = renderWithOutside({isNonModal: false}); + let outside = getByTestId('outside'); + let input = getByRole('combobox'); + + await user.click(getByRole('button')); + + expect(getByRole('listbox')).toBeInTheDocument(); + expect(outside).toHaveAttribute('aria-hidden', 'true'); + expect(input).not.toHaveAttribute('aria-hidden'); + }); + }); + it('should support custom render function', () => { let {getByRole} = render(
} /> diff --git a/packages/react-aria/docs/combobox/useComboBox.mdx b/packages/react-aria/docs/combobox/useComboBox.mdx index 788042ec84e..6395321a382 100644 --- a/packages/react-aria/docs/combobox/useComboBox.mdx +++ b/packages/react-aria/docs/combobox/useComboBox.mdx @@ -674,6 +674,27 @@ function AsyncLoadingExample() { By default, interacting with an item in a ComboBox selects it and updates the input value. Alternatively, items may be links to another page or website. This can be achieved by passing the `href` prop to the `` component. Interacting with link items navigates to the provided URL and does not update the selection or input value. See the [links](../ListBox/useListBox.html#links) section in the `useListBox` docs for details on how to support this. +### Modal + +While the ComboBox popover is open, `useComboBox` always hides the content outside the input and popup +from assistive technologies. This preserves the behavior described in the +[building a ComboBox blog post](https://react-aria.adobe.com/blog/building-a-combobox#portals), where +the surrounding page is hidden so that screen reader users can easily navigate to the popover. + +By default (`isNonModal: true`), content is hidden using the +[aria-hidden](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden) +attribute. Set `isNonModal={false}` for a fully modal experience, where outside content receives the +[inert](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inert) attribute instead. The attribute +prevents pointer and keyboard interaction with the hidden content. + +```tsx + + Red Panda + Cat + Dog + +``` + ## Internationalization `useComboBox` handles some aspects of internationalization automatically. diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index a1e3444817c..2de53e2cb1c 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -86,6 +86,18 @@ export interface AriaComboBoxOptions exte * virtualized scrolling. */ layoutDelegate?: LayoutDelegate; + /** + * Whether elements outside the combobox popover are hidden from assistive + * technologies. + * + * While the popover is open, content outside the input and popover is always + * hidden. By default this applies `aria-hidden` to those elemenst. Set to + * `false` to use `inert`, which prevents pointer and keyboard interaction with + * the hidden content. + * + * @default true + */ + isNonModal?: boolean; } export interface ComboBoxAria extends ValidationResult { @@ -127,7 +139,8 @@ export function useComboBox( // completionMode = 'suggest', shouldFocusWrap, isReadOnly, - isDisabled + isDisabled, + isNonModal = true } = props; let backupBtnRef = useRef(null); buttonRef = buttonRef ?? backupBtnRef; @@ -449,10 +462,15 @@ export function useComboBox( useEffect(() => { if (state.isOpen) { return ariaHideOutside( - [inputRef.current, popoverRef.current].filter(element => element != null) + [inputRef.current, popoverRef.current].filter(element => element != null), + // Outside elements should always have `aria-hidden` while the popover is + // open to allow screen readers to immediately navigate to portalled popover. + // If `isNonModal` is false, we instead use `inert` to prevent pointer and + // keyboard interaction with the hidden content. + { shouldUseInert: !isNonModal } ); } - }, [state.isOpen, inputRef, popoverRef]); + }, [state.isOpen, isNonModal, inputRef, popoverRef]); useUpdateEffect(() => { // Re-show focus ring when there is no virtually focused item. diff --git a/packages/react-aria/stories/combobox/example.tsx b/packages/react-aria/stories/combobox/example.tsx index 7a786b9ee78..ae540eab874 100644 --- a/packages/react-aria/stories/combobox/example.tsx +++ b/packages/react-aria/stories/combobox/example.tsx @@ -22,7 +22,7 @@ import {useListBox} from '../../src/listbox/useListBox'; import {useOption} from '../../src/listbox/useOption'; import {useOverlay} from '../../src/overlays/useOverlay'; -export function ComboBox(props: AriaComboBoxProps): JSX.Element { +export function ComboBox(props: AriaComboBoxProps & {isNonModal?: boolean}): JSX.Element { // Setup filter function and state. let {contains} = useFilter({sensitivity: 'base'}); let state = useComboBoxState({...props, defaultFilter: contains}); diff --git a/packages/react-aria/stories/combobox/useComboBox.stories.tsx b/packages/react-aria/stories/combobox/useComboBox.stories.tsx index 3b622ea24f1..65a1f87fb5e 100644 --- a/packages/react-aria/stories/combobox/useComboBox.stories.tsx +++ b/packages/react-aria/stories/combobox/useComboBox.stories.tsx @@ -27,7 +27,7 @@ for (let i = 0; i < 50; i++) { lotsOfItems.push({name: 'Item ' + i}); } -const Template = (args: AriaComboBoxProps): JSX.Element => ( +const Template = (args: AriaComboBoxProps & {isNonModal?: boolean}): JSX.Element => ( {(item: any) => {item.name}} @@ -47,3 +47,33 @@ export const FocusWrapping: TemplateStory = { render: Template, args: {shouldFocusWrap: true} }; + +export const Modal: TemplateStory = { + render: (args: AriaComboBoxProps & {isNonModal?: boolean}): JSX.Element => ( +
+

+ Content before — inert while the list is open +

+