// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file MultiSelector.tsx * @input Uses React, StyleX, usePopover, useTooltip, CheckboxInput, Field, Badge, Icon, InputGroupContext * @output Exports MultiSelector component * @position Core implementation; consumed by index.ts * * SYNC: When modified, update: * - /packages/core/src/MultiSelector/MultiSelector.doc.mjs * - /packages/core/src/MultiSelector/MultiSelector.test.tsx * - /packages/core/src/MultiSelector/index.ts * - /apps/storybook/stories/InputGroup.stories.tsx * - /packages/cli/assets/templates/blocks/components/MultiSelector/ (showcase blocks) */ import React, { useCallback, useEffect, useId, useMemo, useOptimistic, useRef, useState, useTransition, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import { useTooltip } from "../Tooltip/index.js"; import { Icon, renderIconSlot, type IconType } from "../Icon/index.js"; import type { IconName } from "../Icon/index.js"; import { Field, inputStatusBorderStyles, inputStatusHoverShadowStyles, inputWrapperStyles, type FieldStatusVariant } from "../Field/index.js"; import { useKeepLayerOpenProps } from "../Layer/useLayer.js"; import { InternalInputClearButton } from "../Field/InputClearButton.js"; import { Divider } from "../Divider/index.js"; import { Spinner } from "../Spinner/index.js"; import { PanelSearchInput } from "../Field/PanelSearchInput.js"; import { CheckboxInput } from "../CheckboxInput/index.js"; import type { IndicatorPosition } from "../Indicator/index.js"; import { Badge } from "../Badge/index.js"; import "../theme/tokens.stylex.js"; import { colorVars, sizeVars, spacingVars, radiusVars, durationVars, easeVars, typographyVars, fontWeightVars, typeScaleVars } from "../theme/tokens.stylex.js"; import type { MultiSelectorOptionType, MultiSelectorOptionData, MultiSelectorStatus } from "./types.js"; import { isOptionData, isDivider, isSection, normalizeOption, getSelectableOptions } from "../Selector/utils.js"; import { useMultiCombobox } from "./hooks.js"; import { getInputARIA, isImeKeyEvent, mergeProps } from "../utils/index.js"; import { useAnnounce } from "../hooks/useAnnounce.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.js"; import { useSize } from "../SizeContext/SizeContext.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineStyles } from "../utils/focusOutline.stylex.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import { stableClassName } from "../naming.js"; import { groupStyles } from "../InputGroup/groupStyles.js"; import { useInputGroup } from "../InputGroup/InputGroupContext.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { useTranslator } from "../i18n/index.js"; import type { AdaptivePresentation } from "../hooks/useAdaptivePresentation.js"; import { SelectorBottomSheet } from "../Selector/SelectorBottomSheet.js"; import { useSelectorPresentation } from "../Selector/useSelectorPresentation.js"; import { selectorPresentationStyles } from "../Selector/selectorPresentation.stylex.js"; // Sentinel value for the select-all item in keyboard navigation const SELECT_ALL_VALUE = '__xds_select_all__'; const styles = { triggerContainer: { kVAEAm: "x1n2onr6", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "x1qughib", kOIVth: "x1txdalj", kzqmXN: "xh8yej3", k8WAf4: "xce4md1", kg3NbH: "xrrkdod", kMv6JI: "x9ynric", kGuDYH: "xcr08ib", kLWn49: "x1kq96og", kMwMTN: "x1tgivj0", kkrTdU: "x1ypdohk x16khyan", $$css: true as const }, triggerPlaceholder: { kMwMTN: "xv1l7n4", $$css: true as const }, triggerIcon: { kmuXW: "x2lah0s", $$css: true as const }, triggerIconRotation: { k1ekBW: "x11xpdln", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", k3nNDw: "x1g0ag68", $$css: true as const }, triggerIconOpen: { k3aq6I: "x19jd1h0", $$css: true as const }, triggerGhost: { kzqmXN: "x14atkfc", kMzoRj: "xc342km", kWkggS: "xjbqb8w", kGVxlE: "x1gnnqk1 x1s17dad x15ysqaf", k63SB2: "x1e4wzip", k1ekBW: "xrafxwg", k3aq6I: "x3oybdh xk4oym4", $$css: true as const }, triggerGhostDisabled: { kKwaWg: "x18o3ruo", k3aq6I: "x1c071of x1pdlv7q", $$css: true as const }, triggerReadOnly: { kkrTdU: "xt0e3qv", $$css: true as const }, triggerGhostReadOnly: { kKwaWg: "x18o3ruo", k3aq6I: "x1c071of x1pdlv7q", $$css: true as const }, statusButton: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmVPX3: "x1717udv", kogj98: "x1ghz6dp", kMzoRj: "xc342km", ksu8eU: "xng3xce", kWkggS: "xjbqb8w", kMwMTN: "x1heor9g", kkrTdU: "x1ypdohk x16khyan", kaIpWk: "xh6dtrn", $$css: true as const }, popover: { k7Eaqz: "xrzjruh", $$css: true as const }, selectAllWrapper: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", kkrTdU: "x1ypdohk x16khyan", $$css: true as const }, divider: { kqGvvJ: "xsq74q5", $$css: true as const }, item: { kB7OPa: "x9f619", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", kzqmXN: "xh8yej3", kaIpWk: "xh6dtrn", kkrTdU: "x1ypdohk x16khyan", kMv6JI: "x9ynric", kGuDYH: "xcr08ib", k63SB2: "x1e4wzip", kMwMTN: "x1tgivj0", kWkggS: "xjbqb8w", kI3sdo: "x1a2a7pz", $$css: true as const }, itemHighlighted: { kWkggS: "x1lmrjuc", $$css: true as const }, itemDisabled: { kSiTet: "xbyyjgo", kMwMTN: "xnbbluu", kkrTdU: "xt0e3qv", $$css: true as const } }; const sizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; const itemSizeStyles = { sm: { kmVPX3: "x9epnlk", $$css: true as const }, md: { k8WAf4: "x1vofgu7", kg3NbH: "xf314gf", $$css: true as const }, lg: { kmVPX3: "xlsj2fj", $$css: true as const } }; const selectAllSizeStyles = { sm: { kg3NbH: "x7a5moj", k8WAf4: "x13f7esw", $$css: true as const }, md: { kg3NbH: "xf314gf", k8WAf4: "xu0wf1k", $$css: true as const }, lg: { kg3NbH: "xf314gf", k8WAf4: "xu0wf1k", $$css: true as const } }; const STATUS_ICON_MAP: Record = { warning: 'warning', error: 'error', success: 'success' }; const STATUS_ICON_COLOR_MAP: Record = { warning: 'warning', error: 'error', success: 'success' }; const STATUS_BUTTON_LABEL_KEY: Record = { warning: '@astryx.input.statusButton.warning', error: '@astryx.input.statusButton.error', success: '@astryx.input.statusButton.success' }; export type MultiSelectorSize = 'sm' | 'md' | 'lg'; export type MultiSelectorVariant = 'input' | 'ghost'; export type MultiSelectorPresentation = AdaptivePresentation; export type MultiSelectorStatusType = 'warning' | 'error' | 'success'; export type { MultiSelectorStatus }; export interface MultiSelectorSelectedItem { value: string; label: string; } export interface MultiSelectorProps extends Omit { /** * Label text for the multi-selector (always rendered for accessibility). */ label: string; /** * Whether to visually hide the label (still accessible to screen readers). * @default false */ isLabelHidden?: boolean; /** * Description text displayed between the label and selector. */ description?: string; /** * Whether the field is optional. Mutually exclusive with isRequired. * @default false */ isOptional?: boolean; /** * Whether the field is required. Mutually exclusive with isOptional. * @default false */ isRequired?: boolean; /** * Whether the selector is disabled. * @default false */ isDisabled?: boolean; /** * Whether the selector is read-only. * The selected values stay visible, focusable, and included in form * submission, and retain their combobox identity with `aria-readonly`. The * selection surface and editing affordances are removed. * Unlike `isDisabled`, a read-only selector is not dimmed and stays in the * tab order. `isDisabled` takes precedence when both are set. * @default false */ isReadOnly?: boolean; /** * Explains why the selector is disabled. When set together with * `isDisabled`, the selector shows a tooltip with this text on hover and * keyboard focus, and the trigger stays focusable (via `aria-disabled`) * so the reason is discoverable by keyboard and assistive technology. * Activation stays blocked. * * Use this instead of wrapping a disabled selector in `Tooltip` — disabled * controls don't emit the pointer events an external tooltip needs. * * @example * ``` * * ``` */ disabledMessage?: string; /** * The options to display in the selector. * Can be strings, objects, dividers, or sections. */ options: T[]; /** * The currently selected values. */ value: string[]; /** * The HTML name attribute for form submissions. When set, hidden inputs * carry one entry per selected value under this name, matching how a * native multi-select serializes. */ htmlName?: string; /** * Callback when selection changes. */ onChange: (value: string[]) => void; /** * Async action on change. Fires after onChange. */ changeAction?: (value: string[]) => void | Promise; /** * Whether the selector is in a loading state. * @default false */ isLoading?: boolean; /** * Placeholder text when no value is selected. * @default 'Select...' */ placeholder?: string; /** * The size of the selector. * @default 'md' */ size?: MultiSelectorSize; /** * Visual style of the selector trigger. * - 'input': bordered input-style trigger for forms * - 'ghost': borderless trigger matching ghost buttons, for toolbars * @default 'input' */ variant?: MultiSelectorVariant; /** * Status indicator for the selector. */ status?: MultiSelectorStatus; /** * How the status message is placed relative to the input. * - 'attached': message overlaps directly below the bordered input (input variant only) * - 'detached': message floats below as a separate element with spacing * - 'tooltip': message is exposed from the on-field status icon * @default 'attached' for input selectors; 'detached' for ghost selectors */ statusVariant?: FieldStatusVariant; /** * Width of the field. Numbers are treated as pixels, strings are used as-is * (e.g. `'100%'`). Sizes the whole field (label, control, and status) so they * stay aligned, unlike setting width via `xstyle`/`className`/`style`. */ width?: SizeValue; /** * Tooltip text to display in an info icon at the end of the label. */ labelTooltip?: string; /** * Icon displayed at the start of the selector trigger. */ startIcon?: ReactNode | IconType; /** * Whether to show a clear button when values are selected. * When clicked, resets the value to an empty array and returns focus to the trigger. * @default false */ hasClear?: boolean; /** * Whether to show a "Select all" checkbox. * @default false */ hasSelectAll?: boolean; /** * Label for the select-all checkbox. * @default 'Select all' */ selectAllLabel?: string; /** * Whether to show a search input. * @default false */ hasSearch?: boolean; /** * Placeholder text for the search input. * @default 'Search...' */ searchPlaceholder?: string; /** * Content shown in the panel when there are no options to show, and * announced in a polite live region when the panel opens. Not shown while * `isLoading` — the options have not arrived yet. * @default 'No options' */ emptyText?: ReactNode; /** * Content shown in the panel when a search query matches no options, and * announced in a polite live region at the same time. * * The panel message is `role="presentation"`, so the live region is the only * route to assistive tech: a string is announced verbatim, a richer node * falls back to the default text since it cannot be spoken. * @default 'No results found' */ emptySearchText?: ReactNode; /** * How to display selected items in the trigger. * - 'count': "3 selected" * - 'labels': "Name, Email, +3" * - 'badges': [Name] [Email] +2 * @default 'count' */ triggerDisplay?: 'count' | 'labels' | 'badges'; /** * Formats the trigger text when triggerDisplay is 'count' or 'labels'. * Receives the selected items (value plus resolved label, in selection * order) and returns the full trigger text; the count is `items.length`. * Not called when nothing is selected — the placeholder shows instead — and * not called for triggerDisplay 'badges', which renders Badge elements * rather than text. * @default items => `${items.length} selected` for 'count', "A, B, C, +N" for 'labels' */ formatValue?: (items: MultiSelectorSelectedItem[]) => string; /** * Maximum number of badges to show before showing "+N". * Only used when triggerDisplay is 'badges'. * @default 3 */ maxBadges?: number; /** * Custom render function for options. * Only called for selectable options (not dividers/sections or the select-all row). */ renderOption?: (option: MultiSelectorOptionData) => ReactNode; /** * Which edge of the option row carries the checkbox. * * @default 'start' */ indicatorPosition?: IndicatorPosition; /** * How the option list is presented. * - 'popover': anchored to the trigger * - 'bottom-sheet': modal sheet suited to compact touch screens * - 'adaptive': bottom sheet on compact coarse-pointer screens, otherwise popover * @default 'popover' */ presentation?: MultiSelectorPresentation; /** * Whether the dropdown starts open on mount. * Useful for showcases and previews. * @default false */ isDefaultOpen?: boolean; /** * Test ID for testing frameworks. */ 'data-testid'?: string; } // Case-insensitive substring match for a single option. The one predicate used // by both the flat filter (count + keyboard nav) and the grouped renderer, so // what is shown while searching stays in lockstep with the announced count. function optionMatchesQuery(option: MultiSelectorOptionData, query: string): boolean { if (!query) { return true; } return (option.label ?? option.value).toLowerCase().includes(query.toLowerCase()); } // Case-insensitive substring filter over the selectable options. Shared by the // `filteredItems` memo (rendering) and the search-change handler, which needs // the count for the *next* query synchronously to announce it exactly once per // keystroke rather than reacting to state in an effect. function filterOptionsByQuery(items: MultiSelectorOptionData[], query: string): MultiSelectorOptionData[] { if (!query) { return items; } return items.filter(item => optionMatchesQuery(item, query)); } /** * A multi-select dropdown component with checkboxes for choosing * multiple items from a list of options. * * @example * ``` * * ``` */ export function MultiSelector({ label, isLabelHidden = false, description, isOptional = false, isRequired = false, isDisabled = false, isReadOnly = false, disabledMessage, options, value, onChange, changeAction, isLoading = false, placeholder: placeholderFromProps, size: sizeProp, variant = 'input', status, statusVariant = 'attached', labelTooltip, startIcon, hasClear = false, hasSelectAll = false, selectAllLabel: selectAllLabelFromProps, hasSearch = false, searchPlaceholder: searchPlaceholderFromProps, emptyText: emptyTextFromProps, emptySearchText: emptySearchTextFromProps, triggerDisplay = 'count', formatValue, maxBadges = 3, renderOption, indicatorPosition = 'start', presentation = 'popover', isDefaultOpen = false, 'data-testid': testId, htmlName, width, xstyle, className, style, onFocus }: MultiSelectorProps) { const t = useTranslator(); const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); const placeholder = placeholderFromProps ?? t('@astryx.multiSelector.selectPlaceholder'); const selectAllLabel = selectAllLabelFromProps ?? t('@astryx.multiSelector.selectAll'); const searchPlaceholder = searchPlaceholderFromProps ?? t('@astryx.multiSelector.searchPlaceholder'); const emptyText = emptyTextFromProps ?? t('@astryx.multiSelector.empty'); const emptySearchText = emptySearchTextFromProps ?? t('@astryx.multiSelector.emptySearchResults'); const size = useSize(sizeProp, 'md'); const effectiveStatusVariant = variant === 'ghost' && statusVariant === 'attached' ? 'detached' : statusVariant; const isEffectivelyReadOnly = isReadOnly && !isDisabled; const triggerId = useId(); const listboxId = useId(); const descriptionId = useId(); const statusMessageId = useId(); const inputLabelId = useId(); const readOnlyDescriptionId = useId(); const searchId = useId(); const triggerRef = useRef(null); const searchRef = useRef(null); const listboxRef = useRef(null); const inputGroup = useInputGroup(); const [searchQuery, setSearchQuery] = useState(''); // A typed query shows the search row's clear (✕) button, which becomes // the next tab stop after the search input. const hasQuery = searchQuery.length > 0; // Snapshot of which values were selected when the dropdown opened. // Stored as state (not a ref) so sortedItems recomputes exactly once on open, // then stays frozen until the menu closes. const [selectedAtOpen, setSelectedAtOpen] = useState | null>(null); const [, startTransition] = useTransition(); const [optimisticValue, setOptimisticValue] = useOptimistic(value); const isBusy = isLoading || optimisticValue !== value; // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the trigger container (which already exists) // and the trigger button stays perceivable via aria-disabled instead of the // disabled attribute. Activation is blocked by the isDisabled guards in // useMultiCombobox (onTriggerClick / onKeyDown). const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', // The container div is not naturally focusable; focusin bubbles up from // the trigger button, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); const statusTooltip = useTooltip({ placement: 'above', isEnabled: effectiveStatusVariant === 'tooltip' && !!status?.message }); const { ariaLabelledBy, ariaDescribedBy } = getInputARIA(inputLabelId, [description ? descriptionId : null, !inputGroup && effectiveStatusVariant !== 'tooltip' && status?.message ? statusMessageId : null, effectiveStatusVariant === 'tooltip' && status?.message ? statusTooltip.describedBy : null, showsDisabledMessage ? disabledMessageTooltip.describedBy : null, isEffectivelyReadOnly ? readOnlyDescriptionId : null], inputGroup); // Flatten options for keyboard navigation const selectableItems = useMemo(() => getSelectableOptions(options), [options]); // Announce selection-count changes politely (comboboxes-7 announce path). // Toggling options / select-all previously produced no audible feedback. const announce = useAnnounce(); // The panel's empty message is role="presentation" and reaches assistive tech // only through this live region, so the region has to speak whatever the // panel shows. A ReactNode override cannot be spoken; fall back to the // catalog copy for that case rather than announcing nothing. const emptyAnnouncement = typeof emptyText === 'string' ? emptyText : t('@astryx.multiSelector.empty'); const emptySearchAnnouncement = typeof emptySearchText === 'string' ? emptySearchText : t('@astryx.multiSelector.emptySearchResults'); const announceSelection = useCallback((nextValue: string[]) => { const total = selectableItems.length; const selectableSet = new Set(selectableItems.map(item => item.value)); const selectedCount = nextValue.filter(v => selectableSet.has(v)).length; if (selectedCount === 0) { announce(t('@astryx.multiSelector.selectionCleared')); } else if (total > 0 && selectedCount === total) { announce(t('@astryx.multiSelector.allSelected')); } else { announce(t('@astryx.multiSelector.selectionCount', { count: selectedCount, total })); } }, [announce, selectableItems, t]); // Filter items by search query const filteredItems = useMemo(() => filterOptionsByQuery(selectableItems, searchQuery), [selectableItems, searchQuery]); // Single source of truth for item order. Both the hook (keyboard navigation) // and renderOptions (DOM rendering) consume this list — no independent sorting. // Selected-at-open items are placed first within each group/section, and the // same walk applies while searching so group structure survives filtering // (only matching items are kept; the query is empty in non-search mode). const sortedItems = useMemo(() => { const selectedSet = selectedAtOpen ?? new Set(); const result: MultiSelectorOptionData[] = []; let pendingFlat: MultiSelectorOptionData[] = []; const orderSelectedFirst = (items: MultiSelectorOptionData[]) => { const selected = items.filter(item => selectedSet.has(item.value)); const unselected = items.filter(item => !selectedSet.has(item.value)); return [...selected, ...unselected]; }; const flushFlat = () => { if (pendingFlat.length === 0) { return; } result.push(...orderSelectedFirst(pendingFlat)); pendingFlat = []; }; for (const option of options) { if (isDivider(option)) { flushFlat(); } else if (isSection(option)) { flushFlat(); const sectionOptions = option.options.map(opt => normalizeOption(opt)).filter(opt => optionMatchesQuery(opt, searchQuery)); result.push(...orderSelectedFirst(sectionOptions)); } else if (isOptionData(option)) { const normalized = normalizeOption(option); if (optionMatchesQuery(normalized, searchQuery)) { pendingFlat.push(normalized); } } } flushFlat(); if (hasSelectAll) { return [{ value: SELECT_ALL_VALUE, label: selectAllLabel }, ...result]; } return result; }, [searchQuery, options, selectedAtOpen, hasSelectAll, selectAllLabel]); // Layer for dropdown positioning const handleLayerHide = useCallback(() => { setSearchQuery(''); setSelectedAtOpen(null); // Clear any lingering result count when the popover closes so stale status // text does not linger in the a11y tree. announce(''); }, [announce]); const handleLayerShow = useCallback(() => { // Snapshot selection only after the surface actually opens; a same-gesture // rejection must not prepare state for an opening that never happened. setSelectedAtOpen(new Set(optimisticValue)); if (hasSearch) { requestAnimationFrame(() => { searchRef.current?.focus(); }); } }, [hasSearch, optimisticValue]); const surface = useSelectorPresentation({ presentation, onHide: handleLayerHide, onShow: handleLayerShow, triggerRef, popoverOptions: { hasLightDismiss: true, hasCloseButton: false, hasAutoFocus: false, // The popup's own role="listbox" is the exposed semantics; the trigger // keeps DOM focus, so wrapping it in a modal dialog would misrepresent it. role: 'none', // The theme target belongs on the SURFACE that paints the popup, which // `usePopover` owns — not on the scrolling list inside it. surfaceTarget: 'multi-selector-popup' } }); const { popover } = surface; const hideSurface = surface.hide; const isSurfaceOpen = surface.isOpen; const keepOpenProps = useKeepLayerOpenProps(popover.id, popover.isOpen); // Open dropdown on mount when isDefaultOpen is true and interaction is allowed. useEffect(() => { if (isDefaultOpen && !isEffectivelyReadOnly) { surface.show(); } // eslint-disable-next-line @eslint-react/exhaustive-deps -- mount-only: isDefaultOpen is not reactive }, []); // Read-only is controlled by caller policy, so an already-open surface must // close when that policy changes. The presentation controller keeps a sheet // mounted through its exit and lets BottomSheet return final focus. useEffect(() => { if (isEffectivelyReadOnly && isSurfaceOpen) { hideSurface(); } }, [isEffectivelyReadOnly, isSurfaceOpen, hideSurface]); // Announce the filtered result count from the query-change handler (matching // BaseTypeahead) rather than a reactive effect: computing the count for the // next query here fires the announcement exactly once per keystroke and does // not re-speak on unrelated re-renders. Reuses the announce instance shared // with the selection-count announcements above. const handleSearchChange = useCallback((nextQuery: string) => { setSearchQuery(nextQuery); if (nextQuery.length === 0) { // Emptying the query clears the region rather than announcing a count. announce(''); return; } // While isLoading the panel deliberately shows nothing, so announcing a // result would put a claim in the one channel the screen has gone quiet // for. if (isLoading) { announce(''); return; } const count = filterOptionsByQuery(selectableItems, nextQuery).length; announce(count === 0 ? emptySearchAnnouncement : t('@astryx.multiSelector.resultCount', { count })); }, [announce, isLoading, selectableItems, emptySearchAnnouncement, t]); // The panel's empty message is role="presentation", so this region is the // only route to assistive tech. It has to watch the STATE rather than the // open event: the panel can become empty either on open or when a fetch // lands with nothing in it, and an open-only announcement leaves the second // case silent while the message sits on screen. The ref makes it fire once // per arrival at that state rather than on every re-render. const announcedEmptyRef = useRef(null); useEffect(() => { const isPanelEmpty = surface.isOpen && !isLoading && searchQuery === '' && selectableItems.length === 0; if (!isPanelEmpty) { announcedEmptyRef.current = null; return; } if (announcedEmptyRef.current === emptyAnnouncement) { return; } announcedEmptyRef.current = emptyAnnouncement; announce(emptyAnnouncement); }, [surface.isOpen, isLoading, searchQuery, selectableItems.length, emptyAnnouncement, announce]); // Handle toggle // Clear all selected values. Shared by the clear button and the keyboard // Delete/Backspace path so clearing is reachable without a mouse. const clearValues = useCallback(() => { onChange([]); announceSelection([]); if (changeAction) { startTransition(async () => { setOptimisticValue([]); await changeAction([]); }); } }, [onChange, changeAction, startTransition, setOptimisticValue, announceSelection]); // Whether there is at least one selected value (clearing is meaningful). const hasValue = optimisticValue.length > 0; const handleClear = useCallback((e: React.MouseEvent) => { e.stopPropagation(); // Don't open dropdown clearValues(); }, [clearValues]); const handleToggle = useCallback((itemValue: string) => { const newValue = optimisticValue.includes(itemValue) ? optimisticValue.filter(v => v !== itemValue) : [...optimisticValue, itemValue]; onChange(newValue); announceSelection(newValue); if (changeAction) { startTransition(async () => { setOptimisticValue(newValue); await changeAction(newValue); }); } }, [optimisticValue, onChange, changeAction, startTransition, setOptimisticValue, announceSelection]); // Select-all logic const enabledItems = useMemo(() => filteredItems.filter(item => !item.disabled), [filteredItems]); const allEnabledSelected = useMemo(() => enabledItems.length > 0 && enabledItems.every(item => optimisticValue.includes(item.value)), [enabledItems, optimisticValue]); const someSelected = useMemo(() => enabledItems.some(item => optimisticValue.includes(item.value)), [enabledItems, optimisticValue]); const selectAllState: boolean | 'indeterminate' = allEnabledSelected ? true : someSelected ? 'indeterminate' : false; const handleSelectAll = useCallback(() => { let newValue: string[]; if (allEnabledSelected) { // Deselect all enabled items, keep disabled items that are selected const enabledValues = new Set(enabledItems.map(item => item.value)); newValue = optimisticValue.filter(v => !enabledValues.has(v)); } else { // Select all enabled items const currentSet = new Set(optimisticValue); newValue = [...optimisticValue]; for (const item of enabledItems) { if (!currentSet.has(item.value)) { newValue.push(item.value); } } } onChange(newValue); announceSelection(newValue); if (changeAction) { startTransition(async () => { setOptimisticValue(newValue); await changeAction(newValue); }); } }, [allEnabledSelected, enabledItems, optimisticValue, onChange, changeAction, startTransition, setOptimisticValue, announceSelection]); // Route toggle: select-all sentinel → handleSelectAll, everything else → handleToggle const handleNavigableToggle = useCallback((itemValue: string) => { if (itemValue === SELECT_ALL_VALUE) { handleSelectAll(); } else { handleToggle(itemValue); } }, [handleSelectAll, handleToggle]); // Multi-select combobox behavior — index-based, matching useCombobox pattern. // sortedItems is the single source of truth for item order. const { highlightedIndex, getItemId, onTriggerClick, onKeyDown, onItemMouseEnter } = useMultiCombobox({ selectableItems: sortedItems, isDisabled: isDisabled || isEffectivelyReadOnly, isOpen: surface.isOpen, hasSearch, onOpen: useCallback(() => surface.show(), [surface]), onClose: surface.hide, onToggle: handleNavigableToggle, onClear: hasClear ? clearValues : undefined, hasValue, listboxId }); // Highlight scrolling (and its hover/keyboard split) lives in useMultiCombobox. // Build trigger display content const selectedItems = useMemo(() => { return optimisticValue.map(v => { const item = selectableItems.find(i => i.value === v); return { value: v, label: item?.label ?? v }; }); }, [optimisticValue, selectableItems]); const selectedLabels = useMemo(() => selectedItems.map(item => item.label), [selectedItems]); const renderTriggerContent = useCallback(() => { if (optimisticValue.length === 0) { return {placeholder}; } switch (triggerDisplay) { case 'count': return {formatValue?.(selectedItems) ?? `${optimisticValue.length} selected`} ; case 'labels': { const displayed = selectedLabels.slice(0, 3); const remaining = selectedLabels.length - displayed.length; const text = remaining > 0 ? `${displayed.join(', ')}, +${remaining}` : displayed.join(', '); return {formatValue?.(selectedItems) ?? text} ; } case 'badges': { const displayed = selectedLabels.slice(0, maxBadges); const remaining = selectedLabels.length - displayed.length; return {displayed.map(label => )} {remaining > 0 && +{remaining} } ; } } }, [optimisticValue, triggerDisplay, selectedItems, selectedLabels, placeholder, formatValue, maxBadges]); // Render search input const renderSearch = useCallback(() => { if (!hasSearch) { return null; } return = 0 ? getItemId(highlightedIndex) : undefined} value={searchQuery} onValueChange={handleSearchChange} onContainerKeyDown={e => { // The clear (✕) button lives inside the row, after the input in DOM // order. When it is focused and the user tabs forward there is // nothing else in the popup, so dismiss it (Shift+Tab returns to the // input natively). Key events originating on the input are handled on // the input below; ignore them here so we don't double-dismiss. if (e.target === searchRef.current) { return; } if (e.key === 'Tab' && !e.shiftKey) { onKeyDown(e); } }} onKeyDown={e => { // An in-progress IME composition uses these same keys (Enter to // commit the candidate, Escape/Arrows to navigate the candidate // window); the composing keydown fires before compositionend, so // without this guard a Korean/Japanese/Chinese user committing a // syllable with Enter would instead toggle the highlighted option. // See utils/ime.ts. if (isImeKeyEvent(e.nativeEvent)) { return; } // Arrow keys navigate options; Enter toggles; Escape closes. // Space and Home/End are left to the input (type a space / move // the caret) per the APG editable combobox; PageUp/PageDown are // the sanctioned substitute for jumping to the first/last option. if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'PageDown' || e.key === 'Enter' || e.key === 'Escape') { onKeyDown(e); return; } // Tab: when a query is showing the clear (✕) button, forward-tab // moves focus to it (keeping the popup open) so the affordance is // keyboard-reachable. Every other Tab dismisses the popup as usual. if (e.key === 'Tab' && (e.shiftKey || !hasQuery)) { onKeyDown(e); } }} placeholder={searchPlaceholder} />; }, [hasSearch, searchId, listboxId, searchQuery, hasQuery, searchPlaceholder, handleSearchChange, onKeyDown, surface.isOpen, highlightedIndex, getItemId, t]); // Render an individual item (index-based) const renderItem = useCallback((item: MultiSelectorOptionData, flatIndex: number) => { const isHighlighted = flatIndex === highlightedIndex; const isSelectAll = item.value === SELECT_ALL_VALUE; const isSelected = isSelectAll ? allEnabledSelected : optimisticValue.includes(item.value); const checkboxValue = isSelectAll ? selectAllState : isSelected; // aria-selected="mixed" is invalid on role="option", and the tri-state // checkbox is inert/decorative, so the indeterminate state must be // conveyed through the option's accessible name (WCAG 4.1.2). const isPartiallySelected = isSelectAll && selectAllState === 'indeterminate'; const checkbox =
{}} isDisabled={item.disabled} size={size === 'lg' ? 'md' : size} />
; return
{ if (!item.disabled) { handleNavigableToggle(item.value); } }} onMouseEnter={() => onItemMouseEnter(item, flatIndex)} {...mergeProps( // One target for every dropdown row, carrying the row's size and // runtime state so a theme can express "selected option at large" // or restyle just the Select All row (`.select-all`) without // reaching for structural selectors. themeProps('multi-selector-option', { size, 'select-all': isSelectAll ? 'select-all' : null, selected: isSelected ? 'selected' : null, disabled: item.disabled ? 'disabled' : null }), stylex.props(styles.item, isSelectAll ? selectAllSizeStyles[size] : itemSizeStyles[size], isSelectAll && styles.selectAllWrapper, isHighlighted && styles.itemHighlighted, item.disabled && styles.itemDisabled))}> {indicatorPosition === 'start' && checkbox} {renderOption && !isSelectAll ? renderOption(item) : {item.label ?? item.value} } {indicatorPosition === 'end' && checkbox}
; }, [renderOption, indicatorPosition, highlightedIndex, optimisticValue, allEnabledSelected, selectAllState, selectAllLabel, getItemId, handleNavigableToggle, onItemMouseEnter, size, t]); // Render all options. Uses sortedItems as the single source of truth for // item order — no independent sorting here. Structural elements (dividers, // section headers) come from the original options prop. const renderOptions = useCallback(() => { const elements: ReactNode[] = []; let cursor = 0; // Number of real items (excluding the select-all sentinel) const realItemCount = hasSelectAll ? sortedItems.length - 1 : sortedItems.length; // Show select-all only when there are real items to select. It reads as // the first row of the list, not a section of its own — no divider under // it (the checkbox column already lines it up with the options below). if (hasSelectAll && realItemCount > 0) { elements.push(renderItem(sortedItems[0], 0)); cursor = 1; } else if (hasSelectAll) { // Skip the select-all sentinel when there are no real items cursor = 1; } // Empty state — no real items to show. role="presentation" keeps the // message out of the listbox's accessibility tree (role="listbox" only // permits option/group children); the no-results outcome is announced // via the result-count live region instead. // While isLoading the options have not arrived yet, so asserting either // message would be a claim the component cannot make; the trigger's // spinner covers it. if (realItemCount === 0 && !isLoading) { elements.push(
{searchQuery ? emptySearchText : emptyText}
); return elements; } // Consume items from sortedItems in order, interleaving structural elements // (dividers, section headers) from the options prop. While searching, only // matching items are present in sortedItems, so a section consumes just its // matches and is skipped entirely when none match — no header left standing // over nothing, and the cursor stays aligned with the combobox indices. const isSearching = Boolean(searchQuery); let pendingCount = 0; const flushPending = () => { for (let j = 0; j < pendingCount; j++) { elements.push(renderItem(sortedItems[cursor], cursor)); cursor++; } pendingCount = 0; }; for (let i = 0; i < options.length; i++) { const option = options[i]; if (isDivider(option)) { flushPending(); // A standalone divider between groups would orphan itself once its // neighbors are filtered out, so skip it while searching. if (!isSearching) { // role="listbox" only permits option/group children; the divider // carries no information the options don't, so it's hidden from // the accessibility tree entirely rather than exposing // role="separator" as a disallowed listbox child (axe // aria-required-children). elements.push(