diff --git a/packages/headless/package.json b/packages/headless/package.json
index a11fefd3a35..27bc45088a2 100644
--- a/packages/headless/package.json
+++ b/packages/headless/package.json
@@ -28,6 +28,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"
diff --git a/packages/headless/src/primitives/autocomplete/README.md b/packages/headless/src/primitives/autocomplete/README.md
new file mode 100644
index 00000000000..c8cd34524d0
--- /dev/null
+++ b/packages/headless/src/primitives/autocomplete/README.md
@@ -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 (
+
+
+
+
+ {filtered.map(fruit => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+### 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
+
+ Pick a country
+
+
+
+
+
+
+
+
+
+
+
+```
+
+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 context provider |
+| `Autocomplete.Input` | ` ` | Text input that drives filtering |
+| `Autocomplete.Portal` | — | Portals children (accepts `root` prop) |
+| `Autocomplete.Positioner` | `
` | Floating positioned container |
+| `Autocomplete.Popup` | `
` | Visual wrapper for the option list |
+| `Autocomplete.List` | `
` | Inline alternative to Positioner + Popup |
+| `Autocomplete.Option` | `
` | A selectable option |
+| `Autocomplete.Arrow` | `
` | 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)
diff --git a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx
new file mode 100644
index 00000000000..7f947d1a545
--- /dev/null
+++ b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx
@@ -0,0 +1,1155 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { axe } from '../../test-utils/axe';
+import { Popover } from '../popover/popover';
+import { Autocomplete } from './autocomplete';
+
+afterEach(() => cleanup());
+
+const fruits = [
+ { value: 'apple', label: 'Apple' },
+ { value: 'banana', label: 'Banana' },
+ { value: 'cherry', label: 'Cherry' },
+ { value: 'date', label: 'Date' },
+];
+
+function FilteredAutocomplete(
+ props: {
+ onValueChange?: (value: string) => void;
+ onInputValueChange?: (value: string) => void;
+ defaultInputValue?: string;
+ } = {},
+) {
+ const [inputValue, setInputValue] = useState(props.defaultInputValue ?? '');
+ const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase()));
+
+ return (
+ {
+ setInputValue(v);
+ props.onInputValueChange?.(v);
+ }}
+ onValueChange={props.onValueChange}
+ >
+
+
+
+ {filtered.map(f => (
+
+ {f.label}
+
+ ))}
+
+
+
+ );
+}
+
+function StaticAutocomplete(props: Partial> = {}) {
+ return (
+
+
+
+
+ {fruits.map(f => (
+
+ {f.label}
+
+ ))}
+
+
+
+ );
+}
+
+describe('Autocomplete', () => {
+ describe('slot attributes', () => {
+ it('renders input with data-cl-slot', () => {
+ render( );
+ const input = screen.getByPlaceholderText('Search fruits...');
+ expect(input).toHaveAttribute('data-cl-slot', 'autocomplete-input');
+ });
+
+ it('renders all parts with correct slot attributes when open', () => {
+ render( );
+
+ expect(document.querySelector('[data-cl-slot="autocomplete-positioner"]')).toBeInTheDocument();
+ expect(document.querySelector('[data-cl-slot="autocomplete-popup"]')).toBeInTheDocument();
+ expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(4);
+ });
+ });
+
+ describe('open/close', () => {
+ it('opens when user types', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'a');
+
+ expect(document.querySelector('[data-cl-slot="autocomplete-popup"]')).toBeInTheDocument();
+ });
+
+ it('closes when input is cleared', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'a');
+
+ expect(document.querySelector('[data-cl-slot="autocomplete-popup"]')).toBeInTheDocument();
+
+ await user.clear(input);
+
+ expect(input).toHaveAttribute('data-cl-closed', '');
+ });
+
+ it('closes on Escape', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'a');
+ await user.keyboard('{Escape}');
+
+ expect(input).toHaveAttribute('data-cl-closed', '');
+ });
+
+ it('calls onOpenChange when toggled', async () => {
+ const onOpenChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'a');
+
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ });
+ });
+
+ describe('filtering', () => {
+ it('filters options based on input', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'ch');
+
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options).toHaveLength(1);
+ expect(options[0]).toHaveTextContent('Cherry');
+ });
+
+ it('shows all matching options', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'a');
+
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options).toHaveLength(1); // Only "Apple" starts with "a"
+ });
+ });
+
+ describe('selection', () => {
+ it('selects option on click', async () => {
+ const onValueChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'b');
+ await user.click(screen.getByText('Banana'));
+
+ expect(onValueChange).toHaveBeenCalledWith('banana');
+ });
+
+ it('updates input value to label on selection', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...') as HTMLInputElement;
+ await user.type(input, 'b');
+ await user.click(screen.getByText('Banana'));
+
+ expect(input.value).toBe('Banana');
+ });
+
+ it('closes after selection', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'b');
+ await user.click(screen.getByText('Banana'));
+
+ expect(input).toHaveAttribute('data-cl-closed', '');
+ });
+
+ it('returns focus to input after click selection', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'b');
+ await user.click(screen.getByText('Banana'));
+
+ expect(document.activeElement).toBe(input);
+ });
+ });
+
+ describe('keyboard navigation', () => {
+ it('navigates options with arrow keys', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'a');
+ await user.keyboard('{ArrowDown}');
+
+ const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(activeOption).toBeInTheDocument();
+ });
+
+ it('selects option on Enter', async () => {
+ const onValueChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'b');
+ // activeIndex starts at 0 when typing opens the list
+ await user.keyboard('{Enter}');
+
+ expect(onValueChange).toHaveBeenCalledWith('banana');
+ });
+
+ it('updates input value on Enter selection', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...') as HTMLInputElement;
+ await user.type(input, 'b');
+ await user.keyboard('{Enter}');
+
+ expect(input.value).toBe('Banana');
+ });
+
+ it('focus stays on input during arrow navigation', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'a');
+ await user.keyboard('{ArrowDown}');
+
+ expect(document.activeElement).toBe(input);
+ });
+ });
+
+ describe('option state attributes', () => {
+ it('marks active option with data-cl-active', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.type(screen.getByPlaceholderText('Search fruits...'), 'a');
+
+ // First option is active by default (activeIndex starts at 0)
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options[0]).toHaveAttribute('data-cl-active', '');
+ });
+
+ it('marks selected option with data-cl-selected', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options[1]).toHaveAttribute('data-cl-selected', '');
+ });
+ });
+
+ describe('ARIA attributes', () => {
+ it('input has role=combobox', () => {
+ render( );
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
+ });
+
+ it('input has aria-autocomplete=list', () => {
+ render( );
+ const input = screen.getByRole('combobox');
+ expect(input).toHaveAttribute('aria-autocomplete', 'list');
+ });
+
+ it('options have role=option', () => {
+ render( );
+ const options = screen.getAllByRole('option');
+ expect(options).toHaveLength(4);
+ });
+
+ it('active option has aria-selected=true', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.type(screen.getByPlaceholderText('Search fruits...'), 'a');
+
+ const options = screen.getAllByRole('option');
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
+ });
+ });
+
+ describe('animation lifecycle', () => {
+ it('positioner is not rendered when closed', () => {
+ render( );
+ expect(document.querySelector('[data-cl-slot="autocomplete-positioner"]')).not.toBeInTheDocument();
+ });
+
+ it('applies data-cl-open on popup when open', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.type(screen.getByPlaceholderText('Search fruits...'), 'a');
+
+ const popup = document.querySelector('[data-cl-slot="autocomplete-popup"]');
+ expect(popup).toHaveAttribute('data-cl-open', '');
+ });
+
+ it('positioner has data-cl-side', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.type(screen.getByPlaceholderText('Search fruits...'), 'a');
+
+ const positioner = document.querySelector('[data-cl-slot="autocomplete-positioner"]');
+ expect(positioner).toHaveAttribute('data-cl-side');
+ });
+ });
+
+ describe('disabled option', () => {
+ it('renders disabled option with data-cl-disabled', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+
+
+ Apple
+
+
+ Banana
+
+
+
+ ,
+ );
+
+ const disabledOption = screen.getByText('Banana').closest('[data-cl-slot="autocomplete-option"]');
+ expect(disabledOption).toHaveAttribute('data-cl-disabled', '');
+ expect(disabledOption).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('does not select disabled option on click', async () => {
+ const onValueChange = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+
+
+
+ Apple
+
+
+ Banana
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Banana'));
+
+ expect(onValueChange).not.toHaveBeenCalledWith('banana');
+ });
+ });
+
+ describe('Autocomplete.List (inline mode)', () => {
+ function InlineAutocomplete(props: { value?: string; onValueChange?: (value: string) => void } = {}) {
+ const [inputValue, setInputValue] = useState('');
+ const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase()));
+
+ return (
+
+
+
+ {filtered.map(f => (
+
+ {f.label}
+
+ ))}
+
+
+ );
+ }
+
+ it('renders options with data-cl-slot', () => {
+ render( );
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options).toHaveLength(4);
+ });
+
+ it('renders list with data-cl-slot', () => {
+ render( );
+ expect(document.querySelector('[data-cl-slot="autocomplete-list"]')).toBeInTheDocument();
+ });
+
+ it('marks selected option with data-cl-selected via controlled value', () => {
+ render( );
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options[1]).toHaveAttribute('data-cl-selected', '');
+ });
+
+ it('selects option on click', async () => {
+ const onValueChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Banana'));
+
+ expect(onValueChange).toHaveBeenCalledWith('banana');
+ });
+
+ it('navigates options with arrow keys', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.click(input);
+ await user.keyboard('{ArrowDown}');
+
+ const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(activeOption).toBeInTheDocument();
+ });
+
+ it('links the input to the inline listbox with aria-controls', () => {
+ render( );
+
+ const input = screen.getByRole('combobox');
+ const list = document.querySelector('[data-cl-slot="autocomplete-list"]');
+
+ expect(list).toHaveAttribute('id');
+ expect(input).toHaveAttribute('aria-controls', list?.getAttribute('id'));
+ });
+
+ it('updates aria-activedescendant during keyboard navigation', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByRole('combobox');
+ await user.click(input);
+ await user.keyboard('{ArrowDown}');
+
+ const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(activeOption).toHaveAttribute('id');
+ expect(input).toHaveAttribute('aria-activedescendant', activeOption?.getAttribute('id'));
+ });
+
+ it('selects option on Enter after arrow navigation', async () => {
+ const onValueChange = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.click(input);
+ await user.keyboard('{ArrowDown}{Enter}');
+
+ expect(onValueChange).toHaveBeenCalled();
+ });
+
+ it('filters options based on input', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'ch');
+
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options).toHaveLength(1);
+ expect(options[0]).toHaveTextContent('Cherry');
+ });
+
+ it('preserves selected state after unmount and remount', () => {
+ const { unmount } = render( );
+
+ let options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options[2]).toHaveAttribute('data-cl-selected', '');
+
+ unmount();
+
+ render( );
+
+ options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options[2]).toHaveAttribute('data-cl-selected', '');
+ });
+
+ it('shows selected state after selecting then remounting', async () => {
+ function TestHarness() {
+ const [mounted, setMounted] = useState(true);
+ const [value, setValue] = useState();
+
+ return (
+ <>
+ setMounted(m => !m)}
+ />
+ {mounted && (
+
+ )}
+ >
+ );
+ }
+
+ const user = userEvent.setup();
+ render( );
+
+ // Select banana
+ await user.click(screen.getByText('Banana'));
+
+ // Unmount (simulates popover close)
+ await user.click(screen.getByTestId('toggle'));
+ expect(document.querySelector('[data-cl-slot="autocomplete-option"]')).not.toBeInTheDocument();
+
+ // Remount (simulates popover reopen)
+ await user.click(screen.getByTestId('toggle'));
+
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options[1]).toHaveAttribute('data-cl-selected', '');
+ });
+ });
+
+ describe('Autocomplete.List inside Popover', () => {
+ function AutocompleteInPopover() {
+ const [popoverOpen, setPopoverOpen] = useState(false);
+ const [selectedValue, setSelectedValue] = useState();
+ const [inputValue, setInputValue] = useState('');
+ const selectedLabel = fruits.find(f => f.value === selectedValue)?.label;
+ const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase()));
+
+ return (
+ {
+ setPopoverOpen(open);
+ if (open) setInputValue('');
+ }}
+ >
+ {selectedLabel || 'Pick a fruit...'}
+
+
+ {
+ setSelectedValue(value);
+ setPopoverOpen(false);
+ }}
+ >
+
+
+ {filtered.map(f => (
+
+ {f.label}
+
+ ))}
+
+
+
+
+
+ );
+ }
+
+ it('renders options when popover is open', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options).toHaveLength(4);
+ });
+
+ it('wires the popover autocomplete input to the inline listbox', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+
+ const input = screen.getByRole('combobox');
+ const list = document.querySelector('[data-cl-slot="autocomplete-list"]');
+
+ expect(list).toHaveAttribute('id');
+ expect(input).toHaveAttribute('aria-controls', list?.getAttribute('id'));
+ });
+
+ it('navigates options with arrow keys inside popover', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+
+ // Verify options rendered and input has focus
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options.length).toBeGreaterThan(0);
+
+ const input = screen.getByPlaceholderText('Search...');
+ expect(document.activeElement).toBe(input);
+
+ await user.keyboard('{ArrowDown}');
+
+ const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(activeOption).toBeInTheDocument();
+ });
+
+ it('selects option on click and updates trigger', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+ await user.click(screen.getByText('Banana'));
+
+ expect(screen.getByText('Banana')).toBeInTheDocument();
+ expect(screen.getByText('Banana').closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument();
+ });
+
+ it('shows data-cl-selected on previously selected option after reopen', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ // Open and select
+ await user.click(screen.getByText('Pick a fruit...'));
+ await user.click(screen.getByText('Cherry'));
+
+ // Reopen
+ await user.click(screen.getByText('Cherry'));
+
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ const cherryOption = Array.from(options).find(o => o.textContent === 'Cherry');
+ expect(cherryOption).toHaveAttribute('data-cl-selected', '');
+ });
+
+ it('selects option with Enter after arrow navigation inside popover', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+ await user.keyboard('{ArrowDown}{Enter}');
+
+ // Should have selected the first option
+ expect(screen.getByText('Apple').closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument();
+ });
+ });
+
+ describe('scrolling', () => {
+ const manyFruits = [
+ { value: 'apple', label: 'Apple' },
+ { value: 'banana', label: 'Banana' },
+ { value: 'cherry', label: 'Cherry' },
+ { value: 'date', label: 'Date' },
+ { value: 'elderberry', label: 'Elderberry' },
+ { value: 'fig', label: 'Fig' },
+ { value: 'grape', label: 'Grape' },
+ { value: 'honeydew', label: 'Honeydew' },
+ ];
+
+ function ScrollableAutocomplete({ defaultValue }: { defaultValue?: string }) {
+ const [popoverOpen, setPopoverOpen] = useState(false);
+ const [selectedValue, setSelectedValue] = useState(defaultValue);
+ const [inputValue, setInputValue] = useState('');
+ const selectedLabel = manyFruits.find(f => f.value === selectedValue)?.label;
+ const filtered = manyFruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase()));
+
+ return (
+ {
+ setPopoverOpen(open);
+ if (open) setInputValue('');
+ }}
+ >
+ {selectedLabel || 'Pick a fruit...'}
+
+
+ {
+ setSelectedValue(value);
+ setPopoverOpen(false);
+ }}
+ >
+
+
+ {filtered.map(f => (
+
+ {f.label}
+
+ ))}
+
+
+
+
+
+ );
+ }
+
+ it('sets active index on arrow key navigation', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+ await user.keyboard('{ArrowDown}');
+
+ const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(activeOption).toBeInTheDocument();
+ });
+
+ it('selected item has data-cl-active on reopen', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ // Open and select "Grape" (6th item)
+ await user.click(screen.getByText('Pick a fruit...'));
+ await user.click(screen.getByText('Grape'));
+
+ // Reopen — trigger now shows "Grape"
+ await user.click(screen.getByText('Grape').closest('[data-cl-slot="popover-trigger"]')!);
+
+ // The selected option should be active
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ const grapeOption = Array.from(options).find(o => o.textContent === 'Grape');
+ expect(grapeOption).toHaveAttribute('data-cl-active', '');
+ });
+
+ it('selected item is active on open when defaultValue is set', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Grape'));
+
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ const grapeOption = Array.from(options).find(o => o.textContent === 'Grape');
+ expect(grapeOption).toHaveAttribute('data-cl-active', '');
+ });
+ });
+
+ describe('Autocomplete.List inside Popover — edge cases', () => {
+ function AutocompleteInPopoverFull() {
+ const [popoverOpen, setPopoverOpen] = useState(false);
+ const [selectedValue, setSelectedValue] = useState();
+ const [inputValue, setInputValue] = useState('');
+ const selectedLabel = fruits.find(f => f.value === selectedValue)?.label;
+ const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase()));
+
+ return (
+ {
+ setPopoverOpen(open);
+ if (open) setInputValue('');
+ }}
+ >
+ {selectedLabel || 'Pick a fruit...'}
+
+
+ {
+ setSelectedValue(value);
+ setPopoverOpen(false);
+ }}
+ >
+
+
+ {filtered.map(f => (
+
+ {f.label}
+
+ ))}
+
+
+
+
+
+ );
+ }
+
+ it('closes popover on Escape key', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const trigger = screen.getByText('Pick a fruit...');
+ await user.click(trigger);
+ expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]').length).toBeGreaterThan(0);
+
+ await user.keyboard('{Escape}');
+
+ // Popover should close — no options visible
+ expect(document.querySelector('[data-cl-slot="autocomplete-option"]')).not.toBeInTheDocument();
+ expect(document.activeElement).toBe(trigger);
+ });
+
+ it('focuses the input on open with an empty value', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+
+ const input = screen.getByPlaceholderText('Search...') as HTMLInputElement;
+ expect(document.activeElement).toBe(input);
+ expect(input.value).toBe('');
+ });
+
+ it('keeps the input empty on open even when a selected item exists', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+ await user.click(screen.getByText('Cherry'));
+
+ await user.click(screen.getByText('Cherry'));
+
+ const input = screen.getByPlaceholderText('Search...') as HTMLInputElement;
+ expect(document.activeElement).toBe(input);
+ expect(input.value).toBe('');
+ });
+
+ it('marks the previously selected item as active on reopen while focus stays on the input', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+ await user.click(screen.getByText('Banana'));
+
+ await user.click(screen.getByText('Banana'));
+
+ const input = screen.getByPlaceholderText('Search...');
+ const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+
+ expect(document.activeElement).toBe(input);
+ expect(active).toHaveTextContent('Banana');
+ expect(input).toHaveAttribute('aria-activedescendant', active?.getAttribute('id'));
+ });
+
+ it('clears input on reopen after Escape', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+ const input = screen.getByPlaceholderText('Search...');
+ await user.type(input, 'ch');
+
+ // Should be filtered to Cherry only
+ expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(1);
+
+ await user.keyboard('{Escape}');
+
+ // Reopen
+ await user.click(screen.getByText('Pick a fruit...'));
+
+ // Input should be cleared, all options visible
+ const newInput = screen.getByPlaceholderText('Search...');
+ expect((newInput as HTMLInputElement).value).toBe('');
+ expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(4);
+ });
+
+ it('navigates all options with repeated ArrowDown', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+
+ await user.keyboard('{ArrowDown}');
+ let active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(active).toHaveTextContent('Apple');
+
+ await user.keyboard('{ArrowDown}');
+ active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(active).toHaveTextContent('Banana');
+
+ await user.keyboard('{ArrowDown}');
+ active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(active).toHaveTextContent('Cherry');
+
+ await user.keyboard('{ArrowDown}');
+ active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(active).toHaveTextContent('Date');
+ });
+
+ it('loops navigation with ArrowDown past last option', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+
+ // Navigate past all 4 options to loop back
+ await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}');
+ const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(active).toHaveTextContent('Apple');
+ });
+
+ it('navigates with ArrowUp', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+
+ // ArrowUp from no active item should go to last item (loop)
+ await user.keyboard('{ArrowUp}');
+ const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]');
+ expect(active).toHaveTextContent('Date');
+ });
+
+ it('selects with Enter after filtering and navigating', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+
+ const input = screen.getByPlaceholderText('Search...');
+ await user.type(input, 'b');
+
+ // Only Banana should be shown, activeIndex should be 0
+ expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(1);
+
+ await user.keyboard('{Enter}');
+
+ // Popover should close and trigger should show Banana
+ expect(screen.getByText('Banana').closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument();
+ });
+
+ it('selects the highlighted item with Enter, closes the popover, and returns focus to the trigger', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const trigger = screen.getByText('Pick a fruit...');
+ await user.click(trigger);
+ await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
+
+ const updatedTrigger = screen.getByText('Banana');
+ expect(updatedTrigger.closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument();
+ expect(document.querySelector('[data-cl-slot="autocomplete-option"]')).not.toBeInTheDocument();
+ expect(document.activeElement).toBe(updatedTrigger);
+ });
+
+ it('keeps focus on input during keyboard navigation', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+ const input = screen.getByPlaceholderText('Search...');
+
+ await user.keyboard('{ArrowDown}{ArrowDown}');
+
+ expect(document.activeElement).toBe(input);
+ });
+
+ it('can type to filter after arrow navigation', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+
+ // Navigate first
+ await user.keyboard('{ArrowDown}{ArrowDown}');
+
+ // Then type to filter
+ const input = screen.getByPlaceholderText('Search...');
+ await user.type(input, 'd');
+
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ expect(options).toHaveLength(1);
+ expect(options[0]).toHaveTextContent('Date');
+ });
+
+ it('reopen after selection shows all options with empty input', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ // Select Cherry
+ await user.click(screen.getByText('Pick a fruit...'));
+ await user.click(screen.getByText('Cherry'));
+
+ // Reopen
+ await user.click(screen.getByText('Cherry'));
+
+ // Input should be empty, all options should show
+ const input = screen.getByPlaceholderText('Search...');
+ expect((input as HTMLInputElement).value).toBe('');
+ expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(4);
+ });
+
+ it('selected option retains data-cl-selected on reopen', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Pick a fruit...'));
+ await user.click(screen.getByText('Banana'));
+
+ // Reopen
+ await user.click(screen.getByText('Banana'));
+
+ const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]');
+ const bananaOption = Array.from(options).find(o => o.textContent === 'Banana');
+ expect(bananaOption).toHaveAttribute('data-cl-selected', '');
+ });
+ });
+
+ describe('input state attributes', () => {
+ it('input has data-cl-open when list is visible', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ await user.type(input, 'a');
+
+ expect(input).toHaveAttribute('data-cl-open', '');
+ });
+
+ it('input has data-cl-closed when list is hidden', () => {
+ render( );
+
+ const input = screen.getByPlaceholderText('Search fruits...');
+ expect(input).toHaveAttribute('data-cl-closed', '');
+ });
+ });
+
+ describe('accessibility (axe)', () => {
+ it('has no violations when closed', async () => {
+ const { container } = render( );
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it('has no violations when open', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByPlaceholderText('Search fruits...'));
+ await user.keyboard('a');
+
+ expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations();
+ });
+
+ it('has no violations for the inline listbox inside a popover', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Pick a fruit...
+
+
+ Fruit picker
+
+
+
+ {fruits.map(f => (
+
+ {f.label}
+
+ ))}
+
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('combobox'));
+
+ expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations();
+ });
+ });
+});
diff --git a/packages/headless/src/primitives/autocomplete/autocomplete.tsx b/packages/headless/src/primitives/autocomplete/autocomplete.tsx
new file mode 100644
index 00000000000..bd9678e25e9
--- /dev/null
+++ b/packages/headless/src/primitives/autocomplete/autocomplete.tsx
@@ -0,0 +1,585 @@
+'use client';
+
+import {
+ arrow,
+ autoUpdate,
+ type ExtendedRefs,
+ FloatingArrow,
+ type FloatingContext,
+ FloatingFocusManager,
+ FloatingList,
+ FloatingNode,
+ FloatingPortal,
+ FloatingTree,
+ flip,
+ offset,
+ type Placement,
+ type ReferenceType,
+ size,
+ type UseInteractionsReturn,
+ useDismiss,
+ useFloating,
+ useFloatingNodeId,
+ useFloatingParentNodeId,
+ useInteractions,
+ useListItem,
+ useListNavigation,
+ useRole,
+} from '@floating-ui/react';
+import {
+ type CSSProperties,
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import { useControllableState } from '../../hooks/use-controllable-state';
+import { type TransitionProps, useTransition } from '../../hooks/use-transition';
+import { cssVars } from '../../utils/css-vars';
+import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
+
+// ---------------------------------------------------------------------------
+// Context
+// ---------------------------------------------------------------------------
+
+interface AutocompleteContextValue {
+ open: boolean;
+ inputValue: string;
+ selectedValue: string | undefined;
+ floatingContext: FloatingContext;
+ refs: ExtendedRefs;
+ floatingStyles: CSSProperties;
+ placement: Placement;
+ getReferenceProps: UseInteractionsReturn['getReferenceProps'];
+ getFloatingProps: UseInteractionsReturn['getFloatingProps'];
+ getItemProps: UseInteractionsReturn['getItemProps'];
+ activeIndex: number | null;
+ selectedIndex: number | null;
+ elementsRef: React.MutableRefObject>;
+ labelsRef: React.MutableRefObject>;
+ popupRef: React.RefObject;
+ arrowRef: React.MutableRefObject;
+ valuesByIndexRef: React.MutableRefObject>;
+ setInlineMode: React.Dispatch>;
+ handleSelect: (value: string, index: number, label: string) => void;
+ handleInputChange: (value: string) => void;
+ registerSelectedIndex: (index: number, value: string) => void;
+ mounted: boolean;
+ transitionProps: TransitionProps;
+}
+
+const AutocompleteContext = createContext(null);
+
+function useAutocompleteContext() {
+ const ctx = useContext(AutocompleteContext);
+ if (!ctx) {
+ throw new Error('Autocomplete compound components must be used within ');
+ }
+ return ctx;
+}
+
+// ---------------------------------------------------------------------------
+// Autocomplete (root)
+// ---------------------------------------------------------------------------
+
+export interface AutocompleteProps {
+ /** Controlled input text. */
+ inputValue?: string;
+ defaultInputValue?: string;
+ onInputValueChange?: (value: string) => void;
+ /** Controlled selected value. */
+ value?: string;
+ defaultValue?: string;
+ onValueChange?: (value: string) => void;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ placement?: Placement;
+ sideOffset?: number;
+ children: ReactNode;
+}
+
+function AutocompleteInner(props: AutocompleteProps) {
+ const { placement: placementProp = 'bottom-start', sideOffset = 4, children } = props;
+
+ const nodeId = useFloatingNodeId();
+
+ const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);
+
+ const [inputValue, setInputValue] = useControllableState(
+ props.inputValue,
+ props.defaultInputValue ?? '',
+ props.onInputValueChange,
+ );
+
+ const [selectedValue, setSelectedValue] = useControllableState(
+ props.value,
+ props.defaultValue,
+ props.onValueChange as ((value: string | undefined) => void) | undefined,
+ );
+
+ const [activeIndex, setActiveIndex] = useState(null);
+ const [selectedIndex, setSelectedIndex] = useState(null);
+ const [inlineMode, setInlineMode] = useState(false);
+
+ const elementsRef = useRef>([]);
+ const labelsRef = useRef>([]);
+ const arrowRef = useRef(null);
+ const popupRef = useRef(null);
+ const valuesByIndexRef = useRef>(new Map());
+ const registerSelectedIndex = useCallback(
+ (index: number, value: string) => {
+ if (value === selectedValue) {
+ setSelectedIndex(index);
+ }
+ },
+ [selectedValue],
+ );
+
+ // When open transitions to true, sync activeIndex to selectedIndex.
+ // Supplements useListNavigation's built-in sync for timing edge cases.
+ const previousOpenRef = useRef(open);
+ useEffect(() => {
+ if (open && !previousOpenRef.current && selectedIndex != null) {
+ setActiveIndex(selectedIndex);
+ }
+ previousOpenRef.current = open;
+ }, [open, selectedIndex]);
+
+ const {
+ refs,
+ floatingStyles,
+ context: floatingContext,
+ placement,
+ } = useFloating({
+ nodeId,
+ open,
+ onOpenChange: setOpen,
+ placement: placementProp,
+ middleware: [
+ offset(sideOffset),
+ flip({ padding: 5 }),
+ size({
+ apply({ rects, availableHeight, elements }) {
+ // Autocomplete supports two floating targets: Positioner (portal mode)
+ // and List (inline mode). Both call refs.setFloating, but only
+ // Positioner should be auto-sized. Skip when the floating element is
+ // the inline List.
+ if (elements.floating.getAttribute('data-cl-slot') !== 'autocomplete-positioner') return;
+ Object.assign(elements.floating.style, {
+ width: `${rects.reference.width}px`,
+ maxHeight: `${availableHeight}px`,
+ });
+ },
+ padding: 5,
+ }),
+ arrow({ element: arrowRef }),
+ cssVars({ sideOffset }),
+ ],
+ whileElementsMounted: autoUpdate,
+ });
+
+ const { mounted, transitionProps } = useTransition({
+ open,
+ ref: popupRef,
+ });
+
+ const dismiss = useDismiss(floatingContext, {
+ escapeKey: !inlineMode,
+ outsidePress: !inlineMode,
+ bubbles: {
+ escapeKey: inlineMode,
+ outsidePress: inlineMode,
+ },
+ });
+ const role = useRole(floatingContext, { role: 'listbox' });
+ const listNav = useListNavigation(floatingContext, {
+ listRef: elementsRef,
+ activeIndex,
+ selectedIndex,
+ onNavigate: setActiveIndex,
+ virtual: true,
+ loop: true,
+ scrollItemIntoView: true,
+ });
+
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([dismiss, role, listNav]);
+
+ const handleSelect = useCallback(
+ (value: string, index: number, label: string) => {
+ setSelectedValue(value);
+ setSelectedIndex(index);
+ setInputValue(label);
+ setActiveIndex(null);
+ setOpen(false);
+ },
+ [setSelectedValue, setInputValue, setOpen],
+ );
+
+ const handleInputChange = useCallback(
+ (value: string) => {
+ setInputValue(value);
+ if (value) {
+ setOpen(true);
+ setActiveIndex(0);
+ } else {
+ setOpen(false);
+ setActiveIndex(null);
+ }
+ },
+ [setInputValue, setOpen],
+ );
+
+ const contextValue = useMemo(
+ () => ({
+ open,
+ inputValue,
+ selectedValue,
+ floatingContext,
+ refs,
+ floatingStyles,
+ placement,
+ getReferenceProps,
+ getFloatingProps,
+ getItemProps,
+ activeIndex,
+ selectedIndex,
+ elementsRef,
+ labelsRef,
+ popupRef,
+ arrowRef,
+ valuesByIndexRef,
+ setInlineMode,
+ handleSelect,
+ handleInputChange,
+ registerSelectedIndex,
+ mounted,
+ transitionProps,
+ }),
+ [
+ open,
+ inputValue,
+ selectedValue,
+ floatingContext,
+ refs,
+ floatingStyles,
+ placement,
+ getReferenceProps,
+ getFloatingProps,
+ getItemProps,
+ activeIndex,
+ selectedIndex,
+ handleSelect,
+ handleInputChange,
+ registerSelectedIndex,
+ mounted,
+ transitionProps,
+ ],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+function AutocompleteRoot(props: AutocompleteProps) {
+ const parentId = useFloatingParentNodeId();
+
+ if (parentId === null) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
+
+// ---------------------------------------------------------------------------
+// Autocomplete.Input
+// ---------------------------------------------------------------------------
+
+export interface AutocompleteInputProps extends ComponentProps<'input'> {}
+
+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) {
+ handleInputChange(event.target.value);
+ },
+ onKeyDown(event: React.KeyboardEvent) {
+ 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 | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
+ },
+ props: mergeProps<'input'>(defaultProps, otherProps),
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Autocomplete.Portal
+// ---------------------------------------------------------------------------
+
+export interface AutocompletePortalProps {
+ children: ReactNode;
+ root?: HTMLElement | null | React.RefObject;
+}
+
+function AutocompletePortal(props: AutocompletePortalProps) {
+ const { mounted } = useAutocompleteContext();
+ if (!mounted) return null;
+ return {props.children} ;
+}
+
+// ---------------------------------------------------------------------------
+// Autocomplete.Positioner
+// ---------------------------------------------------------------------------
+
+export interface AutocompletePositionerProps extends ComponentProps<'div'> {}
+
+function AutocompletePositioner(props: AutocompletePositionerProps) {
+ const { render, ...otherProps } = props;
+ const { mounted, floatingContext, refs, floatingStyles, placement, getFloatingProps, elementsRef, labelsRef } =
+ useAutocompleteContext();
+
+ const side = placement.split('-')[0];
+
+ const defaultProps = {
+ 'data-cl-slot': 'autocomplete-positioner',
+ 'data-cl-side': side,
+ ref: refs.setFloating,
+ style: floatingStyles,
+ ...(getFloatingProps() as React.ComponentPropsWithRef<'div'>),
+ };
+
+ return (
+
+
+ {renderElement({
+ defaultTagName: 'div',
+ render,
+ enabled: mounted,
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ })}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Autocomplete.Popup
+// ---------------------------------------------------------------------------
+
+export interface AutocompletePopupProps extends ComponentProps<'div'> {}
+
+function AutocompletePopup(props: AutocompletePopupProps) {
+ const { render, ...otherProps } = props;
+ const { popupRef, transitionProps } = useAutocompleteContext();
+
+ const defaultProps = {
+ 'data-cl-slot': 'autocomplete-popup',
+ ref: popupRef,
+ ...transitionProps,
+ };
+
+ return renderElement({
+ defaultTagName: 'div',
+ render,
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Autocomplete.Option
+// ---------------------------------------------------------------------------
+
+export interface AutocompleteOptionProps extends ComponentProps<'div'> {
+ value: string;
+ label?: string;
+ disabled?: boolean;
+}
+
+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;
+
+ // Register value for index-based lookup (used by Enter key on input)
+ 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),
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Autocomplete.Arrow
+// ---------------------------------------------------------------------------
+
+export interface AutocompleteArrowProps extends React.ComponentPropsWithRef {}
+
+function AutocompleteArrowComponent(props: AutocompleteArrowProps) {
+ const { floatingContext, arrowRef, placement } = useAutocompleteContext();
+ const side = placement.split('-')[0];
+
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Autocomplete.List
+// ---------------------------------------------------------------------------
+
+export interface AutocompleteListProps extends ComponentProps<'div'> {}
+
+/**
+ * Inline list wrapper that provides FloatingList context without portal or
+ * positioning. Use this instead of Positioner + Popup when the Autocomplete
+ * is rendered inside another floating element (e.g. a Popover).
+ */
+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 (
+
+ {
+ renderElement({
+ defaultTagName: 'div',
+ render,
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ })!
+ }
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Compound export
+// ---------------------------------------------------------------------------
+
+export const Autocomplete = Object.assign(AutocompleteRoot, {
+ Input: AutocompleteInput,
+ Portal: AutocompletePortal,
+ Positioner: AutocompletePositioner,
+ Popup: AutocompletePopup,
+ List: AutocompleteList,
+ Option: AutocompleteOption,
+ Arrow: AutocompleteArrowComponent,
+});
diff --git a/packages/headless/src/primitives/autocomplete/index.ts b/packages/headless/src/primitives/autocomplete/index.ts
new file mode 100644
index 00000000000..e70ef6e2918
--- /dev/null
+++ b/packages/headless/src/primitives/autocomplete/index.ts
@@ -0,0 +1,11 @@
+export type {
+ AutocompleteArrowProps,
+ AutocompleteInputProps,
+ AutocompleteListProps,
+ AutocompleteOptionProps,
+ AutocompletePopupProps,
+ AutocompletePortalProps,
+ AutocompletePositionerProps,
+ AutocompleteProps,
+} from './autocomplete';
+export { Autocomplete } from './autocomplete';
diff --git a/packages/headless/src/utils/floating-tree.test.tsx b/packages/headless/src/utils/floating-tree.test.tsx
new file mode 100644
index 00000000000..b3fc372d6a2
--- /dev/null
+++ b/packages/headless/src/utils/floating-tree.test.tsx
@@ -0,0 +1,235 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it } from 'vitest';
+import { Dialog } from '../primitives/dialog/dialog';
+import { Popover } from '../primitives/popover/popover';
+import { Select } from '../primitives/select/select';
+import { Tooltip } from '../primitives/tooltip/tooltip';
+
+afterEach(() => {
+ cleanup();
+});
+
+const fruits = [
+ { value: 'apple', label: 'Apple' },
+ { value: 'banana', label: 'Banana' },
+ { value: 'cherry', label: 'Cherry' },
+];
+
+describe('FloatingTree integration', () => {
+ describe('Select inside Popover', () => {
+ function SelectInPopover() {
+ return (
+
+ Open Popover
+
+
+ Pick a fruit
+
+
+
+
+
+
+ {fruits.map(f => (
+
+ {f.label}
+
+ ))}
+
+
+
+
+
+
+ );
+ }
+
+ it('popover stays open when select dropdown opens', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Open Popover'));
+ expect(screen.getByText('Pick a fruit')).toBeInTheDocument();
+
+ await user.click(screen.getByText('Choose...'));
+
+ // Popover should still be open
+ expect(screen.getByText('Pick a fruit')).toBeInTheDocument();
+ // Select dropdown should be visible
+ expect(screen.getByText('Apple')).toBeInTheDocument();
+ });
+
+ it('popover stays open when clicking select option', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Open Popover'));
+ await user.click(screen.getByText('Choose...'));
+ await user.click(screen.getByText('Banana'));
+
+ // Popover should still be open after selecting
+ expect(screen.getByText('Pick a fruit')).toBeInTheDocument();
+ });
+ });
+
+ describe('Select inside Dialog', () => {
+ function SelectInDialog() {
+ return (
+
+ Open Dialog
+
+
+ Select a fruit
+
+
+
+
+
+
+ {fruits.map(f => (
+
+ {f.label}
+
+ ))}
+
+
+
+
+
+
+ );
+ }
+
+ it('dialog stays open when select dropdown opens', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Open Dialog'));
+ expect(screen.getByText('Select a fruit')).toBeInTheDocument();
+
+ await user.click(screen.getByText('Choose...'));
+
+ // Dialog should still be open
+ expect(screen.getByText('Select a fruit')).toBeInTheDocument();
+ // Select options visible
+ expect(screen.getByText('Apple')).toBeInTheDocument();
+ });
+ });
+
+ describe('Popover inside Popover', () => {
+ function NestedPopover() {
+ return (
+
+ Outer
+
+
+ Outer Content
+
+ Inner
+
+
+ Inner Content
+
+
+
+
+
+
+ );
+ }
+
+ it('outer popover stays open when inner popover opens', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Outer'));
+ expect(screen.getByText('Outer Content')).toBeInTheDocument();
+
+ await user.click(screen.getByText('Inner'));
+
+ // Both should be visible
+ expect(screen.getByText('Outer Content')).toBeInTheDocument();
+ expect(screen.getByText('Inner Content')).toBeInTheDocument();
+ });
+ });
+
+ describe('Tooltip inside Popover', () => {
+ function TooltipInPopover() {
+ return (
+
+ Open Popover
+
+
+ Content
+
+ Hover me
+
+ Tooltip text
+
+
+
+
+
+ );
+ }
+
+ it('popover stays open when tooltip trigger is hovered', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Open Popover'));
+ expect(screen.getByText('Content')).toBeInTheDocument();
+
+ await user.hover(screen.getByText('Hover me'));
+
+ // Popover should remain open
+ expect(screen.getByText('Content')).toBeInTheDocument();
+ });
+ });
+
+ describe('Popover inside Dialog', () => {
+ function PopoverInDialog() {
+ return (
+
+ Open Dialog
+
+
+ Dialog Content
+
+ Open Popover
+
+
+ Popover Content
+
+
+
+
+
+
+ );
+ }
+
+ it('dialog stays open when popover opens inside it', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByText('Open Dialog'));
+ expect(screen.getByText('Dialog Content')).toBeInTheDocument();
+
+ await user.click(screen.getByText('Open Popover'));
+
+ // Both should be visible
+ expect(screen.getByText('Dialog Content')).toBeInTheDocument();
+ expect(screen.getByText('Popover Content')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts
index c76bf1067c3..5132bdae65d 100644
--- a/packages/headless/vite.config.ts
+++ b/packages/headless/vite.config.ts
@@ -17,6 +17,7 @@ export default defineConfig({
'primitives/popover/index': 'src/primitives/popover/index.ts',
'primitives/select/index': 'src/primitives/select/index.ts',
'primitives/menu/index': 'src/primitives/menu/index.ts',
+ 'primitives/autocomplete/index': 'src/primitives/autocomplete/index.ts',
'primitives/dialog/index': 'src/primitives/dialog/index.ts',
'utils/index': 'src/utils/index.ts',
'hooks/use-controllable-state': 'src/hooks/use-controllable-state.ts',