From d9993d74c305a71f6fce129649e727c1112a9802 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 10:46:22 -0400 Subject: [PATCH 01/11] feat(headless): add package foundation with Dialog primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the @clerk/headless package — a zero-style React component library providing accessible headless UI primitives. This first PR establishes the core infrastructure and patterns: - Package scaffold: Vite build, Vitest browser tests (Chromium), TypeScript config - Core utils: renderElement (polymorphic rendering with render prop support), mergeProps (event handler chaining, style/className merging) - Hooks: useControllableState, useTransitionStatus, useAnimationsFinished, useTransition - First primitive: Dialog (modal with focus trapping, scroll lock, portal support) Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cde88c61abe..baab767a479 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21735,6 +21735,19 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + '@vue/language-core@3.1.4(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.23 From 5076469b945f6571495a7e56cbc119f49b732791 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 10:46:22 -0400 Subject: [PATCH 02/11] feat(headless): add package foundation with Dialog primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the @clerk/headless package — a zero-style React component library providing accessible headless UI primitives. This first PR establishes the core infrastructure and patterns: - Package scaffold: Vite build, Vitest browser tests (Chromium), TypeScript config - Core utils: renderElement (polymorphic rendering with render prop support), mergeProps (event handler chaining, style/className merging) - Hooks: useControllableState, useTransitionStatus, useAnimationsFinished, useTransition - First primitive: Dialog (modal with focus trapping, scroll lock, portal support) Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baab767a479..fb205cd10f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21748,6 +21748,19 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + '@vue/language-core@3.1.4(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.23 From 4913ec5484849ff4f3a8857afae6f5f886bc1f86 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 10:46:22 -0400 Subject: [PATCH 03/11] feat(headless): add package foundation with Dialog primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the @clerk/headless package — a zero-style React component library providing accessible headless UI primitives. This first PR establishes the core infrastructure and patterns: - Package scaffold: Vite build, Vitest browser tests (Chromium), TypeScript config - Core utils: renderElement (polymorphic rendering with render prop support), mergeProps (event handler chaining, style/className merging) - Hooks: useControllableState, useTransitionStatus, useAnimationsFinished, useTransition - First primitive: Dialog (modal with focus trapping, scroll lock, portal support) Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb205cd10f2..bb2501c4e12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21761,6 +21761,19 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + '@vue/language-core@3.1.4(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.23 From 1546a6d07b8c0734a681016b525c5be48a72b687 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 10:46:22 -0400 Subject: [PATCH 04/11] feat(headless): add package foundation with Dialog primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the @clerk/headless package — a zero-style React component library providing accessible headless UI primitives. This first PR establishes the core infrastructure and patterns: - Package scaffold: Vite build, Vitest browser tests (Chromium), TypeScript config - Core utils: renderElement (polymorphic rendering with render prop support), mergeProps (event handler chaining, style/className merging) - Hooks: useControllableState, useTransitionStatus, useAnimationsFinished, useTransition - First primitive: Dialog (modal with focus trapping, scroll lock, portal support) Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb2501c4e12..82b89b09fdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21774,6 +21774,19 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + '@vue/language-core@3.1.4(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.23 From 790fea37c355845906664b5900c3cef9b86f67b6 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 10:46:22 -0400 Subject: [PATCH 05/11] feat(headless): add package foundation with Dialog primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the @clerk/headless package — a zero-style React component library providing accessible headless UI primitives. This first PR establishes the core infrastructure and patterns: - Package scaffold: Vite build, Vitest browser tests (Chromium), TypeScript config - Core utils: renderElement (polymorphic rendering with render prop support), mergeProps (event handler chaining, style/className merging) - Hooks: useControllableState, useTransitionStatus, useAnimationsFinished, useTransition - First primitive: Dialog (modal with focus trapping, scroll lock, portal support) Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82b89b09fdd..8ca761e013d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21787,6 +21787,19 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + '@vue/language-core@3.1.4(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.23 From d7667ad107525b1b2479c1b024dd11eaf01230d1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 10:46:22 -0400 Subject: [PATCH 06/11] feat(headless): add package foundation with Dialog primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the @clerk/headless package — a zero-style React component library providing accessible headless UI primitives. This first PR establishes the core infrastructure and patterns: - Package scaffold: Vite build, Vitest browser tests (Chromium), TypeScript config - Core utils: renderElement (polymorphic rendering with render prop support), mergeProps (event handler chaining, style/className merging) - Hooks: useControllableState, useTransitionStatus, useAnimationsFinished, useTransition - First primitive: Dialog (modal with focus trapping, scroll lock, portal support) Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ca761e013d..3d5b0d4771b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21800,6 +21800,19 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + '@vue/language-core@3.1.4(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.23 From b4419c88132eaaacecd1f9c16cf2a625d85e7bbc Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 10:46:22 -0400 Subject: [PATCH 07/11] feat(headless): add package foundation with Dialog primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the @clerk/headless package — a zero-style React component library providing accessible headless UI primitives. This first PR establishes the core infrastructure and patterns: - Package scaffold: Vite build, Vitest browser tests (Chromium), TypeScript config - Core utils: renderElement (polymorphic rendering with render prop support), mergeProps (event handler chaining, style/className merging) - Hooks: useControllableState, useTransitionStatus, useAnimationsFinished, useTransition - First primitive: Dialog (modal with focus trapping, scroll lock, portal support) Co-Authored-By: Claude Opus 4.6 --- pnpm-lock.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d5b0d4771b..597037d129e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21813,6 +21813,19 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.33 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.33 + alien-signals: 0.4.14 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + '@vue/language-core@3.1.4(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.23 From fe35b2a6a090cabe8fc297afbdc139c4599454aa Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 5 May 2026 11:38:41 -0400 Subject: [PATCH 08/11] 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 ffd9e038a3a..a116c370d8a 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -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" 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 ( + <> +
` | Floating positioned container | @@ -84,7 +84,7 @@ In this pattern, keep the outer `Popover` or `Dialog` as the source of truth for ## Props -### `Autocomplete` (root) +### `Autocomplete.Root` | Prop | Type | Default | Description | | -------------------- | ------------------------- | ---------------- | ------------------------------------- | From c4305e91c4e7a6499087cb87a8500b6c01939a5c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 3 Jun 2026 13:23:32 -0400 Subject: [PATCH 11/11] fix(headless): update floating-tree test to use Dialog.Root after split --- packages/headless/src/utils/floating-tree.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/headless/src/utils/floating-tree.test.tsx b/packages/headless/src/utils/floating-tree.test.tsx index 5954207428b..9a44278c450 100644 --- a/packages/headless/src/utils/floating-tree.test.tsx +++ b/packages/headless/src/utils/floating-tree.test.tsx @@ -80,7 +80,7 @@ describe('FloatingTree integration', () => { describe('Select inside Dialog', () => { function SelectInDialog() { return ( - + Open Dialog @@ -105,7 +105,7 @@ describe('FloatingTree integration', () => { - + ); } @@ -199,7 +199,7 @@ describe('FloatingTree integration', () => { describe('Popover inside Dialog', () => { function PopoverInDialog() { return ( - + Open Dialog @@ -214,7 +214,7 @@ describe('FloatingTree integration', () => { - + ); }