/** * Select — a picker with one trigger and three ways of showing its options. * * Which one is right depends on what surrounds the trigger, not on what the * options are, which is why it is a prop rather than three components: * * - `sheet` (default) takes the bottom of the screen. Best for a long list, or * on a small screen where an anchored panel would cover the thing you are * choosing for. * - `inline` expands the list in normal layout flow. Everything below moves * down. Right inside a settings list, where that reads as the row growing; * wrong anywhere the shift is jarring. * - `overlay` floats the list above the page through a portal, anchored to the * trigger and flipped above it when there is no room below. Nothing else on * the screen moves. * * ```tsx * * ``` * * Past a couple of dozen options, scrolling stops being a way of finding * anything: pass `searchable` and the list gets a filter above it, matching on * the option labels. The filter narrows what is *shown* — the declared options * are still the source of truth, so nothing has to be lifted into state to make * it work. * * A list long enough to need a filter is usually long enough to want dividing, * so options can be wrapped in `Select.Group` under a heading. Grouping is * presentational — the value is still a flat string — and the filter reaches * through it, dropping any group the query empties rather than leaving a * heading standing over nothing. */ import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useMemo, useRef, useState, type ReactElement, type ReactNode, } from 'react'; import { Pressable, ScrollView, useWindowDimensions, View, type LayoutChangeEvent, } from 'react-native'; import Animated, { FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import { tv } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { CheckIcon, ChevronDownIcon, SearchIcon } from '../../icons'; import { NativeHost, getNativeUI } from '../../native'; import { Portal } from '../../primitives/portal'; import { Text, textChildren } from '../../primitives/text'; import { useBackHandler } from '../../hooks/use-back-handler'; import { cn } from '../../utils/cn'; import { BottomSheet } from '../bottom-sheet'; import { InputGroup } from '../input-group'; import { nativeSelectSupportsOptions } from './native-select-contract'; const selectVariants = tv({ slots: { // `rounded-lg`, the same radius Button and Input use — a select sitting in // a form beside either of them should read as the same family of control. trigger: 'w-full flex-row items-center justify-between gap-3 rounded-lg border border-input bg-background px-4 py-3.5', triggerLabel: 'flex-1 text-base font-medium text-foreground', placeholder: 'flex-1 text-base text-muted-foreground', list: 'overflow-hidden rounded-xl border border-border bg-popover p-2 shadow-sm', // The block of options inside a sheet. The sheet is the surface there, so // this is what `listClassName` has to reach instead of `list`. options: 'gap-1 pb-2', search: 'pb-2', empty: 'px-3 py-6 text-center text-sm text-muted-foreground', 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', }, variants: { selected: { true: { item: 'bg-accent' }, }, disabled: { true: { trigger: 'opacity-[0.64]' }, }, itemDisabled: { true: { item: 'opacity-[0.64]' }, }, presentation: { sheet: {}, inline: { list: 'mt-2' }, overlay: { list: 'shadow-lg' }, }, }, defaultVariants: { presentation: 'sheet', }, }); export type SelectPresentation = 'sheet' | 'inline' | 'overlay'; interface SelectContextValue { value: string | undefined; onSelect: (value: string) => void; query: string; setQuery: (query: string) => void; } const SelectContext = createContext(null); /** * The filter field's text, from inside an open Select. * * Select can only filter the options it renders itself. A caller who hands it * a virtualized list is rendering their own rows, from their own data, and * this is how they get the query to filter that data with — `Select.Item` * still works wherever those rows put it, because selection travels by * context rather than by position. * * ```tsx * function Options() { * const { query } = useSelectSearch(); * const rows = useMemo(() => filter(timezones, query), [query]); * return ( * } * /> * ); * } * ``` * * `setQuery` is there for a caller who wants to clear or seed the field. */ export function useSelectSearch(): { query: string; setQuery: (query: string) => void } { const context = useContext(SelectContext); if (!context) { throw new Error('useSelectSearch must be used within a '); } const selected = context.value === value; const { item, itemLabel, itemIndicator } = selectVariants({ selected, itemDisabled: !!disabled, }); const checkColor = useCSSVariable('--color-muted-foreground'); return ( context.onSelect(value)} className={item({ className })} > {label} {selected ? ( ) : null} ); } export interface SelectGroupProps { /** * 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. * * Purely a way of arranging the list: a grouped Select still reports one flat * string, and `Select.Item` needs to know nothing about being inside one. */ function SelectGroup({ label, className, labelClassName, children }: SelectGroupProps) { const { group, groupLabel } = selectVariants(); return ( {label ? ( {label} ) : null} {textChildren(children)} ); } /** * Walk the declared children, visiting every `Select.Item` — including the ones * nested inside a `Select.Group`. * * Grouping is a rendering concern, but the selected label and the native * picker's option list both want the flat set, so the tree is flattened once * here rather than in each of them. */ function eachOption(children: ReactNode, visit: (option: SelectItemProps) => void) { Children.forEach(children, (child) => { if (!isValidElement(child)) return; if (child.type === SelectGroup) { eachOption((child.props as SelectGroupProps).children, visit); return; } if (child.type !== SelectItem) return; const { value, label, disabled } = child.props as SelectItemProps; visit({ value, label, disabled }); }); } /** What a pass of the filter left, and how much there was to filter. */ interface FilterResult { /** The children to render. */ kept: ReactNode[]; /** How many `Select.Item`s the pass saw, at any depth it can reach. */ seen: number; } /** * The children a query leaves standing. * * A group is rebuilt around whatever survives inside it and dropped when that * is nothing — a heading with no options under it reads as a section that * failed to load rather than one the filter emptied. * * Anything that is neither an item nor a group is left alone: a caption or a * divider the caller put in the list is not something a filter has an opinion * about, and neither is a list component rendering its own rows. Dropping * those was how a `Select.Item` inside a virtualized list disappeared the * moment anybody typed — the list was not an item, so nothing kept it. * * `seen` is what tells an empty result from an unfilterable one. Zero items * seen means the caller is rendering their own rows and filtering them * themselves through `useSelectSearch`, so there is nothing to call empty. */ function filterOptions(children: ReactNode, needle: string): FilterResult { const kept: ReactNode[] = []; let seen = 0; Children.forEach(children, (child) => { if (!isValidElement(child)) { if (child !== null && child !== undefined && child !== false) kept.push(child); return; } if (child.type === SelectGroup) { const props = child.props as SelectGroupProps; const inner = filterOptions(props.children, needle); seen += inner.seen; if (inner.kept.length) { kept.push(cloneElement(child as ReactElement, {}, inner.kept)); } return; } if (child.type === SelectItem) { seen += 1; const { label } = child.props as SelectItemProps; if (label.toLowerCase().includes(needle)) kept.push(child); return; } kept.push(child); }); return { kept, seen }; } /** Trigger frame in window coordinates, measured when the list opens. */ interface Anchor { x: number; y: number; width: number; height: number; } export interface SelectProps { /** * Extra classes for the wrapper around the trigger — the box the select * occupies in your layout, which is where margins and widths belong. To * restyle the field itself, use `triggerClassName`. */ className?: string; /** The selected option's `value`. Leave unset for the placeholder. */ value?: string; /** * What the trigger shows for the current `value`. * * Select reads the label off its `Select.Item` children, which it cannot do * when a list component renders those rows — the elements do not exist until * the list decides to draw them, and the selected one may be scrolled far * out of view. Pass the label yourself in that case; otherwise leave it * unset and the trigger will find it. */ valueLabel?: string; /** Called with the `value` of the option that was picked. */ onValueChange: (value: string) => void; /** Shown on the trigger while nothing is selected. */ placeholder?: string; /** Refuses the trigger and dims it. The options cannot be opened. */ disabled?: boolean; /** Extra classes for the trigger — the field you press to open the list. */ triggerClassName?: string; /** Extra classes for the selected option's text on the trigger. */ valueClassName?: string; /** Extra classes for the placeholder text on the trigger. */ placeholderClassName?: string; /** * Extra classes for the surface the options sit on. In `sheet` the sheet is * that surface, so this reaches the block of options inside it instead. */ listClassName?: string; /** Extra classes for the row the filter field sits in. `searchable` only. */ searchClassName?: string; /** Extra classes for the filter field itself. `searchable` only. */ searchInputClassName?: string; /** * Extra classes for the box drawn around the filter field — its fill, border * and radius. `searchable` only. */ searchContainerClassName?: string; /** Extra classes for the message shown when the filter matches nothing. */ emptyClassName?: string; /** * Where the options appear. `sheet` takes the bottom of the screen, `inline` * expands the list in layout flow, `overlay` floats it above the page * anchored to the trigger. */ presentation?: SelectPresentation; /** Sheet title shown above the options. `sheet` presentation only. */ title?: string; /** * Width of the floating list. `trigger` matches the trigger, `content` sizes * to the longest option, or pass a pixel value. `overlay` only. */ contentWidth?: 'trigger' | 'content' | number; /** Gap between the trigger and the floating list. `overlay` only. */ offset?: number; /** Called when the options open or close. */ onOpenChange?: (open: boolean) => void; /** * Put a filter above the options, matching case-insensitively on any part of * an option's label. For a list long enough that scrolling it is not finding * anything — countries, currencies, a repository's branches. * * The field is not focused on open: on a phone that would throw the keyboard * over the very list you are trying to look at. */ searchable?: boolean; /** Placeholder for the filter field. `searchable` only. */ searchPlaceholder?: string; /** Shown in place of the list when the filter matches nothing. */ emptyMessage?: string; /** * Render the platform's own picker instead of the trigger-and-list pair. * Requires the optional `@expo/ui` package; without it this prop does * nothing. * * **Theme tokens do not apply** — the platform draws the picker, so * `className`, `title` and `presentation` are ignored. `Select.Item` * children still declare the options. */ native?: boolean; /** * Native picker style. `menu` is a compact button opening a dropdown; * `wheel` is an always-visible rotor (iOS; falls back to `menu` elsewhere). */ nativeAppearance?: 'menu' | 'wheel'; children: ReactNode; } function SelectRoot({ className, value, valueLabel, onValueChange, placeholder = 'Select an option', disabled, triggerClassName, valueClassName, placeholderClassName, listClassName, searchClassName, searchInputClassName, searchContainerClassName, emptyClassName, presentation = 'sheet', title, contentWidth = 'trigger', offset = 8, onOpenChange, searchable = false, searchPlaceholder = 'Search', emptyMessage = 'No matches', native, nativeAppearance = 'menu', children, }: SelectProps) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [anchor, setAnchor] = useState(null); const [listHeight, setListHeight] = useState(0); const triggerRef = useRef(null); const chevron = useSharedValue(0); const { height: screenHeight } = useWindowDimensions(); const options = useMemo(() => { const collected: SelectItemProps[] = []; eachOption(children, (option) => collected.push(option)); return collected; }, [children]); /* * @expo/ui's portable Picker only has a control-wide `enabled` flag. Its * items accept label and value, but no disabled state, so handing it a * disabled Select.Item would make that option selectable again. Preserve * the public item contract by using the styled Select for that list. */ const nativeUI = native && nativeSelectSupportsOptions(options) ? getNativeUI() : null; const selectedLabel = useMemo( () => valueLabel ?? options.find((option) => option.value === value)?.label, [options, value, valueLabel] ); /* * The options the filter leaves standing, or null when nothing is being * filtered — which is the common case, and the one that must not pay for the * feature. `null` means "render the children as given", so an unsearched * Select does no per-option work at all. */ const filtered = useMemo(() => { const needle = searchable ? query.trim().toLowerCase() : ''; if (!needle) return null; return filterOptions(children, needle); }, [children, query, searchable]); /* * Nothing left, out of something there was. A caller rendering their own * rows filters them themselves, so `seen` is zero and their list stands — * saying "no matches" over the top of it would be Select claiming to know an * answer it was never shown. */ const noMatches = filtered !== null && filtered.seen > 0 && filtered.kept.length === 0; const close = useCallback(() => { chevron.value = withTiming(0, { duration: 160 }); setOpen(false); onOpenChange?.(false); // A filter left behind would be waiting the next time the list opens, with // most of the options missing and no obvious reason why. setQuery(''); }, [chevron, onOpenChange]); // An open overlay list catches the Android back button, closing itself // instead of popping the screen behind it. The `sheet` presentation gets the // same behaviour from its BottomSheet; the native picker owns its own back. useBackHandler(open && presentation === 'overlay' && !nativeUI, close); const toggle = useCallback(() => { if (open) { close(); return; } const show = () => { chevron.value = withTiming(1, { duration: 160 }); setOpen(true); onOpenChange?.(true); }; if (presentation !== 'overlay') { show(); return; } // The floating list is positioned in window coordinates, so it has to know // where the trigger actually landed — not where layout said it would. triggerRef.current?.measureInWindow((x, y, width, height) => { setAnchor({ x, y, width, height }); show(); }); }, [chevron, close, onOpenChange, open, presentation]); const context = useMemo( () => ({ value, onSelect: (next) => { onValueChange(next); close(); }, query, setQuery, }), [value, onValueChange, close, query] ); const slots = selectVariants({ disabled: !!disabled, presentation }); const chevronColor = useCSSVariable('--color-muted-foreground'); const chevronStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${chevron.value * 180}deg` }], })); if (nativeUI) { const { Host, Picker } = nativeUI; // The native picker has no empty state, so an unset value shows the first // selectable option rather than the placeholder. const firstEnabled = options.find((option) => !option.disabled); return ( // A picker fills the width of the row it sits in and reports its own // height — a menu is a compact button, a wheel a full rotor, and the // platform is the only thing that knows which by how much. onValueChange(next)} appearance={nativeAppearance} enabled={!disabled} > {options.map((option) => ( ))} ); } const trigger = ( {selectedLabel ? ( {selectedLabel} ) : ( {placeholder} )} ); /* * The filter, and the list it narrows. Both are built once here rather than * per presentation: the three surfaces differ in where they put the field — * above the scroller, so it does not scroll away with the options — not in * what it is. */ const search = searchable ? ( ) : null; const optionList = noMatches ? ( {emptyMessage} ) : filtered === null ? ( textChildren(children) ) : ( filtered.kept ); if (presentation === 'sheet') { return ( {trigger} {/* The sheet only ever reports a close — it is opened from the trigger — and close() has the chevron to put back. */} { if (!next) close(); }} > {/* BottomSheet.Content portals its children out of this subtree — re-provide the select context so Select.Item keeps working. */} {title ? ( {title} ) : null} {search ? {search} : null} {optionList} ); } if (presentation === 'inline') { return ( {trigger} {open ? ( {search} {optionList} ) : null} ); } // Flip above the trigger when the list would run off the bottom. listHeight // is 0 on the first frame, so the list opens downwards and corrects itself // once measured — which is invisible inside the 140ms fade. const spaceBelow = anchor ? screenHeight - (anchor.y + anchor.height) - offset : 0; const flip = !!anchor && listHeight > 0 && listHeight > spaceBelow; const overlayPosition = anchor ? { position: 'absolute' as const, left: anchor.x, ...(flip ? { bottom: screenHeight - anchor.y + offset } : { top: anchor.y + anchor.height + offset }), ...(contentWidth === 'trigger' ? { 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; const onListLayout = (event: LayoutChangeEvent) => { setListHeight(event.nativeEvent.layout.height); }; return ( {trigger} {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, * where swiping through the list would land on it before the first * one. Escaping the list is the back gesture's job, and on iOS the * modal flag's. */} {search} {optionList} ) : null} ); } SelectItem.displayName = 'Select.Item'; SelectGroup.displayName = 'Select.Group'; export const Select = Object.assign(SelectRoot, { Item: SelectItem, Group: SelectGroup, });