From e6353303b019f0258314864ff21e90a747b0fc66 Mon Sep 17 00:00:00 2001 From: Omar De Los Santos Date: Wed, 8 Jul 2026 08:20:49 -0700 Subject: [PATCH 1/3] Support modal mode in useComboBox --- .../react-aria/docs/combobox/useComboBox.mdx | 21 ++++++ .../react-aria/src/combobox/useComboBox.ts | 24 ++++++- .../stories/combobox/useComboBox.stories.tsx | 32 ++++++++- .../test/combobox/useComboBox.test.js | 68 +++++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) 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/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 +

+