From 5eb6c20eb5c1919ad930f059c20006e00a971dd1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 18:28:44 -0600 Subject: [PATCH 1/7] feat(headless): add combobox primitive --- .changeset/tidy-combobox-primitive.md | 2 + packages/headless/package.json | 68 ++-- .../autocomplete/autocomplete-collection.tsx | 25 ++ .../autocomplete/autocomplete-context.ts | 10 +- .../autocomplete-option-context.ts | 3 + .../autocomplete-option-indicator.tsx | 24 ++ .../autocomplete/autocomplete-option.tsx | 22 +- .../autocomplete/autocomplete-popup.tsx | 7 +- .../autocomplete/autocomplete-positioner.tsx | 15 +- .../autocomplete/autocomplete-root.tsx | 119 ++++++- .../autocomplete/autocomplete-trigger.tsx | 42 +++ .../autocomplete/autocomplete.test.tsx | 131 +++++++- .../src/primitives/autocomplete/index.ts | 2 + .../src/primitives/autocomplete/parts.ts | 6 + .../src/primitives/combobox/README.md | 38 +++ .../src/primitives/combobox/combobox-root.tsx | 14 + .../src/primitives/combobox/combobox.test.tsx | 307 ++++++++++++++++++ .../headless/src/primitives/combobox/index.ts | 2 + .../headless/src/primitives/combobox/parts.ts | 13 + packages/headless/vite.config.ts | 1 + 20 files changed, 785 insertions(+), 66 deletions(-) create mode 100644 .changeset/tidy-combobox-primitive.md create mode 100644 packages/headless/src/primitives/autocomplete/autocomplete-collection.tsx create mode 100644 packages/headless/src/primitives/autocomplete/autocomplete-option-context.ts create mode 100644 packages/headless/src/primitives/autocomplete/autocomplete-option-indicator.tsx create mode 100644 packages/headless/src/primitives/autocomplete/autocomplete-trigger.tsx create mode 100644 packages/headless/src/primitives/combobox/README.md create mode 100644 packages/headless/src/primitives/combobox/combobox-root.tsx create mode 100644 packages/headless/src/primitives/combobox/combobox.test.tsx create mode 100644 packages/headless/src/primitives/combobox/index.ts create mode 100644 packages/headless/src/primitives/combobox/parts.ts diff --git a/.changeset/tidy-combobox-primitive.md b/.changeset/tidy-combobox-primitive.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tidy-combobox-primitive.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/package.json b/packages/headless/package.json index 62dd0163c5b..d55b043f4aa 100644 --- a/packages/headless/package.json +++ b/packages/headless/package.json @@ -6,68 +6,72 @@ "type": "module", "exports": { "./accordion": { - "import": "./dist/primitives/accordion/index.js", - "types": "./dist/primitives/accordion/index.d.ts" + "types": "./dist/primitives/accordion/index.d.ts", + "import": "./dist/primitives/accordion/index.js" }, "./button": { - "import": "./dist/primitives/button/index.js", - "types": "./dist/primitives/button/index.d.ts" + "types": "./dist/primitives/button/index.d.ts", + "import": "./dist/primitives/button/index.js" }, "./tabs": { - "import": "./dist/primitives/tabs/index.js", - "types": "./dist/primitives/tabs/index.d.ts" + "types": "./dist/primitives/tabs/index.d.ts", + "import": "./dist/primitives/tabs/index.js" }, "./tooltip": { - "import": "./dist/primitives/tooltip/index.js", - "types": "./dist/primitives/tooltip/index.d.ts" + "types": "./dist/primitives/tooltip/index.d.ts", + "import": "./dist/primitives/tooltip/index.js" }, "./popover": { - "import": "./dist/primitives/popover/index.js", - "types": "./dist/primitives/popover/index.d.ts" + "types": "./dist/primitives/popover/index.d.ts", + "import": "./dist/primitives/popover/index.js" }, "./select": { - "import": "./dist/primitives/select/index.js", - "types": "./dist/primitives/select/index.d.ts" + "types": "./dist/primitives/select/index.d.ts", + "import": "./dist/primitives/select/index.js" }, "./menu": { - "import": "./dist/primitives/menu/index.js", - "types": "./dist/primitives/menu/index.d.ts" + "types": "./dist/primitives/menu/index.d.ts", + "import": "./dist/primitives/menu/index.js" + }, + "./combobox": { + "types": "./dist/primitives/combobox/index.d.ts", + "import": "./dist/primitives/combobox/index.js" }, "./autocomplete": { - "import": "./dist/primitives/autocomplete/index.js", - "types": "./dist/primitives/autocomplete/index.d.ts" + "types": "./dist/primitives/autocomplete/index.d.ts", + "import": "./dist/primitives/autocomplete/index.js" }, "./collapsible": { - "import": "./dist/primitives/collapsible/index.js", - "types": "./dist/primitives/collapsible/index.d.ts" + "types": "./dist/primitives/collapsible/index.d.ts", + "import": "./dist/primitives/collapsible/index.js" }, "./dialog": { - "import": "./dist/primitives/dialog/index.js", - "types": "./dist/primitives/dialog/index.d.ts" + "types": "./dist/primitives/dialog/index.d.ts", + "import": "./dist/primitives/dialog/index.js" }, "./drawer": { - "import": "./dist/primitives/drawer/index.js", - "types": "./dist/primitives/drawer/index.d.ts" + "types": "./dist/primitives/drawer/index.d.ts", + "import": "./dist/primitives/drawer/index.js" }, "./file-upload": { - "import": "./dist/primitives/file-upload/index.js", - "types": "./dist/primitives/file-upload/index.d.ts" + "types": "./dist/primitives/file-upload/index.d.ts", + "import": "./dist/primitives/file-upload/index.js" }, "./flow": { - "import": "./dist/primitives/flow/index.js", - "types": "./dist/primitives/flow/index.d.ts" + "types": "./dist/primitives/flow/index.d.ts", + "import": "./dist/primitives/flow/index.js" }, "./otp": { - "import": "./dist/primitives/otp/index.js", - "types": "./dist/primitives/otp/index.d.ts" + "types": "./dist/primitives/otp/index.d.ts", + "import": "./dist/primitives/otp/index.js" }, "./hooks": { - "import": "./dist/hooks/index.js", - "types": "./dist/hooks/index.d.ts" + "types": "./dist/hooks/index.d.ts", + "import": "./dist/hooks/index.js" }, "./utils": { - "import": "./dist/utils/index.js", - "types": "./dist/utils/index.d.ts" + "types": "./dist/utils/index.d.ts", + "import": "./dist/utils/index.js" } }, "scripts": { diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-collection.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-collection.tsx new file mode 100644 index 00000000000..0c7175c6df1 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-collection.tsx @@ -0,0 +1,25 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { useAutocompleteContext } from './autocomplete-context'; + +export interface AutocompleteCollectionProps { + items: readonly Item[]; + itemToStringLabel: (item: Item) => string; + children: (item: Item) => ReactNode; + empty?: ReactNode; +} + +/** Filters items using the search query rather than the selected label. */ +export function AutocompleteCollection({ + items, + itemToStringLabel, + children, + empty, +}: AutocompleteCollectionProps) { + const { filterQuery } = useAutocompleteContext(); + const query = filterQuery.trim().toLocaleLowerCase(); + const filtered = query ? items.filter(item => itemToStringLabel(item).toLocaleLowerCase().includes(query)) : items; + return <>{filtered.length ? filtered.map(children) : empty}; +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-context.ts b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts index 3fae09aff96..6c77534e183 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-context.ts +++ b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts @@ -10,9 +10,11 @@ import { createContext, type CSSProperties, useContext } from 'react'; import type { TransitionProps } from '../../hooks/use-transition'; export interface AutocompleteContextValue { + allowsCustomValue: boolean; open: boolean; inputValue: string; - selectedValue: string | undefined; + filterQuery: string; + selectedValue: string | null; floatingContext: FloatingContext; refs: ExtendedRefs; floatingStyles: CSSProperties; @@ -25,12 +27,16 @@ export interface AutocompleteContextValue { elementsRef: React.MutableRefObject>; labelsRef: React.MutableRefObject>; popupRef: React.RefObject; + triggerRef: React.MutableRefObject; 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; + setOpen: (open: boolean) => void; + focusInput: () => void; + popupId: string | undefined; + registerSelectedIndex: (index: number, value: string, label: string) => void; mounted: boolean; transitionProps: TransitionProps; } diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option-context.ts b/packages/headless/src/primitives/autocomplete/autocomplete-option-context.ts new file mode 100644 index 00000000000..31f022fef67 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-option-context.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const AutocompleteOptionContext = createContext(null); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option-indicator.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-option-indicator.tsx new file mode 100644 index 00000000000..75b08b58a79 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-option-indicator.tsx @@ -0,0 +1,24 @@ +'use client'; + +import React, { useContext } from 'react'; + +import { type ComponentProps, mergeProps, useRender } from '../../utils'; +import { AutocompleteOptionContext } from './autocomplete-option-context'; + +export type AutocompleteOptionIndicatorProps = ComponentProps<'span'>; + +export const AutocompleteOptionIndicator = React.forwardRef( + function AutocompleteOptionIndicator({ render, ...props }, ref) { + const selected = useContext(AutocompleteOptionContext); + if (selected === null) { + throw new Error('Autocomplete.OptionIndicator must be used within Autocomplete.Option'); + } + return useRender({ + defaultTagName: 'span', + render, + ref, + enabled: selected, + props: mergeProps<'span'>({ 'aria-hidden': true }, props), + }); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx index e1a7fdda31f..2d6fe4218cd 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx @@ -5,6 +5,7 @@ import React, { useEffect, useId } from 'react'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useAutocompleteContext } from './autocomplete-context'; +import { AutocompleteOptionContext } from './autocomplete-option-context'; export interface AutocompleteOptionProps extends ComponentProps<'div'> { value: string; @@ -15,8 +16,16 @@ export interface AutocompleteOptionProps extends ComponentProps<'div'> { 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 { + allowsCustomValue, + activeIndex, + selectedValue, + getItemProps, + handleSelect, + valuesByIndexRef, + registerSelectedIndex, + refs, + } = useAutocompleteContext(); const id = useId(); const displayLabel = label ?? value; @@ -30,11 +39,11 @@ export const AutocompleteOption = React.forwardRef { map.delete(index); }; - }, [index, value, disabled, valuesByIndexRef, registerSelectedIndex]); + }, [index, value, displayLabel, disabled, valuesByIndexRef, registerSelectedIndex]); const state = { selected: isSelected, @@ -45,7 +54,7 @@ export const AutocompleteOption = React.forwardRef; @@ -66,7 +75,7 @@ export const AutocompleteOption = React.forwardRef{element}; }, ); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx index 06d6a582fd3..b13b4b668eb 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx @@ -2,18 +2,19 @@ import React from 'react'; -import { type ComponentProps, mergeProps, useRender } from '../../utils'; +import { type ComponentProps, Freeze, mergeProps, useRender } from '../../utils'; 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 { render, children, ...otherProps } = props; + const { open, popupRef, transitionProps } = useAutocompleteContext(); const defaultProps = { ...transitionProps, + children: {children}, }; return useRender({ diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx index 326ccdb654c..e4938707415 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx @@ -6,14 +6,25 @@ import React from 'react'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useAutocompleteContext } from './autocomplete-context'; -export type AutocompletePositionerProps = ComponentProps<'div'>; +export interface AutocompletePositionerProps extends ComponentProps<'div'> { + /** Element used for popup positioning. Defaults to the input. */ + anchor?: HTMLElement | null; +} export const AutocompletePositioner = React.forwardRef( function AutocompletePositioner(props, ref) { - const { render, ...otherProps } = props; + const { anchor, render, ...otherProps } = props; const { mounted, floatingContext, refs, floatingStyles, placement, getFloatingProps, elementsRef, labelsRef } = useAutocompleteContext(); + React.useLayoutEffect(() => { + if (!anchor) { + return; + } + refs.setPositionReference(anchor); + return () => refs.setPositionReference(refs.domReference.current); + }, [anchor, refs]); + const side = placement.split('-')[0]; const floatingProps = getFloatingProps(); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx index daa6f5581d5..9c1774b0b98 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx @@ -41,12 +41,20 @@ export interface AutocompleteProps { children: ReactNode; } -function AutocompleteInner(props: AutocompleteProps) { - const { placement: placementProp = 'bottom-start', sideOffset = 4, children } = props; +export interface ComboboxRootInternalProps extends Omit { + allowsCustomValue?: boolean; + /** Separate search input inside another surface. Bind open to that surface. */ + inline?: boolean; + value?: string | null; + defaultValue?: string | null; + onValueChange?: (value: string | null) => void; +} +function AutocompleteInner(props: ComboboxRootInternalProps) { + const { placement: placementProp = 'bottom-start', sideOffset = 4, allowsCustomValue = true, children } = props; const nodeId = useFloatingNodeId(); - const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + const [open, setOpenState] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); const [inputValue, setInputValue] = useControllableState( props.inputValue, @@ -54,23 +62,59 @@ function AutocompleteInner(props: AutocompleteProps) { props.onInputValueChange, ); - const [selectedValue, setSelectedValue] = useControllableState( + const [selectedValue, setSelectedValue] = useControllableState( props.value, - props.defaultValue, - props.onValueChange as ((value: string | undefined) => void) | undefined, + props.defaultValue ?? null, + props.onValueChange, ); const [activeIndex, setActiveIndex] = useState(null); const [selectedIndex, setSelectedIndex] = useState(null); const [inlineMode, setInlineMode] = useState(false); + const [queryChanged, setQueryChanged] = useState(false); + const initialValue = props.value ?? props.defaultValue; + const labelsByValueRef = useRef( + new Map( + initialValue != null && props.defaultInputValue !== undefined ? [[initialValue, props.defaultInputValue]] : [], + ), + ); + + const setOpen = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + setQueryChanged(false); + } + setOpenState(nextOpen); + }, + [setOpenState], + ); + + useEffect(() => { + if (!open) { + setQueryChanged(false); + } + if (allowsCustomValue || open) { + return; + } + const label = + props.inline || selectedValue === null ? '' : (labelsByValueRef.current.get(selectedValue) ?? selectedValue); + if (inputValue !== label) { + setInputValue(label); + } + }, [allowsCustomValue, open, props.inline, selectedValue, selectedIndex, inputValue, setInputValue]); + + const selectedLabel = selectedValue === null ? '' : (labelsByValueRef.current.get(selectedValue) ?? selectedValue); + const filterQuery = !allowsCustomValue && !queryChanged && inputValue === selectedLabel ? '' : inputValue; const elementsRef = useRef>([]); const labelsRef = useRef>([]); const arrowRef = useRef(null); const popupRef = useRef(null); + const triggerRef = useRef(null); const valuesByIndexRef = useRef>(new Map()); const registerSelectedIndex = useCallback( - (index: number, value: string) => { + (index: number, value: string, label: string) => { + labelsByValueRef.current.set(value, label); if (value === selectedValue) { setSelectedIndex(index); } @@ -125,7 +169,13 @@ function AutocompleteInner(props: AutocompleteProps) { const dismiss = useDismiss(floatingContext, { escapeKey: !inlineMode, - outsidePress: !inlineMode, + outsidePress(event) { + if (inlineMode) { + return false; + } + const target = event.target; + return !(target instanceof Node && triggerRef.current?.contains(target)); + }, bubbles: { escapeKey: inlineMode, outsidePress: inlineMode, @@ -143,36 +193,53 @@ function AutocompleteInner(props: AutocompleteProps) { }); const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([dismiss, role, listNav]); + const referenceProps = getReferenceProps(); + const popupId = typeof referenceProps['aria-controls'] === 'string' ? referenceProps['aria-controls'] : undefined; + + const focusInput = useCallback(() => { + const input = refs.domReference.current; + if (input instanceof HTMLElement) { + input.focus(); + } + }, [refs.domReference]); const handleSelect = useCallback( (value: string, index: number, label: string) => { + labelsByValueRef.current.set(value, label); setSelectedValue(value); setSelectedIndex(index); - setInputValue(label); + setInputValue(props.inline ? '' : label); setActiveIndex(null); setOpen(false); }, - [setSelectedValue, setInputValue, setOpen], + [props.inline, setSelectedValue, setInputValue, setOpen], ); const handleInputChange = useCallback( (value: string) => { + setQueryChanged(true); setInputValue(value); - if (value) { - setOpen(true); + if (value === '' && !allowsCustomValue && !props.inline) { + setSelectedValue(null); + setSelectedIndex(null); + } + if (value || !allowsCustomValue) { + setOpenState(true); setActiveIndex(0); } else { - setOpen(false); + setOpenState(false); setActiveIndex(null); } }, - [setInputValue, setOpen], + [allowsCustomValue, props.inline, setInputValue, setOpenState, setSelectedValue], ); const contextValue = useMemo( () => ({ + allowsCustomValue, open, inputValue, + filterQuery, selectedValue, floatingContext, refs, @@ -186,18 +253,24 @@ function AutocompleteInner(props: AutocompleteProps) { elementsRef, labelsRef, popupRef, + triggerRef, arrowRef, valuesByIndexRef, setInlineMode, handleSelect, handleInputChange, + setOpen, + focusInput, + popupId, registerSelectedIndex, mounted, transitionProps, }), [ + allowsCustomValue, open, inputValue, + filterQuery, selectedValue, floatingContext, refs, @@ -210,6 +283,9 @@ function AutocompleteInner(props: AutocompleteProps) { selectedIndex, handleSelect, handleInputChange, + setOpen, + focusInput, + popupId, registerSelectedIndex, mounted, transitionProps, @@ -223,7 +299,7 @@ function AutocompleteInner(props: AutocompleteProps) { ); } -export function AutocompleteRoot(props: AutocompleteProps) { +export function ComboboxRootInternal(props: ComboboxRootInternalProps) { const parentId = useFloatingParentNodeId(); if (parentId === null) { @@ -236,3 +312,16 @@ export function AutocompleteRoot(props: AutocompleteProps) { return ; } + +export function AutocompleteRoot({ onValueChange, ...props }: AutocompleteProps) { + return ( + { + if (value !== null) { + onValueChange?.(value); + } + }} + /> + ); +} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-trigger.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-trigger.tsx new file mode 100644 index 00000000000..d1db84cd724 --- /dev/null +++ b/packages/headless/src/primitives/autocomplete/autocomplete-trigger.tsx @@ -0,0 +1,42 @@ +'use client'; + +import React from 'react'; + +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; +import { useAutocompleteContext } from './autocomplete-context'; + +export type AutocompleteTriggerProps = ComponentProps<'button'>; + +export const AutocompleteTrigger = React.forwardRef( + function AutocompleteTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, setOpen, focusInput, popupId, triggerRef } = useAutocompleteContext(); + const state = { open }; + + const defaultProps = { + type: 'button', + tabIndex: -1, + 'aria-controls': popupId, + 'aria-expanded': open, + 'aria-haspopup': 'listbox', + onPointerDown(event: React.PointerEvent) { + event.preventDefault(); + }, + onClick() { + setOpen(!open); + focusInput(); + }, + } satisfies DefaultProps<'button'>; + + return useRender({ + defaultTagName: 'button', + render, + ref: [triggerRef, ref], + state, + stateAttributesMapping: { + open: (value: boolean): Record | null => (value ? { 'data-open': '' } : { 'data-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx index 18066c37bd7..2d80af3fc67 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -18,7 +18,7 @@ const fruits = [ function FilteredAutocomplete( props: { - onValueChange?: (value: string) => void; + onValueChange?: (value: string | undefined) => void; onInputValueChange?: (value: string) => void; defaultInputValue?: string; } = {}, @@ -77,7 +77,76 @@ function StaticAutocomplete(props: Partial { + it('measures a custom popup anchor while keeping keyboard focus on the input', async () => { + const user = userEvent.setup(); + const anchor = document.createElement('div'); + document.body.append(anchor); + const measureAnchor = vi.spyOn(anchor, 'getBoundingClientRect'); + + function AnchoredAutocomplete({ anchor }: { anchor?: HTMLElement }) { + return ( + + + + + Apple + + + + ); + } + + const { rerender } = render(); + const input = screen.getByRole('combobox', { name: 'Fruit' }); + await user.type(input, 'a'); + + await waitFor(() => expect(measureAnchor).toHaveBeenCalled()); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveFocus(); + expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id); + + const measureInput = vi.spyOn(input, 'getBoundingClientRect'); + rerender(); + await waitFor(() => expect(measureInput).toHaveBeenCalled()); + expect(input).toHaveFocus(); + anchor.remove(); + }); + describe('open/close', () => { + it('toggles from a popup button while keeping focus on the input', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + + + + + + Apple + + + , + ); + + const input = screen.getByRole('combobox'); + const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); + expect(trigger).toHaveAttribute('tabindex', '-1'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + await user.click(trigger); + + const listbox = screen.getByRole('listbox'); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(trigger).toHaveAttribute('aria-controls', listbox.id); + expect(input).toHaveFocus(); + + await user.click(trigger); + + expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(input).toHaveFocus(); + }); + it('opens when user types', async () => { const user = userEvent.setup(); render(); @@ -303,6 +372,54 @@ describe('Autocomplete', () => { }); describe('animation lifecycle', () => { + it('holds options during exit and shows updated options when reopened', () => { + function Fixture({ open, label }: { open: boolean; label: string }) { + return ( + + + + + {label} + + + + ); + } + + const { rerender } = render( + , + ); + const popup = screen.getByTestId('autocomplete-popup'); + Object.defineProperty(popup, 'getAnimations', { + value: () => [{ finished: new Promise(() => {}) }], + }); + + rerender( + , + ); + + expect(popup).toHaveAttribute('data-closed', ''); + expect(popup).toHaveTextContent('Apple'); + expect(popup).not.toHaveTextContent('Banana'); + + rerender( + , + ); + + expect(popup).toHaveAttribute('data-open', ''); + expect(screen.getByRole('option', { name: 'Banana' })).toBeInTheDocument(); + expect(popup).not.toHaveTextContent('Apple'); + }); + it('positioner is not rendered when closed', () => { render(); expect(document.querySelector('[data-testid="autocomplete-positioner"]')).not.toBeInTheDocument(); @@ -436,7 +553,9 @@ describe('Autocomplete', () => { }); describe('Autocomplete.List (inline mode)', () => { - function InlineAutocomplete(props: { value?: string; onValueChange?: (value: string) => void } = {}) { + function InlineAutocomplete( + props: { value?: string | undefined; onValueChange?: (value: string | undefined) => void } = {}, + ) { const [inputValue, setInputValue] = useState(''); const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); @@ -596,7 +715,7 @@ describe('Autocomplete', () => { it('shows selected state after selecting then remounting', async () => { function TestHarness() { const [mounted, setMounted] = useState(true); - const [value, setValue] = useState(); + const [value, setValue] = useState(undefined); return ( <> @@ -636,7 +755,7 @@ describe('Autocomplete', () => { describe('Autocomplete.List inside Popover', () => { function AutocompleteInPopover() { const [popoverOpen, setPopoverOpen] = useState(false); - const [selectedValue, setSelectedValue] = useState(); + const [selectedValue, setSelectedValue] = useState(undefined); const [inputValue, setInputValue] = useState(''); const selectedLabel = fruits.find(f => f.value === selectedValue)?.label; const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); @@ -875,7 +994,7 @@ describe('Autocomplete', () => { describe('Autocomplete.List inside Popover — edge cases', () => { function AutocompleteInPopoverFull() { const [popoverOpen, setPopoverOpen] = useState(false); - const [selectedValue, setSelectedValue] = useState(); + const [selectedValue, setSelectedValue] = useState(undefined); const [inputValue, setInputValue] = useState(''); const selectedLabel = fruits.find(f => f.value === selectedValue)?.label; const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); diff --git a/packages/headless/src/primitives/autocomplete/index.ts b/packages/headless/src/primitives/autocomplete/index.ts index e282f71bbda..157dd7d73c1 100644 --- a/packages/headless/src/primitives/autocomplete/index.ts +++ b/packages/headless/src/primitives/autocomplete/index.ts @@ -1,9 +1,11 @@ export * as Autocomplete from './parts'; export type { + AutocompleteCollectionProps, AutocompleteArrowProps, AutocompleteInputProps, AutocompleteListProps, AutocompleteOptionProps, + AutocompleteOptionIndicatorProps, AutocompletePopupProps, AutocompletePortalProps, AutocompletePositionerProps, diff --git a/packages/headless/src/primitives/autocomplete/parts.ts b/packages/headless/src/primitives/autocomplete/parts.ts index 8d184c6c6d3..665fd3a730d 100644 --- a/packages/headless/src/primitives/autocomplete/parts.ts +++ b/packages/headless/src/primitives/autocomplete/parts.ts @@ -1,8 +1,14 @@ export { type AutocompleteProps, AutocompleteRoot as Root } from './autocomplete-root'; +export { type AutocompleteCollectionProps, AutocompleteCollection as Collection } from './autocomplete-collection'; export { type AutocompleteInputProps, AutocompleteInput as Input } from './autocomplete-input'; +export { type AutocompleteTriggerProps, AutocompleteTrigger as Trigger } from './autocomplete-trigger'; export { type AutocompletePortalProps, AutocompletePortal as Portal } from './autocomplete-portal'; export { type AutocompletePositionerProps, AutocompletePositioner as Positioner } from './autocomplete-positioner'; export { type AutocompletePopupProps, AutocompletePopup as Popup } from './autocomplete-popup'; export { type AutocompleteListProps, AutocompleteList as List } from './autocomplete-list'; export { type AutocompleteOptionProps, AutocompleteOption as Option } from './autocomplete-option'; +export { + type AutocompleteOptionIndicatorProps, + AutocompleteOptionIndicator as OptionIndicator, +} from './autocomplete-option-indicator'; export { type AutocompleteArrowProps, AutocompleteArrow as Arrow } from './autocomplete-arrow'; diff --git a/packages/headless/src/primitives/combobox/README.md b/packages/headless/src/primitives/combobox/README.md new file mode 100644 index 00000000000..9c00a340d13 --- /dev/null +++ b/packages/headless/src/primitives/combobox/README.md @@ -0,0 +1,38 @@ +# Combobox + +A headless single-selection input. Typing searches options; choosing one remembers its value. + +```tsx +import { Combobox } from '@clerk/headless/combobox'; + + + + + + + item} + > + {item => ( + + {item} + ✓ + + )} + + + +; +``` + +`value`, `defaultValue`, and `onValueChange` control the selected option. Clearing the input clears selection with `null`. `inputValue`, `defaultInputValue`, and `onInputValueChange` control search text separately. + +Dismissal restores the selected label. Reopening shows all options until typing starts. Hover and keyboard highlighting do not change selection. + +For search inside another popup, use `inline` with `List` and bind `open` to the outer popup. Closing clears search without clearing selection. Supply `defaultInputValue` when an initial selection's label differs from its value. + +Combobox reuses Autocomplete's rendering and keyboard internals. Autocomplete's existing public props and free-text behavior remain unchanged. Mosaic adds styling separately. diff --git a/packages/headless/src/primitives/combobox/combobox-root.tsx b/packages/headless/src/primitives/combobox/combobox-root.tsx new file mode 100644 index 00000000000..88a8293aca7 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-root.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { ComboboxRootInternal, type ComboboxRootInternalProps } from '../autocomplete/autocomplete-root'; + +export type ComboboxProps = Omit; + +export function ComboboxRoot(props: ComboboxProps) { + return ( + + ); +} diff --git a/packages/headless/src/primitives/combobox/combobox.test.tsx b/packages/headless/src/primitives/combobox/combobox.test.tsx new file mode 100644 index 00000000000..6681f59d118 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox.test.tsx @@ -0,0 +1,307 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Combobox } from './index'; + +afterEach(() => cleanup()); + +const fruits = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, +]; + +describe('Combobox', () => { + describe('selection-only input', () => { + it('keeps the selected option when hover and keyboard highlight move', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Show fruits' }); + await user.click(trigger); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await user.click(trigger); + await user.hover(screen.getByRole('option', { name: 'Cherry' })); + expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('data-active'); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('aria-selected', 'false'); + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('option', { name: 'Date' })).toHaveAttribute('data-active'); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true'); + }); + function SelectionOnly({ onValueChange }: { onValueChange?: (value: string | null) => void }) { + return ( + + + + + + fruit.label} + > + {fruit => ( + + {fruit.label} + + )} + + + + + + ); + } + + it.each(['escape', 'outside', 'tab', 'trigger'])('restores the selection when dismissed with %s', async method => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + const input = screen.getByRole('combobox'); + const trigger = screen.getByRole('button', { name: 'Show fruits' }); + const outside = screen.getByRole('button', { name: 'Outside' }); + await user.click(trigger); + await user.click(screen.getByRole('option', { name: 'Apple' })); + await user.type(input, ' not a fruit'); + expect(onValueChange).toHaveBeenCalledExactlyOnceWith('apple'); + + if (method === 'escape') { + await user.keyboard('{Escape}'); + } else if (method === 'outside') { + await user.click(outside); + } else if (method === 'tab') { + await user.tab(); + } else { + await user.click(trigger); + } + + await waitFor(() => expect(input).toHaveValue('Apple')); + expect(input).toHaveAttribute('aria-expanded', 'false'); + expect(onValueChange).toHaveBeenCalledExactlyOnceWith('apple'); + }); + + it('discards unmatched text when there is no selection', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + const input = screen.getByRole('combobox'); + await user.type(input, 'not a fruit'); + await user.keyboard('{Escape}'); + expect(input).toHaveValue(''); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('keeps the selected label on reopen', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Show fruits' }); + await user.click(trigger); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await user.click(trigger); + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-selected', ''); + await user.keyboard('{Escape}'); + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + }); + + it('shows all options on reopen and filters only after editing the selected label', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('combobox'); + const trigger = screen.getByRole('button', { name: 'Show fruits' }); + await user.type(input, 'Ban'); + expect(screen.getAllByRole('option')).toHaveLength(1); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await user.click(trigger); + expect(input).toHaveValue('Banana'); + expect(screen.getAllByRole('option')).toHaveLength(4); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active'); + await user.type(input, 'Cherry', { initialSelectionStart: 0, initialSelectionEnd: 6 }); + expect(screen.getAllByRole('option')).toHaveLength(1); + await user.keyboard('{Escape}'); + expect(input).toHaveValue('Banana'); + await user.keyboard('{ArrowDown}'); + expect(screen.getAllByRole('option')).toHaveLength(4); + }); + + it('keeps the popup open when the search is cleared', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('combobox'); + await user.type(input, 'a'); + await user.clear(input); + expect(input).toHaveValue(''); + expect(input).toHaveAttribute('aria-expanded', 'true'); + }); + + it.each(['backspace', 'clear'])('clears the selection when the input is emptied with %s', async method => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + const input = screen.getByRole('combobox'); + await user.click(screen.getByRole('button', { name: 'Show fruits' })); + await user.click(screen.getByRole('option', { name: 'Banana' })); + if (method === 'backspace') { + await user.keyboard('{End}{Backspace>6/}'); + } else { + await user.clear(input); + } + expect(onValueChange).toHaveBeenLastCalledWith(null); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'false'); + await user.keyboard('{Escape}'); + expect(input).toHaveValue(''); + await user.keyboard('{ArrowDown}'); + expect(screen.getAllByRole('option')).toHaveLength(4); + expect(screen.getByRole('option', { name: 'Banana' })).not.toHaveAttribute('data-selected'); + }); + + it('resets a separate popup search without clearing its selection', async () => { + const user = userEvent.setup(); + function Picker() { + const [open, setOpen] = useState(false); + const [value, setValue] = useState('apple'); + return ( + + + {open && ( + <> + + + fruit.label} + > + {fruit => ( + + {fruit.label} + + )} + + + + )} + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'apple' })); + expect(screen.getByRole('combobox')).toHaveValue(''); + await user.type(screen.getByRole('combobox'), 'Ban'); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await user.click(screen.getByRole('button', { name: 'banana' })); + expect(screen.getByRole('combobox')).toHaveValue(''); + expect(screen.getAllByRole('option')).toHaveLength(4); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-selected'); + await user.type(screen.getByRole('combobox'), 'Cher'); + await user.clear(screen.getByRole('combobox')); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true'); + await user.click(screen.getByRole('button', { name: 'banana' })); + await user.click(screen.getByRole('button', { name: 'banana' })); + expect(screen.getByRole('combobox')).toHaveValue(''); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-selected'); + }); + + it.each([true, false])('respects a controlled clearing request when accepted=%s', async accept => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + function Controlled() { + const [value, setValue] = useState('apple'); + return ( + { + onValueChange(next); + if (accept) { + setValue(next); + } + }} + > + + + + + Apple + + + + + ); + } + render(); + const input = screen.getByRole('combobox'); + await user.clear(input); + expect(onValueChange).toHaveBeenLastCalledWith(null); + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute('aria-selected', String(!accept)); + await user.keyboard('{Escape}'); + expect(input).toHaveValue(accept ? '' : 'Apple'); + }); + + it('restores a controlled selection and query when the parent closes the popup', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + function Controlled({ open }: { open: boolean }) { + const [query, setQuery] = useState(''); + return ( + + + + + + Apple + + + Banana + + + + + ); + } + + const { rerender } = render(); + const input = screen.getByRole('combobox'); + await user.type(input, 'banana'); + await user.click(screen.getByRole('option', { name: 'Banana' })); + expect(onValueChange).toHaveBeenCalledExactlyOnceWith('banana'); + rerender(); + expect(input).toHaveValue('Apple'); + }); + }); +}); diff --git a/packages/headless/src/primitives/combobox/index.ts b/packages/headless/src/primitives/combobox/index.ts new file mode 100644 index 00000000000..6d5b0232f8d --- /dev/null +++ b/packages/headless/src/primitives/combobox/index.ts @@ -0,0 +1,2 @@ +export * as Combobox from './parts'; +export type { ComboboxProps } from './combobox-root'; diff --git a/packages/headless/src/primitives/combobox/parts.ts b/packages/headless/src/primitives/combobox/parts.ts new file mode 100644 index 00000000000..9cb0027c36a --- /dev/null +++ b/packages/headless/src/primitives/combobox/parts.ts @@ -0,0 +1,13 @@ +export { type ComboboxProps, ComboboxRoot as Root } from './combobox-root'; +export { + Collection, + Input, + Trigger, + Portal, + Positioner, + Popup, + List, + Option, + OptionIndicator, + Arrow, +} from '../autocomplete/parts'; diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts index 0722ae12526..e008c3a3748 100644 --- a/packages/headless/vite.config.ts +++ b/packages/headless/vite.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ 'primitives/select/index': 'src/primitives/select/index.ts', 'primitives/menu/index': 'src/primitives/menu/index.ts', 'primitives/autocomplete/index': 'src/primitives/autocomplete/index.ts', + 'primitives/combobox/index': 'src/primitives/combobox/index.ts', 'primitives/collapsible/index': 'src/primitives/collapsible/index.ts', 'primitives/dialog/index': 'src/primitives/dialog/index.ts', 'primitives/drawer/index': 'src/primitives/drawer/index.ts', From 283bb35729da87eb68f4505887723a9530fed05d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 20:41:41 -0600 Subject: [PATCH 2/7] refactor(headless): make combobox independent of autocomplete --- .../src/primitives/combobox/README.md | 2 +- .../primitives/combobox/combobox-arrow.tsx | 22 ++ .../combobox/combobox-collection.tsx | 20 ++ .../primitives/combobox/combobox-context.ts | 51 +++ .../primitives/combobox/combobox-input.tsx | 60 ++++ .../src/primitives/combobox/combobox-list.tsx | 51 +++ .../combobox/combobox-option-context.ts | 3 + .../combobox/combobox-option-indicator.tsx | 24 ++ .../primitives/combobox/combobox-option.tsx | 84 +++++ .../primitives/combobox/combobox-popup.tsx | 25 ++ .../primitives/combobox/combobox-portal.tsx | 19 ++ .../combobox/combobox-positioner.tsx | 79 +++++ .../src/primitives/combobox/combobox-root.tsx | 301 +++++++++++++++++- .../primitives/combobox/combobox-trigger.tsx | 42 +++ .../headless/src/primitives/combobox/index.ts | 13 +- .../headless/src/primitives/combobox/parts.ts | 23 +- 16 files changed, 799 insertions(+), 20 deletions(-) create mode 100644 packages/headless/src/primitives/combobox/combobox-arrow.tsx create mode 100644 packages/headless/src/primitives/combobox/combobox-collection.tsx create mode 100644 packages/headless/src/primitives/combobox/combobox-context.ts create mode 100644 packages/headless/src/primitives/combobox/combobox-input.tsx create mode 100644 packages/headless/src/primitives/combobox/combobox-list.tsx create mode 100644 packages/headless/src/primitives/combobox/combobox-option-context.ts create mode 100644 packages/headless/src/primitives/combobox/combobox-option-indicator.tsx create mode 100644 packages/headless/src/primitives/combobox/combobox-option.tsx create mode 100644 packages/headless/src/primitives/combobox/combobox-popup.tsx create mode 100644 packages/headless/src/primitives/combobox/combobox-portal.tsx create mode 100644 packages/headless/src/primitives/combobox/combobox-positioner.tsx create mode 100644 packages/headless/src/primitives/combobox/combobox-trigger.tsx diff --git a/packages/headless/src/primitives/combobox/README.md b/packages/headless/src/primitives/combobox/README.md index 9c00a340d13..03df4e759db 100644 --- a/packages/headless/src/primitives/combobox/README.md +++ b/packages/headless/src/primitives/combobox/README.md @@ -35,4 +35,4 @@ Dismissal restores the selected label. Reopening shows all options until typing For search inside another popup, use `inline` with `List` and bind `open` to the outer popup. Closing clears search without clearing selection. Supply `defaultInputValue` when an initial selection's label differs from its value. -Combobox reuses Autocomplete's rendering and keyboard internals. Autocomplete's existing public props and free-text behavior remain unchanged. Mosaic adds styling separately. +Combobox owns its selection state and rendering parts independently of Autocomplete. Both use general headless utilities for rendering, controllable state, and transitions. Mosaic adds styling separately. diff --git a/packages/headless/src/primitives/combobox/combobox-arrow.tsx b/packages/headless/src/primitives/combobox/combobox-arrow.tsx new file mode 100644 index 00000000000..1d499fe3096 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-arrow.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { FloatingArrow } from '@floating-ui/react'; +import React from 'react'; + +import { useComboboxContext } from './combobox-context'; + +export type ComboboxArrowProps = React.ComponentPropsWithRef; + +export function ComboboxArrow(props: ComboboxArrowProps) { + const { floatingContext, arrowRef, placement } = useComboboxContext(); + const side = placement.split('-')[0]; + + return ( + + ); +} diff --git a/packages/headless/src/primitives/combobox/combobox-collection.tsx b/packages/headless/src/primitives/combobox/combobox-collection.tsx new file mode 100644 index 00000000000..ed90c60195b --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-collection.tsx @@ -0,0 +1,20 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { useComboboxContext } from './combobox-context'; + +export interface ComboboxCollectionProps { + items: readonly Item[]; + itemToStringLabel: (item: Item) => string; + children: (item: Item) => ReactNode; + empty?: ReactNode; +} + +/** Filters items using the search query rather than the selected label. */ +export function ComboboxCollection({ items, itemToStringLabel, children, empty }: ComboboxCollectionProps) { + const { filterQuery } = useComboboxContext(); + const query = filterQuery.trim().toLocaleLowerCase(); + const filtered = query ? items.filter(item => itemToStringLabel(item).toLocaleLowerCase().includes(query)) : items; + return <>{filtered.length ? filtered.map(children) : empty}; +} diff --git a/packages/headless/src/primitives/combobox/combobox-context.ts b/packages/headless/src/primitives/combobox/combobox-context.ts new file mode 100644 index 00000000000..54dd1c89bdd --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-context.ts @@ -0,0 +1,51 @@ +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 ComboboxContextValue { + open: boolean; + inputValue: string; + filterQuery: string; + selectedValue: string | null; + 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; + triggerRef: React.MutableRefObject; + arrowRef: React.MutableRefObject; + valuesByIndexRef: React.MutableRefObject>; + setInlineMode: React.Dispatch>; + handleSelect: (value: string, index: number, label: string) => void; + handleInputChange: (value: string) => void; + setOpen: (open: boolean) => void; + focusInput: () => void; + popupId: string | undefined; + registerSelectedIndex: (index: number, value: string, label: string) => void; + mounted: boolean; + transitionProps: TransitionProps; +} + +export const ComboboxContext = createContext(null); + +export function useComboboxContext() { + const ctx = useContext(ComboboxContext); + if (!ctx) { + throw new Error('Combobox compound components must be used within '); + } + return ctx; +} diff --git a/packages/headless/src/primitives/combobox/combobox-input.tsx b/packages/headless/src/primitives/combobox/combobox-input.tsx new file mode 100644 index 00000000000..2fa9632e973 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-input.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React from 'react'; + +import { type ComponentProps, mergeProps, useRender } from '../../utils'; +import { useComboboxContext } from './combobox-context'; + +export type ComboboxInputProps = ComponentProps<'input'>; + +export const ComboboxInput = React.forwardRef(function ComboboxInput(props, ref) { + const { render, ...otherProps } = props; + const { + open, + inputValue, + activeIndex, + refs, + getReferenceProps, + handleInputChange, + handleSelect, + labelsRef, + valuesByIndexRef, + } = useComboboxContext(); + + const state = { open }; + + const defaultProps = { + ...getReferenceProps({ + 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); + } + } + }, + }), + }; + + return useRender({ + defaultTagName: 'input', + render, + // 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 + ref: [refs.setReference, ref], + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), + }, + props: mergeProps<'input'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/combobox/combobox-list.tsx b/packages/headless/src/primitives/combobox/combobox-list.tsx new file mode 100644 index 00000000000..ac3bf862f78 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-list.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { FloatingList } from '@floating-ui/react'; +import React, { useEffect } from 'react'; + +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; +import { useComboboxContext } from './combobox-context'; + +export type ComboboxListProps = ComponentProps<'div'>; + +export const ComboboxList = React.forwardRef(function ComboboxList(props, ref) { + const { render, ...otherProps } = props; + const { elementsRef, labelsRef, refs, getFloatingProps, setInlineMode } = useComboboxContext(); + + useEffect(() => { + setInlineMode(true); + return () => setInlineMode(false); + }, [setInlineMode]); + + const floatingProps = getFloatingProps(); + const wiredId = floatingProps.id; + + const ownProps = {} satisfies DefaultProps<'div'>; + + const defaultProps = { ...ownProps, ...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 ( + + {useRender({ + defaultTagName: 'div', + render, + // 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 + ref: [refs.setFloating, ref], + props: merged, + })} + + ); +}); diff --git a/packages/headless/src/primitives/combobox/combobox-option-context.ts b/packages/headless/src/primitives/combobox/combobox-option-context.ts new file mode 100644 index 00000000000..1865784f976 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-option-context.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const ComboboxOptionContext = createContext(null); diff --git a/packages/headless/src/primitives/combobox/combobox-option-indicator.tsx b/packages/headless/src/primitives/combobox/combobox-option-indicator.tsx new file mode 100644 index 00000000000..8773fd66594 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-option-indicator.tsx @@ -0,0 +1,24 @@ +'use client'; + +import React, { useContext } from 'react'; + +import { type ComponentProps, mergeProps, useRender } from '../../utils'; +import { ComboboxOptionContext } from './combobox-option-context'; + +export type ComboboxOptionIndicatorProps = ComponentProps<'span'>; + +export const ComboboxOptionIndicator = React.forwardRef( + function ComboboxOptionIndicator({ render, ...props }, ref) { + const selected = useContext(ComboboxOptionContext); + if (selected === null) { + throw new Error('Combobox.OptionIndicator must be used within Combobox.Option'); + } + return useRender({ + defaultTagName: 'span', + render, + ref, + enabled: selected, + props: mergeProps<'span'>({ 'aria-hidden': true }, props), + }); + }, +); diff --git a/packages/headless/src/primitives/combobox/combobox-option.tsx b/packages/headless/src/primitives/combobox/combobox-option.tsx new file mode 100644 index 00000000000..3d84ad17734 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-option.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useListItem } from '@floating-ui/react'; +import React, { useEffect, useId } from 'react'; + +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; +import { useComboboxContext } from './combobox-context'; +import { ComboboxOptionContext } from './combobox-option-context'; + +export interface ComboboxOptionProps extends ComponentProps<'div'> { + value: string; + label?: string; + disabled?: boolean; +} + +export const ComboboxOption = React.forwardRef( + function ComboboxOption(props, ref) { + const { render, value, label, disabled, ...otherProps } = props; + const { activeIndex, selectedValue, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } = + useComboboxContext(); + + const id = useId(); + const displayLabel = label ?? value; + const { ref: itemRef, index } = useListItem({ label: displayLabel }); + + const isSelected = selectedValue === value; + const isActive = activeIndex === index; + + useEffect(() => { + const map = valuesByIndexRef.current; + if (!disabled) { + map.set(index, value); + } + registerSelectedIndex(index, value, displayLabel); + return () => { + map.delete(index); + }; + }, [index, value, displayLabel, disabled, valuesByIndexRef, registerSelectedIndex]); + + const state = { + selected: isSelected, + active: isActive, + disabled: !!disabled, + }; + + const ownProps = { + id, + role: 'option', + 'aria-selected': isSelected, + 'aria-disabled': disabled || undefined, + } satisfies DefaultProps<'div'>; + + const defaultProps = { + ...ownProps, + ...getItemProps({ + onClick() { + if (!disabled) { + handleSelect(value, index, displayLabel); + (refs.domReference.current as HTMLElement | null)?.focus(); + } + }, + }), + }; + + 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; + + const element = useRender({ + defaultTagName: 'div', + render, + ref: [itemRef, ref], + state, + stateAttributesMapping: { + selected: (v: boolean) => (v ? { 'data-selected': '' } : null), + active: (v: boolean) => (v ? { 'data-active': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + }, + props: merged, + }); + return {element}; + }, +); diff --git a/packages/headless/src/primitives/combobox/combobox-popup.tsx b/packages/headless/src/primitives/combobox/combobox-popup.tsx new file mode 100644 index 00000000000..f780fbb147c --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-popup.tsx @@ -0,0 +1,25 @@ +'use client'; + +import React from 'react'; + +import { type ComponentProps, Freeze, mergeProps, useRender } from '../../utils'; +import { useComboboxContext } from './combobox-context'; + +export type ComboboxPopupProps = ComponentProps<'div'>; + +export const ComboboxPopup = React.forwardRef(function ComboboxPopup(props, ref) { + const { render, children, ...otherProps } = props; + const { open, popupRef, transitionProps } = useComboboxContext(); + + const defaultProps = { + ...transitionProps, + children: {children}, + }; + + return useRender({ + defaultTagName: 'div', + render, + ref: [popupRef, ref], + props: mergeProps<'div'>(defaultProps, otherProps), + }); +}); diff --git a/packages/headless/src/primitives/combobox/combobox-portal.tsx b/packages/headless/src/primitives/combobox/combobox-portal.tsx new file mode 100644 index 00000000000..e46ca8ff010 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-portal.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FloatingPortal } from '@floating-ui/react'; +import type { ReactNode } from 'react'; + +import { useComboboxContext } from './combobox-context'; + +export interface ComboboxPortalProps { + children: ReactNode; + root?: HTMLElement | null | React.RefObject; +} + +export function ComboboxPortal(props: ComboboxPortalProps) { + const { mounted } = useComboboxContext(); + if (!mounted) { + return null; + } + return {props.children}; +} diff --git a/packages/headless/src/primitives/combobox/combobox-positioner.tsx b/packages/headless/src/primitives/combobox/combobox-positioner.tsx new file mode 100644 index 00000000000..4c15d412700 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-positioner.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { FloatingFocusManager, FloatingList } from '@floating-ui/react'; +import React from 'react'; + +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; +import { useComboboxContext } from './combobox-context'; + +export interface ComboboxPositionerProps extends ComponentProps<'div'> { + /** Element used for popup positioning. Defaults to the input. */ + anchor?: HTMLElement | null; +} + +export const ComboboxPositioner = React.forwardRef( + function ComboboxPositioner(props, ref) { + const { anchor, render, ...otherProps } = props; + const { mounted, floatingContext, refs, floatingStyles, placement, getFloatingProps, elementsRef, labelsRef } = + useComboboxContext(); + + React.useLayoutEffect(() => { + if (!anchor) { + return; + } + refs.setPositionReference(anchor); + return () => refs.setPositionReference(refs.domReference.current); + }, [anchor, refs]); + + const side = placement.split('-')[0]; + + const floatingProps = getFloatingProps(); + const wiredId = floatingProps.id; + + const ownProps = { + 'data-side': side, + style: floatingStyles, + } satisfies DefaultProps<'div'>; + + const defaultProps = { ...ownProps, ...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; + } + + const element = useRender({ + defaultTagName: 'div', + render, + // 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 + ref: [refs.setFloating, ref], + enabled: mounted, + props: merged, + }); + + if (!element) { + return null; + } + + return ( + + + {element} + + + ); + }, +); diff --git a/packages/headless/src/primitives/combobox/combobox-root.tsx b/packages/headless/src/primitives/combobox/combobox-root.tsx index 88a8293aca7..f253a9c9a3e 100644 --- a/packages/headless/src/primitives/combobox/combobox-root.tsx +++ b/packages/headless/src/primitives/combobox/combobox-root.tsx @@ -1,14 +1,301 @@ 'use client'; -import { ComboboxRootInternal, type ComboboxRootInternalProps } from '../autocomplete/autocomplete-root'; +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'; -export type ComboboxProps = Omit; +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { ComboboxContext, type ComboboxContextValue } from './combobox-context'; + +export interface ComboboxProps { + /** Controlled input text. */ + inputValue?: string; + defaultInputValue?: string; + onInputValueChange?: (value: string) => void; + /** Controlled selected value. */ + value?: string | null; + defaultValue?: string | null; + onValueChange?: (value: string | null) => void; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: Placement; + sideOffset?: number; + children: ReactNode; + /** Separate search input inside another surface. Bind open to that surface. */ + inline?: boolean; +} + +function ComboboxInner(props: ComboboxProps) { + const { placement: placementProp = 'bottom-start', sideOffset = 4, children } = props; + + const nodeId = useFloatingNodeId(); + + const [open, setOpenState] = 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 ?? null, + props.onValueChange, + ); + + const [activeIndex, setActiveIndex] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(null); + const [inlineMode, setInlineMode] = useState(false); + const [queryChanged, setQueryChanged] = useState(false); + const initialValue = props.value ?? props.defaultValue; + const labelsByValueRef = useRef( + new Map( + initialValue != null && props.defaultInputValue !== undefined ? [[initialValue, props.defaultInputValue]] : [], + ), + ); + + const setOpen = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + setQueryChanged(false); + } + setOpenState(nextOpen); + }, + [setOpenState], + ); + + useEffect(() => { + if (!open) { + setQueryChanged(false); + } + if (open) { + return; + } + const label = + props.inline || selectedValue === null ? '' : (labelsByValueRef.current.get(selectedValue) ?? selectedValue); + if (inputValue !== label) { + setInputValue(label); + } + }, [open, props.inline, selectedValue, selectedIndex, inputValue, setInputValue]); + + const selectedLabel = selectedValue === null ? '' : (labelsByValueRef.current.get(selectedValue) ?? selectedValue); + const filterQuery = !queryChanged && inputValue === selectedLabel ? '' : inputValue; + + const elementsRef = useRef>([]); + const labelsRef = useRef>([]); + const arrowRef = useRef(null); + const popupRef = useRef(null); + const triggerRef = useRef(null); + const valuesByIndexRef = useRef>(new Map()); + const registerSelectedIndex = useCallback( + (index: number, value: string, label: string) => { + labelsByValueRef.current.set(value, label); + 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 }) { + // Only size the positioner (identified by its data-side), never the input reference. + if (!elements.floating.hasAttribute('data-side')) { + 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(event) { + if (inlineMode) { + return false; + } + const target = event.target; + return !(target instanceof Node && triggerRef.current?.contains(target)); + }, + 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 referenceProps = getReferenceProps(); + const popupId = typeof referenceProps['aria-controls'] === 'string' ? referenceProps['aria-controls'] : undefined; + + const focusInput = useCallback(() => { + const input = refs.domReference.current; + if (input instanceof HTMLElement) { + input.focus(); + } + }, [refs.domReference]); + + const handleSelect = useCallback( + (value: string, index: number, label: string) => { + labelsByValueRef.current.set(value, label); + setSelectedValue(value); + setSelectedIndex(index); + setInputValue(props.inline ? '' : label); + setActiveIndex(null); + setOpen(false); + }, + [props.inline, setSelectedValue, setInputValue, setOpen], + ); + + const handleInputChange = useCallback( + (value: string) => { + setQueryChanged(true); + setInputValue(value); + if (value === '' && !props.inline) { + setSelectedValue(null); + setSelectedIndex(null); + } + setOpenState(true); + setActiveIndex(0); + }, + [props.inline, setInputValue, setOpenState, setSelectedValue], + ); + + const contextValue = useMemo( + () => ({ + open, + inputValue, + filterQuery, + selectedValue, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + selectedIndex, + elementsRef, + labelsRef, + popupRef, + triggerRef, + arrowRef, + valuesByIndexRef, + setInlineMode, + handleSelect, + handleInputChange, + setOpen, + focusInput, + popupId, + registerSelectedIndex, + mounted, + transitionProps, + }), + [ + open, + inputValue, + filterQuery, + selectedValue, + floatingContext, + refs, + floatingStyles, + placement, + getReferenceProps, + getFloatingProps, + getItemProps, + activeIndex, + selectedIndex, + handleSelect, + handleInputChange, + setOpen, + focusInput, + popupId, + registerSelectedIndex, + mounted, + transitionProps, + ], + ); -export function ComboboxRoot(props: ComboboxProps) { return ( - + + {children} + ); } + +export function ComboboxRoot(props: ComboboxProps) { + const parentId = useFloatingParentNodeId(); + + if (parentId === null) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/headless/src/primitives/combobox/combobox-trigger.tsx b/packages/headless/src/primitives/combobox/combobox-trigger.tsx new file mode 100644 index 00000000000..7d6420e3565 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-trigger.tsx @@ -0,0 +1,42 @@ +'use client'; + +import React from 'react'; + +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; +import { useComboboxContext } from './combobox-context'; + +export type ComboboxTriggerProps = ComponentProps<'button'>; + +export const ComboboxTrigger = React.forwardRef( + function ComboboxTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, setOpen, focusInput, popupId, triggerRef } = useComboboxContext(); + const state = { open }; + + const defaultProps = { + type: 'button', + tabIndex: -1, + 'aria-controls': popupId, + 'aria-expanded': open, + 'aria-haspopup': 'listbox', + onPointerDown(event: React.PointerEvent) { + event.preventDefault(); + }, + onClick() { + setOpen(!open); + focusInput(); + }, + } satisfies DefaultProps<'button'>; + + return useRender({ + defaultTagName: 'button', + render, + ref: [triggerRef, ref], + state, + stateAttributesMapping: { + open: (value: boolean): Record | null => (value ? { 'data-open': '' } : { 'data-closed': '' }), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/headless/src/primitives/combobox/index.ts b/packages/headless/src/primitives/combobox/index.ts index 6d5b0232f8d..d0c2bfa49a5 100644 --- a/packages/headless/src/primitives/combobox/index.ts +++ b/packages/headless/src/primitives/combobox/index.ts @@ -1,2 +1,13 @@ export * as Combobox from './parts'; -export type { ComboboxProps } from './combobox-root'; +export type { + ComboboxCollectionProps, + ComboboxArrowProps, + ComboboxInputProps, + ComboboxListProps, + ComboboxOptionProps, + ComboboxOptionIndicatorProps, + ComboboxPopupProps, + ComboboxPortalProps, + ComboboxPositionerProps, + ComboboxProps, +} from './parts'; diff --git a/packages/headless/src/primitives/combobox/parts.ts b/packages/headless/src/primitives/combobox/parts.ts index 9cb0027c36a..84d9c792dd8 100644 --- a/packages/headless/src/primitives/combobox/parts.ts +++ b/packages/headless/src/primitives/combobox/parts.ts @@ -1,13 +1,14 @@ export { type ComboboxProps, ComboboxRoot as Root } from './combobox-root'; +export { type ComboboxCollectionProps, ComboboxCollection as Collection } from './combobox-collection'; +export { type ComboboxInputProps, ComboboxInput as Input } from './combobox-input'; +export { type ComboboxTriggerProps, ComboboxTrigger as Trigger } from './combobox-trigger'; +export { type ComboboxPortalProps, ComboboxPortal as Portal } from './combobox-portal'; +export { type ComboboxPositionerProps, ComboboxPositioner as Positioner } from './combobox-positioner'; +export { type ComboboxPopupProps, ComboboxPopup as Popup } from './combobox-popup'; +export { type ComboboxListProps, ComboboxList as List } from './combobox-list'; +export { type ComboboxOptionProps, ComboboxOption as Option } from './combobox-option'; export { - Collection, - Input, - Trigger, - Portal, - Positioner, - Popup, - List, - Option, - OptionIndicator, - Arrow, -} from '../autocomplete/parts'; + type ComboboxOptionIndicatorProps, + ComboboxOptionIndicator as OptionIndicator, +} from './combobox-option-indicator'; +export { type ComboboxArrowProps, ComboboxArrow as Arrow } from './combobox-arrow'; From 873e09c12226b38dc6bbf55735d7ba1a29f77d1b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 20:42:00 -0600 Subject: [PATCH 3/7] refactor(headless): remove combobox-specific code from autocomplete --- .../autocomplete/autocomplete-collection.tsx | 25 ----- .../autocomplete/autocomplete-context.ts | 6 +- .../autocomplete-option-context.ts | 3 - .../autocomplete-option-indicator.tsx | 24 ----- .../autocomplete/autocomplete-option.tsx | 22 ++--- .../autocomplete/autocomplete-root.tsx | 98 ++++--------------- .../src/primitives/autocomplete/index.ts | 2 - .../src/primitives/autocomplete/parts.ts | 5 - 8 files changed, 26 insertions(+), 159 deletions(-) delete mode 100644 packages/headless/src/primitives/autocomplete/autocomplete-collection.tsx delete mode 100644 packages/headless/src/primitives/autocomplete/autocomplete-option-context.ts delete mode 100644 packages/headless/src/primitives/autocomplete/autocomplete-option-indicator.tsx diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-collection.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-collection.tsx deleted file mode 100644 index 0c7175c6df1..00000000000 --- a/packages/headless/src/primitives/autocomplete/autocomplete-collection.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; - -import type { ReactNode } from 'react'; - -import { useAutocompleteContext } from './autocomplete-context'; - -export interface AutocompleteCollectionProps { - items: readonly Item[]; - itemToStringLabel: (item: Item) => string; - children: (item: Item) => ReactNode; - empty?: ReactNode; -} - -/** Filters items using the search query rather than the selected label. */ -export function AutocompleteCollection({ - items, - itemToStringLabel, - children, - empty, -}: AutocompleteCollectionProps) { - const { filterQuery } = useAutocompleteContext(); - const query = filterQuery.trim().toLocaleLowerCase(); - const filtered = query ? items.filter(item => itemToStringLabel(item).toLocaleLowerCase().includes(query)) : items; - return <>{filtered.length ? filtered.map(children) : empty}; -} diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-context.ts b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts index 6c77534e183..4dd7327e5e0 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-context.ts +++ b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts @@ -10,11 +10,9 @@ import { createContext, type CSSProperties, useContext } from 'react'; import type { TransitionProps } from '../../hooks/use-transition'; export interface AutocompleteContextValue { - allowsCustomValue: boolean; open: boolean; inputValue: string; - filterQuery: string; - selectedValue: string | null; + selectedValue: string | undefined; floatingContext: FloatingContext; refs: ExtendedRefs; floatingStyles: CSSProperties; @@ -36,7 +34,7 @@ export interface AutocompleteContextValue { setOpen: (open: boolean) => void; focusInput: () => void; popupId: string | undefined; - registerSelectedIndex: (index: number, value: string, label: string) => void; + registerSelectedIndex: (index: number, value: string) => void; mounted: boolean; transitionProps: TransitionProps; } diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option-context.ts b/packages/headless/src/primitives/autocomplete/autocomplete-option-context.ts deleted file mode 100644 index 31f022fef67..00000000000 --- a/packages/headless/src/primitives/autocomplete/autocomplete-option-context.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createContext } from 'react'; - -export const AutocompleteOptionContext = createContext(null); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option-indicator.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-option-indicator.tsx deleted file mode 100644 index 75b08b58a79..00000000000 --- a/packages/headless/src/primitives/autocomplete/autocomplete-option-indicator.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client'; - -import React, { useContext } from 'react'; - -import { type ComponentProps, mergeProps, useRender } from '../../utils'; -import { AutocompleteOptionContext } from './autocomplete-option-context'; - -export type AutocompleteOptionIndicatorProps = ComponentProps<'span'>; - -export const AutocompleteOptionIndicator = React.forwardRef( - function AutocompleteOptionIndicator({ render, ...props }, ref) { - const selected = useContext(AutocompleteOptionContext); - if (selected === null) { - throw new Error('Autocomplete.OptionIndicator must be used within Autocomplete.Option'); - } - return useRender({ - defaultTagName: 'span', - render, - ref, - enabled: selected, - props: mergeProps<'span'>({ 'aria-hidden': true }, props), - }); - }, -); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx index 2d6fe4218cd..e1a7fdda31f 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx @@ -5,7 +5,6 @@ import React, { useEffect, useId } from 'react'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useAutocompleteContext } from './autocomplete-context'; -import { AutocompleteOptionContext } from './autocomplete-option-context'; export interface AutocompleteOptionProps extends ComponentProps<'div'> { value: string; @@ -16,16 +15,8 @@ export interface AutocompleteOptionProps extends ComponentProps<'div'> { export const AutocompleteOption = React.forwardRef( function AutocompleteOption(props, ref) { const { render, value, label, disabled, ...otherProps } = props; - const { - allowsCustomValue, - activeIndex, - selectedValue, - getItemProps, - handleSelect, - valuesByIndexRef, - registerSelectedIndex, - refs, - } = useAutocompleteContext(); + const { activeIndex, selectedValue, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } = + useAutocompleteContext(); const id = useId(); const displayLabel = label ?? value; @@ -39,11 +30,11 @@ export const AutocompleteOption = React.forwardRef { map.delete(index); }; - }, [index, value, displayLabel, disabled, valuesByIndexRef, registerSelectedIndex]); + }, [index, value, disabled, valuesByIndexRef, registerSelectedIndex]); const state = { selected: isSelected, @@ -54,7 +45,7 @@ export const AutocompleteOption = React.forwardRef; @@ -75,7 +66,7 @@ export const AutocompleteOption = React.forwardRef{element}; }, ); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx index 9c1774b0b98..aa70313c6bf 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx @@ -41,20 +41,12 @@ export interface AutocompleteProps { children: ReactNode; } -export interface ComboboxRootInternalProps extends Omit { - allowsCustomValue?: boolean; - /** Separate search input inside another surface. Bind open to that surface. */ - inline?: boolean; - value?: string | null; - defaultValue?: string | null; - onValueChange?: (value: string | null) => void; -} -function AutocompleteInner(props: ComboboxRootInternalProps) { - const { placement: placementProp = 'bottom-start', sideOffset = 4, allowsCustomValue = true, children } = props; +function AutocompleteInner(props: AutocompleteProps) { + const { placement: placementProp = 'bottom-start', sideOffset = 4, children } = props; const nodeId = useFloatingNodeId(); - const [open, setOpenState] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); const [inputValue, setInputValue] = useControllableState( props.inputValue, @@ -62,49 +54,19 @@ function AutocompleteInner(props: ComboboxRootInternalProps) { props.onInputValueChange, ); - const [selectedValue, setSelectedValue] = useControllableState( + const [selectedValue, setSelectedValue] = useControllableState( props.value, - props.defaultValue ?? null, - props.onValueChange, + props.defaultValue, + value => { + if (value !== undefined) { + props.onValueChange?.(value); + } + }, ); const [activeIndex, setActiveIndex] = useState(null); const [selectedIndex, setSelectedIndex] = useState(null); const [inlineMode, setInlineMode] = useState(false); - const [queryChanged, setQueryChanged] = useState(false); - const initialValue = props.value ?? props.defaultValue; - const labelsByValueRef = useRef( - new Map( - initialValue != null && props.defaultInputValue !== undefined ? [[initialValue, props.defaultInputValue]] : [], - ), - ); - - const setOpen = useCallback( - (nextOpen: boolean) => { - if (!nextOpen) { - setQueryChanged(false); - } - setOpenState(nextOpen); - }, - [setOpenState], - ); - - useEffect(() => { - if (!open) { - setQueryChanged(false); - } - if (allowsCustomValue || open) { - return; - } - const label = - props.inline || selectedValue === null ? '' : (labelsByValueRef.current.get(selectedValue) ?? selectedValue); - if (inputValue !== label) { - setInputValue(label); - } - }, [allowsCustomValue, open, props.inline, selectedValue, selectedIndex, inputValue, setInputValue]); - - const selectedLabel = selectedValue === null ? '' : (labelsByValueRef.current.get(selectedValue) ?? selectedValue); - const filterQuery = !allowsCustomValue && !queryChanged && inputValue === selectedLabel ? '' : inputValue; const elementsRef = useRef>([]); const labelsRef = useRef>([]); @@ -113,8 +75,7 @@ function AutocompleteInner(props: ComboboxRootInternalProps) { const triggerRef = useRef(null); const valuesByIndexRef = useRef>(new Map()); const registerSelectedIndex = useCallback( - (index: number, value: string, label: string) => { - labelsByValueRef.current.set(value, label); + (index: number, value: string) => { if (value === selectedValue) { setSelectedIndex(index); } @@ -205,41 +166,33 @@ function AutocompleteInner(props: ComboboxRootInternalProps) { const handleSelect = useCallback( (value: string, index: number, label: string) => { - labelsByValueRef.current.set(value, label); setSelectedValue(value); setSelectedIndex(index); - setInputValue(props.inline ? '' : label); + setInputValue(label); setActiveIndex(null); setOpen(false); }, - [props.inline, setSelectedValue, setInputValue, setOpen], + [setSelectedValue, setInputValue, setOpen], ); const handleInputChange = useCallback( (value: string) => { - setQueryChanged(true); setInputValue(value); - if (value === '' && !allowsCustomValue && !props.inline) { - setSelectedValue(null); - setSelectedIndex(null); - } - if (value || !allowsCustomValue) { - setOpenState(true); + if (value) { + setOpen(true); setActiveIndex(0); } else { - setOpenState(false); + setOpen(false); setActiveIndex(null); } }, - [allowsCustomValue, props.inline, setInputValue, setOpenState, setSelectedValue], + [setInputValue, setOpen], ); const contextValue = useMemo( () => ({ - allowsCustomValue, open, inputValue, - filterQuery, selectedValue, floatingContext, refs, @@ -267,10 +220,8 @@ function AutocompleteInner(props: ComboboxRootInternalProps) { transitionProps, }), [ - allowsCustomValue, open, inputValue, - filterQuery, selectedValue, floatingContext, refs, @@ -299,7 +250,7 @@ function AutocompleteInner(props: ComboboxRootInternalProps) { ); } -export function ComboboxRootInternal(props: ComboboxRootInternalProps) { +export function AutocompleteRoot(props: AutocompleteProps) { const parentId = useFloatingParentNodeId(); if (parentId === null) { @@ -312,16 +263,3 @@ export function ComboboxRootInternal(props: ComboboxRootInternalProps) { return ; } - -export function AutocompleteRoot({ onValueChange, ...props }: AutocompleteProps) { - return ( - { - if (value !== null) { - onValueChange?.(value); - } - }} - /> - ); -} diff --git a/packages/headless/src/primitives/autocomplete/index.ts b/packages/headless/src/primitives/autocomplete/index.ts index 157dd7d73c1..e282f71bbda 100644 --- a/packages/headless/src/primitives/autocomplete/index.ts +++ b/packages/headless/src/primitives/autocomplete/index.ts @@ -1,11 +1,9 @@ export * as Autocomplete from './parts'; export type { - AutocompleteCollectionProps, AutocompleteArrowProps, AutocompleteInputProps, AutocompleteListProps, AutocompleteOptionProps, - AutocompleteOptionIndicatorProps, AutocompletePopupProps, AutocompletePortalProps, AutocompletePositionerProps, diff --git a/packages/headless/src/primitives/autocomplete/parts.ts b/packages/headless/src/primitives/autocomplete/parts.ts index 665fd3a730d..c3246fee40a 100644 --- a/packages/headless/src/primitives/autocomplete/parts.ts +++ b/packages/headless/src/primitives/autocomplete/parts.ts @@ -1,5 +1,4 @@ export { type AutocompleteProps, AutocompleteRoot as Root } from './autocomplete-root'; -export { type AutocompleteCollectionProps, AutocompleteCollection as Collection } from './autocomplete-collection'; export { type AutocompleteInputProps, AutocompleteInput as Input } from './autocomplete-input'; export { type AutocompleteTriggerProps, AutocompleteTrigger as Trigger } from './autocomplete-trigger'; export { type AutocompletePortalProps, AutocompletePortal as Portal } from './autocomplete-portal'; @@ -7,8 +6,4 @@ export { type AutocompletePositionerProps, AutocompletePositioner as Positioner export { type AutocompletePopupProps, AutocompletePopup as Popup } from './autocomplete-popup'; export { type AutocompleteListProps, AutocompleteList as List } from './autocomplete-list'; export { type AutocompleteOptionProps, AutocompleteOption as Option } from './autocomplete-option'; -export { - type AutocompleteOptionIndicatorProps, - AutocompleteOptionIndicator as OptionIndicator, -} from './autocomplete-option-indicator'; export { type AutocompleteArrowProps, AutocompleteArrow as Arrow } from './autocomplete-arrow'; From 38c26254101dfe83bcbf55abf5b59dede330f530 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 20:55:19 -0600 Subject: [PATCH 4/7] docs(swingset): add combobox primitive page --- .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 3 + .../src/stories/combobox.primitive.mdx | 135 ++++++++++++++++++ .../stories/combobox.primitive.stories.tsx | 47 ++++++ 4 files changed, 186 insertions(+) create mode 100644 packages/swingset/src/stories/combobox.primitive.mdx create mode 100644 packages/swingset/src/stories/combobox.primitive.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d7e76660da5..6d73b3583c7 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -69,6 +69,7 @@ const docModules: Record> = { // Headless primitives — alphabetical. accordion: dynamic(() => import('../stories/accordion.mdx')), autocomplete: dynamic(() => import('../stories/autocomplete.mdx')), + combobox: dynamic(() => import('../stories/combobox.primitive.mdx')), collapsible: dynamic(() => import('../stories/collapsible.mdx')), dialog: dynamic(() => import('../stories/dialog.mdx')), drawer: dynamic(() => import('../stories/drawer.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 9dd721943ac..4d3105e5096 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -1,6 +1,7 @@ // Import stories explicitly to control order and avoid type casting through unknown. import { meta as accordionMeta } from '../stories/accordion.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; +import { meta as comboboxPrimitiveMeta } from '../stories/combobox.primitive.stories'; import { Fallback as AvatarFallbackStory, Interactive as AvatarInteractive, @@ -393,6 +394,7 @@ const iconFrameModule: StoryModule = { // `` embeds in the MDX, which import the stories module directly. const accordionModule: StoryModule = { meta: accordionMeta }; const autocompleteModule: StoryModule = { meta: autocompleteMeta }; +const comboboxPrimitiveModule: StoryModule = { meta: comboboxPrimitiveMeta }; const collapsibleModule: StoryModule = { meta: collapsibleMeta }; const dialogModule: StoryModule = { meta: dialogMeta }; const drawerModule: StoryModule = { meta: drawerMeta }; @@ -573,6 +575,7 @@ export const registry: StoryModule[] = [ // Primitives — alphabetical within the group. accordionModule, autocompleteModule, + comboboxPrimitiveModule, collapsibleModule, dialogModule, drawerModule, diff --git a/packages/swingset/src/stories/combobox.primitive.mdx b/packages/swingset/src/stories/combobox.primitive.mdx new file mode 100644 index 00000000000..29a0d98ee94 --- /dev/null +++ b/packages/swingset/src/stories/combobox.primitive.mdx @@ -0,0 +1,135 @@ +import * as ComboboxStories from './combobox.primitive.stories'; + +# Combobox + +A headless primitive for choosing an option from a searchable list. It ships no styles. +Selection is remembered separately from the search text. Use Autocomplete for free text with suggestions. + +## Example + +This example renders the unstyled parts. Focus the input and press Arrow Down to open the list, +then Enter to select. Clearing the input clears the selection; dismissing a search restores +the selected label. Reopening shows all options until you type again. + + + +## Usage + +```tsx +import { Combobox } from '@clerk/headless/combobox'; + + + + ⌄ + + + + fruit}> + {fruit => ( + + {fruit} + ✓ + + )} + + + + +; +``` + +Control selection with `value` and `onValueChange`. Search text has its own `inputValue` +and `onInputValueChange` props. + +```tsx +const [value, setValue] = useState(null); + + + {/* Compose the same parts here. */} +; +``` + +## Parts + +| Part | Default element | Description | +| -------------------------- | --------------- | -------------------------------------------------------------- | +| `Combobox.Root` | none (context) | Owns selection, search, navigation, and open state | +| `Combobox.Input` | `input` | Search input and keyboard focus target | +| `Combobox.Trigger` | `button` | Toggles the popup and focuses the input | +| `Combobox.Portal` | none (portal) | Portals the floating content | +| `Combobox.Positioner` | `div` | Positions the listbox relative to the input or supplied anchor | +| `Combobox.Popup` | `div` | Wraps popup content and preserves it during exit transitions | +| `Combobox.Collection` | none | Filters items by their labels | +| `Combobox.Option` | `div` | Selectable option | +| `Combobox.OptionIndicator` | `span` | Renders its children only for the selected option | +| `Combobox.List` | `div` | Inline listbox inside another floating surface | +| `Combobox.Arrow` | `svg` | Optional arrow pointing toward the anchor | + +Parts belong inside `Combobox.Root`; `OptionIndicator` belongs inside `Option`. +DOM parts using `useRender` accept `render` and standard HTML attributes. +`Arrow` accepts Floating UI's `FloatingArrow` props. + +## Props + +### `Combobox.Root` + +| Prop | Type | Default | Description | +| -------------------- | --------------------------------- | ---------------- | ------------------------------------------------------------------- | +| `value` | `string \| null` | — | Controlled selection | +| `defaultValue` | `string \| null` | `null` | Initial selection | +| `onValueChange` | `(value: string \| null) => void` | — | Selection or clearing callback | +| `inputValue` | `string` | — | Controlled search text | +| `defaultInputValue` | `string` | `''` | Initial text; use the selected label with `defaultValue` | +| `onInputValueChange` | `(value: string) => void` | — | Input text callback | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state | +| `onOpenChange` | `(open: boolean) => void` | — | Open state callback | +| `placement` | `Placement` | `'bottom-start'` | Popup placement | +| `sideOffset` | `number` | `4` | Gap from the anchor in pixels | +| `inline` | `boolean` | `false` | Separate search inside another surface; bind `open` to that surface | + +### `Combobox.Collection` + +| Prop | Type | Default | Description | +| ------------------- | --------------------------- | ------------ | ---------------------------------------- | +| `items` | `readonly Item[]` | — (required) | Available options | +| `itemToStringLabel` | `(item: Item) => string` | — (required) | Label used for case-insensitive matching | +| `children` | `(item: Item) => ReactNode` | — (required) | Renders each matching option | +| `empty` | `ReactNode` | — | Content when no items match | + +### `Combobox.Option` + +| Prop | Type | Default | Description | +| ---------- | --------- | --------------------- | --------------------------- | +| `value` | `string` | — (required) | Option value | +| `label` | `string` | falls back to `value` | Text displayed on selection | +| `disabled` | `boolean` | — | Prevents selection | + +### `Combobox.Positioner` + +| Prop | Type | Default | Description | +| -------- | --------------------- | ------------- | -------------------------------------- | +| `anchor` | `HTMLElement \| null` | input element | Element used for positioning and width | + +### `Combobox.Portal` + +| Prop | Type | Default | Description | +| ------ | ------------------------------------------------------- | ------------- | ---------------- | +| `root` | `HTMLElement \| null \| RefObject` | document body | Portal container | + +`Trigger` defaults to `type='button'` and `tabIndex={-1}`; keyboard navigation uses the input. +For an inline list, use `List` in place of `Portal` and `Positioner` so the outer surface owns dismissal. + +## Styling + +| Attribute | Parts | Description | +| -------------------------- | ----------------- | ------------------------------------- | +| `data-open`, `data-closed` | Input, Trigger | Popup state | +| `data-selected` | Option | Matches the selected value | +| `data-active` | Option | Current keyboard or pointer highlight | +| `data-disabled` | Option | Disabled option | +| `data-side` | Positioner, Arrow | Resolved popup side | + +The positioner matches the anchor width and exposes `--cl-anchor-width`, `--cl-anchor-height`, +`--cl-available-width`, `--cl-available-height`, and `--cl-transform-origin` for custom styling. +Selection and highlight are separate, so the checkmark stays on the selected option while you browse others. diff --git a/packages/swingset/src/stories/combobox.primitive.stories.tsx b/packages/swingset/src/stories/combobox.primitive.stories.tsx new file mode 100644 index 00000000000..3f9cbc33645 --- /dev/null +++ b/packages/swingset/src/stories/combobox.primitive.stories.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { Combobox } from '@clerk/headless/combobox'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Primitives', + status: 'wip', + title: 'Combobox', + source: 'packages/headless/src/primitives/combobox/index.ts', +}; + +const FRUITS = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig']; + +export function Default() { + return ( + + + ⌄ + + + + fruit} + empty='No fruit found' + > + {fruit => ( + + {fruit} + ✓ + + )} + + + + + + ); +} From 5cd0e1503690a9b14b5685117e6ccc51e2f232d9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 21:22:28 -0600 Subject: [PATCH 5/7] fix(swingset): sort combobox primitive registry import --- packages/swingset/src/lib/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 4d3105e5096..8e3006ea22e 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -1,7 +1,6 @@ // Import stories explicitly to control order and avoid type casting through unknown. import { meta as accordionMeta } from '../stories/accordion.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; -import { meta as comboboxPrimitiveMeta } from '../stories/combobox.primitive.stories'; import { Fallback as AvatarFallbackStory, Interactive as AvatarInteractive, @@ -26,6 +25,7 @@ import { import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories'; import { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories'; import { meta as collapsibleMeta } from '../stories/collapsible.stories'; +import { meta as comboboxPrimitiveMeta } from '../stories/combobox.primitive.stories'; import { Default as DestructiveDefault, meta as destructiveMeta, From b1465cd20053f32d162ef2f6f5ff8feb03f153d4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 10 Sep 2026 10:14:25 -0600 Subject: [PATCH 6/7] refactor(headless): keep combobox changes out of autocomplete --- .../autocomplete/autocomplete-context.ts | 4 - .../autocomplete/autocomplete-popup.tsx | 7 +- .../autocomplete/autocomplete-positioner.tsx | 15 +- .../autocomplete/autocomplete-root.tsx | 31 +---- .../autocomplete/autocomplete-trigger.tsx | 42 ------ .../autocomplete/autocomplete.test.tsx | 131 +----------------- .../src/primitives/autocomplete/parts.ts | 1 - .../src/primitives/combobox/combobox.test.tsx | 116 ++++++++++++++++ 8 files changed, 129 insertions(+), 218 deletions(-) delete mode 100644 packages/headless/src/primitives/autocomplete/autocomplete-trigger.tsx diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-context.ts b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts index 4dd7327e5e0..3fae09aff96 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-context.ts +++ b/packages/headless/src/primitives/autocomplete/autocomplete-context.ts @@ -25,15 +25,11 @@ export interface AutocompleteContextValue { elementsRef: React.MutableRefObject>; labelsRef: React.MutableRefObject>; popupRef: React.RefObject; - triggerRef: React.MutableRefObject; arrowRef: React.MutableRefObject; valuesByIndexRef: React.MutableRefObject>; setInlineMode: React.Dispatch>; handleSelect: (value: string, index: number, label: string) => void; handleInputChange: (value: string) => void; - setOpen: (open: boolean) => void; - focusInput: () => void; - popupId: string | undefined; registerSelectedIndex: (index: number, value: string) => void; mounted: boolean; transitionProps: TransitionProps; diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx index b13b4b668eb..06d6a582fd3 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx @@ -2,19 +2,18 @@ import React from 'react'; -import { type ComponentProps, Freeze, mergeProps, useRender } from '../../utils'; +import { type ComponentProps, mergeProps, useRender } from '../../utils'; import { useAutocompleteContext } from './autocomplete-context'; export type AutocompletePopupProps = ComponentProps<'div'>; export const AutocompletePopup = React.forwardRef( function AutocompletePopup(props, ref) { - const { render, children, ...otherProps } = props; - const { open, popupRef, transitionProps } = useAutocompleteContext(); + const { render, ...otherProps } = props; + const { popupRef, transitionProps } = useAutocompleteContext(); const defaultProps = { ...transitionProps, - children: {children}, }; return useRender({ diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx index e4938707415..326ccdb654c 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-positioner.tsx @@ -6,25 +6,14 @@ import React from 'react'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useAutocompleteContext } from './autocomplete-context'; -export interface AutocompletePositionerProps extends ComponentProps<'div'> { - /** Element used for popup positioning. Defaults to the input. */ - anchor?: HTMLElement | null; -} +export type AutocompletePositionerProps = ComponentProps<'div'>; export const AutocompletePositioner = React.forwardRef( function AutocompletePositioner(props, ref) { - const { anchor, render, ...otherProps } = props; + const { render, ...otherProps } = props; const { mounted, floatingContext, refs, floatingStyles, placement, getFloatingProps, elementsRef, labelsRef } = useAutocompleteContext(); - React.useLayoutEffect(() => { - if (!anchor) { - return; - } - refs.setPositionReference(anchor); - return () => refs.setPositionReference(refs.domReference.current); - }, [anchor, refs]); - const side = placement.split('-')[0]; const floatingProps = getFloatingProps(); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx index aa70313c6bf..daa6f5581d5 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx @@ -57,11 +57,7 @@ function AutocompleteInner(props: AutocompleteProps) { const [selectedValue, setSelectedValue] = useControllableState( props.value, props.defaultValue, - value => { - if (value !== undefined) { - props.onValueChange?.(value); - } - }, + props.onValueChange as ((value: string | undefined) => void) | undefined, ); const [activeIndex, setActiveIndex] = useState(null); @@ -72,7 +68,6 @@ function AutocompleteInner(props: AutocompleteProps) { const labelsRef = useRef>([]); const arrowRef = useRef(null); const popupRef = useRef(null); - const triggerRef = useRef(null); const valuesByIndexRef = useRef>(new Map()); const registerSelectedIndex = useCallback( (index: number, value: string) => { @@ -130,13 +125,7 @@ function AutocompleteInner(props: AutocompleteProps) { const dismiss = useDismiss(floatingContext, { escapeKey: !inlineMode, - outsidePress(event) { - if (inlineMode) { - return false; - } - const target = event.target; - return !(target instanceof Node && triggerRef.current?.contains(target)); - }, + outsidePress: !inlineMode, bubbles: { escapeKey: inlineMode, outsidePress: inlineMode, @@ -154,15 +143,6 @@ function AutocompleteInner(props: AutocompleteProps) { }); const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([dismiss, role, listNav]); - const referenceProps = getReferenceProps(); - const popupId = typeof referenceProps['aria-controls'] === 'string' ? referenceProps['aria-controls'] : undefined; - - const focusInput = useCallback(() => { - const input = refs.domReference.current; - if (input instanceof HTMLElement) { - input.focus(); - } - }, [refs.domReference]); const handleSelect = useCallback( (value: string, index: number, label: string) => { @@ -206,15 +186,11 @@ function AutocompleteInner(props: AutocompleteProps) { elementsRef, labelsRef, popupRef, - triggerRef, arrowRef, valuesByIndexRef, setInlineMode, handleSelect, handleInputChange, - setOpen, - focusInput, - popupId, registerSelectedIndex, mounted, transitionProps, @@ -234,9 +210,6 @@ function AutocompleteInner(props: AutocompleteProps) { selectedIndex, handleSelect, handleInputChange, - setOpen, - focusInput, - popupId, registerSelectedIndex, mounted, transitionProps, diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-trigger.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-trigger.tsx deleted file mode 100644 index d1db84cd724..00000000000 --- a/packages/headless/src/primitives/autocomplete/autocomplete-trigger.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; - -import React from 'react'; - -import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; -import { useAutocompleteContext } from './autocomplete-context'; - -export type AutocompleteTriggerProps = ComponentProps<'button'>; - -export const AutocompleteTrigger = React.forwardRef( - function AutocompleteTrigger(props, ref) { - const { render, ...otherProps } = props; - const { open, setOpen, focusInput, popupId, triggerRef } = useAutocompleteContext(); - const state = { open }; - - const defaultProps = { - type: 'button', - tabIndex: -1, - 'aria-controls': popupId, - 'aria-expanded': open, - 'aria-haspopup': 'listbox', - onPointerDown(event: React.PointerEvent) { - event.preventDefault(); - }, - onClick() { - setOpen(!open); - focusInput(); - }, - } satisfies DefaultProps<'button'>; - - return useRender({ - defaultTagName: 'button', - render, - ref: [triggerRef, ref], - state, - stateAttributesMapping: { - open: (value: boolean): Record | null => (value ? { 'data-open': '' } : { 'data-closed': '' }), - }, - props: mergeProps<'button'>(defaultProps, otherProps), - }); - }, -); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx index 2d80af3fc67..18066c37bd7 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +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'; @@ -18,7 +18,7 @@ const fruits = [ function FilteredAutocomplete( props: { - onValueChange?: (value: string | undefined) => void; + onValueChange?: (value: string) => void; onInputValueChange?: (value: string) => void; defaultInputValue?: string; } = {}, @@ -77,76 +77,7 @@ function StaticAutocomplete(props: Partial { - it('measures a custom popup anchor while keeping keyboard focus on the input', async () => { - const user = userEvent.setup(); - const anchor = document.createElement('div'); - document.body.append(anchor); - const measureAnchor = vi.spyOn(anchor, 'getBoundingClientRect'); - - function AnchoredAutocomplete({ anchor }: { anchor?: HTMLElement }) { - return ( - - - - - Apple - - - - ); - } - - const { rerender } = render(); - const input = screen.getByRole('combobox', { name: 'Fruit' }); - await user.type(input, 'a'); - - await waitFor(() => expect(measureAnchor).toHaveBeenCalled()); - await user.keyboard('{ArrowDown}'); - expect(input).toHaveFocus(); - expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id); - - const measureInput = vi.spyOn(input, 'getBoundingClientRect'); - rerender(); - await waitFor(() => expect(measureInput).toHaveBeenCalled()); - expect(input).toHaveFocus(); - anchor.remove(); - }); - describe('open/close', () => { - it('toggles from a popup button while keeping focus on the input', async () => { - const user = userEvent.setup(); - const onOpenChange = vi.fn(); - render( - - - - - - Apple - - - , - ); - - const input = screen.getByRole('combobox'); - const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); - expect(trigger).toHaveAttribute('tabindex', '-1'); - expect(trigger).toHaveAttribute('aria-expanded', 'false'); - - await user.click(trigger); - - const listbox = screen.getByRole('listbox'); - expect(trigger).toHaveAttribute('aria-expanded', 'true'); - expect(trigger).toHaveAttribute('aria-controls', listbox.id); - expect(input).toHaveFocus(); - - await user.click(trigger); - - expect(onOpenChange).toHaveBeenLastCalledWith(false); - expect(trigger).toHaveAttribute('aria-expanded', 'false'); - expect(input).toHaveFocus(); - }); - it('opens when user types', async () => { const user = userEvent.setup(); render(); @@ -372,54 +303,6 @@ describe('Autocomplete', () => { }); describe('animation lifecycle', () => { - it('holds options during exit and shows updated options when reopened', () => { - function Fixture({ open, label }: { open: boolean; label: string }) { - return ( - - - - - {label} - - - - ); - } - - const { rerender } = render( - , - ); - const popup = screen.getByTestId('autocomplete-popup'); - Object.defineProperty(popup, 'getAnimations', { - value: () => [{ finished: new Promise(() => {}) }], - }); - - rerender( - , - ); - - expect(popup).toHaveAttribute('data-closed', ''); - expect(popup).toHaveTextContent('Apple'); - expect(popup).not.toHaveTextContent('Banana'); - - rerender( - , - ); - - expect(popup).toHaveAttribute('data-open', ''); - expect(screen.getByRole('option', { name: 'Banana' })).toBeInTheDocument(); - expect(popup).not.toHaveTextContent('Apple'); - }); - it('positioner is not rendered when closed', () => { render(); expect(document.querySelector('[data-testid="autocomplete-positioner"]')).not.toBeInTheDocument(); @@ -553,9 +436,7 @@ describe('Autocomplete', () => { }); describe('Autocomplete.List (inline mode)', () => { - function InlineAutocomplete( - props: { value?: string | undefined; onValueChange?: (value: string | undefined) => void } = {}, - ) { + function InlineAutocomplete(props: { value?: string; onValueChange?: (value: string) => void } = {}) { const [inputValue, setInputValue] = useState(''); const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); @@ -715,7 +596,7 @@ describe('Autocomplete', () => { it('shows selected state after selecting then remounting', async () => { function TestHarness() { const [mounted, setMounted] = useState(true); - const [value, setValue] = useState(undefined); + const [value, setValue] = useState(); return ( <> @@ -755,7 +636,7 @@ describe('Autocomplete', () => { describe('Autocomplete.List inside Popover', () => { function AutocompleteInPopover() { const [popoverOpen, setPopoverOpen] = useState(false); - const [selectedValue, setSelectedValue] = useState(undefined); + const [selectedValue, setSelectedValue] = useState(); const [inputValue, setInputValue] = useState(''); const selectedLabel = fruits.find(f => f.value === selectedValue)?.label; const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); @@ -994,7 +875,7 @@ describe('Autocomplete', () => { describe('Autocomplete.List inside Popover — edge cases', () => { function AutocompleteInPopoverFull() { const [popoverOpen, setPopoverOpen] = useState(false); - const [selectedValue, setSelectedValue] = useState(undefined); + const [selectedValue, setSelectedValue] = useState(); const [inputValue, setInputValue] = useState(''); const selectedLabel = fruits.find(f => f.value === selectedValue)?.label; const filtered = fruits.filter(f => f.label.toLowerCase().startsWith(inputValue.toLowerCase())); diff --git a/packages/headless/src/primitives/autocomplete/parts.ts b/packages/headless/src/primitives/autocomplete/parts.ts index c3246fee40a..8d184c6c6d3 100644 --- a/packages/headless/src/primitives/autocomplete/parts.ts +++ b/packages/headless/src/primitives/autocomplete/parts.ts @@ -1,6 +1,5 @@ export { type AutocompleteProps, AutocompleteRoot as Root } from './autocomplete-root'; export { type AutocompleteInputProps, AutocompleteInput as Input } from './autocomplete-input'; -export { type AutocompleteTriggerProps, AutocompleteTrigger as Trigger } from './autocomplete-trigger'; export { type AutocompletePortalProps, AutocompletePortal as Portal } from './autocomplete-portal'; export { type AutocompletePositionerProps, AutocompletePositioner as Positioner } from './autocomplete-positioner'; export { type AutocompletePopupProps, AutocompletePopup as Popup } from './autocomplete-popup'; diff --git a/packages/headless/src/primitives/combobox/combobox.test.tsx b/packages/headless/src/primitives/combobox/combobox.test.tsx index 6681f59d118..a3310040488 100644 --- a/packages/headless/src/primitives/combobox/combobox.test.tsx +++ b/packages/headless/src/primitives/combobox/combobox.test.tsx @@ -15,6 +15,122 @@ const fruits = [ ]; describe('Combobox', () => { + it('measures a custom popup anchor while keeping keyboard focus on the input', async () => { + const user = userEvent.setup(); + const anchor = document.createElement('div'); + document.body.append(anchor); + const measureAnchor = vi.spyOn(anchor, 'getBoundingClientRect'); + + function AnchoredCombobox({ anchor }: { anchor?: HTMLElement }) { + return ( + + + + + Apple + + + + ); + } + + const { rerender } = render(); + const input = screen.getByRole('combobox', { name: 'Fruit' }); + await user.type(input, 'a'); + + await waitFor(() => expect(measureAnchor).toHaveBeenCalled()); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveFocus(); + expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id); + + const measureInput = vi.spyOn(input, 'getBoundingClientRect'); + rerender(); + await waitFor(() => expect(measureInput).toHaveBeenCalled()); + expect(input).toHaveFocus(); + anchor.remove(); + }); + + it('toggles from a popup button while keeping focus on the input', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + + + + + + Apple + + + , + ); + + const input = screen.getByRole('combobox'); + const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); + expect(trigger).toHaveAttribute('tabindex', '-1'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + await user.click(trigger); + + const listbox = screen.getByRole('listbox'); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(trigger).toHaveAttribute('aria-controls', listbox.id); + expect(input).toHaveFocus(); + + await user.click(trigger); + + expect(onOpenChange).toHaveBeenLastCalledWith(false); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(input).toHaveFocus(); + }); + + it('holds options during exit and shows updated options when reopened', () => { + function Fixture({ open, label }: { open: boolean; label: string }) { + return ( + + + + + {label} + + + + ); + } + + const { rerender } = render( + , + ); + const popup = screen.getByTestId('combobox-popup'); + Object.defineProperty(popup, 'getAnimations', { + value: () => [{ finished: new Promise(() => {}) }], + }); + + rerender( + , + ); + + expect(popup).toHaveAttribute('data-closed', ''); + expect(popup).toHaveTextContent('Apple'); + expect(popup).not.toHaveTextContent('Banana'); + + rerender( + , + ); + + expect(popup).toHaveAttribute('data-open', ''); + expect(screen.getByRole('option', { name: 'Banana' })).toBeInTheDocument(); + expect(popup).not.toHaveTextContent('Apple'); + }); describe('selection-only input', () => { it('keeps the selected option when hover and keyboard highlight move', async () => { const user = userEvent.setup(); From e352c8c6bdcc16e92d19c5c2f5fadea9dbb5e05d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 10 Sep 2026 11:55:18 -0600 Subject: [PATCH 7/7] fix(headless): correct combobox refs and option cleanup --- .../primitives/combobox/combobox-arrow.tsx | 11 +- .../primitives/combobox/combobox-context.ts | 2 +- .../primitives/combobox/combobox-option.tsx | 3 +- .../src/primitives/combobox/combobox-root.tsx | 1 + .../src/primitives/combobox/combobox.test.tsx | 137 +++++++++++++++++- .../headless/src/primitives/combobox/index.ts | 1 + 6 files changed, 145 insertions(+), 10 deletions(-) diff --git a/packages/headless/src/primitives/combobox/combobox-arrow.tsx b/packages/headless/src/primitives/combobox/combobox-arrow.tsx index 1d499fe3096..5ebfa482e01 100644 --- a/packages/headless/src/primitives/combobox/combobox-arrow.tsx +++ b/packages/headless/src/primitives/combobox/combobox-arrow.tsx @@ -1,22 +1,23 @@ 'use client'; -import { FloatingArrow } from '@floating-ui/react'; +import { FloatingArrow, useMergeRefs } from '@floating-ui/react'; import React from 'react'; import { useComboboxContext } from './combobox-context'; -export type ComboboxArrowProps = React.ComponentPropsWithRef; +export type ComboboxArrowProps = Omit, 'context'>; -export function ComboboxArrow(props: ComboboxArrowProps) { +export const ComboboxArrow = React.forwardRef(function ComboboxArrow(props, ref) { const { floatingContext, arrowRef, placement } = useComboboxContext(); + const mergedRef = useMergeRefs([arrowRef, ref]); const side = placement.split('-')[0]; return ( ); -} +}); diff --git a/packages/headless/src/primitives/combobox/combobox-context.ts b/packages/headless/src/primitives/combobox/combobox-context.ts index 54dd1c89bdd..f8ac1d0d3fb 100644 --- a/packages/headless/src/primitives/combobox/combobox-context.ts +++ b/packages/headless/src/primitives/combobox/combobox-context.ts @@ -35,7 +35,7 @@ export interface ComboboxContextValue { setOpen: (open: boolean) => void; focusInput: () => void; popupId: string | undefined; - registerSelectedIndex: (index: number, value: string, label: string) => void; + registerSelectedIndex: (index: number, value: string, label: string) => (() => void) | undefined; mounted: boolean; transitionProps: TransitionProps; } diff --git a/packages/headless/src/primitives/combobox/combobox-option.tsx b/packages/headless/src/primitives/combobox/combobox-option.tsx index 3d84ad17734..5aa09a69863 100644 --- a/packages/headless/src/primitives/combobox/combobox-option.tsx +++ b/packages/headless/src/primitives/combobox/combobox-option.tsx @@ -31,9 +31,10 @@ export const ComboboxOption = React.forwardRef { map.delete(index); + unregisterSelectedIndex?.(); }; }, [index, value, displayLabel, disabled, valuesByIndexRef, registerSelectedIndex]); diff --git a/packages/headless/src/primitives/combobox/combobox-root.tsx b/packages/headless/src/primitives/combobox/combobox-root.tsx index f253a9c9a3e..cb06488bf01 100644 --- a/packages/headless/src/primitives/combobox/combobox-root.tsx +++ b/packages/headless/src/primitives/combobox/combobox-root.tsx @@ -111,6 +111,7 @@ function ComboboxInner(props: ComboboxProps) { labelsByValueRef.current.set(value, label); if (value === selectedValue) { setSelectedIndex(index); + return () => setSelectedIndex(current => (current === index ? null : current)); } }, [selectedValue], diff --git a/packages/headless/src/primitives/combobox/combobox.test.tsx b/packages/headless/src/primitives/combobox/combobox.test.tsx index a3310040488..6892f2203c8 100644 --- a/packages/headless/src/primitives/combobox/combobox.test.tsx +++ b/packages/headless/src/primitives/combobox/combobox.test.tsx @@ -1,9 +1,9 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; +import { createRef, StrictMode, useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Combobox } from './index'; +import { Combobox, type ComboboxTriggerProps } from './index'; afterEach(() => cleanup()); @@ -15,6 +15,136 @@ const fruits = [ ]; describe('Combobox', () => { + it('keeps selection aligned through repeated reordering, removal, and remounting in Strict Mode', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + function Fixture({ items, value }: { items: typeof fruits; value: string }) { + return ( + + + + + + {items.map(item => ( + + {item.label} + + ))} + + + + ); + } + const { rerender } = render( + , + ); + for (let cycle = 0; cycle < 5; cycle++) { + for (const items of [fruits, [...fruits].reverse(), fruits.filter(item => item.value !== 'banana'), fruits]) { + rerender( + , + ); + await user.click(screen.getByRole('button', { name: 'Toggle' })); + const banana = screen.queryByRole('option', { name: 'Banana' }); + if (banana) { + expect(banana).toHaveAttribute('data-active'); + expect(banana).toHaveAttribute('aria-selected', 'true'); + } else { + expect(screen.getAllByRole('option').every(option => option.getAttribute('aria-selected') === 'false')).toBe( + true, + ); + } + await user.click(screen.getByRole('button', { name: 'Toggle' })); + } + rerender( + item.value !== 'banana')} + value='cherry' + />, + ); + await user.click(screen.getByRole('button', { name: 'Toggle' })); + expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('data-active'); + await user.click(screen.getByRole('button', { name: 'Toggle' })); + } + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('forwards the arrow ref without requiring a floating context prop', async () => { + const ref = createRef(); + const { unmount } = render( + + + + + + + + , + ); + await waitFor(() => expect(ref.current).toBe(screen.getByTestId('arrow'))); + expect(ref.current).toHaveAttribute('data-side', 'bottom'); + unmount(); + expect(ref.current).toBeNull(); + }); + + it.each(['banana', 'cherry'])('handles option removal with %s selected', async selected => { + const user = userEvent.setup(); + function Fixture({ showBanana, value = 'banana' }: { showBanana: boolean; value?: string }) { + return ( + + + + + Apple + {showBanana && Banana} + Cherry + + + ); + } + const { rerender } = render(); + rerender( + , + ); + await user.click(screen.getByRole('button', { name: 'Open' })); + const cherry = screen.getByRole('option', { name: 'Cherry' }); + if (selected === 'cherry') { + expect(cherry).toHaveAttribute('data-active'); + expect(cherry).toHaveAttribute('aria-selected', 'true'); + } else { + expect(cherry).not.toHaveAttribute('data-active'); + } + }); + + it('requires an option around the selection indicator', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + expect(() => render()).toThrow( + 'Combobox.OptionIndicator must be used within Combobox.Option', + ); + } finally { + error.mockRestore(); + } + }); + it('measures a custom popup anchor while keeping keyboard focus on the input', async () => { const user = userEvent.setup(); const anchor = document.createElement('div'); @@ -52,11 +182,12 @@ describe('Combobox', () => { it('toggles from a popup button while keeping focus on the input', async () => { const user = userEvent.setup(); + const triggerProps: ComboboxTriggerProps = { 'aria-label': 'Toggle fruit options' }; const onOpenChange = vi.fn(); render( - + Apple diff --git a/packages/headless/src/primitives/combobox/index.ts b/packages/headless/src/primitives/combobox/index.ts index d0c2bfa49a5..b95784d9778 100644 --- a/packages/headless/src/primitives/combobox/index.ts +++ b/packages/headless/src/primitives/combobox/index.ts @@ -10,4 +10,5 @@ export type { ComboboxPortalProps, ComboboxPositionerProps, ComboboxProps, + ComboboxTriggerProps, } from './parts';