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
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
"import": "./dist/primitives/menu/index.js",
"types": "./dist/primitives/menu/index.d.ts"
},
"./autocomplete": {
"import": "./dist/primitives/autocomplete/index.js",
"types": "./dist/primitives/autocomplete/index.d.ts"
},
"./dialog": {
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
Expand Down
152 changes: 152 additions & 0 deletions packages/headless/src/primitives/autocomplete/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Autocomplete

A combobox input with a filterable dropdown list. Supports virtual focus (focus stays on the input), keyboard navigation, and controlled/uncontrolled input and selection values.

## When to Use

- Search inputs with suggestions, tag pickers, or any input that filters a list of options.
- When the user needs to type to narrow down choices, unlike `Select` which is for picking from a static list.
- When you need `aria-autocomplete` behavior with `aria-activedescendant` virtual focus.

## Usage

```tsx
import { Autocomplete } from '@/primitives/autocomplete';

const fruits = ['Apple', 'Banana', 'Cherry', 'Date'];

function MyAutocomplete() {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(f => f.toLowerCase().includes(inputValue.toLowerCase()));

return (
<Autocomplete.Root
inputValue={inputValue}
onInputValueChange={setInputValue}
>
<Autocomplete.Input placeholder='Search fruits...' />
<Autocomplete.Positioner>
<Autocomplete.Popup>
{filtered.map(fruit => (
<Autocomplete.Option
key={fruit}
value={fruit}
label={fruit}
/>
))}
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Root>
);
}
```

### Inline List (inside another floating element)

Use `Autocomplete.List` when the autocomplete input lives inside an outer floating surface such as a Popover or Dialog. In this mode, the outer primitive owns placement and dismissal for the overall panel, while `Autocomplete` still owns the combobox/listbox semantics between the input and the results list.

```tsx
<Popover.Root>
<Popover.Trigger>Pick a country</Popover.Trigger>
<Popover.Positioner>
<Popover.Popup>
<Autocomplete.Root open>
<Autocomplete.Input
placeholder='Search countries...'
autoFocus
/>
<Autocomplete.List>
<Autocomplete.Option
value='us'
label='United States'
/>
</Autocomplete.List>
</Autocomplete.Root>
</Popover.Popup>
</Popover.Positioner>
</Popover.Root>
```

In this pattern, keep the outer `Popover` or `Dialog` as the source of truth for whether the panel is visible. `Autocomplete` should render the input and inline listbox inside that surface, and selecting an option can close the outer shell if desired.

## Parts

| Part | Default Element | Description |
| ------------------------- | --------------- | ---------------------------------------- |
| `Autocomplete.Root` | — | Root context provider |
| `Autocomplete.Input` | `<input>` | Text input that drives filtering |
| `Autocomplete.Portal` | — | Portals children (accepts `root` prop) |
| `Autocomplete.Positioner` | `<div>` | Floating positioned container |
| `Autocomplete.Popup` | `<div>` | Visual wrapper for the option list |
| `Autocomplete.List` | `<div>` | Inline alternative to Positioner + Popup |
| `Autocomplete.Option` | `<div>` | A selectable option |
| `Autocomplete.Arrow` | `<svg>` | Optional floating arrow |

## Props

### `Autocomplete.Root`

| Prop | Type | Default | Description |
| -------------------- | ------------------------- | ---------------- | ------------------------------------- |
| `inputValue` | `string` | — | Controlled input text |
| `defaultInputValue` | `string` | `""` | Initial input text (uncontrolled) |
| `onInputValueChange` | `(value: string) => void` | — | Called when input text changes |
| `value` | `string` | — | Controlled selected value |
| `defaultValue` | `string` | — | Initial selected value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | — | Called when an option is selected |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `placement` | `Placement` | `"bottom-start"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between input and popup (px) |

### `Autocomplete.Option`

| Prop | Type | Default | Description |
| ---------- | --------- | --------------------- | ---------------------------------------------------- |
| `value` | `string` | **required** | The option's value |
| `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`

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

### `Autocomplete.Arrow`

Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.

## Keyboard Navigation

| Key | Action |
| ----------- | ------------------------------------- |
| `ArrowDown` | Move to next option |
| `ArrowUp` | Move to previous option |
| `Enter` | Select the active option, close popup |
| `Escape` | Close the popup |

Navigation loops and auto-scrolls the active option into view.

## Data Attributes

| Attribute | Applies To | Description |
| --------------------------------- | ----------------- | --------------------------------------------- |
| `data-cl-slot` | All parts | Part identifier (e.g. `"autocomplete-input"`) |
| `data-cl-open` / `data-cl-closed` | Input | Popup open state |
| `data-cl-selected` | Option | The currently selected option |
| `data-cl-active` | Option | The keyboard-highlighted option |
| `data-cl-disabled` | Option | Disabled option |
| `data-cl-side` | Positioner, Arrow | Resolved placement side |

## Open/Close Behavior

- Typing a non-empty string opens the popup automatically.
- 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)
- 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
@@ -0,0 +1,22 @@
'use client';

import { FloatingArrow } from '@floating-ui/react';
import React from 'react';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteArrowProps extends React.ComponentPropsWithRef<typeof FloatingArrow> {}

export function AutocompleteArrow(props: AutocompleteArrowProps) {
const { floatingContext, arrowRef, placement } = useAutocompleteContext();
const side = placement.split('-')[0];

return (
<FloatingArrow
data-cl-slot='autocomplete-arrow'
data-cl-side={side}
{...props}
ref={arrowRef}
context={floatingContext}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type {
ExtendedRefs,
FloatingContext,
Placement,
ReferenceType,
UseInteractionsReturn,
} from '@floating-ui/react';
import { type CSSProperties, createContext, useContext } from 'react';
import type { TransitionProps } from '../../hooks/use-transition';

export interface AutocompleteContextValue {
open: boolean;
inputValue: string;
selectedValue: string | undefined;
floatingContext: FloatingContext;
refs: ExtendedRefs<ReferenceType>;
floatingStyles: CSSProperties;
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
selectedIndex: number | null;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef: React.MutableRefObject<Array<string | null>>;
popupRef: React.RefObject<HTMLDivElement | 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;
registerSelectedIndex: (index: number, value: string) => void;
mounted: boolean;
transitionProps: TransitionProps;
}

export const AutocompleteContext = createContext<AutocompleteContextValue | null>(null);

export function useAutocompleteContext() {
const ctx = useContext(AutocompleteContext);
if (!ctx) {
throw new Error('Autocomplete compound components must be used within <Autocomplete>');
}
return ctx;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client';

import React from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteInputProps extends ComponentProps<'input'> {}

export function AutocompleteInput(props: AutocompleteInputProps) {
const { render, ...otherProps } = props;
const {
open,
inputValue,
activeIndex,
refs,
getReferenceProps,
handleInputChange,
handleSelect,
labelsRef,
valuesByIndexRef,
} = useAutocompleteContext();

const state = { open };

const defaultProps = {
'data-cl-slot': 'autocomplete-input',
...(getReferenceProps({
ref: refs.setReference,
value: inputValue,
'aria-autocomplete': 'list' as const,
onChange(event: React.ChangeEvent<HTMLInputElement>) {
handleInputChange(event.target.value);
},
onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter' && activeIndex != null) {
const value = valuesByIndexRef.current.get(activeIndex);
const label = labelsRef.current[activeIndex];
if (value != null) {
event.preventDefault();
handleSelect(value, activeIndex, label ?? value);
}
}
},
}) as React.ComponentPropsWithRef<'input'>),
};

return renderElement({
defaultTagName: 'input',
render,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
},
props: mergeProps<'input'>(defaultProps, otherProps),
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client';

import { FloatingList } from '@floating-ui/react';
import React, { useEffect } from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteListProps extends ComponentProps<'div'> {}

export function AutocompleteList(props: AutocompleteListProps) {
const { render, ...otherProps } = props;
const { elementsRef, labelsRef, refs, getFloatingProps, setInlineMode } = useAutocompleteContext();

useEffect(() => {
setInlineMode(true);
return () => setInlineMode(false);
}, [setInlineMode]);

const defaultProps = {
'data-cl-slot': 'autocomplete-list',
ref: refs.setFloating,
...(getFloatingProps() as React.ComponentPropsWithRef<'div'>),
};

return (
<FloatingList
elementsRef={elementsRef}
labelsRef={labelsRef}
>
{
renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
})!
}
</FloatingList>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use client';

import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useAutocompleteContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
value: string;
label?: string;
disabled?: boolean;
}

export function AutocompleteOption(props: AutocompleteOptionProps) {
const { render, value, label, disabled, ...otherProps } = props;
const { activeIndex, selectedValue, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } =
useAutocompleteContext();

const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });

const isSelected = selectedValue === value;
const isActive = activeIndex === index;

useEffect(() => {
valuesByIndexRef.current.set(index, value);
registerSelectedIndex(index, value);
return () => {
valuesByIndexRef.current.delete(index);
};
}, [index, value, valuesByIndexRef, registerSelectedIndex]);

const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
};

const defaultProps = {
'data-cl-slot': 'autocomplete-option',
id,
ref: itemRef,
role: 'option' as const,
'aria-selected': isActive,
'aria-disabled': disabled || undefined,
...(getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
(refs.domReference.current as HTMLElement | null)?.focus();
}
},
}) as React.ComponentPropsWithRef<'div'>),
};

return renderElement({
defaultTagName: 'div',
render,
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
}
Loading
Loading