Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .changeset/calm-combs-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
8 changes: 6 additions & 2 deletions packages/headless/src/primitives/autocomplete/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function MyAutocomplete() {
onInputValueChange={setInputValue}
>
<Autocomplete.Input placeholder='Search fruits...' />
<Autocomplete.Trigger aria-label='Toggle fruit options'>⌄</Autocomplete.Trigger>
<Autocomplete.Positioner>
<Autocomplete.Popup>
{filtered.map(fruit => (
Expand Down Expand Up @@ -75,6 +76,7 @@ In this pattern, keep the outer `Popover` or `Dialog` as the source of truth for
| ------------------------- | --------------- | ---------------------------------------- |
| `Autocomplete.Root` | — | Root context provider |
| `Autocomplete.Input` | `<input>` | Text input that drives filtering |
| `Autocomplete.Trigger` | `<button>` | Pointer-accessible popup toggle |
| `Autocomplete.Portal` | — | Portals children (accepts `root` prop) |
| `Autocomplete.Positioner` | `<div>` | Floating positioned container |
| `Autocomplete.Popup` | `<div>` | Visual wrapper for the option list |
Expand Down Expand Up @@ -108,7 +110,7 @@ In this pattern, keep the outer `Popover` or `Dialog` as the source of truth for
| `label` | `string` | falls back to `value` | Display label, also used for input text on selection |
| `disabled` | `boolean` | — | Prevents selection |

### `Autocomplete.Input`, `Autocomplete.Positioner`, `Autocomplete.Popup`, `Autocomplete.List`
### `Autocomplete.Input`, `Autocomplete.Trigger`, `Autocomplete.Positioner`, `Autocomplete.Popup`, `Autocomplete.List`

No additional props beyond standard HTML attributes and the `render` prop.

Expand All @@ -131,7 +133,7 @@ Navigation loops and auto-scrolls the active option into view.

| Attribute | Applies To | Description |
| --------------------------- | ----------------- | ------------------------------- |
| `data-open` / `data-closed` | Input | Popup open state |
| `data-open` / `data-closed` | Input, Trigger | Popup open state |
| `data-selected` | Option | The currently selected option |
| `data-active` | Option | The keyboard-highlighted option |
| `data-disabled` | Option | Disabled option |
Expand All @@ -140,12 +142,14 @@ Navigation loops and auto-scrolls the active option into view.
## Open/Close Behavior

- Typing a non-empty string opens the popup automatically.
- Activating `Trigger` toggles the popup and returns focus to the input.
- Clearing the input closes the popup.
- Clicking an option closes the popup and returns focus to the input.
- Outside click and Escape close the popup.

## ARIA

- Input: `aria-autocomplete="list"`, `aria-activedescendant` (virtual focus)
- Trigger: `tabindex="-1"`, `aria-controls`, `aria-expanded`
- Options: `role="option"`, `aria-selected`, `aria-disabled`
- Focus manager: non-modal, `initialFocus={-1}` (focus stays on input)
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ export interface AutocompleteContextValue {
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef: React.MutableRefObject<Array<string | null>>;
popupRef: React.RefObject<HTMLDivElement | null>;
triggerRef: React.MutableRefObject<HTMLButtonElement | null>;
arrowRef: React.MutableRefObject<SVGSVGElement | null>;
valuesByIndexRef: React.MutableRefObject<Map<number, string>>;
setInlineMode: React.Dispatch<React.SetStateAction<boolean>>;
handleSelect: (value: string, index: number, label: string) => void;
handleInputChange: (value: string) => void;
setOpen: (open: boolean) => void;
focusInput: () => void;
popupId: string | undefined;
registerSelectedIndex: (index: number, value: string) => void;
mounted: boolean;
transitionProps: TransitionProps;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function AutocompleteInner(props: AutocompleteProps) {
const labelsRef = useRef<Array<string | null>>([]);
const arrowRef = useRef<SVGSVGElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const valuesByIndexRef = useRef<Map<number, string>>(new Map());
const registerSelectedIndex = useCallback(
(index: number, value: string) => {
Expand Down Expand Up @@ -125,7 +126,13 @@ function AutocompleteInner(props: AutocompleteProps) {

const dismiss = useDismiss(floatingContext, {
escapeKey: !inlineMode,
outsidePress: !inlineMode,
outsidePress(event) {
if (inlineMode) {
return false;
}
const target = event.target;
return !(target instanceof Node && triggerRef.current?.contains(target));
},
bubbles: {
escapeKey: inlineMode,
outsidePress: inlineMode,
Expand All @@ -143,6 +150,15 @@ function AutocompleteInner(props: AutocompleteProps) {
});

const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([dismiss, role, listNav]);
const referenceProps = getReferenceProps();
const popupId = typeof referenceProps['aria-controls'] === 'string' ? referenceProps['aria-controls'] : undefined;

const focusInput = useCallback(() => {
const input = refs.domReference.current;
if (input instanceof HTMLElement) {
input.focus();
}
}, [refs.domReference]);

const handleSelect = useCallback(
(value: string, index: number, label: string) => {
Expand Down Expand Up @@ -186,11 +202,15 @@ function AutocompleteInner(props: AutocompleteProps) {
elementsRef,
labelsRef,
popupRef,
triggerRef,
arrowRef,
valuesByIndexRef,
setInlineMode,
handleSelect,
handleInputChange,
setOpen,
focusInput,
popupId,
registerSelectedIndex,
mounted,
transitionProps,
Expand All @@ -210,6 +230,9 @@ function AutocompleteInner(props: AutocompleteProps) {
selectedIndex,
handleSelect,
handleInputChange,
setOpen,
focusInput,
popupId,
registerSelectedIndex,
mounted,
transitionProps,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import React from 'react';

import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteTriggerProps = ComponentProps<'button'>;

export const AutocompleteTrigger = React.forwardRef<HTMLButtonElement, AutocompleteTriggerProps>(
function AutocompleteTrigger(props, ref) {
const { render, ...otherProps } = props;
const { open, setOpen, focusInput, popupId, triggerRef } = useAutocompleteContext();
const state = { open };

const defaultProps = {
type: 'button',
tabIndex: -1,
'aria-controls': popupId,
'aria-expanded': open,
'aria-haspopup': 'listbox',
onPointerDown(event: React.PointerEvent<HTMLButtonElement>) {
event.preventDefault();
},
onClick() {
setOpen(!open);
focusInput();
},
} satisfies DefaultProps<'button'>;

return useRender({
defaultTagName: 'button',
render,
ref: [triggerRef, ref],
state,
stateAttributesMapping: {
open: (value: boolean): Record<string, string> | null => (value ? { 'data-open': '' } : { 'data-closed': '' }),
},
props: mergeProps<'button'>(defaultProps, otherProps),
});
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,40 @@ function StaticAutocomplete(props: Partial<React.ComponentProps<typeof Autocompl

describe('Autocomplete', () => {
describe('open/close', () => {
it('toggles from a popup button while keeping focus on the input', async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(
<Autocomplete.Root onOpenChange={onOpenChange}>
<Autocomplete.Input placeholder='Search fruits...' />
<Autocomplete.Trigger aria-label='Toggle fruit options' />
<Autocomplete.Positioner>
<Autocomplete.Popup>
<Autocomplete.Option value='apple'>Apple</Autocomplete.Option>
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Root>,
);

const input = screen.getByRole('combobox');
const trigger = screen.getByRole('button', { name: 'Toggle fruit options' });
expect(trigger).toHaveAttribute('tabindex', '-1');
expect(trigger).toHaveAttribute('aria-expanded', 'false');

await user.click(trigger);

const listbox = screen.getByRole('listbox');
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(trigger).toHaveAttribute('aria-controls', listbox.id);
expect(input).toHaveFocus();

await user.click(trigger);

expect(onOpenChange).toHaveBeenLastCalledWith(false);
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(input).toHaveFocus();
});

it('opens when user types', async () => {
const user = userEvent.setup();
render(<FilteredAutocomplete />);
Expand Down
1 change: 1 addition & 0 deletions packages/headless/src/primitives/autocomplete/parts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { type AutocompleteProps, AutocompleteRoot as Root } from './autocomplete-root';
export { type AutocompleteInputProps, AutocompleteInput as Input } from './autocomplete-input';
export { type AutocompleteTriggerProps, AutocompleteTrigger as Trigger } from './autocomplete-trigger';
export { type AutocompletePortalProps, AutocompletePortal as Portal } from './autocomplete-portal';
export { type AutocompletePositionerProps, AutocompletePositioner as Positioner } from './autocomplete-positioner';
export { type AutocompletePopupProps, AutocompletePopup as Popup } from './autocomplete-popup';
Expand Down
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
banner: dynamic(() => import('../stories/banner.mdx')),
button: dynamic(() => import('../stories/button.mdx')),
card: dynamic(() => import('../stories/card.component.mdx')),
combobox: dynamic(() => import('../stories/combobox.mdx')),
input: dynamic(() => import('../stories/input.mdx')),
'input-group': dynamic(() => import('../stories/input-group.mdx')),
item: dynamic(() => import('../stories/item.mdx')),
Expand Down
12 changes: 12 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import {
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
import { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories';
import { meta as collapsibleMeta } from '../stories/collapsible.stories';
import {
Default as ComboboxDefault,
meta as comboboxMeta,
Scrolling as ComboboxScrolling,
} from '../stories/combobox.stories';
import {
Default as DestructiveDefault,
meta as destructiveMeta,
Expand Down Expand Up @@ -243,6 +248,12 @@ const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default:

const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault };

const comboboxModule: StoryModule = {
meta: comboboxMeta,
Default: ComboboxDefault,
Scrolling: ComboboxScrolling,
};

const avatarModule: StoryModule = {
meta: avatarMeta,
Primary: AvatarPrimary,
Expand Down Expand Up @@ -521,6 +532,7 @@ export const registry: StoryModule[] = [
bannerModule,
buttonModule,
cardComponentModule,
comboboxModule,
flowComponentModule,
inputModule,
inputGroupModule,
Expand Down
64 changes: 64 additions & 0 deletions packages/swingset/src/stories/combobox.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as ComboboxStories from './combobox.stories';

# Combobox

`Combobox` adds Mosaic styling to the headless `Autocomplete` behavior: text entry, listbox ARIA,
keyboard navigation, selection, positioning, and the shared scrolling treatment.

## Example

Use the trigger to show every option, or type to open and filter the list. Use the arrow keys and
Enter to select an option.

<Story
name='Default'
storyModule={ComboboxStories}
/>

Filtering stays with the caller: read `inputValue` and render only the matching options.
`Combobox.Popup` owns its portal, positioner, surface, and scrolling viewport.

## Inline lists

Use `List` when the searchable list already lives in another floating surface, such as a country
picker inside a popover. Use the headless input variant when it needs an icon or adjacent text.
This avoids both a second input implementation and a nested popup.

```tsx
<Combobox.Root open inputValue={query} onInputValueChange={setQuery}>
<InputGroup.Root>
<InputGroup.Text>
<Icon name='search' aria-hidden='true' />
</InputGroup.Text>
<Combobox.Input variant='headless' aria-label='Search countries' />
</InputGroup.Root>
<Combobox.List>
{countries.map(country => (
<Combobox.Option key={country.iso} value={country.iso} label={country.name}>
{country.name}
</Combobox.Option>
))}
</Combobox.List>
</Combobox.Root>
```

## Scrolling

Long lists receive the shared ScrollArea fade and scrollbar automatically.

<Story
name='Scrolling'
storyModule={ComboboxStories}
/>

## Parts

| Part | Description |
| ------------------ | --------------------------------------------------------------------------------- |
| `Combobox.Root` | Owns input, selection, open state, and keyboard navigation. |
| `Combobox.Input` | Autocomplete input; renders Mosaic `Input` unless composed through another input. |
| `Combobox.Trigger` | Opens and closes the option list while keeping focus on the input. |
| `Combobox.Popup` | Portals, positions, surfaces, and scrolls a floating option list. |
| `Combobox.List` | Scrollable inline listbox. |
| `Combobox.Option` | Selectable option with active, selected, and disabled states. |
| `Combobox.Empty` | Empty result message. |
Loading
Loading