diff --git a/.changeset/headless-primitives-batch.md b/.changeset/headless-primitives-batch.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/headless-primitives-batch.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/package.json b/packages/headless/package.json index 96f93e4b4d3..7d45e877bba 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -9,6 +9,34 @@ "import": "./dist/primitives/accordion/index.js", "types": "./dist/primitives/accordion/index.d.ts" }, + "./tabs": { + "import": "./dist/primitives/tabs/index.js", + "types": "./dist/primitives/tabs/index.d.ts" + }, + "./tooltip": { + "import": "./dist/primitives/tooltip/index.js", + "types": "./dist/primitives/tooltip/index.d.ts" + }, + "./popover": { + "import": "./dist/primitives/popover/index.js", + "types": "./dist/primitives/popover/index.d.ts" + }, + "./select": { + "import": "./dist/primitives/select/index.js", + "types": "./dist/primitives/select/index.d.ts" + }, + "./menu": { + "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" + }, + "./collapsible": { + "import": "./dist/primitives/collapsible/index.js", + "types": "./dist/primitives/collapsible/index.d.ts" + }, "./dialog": { "import": "./dist/primitives/dialog/index.js", "types": "./dist/primitives/dialog/index.d.ts" @@ -42,6 +70,7 @@ "happy-dom": "^18.0.1", "react": "catalog:react", "react-dom": "catalog:react", + "rollup-plugin-preserve-directives": "^0.4.0", "typescript": "catalog:repo", "vite": "6.4.2", "vite-plugin-dts": "^4.5.4", diff --git a/packages/headless/src/__tests__/floating-tree.test.tsx b/packages/headless/src/__tests__/floating-tree.test.tsx new file mode 100644 index 00000000000..f0731c67c7d --- /dev/null +++ b/packages/headless/src/__tests__/floating-tree.test.tsx @@ -0,0 +1,236 @@ +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/index'; +import { Popover } from '../primitives/popover/index'; +import { Select } from '../primitives/select/index'; +import { Tooltip } from '../primitives/tooltip/index'; + +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/src/primitives/accordion/accordion-panel.tsx b/packages/headless/src/primitives/accordion/accordion-panel.tsx index 6f065aa946c..23eed6f955a 100644 --- a/packages/headless/src/primitives/accordion/accordion-panel.tsx +++ b/packages/headless/src/primitives/accordion/accordion-panel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useMergeRefs } from '@floating-ui/react'; -import { type RefObject, useLayoutEffect, useRef, useState } from 'react'; +import React, { type RefObject, useLayoutEffect, useRef, useState } from 'react'; import { useTransition } from '../../hooks/use-transition'; import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; @@ -9,90 +9,92 @@ import { useAccordionItemContext } from './accordion-context'; export type AccordionPanelProps = ComponentProps<'div'>; -export function AccordionPanel(props: AccordionPanelProps) { - const { render, ref: consumerRef, ...otherProps } = props; - const { open, triggerId, panelId } = useAccordionItemContext(); - - const panelRef = useRef(null); - // Merge the consumer ref with the internal panelRef so passing a ref does not - // clobber the ref the panel relies on for height measurement. - const combinedRef = useMergeRefs([panelRef, consumerRef]); - const [height, setHeight] = useState(undefined); - - // Track whether open has ever transitioned from true→false. - // Until that happens, skip enter animations (prevents animate-on-load). - const hasBeenClosed = useRef(false); - if (!open) { - hasBeenClosed.current = true; - } - - const { mounted, transitionProps } = useTransition({ - open, - ref: panelRef as RefObject, - }); - - // Measure the content height and keep it in sync via ResizeObserver - useLayoutEffect(() => { - if (!mounted) { - return; +export const AccordionPanel = React.forwardRef( + function AccordionPanel(props, ref) { + const { render, ...otherProps } = props; + const { open, triggerId, panelId } = useAccordionItemContext(); + + const panelRef = useRef(null); + // Merge the consumer ref with the internal panelRef so passing a ref does not + // clobber the ref the panel relies on for height measurement. + const combinedRef = useMergeRefs([panelRef, ref]); + const [height, setHeight] = useState(undefined); + + // Track whether open has ever transitioned from true→false. + // Until that happens, skip enter animations (prevents animate-on-load). + const hasBeenClosed = useRef(false); + if (!open) { + hasBeenClosed.current = true; } - const panel = panelRef.current; - if (!panel) { - return; - } - - // Measure scrollHeight of the panel's content - const measure = () => { - setHeight(panel.scrollHeight); - }; - - measure(); + const { mounted, transitionProps } = useTransition({ + open, + ref: panelRef as RefObject, + }); - const ro = new ResizeObserver(measure); - // Observe children mutations that affect height - ro.observe(panel, { box: 'border-box' }); + // Measure the content height and keep it in sync via ResizeObserver + useLayoutEffect(() => { + if (!mounted) { + return; + } - return () => ro.disconnect(); - }, [mounted]); + const panel = panelRef.current; + if (!panel) { + return; + } - const state = { open }; + // Measure scrollHeight of the panel's content + const measure = () => { + setHeight(panel.scrollHeight); + }; + + measure(); + + const ro = new ResizeObserver(measure); + // Observe children mutations that affect height + ro.observe(panel, { box: 'border-box' }); + + return () => ro.disconnect(); + }, [mounted]); + + const state = { open }; + + // Skip enter animation for panels that have never been closed + const effectiveTransitionProps = !hasBeenClosed.current + ? { + ...transitionProps, + 'data-cl-starting-style': undefined, + style: undefined, + } + : transitionProps; + + const defaultProps: Record = { + 'data-cl-slot': 'accordion-panel', + id: panelId, + role: 'region' as const, + 'aria-labelledby': triggerId, + ref: combinedRef, + ...effectiveTransitionProps, + style: { + '--cl-accordion-panel-height': height != null ? `${height}px` : undefined, + ...effectiveTransitionProps.style, + }, + }; - // Skip enter animation for panels that have never been closed - const effectiveTransitionProps = !hasBeenClosed.current - ? { - ...transitionProps, - 'data-cl-starting-style': undefined, - style: undefined, - } - : transitionProps; - - const defaultProps: Record = { - 'data-cl-slot': 'accordion-panel', - id: panelId, - role: 'region' as const, - 'aria-labelledby': triggerId, - ref: combinedRef, - ...effectiveTransitionProps, - style: { - '--cl-accordion-panel-height': height != null ? `${height}px` : undefined, - ...effectiveTransitionProps.style, - }, - }; - - const merged = mergeProps<'div'>(defaultProps, otherProps); - // The wired id is owned by the primitive: a consumer-supplied id must not - // override it, or the trigger/panel aria pairing would silently break. - merged.id = panelId; - - return renderElement({ - defaultTagName: 'div', - render, - enabled: mounted, - state, - stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), - }, - props: merged, - }); -} + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the trigger/panel aria pairing would silently break. + merged.id = panelId; + + return renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + }, + props: merged, + }); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/README.md b/packages/headless/src/primitives/autocomplete/README.md new file mode 100644 index 00000000000..46e45ce37f3 --- /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` | — | 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-arrow.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-arrow.tsx new file mode 100644 index 00000000000..f1172044e17 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-arrow.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { FloatingArrow } from '@floating-ui/react'; +import React from 'react'; + +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompleteArrowProps = React.ComponentPropsWithRef; + +export function AutocompleteArrow(props: AutocompleteArrowProps) { + const { floatingContext, arrowRef, placement } = useAutocompleteContext(); + const side = placement.split('-')[0]; + + return ( + + ); +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-context.ts b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts new file mode 100644 index 00000000000..3fae09aff96 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts @@ -0,0 +1,46 @@ +import type { + ExtendedRefs, + FloatingContext, + Placement, + ReferenceType, + UseInteractionsReturn, +} from '@floating-ui/react'; +import { createContext, type CSSProperties, useContext } from 'react'; + +import type { TransitionProps } from '../../hooks/use-transition'; + +export 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; +} + +export const AutocompleteContext = createContext(null); + +export function useAutocompleteContext() { + const ctx = useContext(AutocompleteContext); + if (!ctx) { + throw new Error('Autocomplete compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx new file mode 100644 index 00000000000..0604785a422 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompleteInputProps = ComponentProps<'input'>; + +export const AutocompleteInput = React.forwardRef( + function AutocompleteInput(props, ref) { + const { render, ...otherProps } = props; + const { + open, + inputValue, + activeIndex, + refs, + getReferenceProps, + handleInputChange, + handleSelect, + labelsRef, + valuesByIndexRef, + } = useAutocompleteContext(); + + // floating-ui types `setReference` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setReference, ref]); + + const state = { open }; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-input', + ...(getReferenceProps({ + ref: combinedRef, + 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), + }); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx new file mode 100644 index 00000000000..27b4e952c6c --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { FloatingList, useMergeRefs } from '@floating-ui/react'; +import React, { useEffect } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompleteListProps = ComponentProps<'div'>; + +export const AutocompleteList = React.forwardRef( + function AutocompleteList(props, ref) { + const { render, ...otherProps } = props; + const { elementsRef, labelsRef, refs, getFloatingProps, setInlineMode } = useAutocompleteContext(); + + useEffect(() => { + setInlineMode(true); + return () => setInlineMode(false); + }, [setInlineMode]); + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>; + const wiredId = floatingProps.id; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-list', + ref: combinedRef, + ...floatingProps, + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the aria-controls pairing would silently break. + if (wiredId != null) { + merged.id = wiredId; + } + + return ( + + {renderElement({ + defaultTagName: 'div', + render, + props: merged, + })} + + ); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx new file mode 100644 index 00000000000..38ba5d05237 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useListItem, useMergeRefs } from '@floating-ui/react'; +import React, { useEffect, useId } from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompleteOptionProps extends ComponentProps<'div'> { + value: string; + label?: string; + disabled?: boolean; +} + +export const AutocompleteOption = React.forwardRef( + function AutocompleteOption(props, ref) { + 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 combinedRef = useMergeRefs([itemRef, ref]); + + const isSelected = selectedValue === value; + const isActive = activeIndex === index; + + useEffect(() => { + const map = valuesByIndexRef.current; + if (!disabled) { + map.set(index, value); + } + registerSelectedIndex(index, value); + return () => { + map.delete(index); + }; + }, [index, value, disabled, valuesByIndexRef, registerSelectedIndex]); + + const state = { + selected: isSelected, + active: isActive, + disabled: !!disabled, + }; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-option', + id, + ref: combinedRef, + 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'>), + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The option id is owned by the primitive and drives the input's + // aria-activedescendant linkage: a consumer-supplied id must not override it. + merged.id = id; + + 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: merged, + }); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx new file mode 100644 index 00000000000..74f443ec5be --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompletePopupProps = ComponentProps<'div'>; + +export const AutocompletePopup = React.forwardRef( + function AutocompletePopup(props, ref) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = useAutocompleteContext(); + + const combinedRef = useMergeRefs([popupRef, ref]); + + const defaultProps = { + 'data-cl-slot': 'autocomplete-popup', + ref: combinedRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-portal.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-portal.tsx new file mode 100644 index 00000000000..a0d3f780a85 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-portal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FloatingPortal } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompletePortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +export function AutocompletePortal(props: AutocompletePortalProps) { + const { mounted } = useAutocompleteContext(); + if (!mounted) { + return null; + } + return {props.children}; +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx new file mode 100644 index 00000000000..4cb4cc03cfa --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { FloatingFocusManager, FloatingList, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompletePositionerProps = ComponentProps<'div'>; + +export const AutocompletePositioner = React.forwardRef( + function AutocompletePositioner(props, ref) { + const { render, ...otherProps } = props; + const { mounted, floatingContext, refs, floatingStyles, placement, getFloatingProps, elementsRef, labelsRef } = + useAutocompleteContext(); + + const side = placement.split('-')[0]; + + // floating-ui types `setFloating` as a method signature, but at runtime it's + // a stable callback that doesn't use `this`, so the unbound-method check is a + // false positive here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const combinedRef = useMergeRefs([refs.setFloating, ref]); + + const floatingProps = getFloatingProps() as React.ComponentPropsWithRef<'div'>; + const wiredId = floatingProps.id; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-positioner', + 'data-cl-side': side, + ref: combinedRef, + style: floatingStyles, + ...floatingProps, + }; + + const merged = mergeProps<'div'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the aria-controls pairing would silently break. + if (wiredId != null) { + merged.id = wiredId; + } + + return ( + + + {renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: merged, + })} + + + ); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx new file mode 100644 index 00000000000..fd2eba8a36c --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { + arrow, + autoUpdate, + flip, + FloatingNode, + FloatingTree, + offset, + type Placement, + size, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useInteractions, + useListNavigation, + useRole, +} from '@floating-ui/react'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { AutocompleteContext, type AutocompleteContextValue } from './autocomplete-context'; + +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], + ); + + 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 }) { + 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} + + ); +} + +export function AutocompleteRoot(props: AutocompleteProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} 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..0e0ce59d47c --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx @@ -0,0 +1,1272 @@ +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/index'; +import { Autocomplete } from './index'; + +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', () => { + 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', () => { + 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'); + }); + + it('does not select a disabled option via Enter', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render( + + + + + + Apple + + + Banana + + + + , + ); + + const input = screen.getByPlaceholderText('Search...'); + // Typing opens the list and highlights the first option (the disabled "Apple"). + await user.type(input, 'a'); + const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + expect(active).toHaveTextContent('Apple'); + + await user.keyboard('{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + }); + + 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('keeps the input/listbox aria-controls pairing intact when a custom id is passed to the list', () => { + render( + + + + + Apple + + + , + ); + + const input = screen.getByRole('combobox'); + const list = document.querySelector('[data-cl-slot="autocomplete-list"]'); + + // The listbox id is owned by floating-ui: a consumer-supplied id must not + // override it, or the input's aria-controls pairing would silently break. + expect(list).not.toHaveAttribute('id', 'consumer-custom-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 ( + <> +
` | Root wrapper, provides context | +| `Collapsible.Trigger` | `