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/combobox/README.md b/packages/headless/src/primitives/combobox/README.md new file mode 100644 index 00000000000..03df4e759db --- /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 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..5ebfa482e01 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-arrow.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { FloatingArrow, useMergeRefs } from '@floating-ui/react'; +import React from 'react'; + +import { useComboboxContext } from './combobox-context'; + +export type ComboboxArrowProps = Omit, 'context'>; + +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-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..f8ac1d0d3fb --- /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) | undefined; + 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..5aa09a69863 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-option.tsx @@ -0,0 +1,85 @@ +'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); + } + const unregisterSelectedIndex = registerSelectedIndex(index, value, displayLabel); + return () => { + map.delete(index); + unregisterSelectedIndex?.(); + }; + }, [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 new file mode 100644 index 00000000000..cb06488bf01 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox-root.tsx @@ -0,0 +1,302 @@ +'use client'; + +import { + arrow, + autoUpdate, + flip, + FloatingNode, + FloatingTree, + offset, + type Placement, + size, + useDismiss, + useFloating, + useFloatingNodeId, + useFloatingParentNodeId, + useInteractions, + useListNavigation, + useRole, +} from '@floating-ui/react'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useControllableState } from '../../hooks/use-controllable-state'; +import { useTransition } from '../../hooks/use-transition'; +import { cssVars } from '../../utils/css-vars'; +import { 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); + return () => setSelectedIndex(current => (current === index ? null : current)); + } + }, + [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, + ], + ); + + 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/combobox.test.tsx b/packages/headless/src/primitives/combobox/combobox.test.tsx new file mode 100644 index 00000000000..6892f2203c8 --- /dev/null +++ b/packages/headless/src/primitives/combobox/combobox.test.tsx @@ -0,0 +1,554 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef, StrictMode, useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Combobox, type ComboboxTriggerProps } from './index'; + +afterEach(() => cleanup()); + +const fruits = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, +]; + +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'); + 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 triggerProps: ComboboxTriggerProps = { 'aria-label': 'Toggle fruit options' }; + 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(); + 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..b95784d9778 --- /dev/null +++ b/packages/headless/src/primitives/combobox/index.ts @@ -0,0 +1,14 @@ +export * as Combobox from './parts'; +export type { + ComboboxCollectionProps, + ComboboxArrowProps, + ComboboxInputProps, + ComboboxListProps, + ComboboxOptionProps, + ComboboxOptionIndicatorProps, + ComboboxPopupProps, + ComboboxPortalProps, + ComboboxPositionerProps, + ComboboxProps, + ComboboxTriggerProps, +} from './parts'; diff --git a/packages/headless/src/primitives/combobox/parts.ts b/packages/headless/src/primitives/combobox/parts.ts new file mode 100644 index 00000000000..84d9c792dd8 --- /dev/null +++ b/packages/headless/src/primitives/combobox/parts.ts @@ -0,0 +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 { + type ComboboxOptionIndicatorProps, + ComboboxOptionIndicator as OptionIndicator, +} from './combobox-option-indicator'; +export { type ComboboxArrowProps, ComboboxArrow as Arrow } from './combobox-arrow'; 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', 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..8e3006ea22e 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -25,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, @@ -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} + + + )} + + + + + + ); +}