import type { ForwardedRef, PropsWithChildren, ReactNode } from 'react'; import React, { Children, isValidElement, useCallback, useId, useMemo, useRef, useState } from 'react'; import { mergeProps, useInteractOutside } from 'react-aria'; import type { ListBoxItemRenderProps, ListBoxProps } from 'react-aria-components'; import { mergeRefs } from '@react-aria/utils'; import classNames from 'classnames'; import { Combobox as CoreCombobox, Popover as CorePopover, Select as CoreSelect, Text as CoreText, useTranslations, } from '@shoptet/ui-core-web'; import type { ComboboxOptionState, SelectOptionState } from '@shoptet/ui-core-web'; import type { SafeOmit } from '@shoptet/utils'; import { getControlMode, omit, pick, useControlledState } from '@shoptet/utils'; import type { ScreenSize } from '../../../hooks/useScreenSize'; import { useScreenSize } from '../../../hooks/useScreenSize'; import { forwardRef } from '../../../utils/forwardRef'; import { DataList, getDataListProps } from '../../DataList/DataList'; import { getDatalistItemProps } from '../../DataList/DataListItem'; import type { DataListItemCellProps } from '../../DataList/DataListItemCell'; import { DataListItemCell, getDataListCellProps } from '../../DataList/DataListItemCell'; import { getDataListSectionProps } from '../../DataList/DataListSection'; import { getDataListSectionHeaderProps } from '../../DataList/DataListSectionHeader'; import { InputDialog } from '../../Fields/InputDialog/InputDialog'; import { Icon } from '../../Icon/Icon'; import { getTypographyProps } from '../../Typography/getTypographyProps'; import { Text } from '../../Typography/Text/Text'; import { getSearchInputProps } from '../SearchInput/getSearchInputProps'; import type { SearchInputProps } from '../SearchInput/SearchInput'; import type { CommonInputOwnProps } from '../types'; import { dictionary } from './dictionary'; import { SingleSelectTrigger } from './SingleSelectTrigger'; import type { SelectRow } from './SingleSelectVirtualizedListbox'; import { SingleSelectVirtualizedListbox } from './SingleSelectVirtualizedListbox'; const EMPTY_OPTION_KEY = '__singleSelectFieldNull'; export type SelectKey = string | number; export type RenderOptionContext = ListBoxItemRenderProps & { defaultChildren: ReactNode; screenSize: ScreenSize; }; const SingleSelectOptionCell = Object.assign( function Cell({ children, ...rest }: DataListItemCellProps) { return ( {Children.map(children, child => (isValidElement(child) ? child : {child}))} ); }, { displayName: 'SingleSelect.Option.Cell', } ); const SingleSelectOption = Object.assign( function Option({ children, ..._unusedRest }: PropsWithChildren) { _unusedRest satisfies Record; // Bare children (typically the option label string) go into the default growing cell; without it // the row's `justify-content: space-between` pushes them to the right edge. Cell elements (and // other custom elements) pass through so multi-cell layouts keep full control. return ( <> {Children.map(children, child => isValidElement(child) ? child : {child} )} ); }, { displayName: 'SingleSelect.Option', Cell: SingleSelectOptionCell, } ); /** @inheritdoc */ export interface SingleSelectProps extends Omit, Pick { /** * The unique identifier of the input. */ id?: string; /** * Whether the checked indicator should be displayed. * @default 'start' */ checkedIndicator?: false | 'start' | 'end'; /** * The default query to search for (uncontrolled). */ defaultQuery?: string; /** * Default selected key value of the input (uncontrolled). */ defaultValue?: V; /** * List of values of the disabled options (controlled). */ disabledValues?: Array; /** * The text for the empty options state. * * If not provided, the default translation from the system will be used. * Use this prop to override the default translation when a custom label is needed. */ emptyOptionsLabel?: string; /** * Function to get nested options for an option. * @param option - The option to get the children for. */ getGroupOptions?(option: T): Array | undefined; /** * Function to get the key for an option. * @param option - The option to get the key for. */ getOptionValue(option: T): V; /** * Function to get the text label for an option. * @param option - The option to get the text label for. */ getOptionLabel(option: T): string; /** * List of options to display (controlled). */ options: Array; /** * Handler that is called when the selection changes. * */ onChange?: (option: V | null) => void; /** * Handler that is called when the search query value changes. */ onQueryChange?: (value: string | null) => void; /** * Handler that is called when the search query loses focus. */ onQueryBlur?: SearchInputProps['onBlur']; /** * Handler that is called when the search query gains focus. */ onQueryFocus?: SearchInputProps['onFocus']; /** * The query to search for (controlled). */ query?: string | null; /** * The mode of the input. * @default 'picker' */ mode?: 'picker' | 'search'; /** * Function to render the empty state of the list box. */ renderEmptyState?: ListBoxProps['renderEmptyState']; /** * Function to render an option in the search results. * @param option - The option to render. */ renderOption?(option: T, context: RenderOptionContext): ReactNode; /** * Function to render the value of the input. * @param value - The value to render. */ renderValue?(option: T, labelId: string): ReactNode; /** * Selected key value (controlled). */ value?: V | null; /** * Show first option as empty choice. Supported only in 'picker' mode. */ nullable?: boolean; /** * Virtualization can improve performance when rendering a large number of options. * Only supported in 'search' mode. Pass `true` for defaults, or an object to tune the estimates * and popover width. */ virtualize?: boolean | SelectVirtualizerOptions; } /** * Tuning for the search-mode virtualizer. Option heights are measured dynamically (options may render * arbitrary content), so `rowHeight`/`headingHeight` are only the initial estimates used before the * real sizes are measured. */ export interface SelectVirtualizerOptions { /** Estimated option-row height, in px, before dynamic measurement. @default 34 */ rowHeight?: number; /** Estimated group-heading-row height, in px, before dynamic measurement. @default 24 */ headingHeight?: number; /** Gap between rows, in px. @default 0 */ gap?: number; /** Vertical padding at the start/end of the list, in px. @default 0 */ padding?: number; /** Fixed popover width, in px. Applied to the desktop search popover. */ popoverWidth?: number; } const OPTION_HEIGHT = 34; const HEADING_HEIGHT = 24; export const SingleSelect = Object.assign( forwardRef(function SingleSelect( { checkedIndicator = 'start', defaultQuery: defaultSearchQueryProp, defaultValue, label, disabled, disabledValues, emptyOptionsLabel, getGroupOptions, getOptionValue, getOptionLabel, icon = true, id, name, mode = 'picker', onChange: onChangeProp, onQueryBlur: onQueryBlurProp, onQueryChange: onQueryChangeProp, onQueryFocus, options, placeholder, query: searchQueryProp, renderEmptyState, renderOption, renderValue, value: selectedKeyProp, nullable = false, virtualize, ...rest }: SingleSelectProps, forwardedRef: ForwardedRef ) { rest satisfies SafeOmit; const [controlMode] = useState(() => getControlMode(searchQueryProp)); if (process.env.NODE_ENV !== 'production') { if (controlMode !== getControlMode(searchQueryProp)) { console.warn( `[SingleSelectField] Cannot change mode from ${controlMode} to ${getControlMode(searchQueryProp)}.` ); } if (controlMode === 'controlled' && onChangeProp === undefined) { console.warn('[SingleSelectField] The "onChange" prop is required when the "options" prop is used.'); } if (selectedKeyProp !== undefined && defaultValue !== undefined) { console.warn('[SingleSelectField] The "value" and "defaultValue" props cannot be used at the same time.'); } if (nullable && mode !== 'picker') { console.warn(`[SingleSelectField] Nullable prop is only supported in 'picker' mode.`); } if (virtualize && mode !== 'search') { throw new Error(`[SingleSelectField] Virtualization is only supported in 'search' mode.`); } } const screenSize = useScreenSize(); const [isOpen, setIsOpen] = useState(false); // The desktop search popover is non-modal (the combobox input keeps focus), so react-aria's // usePopover does not arm outside dismissal. Wire it explicitly: close on an interaction outside // the popover, except when it lands on the input itself. const popoverOverlayRef = useRef(null); const searchInputRef = useRef(null); useInteractOutside({ ref: popoverOverlayRef, isDisabled: !isOpen, onInteractOutside: event => { if (searchInputRef.current?.contains(event.target as Node)) return; setIsOpen(false); }, }); const [localSelectedKey, setLocalSelectedKey] = useState(selectedKeyProp ?? defaultValue ?? null); const selectedKey = selectedKeyProp === undefined ? localSelectedKey : selectedKeyProp; // Semi-controlled, like the core inputs: selection and blur replace the query internally (the // input text becomes the selected option's label, per the APG combobox pattern), and a changed // `query` prop re-syncs it. const [searchQuery, setSearchQuery] = useControlledState( () => searchQueryProp ?? defaultSearchQueryProp ?? null, searchQueryProp ); const selectedOption = findOption(options, selectedKey, getOptionValue, getGroupOptions); const selectedTextValue = selectedOption ? getOptionLabel(selectedOption) : ''; const showNullableOption = nullable && mode === 'picker'; // Uncontrolled search filters locally — the react-aria ComboBox `defaultItems` contains-filter // this replaced. A query equal to the selected option's label shows the full list (the input // holds the label right after selection or focus, not a user-typed query). const searchFilter = useMemo(() => { if (mode !== 'search' || controlMode !== 'uncontrolled') return null; const query = searchQuery ?? ''; if (query === '' || query === selectedTextValue) return null; const normalizedQuery = normalizeForSearch(query); return (option: T) => normalizeForSearch(getOptionLabel(option)).includes(normalizedQuery); }, [mode, controlMode, searchQuery, selectedTextValue, getOptionLabel]); const searchGetGroupOptions = useMemo(() => { if (!searchFilter) return getGroupOptions; return (option: T) => { const group = getGroupOptions?.(option); return group ? group.filter(element => searchFilter(element)) : group; }; }, [searchFilter, getGroupOptions]); const searchVisibleOptions = useMemo(() => { const all = options ?? []; if (!searchFilter) return all; return all.filter(option => { const group = getGroupOptions?.(option); return group && group.length > 0 ? group.some(element => searchFilter(element)) : searchFilter(option); }); }, [options, searchFilter, getGroupOptions]); // Virtualization applies only to the search listbox. `navigableValues`/`onActiveChange` are wired // to the core Combobox only when virtualizing, so plain search keeps DOM-driven navigation. const isVirtualized = Boolean(virtualize) && mode === 'search'; const virtualizeOptions = typeof virtualize === 'object' ? virtualize : undefined; const virtualizerLayout = useMemo( () => ({ rowHeight: virtualizeOptions?.rowHeight ?? OPTION_HEIGHT, headingHeight: virtualizeOptions?.headingHeight ?? HEADING_HEIGHT, gap: virtualizeOptions?.gap ?? 0, padding: virtualizeOptions?.padding ?? 0, }), [ virtualizeOptions?.rowHeight, virtualizeOptions?.headingHeight, virtualizeOptions?.gap, virtualizeOptions?.padding, ] ); const popoverWidth = virtualizeOptions?.popoverWidth; const searchRows = useMemo( () => isVirtualized ? flattenSearchRows(searchVisibleOptions, { getGroupOptions: searchGetGroupOptions, getOptionValue, getOptionLabel, disabledValues, }) : [], [isVirtualized, searchVisibleOptions, searchGetGroupOptions, getOptionValue, getOptionLabel, disabledValues] ); // Ordered ENABLED option values, in display order — the core Combobox navigates this instead of // the (windowed) DOM options. const navigableValues = useMemo( () => searchRows.filter(row => row.kind === 'option' && !row.disabled).map(row => (row as { value: V }).value), [searchRows] ); // Index (into `searchRows`) of an option by value; -1 when absent. Used to resolve the active row // (force-mount + scroll) and the selected row (reveal on open). const rowIndexByValue = useMemo(() => { const map = new Map(); searchRows.forEach((row, index) => { if (row.kind === 'option') map.set(row.value, index); }); return map; }, [searchRows]); // The active row (by index) drives both force-mounting and scroll-into-view inside the listbox. const [activeRowIndex, setActiveRowIndex] = useState(null); const onActiveChange = useCallback( (active: { value: V | null; id: string } | null) => { if (active === null || active.value === null) { setActiveRowIndex(null); return; } setActiveRowIndex(rowIndexByValue.get(active.value) ?? null); }, [rowIndexByValue] ); const selectedRowIndex = selectedKey === null ? null : (rowIndexByValue.get(selectedKey) ?? null); const onSelectionChange = useCallback( function onSelectionChange(key: V | null) { if (key === EMPTY_OPTION_KEY) { onChangeProp?.(null); setLocalSelectedKey(null); } else { onChangeProp?.(key); setLocalSelectedKey(key); } // The input text becomes the selected option's label: the internal query clears (the display // falls back to the label), and a controlled consumer is told so its query state follows. setSearchQuery(null); onQueryChangeProp?.(null); setIsOpen(false); }, [onChangeProp, onQueryChangeProp, setSearchQuery] ); const onQueryBlurHandler = useCallback( (value: string, event?: React.FocusEvent) => { const option = findOption(options, selectedKey, getOptionValue, getGroupOptions); onQueryBlurProp?.(value, event); setSearchQuery(option ? getOptionLabel(option) : null); onQueryChangeProp?.(null); }, [ getGroupOptions, getOptionValue, getOptionLabel, onQueryBlurProp, onQueryChangeProp, options, selectedKey, setSearchQuery, ] ); const onQueryFocusHandler = useCallback( (value: string, event?: React.FocusEvent) => { onQueryFocus?.(value, event); // menuTrigger:'focus' equivalent — focusing the search input opens the listbox. setIsOpen(true); }, [onQueryFocus] ); const onQueryBlurStandard = useCallback( (event: React.FocusEvent) => { onQueryBlurHandler(event.target.value, event); }, [onQueryBlurHandler] ); const onQueryFocusStandard = useCallback( (event: React.FocusEvent) => { onQueryFocusHandler(event.target.value, event); }, [onQueryFocusHandler] ); const dialogInputId = useId(); const triggerValueId = `SingleSelectTrigger${dialogInputId}`; const triggerButtonId = id ?? `SingleSelectTriggerButton${dialogInputId}`; const [triggerRef, triggerWidth] = useTriggerWidth(); const translations = useTranslations(dictionary); const { emptyOptionsText, nullableOptionText } = useMemo( () => ({ emptyOptionsText: emptyOptionsLabel ?? translations.emptyOptions, nullableOptionText: translations.nullableOption, }), [emptyOptionsLabel, translations] ); const renderEmptyStateDefault = useCallback( () => ( {emptyOptionsText} ), [emptyOptionsText] ); const onQueryChange = useCallback( (value: string) => { setSearchQuery(value || null); onQueryChangeProp?.(value); if (value === '') { onChangeProp?.(null); setLocalSelectedKey(null); } }, [onChangeProp, onQueryChangeProp, setSearchQuery] ); const popoverListboxWidthStyle = triggerWidth ? ({ '--trigger-width': `${triggerWidth}px` } as React.CSSProperties) : undefined; // The desktop picker's `Select.Listbox` is itself the popover overlay. const pickerListboxClassName = classNames( 'singleSelectField__popover', getDataListProps({ dense: true, className: 'singleSelectField__listbox' }).className ); // The desktop search listbox lives inside a `Popover.Overlay` that owns `singleSelectField__popover`. const searchListboxClassName = getDataListProps({ dense: true, className: 'singleSelectField__listbox', }).className; // The mobile dialog listbox is not dense and draws dividers between options. const dialogListboxClassName = getDataListProps({ className: 'singleSelectField__listbox' }).className; const renderSearchOptions = (divider: boolean): ReactNode => ( <> {searchVisibleOptions.map(option => renderCoreComboboxNode(option, { getGroupOptions: searchGetGroupOptions, getOptionValue, getOptionLabel, checkedIndicator, renderOption, disabledValues, screenSize, divider, }) )} {searchVisibleOptions.length === 0 && (renderEmptyState ? (renderEmptyState as () => ReactNode)() : renderEmptyStateDefault())} ); const renderVirtualizedSearchListbox = ( className: string, scrollSelector: string, divider: boolean, style?: React.CSSProperties ): ReactNode => { if (searchRows.length === 0) { return ( {renderEmptyState ? (renderEmptyState as () => ReactNode)() : renderEmptyStateDefault()} ); } return ( rows={searchRows} layout={virtualizerLayout} className={className} optionClassName={getDatalistItemProps({ divider }).className} style={style} scrollSelector={scrollSelector} activeRowIndex={activeRowIndex} selectedRowIndex={selectedRowIndex} renderOptionRow={(option, state) => renderComboboxOptionContent(option, getOptionLabel(option), state, { checkedIndicator, renderOption, screenSize, }) } /> ); }; const hiddenSelectOptions = (options ?? []) .flatMap(option => getGroupOptions?.(option) ?? [option]) .map(option => ({ value: getOptionValue(option), label: getOptionLabel(option) })); return ( <> {screenSize === 'large' ? ( mode === 'picker' ? ( name={name} value={selectedKey} onChange={onSelectionChange} open={isOpen} onOpenChange={setIsOpen} disabled={disabled} placeholder={placeholder} a11yLabel={label} >
{({ props: triggerDomProps }) => ( )}
{showNullableOption && ( {() => renderNullableOptionCells(checkedIndicator)} )} {(options ?? []).map(option => renderCorePickerNode(option, { getGroupOptions, getOptionValue, getOptionLabel, checkedIndicator, renderOption, disabledValues, screenSize, }) )} {(options ?? []).length === 0 && !showNullableOption && (renderEmptyState ? (renderEmptyState as () => ReactNode)() : renderEmptyStateDefault())} ) : (
value={selectedKey} onChange={onSelectionChange} query={searchQuery ?? selectedTextValue} onQueryChange={onQueryChange} open={isOpen} onOpenChange={setIsOpen} disabled={disabled} placeholder={placeholder} a11yLabel={label} {...(isVirtualized ? { navigableValues, onActiveChange } : {})} > {({ props: anchorProps }) => ( } > {({ props: inputProps }) => ( )} )} {isVirtualized ? ( renderVirtualizedSearchListbox(searchListboxClassName, '.singleSelectField__popover', false) ) : ( {renderSearchOptions(false)} )}
) ) : mode === 'picker' ? ( setIsOpen(true)} isDisabled={disabled} getOptionLabel={getOptionLabel} renderValue={renderValue} label={label} {...rest} /> } > overlay='none' name={name} value={selectedKey} onChange={onSelectionChange} open={isOpen} onOpenChange={setIsOpen} disabled={disabled} a11yLabel={label} > {showNullableOption && ( {() => renderNullableOptionCells(checkedIndicator)} )} {(options ?? []).map(option => renderCorePickerNode(option, { getGroupOptions, getOptionValue, getOptionLabel, checkedIndicator, renderOption, disabledValues, screenSize, divider: true, }) )} {(options ?? []).length === 0 && !showNullableOption && (renderEmptyState ? (renderEmptyState as () => ReactNode)() : renderEmptyStateDefault())} ) : ( // The Combobox root wraps the dialog so its hidden form input stays mounted while the // sheet is closed (the input/listbox subcomponents still render only inside the dialog). name={name} value={selectedKey} onChange={onSelectionChange} query={searchQuery ?? selectedTextValue} onQueryChange={onQueryChange} open={isOpen} onOpenChange={setIsOpen} disabled={disabled} a11yLabel={label} {...(isVirtualized ? { navigableValues, onActiveChange } : {})} > setIsOpen(true)} isDisabled={disabled} getOptionLabel={getOptionLabel} renderValue={renderValue} label={label} {...rest} /> } > {({ props: inputProps }) => ( )} {isVirtualized ? ( renderVirtualizedSearchListbox(dialogListboxClassName, '.inputDialog__body', true) ) : ( {renderSearchOptions(true)} )} )} ); }), { Option: SingleSelectOption, } ); function findOption( options: Array | undefined, key: string | number | null | undefined, getOptionValue: (option: T) => V, getGroupOptions?: (option: T) => T[] | undefined ): T | undefined { if (!options || key === null || key === undefined) { return; } for (const option of options) { if (getOptionValue(option) === key) { return option; } const children = getGroupOptions?.(option); if (children) { const found = findOption(children, key, getOptionValue, getGroupOptions); if (found) { return found; } } } } const indicatorCell = (
); const nullablePlaceholderCell = (
); /** The row content of the "no selection" option, shared by the picker and search paths. */ function renderNullableOptionCells( checkedIndicator: Exclude ): ReactNode { return ( <> {checkedIndicator === 'start' && nullablePlaceholderCell} - {checkedIndicator === 'end' && nullablePlaceholderCell} ); } /** * Measures the trigger button so the core listbox popover can match its width via `--trigger-width` * (react-aria-components sets this automatically; the hook-based core `Popover` does not). * A callback ref, so the observer (re)attaches whenever the trigger mounts — including when the * desktop branch appears only after a viewport resize. */ function useTriggerWidth(): [(element: HTMLElement | null) => void, number | undefined] { const [width, setWidth] = useState(undefined); const observerRef = React.useRef(null); const measureRef = useCallback((element: HTMLElement | null) => { observerRef.current?.disconnect(); observerRef.current = null; if (!element) { return; } const update = () => setWidth(element.offsetWidth); update(); observerRef.current = new ResizeObserver(update); observerRef.current.observe(element); }, []); return [measureRef, width]; } type CorePickerConfig = { getGroupOptions: ((option: T) => T[] | undefined) | undefined; getOptionValue: (option: T) => V; getOptionLabel: (option: T) => string; checkedIndicator: Exclude; renderOption: ((option: T, context: RenderOptionContext) => ReactNode) | undefined; disabledValues: V[] | undefined; screenSize: ScreenSize; /** Adds a divider between options (mobile dialog listbox). @default false */ divider?: boolean; }; /** * Adapts core `SelectOption` state to the `RenderOptionContext` (`ListBoxItemRenderProps`) shape that * `renderOption` consumers expect. `isPressed`/`selectionBehavior` are synthesized — this path drives * selection by value, not by react-aria's collection. */ function buildRenderOptionContext( state: SelectOptionState, defaultChildren: ReactNode, screenSize: ScreenSize ): RenderOptionContext { return { isSelected: state.selected, isDisabled: state.disabled, isHovered: state.hovered, isFocused: state.focused, isFocusVisible: state.focusVisible, isPressed: false, selectionMode: 'single', selectionBehavior: 'toggle', defaultChildren, screenSize, } as RenderOptionContext; } /** * Adapts core `ComboboxOption` state to `RenderOptionContext`. The combobox has no DOM focus on * options (virtual focus via `aria-activedescendant`), so `isFocused` maps to the option's `active` * (virtually-focused) flag; `isPressed`/`selectionBehavior` are synthesized. */ function buildComboboxRenderOptionContext( state: ComboboxOptionState, defaultChildren: ReactNode, screenSize: ScreenSize ): RenderOptionContext { return { isSelected: state.selected, isDisabled: state.disabled, isHovered: state.hovered, isFocused: state.active, isFocusVisible: state.focusVisible, isPressed: false, selectionMode: 'single', selectionBehavior: 'toggle', defaultChildren, screenSize, } as RenderOptionContext; } function renderCorePickerNode( option: T, config: CorePickerConfig ): ReactNode { const children = config.getGroupOptions?.(option); if (children) { const id = config.getOptionValue(option); return (
{/* Listboxes may not own static-text children, so the visible heading is presentational; the group is named via its aria-label (mirrors react-aria's useListBoxSection). */}
{config.getOptionLabel(option)}
{children.map(child => renderCorePickerOption(child, config))}
); } return renderCorePickerOption(option, config); } function renderCorePickerOption( option: T, config: CorePickerConfig ): ReactNode { const value = config.getOptionValue(option); if (typeof value !== 'string' && typeof value !== 'number') { throw new TypeError('Option key must be a string or number. Did you forget to pass getGroupOptions prop?'); } const label = config.getOptionLabel(option); const disabled = config.disabledValues?.includes(value) ?? false; const defaultChildren = ( {label} ); return ( {({ state }) => ( <> {config.checkedIndicator === 'start' && indicatorCell} {config.renderOption ? config.renderOption(option, buildRenderOptionContext(state, defaultChildren, config.screenSize)) : defaultChildren} {config.checkedIndicator === 'end' && indicatorCell} )} ); } function renderCoreComboboxNode( option: T, config: CorePickerConfig ): ReactNode { const children = config.getGroupOptions?.(option); if (children) { const id = config.getOptionValue(option); return (
{/* Listboxes may not own static-text children, so the visible heading is presentational; the group is named via its aria-label (mirrors react-aria's useListBoxSection). */}
{config.getOptionLabel(option)}
{children.map(child => renderCoreComboboxOption(child, config))}
); } return renderCoreComboboxOption(option, config); } function renderCoreComboboxOption( option: T, config: CorePickerConfig ): ReactNode { const value = config.getOptionValue(option); if (typeof value !== 'string' && typeof value !== 'number') { throw new TypeError('Option key must be a string or number. Did you forget to pass getGroupOptions prop?'); } const label = config.getOptionLabel(option); const disabled = config.disabledValues?.includes(value) ?? false; return ( {({ state }) => renderComboboxOptionContent(option, label, state, config)} ); } /** Inner cells of a combobox option (checked indicator + `renderOption`); shared by both listboxes. */ function renderComboboxOptionContent( option: T, label: string, state: ComboboxOptionState, config: Pick, 'checkedIndicator' | 'renderOption' | 'screenSize'> ): ReactNode { const defaultChildren = ( {label} ); return ( <> {config.checkedIndicator === 'start' && indicatorCell} {config.renderOption ? config.renderOption(option, buildComboboxRenderOptionContext(state, defaultChildren, config.screenSize)) : defaultChildren} {config.checkedIndicator === 'end' && indicatorCell} ); } /** Case- and diacritic-insensitive form of a label or query for the uncontrolled search filter. */ function normalizeForSearch(text: string): string { return text .normalize('NFD') .replaceAll(/\p{Diacritic}+/gu, '') .toLocaleLowerCase(); } /** * Flattens the (already-filtered) options into virtualizer rows: a group emits a heading row followed * by its option rows; a plain option emits one option row. Mirrors the group handling of * `renderCoreComboboxNode`. */ function flattenSearchRows( options: Array, config: Pick, 'getGroupOptions' | 'getOptionValue' | 'getOptionLabel' | 'disabledValues'> ): Array> { const rows: Array> = []; const pushOption = (option: T) => { const value = config.getOptionValue(option); if (typeof value !== 'string' && typeof value !== 'number') { throw new TypeError('Option key must be a string or number. Did you forget to pass getGroupOptions prop?'); } rows.push({ kind: 'option', key: String(value), option, label: config.getOptionLabel(option), value, disabled: config.disabledValues?.includes(value) ?? false, }); }; for (const option of options) { const children = config.getGroupOptions?.(option); if (children) { rows.push({ kind: 'heading', key: `heading-${String(config.getOptionValue(option))}`, label: config.getOptionLabel(option), }); children.forEach(element => pushOption(element)); } else { pushOption(option); } } return rows; }