Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/react-aria-components/docs/ComboBox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,22 @@ The `description` slot can be used to associate additional help text with a Comb

</details>

## 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
<ComboBox isNonModal={false}>
<Item key="red panda">Red Panda</Item>
<Item key="cat">Cat</Item>
<Item key="dog">Dog</Item>
</ComboBox>
```

## Props

### ComboBox
Expand Down
6 changes: 6 additions & 0 deletions packages/react-aria-components/src/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ export interface ComboBoxProps<T, M extends SelectionMode = 'single'>
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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the name isModal is more clear, but I used isNonModal to align with Popover's isNonModal prop.

}

export const ComboBoxContext =
Expand Down
58 changes: 58 additions & 0 deletions packages/react-aria-components/stories/ComboBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -590,3 +590,61 @@ export const InModal: ComboBoxStory = () => (
</ModalOverlay>
</DialogTrigger>
);

export const ModalComboBox: ComboBoxStory = () => (
<div>
<p>
<a href="//example.com">Content before</a> — inert while the list is open
</p>
<ComboBox name="modal-combo-box" isNonModal={false}>
<Label style={{display: 'block'}}>Test</Label>
<div style={{display: 'flex'}}>
<Input />
<Button>
<span aria-hidden="true" style={{padding: '0 2px'}}>
</span>
</Button>
</div>
<Popover>
<ListBox className={styles.menu} style={{width: 'var(--trigger-width)'}}>
<MyListBoxItem>Foo</MyListBoxItem>
<MyListBoxItem>Bar</MyListBoxItem>
<MyListBoxItem>Baz</MyListBoxItem>
</ListBox>
</Popover>
</ComboBox>
<p>
<a href="//example.com">Content after</a> — inert while the list is open
</p>
</div>
);

export const NonModalComboBox: ComboBoxStory = () => (
<div>
<p>
<a href="//example.com">Content before</a> — hidden (but interactive) while the list is open
</p>
<ComboBox name="non-modal-combo-box" isNonModal>
<Label style={{display: 'block'}}>Test</Label>
<div style={{display: 'flex'}}>
<Input />
<Button>
<span aria-hidden="true" style={{padding: '0 2px'}}>
</span>
</Button>
</div>
<Popover>
<ListBox className={styles.menu} style={{width: 'var(--trigger-width)'}}>
<MyListBoxItem>Foo</MyListBoxItem>
<MyListBoxItem>Bar</MyListBoxItem>
<MyListBoxItem>Baz</MyListBoxItem>
</ListBox>
</Popover>
</ComboBox>
<p>
<a href="//example.com">Content after</a> — hidden (but interactive) while the list is open
</p>
</div>
);
42 changes: 42 additions & 0 deletions packages/react-aria-components/test/ComboBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<a href="//example.com" data-testid="outside">
Outside link
</a>
<TestComboBox {...props} />
</>
);

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(
<TestComboBox render={props => <div {...props} data-custom="true" />} />
Expand Down
21 changes: 21 additions & 0 deletions packages/react-aria/docs/combobox/useComboBox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Item>` 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
<ComboBox label="Favorite Animal" isNonModal={false}>
<Item key="red panda">Red Panda</Item>
<Item key="cat">Cat</Item>
<Item key="dog">Dog</Item>
</ComboBox>
```

## Internationalization

`useComboBox` handles some aspects of internationalization automatically.
Expand Down
24 changes: 21 additions & 3 deletions packages/react-aria/src/combobox/useComboBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ export interface AriaComboBoxOptions<T, M extends SelectionMode = 'single'> 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<T> extends ValidationResult {
Expand Down Expand Up @@ -127,7 +139,8 @@ export function useComboBox<T, M extends SelectionMode = 'single'>(
// completionMode = 'suggest',
shouldFocusWrap,
isReadOnly,
isDisabled
isDisabled,
isNonModal = true

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true preserves existing behavior.

} = props;
let backupBtnRef = useRef(null);
buttonRef = buttonRef ?? backupBtnRef;
Expand Down Expand Up @@ -449,10 +462,15 @@ export function useComboBox<T, M extends SelectionMode = 'single'>(
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.
Expand Down
2 changes: 1 addition & 1 deletion packages/react-aria/stories/combobox/example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>): JSX.Element {
export function ComboBox(props: AriaComboBoxProps<any> & {isNonModal?: boolean}): JSX.Element {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't expose the modal behavior on Spectrum's ComboBox, so we need this type intersection here.

// Setup filter function and state.
let {contains} = useFilter({sensitivity: 'base'});
let state = useComboBoxState({...props, defaultFilter: contains});
Expand Down
32 changes: 31 additions & 1 deletion packages/react-aria/stories/combobox/useComboBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ for (let i = 0; i < 50; i++) {
lotsOfItems.push({name: 'Item ' + i});
}

const Template = (args: AriaComboBoxProps<any>): JSX.Element => (
const Template = (args: AriaComboBoxProps<any> & {isNonModal?: boolean}): JSX.Element => (
<ComboBox {...args} label="Example" defaultItems={lotsOfItems}>
{(item: any) => <Item key={item.name}>{item.name}</Item>}
</ComboBox>
Expand All @@ -47,3 +47,33 @@ export const FocusWrapping: TemplateStory = {
render: Template,
args: {shouldFocusWrap: true}
};

export const Modal: TemplateStory = {
render: (args: AriaComboBoxProps<any> & {isNonModal?: boolean}): JSX.Element => (
<div>
<p>
<a href="//example.com">Content before</a> — inert while the list is open
</p>
<Template {...args} />
<p>
<a href="//example.com">Content after</a> — inert while the list is open
</p>
</div>
),
args: {isNonModal: false}
};

export const NonModal: TemplateStory = {
render: (args: AriaComboBoxProps<any> & {isNonModal?: boolean}): JSX.Element => (
<div>
<p>
<a href="//example.com">Content before</a> — hidden (but interactive) while the list is open
</p>
<Template {...args} />
<p>
<a href="//example.com">Content after</a> — hidden (but interactive) while the list is open
</p>
</div>
),
args: {isNonModal: true}
};
68 changes: 68 additions & 0 deletions packages/react-aria/test/combobox/useComboBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,72 @@ describe('useComboBox', function () {

expect(onBlurMock).toHaveBeenCalledTimes(1);
});

// While the popover is open, content outside the input and popover is always hidden.
// Note: jsdom does not support the `inert` property, so `shouldUseInert` falls back to
// `aria-hidden` here.
describe('ariaHideOutside behavior', function () {
let inputEl, popoverEl, siblingEl;

beforeEach(() => {
inputEl = document.createElement('input');
popoverEl = document.createElement('div');
siblingEl = document.createElement('div');
siblingEl.textContent = 'outside content';
document.body.appendChild(inputEl);
document.body.appendChild(popoverEl);
document.body.appendChild(siblingEl);
});

afterEach(() => {
inputEl.remove();
popoverEl.remove();
siblingEl.remove();
});

let renderOpenComboBox = extraProps => {
let hookProps = {
...defaultProps,
...props,
...extraProps,
inputRef: {current: inputEl},
popoverRef: {current: popoverEl}
};
let {result: state} = renderHook(p => useComboBoxState(p), {initialProps: hookProps});
act(() => {
state.current.open();
});
return renderHook(p => useComboBox(p, state.current), {initialProps: hookProps});
};

it('hides outside elements by default, keeping the input and popover visible', function () {
renderOpenComboBox();

expect(siblingEl).toHaveAttribute('aria-hidden', 'true');
expect(inputEl).not.toHaveAttribute('aria-hidden');
expect(popoverEl).not.toHaveAttribute('aria-hidden');
});

it('hides outside elements when isNonModal is true', function () {
renderOpenComboBox({isNonModal: true});

expect(siblingEl).toHaveAttribute('aria-hidden', 'true');
expect(inputEl).not.toHaveAttribute('aria-hidden');
expect(popoverEl).not.toHaveAttribute('aria-hidden');
});

it('hides outside elements when isNonModal is false', function () {
let {unmount} = renderOpenComboBox({isNonModal: false});

expect(siblingEl).toHaveAttribute('aria-hidden', 'true');
expect(inputEl).not.toHaveAttribute('aria-hidden');
expect(popoverEl).not.toHaveAttribute('aria-hidden');

act(() => {
unmount();
});

expect(siblingEl).not.toHaveAttribute('aria-hidden');
});
});
});