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..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..7d9e8ee54a8 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-arrow.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { FloatingArrow } from '@floating-ui/react'; +import React from 'react'; +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompleteArrowProps extends React.ComponentPropsWithRef {} + +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..a937717557b --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts @@ -0,0 +1,45 @@ +import type { + ExtendedRefs, + FloatingContext, + Placement, + ReferenceType, + UseInteractionsReturn, +} from '@floating-ui/react'; +import { type CSSProperties, createContext, useContext } from 'react'; +import type { TransitionProps } from '../../hooks/use-transition'; + +export interface AutocompleteContextValue { + open: boolean; + inputValue: string; + selectedValue: string | undefined; + floatingContext: FloatingContext; + refs: ExtendedRefs; + 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..314bdc38c64 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-input.tsx @@ -0,0 +1,56 @@ +'use client'; + +import React from 'react'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompleteInputProps extends ComponentProps<'input'> {} + +export function AutocompleteInput(props: AutocompleteInputProps) { + const { render, ...otherProps } = props; + const { + open, + inputValue, + activeIndex, + refs, + getReferenceProps, + handleInputChange, + handleSelect, + labelsRef, + valuesByIndexRef, + } = useAutocompleteContext(); + + const state = { open }; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-input', + ...(getReferenceProps({ + ref: refs.setReference, + value: inputValue, + 'aria-autocomplete': 'list' as const, + onChange(event: React.ChangeEvent) { + 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..990ccfde988 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { FloatingList } from '@floating-ui/react'; +import React, { useEffect } from 'react'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompleteListProps extends ComponentProps<'div'> {} + +export function AutocompleteList(props: AutocompleteListProps) { + const { render, ...otherProps } = props; + const { elementsRef, labelsRef, refs, getFloatingProps, setInlineMode } = useAutocompleteContext(); + + useEffect(() => { + setInlineMode(true); + return () => setInlineMode(false); + }, [setInlineMode]); + + const defaultProps = { + 'data-cl-slot': 'autocomplete-list', + ref: refs.setFloating, + ...(getFloatingProps() as React.ComponentPropsWithRef<'div'>), + }; + + return ( + + { + renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + })! + } + + ); +} 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..772c6838713 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useListItem } from '@floating-ui/react'; +import React, { useEffect, useId } from 'react'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompleteOptionProps extends ComponentProps<'div'> { + value: string; + label?: string; + disabled?: boolean; +} + +export function AutocompleteOption(props: AutocompleteOptionProps) { + const { render, value, label, disabled, ...otherProps } = props; + const { activeIndex, selectedValue, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } = + useAutocompleteContext(); + + const id = useId(); + const displayLabel = label ?? value; + const { ref: itemRef, index } = useListItem({ label: displayLabel }); + + const isSelected = selectedValue === value; + const isActive = activeIndex === index; + + useEffect(() => { + valuesByIndexRef.current.set(index, value); + registerSelectedIndex(index, value); + return () => { + valuesByIndexRef.current.delete(index); + }; + }, [index, value, valuesByIndexRef, registerSelectedIndex]); + + const state = { + selected: isSelected, + active: isActive, + disabled: !!disabled, + }; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-option', + id, + ref: itemRef, + role: 'option' as const, + 'aria-selected': isActive, + 'aria-disabled': disabled || undefined, + ...(getItemProps({ + onClick() { + if (!disabled) { + handleSelect(value, index, displayLabel); + (refs.domReference.current as HTMLElement | null)?.focus(); + } + }, + }) as React.ComponentPropsWithRef<'div'>), + }; + + return renderElement({ + defaultTagName: 'div', + render, + state, + stateAttributesMapping: { + selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), + active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), + disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + }, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +} 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..3f5f1e0c5ff --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompletePopupProps extends ComponentProps<'div'> {} + +export function AutocompletePopup(props: AutocompletePopupProps) { + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = useAutocompleteContext(); + + const defaultProps = { + 'data-cl-slot': 'autocomplete-popup', + ref: popupRef, + ...transitionProps, + }; + + return renderElement({ + defaultTagName: 'div', + render, + props: mergeProps<'div'>(defaultProps, otherProps), + }); +} 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..f5d007b04dd --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-portal.tsx @@ -0,0 +1,16 @@ +'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..d9f07bc4d51 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { FloatingFocusManager, FloatingList } from '@floating-ui/react'; +import React from 'react'; +import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element'; +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompletePositionerProps extends ComponentProps<'div'> {} + +export function AutocompletePositioner(props: AutocompletePositionerProps) { + const { render, ...otherProps } = props; + const { mounted, floatingContext, refs, floatingStyles, placement, getFloatingProps, elementsRef, labelsRef } = + useAutocompleteContext(); + + const side = placement.split('-')[0]; + + const defaultProps = { + 'data-cl-slot': 'autocomplete-positioner', + 'data-cl-side': side, + ref: refs.setFloating, + style: floatingStyles, + ...(getFloatingProps() as React.ComponentPropsWithRef<'div'>), + }; + + return ( + + + {renderElement({ + defaultTagName: 'div', + render, + enabled: mounted, + props: mergeProps<'div'>(defaultProps, otherProps), + })} + + + ); +} 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..df3391d9ba9 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx @@ -0,0 +1,234 @@ +'use client'; + +import { + arrow, + autoUpdate, + FloatingNode, + FloatingTree, + flip, + 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..698023a084f --- /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/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', 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 ( + <> +