/** * Combobox — a text field that filters a list of options as you type. * * The difference from Select is where the typing happens, and it is not a * detail: a Select is a button that opens a list, and its optional filter lives * *inside* the list once it is open. A Combobox is the field itself. You are * already typing when the options appear, which is what you want when the value * is something you know the name of — a city, a repository, a tag — rather than * something you expect to recognise by scrolling. * * ```tsx * * * * * ``` * * ## Two presentations, and why there is no sheet * * `overlay` (default) floats the list above the page through a portal, anchored * under the field and flipped above it when the keyboard leaves no room below. * `inline` expands the list in normal layout flow instead, which is right in a * form where nothing should be covered. * * There is deliberately no sheet presentation. A sheet takes the bottom of the * screen, which is exactly where the keyboard is, and the field you are typing * into would end up behind one or the other. Select can offer a sheet because * its trigger stops mattering once the list is open; a Combobox's never does. * * ## Filtering is yours to turn off * * Filtering happens here by default, matching case-insensitively on any part of * an option's label. That is the whole feature for a list you already have in * hand. When the options come from a server that is doing the matching itself, * pass `filter={false}` and render whatever came back — the field stops second- * guessing results it cannot see the query behind. * * ## Values it does not know about * * `allowCustomValue` lets the typed text become the value when it matches no * option, which is how a tag field works: the list is a set of suggestions * rather than the set of legal answers. */ import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from 'react'; import { Pressable, ScrollView, TextInput, useWindowDimensions, View, type LayoutChangeEvent, type ViewProps, type ViewStyle, } from 'react-native'; import Animated, { FadeIn, FadeOut, interpolateColor, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import { tv } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { CheckIcon, ChevronDownIcon, XIcon } from '../../icons'; import { Portal } from '../../primitives/portal'; import { Text, textChildren } from '../../primitives/text'; import { useBackHandler } from '../../hooks/use-back-handler'; import { useKeyboard } from '../../hooks/use-keyboard'; import { cn } from '../../utils/cn'; import { Chip } from '../chip'; import { Spinner } from '../spinner'; /** Matches Input's focus crossfade, so the two read as the same control. */ const FOCUS_DURATION = 150; const comboboxVariants = tv({ slots: { root: 'w-full', /* * `rounded-lg` and the same padding scale as Select's trigger and Input's * field: a Combobox sitting in a form beside either of them has to read as * the same family of control, not as a text field that happens to be near * a picker. * * The border colour is animated between the resting and focused tokens, so * it is deliberately absent from the class. */ field: 'w-full flex-row items-center gap-2 rounded-lg border bg-background px-4 py-2.5', // Chips wrap onto their own lines; the input keeps a sane minimum so it is // still tappable once a few of them are in front of it. fieldContent: 'flex-1 flex-row flex-wrap items-center gap-1.5 py-1', /* * A fixed height rather than vertical padding, because the chips beside it * have one (`h-6`) and two differently-sized boxes on a `items-center` row * centre to two different baselines. `py-0` clears the platform default, * which is not the same on iOS and Android. * * The size is a length rather than a `text-*` step for the reason Input * gives: a step sets a size *and* a line height, and the extra leading * lands above the glyphs, so inside a box of fixed height the text and the * placeholder sit below its middle — a few pixels under the chips they are * supposed to be level with. A length leaves the line box the font's own. */ input: 'h-7 min-w-24 flex-1 py-0 text-[16px] font-normal text-foreground', action: 'h-6 w-6 items-center justify-center rounded-full', list: 'overflow-hidden rounded-xl border border-border bg-popover p-2 shadow-sm', item: 'flex-row items-center gap-2 rounded-lg px-3 py-3', itemLabel: 'flex-1 text-base font-medium text-foreground', itemIndicator: 'h-5 w-5 items-center justify-center', group: 'gap-1', groupLabel: 'px-3 pb-1 pt-2', status: 'flex-row items-center justify-center gap-2 px-3 py-6', }, variants: { selected: { true: { item: 'bg-accent' }, }, disabled: { true: { field: 'opacity-[0.64]' }, }, itemDisabled: { true: { item: 'opacity-[0.64]' }, }, presentation: { overlay: { list: 'shadow-lg' }, inline: { list: 'mt-2' }, }, }, defaultVariants: { presentation: 'overlay', }, }); export type ComboboxPresentation = 'overlay' | 'inline'; /** Which selection shape a `mode` produces. */ export type ComboboxMode = 'single' | 'multiple'; export interface ComboboxSelection { single: string | undefined; multiple: string[]; } interface ComboboxContextValue { values: string[]; onSelect: (value: string, label: string) => void; } const ComboboxContext = createContext(null); export interface ComboboxItemProps { value: string; label: string; /** * Shows the option but refuses it. Kept in the list rather than dropped from * it, because an option that vanishes reads as one that never existed. */ disabled?: boolean; /** Anything to draw before the label — an avatar, a flag, a status dot. */ start?: ReactNode; /** A second line under the label, for what the label alone cannot say. */ description?: string; } /** Declarative option. Rendered inside whichever surface is presenting. */ function ComboboxItem({ value, label, disabled, start, description, }: ComboboxItemProps) { const context = useContext(ComboboxContext); if (!context) { throw new Error('Combobox.Item must be used within a '); } const selected = context.values.includes(value); const { item, itemLabel, itemIndicator } = comboboxVariants({ selected, itemDisabled: !!disabled, }); const checkColor = useCSSVariable('--color-muted-foreground'); return ( context.onSelect(value, label)} // The rows sit flush against each other, so the 4pt either side of the // gap between two of them belongs to neither without this. hitSlop={{ top: 2, bottom: 2 }} className={item()} > {start} {label} {description ? ( {description} ) : null} {selected ? ( ) : null} ); } export interface ComboboxGroupProps { /** * Heading over the run of options. Announced as a header, so a screen reader * reaching the group is told what it is before walking into it. */ label?: string; /** Extra classes for the group wrapper. */ className?: string; /** Extra classes for the heading. */ labelClassName?: string; children: ReactNode; } /** * A titled run of options. * * Presentational only: a grouped Combobox reports the same values a flat one * would, and `Combobox.Item` needs to know nothing about being inside one. */ function ComboboxGroup({ label, className, labelClassName, children, }: ComboboxGroupProps) { const { group, groupLabel } = comboboxVariants(); return ( {label ? ( {label} ) : null} {textChildren(children)} ); } /** * Walk the declared children, visiting every option — including the ones nested * inside a `Combobox.Group`. * * The flat set is what the field's own text needs: the label to show for a * selected value, and the chips to draw for several of them. Rendering keeps * the tree; only the lookup is flattened. */ function eachOption(children: ReactNode, visit: (option: ComboboxItemProps) => void) { Children.forEach(children, (child) => { if (!isValidElement(child)) return; if (child.type === ComboboxGroup) { eachOption((child.props as ComboboxGroupProps).children, visit); return; } if (child.type !== ComboboxItem) return; visit(child.props as ComboboxItemProps); }); } /** * The children a query leaves standing. * * A group is rebuilt around whatever survives inside it and dropped when that * is nothing — a heading over no options reads as a section that failed to load * rather than one the query emptied. * * Every kept node is given a key on the way out. This walk builds a plain array * rather than going through `Children.map`, which is the one that hands out keys * of its own, so options written as literal JSX — the ordinary way to write a * short, fixed list — arrive here with none. An option's `value` is already * unique within a list, and a group is named by its label, so both key * themselves; an explicit key on the element still wins. */ function filterOptions( children: ReactNode, matches: (option: ComboboxItemProps) => boolean ): ReactNode[] { const kept: ReactNode[] = []; Children.forEach(children, (child, index) => { if (!isValidElement(child)) return; if (child.type === ComboboxGroup) { const props = child.props as ComboboxGroupProps; const inner = filterOptions(props.children, matches); if (inner.length) { kept.push( cloneElement( child as ReactElement, { key: child.key ?? `group:${props.label ?? index}` }, inner ) ); } return; } if (child.type === ComboboxItem && matches(child.props as ComboboxItemProps)) { const props = child.props as ComboboxItemProps; kept.push( cloneElement(child as ReactElement, { key: child.key ?? `option:${props.value}`, }) ); } }); return kept; } /** Field frame in window coordinates, measured when the list opens. */ interface Anchor { x: number; y: number; width: number; height: number; } export interface ComboboxProps extends Omit { className?: string; /** * One value or several. `multiple` draws the chosen options as removable * chips in front of the input and keeps the list open between picks. */ mode?: Mode; /** Controlled selection. Its shape follows `mode`. */ value?: ComboboxSelection[Mode]; /** Starting selection when uncontrolled. */ defaultValue?: ComboboxSelection[Mode]; onValueChange?: (value: ComboboxSelection[Mode]) => void; /** * Controlled query — the text actually in the field. Pair it with * `onInputValueChange` when the options are fetched for it. */ inputValue?: string; /** Starting query when uncontrolled. */ defaultInputValue?: string; onInputValueChange?: (value: string) => void; placeholder?: string; disabled?: boolean; /** Where the options appear. */ presentation?: ComboboxPresentation; /** * Narrow the options to the query here. `true` matches case-insensitively on * any part of an option's label; pass a function to match on something else — * a description, an alias list, an initialism. * * Pass `false` when a server is doing the matching: the options you render * are then shown exactly as given, since a second filter over results the * field cannot see the query behind would only remove correct answers. */ filter?: boolean | ((option: ComboboxItemProps, query: string) => boolean); /** * Let the typed text become the value when it matches no option, committed on * submit. Turns the list into a set of suggestions rather than the set of * legal answers — which is what a tag field is. */ allowCustomValue?: boolean; /** Show a spinner in place of the list. For options still being fetched. */ loading?: boolean; /** Shown in place of the list when nothing matches. */ emptyMessage?: string; /** Shown in place of the list while `loading`. */ loadingMessage?: string; /** Offer a ✕ that clears the query and the selection. */ clearable?: boolean; /** Open the list as soon as the field takes focus, before anything is typed. */ openOnFocus?: boolean; /** Called when the list opens or closes. */ onOpenChange?: (open: boolean) => void; /** * Width of the floating list. `field` matches the field, `content` sizes to * the longest option, or pass a pixel value. `overlay` only. */ contentWidth?: 'field' | 'content' | number; /** Gap between the field and the floating list. `overlay` only. */ offset?: number; /** Extra classes for the list surface. */ listClassName?: string; /** Accessible name for the field. */ accessibilityLabel?: string; children: ReactNode; } function ComboboxRoot({ className, mode, value, defaultValue, onValueChange, inputValue, defaultInputValue = '', onInputValueChange, placeholder = 'Search', disabled = false, presentation = 'overlay', filter = true, allowCustomValue = false, loading = false, emptyMessage = 'No matches', loadingMessage = 'Searching', clearable = false, openOnFocus = false, onOpenChange, contentWidth = 'field', offset = 8, listClassName, accessibilityLabel, children, ...props }: ComboboxProps) { const multiple = mode === 'multiple'; const [open, setOpen] = useState(false); const [focused, setFocused] = useState(false); const [anchor, setAnchor] = useState(null); /* * Which side the list settled on, latched for as long as it stays open. * * The side is decided from the list's measured height, which is 0 on the * frame it opens — so the honest answer changes once, from "below" to * whatever fits, and the list jumps. A finger already on its way down to an * option lands where the option used to be, which is the whole of "sometimes * it takes two taps". Deciding once and holding it costs a list that opens * downwards for one frame in a cramped viewport, which the fade covers. */ const [flipped, setFlipped] = useState(null); /* * The anchor is measured off the plain wrapper rather than off the animated * field inside it. In `overlay` the wrapper *is* the field's box — the list * is portalled out — and a host View is the thing with a dependable * `measureInWindow`. `inline` never reads the anchor, so the list it also * wraps cannot skew anything. */ const fieldRef = useRef(null); const inputRef = useRef(null); const { height: screenHeight } = useWindowDimensions(); /* * The keyboard is up whenever this list is open — the field is a text input * and opening the list is what typing in it does. So the space the list has * to work with is never the window: it is the window above the keyboard, and * measuring against the window would put the options behind it. */ const { height: keyboardHeight } = useKeyboard(); const [internalValue, setInternalValue] = useState( () => (defaultValue ?? (mode === 'multiple' ? [] : undefined)) as ComboboxSelection[Mode] ); const selection = (value !== undefined ? value : internalValue) as | string | string[] | undefined; const [internalQuery, setInternalQuery] = useState(defaultInputValue); const query = inputValue !== undefined ? inputValue : internalQuery; /* * Which chip a second backspace would take, in `multiple` mode. A held * backspace repeats, and a field that removed on the first one would empty * itself in the time it takes to notice — the mark is the beat that lets you * stop. It is an index rather than a value because the same label can appear * twice once the caller allows a custom value that matches an option. */ const [marked, setMarked] = useState(null); /** The selection as a list, which is the shape everything downstream wants. */ const values = useMemo(() => { if (selection == null) return []; return Array.isArray(selection) ? selection : [selection]; }, [selection]); const options = useMemo(() => { const collected: ComboboxItemProps[] = []; eachOption(children, (option) => collected.push(option)); return collected; }, [children]); const labelOf = useCallback( (candidate: string) => options.find((option) => option.value === candidate)?.label ?? candidate, [options] ); const setQuery = useCallback( (next: string) => { if (inputValue === undefined) setInternalQuery(next); onInputValueChange?.(next); }, [inputValue, onInputValueChange] ); const commit = useCallback( (next: ComboboxSelection[Mode]) => { if (value === undefined) setInternalValue(next); onValueChange?.(next); }, [value, onValueChange] ); const setOpenState = useCallback( (next: boolean) => { setOpen((current) => { if (current === next) return current; onOpenChange?.(next); return next; }); }, [onOpenChange] ); /** * The floating list is positioned in window coordinates, so it has to know * where the field actually landed — not where layout said it would. */ const openList = useCallback(() => { if (disabled) return; if (presentation !== 'overlay') { setOpenState(true); return; } fieldRef.current?.measureInWindow((x, y, width, height) => { setAnchor({ x, y, width, height }); setOpenState(true); }); }, [disabled, presentation, setOpenState]); const close = useCallback(() => setOpenState(false), [setOpenState]); // A closed list has no side. Clearing the latch here rather than in `close` // covers the caller closing it through `open` as well. useEffect(() => { if (!open) setFlipped(null); }, [open]); // An open overlay list catches the Android back button, closing itself // instead of popping the screen behind it. useBackHandler(open && presentation === 'overlay', close); /* * The anchor is a snapshot, and the keyboard invalidates it: a scroll view * that lifts its content clear of the keyboard moves the field after it was * measured, and the list would stay at the old position. Re-measure whenever * the keyboard's height changes while the list is open. * * Except while the list is being dragged. The list dismisses the keyboard on * drag, so the first finger movement changes the keyboard's height, which * lands a new anchor, which recomputes the panel's `top` *and* its * `maxHeight` — the panel resizes and moves under the finger that is * scrolling it, which is the stutter. The measurement is only stale once the * keyboard has finished leaving, so it is deferred to the end of the drag, * which is also the first moment it can be taken correctly. */ const draggingList = useRef(false); const anchorStale = useRef(false); const remeasure = useCallback(() => { fieldRef.current?.measureInWindow((x, y, width, height) => setAnchor({ x, y, width, height }) ); }, []); useEffect(() => { if (!open || presentation !== 'overlay') return; if (draggingList.current) { anchorStale.current = true; return; } remeasure(); }, [open, presentation, keyboardHeight, remeasure]); const settleTimer = useRef | null>(null); const cancelSettle = useCallback(() => { if (settleTimer.current === null) return; clearTimeout(settleTimer.current); settleTimer.current = null; }, []); const onListDragStart = useCallback(() => { cancelSettle(); draggingList.current = true; }, [cancelSettle]); const onListSettled = useCallback(() => { cancelSettle(); draggingList.current = false; if (!anchorStale.current) return; anchorStale.current = false; remeasure(); }, [cancelSettle, remeasure]); /* * A fling ends the drag and then keeps moving, and momentum begins a frame * *after* the drag ends — so the end of the drag cannot tell the two apart by * itself. It arms a short timer instead, which momentum starting cancels; a * drag that stops dead has no momentum to cancel it and settles on the timer. */ const onListDragEnd = useCallback(() => { cancelSettle(); settleTimer.current = setTimeout(onListSettled, 80); }, [cancelSettle, onListSettled]); useEffect(() => cancelSettle, [cancelSettle]); /* * A single-select field shows the chosen option's label when it is not being * typed into. Re-deriving it on every keystroke would fight the typing, so it * is only written back when the selection itself changes and the field is not * focused — which covers a value arriving from outside, and the blur after a * pick. A custom-value field is left alone: the text *is* the value there. */ const singleValue = multiple ? undefined : (selection as string | undefined); useEffect(() => { if (multiple || focused || allowCustomValue) return; setQuery(singleValue == null ? '' : labelOf(singleValue)); // `setQuery` is stable per controlled-ness; re-running on every identity // change would overwrite the query the caller is controlling. // eslint-disable-next-line react-hooks/exhaustive-deps }, [multiple, focused, allowCustomValue, singleValue, labelOf]); const matcher = useCallback( (option: ComboboxItemProps) => { if (filter === false) return true; const needle = query.trim().toLowerCase(); if (!needle) return true; if (typeof filter === 'function') return filter(option, query.trim()); return option.label.toLowerCase().includes(needle); }, [filter, query] ); /* * `null` means "render the children as given" — nothing is being narrowed, so * an unfiltered list does no per-option work at all. */ const filtered = useMemo(() => { if (filter === false) return null; if (!query.trim()) return null; return filterOptions(children, matcher); }, [children, filter, query, matcher]); const exactMatch = useMemo( () => options.some( (option) => option.label.toLowerCase() === query.trim().toLowerCase() ), [options, query] ); const select = useCallback( (next: string) => { if (multiple) { const current = Array.isArray(selection) ? selection : []; const without = current.filter((entry) => entry !== next); // Toggling: picking a chosen option again removes it, which is the only // way to undo a pick without reaching for its chip. const updated = without.length === current.length ? [...current, next] : without; commit(updated as ComboboxSelection[Mode]); // The query has done its job once the pick is made, and leaving it // would hide every option that does not also match it. setQuery(''); return; } commit(next as ComboboxSelection[Mode]); setQuery(labelOf(next)); close(); inputRef.current?.blur(); }, [multiple, selection, commit, setQuery, labelOf, close] ); /** Enter, or the keyboard's Done: take the typed text if it can be taken. */ const submit = useCallback(() => { const typed = query.trim(); if (!typed) return; const match = options.find( (option) => option.label.toLowerCase() === typed.toLowerCase() ); if (match && !match.disabled) { select(match.value); return; } if (!allowCustomValue) return; if (multiple) { const current = Array.isArray(selection) ? selection : []; if (!current.includes(typed)) { commit([...current, typed] as ComboboxSelection[Mode]); } setQuery(''); return; } commit(typed as ComboboxSelection[Mode]); close(); }, [ query, options, allowCustomValue, multiple, selection, select, commit, setQuery, close, ]); const remove = useCallback( (target: string) => { const current = Array.isArray(selection) ? selection : []; commit(current.filter((entry) => entry !== target) as ComboboxSelection[Mode]); }, [selection, commit] ); /** * Removal by position, which is what the backspace mark holds. Removing by * value would take both of a repeated label rather than the marked one. */ const removeAt = useCallback( (index: number) => { const current = Array.isArray(selection) ? selection : []; if (index < 0 || index >= current.length) return; commit( current.filter((_, position) => position !== index) as ComboboxSelection[Mode] ); }, [selection, commit] ); const clear = useCallback(() => { setQuery(''); setMarked(null); commit((multiple ? [] : undefined) as ComboboxSelection[Mode]); inputRef.current?.focus(); }, [setQuery, commit, multiple]); /* * The chips can also change from outside — a form reset, a pick undone in the * list — which would leave the mark pointing past the end of them. A stale * mark is a chip deleted by a backspace meant for the one that used to be * there. */ useEffect(() => { if (marked !== null && marked >= values.length) setMarked(null); }, [marked, values.length]); const context = useMemo( () => ({ values, onSelect: (next) => select(next) }), [values, select] ); const slots = comboboxVariants({ disabled, presentation }); const mutedColor = useCSSVariable('--color-muted-foreground'); const restColor = useCSSVariable('--color-input'); const focusColor = useCSSVariable('--color-ring'); const placeholderColor = typeof mutedColor === 'string' ? mutedColor : '#737373'; const focus = useSharedValue(0); useEffect(() => { focus.value = withTiming(focused ? 1 : 0, { duration: FOCUS_DURATION }); }, [focused, focus]); const fieldStyle = useAnimatedStyle(() => { const idle = typeof restColor === 'string' ? restColor : 'rgba(0,0,0,0.1)'; const active = typeof focusColor === 'string' ? focusColor : '#a3a3a3'; return { borderColor: interpolateColor(focus.value, [0, 1], [idle, active]), }; }); const chevron = useSharedValue(0); useEffect(() => { chevron.value = withTiming(open ? 1 : 0, { duration: 160 }); }, [open, chevron]); const chevronStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${chevron.value * 180}deg` }], })); const hasContent = query.length > 0 || values.length > 0; /* * The list body, built once and handed to whichever surface is presenting. * The two differ in where they put it, not in what it is. */ /* * `filtered === null` means nothing was narrowed — either there is no query * or a server is doing the matching — so the children are rendered as given * and emptiness is a question about the options themselves. A server that * came back with nothing still has to say so, which is why this is not just * `filtered.length`. */ const shown = filtered === null ? textChildren(children) : filtered; const isEmpty = filtered === null ? options.length === 0 : filtered.length === 0; const body = loading ? ( {loadingMessage} ) : isEmpty ? ( {allowCustomValue && query.trim() && !exactMatch ? `Press return to add “${query.trim()}”` : emptyMessage} ) : ( shown ); const list = ( {body} ); const field = ( {multiple ? values.map((entry, index) => ( remove(entry)} closeLabel={`Remove ${labelOf(entry)}`} > {labelOf(entry)} )) : null} { // Typing takes the mark off: the backspace that would have removed // a chip has been overtaken by a new query. setMarked(null); setQuery(next); if (!open) openList(); }} onFocus={() => { setFocused(true); if (openOnFocus) openList(); }} onBlur={() => { setFocused(false); setMarked(null); }} onSubmitEditing={submit} onKeyPress={({ nativeEvent }) => { if (!multiple) return; if (nativeEvent.key !== 'Backspace') { setMarked(null); return; } // There is still a character in front of the cursor: backspace // means what it always means, and the chips are none of its // business. if (query.length > 0 || values.length === 0) return; // Backspace on an empty field reaches the thing in front of the // cursor when there is no character left to delete — but it marks // that chip first, and only the next one takes it. if (marked !== null && marked < values.length) { removeAt(marked); setMarked(null); return; } setMarked(values.length - 1); }} editable={!disabled} // Android lays a single-line input's text against the top of its box // unless told otherwise; iOS centres it. Without this the text sits // above the chips on one platform and level with them on the other. textAlignVertical="center" placeholder={values.length && multiple ? undefined : placeholder} placeholderTextColor={placeholderColor} autoCapitalize="none" autoCorrect={false} autoComplete="off" returnKeyType={allowCustomValue ? 'done' : 'search'} submitBehavior={multiple ? 'submit' : 'blurAndSubmit'} accessibilityLabel={accessibilityLabel ?? placeholder} /> {clearable && hasContent && !disabled ? ( ) : null} { if (open) { close(); return; } openList(); inputRef.current?.focus(); }} className={slots.action()} > ); if (presentation === 'inline') { return ( {field} {open ? ( {list} ) : null} ); } // Flip above the field when the list would run off the bottom — where the // bottom is the top of the keyboard, not the bottom of the screen. The list // has no height until it has been laid out, so the side is decided on that // first measurement and then held; see `flipped`. const viewportBottom = screenHeight - keyboardHeight; const spaceBelow = anchor ? viewportBottom - (anchor.y + anchor.height) - offset : 0; const flip = flipped ?? false; const overlayPosition = anchor ? { position: 'absolute' as const, left: anchor.x, ...(flip ? { bottom: screenHeight - anchor.y + offset } : { top: anchor.y + anchor.height + offset }), // Above the dismiss strips it shares the portal with. They are earlier // siblings so paint order already puts the list on top on iOS, but on // Android a sibling without an elevation is not reliably ordered. zIndex: 1, elevation: 1, ...(contentWidth === 'field' ? { width: anchor.width } : typeof contentWidth === 'number' ? { width: contentWidth } : { minWidth: anchor.width }), // Never collapse to nothing in a cramped viewport — the list scrolls. maxHeight: Math.max((flip ? anchor.y : spaceBelow) - offset, 160), } : null; /** The window minus the field: above it, below it, and either side of it. */ const catcherRects: ViewStyle[] = anchor ? [ { top: 0, left: 0, right: 0, height: Math.max(anchor.y, 0) }, { top: anchor.y + anchor.height, left: 0, right: 0, bottom: 0 }, { top: anchor.y, height: anchor.height, left: 0, width: Math.max(anchor.x, 0) }, { top: anchor.y, height: anchor.height, left: anchor.x + anchor.width, right: 0, }, ] : []; return ( {field} {open && overlayPosition ? ( {/* * Full-screen catcher so a press anywhere else dismisses the list. * * Hidden from assistive tech, and deliberately: it is a dismiss * affordance for a pointer, and announcing it would put an unlabelled * full-screen "button" ahead of the options in the reading order. * Escaping the list is the back gesture's job. * * "Anywhere else" has to exclude the field, and one window-sized view * cannot: covering the control that opened the list means a tap on * the input to carry on typing, or on the chevron to close it, is * spent dismissing instead and the field only answers on the second * try. A view cannot have a hole cut in it, so the catcher is the * four strips around the field's measured rect — which leaves the * field uncovered and directly tappable. */} {catcherRects.map((rect, index) => ( { close(); inputRef.current?.blur(); }} style={[{ position: 'absolute' }, rect]} /> ))} {/* Portalled out of this subtree — re-provide the context so Combobox.Item keeps working. */} { // The first measurement decides the side, and nothing after it // does — see `flipped`. const measured = event.nativeEvent.layout.height; setFlipped((current) => current === null ? measured > spaceBelow : current ); }} style={overlayPosition} /* * The floating list covers the screen with a catcher and takes * the back button, so it is a modal layer. Without this the page * behind it stays in the accessibility tree and a screen reader * could walk out of the open list into content it is covering. */ accessibilityViewIsModal className={cn(slots.list(), listClassName)} > {list} ) : null} ); } ComboboxItem.displayName = 'Combobox.Item'; ComboboxGroup.displayName = 'Combobox.Group'; export const Combobox = Object.assign(ComboboxRoot, { Item: ComboboxItem, Group: ComboboxGroup, });