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.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 48a510851b1..8e6a803a9ea 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 { useOptionalFieldContext, useOptionalFieldControlProps } from '../field/field.context';
import { Icon } from '../icon';
import { Input, type InputVariant } from '../input';
import { useOptionalInputGroupContext } from '../input-group/input-group.context';
@@ -24,13 +29,18 @@ 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 fieldDisabled = useOptionalFieldContext()?.disabled;
const [anchor, setAnchor] = React.useState(null);
const context = React.useMemo(() => ({ anchor, setAnchor }), [anchor]);
return (
@@ -39,6 +49,7 @@ export function ComboboxRoot({ sideOffset = 8, openOnInputClick = true, ...props
sideOffset={sideOffset}
openOnInputClick={openOnInputClick}
{...props}
+ disabled={props.disabled ?? fieldDisabled}
/>
);
@@ -112,13 +123,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 +378,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/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
new file mode 100644
index 00000000000..a4b7368006f
--- /dev/null
+++ b/packages/mosaic/src/primitives/combobox/combobox-chip-remove.tsx
@@ -0,0 +1,41 @@
+'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, disabled } = useComboboxContext();
+ const value = useComboboxChipValue();
+
+ const defaultProps = {
+ type: 'button',
+ tabIndex: -1,
+ disabled,
+ onPointerDown(event: React.PointerEvent) {
+ event.preventDefault();
+ },
+ onClick() {
+ removeValue(value);
+ focusInput();
+ },
+ } satisfies DefaultProps<'button'>;
+
+ return useRender({
+ defaultTagName: 'button',
+ render,
+ ref,
+ state: { disabled },
+ stateAttributesMapping: {
+ disabled: (v: boolean) => (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
new file mode 100644
index 00000000000..e380afb8b07
--- /dev/null
+++ b/packages/mosaic/src/primitives/combobox/combobox-chip.tsx
@@ -0,0 +1,67 @@
+'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, disabled } = useComboboxContext();
+
+ const registerRef = useCallback(
+ (element: HTMLDivElement | null) => registerChip(value, element),
+ [registerChip, value],
+ );
+
+ 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;
+ }
+ 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],
+ state: { disabled },
+ stateAttributesMapping: {
+ disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
+ },
+ 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..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, selectedValue, 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,9 +29,13 @@ export const ComboboxClear = React.forwardRef (v ? { 'data-disabled': '' } : null),
+ },
props: mergeProps<'button'>(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..4cdc8989648 100644
--- a/packages/mosaic/src/primitives/combobox/combobox-context.ts
+++ b/packages/mosaic/src/primitives/combobox/combobox-context.ts
@@ -14,7 +14,11 @@ export interface ComboboxContextValue {
clear: () => void;
inputValue: string;
filterQuery: string;
- selectedValue: string | null;
+ multiple: boolean;
+ disabled: boolean;
+ selectedValues: string[];
+ labelsByValueRef: React.MutableRefObject