From 120b7685790a7dd2248962bf0e28eeb21a91ae6d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 11 Nov 2026 08:52:35 -0500 Subject: [PATCH 1/4] feat(mosaic): add multiple selection to Combobox --- .changeset/mosaic-combobox-multiple.md | 2 + .../components/combobox/combobox.styles.ts | 5 + .../src/components/combobox/combobox.tsx | 129 ++++++++- .../mosaic/src/components/combobox/index.ts | 6 + .../components/tag-input/tag-input.styles.ts | 126 +-------- .../src/components/tag-input/tag-input.tsx | 11 +- .../combobox/combobox-chip-remove.tsx | 36 +++ .../src/primitives/combobox/combobox-chip.tsx | 56 ++++ .../combobox/combobox-chips-context.ts | 30 +++ .../primitives/combobox/combobox-chips.tsx | 102 ++++++++ .../primitives/combobox/combobox-clear.tsx | 4 +- .../primitives/combobox/combobox-context.ts | 8 +- .../primitives/combobox/combobox-input.tsx | 22 +- .../src/primitives/combobox/combobox-list.tsx | 4 +- .../primitives/combobox/combobox-option.tsx | 12 +- .../combobox/combobox-positioner.tsx | 16 +- .../src/primitives/combobox/combobox-root.tsx | 116 +++++++-- .../primitives/combobox/combobox-value.tsx | 27 ++ .../src/primitives/combobox/combobox.test.tsx | 245 ++++++++++++++++++ .../mosaic/src/primitives/combobox/index.ts | 6 + .../mosaic/src/primitives/combobox/parts.ts | 11 +- packages/mosaic/src/styles/chip.styles.ts | 130 ++++++++++ packages/mosaic/src/styles/index.ts | 3 + packages/swingset/src/stories/combobox.mdx | 58 ++++- .../swingset/src/stories/combobox.stories.tsx | 90 +++++++ 25 files changed, 1076 insertions(+), 179 deletions(-) create mode 100644 .changeset/mosaic-combobox-multiple.md create mode 100644 packages/mosaic/src/primitives/combobox/combobox-chip-remove.tsx create mode 100644 packages/mosaic/src/primitives/combobox/combobox-chip.tsx create mode 100644 packages/mosaic/src/primitives/combobox/combobox-chips-context.ts create mode 100644 packages/mosaic/src/primitives/combobox/combobox-chips.tsx create mode 100644 packages/mosaic/src/primitives/combobox/combobox-value.tsx create mode 100644 packages/mosaic/src/styles/chip.styles.ts diff --git a/.changeset/mosaic-combobox-multiple.md b/.changeset/mosaic-combobox-multiple.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-combobox-multiple.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/combobox/combobox.styles.ts b/packages/mosaic/src/components/combobox/combobox.styles.ts index 4ab0d21d55d..0b3c3fb3094 100644 --- a/packages/mosaic/src/components/combobox/combobox.styles.ts +++ b/packages/mosaic/src/components/combobox/combobox.styles.ts @@ -18,6 +18,11 @@ const inset = space['1.5']; const optionRadius = `max(0px, calc(${popupRadius} - ${inset}))`; export const styles = stylex.create({ + chips: { + padding: `calc(${space['1']} - 1px)`, + gap: space['1'], + minHeight: space['8'], + }, positioner: { outline: 'none', }, diff --git a/packages/mosaic/src/components/combobox/combobox.tsx b/packages/mosaic/src/components/combobox/combobox.tsx index 48a510851b1..c96196e70bc 100644 --- a/packages/mosaic/src/components/combobox/combobox.tsx +++ b/packages/mosaic/src/components/combobox/combobox.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -11,8 +12,12 @@ import { Combobox as HeadlessCombobox } from '../../primitives/combobox'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; +import { chipStyles } from '../../styles/chip.styles'; +import { focusOutline } from '../../styles/focus-outline.styles'; +import { inputStyles } from '../../styles/input.styles'; import { reset } from '../../styles/reset.styles'; import { Button } from '../button'; +import { useOptionalFieldControlProps } from '../field/field.context'; import { Icon } from '../icon'; import { Input, type InputVariant } from '../input'; import { useOptionalInputGroupContext } from '../input-group/input-group.context'; @@ -24,12 +29,16 @@ export type ComboboxSize = 'sm' | 'md' | 'lg'; export type ComboboxTriggerProps = MosaicComponentProps<'button'>; export type ComboboxClearProps = MosaicComponentProps<'button'>; export const ComboboxCollection = HeadlessCombobox.Collection; +export const ComboboxValue = HeadlessCombobox.Value; +export type ComboboxValueProps = React.ComponentProps; const ComboboxAnchorContext = React.createContext<{ anchor: HTMLElement | null; setAnchor: React.Dispatch>; } | null>(null); +const ComboboxInChipsContext = React.createContext(false); + export function ComboboxRoot({ sideOffset = 8, openOnInputClick = true, ...props }: ComboboxRootProps) { const [anchor, setAnchor] = React.useState(null); const context = React.useMemo(() => ({ anchor, setAnchor }), [anchor]); @@ -112,13 +121,28 @@ export const ComboboxInput = React.forwardRef { + if (inChips) { + return; + } setAnchor?.(groupElement ?? null); return () => setAnchor?.(null); - }, [groupElement, setAnchor]); + }, [groupElement, setAnchor, inChips]); const size = inputGroup?.size ?? sizeProp ?? 'md'; + if (inChips) { + return ( + + ); + } + return ( >(function MosaicComboboxChipsInput( + { + id, + disabled: disabledProp, + required: requiredProp, + 'aria-invalid': ariaInvalid, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + xstyle, + ...rest + }, + ref, +) { + const fieldProps = useOptionalFieldControlProps({ + id, + disabled: disabledProp, + required: requiredProp, + ariaInvalid, + ariaLabelledBy, + ariaDescribedBy, + }); + + return ( + + ); +}); + +export type ComboboxChipsProps = MosaicComponentProps<'div'>; + +/** The field for a `multiple` combobox. Holds the chips and the input, and anchors the popup. */ +export const ComboboxChips = React.forwardRef(function MosaicComboboxChips( + { xstyle, ...rest }, + ref, +) { + const setAnchor = React.useContext(ComboboxAnchorContext)?.setAnchor; + const [element, setElement] = React.useState(null); + const mergedRef = useMergeRefs([setElement, ref]); + React.useLayoutEffect(() => { + setAnchor?.(element); + return () => setAnchor?.(null); + }, [element, setAnchor]); + + return ( + + + + ); +}); + +export interface ComboboxChipProps extends MosaicComponentProps<'div'> { + value: string; + /** Accessible name of the chip's remove button. */ + removeLabel: string; +} + +export const ComboboxChip = React.forwardRef(function MosaicComboboxChip( + { removeLabel, children, xstyle, ...rest }, + ref, +) { + return ( + + + {children} + + + + + + ); +}); + export interface ComboboxPopupProps extends MosaicComponentProps<'div'> { /** Overrides positioning against the input group or standalone input. */ anchor?: React.ComponentPropsWithoutRef['anchor']; @@ -252,6 +376,9 @@ export const ComboboxEmpty = React.forwardRef stri {...itemProps} {...mergeStyleProps( themeProps('tag-input-tag', { invalid: tag.invalid, disabled }), - stylex.props(reset.base, focusOutline.visible, styles.tag, tag.invalid && styles.tagInvalid), + stylex.props(reset.base, focusOutline.visible, chipStyles.chip, tag.invalid && styles.tagInvalid), )} > - + {tag.value} (function Mo required={required && value.length === 0} aria-required={required || undefined} {...itemProps} - {...mergeStyleProps(themeProps('tag-input-input'), stylex.props(reset.base, styles.input), rest)} + {...mergeStyleProps(themeProps('tag-input-input'), stylex.props(reset.base, chipStyles.input), rest)} /> ); }); @@ -154,7 +155,7 @@ export const TagInput = React.forwardRef(functi focusableWhenDisabled={fieldProps?.focusableWhenDisabled} {...mergeStyleProps( themeProps('tag-input', { disabled, invalid }), - stylex.props(reset.base, inputStyles.group, styles.root, disabled && styles.disabled, xstyle), + stylex.props(reset.base, inputStyles.group, chipStyles.field, disabled && styles.disabled, xstyle), )} > diff --git a/packages/mosaic/src/primitives/combobox/combobox-chip-remove.tsx b/packages/mosaic/src/primitives/combobox/combobox-chip-remove.tsx new file mode 100644 index 00000000000..db025a6792a --- /dev/null +++ b/packages/mosaic/src/primitives/combobox/combobox-chip-remove.tsx @@ -0,0 +1,36 @@ +'use client'; + +import React from 'react'; + +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../utils'; +import { useComboboxChipValue } from './combobox-chips-context'; +import { useComboboxContext } from './combobox-context'; + +export type ComboboxChipRemoveProps = ComponentProps<'button'>; + +export const ComboboxChipRemove = React.forwardRef( + function ComboboxChipRemove(props, ref) { + const { render, ...otherProps } = props; + const { removeValue, focusInput } = useComboboxContext(); + const value = useComboboxChipValue(); + + const defaultProps = { + type: 'button', + tabIndex: -1, + onPointerDown(event: React.PointerEvent) { + event.preventDefault(); + }, + onClick() { + removeValue(value); + focusInput(); + }, + } satisfies DefaultProps<'button'>; + + return useRender({ + defaultTagName: 'button', + render, + ref, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/mosaic/src/primitives/combobox/combobox-chip.tsx b/packages/mosaic/src/primitives/combobox/combobox-chip.tsx new file mode 100644 index 00000000000..c557dd1f7b4 --- /dev/null +++ b/packages/mosaic/src/primitives/combobox/combobox-chip.tsx @@ -0,0 +1,56 @@ +'use client'; + +import React, { useCallback } from 'react'; + +import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../utils'; +import { ComboboxChipContext, useComboboxChipsContext } from './combobox-chips-context'; +import { useComboboxContext } from './combobox-context'; + +export interface ComboboxChipProps extends ComponentProps<'div'> { + value: string; +} + +export const ComboboxChip = React.forwardRef(function ComboboxChip(props, ref) { + const { render, value, ...otherProps } = props; + const { registerChip, navigate, removeChip } = useComboboxChipsContext(); + const { setOpen, focusInput } = useComboboxContext(); + + const registerRef = useCallback( + (element: HTMLDivElement | null) => registerChip(value, element), + [registerChip, value], + ); + + const defaultProps = { + tabIndex: -1, + onKeyDown(event: React.KeyboardEvent) { + if (event.target !== event.currentTarget) { + return; + } + if (event.key === 'Backspace' || event.key === 'Delete') { + event.preventDefault(); + removeChip(value, event.key === 'Backspace' ? 'previous' : 'next'); + return; + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setOpen(true); + focusInput(); + return; + } + if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + focusInput(); + return; + } + navigate(event); + }, + } satisfies DefaultProps<'div'>; + + const element = useRender({ + defaultTagName: 'div', + render, + ref: [registerRef, ref], + props: mergeProps<'div'>(defaultProps, otherProps), + }); + + return {element}; +}); diff --git a/packages/mosaic/src/primitives/combobox/combobox-chips-context.ts b/packages/mosaic/src/primitives/combobox/combobox-chips-context.ts new file mode 100644 index 00000000000..428ea195b06 --- /dev/null +++ b/packages/mosaic/src/primitives/combobox/combobox-chips-context.ts @@ -0,0 +1,30 @@ +import { createContext, useContext } from 'react'; + +import type { CompositeKeyEvent } from '../hooks/use-composite'; + +export interface ComboboxChipsContextValue { + registerChip: (value: string, element: HTMLElement | null) => void; + navigate: (event: CompositeKeyEvent) => void; + focusLastChip: () => void; + removeChip: (value: string, focusAfter: 'previous' | 'next') => void; +} + +export const ComboboxChipsContext = createContext(null); + +export function useComboboxChipsContext(): ComboboxChipsContextValue { + const ctx = useContext(ComboboxChipsContext); + if (!ctx) { + throw new Error('Combobox.Chip must be used within Combobox.Chips'); + } + return ctx; +} + +export const ComboboxChipContext = createContext(null); + +export function useComboboxChipValue(): string { + const value = useContext(ComboboxChipContext); + if (value === null) { + throw new Error('Combobox.ChipRemove must be used within Combobox.Chip'); + } + return value; +} diff --git a/packages/mosaic/src/primitives/combobox/combobox-chips.tsx b/packages/mosaic/src/primitives/combobox/combobox-chips.tsx new file mode 100644 index 00000000000..61a6e4e320c --- /dev/null +++ b/packages/mosaic/src/primitives/combobox/combobox-chips.tsx @@ -0,0 +1,102 @@ +'use client'; + +import React, { useCallback, useMemo, useRef, useState } from 'react'; + +import { readingDirectionKeys, useDirection } from '../direction-provider'; +import { useComposite } from '../hooks/use-composite'; +import { type ComponentProps, type DefaultProps, isHTMLElement, mergeProps, useRender } from '../utils'; +import { ComboboxChipsContext, type ComboboxChipsContextValue } from './combobox-chips-context'; +import { useComboboxContext } from './combobox-context'; + +export type ComboboxChipsProps = ComponentProps<'div'>; + +export const ComboboxChips = React.forwardRef(function ComboboxChips(props, ref) { + const { render, ...otherProps } = props; + const { selectedValues, removeValue, focusInput, refs, chipsRef } = useComboboxContext(); + + const chipRefs = useRef(new Map()); + const [element, setElement] = useState(null); + const direction = useDirection(); + const { previousKey, nextKey } = readingDirectionKeys(direction); + const presentChips = () => selectedValues.flatMap(value => chipRefs.current.get(value) ?? []); + + const composite = useComposite(element, { + getItems: presentChips, + orientation: 'horizontal', + loop: false, + rtl: direction === 'rtl', + stopPropagation: 'none', + syncTabIndexes: () => {}, + getNextItem: (event, target, defaultNext) => { + const chips = presentChips(); + if (event.key === nextKey && target === chips.at(-1)) { + const input = refs.domReference.current; + return isHTMLElement(input) ? input : null; + } + if (event.key === previousKey && target === chips[0]) { + event.preventDefault(); + return null; + } + return defaultNext(); + }, + }); + + const registerChip = useCallback((value: string, chip: HTMLElement | null) => { + if (chip) { + chipRefs.current.set(value, chip); + } else { + chipRefs.current.delete(value); + } + }, []); + + const focusLastChip = useCallback(() => { + const last = selectedValues.at(-1); + if (last !== undefined) { + chipRefs.current.get(last)?.focus(); + } + }, [selectedValues]); + + const removeChip = useCallback( + (value: string, focusAfter: 'previous' | 'next') => { + const index = selectedValues.indexOf(value); + const target = + focusAfter === 'previous' + ? (selectedValues[index - 1] ?? selectedValues[index + 1]) + : selectedValues[index + 1]; + const chip = target === undefined ? undefined : chipRefs.current.get(target); + if (chip) { + chip.focus(); + } else { + focusInput(); + } + removeValue(value); + }, + [selectedValues, removeValue, focusInput], + ); + + const contextValue = useMemo( + () => ({ registerChip, navigate: composite.handleKeyDown, focusLastChip, removeChip }), + [registerChip, composite.handleKeyDown, focusLastChip, removeChip], + ); + + const defaultProps = { + role: selectedValues.length > 0 ? 'toolbar' : undefined, + onMouseDown(event: React.MouseEvent) { + if (event.target === event.currentTarget) { + event.preventDefault(); + focusInput(); + } + }, + } satisfies DefaultProps<'div'>; + + return ( + + {useRender({ + defaultTagName: 'div', + render, + ref: [setElement, chipsRef, ref], + props: mergeProps<'div'>(defaultProps, otherProps), + })} + + ); +}); diff --git a/packages/mosaic/src/primitives/combobox/combobox-clear.tsx b/packages/mosaic/src/primitives/combobox/combobox-clear.tsx index 801634e9c85..4e948dc2fc1 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-clear.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-clear.tsx @@ -10,7 +10,7 @@ export type ComboboxClearProps = ComponentProps<'button'>; export const ComboboxClear = React.forwardRef( function ComboboxClear(props, ref) { const { render, ...otherProps } = props; - const { inputValue, selectedValue, clear } = useComboboxContext(); + const { inputValue, selectedValues, clear } = useComboboxContext(); const defaultProps = { type: 'button', @@ -31,6 +31,6 @@ export const ComboboxClear = React.forwardRef(defaultProps, otherProps), }); - return inputValue !== '' || selectedValue !== null ? element : null; + return inputValue !== '' || selectedValues.length > 0 ? element : null; }, ); diff --git a/packages/mosaic/src/primitives/combobox/combobox-context.ts b/packages/mosaic/src/primitives/combobox/combobox-context.ts index 941b2428791..1d59e26ad42 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-context.ts +++ b/packages/mosaic/src/primitives/combobox/combobox-context.ts @@ -14,7 +14,10 @@ export interface ComboboxContextValue { clear: () => void; inputValue: string; filterQuery: string; - selectedValue: string | null; + multiple: boolean; + selectedValues: string[]; + labelsByValueRef: React.MutableRefObject>; + removeValue: (value: string) => void; floatingContext: FloatingContext; refs: ExtendedRefs; floatingStyles: CSSProperties; @@ -27,6 +30,7 @@ export interface ComboboxContextValue { labelsRef: React.MutableRefObject>; popupRef: React.RefObject; triggerRef: React.MutableRefObject; + chipsRef: React.MutableRefObject; arrowRef: React.MutableRefObject; valuesByIndexRef: React.MutableRefObject>; setInlineMode: React.Dispatch>; @@ -60,7 +64,7 @@ export interface ComboboxOptionsContextValue { export interface ComboboxHighlightContextValue { activeIndex: number | null; - selectedValue: string | null; + selectedValues: string[]; } export const ComboboxHighlightContext = createContext(null); diff --git a/packages/mosaic/src/primitives/combobox/combobox-input.tsx b/packages/mosaic/src/primitives/combobox/combobox-input.tsx index c6c59f5d8de..57b17e57196 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-input.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-input.tsx @@ -1,8 +1,10 @@ 'use client'; -import React from 'react'; +import React, { useContext } from 'react'; +import { readingDirectionKeys, useDirection } from '../direction-provider'; import { type ComponentProps, mergeProps, useRender } from '../utils'; +import { ComboboxChipsContext } from './combobox-chips-context'; import { useComboboxContext } from './combobox-context'; export type ComboboxInputProps = ComponentProps<'input'>; @@ -19,7 +21,11 @@ export const ComboboxInput = React.forwardRef) { + const last = selectedValues.at(-1); + const { selectionStart, selectionEnd } = event.currentTarget; + if (chips && last !== undefined && selectionStart === 0 && selectionEnd === 0) { + if (event.key === 'Backspace') { + event.preventDefault(); + removeValue(last); + return; + } + if (event.key === previousKey) { + event.preventDefault(); + chips.focusLastChip(); + return; + } + } if (event.key === 'Enter' && activeIndex != null) { const value = valuesByIndexRef.current.get(activeIndex); const label = labelsRef.current[activeIndex]; diff --git a/packages/mosaic/src/primitives/combobox/combobox-list.tsx b/packages/mosaic/src/primitives/combobox/combobox-list.tsx index 0cde102f1fd..70967cfdf0f 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-list.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-list.tsx @@ -10,7 +10,7 @@ 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(); + const { multiple, elementsRef, labelsRef, refs, getFloatingProps, setInlineMode } = useComboboxContext(); useEffect(() => { setInlineMode(true); @@ -20,7 +20,7 @@ export const ComboboxList = React.forwardRef( const floatingProps = getFloatingProps(); const wiredId = floatingProps['id']; - const ownProps = {} satisfies DefaultProps<'div'>; + const ownProps = { 'aria-multiselectable': multiple || undefined } satisfies DefaultProps<'div'>; const defaultProps = { ...ownProps, ...floatingProps }; diff --git a/packages/mosaic/src/primitives/combobox/combobox-option.tsx b/packages/mosaic/src/primitives/combobox/combobox-option.tsx index 9dd1c19d31c..7d77ebc1ec2 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-option.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-option.tsx @@ -15,7 +15,7 @@ export interface ComboboxOptionProps extends ComponentProps<'div'> { export const ComboboxOption = React.forwardRef( function ComboboxOption(props, ref) { - const { activeIndex, selectedValue } = useComboboxHighlightContext(); + const { activeIndex, selectedValues } = useComboboxHighlightContext(); const { ref: itemRef, index } = useListItem({ label: props.label ?? props.value }); return ( ); }, @@ -36,10 +37,11 @@ interface ComboboxOptionBodyProps extends ComboboxOptionProps { index: number; isActive: boolean; isSelected: boolean; + isAnchor: boolean; } const ComboboxOptionBody = React.memo(function ComboboxOptionBody(bodyProps: ComboboxOptionBodyProps) { - const { forwardedRef: ref, listItemRef: itemRef, index, isActive, isSelected, ...props } = bodyProps; + const { forwardedRef: ref, listItemRef: itemRef, index, isActive, isSelected, isAnchor, ...props } = bodyProps; const { render, value, label, disabled, ...otherProps } = props; const { getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, registerOption, refs } = useComboboxOptionsContext(); @@ -52,13 +54,13 @@ const ComboboxOptionBody = React.memo(function ComboboxOptionBody(bodyProps: Com if (!disabled) { map.set(index, value); } - const unregisterSelectedIndex = registerSelectedIndex(index, value, displayLabel, isSelected); + const unregisterSelectedIndex = registerSelectedIndex(index, value, displayLabel, isAnchor); registerOption(index); return () => { map.delete(index); unregisterSelectedIndex?.(); }; - }, [index, value, displayLabel, disabled, isSelected, valuesByIndexRef, registerSelectedIndex, registerOption]); + }, [index, value, displayLabel, disabled, isAnchor, valuesByIndexRef, registerSelectedIndex, registerOption]); const state = { selected: isSelected, diff --git a/packages/mosaic/src/primitives/combobox/combobox-positioner.tsx b/packages/mosaic/src/primitives/combobox/combobox-positioner.tsx index 1b8018debf1..61fc0db447a 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-positioner.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-positioner.tsx @@ -15,8 +15,18 @@ export interface ComboboxPositionerProps extends ComponentProps<'div'> { export const ComboboxPositioner = React.forwardRef( function ComboboxPositioner(props, ref) { const { anchor, render, ...otherProps } = props; - const { mounted, floatingContext, refs, floatingStyles, placement, getFloatingProps, elementsRef, labelsRef } = - useComboboxContext(); + const { + mounted, + multiple, + floatingContext, + refs, + floatingStyles, + placement, + getFloatingProps, + elementsRef, + labelsRef, + chipsRef, + } = useComboboxContext(); const restoreReference = React.useCallback(() => refs.setPositionReference(refs.domReference.current), [refs]); @@ -35,6 +45,7 @@ export const ComboboxPositioner = React.forwardRef; @@ -67,6 +78,7 @@ export const ComboboxPositioner = React.forwardRef (chipsRef.current ? [chipsRef.current] : [])} > void; - /** Controlled selected value. */ - value?: string | null; - defaultValue?: string | null; - onValueChange?: (value: string | null) => void; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; @@ -53,8 +49,37 @@ export interface ComboboxProps { openOnInputClick?: boolean; } +export interface ComboboxSingleProps extends ComboboxBaseProps { + multiple?: false; + /** Controlled selected value. */ + value?: string | null; + defaultValue?: string | null; + onValueChange?: (value: string | null) => void; +} + +export interface ComboboxMultipleProps extends ComboboxBaseProps { + /** Lets several options be selected. Picking an option toggles it, clears the query and keeps the popup open. */ + multiple: true; + /** Controlled selected values, in the order they were picked. */ + value?: string[]; + defaultValue?: string[]; + onValueChange?: (value: string[]) => void; +} + +export type ComboboxProps = ComboboxSingleProps | ComboboxMultipleProps; + +function toValues(value: string | null | undefined): string[] | undefined { + if (value === undefined) { + return undefined; + } + return value === null ? [] : [value]; +} + function ComboboxInner(props: ComboboxProps) { const { placement: placementProp = 'bottom-start', sideOffset = 4, children } = props; + const multiple = props.multiple === true; + const onSingleValueChange = props.multiple ? undefined : props.onValueChange; + const onMultipleValueChange = props.multiple ? props.onValueChange : undefined; const nodeId = useFloatingNodeId(); @@ -66,17 +91,27 @@ function ComboboxInner(props: ComboboxProps) { props.onInputValueChange, ); - const [selectedValue, setSelectedValue] = useControllableState( - props.value, - props.defaultValue ?? null, - props.onValueChange, + const onSelectedValuesChange = useCallback( + (values: string[]) => { + onMultipleValueChange?.(values); + onSingleValueChange?.(values[0] ?? null); + }, + [onSingleValueChange, onMultipleValueChange], ); + const singleValue = props.multiple ? undefined : props.value; + const controlledSingleValues = useMemo(() => toValues(singleValue), [singleValue]); + const [selectedValues, setSelectedValues] = useControllableState( + props.multiple ? props.value : controlledSingleValues, + (props.multiple ? props.defaultValue : toValues(props.defaultValue)) ?? [], + onSelectedValuesChange, + ); + const selectedValue = multiple ? null : (selectedValues[0] ?? null); 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 initialValue = props.multiple ? undefined : (props.value ?? props.defaultValue); const labelsByValueRef = useRef( new Map( initialValue != null && props.defaultInputValue !== undefined ? [[initialValue, props.defaultInputValue]] : [], @@ -84,6 +119,7 @@ function ComboboxInner(props: ComboboxProps) { ); const highlightFirstRef = useRef(false); + const highlightValueRef = useRef(null); const setOpen = useCallback( (nextOpen: boolean) => { @@ -120,6 +156,7 @@ function ComboboxInner(props: ComboboxProps) { const arrowRef = useRef(null); const popupRef = useRef(null); const triggerRef = useRef(null); + const chipsRef = useRef(null); const valuesByIndexRef = useRef>(new Map()); const registerOption = useCallback((index: number) => { if (index === 0 && highlightFirstRef.current) { @@ -129,6 +166,10 @@ function ComboboxInner(props: ComboboxProps) { }, []); const registerSelectedIndex = useCallback((index: number, value: string, label: string, selected: boolean) => { labelsByValueRef.current.set(value, label); + if (highlightValueRef.current === value) { + highlightValueRef.current = null; + setActiveIndex(index); + } if (!selected) { return undefined; } @@ -189,7 +230,7 @@ function ComboboxInner(props: ComboboxProps) { return false; } const target = event.target; - return !(target instanceof Node && triggerRef.current?.contains(target)); + return !(target instanceof Node && (triggerRef.current?.contains(target) || chipsRef.current?.contains(target))); }, bubbles: { escapeKey: inlineMode, @@ -222,21 +263,46 @@ function ComboboxInner(props: ComboboxProps) { const handleSelect = useCallback( (value: string, index: number, label: string) => { labelsByValueRef.current.set(value, label); - setSelectedValue(value); + if (multiple) { + const next = selectedValues.includes(value) + ? selectedValues.filter(selected => selected !== value) + : [...selectedValues, value]; + setSelectedValues(next); + if (next.length === 0) { + setSelectedIndex(null); + } + if (inputValue !== '') { + highlightValueRef.current = value; + setInputValue(''); + } + return; + } + setSelectedValues([value]); setSelectedIndex(index); setInputValue(props.inline ? '' : label); setActiveIndex(null); setOpen(false); }, - [props.inline, setSelectedValue, setInputValue, setOpen], + [multiple, selectedValues, inputValue, props.inline, setSelectedValues, setInputValue, setOpen], + ); + + const removeValue = useCallback( + (value: string) => { + const next = selectedValues.filter(selected => selected !== value); + setSelectedValues(next); + if (next.length === 0) { + setSelectedIndex(null); + } + }, + [selectedValues, setSelectedValues], ); const handleInputChange = useCallback( (value: string) => { setQueryChanged(true); setInputValue(value); - if (value === '' && !props.inline) { - setSelectedValue(null); + if (value === '' && !props.inline && !multiple) { + setSelectedValues([]); setSelectedIndex(null); } setOpenState(true); @@ -247,17 +313,17 @@ function ComboboxInner(props: ComboboxProps) { setActiveIndex(null); } }, - [props.inline, setInputValue, setOpenState, setSelectedValue], + [props.inline, multiple, setInputValue, setOpenState, setSelectedValues], ); const clear = useCallback(() => { setQueryChanged(false); setInputValue(''); - setSelectedValue(null); + setSelectedValues([]); setSelectedIndex(null); setActiveIndex(null); focusInput(); - }, [focusInput, setInputValue, setSelectedValue]); + }, [focusInput, setInputValue, setSelectedValues]); const contextValue = useMemo( () => ({ @@ -265,7 +331,10 @@ function ComboboxInner(props: ComboboxProps) { clear, inputValue, filterQuery, - selectedValue, + multiple, + selectedValues, + labelsByValueRef, + removeValue, floatingContext, refs, floatingStyles, @@ -278,6 +347,7 @@ function ComboboxInner(props: ComboboxProps) { labelsRef, popupRef, triggerRef, + chipsRef, arrowRef, valuesByIndexRef, setInlineMode, @@ -294,7 +364,9 @@ function ComboboxInner(props: ComboboxProps) { clear, inputValue, filterQuery, - selectedValue, + multiple, + selectedValues, + removeValue, floatingContext, refs, floatingStyles, @@ -326,8 +398,8 @@ function ComboboxInner(props: ComboboxProps) { ); const highlightContextValue = useMemo( - () => ({ activeIndex, selectedValue }), - [activeIndex, selectedValue], + () => ({ activeIndex, selectedValues }), + [activeIndex, selectedValues], ); return ( diff --git a/packages/mosaic/src/primitives/combobox/combobox-value.tsx b/packages/mosaic/src/primitives/combobox/combobox-value.tsx new file mode 100644 index 00000000000..117118c92f3 --- /dev/null +++ b/packages/mosaic/src/primitives/combobox/combobox-value.tsx @@ -0,0 +1,27 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { useComboboxContext } from './combobox-context'; + +export interface ComboboxValueProps { + placeholder?: ReactNode; + /** Replaces the selected labels. A function receives the selected values in the order they were picked. */ + children?: ReactNode | ((value: string[]) => ReactNode); +} + +export function ComboboxValue({ placeholder, children }: ComboboxValueProps) { + const { selectedValues, labelsByValueRef } = useComboboxContext(); + + if (typeof children === 'function') { + return <>{children(selectedValues)}; + } + if (children != null) { + return <>{children}; + } + if (selectedValues.length === 0) { + return <>{placeholder}; + } + // eslint-disable-next-line react/refs -- Options write their labels before the selection changes, so this render already sees them. + return <>{selectedValues.map(value => labelsByValueRef.current.get(value) ?? value).join(', ')}; +} diff --git a/packages/mosaic/src/primitives/combobox/combobox.test.tsx b/packages/mosaic/src/primitives/combobox/combobox.test.tsx index c7558ab798d..8c63a778639 100644 --- a/packages/mosaic/src/primitives/combobox/combobox.test.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox.test.tsx @@ -885,3 +885,248 @@ describe('Combobox selection in a transition', () => { expect(screen.getByRole('option', { name: 'Banana' })).not.toHaveAttribute('data-active'); }); }); + +describe('Combobox multiple', () => { + function labelOf(value: string) { + return fruits.find(fruit => fruit.value === value)?.label ?? value; + } + + function Multiple(props: { + value?: string[]; + defaultValue?: string[]; + onValueChange?: (value: string[]) => void; + chips?: boolean; + }) { + const { chips = true, ...rootProps } = props; + return ( + + {chips ? ( + + + {values => + values.map(value => ( + + {labelOf(value)} + + + )) + } + + + + ) : ( + <> + + + + + + )} + + + + + fruit.label} + > + {fruit => ( + + {fruit.label} + + )} + + + + + + ); + } + + function chipNames() { + return Array.from(screen.queryAllByTestId('chip'), chip => chip.getAttribute('aria-label')); + } + + it('toggles options in pick order and keeps the popup open', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: 'Show fruits' })); + expect(screen.getByRole('listbox')).toHaveAttribute('aria-multiselectable', 'true'); + + await user.click(screen.getByRole('option', { name: 'Cherry' })); + await user.click(screen.getByRole('option', { name: 'Apple' })); + expect(onValueChange).toHaveBeenLastCalledWith(['cherry', 'apple']); + expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('combobox')).toHaveFocus(); + expect(chipNames()).toEqual(['Cherry', 'Apple']); + + await user.click(screen.getByRole('option', { name: 'Cherry' })); + expect(onValueChange).toHaveBeenLastCalledWith(['apple']); + expect(chipNames()).toEqual(['Apple']); + }); + + it('clears the query after a pick from a filtered list and keeps that option highlighted', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + const input = screen.getByRole('combobox'); + await user.type(input, 'ch'); + expect(screen.getAllByRole('option')).toHaveLength(1); + await user.keyboard('{Enter}'); + + expect(onValueChange).toHaveBeenLastCalledWith(['cherry']); + expect(input).toHaveValue(''); + expect(input).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getAllByRole('option')).toHaveLength(fruits.length); + await waitFor(() => expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('data-active')); + }); + + it('never shows a label in the input and drops the query on close', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('combobox'); + const outside = screen.getByRole('button', { name: 'Outside' }); + expect(input).toHaveValue(''); + await user.type(input, 'zz'); + await user.click(outside); + await waitFor(() => expect(input).toHaveValue('')); + expect(chipNames()).toEqual(['Banana']); + }); + + it('shows the placeholder, then the selected labels joined with commas', async () => { + const user = userEvent.setup(); + render(); + expect(screen.getByTestId('value')).toHaveTextContent('No fruits'); + await user.click(screen.getByRole('button', { name: 'Show fruits' })); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await user.click(screen.getByRole('option', { name: 'Apple' })); + expect(screen.getByTestId('value')).toHaveTextContent('Banana, Apple'); + }); + + it('removes the last chip with Backspace in an empty input only', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + const input = screen.getByRole('combobox'); + await user.type(input, 'x'); + await user.keyboard('{Backspace}'); + expect(onValueChange).not.toHaveBeenCalled(); + await user.keyboard('{Backspace}'); + expect(onValueChange).toHaveBeenLastCalledWith(['apple']); + expect(chipNames()).toEqual(['Apple']); + expect(input).toHaveFocus(); + }); + + it('moves between the chips and the input with the arrow keys', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByRole('combobox'); + await user.type(input, 'a'); + await user.keyboard('{ArrowLeft}'); + expect(input).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Banana'); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Apple'); + await user.keyboard('{ArrowLeft}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Apple'); + await user.keyboard('{ArrowRight}{ArrowRight}'); + expect(input).toHaveFocus(); + }); + + it('removes a focused chip and moves focus to its neighbour', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + const input = screen.getByRole('combobox'); + await user.click(input); + await user.keyboard('{Escape}{ArrowLeft}{ArrowLeft}'); + expect(document.activeElement).toHaveAttribute('aria-label', 'Banana'); + + await user.keyboard('{Backspace}'); + expect(onValueChange).toHaveBeenLastCalledWith(['apple', 'cherry']); + expect(document.activeElement).toHaveAttribute('aria-label', 'Apple'); + + await user.keyboard('{Delete}'); + expect(onValueChange).toHaveBeenLastCalledWith(['cherry']); + expect(document.activeElement).toHaveAttribute('aria-label', 'Cherry'); + + await user.keyboard('{Delete}'); + expect(onValueChange).toHaveBeenLastCalledWith([]); + expect(input).toHaveFocus(); + }); + + it('removes a chip from its remove button without opening the popup', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Remove Apple' })); + expect(onValueChange).toHaveBeenLastCalledWith(['banana']); + expect(chipNames()).toEqual(['Banana']); + expect(screen.getByRole('combobox')).toHaveFocus(); + expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false'); + }); + + it('clears every selected value', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(onValueChange).toHaveBeenLastCalledWith([]); + expect(chipNames()).toEqual([]); + expect(screen.queryByRole('button', { name: 'Clear' })).toBeNull(); + }); + + it('keeps the controlled selection when the parent rejects a change', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('button', { name: 'Show fruits' })); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await user.click(screen.getByRole('button', { name: 'Remove Apple' })); + expect(onValueChange.mock.calls).toEqual([[['apple', 'banana']], [[]]]); + expect(chipNames()).toEqual(['Apple']); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'false'); + }); +}); diff --git a/packages/mosaic/src/primitives/combobox/index.ts b/packages/mosaic/src/primitives/combobox/index.ts index e73f0b1d571..547313af4bf 100644 --- a/packages/mosaic/src/primitives/combobox/index.ts +++ b/packages/mosaic/src/primitives/combobox/index.ts @@ -2,14 +2,20 @@ export * as Combobox from './parts'; export type { ComboboxCollectionProps, ComboboxArrowProps, + ComboboxChipProps, + ComboboxChipRemoveProps, + ComboboxChipsProps, ComboboxClearProps, ComboboxInputProps, ComboboxListProps, + ComboboxMultipleProps, ComboboxOptionProps, ComboboxOptionIndicatorProps, ComboboxPopupProps, ComboboxPortalProps, ComboboxPositionerProps, ComboboxProps, + ComboboxSingleProps, ComboboxTriggerProps, + ComboboxValueProps, } from './parts'; diff --git a/packages/mosaic/src/primitives/combobox/parts.ts b/packages/mosaic/src/primitives/combobox/parts.ts index b84357d1e3c..72b4a49c126 100644 --- a/packages/mosaic/src/primitives/combobox/parts.ts +++ b/packages/mosaic/src/primitives/combobox/parts.ts @@ -1,4 +1,13 @@ -export { type ComboboxProps, ComboboxRoot as Root } from './combobox-root'; +export { + type ComboboxMultipleProps, + type ComboboxProps, + type ComboboxSingleProps, + ComboboxRoot as Root, +} from './combobox-root'; +export { type ComboboxValueProps, ComboboxValue as Value } from './combobox-value'; +export { type ComboboxChipsProps, ComboboxChips as Chips } from './combobox-chips'; +export { type ComboboxChipProps, ComboboxChip as Chip } from './combobox-chip'; +export { type ComboboxChipRemoveProps, ComboboxChipRemove as ChipRemove } from './combobox-chip-remove'; export { type ComboboxCollectionProps, ComboboxCollection as Collection } from './combobox-collection'; export { type ComboboxInputProps, ComboboxInput as Input } from './combobox-input'; export { type ComboboxClearProps, ComboboxClear as Clear } from './combobox-clear'; diff --git a/packages/mosaic/src/styles/chip.styles.ts b/packages/mosaic/src/styles/chip.styles.ts new file mode 100644 index 00000000000..920209be879 --- /dev/null +++ b/packages/mosaic/src/styles/chip.styles.ts @@ -0,0 +1,130 @@ +import * as stylex from '@stylexjs/stylex'; + +import { + colorVars, + durationVars, + easingVars, + fontFamilyVars, + radiusVars, + space, + typeScaleVars, +} from '../tokens.stylex'; + +export const chipStyles = stylex.create({ + field: { + '--cl-layout-duration': durationVars['--cl-duration-base'], + '--cl-layout-easing': easingVars['--cl-ease-enter'], + padding: space['2'], + borderRadius: radiusVars['--cl-radius-md'], + gap: space['2'], + alignContent: 'flex-start', + alignItems: 'center', + cursor: 'text', + display: 'flex', + flexWrap: 'wrap', + fontFamily: fontFamilyVars['--cl-font-family-sans'], + position: 'relative', + minHeight: space['20'], + width: '100%', + }, + input: { + borderStyle: 'none', + borderWidth: 0, + outline: 'none', + paddingBlock: 0, + paddingInline: space['1'], + backgroundColor: 'transparent', + color: colorVars['--cl-color-foreground'], + cursor: { default: null, ':disabled': 'not-allowed', ':read-only': 'not-allowed' }, + flexBasis: '8ch', + flexGrow: 1, + fontFamily: 'inherit', + fontSize: { + default: typeScaleVars['--cl-text-sm-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-sm-size']})`, + }, + lineHeight: typeScaleVars['--cl-text-sm-leading'], + height: space['6'], + minWidth: 0, + '::placeholder': { + color: colorVars['--cl-color-input-placeholder'], + }, + }, + chip: { + borderRadius: radiusVars['--cl-radius-sm'], + paddingInline: space['1'], + alignItems: 'center', + backgroundColor: colorVars['--cl-color-neutral-alpha-100'], + color: colorVars['--cl-color-foreground'], + cursor: 'default', + display: 'inline-flex', + fontSize: { + default: typeScaleVars['--cl-text-sm-size'], + '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--cl-text-sm-size']})`, + }, + lineHeight: typeScaleVars['--cl-text-sm-leading'], + opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0 }, + transform: { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(0.9)', + '@media (prefers-reduced-motion: reduce)': { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(1)', + }, + }, + transitionDuration: { + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionProperty: { + default: 'opacity, transform', + '@media (prefers-reduced-motion: reduce)': 'opacity', + }, + transitionTimingFunction: { + default: `${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': easingVars['--cl-ease-exit'], + }, + height: space['6'], + maxWidth: '100%', + minWidth: 0, + }, + label: { + overflow: 'hidden', + paddingInline: space['1'], + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + remove: { + padding: 0, + borderRadius: radiusVars['--cl-radius-sm'], + borderStyle: 'none', + borderWidth: 0, + alignItems: 'center', + backgroundColor: { + default: 'transparent', + ':enabled:active': colorVars['--cl-color-neutral-alpha-300'], + '@media (hover: hover)': { + default: null, + ':hover:not(:disabled, [data-disabled], [aria-disabled="true"], :active)': + colorVars['--cl-color-neutral-alpha-200'], + }, + }, + color: 'inherit', + cursor: { default: 'pointer', ':disabled': 'not-allowed' }, + display: 'inline-flex', + flexShrink: 0, + justifyContent: 'center', + transitionDuration: { + default: durationVars['--cl-duration-base'], + ':enabled:active': durationVars['--cl-duration-instant'], + '@media (hover: hover)': { + default: null, + ':hover:not(:disabled, [data-disabled], [aria-disabled="true"])': durationVars['--cl-duration-instant'], + }, + }, + transitionProperty: 'background-color', + transitionTimingFunction: 'linear', + height: space['4'], + width: space['4'], + }, +}); diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index bcb936fa3da..0959f87ceb0 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -36,6 +36,8 @@ export type { } from '../components/empty-state'; export { Combobox } from '../components/combobox'; export type { + ComboboxChipProps, + ComboboxChipsProps, ComboboxEmptyProps, ComboboxInputProps, ComboboxListProps, @@ -43,6 +45,7 @@ export type { ComboboxPopupProps, ComboboxRootProps, ComboboxSize, + ComboboxValueProps, } from '../components/combobox'; export { DataList } from '../components/data-list'; export type { DataListProps } from '../components/data-list'; diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx index 7da9d9648ff..c3639f4524c 100644 --- a/packages/swingset/src/stories/combobox.mdx +++ b/packages/swingset/src/stories/combobox.mdx @@ -11,6 +11,39 @@ import * as ComboboxStories from './combobox.stories'; `value` is the selected option; `inputValue` is the search text. Clearing the input clears the selection. +## Multiple + +Set `multiple` to let several options be picked. `value` is then an array in pick order. Picking an +option toggles it, clears the search and keeps the popup open. `Combobox.Value` passes the selected +values to its children, so each one can render as a `Chip`. Backspace in an empty input removes the +last chip, and the left arrow moves from the input to the chips. + + + +Chips are optional. Like `Select.Value`, `Combobox.Value` takes a function of the selected values, +so a plain input can show a count instead. + + + +```tsx + + + + + {value => (value.length > 0 ? `${value.length} selected` : null)} + + + + {/* popup */} + +``` + ## Inline lists Use `List` inside an existing popover. Set `inline` and bind the root's open state to that popover @@ -45,14 +78,17 @@ so closing resets the search, not the selection. ## Parts -| Part | Description | -| --------------------- | --------------------------------------------------------------------------- | -| `Combobox.Root` | Owns input, selection, open state, and keyboard navigation. | -| `Combobox.Input` | Search input; renders Mosaic `Input` unless composed through another input. | -| `Combobox.Trigger` | Opens and closes the option list while keeping focus on the input. | -| `Combobox.Clear` | Empties the input and selection; shown only while there is a value. | -| `Combobox.Popup` | Portals, positions, surfaces, and scrolls a floating option list. | -| `Combobox.List` | Scrollable inline listbox. | -| `Combobox.Collection` | Filters items and renders each option, with an optional empty state. | -| `Combobox.Option` | Selectable option with active, selected, and disabled states. | -| `Combobox.Empty` | Empty result message. | +| Part | Description | +| --------------------- | ---------------------------------------------------------------------------- | +| `Combobox.Root` | Owns input, selection, open state, and keyboard navigation. | +| `Combobox.Chips` | Field for `multiple`; holds the chips and the input, and anchors the popup. | +| `Combobox.Value` | Selected labels, or a function of the selected values. | +| `Combobox.Chip` | One selected value with a remove button; removable with Backspace or Delete. | +| `Combobox.Input` | Search input; renders Mosaic `Input` unless composed through another input. | +| `Combobox.Trigger` | Opens and closes the option list while keeping focus on the input. | +| `Combobox.Clear` | Empties the input and selection; shown only while there is a value. | +| `Combobox.Popup` | Portals, positions, surfaces, and scrolls a floating option list. | +| `Combobox.List` | Scrollable inline listbox. | +| `Combobox.Collection` | Filters items and renders each option, with an optional empty state. | +| `Combobox.Option` | Selectable option with active, selected, and disabled states. | +| `Combobox.Empty` | Empty result message. | diff --git a/packages/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx index c86ecab2285..11aeb122241 100644 --- a/packages/swingset/src/stories/combobox.stories.tsx +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -57,6 +57,96 @@ export function Default() { ); } +export function Multiple() { + const options = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Cherry', 'Fig', 'Grape', 'Pear', 'Plum']; + const labelOf = (value: string) => options.find(option => option.toLowerCase() === value) ?? value; + + return ( + + + Fruits + + + {values => + values.map(value => ( + + {labelOf(value)} + + )) + } + + + + + + option} + empty={No fruit found} + > + {option => ( + + {option} + + + )} + + + + ); +} + +export function MultipleCount() { + const options = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Cherry', 'Fig', 'Grape', 'Pear', 'Plum']; + + return ( + + + Fruits + + + + {value => (value.length > 0 ? `${value.length} selected` : null)} + + + + + + option} + empty={No fruit found} + > + {option => ( + + {option} + + + )} + + + + ); +} + export function Scrolling() { const options = Array.from({ length: 40 }, (_, index) => `Fruit ${index + 1}`); From 36c6c4373dd8949ba458a8a771f51fed359c40cf Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 11 Nov 2026 08:57:07 -0500 Subject: [PATCH 2/4] feat(mosaic): show Clear in the Combobox count story --- .../src/primitives/combobox/combobox.test.tsx | 20 +++++++++++++++++++ packages/swingset/src/stories/combobox.mdx | 1 + .../swingset/src/stories/combobox.stories.tsx | 1 + 3 files changed, 22 insertions(+) diff --git a/packages/mosaic/src/primitives/combobox/combobox.test.tsx b/packages/mosaic/src/primitives/combobox/combobox.test.tsx index 8c63a778639..b0256ca8ba6 100644 --- a/packages/mosaic/src/primitives/combobox/combobox.test.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox.test.tsx @@ -1113,6 +1113,26 @@ describe('Combobox multiple', () => { expect(screen.queryByRole('button', { name: 'Clear' })).toBeNull(); }); + it('updates a count from Value when Clear empties the selection', async () => { + const user = userEvent.setup(); + render( + + + + {value => (value.length > 0 ? `${value.length} selected` : 'None')} + + + , + ); + expect(screen.getByTestId('count')).toHaveTextContent('2 selected'); + await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(screen.getByTestId('count')).toHaveTextContent('None'); + expect(screen.queryByRole('button', { name: 'Clear' })).toBeNull(); + }); + it('keeps the controlled selection when the parent rejects a change', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx index c3639f4524c..40a08d83d4c 100644 --- a/packages/swingset/src/stories/combobox.mdx +++ b/packages/swingset/src/stories/combobox.mdx @@ -37,6 +37,7 @@ so a plain input can show a count instead. {value => (value.length > 0 ? `${value.length} selected` : null)} + diff --git a/packages/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx index 11aeb122241..7e5e9a098dc 100644 --- a/packages/swingset/src/stories/combobox.stories.tsx +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -121,6 +121,7 @@ export function MultipleCount() { {value => (value.length > 0 ? `${value.length} selected` : null)} + From a3d95b69d01d48f8c80eaf35285ff8a2635d2a21 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 12 Nov 2026 08:43:50 -0500 Subject: [PATCH 3/4] docs(mosaic): add an always-open multiple Combobox story --- packages/swingset/src/stories/combobox.mdx | 11 ++++ .../swingset/src/stories/combobox.stories.tsx | 62 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx index 40a08d83d4c..c4cbecfa1d6 100644 --- a/packages/swingset/src/stories/combobox.mdx +++ b/packages/swingset/src/stories/combobox.mdx @@ -45,6 +45,17 @@ so a plain input can show a count instead. ``` +## Always open + +For a picker that sits in the page, set `inline` and `open` and render `List` in place of `Popup`. +Picking keeps the list open. To hide picked options, as below, control `value` and filter them out of +`items`. + + + ## Inline lists Use `List` inside an existing popover. Set `inline` and bind the root's open state to that popover diff --git a/packages/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx index 7e5e9a098dc..ac9bf254731 100644 --- a/packages/swingset/src/stories/combobox.stories.tsx +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -4,6 +4,7 @@ import { Combobox } from '@clerk/mosaic/components/combobox'; import { Field } from '@clerk/mosaic/components/field'; import { InputGroup } from '@clerk/mosaic/components/input-group'; import * as stylex from '@stylexjs/stylex'; +import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -148,6 +149,67 @@ export function MultipleCount() { ); } +export function AlwaysOpen() { + const projects = [ + 'storybook', + 'clerk', + 'internal-compliance-portal', + 'clark-agent', + 'swingset', + 'clerk-js-sandbox', + 'roadmap-admin', + 'roadmap-portal', + ]; + const [value, setValue] = useState([]); + + return ( + + + Projects + + + {values => + values.map(project => ( + + {project} + + )) + } + + + + + !value.includes(project))} + itemToStringLabel={project => project} + empty={No projects found} + > + {project => ( + + {project} + + )} + + + + + ); +} + export function Scrolling() { const options = Array.from({ length: 40 }, (_, index) => `Fruit ${index + 1}`); From b418f78eda0d1a011f2ffeeb1e859b06a288361b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 12 Nov 2026 09:16:05 -0500 Subject: [PATCH 4/4] feat(mosaic): add disabled to Combobox --- .../src/components/combobox/combobox.test.tsx | 46 +++++++++++++++ .../src/components/combobox/combobox.tsx | 4 +- .../mosaic/src/primitives/combobox/README.md | 2 +- .../combobox/combobox-chip-remove.tsx | 7 ++- .../src/primitives/combobox/combobox-chip.tsx | 13 +++- .../primitives/combobox/combobox-clear.tsx | 7 ++- .../primitives/combobox/combobox-context.ts | 2 + .../primitives/combobox/combobox-input.tsx | 2 + .../primitives/combobox/combobox-option.tsx | 16 +++-- .../src/primitives/combobox/combobox-root.tsx | 23 ++++++-- .../primitives/combobox/combobox-trigger.tsx | 6 +- .../src/primitives/combobox/combobox.test.tsx | 59 +++++++++++++++++++ packages/swingset/src/stories/combobox.mdx | 3 +- 13 files changed, 174 insertions(+), 16 deletions(-) diff --git a/packages/mosaic/src/components/combobox/combobox.test.tsx b/packages/mosaic/src/components/combobox/combobox.test.tsx index 1fa8bf124d5..8bb29caca61 100644 --- a/packages/mosaic/src/components/combobox/combobox.test.tsx +++ b/packages/mosaic/src/components/combobox/combobox.test.tsx @@ -425,4 +425,50 @@ describe('Mosaic Combobox', () => { await user.click(trigger); expect(trigger).toHaveAttribute('aria-expanded', 'false'); }); + + it('locks a multiple combobox inside a disabled Field', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + Fruits + + + + {values => + values.map(value => ( + + {value} + + )) + } + + + + + + Banana + + + + , + ); + expect(screen.getByRole('combobox', { name: 'Fruits' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Remove apple' })).toBeDisabled(); + await user.click(screen.getByRole('option', { name: 'Banana' })); + expect(onValueChange).not.toHaveBeenCalled(); + }); }); diff --git a/packages/mosaic/src/components/combobox/combobox.tsx b/packages/mosaic/src/components/combobox/combobox.tsx index c96196e70bc..8e6a803a9ea 100644 --- a/packages/mosaic/src/components/combobox/combobox.tsx +++ b/packages/mosaic/src/components/combobox/combobox.tsx @@ -17,7 +17,7 @@ import { focusOutline } from '../../styles/focus-outline.styles'; import { inputStyles } from '../../styles/input.styles'; import { reset } from '../../styles/reset.styles'; import { Button } from '../button'; -import { useOptionalFieldControlProps } from '../field/field.context'; +import { useOptionalFieldContext, useOptionalFieldControlProps } from '../field/field.context'; import { Icon } from '../icon'; import { Input, type InputVariant } from '../input'; import { useOptionalInputGroupContext } from '../input-group/input-group.context'; @@ -40,6 +40,7 @@ const ComboboxAnchorContext = React.createContext<{ const ComboboxInChipsContext = React.createContext(false); export function ComboboxRoot({ sideOffset = 8, openOnInputClick = true, ...props }: ComboboxRootProps) { + const fieldDisabled = useOptionalFieldContext()?.disabled; const [anchor, setAnchor] = React.useState(null); const context = React.useMemo(() => ({ anchor, setAnchor }), [anchor]); return ( @@ -48,6 +49,7 @@ export function ComboboxRoot({ sideOffset = 8, openOnInputClick = true, ...props sideOffset={sideOffset} openOnInputClick={openOnInputClick} {...props} + disabled={props.disabled ?? fieldDisabled} /> ); diff --git a/packages/mosaic/src/primitives/combobox/README.md b/packages/mosaic/src/primitives/combobox/README.md index 31d76647058..492991e9e09 100644 --- a/packages/mosaic/src/primitives/combobox/README.md +++ b/packages/mosaic/src/primitives/combobox/README.md @@ -29,7 +29,7 @@ import { Combobox } from '../../primitives/combobox'; ; ``` -`value`, `defaultValue`, and `onValueChange` control the selected option. Clearing the input clears selection with `null`. `inputValue`, `defaultInputValue`, and `onInputValueChange` control search text separately. +`value`, `defaultValue`, and `onValueChange` control the selected option. Clearing the input clears selection with `null`. `inputValue`, `defaultInputValue`, and `onInputValueChange` control search text separately. `disabled` disables the input, trigger, Clear and chip remove buttons, marks options and chips `aria-disabled`, and ignores every change to the selection. Dismissal restores the selected label. Reopening shows all options until typing starts. Hover and keyboard highlighting do not change selection. diff --git a/packages/mosaic/src/primitives/combobox/combobox-chip-remove.tsx b/packages/mosaic/src/primitives/combobox/combobox-chip-remove.tsx index db025a6792a..a4b7368006f 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-chip-remove.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-chip-remove.tsx @@ -11,12 +11,13 @@ export type ComboboxChipRemoveProps = ComponentProps<'button'>; export const ComboboxChipRemove = React.forwardRef( function ComboboxChipRemove(props, ref) { const { render, ...otherProps } = props; - const { removeValue, focusInput } = useComboboxContext(); + const { removeValue, focusInput, disabled } = useComboboxContext(); const value = useComboboxChipValue(); const defaultProps = { type: 'button', tabIndex: -1, + disabled, onPointerDown(event: React.PointerEvent) { event.preventDefault(); }, @@ -30,6 +31,10 @@ export const ComboboxChipRemove = React.forwardRef (v ? { 'data-disabled': '' } : null), + }, props: mergeProps<'button'>(defaultProps, otherProps), }); }, diff --git a/packages/mosaic/src/primitives/combobox/combobox-chip.tsx b/packages/mosaic/src/primitives/combobox/combobox-chip.tsx index c557dd1f7b4..e380afb8b07 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-chip.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-chip.tsx @@ -13,7 +13,7 @@ export interface ComboboxChipProps extends ComponentProps<'div'> { export const ComboboxChip = React.forwardRef(function ComboboxChip(props, ref) { const { render, value, ...otherProps } = props; const { registerChip, navigate, removeChip } = useComboboxChipsContext(); - const { setOpen, focusInput } = useComboboxContext(); + const { setOpen, focusInput, disabled } = useComboboxContext(); const registerRef = useCallback( (element: HTMLDivElement | null) => registerChip(value, element), @@ -22,17 +22,24 @@ export const ComboboxChip = React.forwardRef( const defaultProps = { tabIndex: -1, + 'aria-disabled': disabled || undefined, onKeyDown(event: React.KeyboardEvent) { if (event.target !== event.currentTarget) { return; } if (event.key === 'Backspace' || event.key === 'Delete') { event.preventDefault(); + if (disabled) { + return; + } removeChip(value, event.key === 'Backspace' ? 'previous' : 'next'); return; } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); + if (disabled) { + return; + } setOpen(true); focusInput(); return; @@ -49,6 +56,10 @@ export const ComboboxChip = React.forwardRef( defaultTagName: 'div', render, ref: [registerRef, ref], + state: { disabled }, + stateAttributesMapping: { + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + }, props: mergeProps<'div'>(defaultProps, otherProps), }); diff --git a/packages/mosaic/src/primitives/combobox/combobox-clear.tsx b/packages/mosaic/src/primitives/combobox/combobox-clear.tsx index 4e948dc2fc1..adfa4828dbb 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-clear.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-clear.tsx @@ -10,12 +10,13 @@ export type ComboboxClearProps = ComponentProps<'button'>; export const ComboboxClear = React.forwardRef( function ComboboxClear(props, ref) { const { render, ...otherProps } = props; - const { inputValue, selectedValues, clear } = useComboboxContext(); + const { inputValue, selectedValues, clear, disabled } = useComboboxContext(); const defaultProps = { type: 'button', tabIndex: -1, 'aria-label': 'Clear', + disabled, onPointerDown(event: React.PointerEvent) { event.preventDefault(); }, @@ -28,6 +29,10 @@ export const ComboboxClear = React.forwardRef (v ? { 'data-disabled': '' } : null), + }, props: mergeProps<'button'>(defaultProps, otherProps), }); diff --git a/packages/mosaic/src/primitives/combobox/combobox-context.ts b/packages/mosaic/src/primitives/combobox/combobox-context.ts index 1d59e26ad42..4cdc8989648 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-context.ts +++ b/packages/mosaic/src/primitives/combobox/combobox-context.ts @@ -15,6 +15,7 @@ export interface ComboboxContextValue { inputValue: string; filterQuery: string; multiple: boolean; + disabled: boolean; selectedValues: string[]; labelsByValueRef: React.MutableRefObject>; removeValue: (value: string) => void; @@ -60,6 +61,7 @@ export interface ComboboxOptionsContextValue { valuesByIndexRef: React.MutableRefObject>; registerSelectedIndex: (index: number, value: string, label: string, selected: boolean) => (() => void) | undefined; registerOption: (index: number) => void; + disabled: boolean; } export interface ComboboxHighlightContextValue { diff --git a/packages/mosaic/src/primitives/combobox/combobox-input.tsx b/packages/mosaic/src/primitives/combobox/combobox-input.tsx index 57b17e57196..3fd13867b75 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-input.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-input.tsx @@ -23,6 +23,7 @@ export const ComboboxInput = React.forwardRef) { handleInputChange(event.target.value); diff --git a/packages/mosaic/src/primitives/combobox/combobox-option.tsx b/packages/mosaic/src/primitives/combobox/combobox-option.tsx index 7d77ebc1ec2..aed3614a698 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-option.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-option.tsx @@ -42,9 +42,17 @@ interface ComboboxOptionBodyProps extends ComboboxOptionProps { const ComboboxOptionBody = React.memo(function ComboboxOptionBody(bodyProps: ComboboxOptionBodyProps) { const { forwardedRef: ref, listItemRef: itemRef, index, isActive, isSelected, isAnchor, ...props } = bodyProps; - const { render, value, label, disabled, ...otherProps } = props; - const { getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, registerOption, refs } = - useComboboxOptionsContext(); + const { render, value, label, disabled: disabledProp, ...otherProps } = props; + const { + getItemProps, + handleSelect, + valuesByIndexRef, + registerSelectedIndex, + registerOption, + refs, + disabled: rootDisabled, + } = useComboboxOptionsContext(); + const disabled = disabledProp === true || rootDisabled; const id = useId(); const displayLabel = label ?? value; @@ -65,7 +73,7 @@ const ComboboxOptionBody = React.memo(function ComboboxOptionBody(bodyProps: Com const state = { selected: isSelected, active: isActive, - disabled: !!disabled, + disabled, }; const ownProps = { diff --git a/packages/mosaic/src/primitives/combobox/combobox-root.tsx b/packages/mosaic/src/primitives/combobox/combobox-root.tsx index 829196f5389..0b9d48d6857 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-root.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-root.tsx @@ -47,6 +47,8 @@ interface ComboboxBaseProps { inline?: boolean; /** Opens the popup when the input is clicked. Has no effect with `inline`. */ openOnInputClick?: boolean; + /** Disables the input, the options, the chips and the buttons, so the selection can't change. */ + disabled?: boolean; } export interface ComboboxSingleProps extends ComboboxBaseProps { @@ -78,6 +80,7 @@ function toValues(value: string | null | undefined): string[] | undefined { function ComboboxInner(props: ComboboxProps) { const { placement: placementProp = 'bottom-start', sideOffset = 4, children } = props; const multiple = props.multiple === true; + const disabled = props.disabled === true; const onSingleValueChange = props.multiple ? undefined : props.onValueChange; const onMultipleValueChange = props.multiple ? props.onValueChange : undefined; @@ -262,6 +265,9 @@ function ComboboxInner(props: ComboboxProps) { const handleSelect = useCallback( (value: string, index: number, label: string) => { + if (disabled) { + return; + } labelsByValueRef.current.set(value, label); if (multiple) { const next = selectedValues.includes(value) @@ -283,18 +289,21 @@ function ComboboxInner(props: ComboboxProps) { setActiveIndex(null); setOpen(false); }, - [multiple, selectedValues, inputValue, props.inline, setSelectedValues, setInputValue, setOpen], + [disabled, multiple, selectedValues, inputValue, props.inline, setSelectedValues, setInputValue, setOpen], ); const removeValue = useCallback( (value: string) => { + if (disabled) { + return; + } const next = selectedValues.filter(selected => selected !== value); setSelectedValues(next); if (next.length === 0) { setSelectedIndex(null); } }, - [selectedValues, setSelectedValues], + [disabled, selectedValues, setSelectedValues], ); const handleInputChange = useCallback( @@ -317,13 +326,16 @@ function ComboboxInner(props: ComboboxProps) { ); const clear = useCallback(() => { + if (disabled) { + return; + } setQueryChanged(false); setInputValue(''); setSelectedValues([]); setSelectedIndex(null); setActiveIndex(null); focusInput(); - }, [focusInput, setInputValue, setSelectedValues]); + }, [disabled, focusInput, setInputValue, setSelectedValues]); const contextValue = useMemo( () => ({ @@ -332,6 +344,7 @@ function ComboboxInner(props: ComboboxProps) { inputValue, filterQuery, multiple, + disabled, selectedValues, labelsByValueRef, removeValue, @@ -365,6 +378,7 @@ function ComboboxInner(props: ComboboxProps) { inputValue, filterQuery, multiple, + disabled, selectedValues, removeValue, floatingContext, @@ -393,8 +407,9 @@ function ComboboxInner(props: ComboboxProps) { valuesByIndexRef, registerSelectedIndex, registerOption, + disabled, }), - [getItemProps, handleSelect, refs, registerSelectedIndex, registerOption], + [getItemProps, handleSelect, refs, registerSelectedIndex, registerOption, disabled], ); const highlightContextValue = useMemo( diff --git a/packages/mosaic/src/primitives/combobox/combobox-trigger.tsx b/packages/mosaic/src/primitives/combobox/combobox-trigger.tsx index 2f324edd19b..5ed4cc45577 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-trigger.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-trigger.tsx @@ -10,12 +10,13 @@ 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 { open, setOpen, focusInput, popupId, triggerRef, disabled } = useComboboxContext(); + const state = { open, disabled }; const defaultProps = { type: 'button', tabIndex: -1, + disabled, 'aria-controls': popupId, 'aria-expanded': open, 'aria-haspopup': 'listbox', @@ -35,6 +36,7 @@ export const ComboboxTrigger = React.forwardRef | null => (value ? { 'data-open': '' } : { 'data-closed': '' }), + disabled: (value: boolean) => (value ? { 'data-disabled': '' } : null), }, props: mergeProps<'button'>(defaultProps, otherProps), }); diff --git a/packages/mosaic/src/primitives/combobox/combobox.test.tsx b/packages/mosaic/src/primitives/combobox/combobox.test.tsx index b0256ca8ba6..58ca5775e12 100644 --- a/packages/mosaic/src/primitives/combobox/combobox.test.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox.test.tsx @@ -896,6 +896,8 @@ describe('Combobox multiple', () => { defaultValue?: string[]; onValueChange?: (value: string[]) => void; chips?: boolean; + disabled?: boolean; + open?: boolean; }) { const { chips = true, ...rootProps } = props; return ( @@ -1133,6 +1135,63 @@ describe('Combobox multiple', () => { expect(screen.queryByRole('button', { name: 'Clear' })).toBeNull(); }); + describe('disabled', () => { + it('disables the input, the chip removers, the trigger and Clear', () => { + render( + , + ); + expect(screen.getByRole('combobox')).toBeDisabled(); + for (const name of ['Remove Apple', 'Show fruits', 'Clear']) { + const button = screen.getByRole('button', { name }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('data-disabled'); + } + expect(screen.getByTestId('chip')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('ignores Backspace, Delete and ArrowDown on a focused chip', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + const [apple, banana] = screen.getAllByTestId('chip'); + apple?.focus(); + await user.keyboard('{Backspace}'); + banana?.focus(); + await user.keyboard('{Delete}'); + await user.keyboard('{ArrowDown}'); + expect(chipNames()).toEqual(['Apple', 'Banana']); + expect(onValueChange).not.toHaveBeenCalled(); + expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false'); + }); + + it('keeps the selection when an option is clicked', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + const cherry = screen.getByRole('option', { name: 'Cherry' }); + expect(cherry).toHaveAttribute('aria-disabled', 'true'); + await user.click(cherry); + expect(chipNames()).toEqual(['Apple']); + expect(onValueChange).not.toHaveBeenCalled(); + }); + }); + it('keeps the controlled selection when the parent rejects a change', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx index c4cbecfa1d6..8f1c78f409c 100644 --- a/packages/swingset/src/stories/combobox.mdx +++ b/packages/swingset/src/stories/combobox.mdx @@ -16,7 +16,8 @@ import * as ComboboxStories from './combobox.stories'; Set `multiple` to let several options be picked. `value` is then an array in pick order. Picking an option toggles it, clears the search and keeps the popup open. `Combobox.Value` passes the selected values to its children, so each one can render as a `Chip`. Backspace in an empty input removes the -last chip, and the left arrow moves from the input to the chips. +last chip, and the left arrow moves from the input to the chips. Set `disabled` on the root, or on an +enclosing `Field.Root`, to lock the input, the options, the chips and the buttons.