// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Selector.tsx * @input Uses React, StyleX, usePopover, useTooltip, Icon, InputGroupContext, * and Selector positioning hooks * @output Exports Selector component * @position Core implementation; consumed by index.ts * * SYNC: When modified, update: * - /packages/core/src/Selector/Selector.doc.mjs * - /packages/core/src/Selector/Selector.test.tsx * - /packages/core/src/Selector/index.ts * - /apps/storybook/stories/InputGroup.stories.tsx * - /packages/cli/assets/templates/blocks/components/Selector/ (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 { useIndicator } from "../Indicator/index.js"; import type { IndicatorPosition } from "../Indicator/index.js"; import type { IconName } from "../Icon/index.js"; import { Field, inputStatusBorderStyles, inputStatusHoverShadowStyles, inputWrapperStyles, type FieldStatusVariant } from "../Field/index.js"; import { Divider } from "../Divider/index.js"; import { layerAnimations } from "../Layer/layerAnimations.stylex.js"; import { useKeepLayerOpenProps, type LayerPlacement } from "../Layer/useLayer.js"; import { InternalInputClearButton } from "../Field/InputClearButton.js"; import { Spinner } from "../Spinner/index.js"; import { PanelSearchInput } from "../Field/PanelSearchInput.js"; import { useAnnounce } from "../hooks/useAnnounce.js"; import "../theme/tokens.stylex.js"; import { colorVars, sizeVars, spacingVars, radiusVars, durationVars, easeVars, typographyVars, fontWeightVars, typeScaleVars, borderVars } from "../theme/tokens.stylex.js"; import type { SelectorOptionType, SelectorOptionData } from "./types.js"; import { isOptionData, isDivider, isSection, normalizeOption, getSelectableOptions } from "./utils.js"; import { useCombobox, useSelectedItemOffset } from "./hooks.js"; import { useTypeahead } from "../hooks/useTypeahead.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import { SelectorOption } from "./SelectorOption.js"; import { SelectorRowLayoutContext } from "./SelectorRowLayoutContext.js"; import { getInputARIA, isImeKeyEvent, mergeProps } from "../utils/index.js"; import { useSize } from "../SizeContext/SizeContext.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.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 "./SelectorBottomSheet.js"; import { useSelectorPresentation } from "./useSelectorPresentation.js"; import { selectorPresentationStyles } from "./selectorPresentation.stylex.js"; const styles = { triggerContainer: { kVAEAm: "x1n2onr6", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "x1qughib", kOIVth: "x1txdalj", kzqmXN: "xh8yej3", k8WAf4: "xce4md1", kg3NbH: "xrrkdod", kMv6JI: "x9ynric", kGuDYH: "xcr08ib x1w61h2b", kLWn49: "x7acx1w", kMwMTN: "x1tgivj0", kkrTdU: "x1ypdohk x16khyan", $$css: true as const }, triggerPlaceholder: { kMwMTN: "xv1l7n4", $$css: true as const }, triggerInGroup: { kAzted: "x2lwn1j", k8WAf4: "xt970qd", $$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 }, searchRowInput: { kg3NbH: "x2hg6jq", $$css: true as const }, popover: { k7Eaqz: "xrzjruh", $$css: true as const }, divider: { kqGvvJ: "xsq74q5", $$css: true as const }, item: { kB7OPa: "x9f619", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "x1qughib", kOIVth: "x1txdalj", kzqmXN: "xh8yej3", kmVPX3: "xlsj2fj", kaIpWk: "xh6dtrn", kMv6JI: "x9ynric", kGuDYH: "xcr08ib", kMwMTN: "x1tgivj0", kWkggS: "xjbqb8w", kkrTdU: "x1ypdohk x16khyan", k9WMMc: "x1yc453h", kI3sdo: "x1a2a7pz", $$css: true as const }, itemHighlighted: { kWkggS: "x1lmrjuc", $$css: true as const }, itemSelected: { k63SB2: "x1e4wzip", $$css: true as const }, itemDisabled: { kSiTet: "xbyyjgo", kkrTdU: "xt0e3qv", $$css: true as const } }; // The trigger is sized by PADDING, not by a fixed height, so it is the size // token plus one text line for each extra line the value uses: 28/32/36 for // one line, 48/52/56 for two. The token and a text line are both multiples of // 4, so every trigger lands on the 4px rhythm and lines up with the Buttons // and inputs beside it. No prop picks the height — the content does, and it // can only land on the grid. // // `--spacing-5` is one line here because `triggerContainer` pins its // line-height to exactly that; the two must stay in step, which is why both // read the same token rather than one hardcoding 20px. // Keep these calculations inline: a consumer's Babel preset can lower a // module-scope helper to a function expression before StyleX evaluates this // object, and StyleX cannot constant-evaluate that transformed helper. const sizeStyles = { sm: { kAzted: "x1gdqauq", k8WAf4: "x1ecmfrv", $$css: true as const }, md: { kAzted: "x1uogy3y", k8WAf4: "x1ioay2v", $$css: true as const }, lg: { kAzted: "x1xf4nls", k8WAf4: "x1vn5h1t", $$css: true as const } }; /** * Size-specific overrides for dropdown list items. * Matches the pattern used by DropdownMenuItem so that * an `sm` selector renders compact list items, `md`/`lg` use * the base padding defined in `styles.item`. */ const itemSizeStyles = { sm: { k8WAf4: "xu0wf1k", kg3NbH: "xf314gf", $$css: true as const }, md: { k8WAf4: "x1vofgu7", $$css: true as const }, lg: { $$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 SelectorSize = 'sm' | 'md' | 'lg'; export type SelectorVariant = 'input' | 'ghost'; export type SelectorPresentation = AdaptivePresentation; export type SelectorStatusType = 'warning' | 'error' | 'success'; export interface SelectorStatus { /** * The type of status to display. */ type: SelectorStatusType; /** * Optional message to display below the input. */ message?: string; } interface SelectorPropsBase extends Omit { /** * Label text for the 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 value stays visible, focusable, and included in form * submission, and retains its 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[]; // value, onChange, changeAction, and hasClear are in the discriminated union below /** * 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. * - 'sm': Compact size * - 'md': Default size * @default 'md' */ size?: SelectorSize; /** * Visual style of the selector trigger. * - 'input': bordered input-style trigger for forms * - 'ghost': borderless trigger matching ghost buttons, for toolbars * @default 'input' */ variant?: SelectorVariant; /** * Status indicator for the selector. * When set, displays a colored border and status icon. * If message is provided, displays a message box below the selector. */ status?: SelectorStatus; /** * 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. Takes precedence over * the selected option's own `icon`, which the trigger otherwise renders. */ startIcon?: ReactNode | IconType; /** * Custom render function for options. * Only called for selectable options (not dividers/sections). */ renderOption?: (option: SelectorOptionData) => ReactNode; /** * Custom render function for the selected option inside the closed trigger. * Only called when something is selected; the placeholder is unaffected. * * Passing this does not change the trigger's height — what it draws does. A * one-line value measures exactly the `size` token, so the control still * lines up with the Buttons and inputs beside it; each further line of * content adds one text line. Inside an `InputGroup` the group owns the row * height: the trigger clamps its value box to that row, so a `SelectorOption` * folds onto one line and ellipsizes, and anything taller than the row is cut * off at it rather than bleeding over the rows above and below. * * @example * ``` * renderValue={option => ( * * )} * ``` */ renderValue?: (option: SelectorOptionData) => ReactNode; /** * Which edge of the option row carries the selected mark. `start` reserves a * mark column ahead of every label so they stay aligned, the way a native * menu does; `end` is the house convention shared with Typeahead and * CommandPalette. * * @default 'end' */ indicatorPosition?: IndicatorPosition; /** * Whether to show a search input for filtering options. * @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; /** * Position placement relative to the trigger. * * Omit to use the selector's default selected-item overlay behavior: the * selected item is positioned over the trigger and clamped to the viewport. * Set a placement to opt into explicit layer positioning (for example, * `placement="above"` for bottom-fixed toolbars). */ placement?: LayerPlacement; /** * 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?: SelectorPresentation; /** * Whether the dropdown starts open on mount. * Useful for showcases and previews. * @default false */ isDefaultOpen?: boolean; /** * The HTML name attribute for form submissions. When set, a hidden input * carries the selected value under this name, matching how a native * select serializes. */ htmlName?: string; /** * Test ID for testing frameworks. */ 'data-testid'?: string; } /** * Without `hasClear`, the selector always has a string value (or undefined for placeholder). * With `hasClear`, the value can be `null` and onChange receives `null` on clear. */ type SelectorPropsNonClearable = SelectorPropsBase & { hasClear?: false; value?: string; onChange?: (value: string) => void; changeAction?: (value: string) => void | Promise; }; type SelectorPropsClearable = SelectorPropsBase & { /** * Whether to show a clear button when a value is selected. * When clicked, resets the value to `null` and returns focus to the trigger. * * When enabled, `value` and `onChange` widen to include `null`. */ hasClear: true; value: string | null; onChange?: (value: string | null) => void; changeAction?: (value: string | null) => void | Promise; }; export type SelectorProps = SelectorPropsNonClearable | SelectorPropsClearable; /** * Default option renderer */ function DefaultOption({ option }: { option: SelectorOptionData; }) { return ; } // 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: SelectorOptionData, 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: SelectorOptionData[], query: string): SelectorOptionData[] { if (!query) { return items; } return items.filter(item => optionMatchesQuery(item, query)); } /** * A selector/dropdown component for choosing from a list of options. * * @example * ``` * * ``` */ export function Selector(props: SelectorProps) { const t = useTranslator(); const { 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, htmlName, renderOption, renderValue, indicatorPosition = 'end', hasSearch = false, searchPlaceholder: searchPlaceholderFromProps, emptyText: emptyTextFromProps, emptySearchText: emptySearchTextFromProps, placement, presentation = 'popover', isDefaultOpen = false, 'data-testid': testId, width, xstyle, className, style, onFocus, hasClear: hasClearProp, id, ...rest } = props as SelectorPropsClearable; const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); const placeholder = placeholderFromProps ?? t('@astryx.selector.placeholder'); const searchPlaceholder = searchPlaceholderFromProps ?? t('@astryx.selector.searchPlaceholder'); const emptyText = emptyTextFromProps ?? t('@astryx.selector.empty'); const emptySearchText = emptySearchTextFromProps ?? t('@astryx.selector.emptySearchResults'); const hasClear = hasClearProp === true; const size = useSize(sizeProp, 'md'); const effectiveStatusVariant = variant === 'ghost' && statusVariant === 'attached' ? 'detached' : statusVariant; const isEffectivelyReadOnly = isReadOnly && !isDisabled; // Normalize null to undefined for internal use (null is the clear sentinel) const normalizedValue = value === null ? undefined : value; const generatedTriggerId = useId(); // A caller's `id` lands on the trigger either way, so the internal identity // has to be that same value or the label and listbox point at nothing. const triggerId = id ?? generatedTriggerId; const listboxId = useId(); const descriptionId = useId(); const statusMessageId = useId(); const inputLabelId = useId(); const readOnlyDescriptionId = useId(); const searchId = useId(); // Measure from the same outer control that usePopover anchors to; using the // shorter inner button makes every size's selected row land too low. const anchorRef = useRef(null); const triggerRef = useRef(null); const searchRef = useRef(null); const inputGroup = useInputGroup(); const [searchQuery, setSearchQuery] = useState(''); // Mirrors searchQuery for the seed path, which runs before focus reaches the // input and so cannot read the rendered state. const searchQueryRef = useRef(''); // 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; const [, startTransition] = useTransition(); const [optimisticValue, setOptimisticValue] = useOptimistic(normalizedValue); const isBusy = isLoading || optimisticValue !== normalizedValue; 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.selector.empty'); const emptySearchAnnouncement = typeof emptySearchText === 'string' ? emptySearchText : t('@astryx.selector.emptySearchResults'); // 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 // useCombobox (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]); // Filter items by search query const filteredItems = useMemo(() => filterOptionsByQuery(selectableItems, searchQuery), [selectableItems, searchQuery]); // Find selected item and its index for positioning const selectedItemIndex = useMemo(() => { return selectableItems.findIndex(item => item.value === optimisticValue); }, [selectableItems, optimisticValue]); const selectedItem = useMemo(() => { return selectedItemIndex >= 0 ? selectableItems[selectedItemIndex] : undefined; }, [selectableItems, selectedItemIndex]); // Ref for listbox to measure selected item position const listboxRef = useRef(null); // Typeahead is defined below (it needs the popover), but closing and clearing // must drop its pending buffer — otherwise a stale prefix survives the reset // window and poisons the next keystroke ("Dog" then "c" would search "dc"). const resetTypeaheadRef = useRef<() => void>(() => {}); // Layer for dropdown positioning const handleLayerHide = useCallback(() => { setSearchQuery(''); searchQueryRef.current = ''; resetTypeaheadRef.current(); // 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(() => { if (hasSearch) { requestAnimationFrame(() => { const input = searchRef.current; if (input) { input.focus(); // When typing seeded the query, place the caret after it so the user // keeps typing where they left off. input.setSelectionRange(input.value.length, input.value.length); } }); } }, [hasSearch]); 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: '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 handlers (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. Split out from handleSearchChange so // the type-to-open seed below reaches the same live region — a query the user // typed is a query however it arrived. const announceSearchResults = useCallback((nextQuery: string) => { 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.selector.resultCount', { count })); }, [announce, isLoading, selectableItems, emptySearchAnnouncement, t]); const handleSearchChange = useCallback((nextQuery: string) => { setSearchQuery(nextQuery); searchQueryRef.current = nextQuery; announceSearchResults(nextQuery); }, [announceSearchResults]); // 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]); // Calculate offset to position selected item over trigger. Explicit // placement opts out of the selector-specific overlay behavior and uses the // standard layer positioning API instead. const shouldOverlaySelectedItem = placement == null && !hasSearch; const { offset: rawOffset, isPositioned: rawIsPositioned } = useSelectedItemOffset({ isOpen: popover.isOpen && shouldOverlaySelectedItem, selectedItemIndex, listboxId, listboxRef, anchorRef }); const selectedItemOffset = shouldOverlaySelectedItem ? rawOffset : 0; const isPositioned = shouldOverlaySelectedItem ? rawIsPositioned : true; const popoverPlacement = placement ?? 'below'; const popoverOffsetStyle: React.CSSProperties | undefined = selectedItemOffset > 0 ? { marginBlockStart: `-${selectedItemOffset}px` } : undefined; // Clear the current value. Shared by the clear button and the keyboard // Delete/Backspace path so clearing is reachable without a mouse. const clearValue = useCallback(() => { resetTypeaheadRef.current(); onChange?.(null); if (changeAction) { startTransition(async () => { setOptimisticValue(undefined); await changeAction(null); }); } }, [onChange, changeAction, startTransition, setOptimisticValue]); // Type-to-find appends to the query rather than replacing it: characters // typed before focus reaches the search input must not be dropped. The ref // is what makes that safe without a state updater — it advances // synchronously, so a second character seeded in the same tick still sees // the first, and the announcement can be computed here rather than inside a // setState callback. const appendSearchQuery = useCallback((char: string) => { const nextQuery = searchQueryRef.current + char; searchQueryRef.current = nextQuery; setSearchQuery(nextQuery); announceSearchResults(nextQuery); }, [announceSearchResults]); const commitValue = useCallback((newValue: string) => { onChange?.(newValue); if (changeAction) { startTransition(async () => { setOptimisticValue(newValue); await changeAction(newValue); }); } }, [onChange, changeAction, startTransition, setOptimisticValue]); // Selector behavior (keyboard nav, selection) const { highlightedIndex, setHighlightedIndex, getItemId, onTriggerClick, onKeyDown, onItemSelect, onItemMouseEnter } = useCombobox({ selectableItems: filteredItems, // The optimistic value, not the raw prop: with a pending changeAction the // prop still holds the old selection, so the popup would open with the // highlight on it and Delete/Backspace could clear a value the action has // already replaced. value: optimisticValue, isDisabled: isDisabled || isEffectivelyReadOnly, isOpen: surface.isOpen, hasSearch, onOpen: useCallback(() => surface.show(), [surface]), onClose: surface.hide, onSelect: commitValue, onClear: hasClear ? clearValue : undefined, onSearchSeed: appendSearchQuery, listboxId }); // Type-to-select, shared with the other collections (menus, listboxes). // Open, it walks the highlight — aria-activedescendant announces each match. // Closed, it commits the match like a native select, which changes the value // without opening the popup or moving focus, so nothing else would prompt // assistive tech to re-read the trigger: announce it explicitly. const typeahead = useTypeahead({ getItemLabels: () => selectableItems.map(item => item.label ?? item.value), isDisabled: index => selectableItems[index]?.disabled === true, // Cycle onward from the highlight when open, from the committed selection // when closed — the optimistic one, so a pending changeAction cannot strand // cycling on the first match. -1 means nothing is selected or highlighted, // which the hook reads as "search from the top". getCurrentIndex: () => surface.isOpen ? highlightedIndex : selectedItemIndex, onMatch: index => { const item = selectableItems[index]; if (surface.isOpen) { setHighlightedIndex(index); } else if (item.value !== optimisticValue) { commitValue(item.value); announce(item.label ?? item.value); } } }); resetTypeaheadRef.current = typeahead.reset; const handleTriggerKeyDown = useCallback((e: React.KeyboardEvent) => { if (isDisabled || isEffectivelyReadOnly) { return; } // With hasSearch the query input owns typing, so type-to-select is off. if (!hasSearch && typeahead.onKeyDown(e)) { e.preventDefault(); return; } onKeyDown(e); }, [isDisabled, isEffectivelyReadOnly, hasSearch, typeahead, onKeyDown]); // Highlight scrolling (and its hover/keyboard split) lives in useCombobox. // Handle clear button click const handleClear = useCallback((e: React.MouseEvent) => { e.stopPropagation(); // Don't open dropdown clearValue(); }, [clearValue]); // 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 select the highlighted option. // See utils/ime.ts. if (isImeKeyEvent(e.nativeEvent)) { return; } // Arrow keys navigate options; Enter selects; Escape closes. // Home/End are left to the input for caret movement (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, surface.activePresentation, highlightedIndex, getItemId, variant, t]); // The single-selection mark, resolved from the theme once per render. A // theme that maps `check` to another indicator (a radio, say) changes every // selected-option mark in the app through this one lookup. const SelectionMark = useIndicator('check'); // Render an individual item const renderItem = useCallback((item: SelectorOptionData, flatIndex: number) => { const isHighlighted = flatIndex === highlightedIndex; const isSelected = item.value === normalizedValue; /* * Rendered UNCONDITIONALLY, with the state passed down: the default * check draws nothing when unchecked, but a theme that replaces the * `check` indicator with a radio needs the unselected state to draw * its empty circle. `{isSelected && …}` would make that impossible. * * `selector-check` stays the stable target for the mark's position * in the row; the indicator owns what the mark looks like. */ const mark = ; const optionContent = {renderOption ? renderOption(item) : } ; const content = indicatorPosition === 'start' ? <> {mark} {optionContent} : <> {optionContent} {mark} ; return
onItemSelect(item)} onMouseEnter={() => onItemMouseEnter(item, flatIndex)} {...mergeProps( // Stable theme target on the option row itself, mirroring // `multi-selector-option`: it carries the row's size and runtime // state so a theme can express "selected option at large" or // restyle a given row density without structural selectors. The // row's padding is split across a base and a per-size override (the // default `md` trims the block axis), so `size` is what a theme // needs to reach it. Named `-option-row` because `selector-option` // is the public SelectorOption content primitive, not this row. themeProps('selector-option-row', { size, selected: isSelected ? 'selected' : null, disabled: item.disabled ? 'disabled' : null }), stylex.props(styles.item, itemSizeStyles[size], isHighlighted && styles.itemHighlighted, isSelected && styles.itemSelected, item.disabled && styles.itemDisabled))}> {content}
; }, [renderOption, indicatorPosition, highlightedIndex, size, normalizedValue, getItemId, onItemSelect, onItemMouseEnter, SelectionMark]); // Render all options (handling sections/dividers) const renderOptions = useCallback(() => { const isSearching = hasSearch && Boolean(searchQuery); // Nothing to show — either the query matched nothing, or no options were // given at all. Both render the same slot with different copy. While // isLoading the options have not arrived yet, so asserting either would be // a claim the component cannot make; the trigger's spinner covers it. if (filteredItems.length === 0 && !isLoading) { // 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. return [
{isSearching ? emptySearchText : emptyText}
]; } let flatIndex = 0; const elements: ReactNode[] = []; for (let i = 0; i < options.length; i++) { const option = options[i]; if (isDivider(option)) { // While searching, a standalone divider between groups would orphan // itself once its neighbors are filtered out, so skip it. if (isSearching) { continue; } // 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(