From 6bea8ec91393522bf64b854aae90aed9bd2f6032 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 11:38:41 -0400 Subject: [PATCH] feat(headless): add Autocomplete primitive and floating-tree integration test --- packages/headless/package.json | 4 + .../src/primitives/autocomplete/README.md | 152 +++ .../autocomplete/autocomplete.test.tsx | 1155 +++++++++++++++++ .../primitives/autocomplete/autocomplete.tsx | 585 +++++++++ .../src/primitives/autocomplete/index.ts | 11 + .../headless/src/utils/floating-tree.test.tsx | 235 ++++ packages/headless/vite.config.ts | 1 + 7 files changed, 2143 insertions(+) create mode 100644 packages/headless/src/primitives/autocomplete/README.md create mode 100644 packages/headless/src/primitives/autocomplete/autocomplete.test.tsx create mode 100644 packages/headless/src/primitives/autocomplete/autocomplete.tsx create mode 100644 packages/headless/src/primitives/autocomplete/index.ts create mode 100644 packages/headless/src/utils/floating-tree.test.tsx 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 ( + <> +