Skip to content

Commit d3b4459

Browse files
committed
feat: add modal prop to Combobox Root
Allows callers to opt out of the default modal behaviour where outside elements are aria-hidden, page scroll is locked, and focus is trapped in the trigger. Setting modal={false} is useful when the combobox sits inside a dialog or other focus-managing container that already handles these concerns, or when the product deliberately needs pointer events outside the open listbox to remain active. Default remains true so existing usage is unaffected.
1 parent 1dcb5b5 commit d3b4459

2 files changed

Lines changed: 75 additions & 36 deletions

File tree

packages/primitives/src/components/Combobox/Combobox.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,4 +987,33 @@ describe('Combobox', () => {
987987
expect(getByText('No value found')).toBeInTheDocument();
988988
});
989989
});
990+
991+
describe('modal prop', () => {
992+
it('should open the listbox when modal is false', async () => {
993+
const { getByRole, user } = render({ modal: false });
994+
995+
await user.click(getByRole('combobox'));
996+
997+
expect(getByRole('listbox')).toBeInTheDocument();
998+
});
999+
1000+
it('should close the listbox on Escape when modal is false', async () => {
1001+
const { getByRole, queryByRole, user } = render({ modal: false });
1002+
1003+
await user.click(getByRole('combobox'));
1004+
expect(getByRole('listbox')).toBeInTheDocument();
1005+
1006+
await user.keyboard('[Escape]');
1007+
expect(queryByRole('listbox')).not.toBeInTheDocument();
1008+
});
1009+
1010+
it('should allow selecting an item when modal is false', async () => {
1011+
const { getByRole, user } = render({ modal: false });
1012+
1013+
await user.click(getByRole('combobox'));
1014+
await user.click(getByRole('option', { name: 'Option 1' }));
1015+
1016+
expect(getByRole('combobox')).toHaveValue('Option 1');
1017+
});
1018+
});
9901019
});

packages/primitives/src/components/Combobox/Combobox.tsx

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ type ComboboxContextValue = {
6969
contentId: string;
7070
disabled?: boolean;
7171
locale: string;
72+
modal: boolean;
7273
onOpenChange(open: boolean): void;
7374
onTriggerChange(node: ComboboxInputElement | null): void;
7475
onValueChange(value: string | undefined): void;
@@ -106,6 +107,13 @@ interface RootProps {
106107
defaultTextValue?: string;
107108
disabled?: boolean;
108109
locale?: string;
110+
/**
111+
* The modality of the combobox. When set to `true`, interaction with
112+
* outside elements will be disabled and only the combobox content will
113+
* be visible to screen readers.
114+
* @default true
115+
*/
116+
modal?: boolean;
109117
onOpenChange?(open: boolean): void;
110118
onValueChange?(value: string): void;
111119
onTextValueChange?(textValue: string): void;
@@ -177,6 +185,7 @@ const Combobox = (props: RootProps) => {
177185
disabled,
178186
required = false,
179187
locale = 'en-EN',
188+
modal = true,
180189
onTextValueChange,
181190
textValue: textValueProp,
182191
defaultTextValue,
@@ -266,8 +275,8 @@ const Combobox = (props: RootProps) => {
266275

267276
// aria-hide everything except the content (better supported equivalent to setting aria-modal)
268277
React.useEffect(() => {
269-
if (content && trigger) return hideOthers([content, trigger]);
270-
}, [content, trigger]);
278+
if (modal && content && trigger) return hideOthers([content, trigger]);
279+
}, [modal, content, trigger]);
271280

272281
return (
273282
<ComboboxProviders>
@@ -284,6 +293,7 @@ const Combobox = (props: RootProps) => {
284293
onOpenChange={setOpen}
285294
disabled={disabled}
286295
locale={locale}
296+
modal={modal}
287297
focusFirst={focusFirst}
288298
textValue={textValue}
289299
onTextValueChange={setTextValue}
@@ -330,7 +340,7 @@ const ComboboxTrigger = React.forwardRef<ComboboxTriggerElement, TriggerProps>((
330340
asChild
331341
// we make sure we're not trapping once it's been closed
332342
// (closed !== unmounted when animating out)
333-
trapped={context.open}
343+
trapped={context.modal && context.open}
334344
onMountAutoFocus={(event) => {
335345
// we prevent open autofocus because we manually focus the selected item
336346
event.preventDefault();
@@ -858,41 +868,41 @@ const ComboboxContentImpl = React.forwardRef<ComboboxContentImplElement, Combobo
858868
};
859869
}, [onOpenChange]);
860870

861-
return (
862-
<RemoveScroll allowPinchZoom>
863-
<DismissableLayer
864-
asChild
865-
onEscapeKeyDown={onEscapeKeyDown}
866-
onPointerDownOutside={onPointerDownOutside}
867-
// When focus is trapped, a focusout event may still happen.
868-
// We make sure we don't trigger our `onDismiss` in such case.
869-
onFocusOutside={(event) => {
870-
event.preventDefault();
871-
}}
872-
onDismiss={() => {
873-
context.onOpenChange(false);
874-
context.trigger?.focus({ preventScroll: true });
871+
const content = (
872+
<DismissableLayer
873+
asChild
874+
onEscapeKeyDown={onEscapeKeyDown}
875+
onPointerDownOutside={onPointerDownOutside}
876+
// When focus is trapped, a focusout event may still happen.
877+
// We make sure we don't trigger our `onDismiss` in such case.
878+
onFocusOutside={(event) => {
879+
event.preventDefault();
880+
}}
881+
onDismiss={() => {
882+
context.onOpenChange(false);
883+
context.trigger?.focus({ preventScroll: true });
884+
}}
885+
>
886+
<ComboboxPopperPosition
887+
role="listbox"
888+
id={context.contentId}
889+
data-state={context.open ? 'open' : 'closed'}
890+
onContextMenu={(event) => event.preventDefault()}
891+
{...contentProps}
892+
ref={composedRefs}
893+
style={{
894+
// flex layout so we can place the scroll buttons properly
895+
display: 'flex',
896+
flexDirection: 'column',
897+
// reset the outline by default as the content MAY get focused
898+
outline: 'none',
899+
...contentProps.style,
875900
}}
876-
>
877-
<ComboboxPopperPosition
878-
role="listbox"
879-
id={context.contentId}
880-
data-state={context.open ? 'open' : 'closed'}
881-
onContextMenu={(event) => event.preventDefault()}
882-
{...contentProps}
883-
ref={composedRefs}
884-
style={{
885-
// flex layout so we can place the scroll buttons properly
886-
display: 'flex',
887-
flexDirection: 'column',
888-
// reset the outline by default as the content MAY get focused
889-
outline: 'none',
890-
...contentProps.style,
891-
}}
892-
/>
893-
</DismissableLayer>
894-
</RemoveScroll>
901+
/>
902+
</DismissableLayer>
895903
);
904+
905+
return context.modal ? <RemoveScroll allowPinchZoom>{content}</RemoveScroll> : content;
896906
},
897907
);
898908

0 commit comments

Comments
 (0)