Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
All components

Inputs

CalendarNewDate PickerNewFloating InputOTP InputPassword InputSearch BarSelectSliderStepperTag InputTextareaTime PickerNew

Selection

CheckboxChip GroupColor SwatchProRadio GroupRatingProSegmented ControlSwitch

Buttons

ButtonFloating Action ButtonProLike ButtonProMorph ButtonProShimmer ButtonProSlide to ConfirmProStateful Button

Feedback

AlertProgressSkeletonSpinnerToast

Overlays

Action SheetNewBottom SheetDialogDrawerNew

Display

AccordionAvatarBadgeCardCarouselProList ItemNewPagination DotsNewSwipeable RowProTimelineNew

Navigation

Bottom NavNewProgress StepsNewTabs

Motion

Number TickerProText Animation
Select
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
DocsComponentsInputsSelect

Select

Scan with Expo Go

Select with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

A dropdown or sheet picker with a floating label; multi-select shows removable chips.

<Select />7 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
options

Plain values or rich options; `group` adds section headings.

(T | { label, value, description?, icon?, group?, disabled? })[]—
value / defaultValue

Controlled or uncontrolled selection.

T | nullnull
onValueChange

Called when a row is chosen.

(value, option) => void—
multiple

Checkbox rows; the menu stays open while toggling, with Clear / Done in its footer. The field shows animated chips plus a "+N" overflow chip.

booleanfalse
values / defaultValues

Controlled or uncontrolled selection in multiple mode.

T[][]
onValuesChange

Called whenever a row is toggled in multiple mode.

(values, options) => void—
maxChips

Selections shown as chips; the rest collapse into a "+N" chip (0 shows a single "N selected" chip).

number2
removableChips

Show a ✕ on each chip in the field to remove it without opening the menu.

booleantrue
label

Floating label / placeholder.

string—
floatingLabel

Set to false to put the label above the field as static text (no notch) and show placeholder inside.

booleantrue
placeholder

Shown in the field while open with nothing selected.

string"Select…"
presentation

Anchored dropdown under the field (flips above when needed) or a bottom sheet (dialog when wide).

"dropdown" | "sheet""dropdown"
variant

Deprecated alias of `presentation`.

"dropdown" | "sheet"—
size

Trigger height (44 / 54 / 64, matching FloatingInput), text, padding, radius and chevron size.

"sm" | "md" | "lg""md"
searchable

Filter field above the options with highlighted matches and an empty state.

booleanfalse
searchPlaceholder

Placeholder of the filter field.

string"Search"
emptyText

Hint under "No results" when the search matches nothing.

string—
title

Sheet heading (sheet presentation only).

stringlabel
error / helper

Message under the field; errors shake once.

string—
disabled

Dims to 50% and blocks opening.

booleanfalse
accent

Focus, label and check colour.

stringtheme accent
surface

Colour behind the field (paints the label notch).

stringtheme background
minMenuWidth

Minimum dropdown width; otherwise it matches the field.

number200
maxMenuHeight

Dropdown height cap before it scrolls (it also shrinks to the room on screen).

number320
style

Style for the outer wrapper around the trigger field and message (the one that shakes).

StyleProp<ViewStyle>—
fieldStyle

The bordered box: background, border width / colour, radius, height, padding. Wins over the computed style; a borderColor replaces the resting border only (focus, error and success colours still show). A numeric height / borderRadius re-centres the label and resizes the halo.

StyleProp<ViewStyle>—
inputStyle

Selected value / placeholder text in the trigger (single mode).

StyleProp<TextStyle>—
labelStyle

Label in both modes (floating and above). color is the resting colour — focus (accent), error and success still take over; fontSize sets the resting size, it floats at the same ratio and the notch follows its width.

StyleProp<TextStyle>—
messageStyle

Helper / error text. color applies to the helper; errors stay danger.

StyleProp<TextStyle>—
placeholderTextColor

Placeholder colour in the trigger.

stringtheme mutedForeground
iconColor

Chevron colour at rest (accent while open).

stringtheme mutedForeground
menuStyle

Dropdown / sheet panel — background, border, radius, padding, shadow (transform and opacity are animated).

StyleProp<ViewStyle>—
optionStyle

Each option row — padding, minHeight, radius, background (press / keyboard highlight still show).

StyleProp<ViewStyle>—
optionTextStyle

Each option's label text.

StyleProp<TextStyle>—

Behaviour & accessibility#

Touch
Tapping the field opens a dropdown anchored under it, flipping above when there's no room. presentation='sheet' slides up a bottom sheet you drag down to dismiss (a centred dialog on wide screens).
Motion
The label floats onto the border, a focus halo glows and the chevron turns. In multiple mode chips pop in and collapse out, overflowing into a '+N' chip.
Keyboard
On web, arrow keys move the highlight, Home and End jump, Enter picks, and Escape or Tab close the menu.
Screen readers
The field is a combobox whose value lists the selection, with an expanded state. Options are menu items, or checkboxes in multiple mode.

Source#

PreviousSearch BarNextSlider

Did you like the content?

Coding agents

Chat apps

Other

9:41

Validation

Select

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/select.tsx1399 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { Select } from "@/components/ui/select";

    See Usage for a full example.

Example.tsx
import { useState } from 'react';import { Select } from '@/components/ui/select'; const [team, setTeam] = useState<string[]>([]);<Select multiple label="Assignees" options={['Amelia', 'Ravi', 'Sofia', 'Kenji']} values={team} onValuesChange={setTeam} />

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/select.tsx
import React, { useCallback, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';import {  Animated,  Dimensions,  Easing,  Keyboard,  KeyboardAvoidingView,  Modal,  PanResponder,  Platform,  Pressable,  ScrollView,  StyleSheet,  Text,  TextInput,  View,  useWindowDimensions,  type StyleProp,  type TextStyle,  type ViewStyle,} from 'react-native';import Svg, { Circle, Path } from 'react-native-svg'; import { alpha, motion, readableOn, useKit } from '@/lib/kit'; export type SelectPresentation = 'dropdown' | 'sheet';/** @deprecated Use `SelectPresentation`. */export type SelectVariant = SelectPresentation;export type SelectSize = 'sm' | 'md' | 'lg'; export interface SelectOption<T extends string | number = string> {  label: string;  value: T;  /** Second line under the label. */  description?: string;  /** Icon, flag or avatar shown before the label. */  icon?: React.ReactNode;  /** Options sharing a group are listed together under a small heading. */  group?: string;  disabled?: boolean;} export interface SelectProps<T extends string | number = string> {  /** Plain strings or `{ label, value, description?, icon?, group?, disabled? }`. */  options: (T | SelectOption<T>)[];  value?: T | null;  defaultValue?: T | null;  onValueChange?: (value: T, option: SelectOption<T>) => void;  /** Pick several options with checkboxes. Use `values` / `defaultValues` / `onValuesChange`. */  multiple?: boolean;  /** Controlled selection in `multiple` mode. */  values?: T[];  defaultValues?: T[];  onValuesChange?: (values: T[], options: SelectOption<T>[]) => void;  /** In `multiple` mode, up to this many selections show as chips; the rest collapse into a "+N" chip (0: one "N selected" chip). */  maxChips?: number;  /** Rests inside the field like a placeholder, floats onto the border once open or filled. */  label: string;  /** `false` puts the label above the field as static text (no notch) and shows `placeholder` inside. */  floatingLabel?: boolean;  /** Shown in the field while nothing is selected yet (only while open when the label floats). */  placeholder?: string;  /**   * 'dropdown' (default) anchors a menu right under the field (above it when there's no room) ·   * 'sheet' slides up from the bottom (a centred dialog on wide screens).   */  presentation?: SelectPresentation;  /** @deprecated Use `presentation`. */  variant?: SelectVariant;  /** Trigger height, text and icon size. */  size?: SelectSize;  /** Adds a filter field above the options; matches are highlighted. */  searchable?: boolean;  searchPlaceholder?: string;  /** Hint under "No results" when the search matches nothing. */  emptyText?: string;  /** Sheet heading (default: label). */  title?: string;  error?: string;  helper?: string;  disabled?: boolean;  /** Focus, label and check colour (default: theme accent). */  accent?: string;  /** Background behind the field — the floating label cuts its notch with this colour. */  surface?: string;  /** Minimum dropdown width; the menu otherwise matches the field. */  minMenuWidth?: number;  /** Maximum dropdown height before it scrolls (it also shrinks to the room on screen). */  maxMenuHeight?: number;  /** In `multiple` mode, show a small ✕ on each chip in the field to remove it. */  removableChips?: boolean;  /** Root wrapper (the one that shakes) — margins, width, flex. */  style?: StyleProp<ViewStyle>;  /**   * The trigger box: background, border width / colour, radius, height, padding. Applied after the   * computed style, so it wins. A `borderColor` replaces the resting border only — the open   * (accent) and error colours still take over. A numeric `height` / `borderRadius` also   * re-centres the label and resizes the focus halo.   */  fieldStyle?: StyleProp<ViewStyle>;  /** The selected value / placeholder text in the trigger. */  inputStyle?: StyleProp<TextStyle>;  /**   * The label, floating or above the field. Its `color` is the resting colour; open (accent) and   * error (danger) colours still take over. A `fontSize` sets the resting size and the label floats   * at the same ratio; the notch follows the rendered label width.   */  labelStyle?: StyleProp<TextStyle>;  /** Helper / error text under the field. `color` applies to the helper; errors stay danger. */  messageStyle?: StyleProp<TextStyle>;  /** Colour of the placeholder in the trigger (default: theme mutedForeground). */  placeholderTextColor?: string;  /** Chevron colour at rest (it turns accent while open). */  iconColor?: string;  /** The dropdown / sheet panel — background, border, radius, padding, shadow. Don't pass `transform` or `opacity`: they animate. */  menuStyle?: StyleProp<ViewStyle>;  /** Each option row — padding, minHeight, radius, background (press / keyboard highlight still show). */  optionStyle?: StyleProp<ViewStyle>;  /** Each option's label text — fontSize, fontWeight, color… */  optionTextStyle?: StyleProp<TextStyle>;} /** Heights and corners match FloatingInput so forms line up. */const SIZES: Record<SelectSize, { h: number; font: number; pad: number; icon: number; chip: number; chipFont: number; corner: number }> = {  sm: { h: 44, font: 15, pad: 12, icon: 16, chip: 24, chipFont: 12.5, corner: -2 },  md: { h: 54, font: 16, pad: 16, icon: 18, chip: 28, chipFont: 13.5, corner: 0 },  lg: { h: 64, font: 17.5, pad: 18, icon: 20, chip: 32, chipFont: 15, corner: 4 },};const FLOAT_SCALE = 0.78;/** Font size of the static label used when `floatingLabel` is false. */const LABEL_ABOVE_FONT: Record<SelectSize, number> = { sm: 13, md: 14, lg: 14.5 };const ICON_GAP = 32;const GAP = 8; // space between field and dropdownconst EDGE = 12; // keep the dropdown this far from the window edgesconst PANEL_PAD = 6;const SEARCH_H = 46;const FOOT_H = 48; const normalize = <T extends string | number>(o: T | SelectOption<T>): SelectOption<T> =>  typeof o === 'object' ? o : { label: String(o), value: o };const webInput = Platform.OS === 'web' ? ({ outlineStyle: 'none', outlineWidth: 0 } as object) : null;const isWeb = Platform.OS === 'web'; /** * A form field that opens a list of options. By default it's a dropdown anchored right under * the field (flipping above when there's no room, scrolling inside when long) that fades and * scales out of the field's edge; `presentation="sheet"` slides up a drag-to-dismiss sheet * (a centred dialog on wide screens). The label floats like FloatingInput, a focus halo glows * while open and the chevron turns. Rows take icons, descriptions, groups and disabled states; * `searchable` filters with highlighted matches, `multiple` switches to checkboxes with chips in * the field. On web: arrow keys move the highlight, Enter picks, Escape closes. */export function Select<T extends string | number = string>({  options,  value,  defaultValue = null,  onValueChange,  multiple,  values,  defaultValues,  onValuesChange,  maxChips = 2,  label,  floatingLabel = true,  placeholder = 'Select…',  presentation,  variant,  size = 'md',  searchable,  searchPlaceholder = 'Search',  emptyText,  title,  error,  helper,  disabled,  accent,  surface,  minMenuWidth = 200,  maxMenuHeight = 320,  removableChips = true,  style,  fieldStyle,  inputStyle,  labelStyle,  messageStyle,  placeholderTextColor,  iconColor,  menuStyle,  optionStyle,  optionTextStyle,}: SelectProps<T>) {  const { colors, radius, dark, font } = useKit();  const ff = font.family ? { fontFamily: font.family } : null;  const tint = accent ?? colors.accent;  const bg = surface ?? colors.background;  const sz = SIZES[size];  // The user's border colour only replaces the resting one; numeric height / radius drive the layout maths.  const { borderColor: userBorder, ...userField } = StyleSheet.flatten(fieldStyle) ?? {};  const userLabelColor = StyleSheet.flatten(labelStyle)?.color as string | undefined;  const HEIGHT = typeof userField.height === 'number' ? userField.height : sz.h;  const fieldRadius = typeof userField.borderRadius === 'number' ? userField.borderRadius : radius.scale(12 + sz.corner);  const panelRadius = Math.max(fieldRadius, radius.scale(14));  const { width: winW, height: winH } = useWindowDimensions();  const items = useMemo(() => options.map(normalize), [options]);  const asSheet = (presentation ?? variant ?? 'dropdown') === 'sheet';  const wide = winW >= 640;   const [inner, setInner] = useState<T | null>(defaultValue);  const [innerMany, setInnerMany] = useState<T[]>(defaultValues ?? []);  const selected = value !== undefined ? value : inner;  const many = values ?? innerMany;  const current = multiple ? undefined : items.find((o) => o.value === selected);  const picked = multiple ? items.filter((o) => many.includes(o.value)) : current ? [current] : [];  const isSelected = (o: SelectOption<T>) => (multiple ? many.includes(o.value) : o.value === selected);   const [open, setOpen] = useState(false); // target state  const [mounted, setMounted] = useState(false); // Modal visible (stays true while closing)  const [query, setQuery] = useState('');  const [anchor, setAnchor] = useState({ x: 0, y: 0, w: 0, h: 0 });  const [hi, setHi] = useState(-1); // highlighted row (keyboard / hover, web)  const [kb, setKb] = useState(0); // on-screen keyboard height while the dropdown is open  const fieldRef = useRef<ComponentRef<typeof View>>(null);  const scrollRef = useRef<ComponentRef<typeof ScrollView>>(null);  const rowBox = useRef<Record<string, { y: number; h: number }>>({});  const scrollY = useRef(0);  const viewH = useRef(0);  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);  const alive = useRef(true);   const floating = open || picked.length > 0;  const float = useRef(new Animated.Value(floating ? 1 : 0)).current; // native  const focus = useRef(new Animated.Value(0)).current; // JS: colours  const halo = useRef(new Animated.Value(0)).current; // native: focus halo  const show = useRef(new Animated.Value(0)).current; // native: backdrop + sheet/dropdown  const drag = useRef(new Animated.Value(0)).current; // native: sheet drag offset  const shake = useRef(new Animated.Value(0)).current; // native   useEffect(() => {    alive.current = true;    return () => {      alive.current = false;      if (closeTimer.current) clearTimeout(closeTimer.current);    };  }, []);   useEffect(() => {    Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();  }, [floating, float]);   useEffect(() => {    Animated.timing(focus, { toValue: open ? 1 : 0, duration: 160, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();    Animated.spring(halo, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();  }, [open, focus, halo]);   useEffect(() => {    if (!error) return;    shake.setValue(0);    Animated.sequence(      [10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))    ).start();  }, [error, shake]);   useEffect(() => {    if (open) {      drag.setValue(0);      if (asSheet) Animated.spring(show, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start();      else Animated.timing(show, { toValue: 1, duration: 170, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();    } else if (mounted) {      Animated.timing(show, { toValue: 0, duration: asSheet ? motion.fast + 40 : 120, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(        ({ finished }) => finished && setMounted(false)      );    }    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [open]);   // Track the on-screen keyboard so a dropdown with a search field shrinks instead of hiding behind it.  useEffect(() => {    if (!open || asSheet || isWeb) return;    const a = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', (e) => setKb(e.endCoordinates.height));    const b = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', () => setKb(0));    return () => {      a.remove();      b.remove();      setKb(0);    };  }, [open, asSheet]);   // Where the field sits on screen. Only commits a change, so re-measuring is cheap.  const measure = useCallback((then?: () => void) => {    const node = fieldRef.current;    if (!node) return then?.();    node.measureInWindow((x, y, w, h) => {      if (!alive.current) return;      setAnchor((a) => (a.x === x && a.y === y && a.w === w && a.h === h ? a : { x, y, w, h }));      then?.();    });  }, []);   // Keep following the field while open: rotation, browser / iframe resize (a docs preview grows  // after the menu appears), the keyboard, and a couple of late passes for layout that settles  // after the first frame. The side and max height are derived from the anchor on every render.  useEffect(() => {    if (!open || asSheet) return;    measure();    const t1 = setTimeout(() => measure(), 150);    const t2 = setTimeout(() => measure(), 400);    const sub = Dimensions.addEventListener('change', () => measure());    return () => {      clearTimeout(t1);      clearTimeout(t2);      sub.remove();    };  }, [open, asSheet, winW, winH, kb, measure]);   const q = query.trim().toLowerCase();  const filtered = useMemo(    () => (q ? items.filter((o) => `${o.label} ${o.description ?? ''}`.toLowerCase().includes(q)) : items),    [items, q]  );  const sections = useMemo(() => sectionsOf(filtered), [filtered]);  const flat = useMemo(() => sections.flatMap((s) => s.rows), [sections]);   // Estimated natural height of the dropdown, used to decide whether it fits below.  const estimate = (list: SelectOption<T>[], groups: number) =>    Math.min(      maxMenuHeight,      PANEL_PAD * 2 +        (searchable ? SEARCH_H : 0) +        (multiple ? FOOT_H : 0) +        groups * 30 +        (list.length ? list.reduce((n, o) => n + (o.description ? 56 : 44), 0) : 96)    );   const firstEnabled = (list: SelectOption<T>[]) => list.findIndex((o) => !o.disabled);   const openMenu = () => {    if (disabled) return;    if (closeTimer.current) clearTimeout(closeTimer.current);    setQuery('');    rowBox.current = {};    scrollY.current = 0;    const all = sectionsOf(items).flatMap((s) => s.rows);    const sel = multiple ? -1 : all.findIndex((o) => o.value === selected);    setHi(isWeb ? sel : -1);    const go = () => {      setMounted(true);      setOpen(true);    };    if (asSheet) return go();    measure(go);  };  const close = () => {    if (closeTimer.current) clearTimeout(closeTimer.current);    setOpen(false);  };   const setMany = (next: T[]) => {    if (values === undefined) setInnerMany(next);    onValuesChange?.(next, items.filter((o) => next.includes(o.value)));  };   const choose = (o: SelectOption<T>) => {    if (o.disabled) return;    if (multiple) {      setMany(many.includes(o.value) ? many.filter((v) => v !== o.value) : [...many, o.value]);      return;    }    if (value === undefined) setInner(o.value);    onValueChange?.(o.value, o);    // Let the check pop before the menu leaves.    if (closeTimer.current) clearTimeout(closeTimer.current);    closeTimer.current = setTimeout(() => setOpen(false), asSheet ? 160 : 90);  };   const reveal = (i: number) => {    const o = flat[i];    const box = o && rowBox.current[String(o.value)];    if (!box || !viewH.current) return;    if (box.y < scrollY.current) scrollRef.current?.scrollTo({ y: Math.max(0, box.y - PANEL_PAD), animated: false });    else if (box.y + box.h > scrollY.current + viewH.current)      scrollRef.current?.scrollTo({ y: box.y + box.h - viewH.current + PANEL_PAD, animated: false });  };   // Scroll the highlighted (selected) row into view once rows have laid out.  useEffect(() => {    if (!open || hi < 0) return;    const t = setTimeout(() => reveal(hi), 60);    return () => clearTimeout(t);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [open]);   // Typing in the search keeps the first match highlighted (web) so Enter picks it.  const onSearch = (text: string) => {    setQuery(text);    if (!isWeb) return;    const t = text.trim().toLowerCase();    const next = t ? items.filter((o) => `${o.label} ${o.description ?? ''}`.toLowerCase().includes(t)) : items;    setHi(t ? firstEnabled(sectionsOf(next).flatMap((x) => x.rows)) : -1);  };   const step = (dir: 1 | -1, from = hi) => {    const n = flat.length;    if (!n) return;    let i = from < 0 ? (dir > 0 ? -1 : n) : from;    for (let k = 0; k < n; k++) {      i = (i + dir + n) % n;      if (!flat[i].disabled) {        setHi(i);        reveal(i);        return;      }    }  };   // Web keyboard: ↑/↓ move, Home/End jump, Enter picks, Escape/Tab close.  const onKey = useRef<(e: KeyboardEvent) => void>(() => {});  onKey.current = (e) => {    switch (e.key) {      case 'ArrowDown':      case 'ArrowUp':        e.preventDefault();        step(e.key === 'ArrowDown' ? 1 : -1);        break;      case 'Home':      case 'End':        if (searchable && query) return; // let the caret move in the search field        e.preventDefault();        step(e.key === 'Home' ? 1 : -1, e.key === 'Home' ? -1 : flat.length);        break;      case 'Enter':        if (hi >= 0 && flat[hi]) {          e.preventDefault();          choose(flat[hi]);        }        break;      case 'Escape':        e.preventDefault();        e.stopPropagation();        close();        break;      case 'Tab':        close();        break;    }  };  useEffect(() => {    if (!open || !isWeb || typeof document === 'undefined') return;    const handler = (e: KeyboardEvent) => onKey.current(e);    document.addEventListener('keydown', handler);    return () => document.removeEventListener('keydown', handler);  }, [open]);   // Drag the sheet's grabber down to dismiss.  const pan = useMemo(    () =>      PanResponder.create({        onMoveShouldSetPanResponder: (_, g) => g.dy > 4 && Math.abs(g.dy) > Math.abs(g.dx),        onPanResponderMove: (_, g) => drag.setValue(Math.max(0, g.dy)),        onPanResponderRelease: (_, g) => {          if (g.dy > 110 || g.vy > 1.1) setOpen(false);          else Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();        },        onPanResponderTerminate: () => Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start(),      }),    [drag]  );   const active = error ? colors.danger : tint;  const borderColor = focus.interpolate({    inputRange: [0, 1],    outputRange: [error ? colors.danger : ((userBorder as string | undefined) ?? colors.input), active],  });  const labelColor = focus.interpolate({ inputRange: [0, 1], outputRange: [error ? colors.danger : (userLabelColor ?? colors.mutedForeground), active] });  const topLabelColor = focus.interpolate({    inputRange: [0, 1],    outputRange: [error ? colors.danger : (userLabelColor ?? colors.mutedForeground), active],  });  const dense = !asSheet;  const rowRadius = Math.max(0, panelRadius - PANEL_PAD - 1);   const searchField = searchable ? (    <View      style={        dense          ? [styles.searchDense, { borderBottomColor: colors.border }]          : [styles.search, { backgroundColor: colors.muted, borderRadius: radius.md }]      }    >      <Icon name="search" size={dense ? 16 : 17} color={colors.mutedForeground} />      <TextInput        value={query}        onChangeText={onSearch}        placeholder={searchPlaceholder}        placeholderTextColor={colors.mutedForeground}        selectionColor={tint}        cursorColor={tint}        autoCorrect={false}        autoCapitalize="none"        autoFocus={isWeb}        returnKeyType="done"        accessibilityLabel={searchPlaceholder}        style={[styles.searchInput, ff, { color: colors.foreground, fontSize: dense ? 15 : 16 }, webInput]}      />      {query ? (        <Pressable          onPress={() => onSearch('')}          hitSlop={12}          accessibilityRole="button"          accessibilityLabel="Clear search"          style={[styles.clear, { backgroundColor: alpha(colors.mutedForeground, 0.22) }]}        >          <Icon name="x" size={11} color={colors.foreground} strokeWidth={2.4} />        </Pressable>      ) : null}    </View>  ) : null;   let rowIndex = -1;  const body = (    <>      {searchField}      <ScrollView        ref={scrollRef}        keyboardShouldPersistTaps="handled"        bounces={false}        style={styles.scroll}        contentContainerStyle={dense ? styles.listDense : styles.listContent}        scrollEventThrottle={32}        onScroll={(e) => (scrollY.current = e.nativeEvent.contentOffset.y)}        onLayout={(e) => (viewH.current = e.nativeEvent.layout.height)}        accessibilityRole="list"      >        {flat.length ? (          sections.map((sec, si) => (            <React.Fragment key={sec.title ?? `s${si}`}>              {sec.title ? (                <>                  {si > 0 ? <View style={[styles.sep, { backgroundColor: colors.border }]} /> : null}                  <Text style={[styles.groupTitle, dense ? styles.groupDense : null, ff, { color: colors.mutedForeground }]}>{sec.title}</Text>                </>              ) : null}              {sec.rows.map((o) => {                rowIndex += 1;                const i = rowIndex;                return (                  <OptionRow                    key={String(o.value)}                    option={o}                    selected={isSelected(o)}                    highlighted={isWeb && i === hi}                    multiple={!!multiple}                    dense={dense}                    rowRadius={rowRadius}                    query={q}                    tint={tint}                    rowStyle={optionStyle}                    textStyle={optionTextStyle}                    onPress={() => choose(o)}                    onHover={() => !o.disabled && setHi(i)}                    onLayout={(y, h) => (rowBox.current[String(o.value)] = { y, h })}                  />                );              })}            </React.Fragment>          ))        ) : (          <View style={dense ? styles.emptyDense : styles.empty}>            <View style={[styles.emptyIcon, dense && styles.emptyIconDense, { backgroundColor: colors.muted }]}>              <Icon name="search" size={dense ? 16 : 20} color={colors.mutedForeground} />            </View>            <Text style={[styles.emptyTitle, ff, { color: colors.foreground, fontSize: dense ? 14 : 15 }]}>              {q ? 'No results' : 'No options'}            </Text>            {q ? (              <Text style={[styles.emptyText, ff, { color: colors.mutedForeground }]}>                {emptyText ?? `Nothing matches “${query.trim()}”.`}              </Text>            ) : null}          </View>        )}      </ScrollView>      {multiple ? (        dense ? (          <View style={[styles.footDense, { borderTopColor: colors.border }]}>            <Pressable              onPress={() => setMany([])}              disabled={!many.length}              accessibilityRole="button"              style={({ pressed }) => [                styles.footGhost,                { borderRadius: radius.capsule(32), opacity: many.length ? 1 : 0.4, backgroundColor: pressed ? colors.muted : 'transparent' },              ]}            >              <Text style={[styles.footDenseText, ff, { color: colors.mutedForeground }]}>Clear</Text>            </Pressable>            <View style={styles.footMid}>              <CountBadge count={many.length} tint={tint} />            </View>            <Pressable              onPress={close}              accessibilityRole="button"              style={({ pressed }) => [styles.footSolid, { backgroundColor: tint, borderRadius: radius.capsule(32), opacity: pressed ? 0.85 : 1 }]}            >              <Text style={[styles.footDenseText, styles.footStrong, ff, { color: readableOn(tint) }]}>Done</Text>            </Pressable>          </View>        ) : (          <View style={[styles.footer, { borderTopColor: colors.border }]}>            <Pressable              onPress={() => setMany([])}              disabled={!many.length}              accessibilityRole="button"              style={({ pressed }) => [styles.footBtn, { borderRadius: radius.md, opacity: many.length ? (pressed ? 0.6 : 1) : 0.4 }]}            >              <Text style={[styles.footText, ff, { color: colors.foreground }]}>Clear</Text>            </Pressable>            <Pressable              onPress={close}              accessibilityRole="button"              style={({ pressed }) => [styles.footBtn, styles.footMain, { backgroundColor: tint, borderRadius: radius.md, opacity: pressed ? 0.85 : 1 }]}            >              <Text style={[styles.footText, styles.footStrong, styles.tabular, ff, { color: readableOn(tint) }]}>                {many.length ? `Done · ${many.length}` : 'Done'}              </Text>            </Pressable>          </View>        )      ) : null}    </>  );   // Dropdown geometry: match the field, never narrower than minMenuWidth, clamped to the window.  const popW = Math.max(0, Math.min(winW - EDGE * 2, Math.max(anchor.w, minMenuWidth)));  const popLeft = Math.max(EDGE, Math.min(anchor.x, winW - popW - EDGE));  const roomBelow = winH - kb - (anchor.y + anchor.h + GAP) - EDGE;  const roomAbove = anchor.y - GAP - EDGE;  // Sized from all options (not the filtered ones) so typing a search never flips the menu.  const need = estimate(items, new Set(items.map((o) => o.group).filter(Boolean)).size);  // Prefer below — the list scrolls inside maxHeight. Only flip above when below can't fit a  // usable strip (~120pt) and above clearly has more room (e.g. field near the screen bottom).  const minUsable = Math.min(120, Math.max(72, Math.floor(need * 0.35)));  const below = roomBelow >= minUsable || roomBelow + 32 >= roomAbove;  const popMaxH = Math.max(96, Math.min(maxMenuHeight, below ? Math.max(0, roomBelow) : Math.max(0, roomAbove)));   const hasIcon = !multiple && !!current?.icon;  const textLeft = hasIcon ? sz.pad + ICON_GAP : sz.pad;  const message = error ?? helper;  const remove = (v: T) => setMany(many.filter((x) => x !== v));  // Multiple mode: the first `maxChips` picks as chips, the rest as a "+N" chip. Removed chips stay  // rendered (collapsing) until their exit animation finishes.  const chipItems: { key: string; item: { option?: SelectOption<T>; more: number } }[] = [];  if (multiple) {    picked.slice(0, maxChips).forEach((o) => chipItems.push({ key: `o:${String(o.value)}`, item: { option: o, more: 0 } }));    if (picked.length > maxChips) chipItems.push({ key: 'more', item: { more: picked.length - maxChips } });  }  const [chips, chipGone] = usePresence(chipItems);  const showChips = chips.length > 0;  const chipBg = alpha(tint, dark ? 0.2 : 0.12);   return (    <Animated.View style={[{ transform: [{ translateX: shake }] }, style]}>      {floatingLabel ? null : (        <Animated.Text          numberOfLines={1}          // The trigger already carries the label for screen readers.          accessible={false}          importantForAccessibility="no"          accessibilityElementsHidden          style={[styles.topLabel, ff, { fontSize: LABEL_ABOVE_FONT[size] }, labelStyle, { color: topLabelColor, opacity: disabled ? 0.5 : 1 }]}        >          {label}        </Animated.Text>      )}      <Pressable        ref={fieldRef}        onPress={() => (open ? close() : openMenu())}        disabled={disabled}        accessibilityRole="combobox"        accessibilityLabel={label}        accessibilityValue={{ text: picked.length ? picked.map((o) => o.label).join(', ') : 'None selected' }}        accessibilityState={{ expanded: open, disabled: !!disabled }}        style={{ opacity: disabled ? 0.5 : 1 }}      >        {({ hovered }: { pressed: boolean; hovered?: boolean }) => (          <>            <Animated.View              pointerEvents="none"              style={[                styles.halo,                {                  borderRadius: fieldRadius + 4,                  borderColor: alpha(active, 0.2),                  opacity: halo,                  transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }],                },              ]}            />            <Animated.View              style={[                styles.field,                { height: HEIGHT, borderRadius: fieldRadius, backgroundColor: bg },                userField,                { borderColor: hovered && !open && !error ? colors.mutedForeground : borderColor },              ]}            >              {hasIcon ? <View style={[styles.icon, { left: sz.pad - 2 }]}>{current!.icon}</View> : null}              {showChips ? (                <View style={[styles.chips, { paddingLeft: sz.pad - 6, paddingRight: sz.pad + sz.icon + 8 }]}>                  {chips.map(({ key, item, leaving, appear }) => (                    <AnimatedChip key={key} leaving={leaving} appear={appear} gap={6} shrink={!!item.option} onGone={() => chipGone(key)}>                      {item.option ? (                        <View                          style={[                            styles.chip,                            {                              height: sz.chip,                              paddingLeft: item.option.icon ? 3 : 10,                              paddingRight: removableChips && !disabled ? 4 : 10,                              backgroundColor: chipBg,                              borderRadius: radius.capsule(sz.chip),                            },                          ]}                        >                          {item.option.icon ? (                            <View style={[styles.chipIcon, { width: sz.chip - 6, height: sz.chip - 6, borderRadius: radius.capsule(sz.chip - 6) }]}>                              {item.option.icon}                            </View>                          ) : null}                          <Text numberOfLines={1} style={[styles.chipText, ff, { color: tint, fontSize: sz.chipFont }]}>                            {item.option.label}                          </Text>                          {removableChips && !disabled ? (                            <Pressable                              onPress={() => item.option && remove(item.option.value)}                              hitSlop={12}                              accessibilityRole="button"                              accessibilityLabel={`Remove ${item.option.label}`}                              style={({ pressed }) => [                                styles.chipX,                                {                                  width: sz.chip - 8,                                  height: sz.chip - 8,                                  borderRadius: radius.capsule(sz.chip - 8),                                  backgroundColor: pressed ? alpha(tint, 0.2) : 'transparent',                                },                              ]}                            >                              <Icon name="x" size={Math.round(sz.chip * 0.45)} color={tint} strokeWidth={2.2} />                            </Pressable>                          ) : null}                        </View>                      ) : (                        <View                          accessible                          accessibilityLabel={maxChips > 0 ? `${item.more} more` : `${item.more} selected`}                          style={[styles.chip, styles.more, { height: sz.chip, backgroundColor: chipBg, borderRadius: radius.capsule(sz.chip) }]}                        >                          {maxChips > 0 ? <Text style={[styles.chipText, styles.tabular, ff, { color: tint, fontSize: sz.chipFont }]}>+</Text> : null}                          <RollingNumber value={item.more} distance={sz.chipFont} style={[styles.chipText, ff, { color: tint, fontSize: sz.chipFont }]} />                          {maxChips > 0 ? null : (                            <Text style={[styles.chipText, ff, { color: tint, fontSize: sz.chipFont, marginLeft: 3 }]}>selected</Text>                          )}                        </View>                      )}                    </AnimatedChip>                  ))}                </View>              ) : (                <Text                  numberOfLines={1}                  style={[                    ff,                    {                      fontSize: sz.font,                      fontWeight: '400',                      color: current ? colors.foreground : colors.mutedForeground,                      paddingLeft: textLeft,                      paddingRight: sz.pad + sz.icon + 8,                    },                    inputStyle,                    !current && { color: placeholderTextColor ?? colors.mutedForeground },                  ]}                >                  {current ? current.label : open || !floatingLabel ? placeholder : ''}                </Text>              )}              {floatingLabel ? (                <Animated.View                  pointerEvents="none"                  style={[                    styles.labelWrap,                    {                      left: textLeft,                      right: sz.pad + sz.icon + 8,                      height: HEIGHT - 3,                      transform: [                        { translateY: float.interpolate({ inputRange: [0, 1], outputRange: [0, -HEIGHT / 2] }) },                        { translateX: float.interpolate({ inputRange: [0, 1], outputRange: [0, -(textLeft - sz.pad) - 4] }) },                        { scale: float.interpolate({ inputRange: [0, 1], outputRange: [1, FLOAT_SCALE] }) },                      ],                    },                  ]}                >                  {/* The notch: the label paints the surface over the border, so it matches the rendered width. */}                  <Animated.Text numberOfLines={1} style={[styles.label, ff, { fontSize: sz.font, backgroundColor: bg }, labelStyle, { color: labelColor }]}>                    {label}                  </Animated.Text>                </Animated.View>              ) : null}              <Chevron open={open} size={sz.icon} right={sz.pad - 4} color={open ? active : (iconColor ?? colors.mutedForeground)} />            </Animated.View>          </>        )}      </Pressable>      {message ? <FieldMessage text={message} error={!!error} style={messageStyle} /> : null}       <Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={close}>        <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: asSheet ? colors.overlay : 'transparent', opacity: show }]}>          <Pressable style={StyleSheet.absoluteFill} onPress={close} accessibilityLabel="Close" accessibilityRole="button" />        </Animated.View>         {asSheet ? (          <KeyboardAvoidingView            behavior={Platform.OS === 'ios' ? 'padding' : undefined}            style={[styles.sheetHost, wide && styles.centered]}            pointerEvents="box-none"          >            <Animated.View              accessibilityViewIsModal              style={[                styles.sheet,                { backgroundColor: colors.popover, maxHeight: winH * (wide ? 0.7 : 0.78), borderColor: colors.border },                wide                  ? { width: Math.min(460, winW - 48), borderRadius: radius.xl, paddingBottom: 8 }                  : { alignSelf: 'stretch', borderTopLeftRadius: radius.xl + 4, borderTopRightRadius: radius.xl + 4, paddingBottom: 28 },                menuStyle,                wide                  ? {                      opacity: show,                      transform: [                        { scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.94, 1] }) },                        { translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [16, 0] }), drag) },                      ],                    }                  : { transform: [{ translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [winH * 0.8, 0] }), drag) }] },              ]}            >              <View {...pan.panHandlers} style={styles.sheetHeader}>                {!wide ? <View style={[styles.grabber, { backgroundColor: colors.border }]} /> : null}                <Text style={[styles.title, ff, { color: colors.foreground }]}>{title ?? label}</Text>                {multiple ? (                  <View style={styles.subtitleRow}>                    {many.length ? (                      <CountBadge count={many.length} tint={tint} />                    ) : (                      <Text style={[styles.subtitle, ff, { color: colors.mutedForeground }]}>Choose one or more</Text>                    )}                  </View>                ) : null}              </View>              {body}            </Animated.View>          </KeyboardAvoidingView>        ) : (          <Animated.View            accessibilityViewIsModal            style={[              styles.dropdown,              {                left: popLeft,                width: popW,                maxHeight: popMaxH,                backgroundColor: colors.popover,                borderColor: colors.border,                borderRadius: panelRadius,                shadowOpacity: dark ? 0.5 : 0.12,                transformOrigin: below ? 'top' : 'bottom',              },              menuStyle,              {                opacity: show,                transform: [                  { translateY: show.interpolate({ inputRange: [0, 1], outputRange: [below ? -6 : 6, 0] }) },                  { scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) },                ],              },              below ? { top: anchor.y + anchor.h + GAP } : { bottom: Math.max(EDGE, winH - anchor.y + GAP) },            ]}          >            {body}          </Animated.View>        )}      </Modal>    </Animated.View>  );} /** Groups options by `group` (first-appearance order); a single untitled section when none are grouped. */function sectionsOf<T extends string | number>(list: SelectOption<T>[]): { title?: string; rows: SelectOption<T>[] }[] {  if (!list.some((o) => o.group)) return [{ rows: list }];  const map = new Map<string, SelectOption<T>[]>();  list.forEach((o) => map.set(o.group ?? '', [...(map.get(o.group ?? '') ?? []), o]));  return [...map].map(([title, rows]) => ({ title: title || undefined, rows }));} function OptionRow<T extends string | number>({  option,  selected,  highlighted,  multiple,  dense,  rowRadius,  query,  tint,  rowStyle,  textStyle,  onPress,  onHover,  onLayout,}: {  option: SelectOption<T>;  selected: boolean;  highlighted: boolean;  multiple: boolean;  dense: boolean;  rowRadius: number;  query: string;  tint: string;  rowStyle?: StyleProp<ViewStyle>;  textStyle?: StyleProp<TextStyle>;  onPress: () => void;  onHover: () => void;  onLayout: (y: number, h: number) => void;}) {  const { colors, radius, font, dark } = useKit();  const ff = font.family ? { fontFamily: font.family } : null;  const check = useRef(new Animated.Value(selected ? 1 : 0)).current; // native: scale + opacity  useEffect(() => {    Animated.spring(check, { toValue: selected ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();  }, [selected, check]);  const on = readableOn(tint);  const box = dense ? 18 : 22;  const hoverBg = alpha(colors.foreground, 0.06);  const pressBg = alpha(colors.foreground, 0.1);  const selectedBg = alpha(tint, dark ? 0.14 : 0.08);   return (    <Pressable      onPress={onPress}      onHoverIn={onHover}      onLayout={(e) => onLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)}      disabled={option.disabled}      accessibilityRole={multiple ? 'checkbox' : 'menuitem'}      accessibilityState={multiple ? { checked: selected, disabled: !!option.disabled } : { selected, disabled: !!option.disabled }}      style={({ pressed }) => [        dense ? styles.rowDense : styles.row,        {          borderRadius: dense ? rowRadius : radius.scale(10),          backgroundColor: pressed ? pressBg : highlighted ? hoverBg : selected ? selectedBg : 'transparent',          opacity: option.disabled ? 0.4 : 1,        },        rowStyle,        // Press and keyboard highlight still show over a custom row background.        (pressed || highlighted) && { backgroundColor: pressed ? pressBg : hoverBg },      ]}    >      {multiple ? (        <View style={[styles.box, { width: box, height: box, borderColor: selected ? tint : colors.input, borderRadius: radius.scale(5) }]}>          <Animated.View style={[styles.boxFill, { backgroundColor: tint, borderRadius: radius.scale(5), opacity: check, transform: [{ scale: check }] }]}>            <Icon name="check" size={dense ? 12 : 14} color={on} strokeWidth={2.8} />          </Animated.View>        </View>      ) : null}      {option.icon ? <View style={styles.rowIcon}>{option.icon}</View> : null}      <View style={styles.rowText}>        <Highlight          text={option.label}          query={query}          tint={tint}          style={[styles.rowLabel, ff, { color: colors.foreground, fontSize: dense ? 15 : 16 }, selected && styles.rowLabelOn, textStyle]}        />        {option.description ? (          <Highlight            text={option.description}            query={query}            tint={tint}            style={[styles.rowDesc, ff, { color: colors.mutedForeground, fontSize: dense ? 12.5 : 13 }]}          />        ) : null}      </View>      {!multiple ? (        <Animated.View          style={[            styles.checkWrap,            {              opacity: check,              transform: [{ scale: check }, { rotate: check.interpolate({ inputRange: [0, 1], outputRange: ['-30deg', '0deg'] }) }],            },          ]}        >          <Icon name="check" size={dense ? 17 : 18} color={tint} strokeWidth={2.4} />        </Animated.View>      ) : null}    </Pressable>  );} type Present<V> = { key: string; item: V; leaving: boolean; appear: boolean }; /** * Keeps entries that drop out of `items` rendered in their old place, flagged `leaving`, until * `done(key)` says their exit animation finished. Entries present on the first render don't animate in. */function usePresence<V>(items: { key: string; item: V }[]) {  const prev = useRef<Present<V>[]>([]);  const finished = useRef(new Set<string>());  const ready = useRef(false);  const alive = useRef(true);  const [, rerender] = useState(0);  useEffect(() => {    ready.current = true;    alive.current = true;    return () => {      alive.current = false;    };  }, []);   const live = new Set(items.map((e) => e.key));  live.forEach((k) => finished.current.delete(k));  const out: Present<V>[] = [];  let next = 0;  const emitUpTo = (end: number) => {    for (; next <= end; next++) out.push({ ...items[next], leaving: false, appear: ready.current });  };  for (const e of prev.current) {    if (live.has(e.key)) emitUpTo(items.findIndex((x) => x.key === e.key));    else if (!finished.current.has(e.key)) out.push({ ...e, leaving: true });  }  emitUpTo(items.length - 1);  prev.current = out;   const done = useCallback((key: string) => {    finished.current.add(key);    if (alive.current) rerender((n) => n + 1);  }, []);  return [out, done] as const;} /** * A chip that pops in (native scale + opacity spring) and, when `leaving`, fades and shrinks while * an outer view collapses its width and margin on the JS driver so neighbours slide over smoothly. */function AnimatedChip({  leaving,  appear,  gap,  shrink,  onGone,  children,}: {  leaving: boolean;  appear: boolean;  gap: number;  /** Whether the slot may shrink when the row is tight (chips yes, the "+N" counter no). */  shrink: boolean;  onGone: () => void;  children: React.ReactNode;}) {  const pop = useRef(new Animated.Value(appear ? 0 : 1)).current; // native: scale + opacity  const size = useRef(new Animated.Value(1)).current; // JS: width + margin of the outer slot  const [w, setW] = useState(0);  const gone = useRef(onGone);  gone.current = onGone;   useEffect(() => {    let alive = true;    let anims: Animated.CompositeAnimation[];    if (leaving) {      let left = 2;      const end = ({ finished }: { finished: boolean }) => {        if (finished && alive && --left === 0) gone.current();      };      anims = [        Animated.timing(pop, { toValue: 0, duration: 150, easing: Easing.out(Easing.quad), useNativeDriver: true }),        Animated.timing(size, { toValue: 0, duration: 230, easing: Easing.inOut(Easing.cubic), useNativeDriver: false }),      ];      anims.forEach((a) => a.start(end));    } else {      size.setValue(1);      anims = [Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.snappy })];      anims.forEach((a) => a.start());    }    return () => {      alive = false;      anims.forEach((a) => a.stop());    };  }, [leaving, pop, size]);   const collapsing = leaving && w > 0;  return (    <Animated.View      pointerEvents={leaving ? 'none' : 'auto'}      style={[        styles.chipSlot,        !shrink && { flexShrink: 0 },        collapsing          ? {              overflow: 'hidden',              width: size.interpolate({ inputRange: [0, 1], outputRange: [0, w] }),              marginRight: size.interpolate({ inputRange: [0, 1], outputRange: [0, gap] }),            }          : { marginRight: gap },      ]}    >      <Animated.View        onLayout={leaving ? undefined : (e) => setW(e.nativeEvent.layout.width)}        style={[          styles.chipPop,          collapsing && { width: w, flexShrink: 0 },          {            opacity: pop.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),            transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.85, 1] }) }],          },        ]}      >        {children}      </Animated.View>    </Animated.View>  );} /** A number that rolls (native translateY + opacity) up when it grows and down when it shrinks. */function RollingNumber({ value, style, distance = 10 }: { value: number; style?: StyleProp<TextStyle>; distance?: number }) {  const [state, setState] = useState({ cur: value, prev: value, dir: 1 });  if (state.cur !== value) setState({ cur: value, prev: state.cur, dir: value > state.cur ? 1 : -1 });  // A fresh value per change starts at 0 in the same render, so there's no one-frame flash.  const roll = useMemo(() => new Animated.Value(state.cur === state.prev ? 1 : 0), [state]);  useEffect(() => {    const a = Animated.spring(roll, { toValue: 1, useNativeDriver: true, ...motion.snappy });    a.start();    return () => a.stop();  }, [roll]);  const moving = state.cur !== state.prev;  return (    <View style={styles.roll}>      {moving ? (        <Animated.Text          accessible={false}          importantForAccessibility="no"          style={[            style,            styles.tabular,            styles.rollPrev,            {              opacity: roll.interpolate({ inputRange: [0, 1], outputRange: [1, 0], extrapolate: 'clamp' }),              transform: [{ translateY: roll.interpolate({ inputRange: [0, 1], outputRange: [0, -state.dir * distance] }) }],            },          ]}        >          {state.prev}        </Animated.Text>      ) : null}      <Animated.Text        style={[          style,          styles.tabular,          {            opacity: roll.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),            transform: [{ translateY: roll.interpolate({ inputRange: [0, 1], outputRange: [state.dir * distance, 0] }) }],          },        ]}      >        {state.cur}      </Animated.Text>    </View>  );} /** "N selected" pill whose number rolls; it pops out when the count drops to 0. */function CountBadge({ count, tint }: { count: number; tint: string }) {  const { radius, font } = useKit();  const ff = font.family ? { fontFamily: font.family } : null;  const on = count > 0;  const vis = useRef(new Animated.Value(on ? 1 : 0)).current; // native  const last = useRef(count);  if (on) last.current = count;  useEffect(() => {    const a = Animated.spring(vis, { toValue: on ? 1 : 0, useNativeDriver: true, ...motion.snappy });    a.start();    return () => a.stop();  }, [on, vis]);  const text = [styles.badgeText, ff, { color: tint }];  return (    <Animated.View      accessible      accessibilityLabel={`${last.current} selected`}      accessibilityLiveRegion="polite"      style={[        styles.badge,        {          backgroundColor: alpha(tint, 0.12),          borderRadius: radius.capsule(24),          opacity: vis.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),          transform: [{ scale: vis.interpolate({ inputRange: [0, 1], outputRange: [0.85, 1] }) }],        },      ]}    >      <RollingNumber value={last.current} distance={9} style={text} />      <Text style={[text, styles.badgeSuffix]}>selected</Text>    </Animated.View>  );} /** Renders `text` with the first match of `query` highlighted. */function Highlight({ text, query, tint, style }: { text: string; query: string; tint: string; style: StyleProp<TextStyle> }) {  const i = query ? text.toLowerCase().indexOf(query) : -1;  if (i < 0) {    return (      <Text numberOfLines={1} style={style}>        {text}      </Text>    );  }  return (    <Text numberOfLines={1} style={style}>      {text.slice(0, i)}      <Text style={[styles.match, { color: tint, backgroundColor: alpha(tint, 0.14) }]}>{text.slice(i, i + query.length)}</Text>      {text.slice(i + query.length)}    </Text>  );} /** Helper / error line that fades and slides in; errors get an icon and are announced. */function FieldMessage({ text, error, style }: { text: string; error: boolean; style?: StyleProp<TextStyle> }) {  const { colors, font } = useKit();  const enter = useRef(new Animated.Value(0)).current; // native  useEffect(() => {    enter.setValue(0);    Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();  }, [text, error, enter]);  const color = error ? colors.danger : colors.mutedForeground;  return (    <Animated.View      accessibilityLiveRegion="polite"      style={[        styles.message,        { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] },      ]}    >      {error ? <Icon name="alert" size={14} color={color} /> : null}      <Text style={[styles.helper, font.family ? { fontFamily: font.family } : null, { color }, style, error && { color }]}>{text}</Text>    </Animated.View>  );} function Chevron({ open, color, size, right }: { open: boolean; color: string; size: number; right: number }) {  const turn = useRef(new Animated.Value(open ? 1 : 0)).current;  useEffect(() => {    Animated.spring(turn, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();  }, [open, turn]);  return (    <Animated.View      pointerEvents="none"      style={[        styles.chevronWrap,        { right, width: size + 4, height: size + 4, transform: [{ rotate: turn.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] }) }] },      ]}    >      <Icon name="chevron" size={size} color={color} />    </Animated.View>  );} function Icon({ name, size, color, strokeWidth = 1.8 }: { name: 'chevron' | 'check' | 'search' | 'x' | 'alert'; size: number; color: string; strokeWidth?: number }) {  const p = { stroke: color, strokeWidth, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };  return (    <Svg width={size} height={size} viewBox="0 0 24 24">      {name === 'chevron' ? <Path d="m6 9 6 6 6-6" {...p} /> : null}      {name === 'check' ? <Path d="m5 12.5 4.5 4.5L19 7.5" {...p} /> : null}      {name === 'x' ? <Path d="M6 6l12 12M18 6 6 18" {...p} /> : null}      {name === 'search' ? (        <>          <Circle cx={11} cy={11} r={6.5} {...p} />          <Path d="m20 20-4.2-4.2" {...p} />        </>      ) : null}      {name === 'alert' ? (        <>          <Circle cx={12} cy={12} r={9} {...p} />          <Path d="M12 7.5v5.5M12 16.5v.01" {...p} strokeWidth={2.2} />        </>      ) : null}    </Svg>  );} const styles = StyleSheet.create({  field: { borderWidth: 1.5, justifyContent: 'center' },  halo: { position: 'absolute', top: -5, left: -5, right: -5, bottom: -5, borderWidth: 3.5 },  icon: { position: 'absolute', top: 0, bottom: 0, justifyContent: 'center' },  chips: { flexDirection: 'row', alignItems: 'center', overflow: 'hidden' },  chipSlot: { flexDirection: 'row', flexShrink: 1, minWidth: 0 },  chipPop: { flexDirection: 'row', flexShrink: 1, minWidth: 0 },  chip: { flexDirection: 'row', alignItems: 'center', gap: 4, maxWidth: 150, flexShrink: 1 },  more: { gap: 0, paddingHorizontal: 9, flexShrink: 0 },  chipIcon: { overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },  chipText: { fontWeight: '400', flexShrink: 1 },  tabular: { fontVariant: ['tabular-nums'], fontWeight: '400' },  roll: { overflow: 'hidden' },  rollPrev: { position: 'absolute', left: 0, top: 0 },  badge: { flexDirection: 'row', alignItems: 'center', height: 24, paddingHorizontal: 9 },  badgeText: { fontSize: 12.5, fontWeight: '400' },  badgeSuffix: { marginLeft: 3 },  chipX: { alignItems: 'center', justifyContent: 'center' },  labelWrap: { position: 'absolute', top: 0, justifyContent: 'center', alignItems: 'flex-start', transformOrigin: 'left center' },  label: { fontWeight: '400', paddingHorizontal: 5, marginLeft: -5 },  topLabel: { fontWeight: '400', marginBottom: 7, marginHorizontal: 1, letterSpacing: 0.01 },  message: { flexDirection: 'row', alignItems: 'flex-start', gap: 5, marginTop: 7, marginLeft: 2 },  helper: { flex: 1, fontSize: 12.5, lineHeight: 17 },  chevronWrap: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },  sheetHost: { flex: 1, justifyContent: 'flex-end' },  centered: { justifyContent: 'center', alignItems: 'center' },  sheet: {    paddingHorizontal: 12,    borderWidth: StyleSheet.hairlineWidth,    shadowColor: '#000',    shadowOpacity: 0.18,    shadowRadius: 24,    shadowOffset: { width: 0, height: -4 },    elevation: 16,  },  sheetHeader: { alignItems: 'center', paddingTop: 10, paddingBottom: 12 },  grabber: { width: 40, height: 5, borderRadius: 3, marginBottom: 12 },  title: { fontSize: 17, fontWeight: '400', letterSpacing: -0.2, marginTop: 4 },  subtitleRow: { height: 26, marginTop: 4, alignItems: 'center', justifyContent: 'center' },  subtitle: { fontSize: 13 },  search: { height: 44, marginBottom: 8, marginHorizontal: 4, flexDirection: 'row', alignItems: 'center', paddingLeft: 12, paddingRight: 10, gap: 8 },  searchDense: {    height: SEARCH_H,    flexDirection: 'row',    alignItems: 'center',    paddingLeft: 13,    paddingRight: 11,    gap: 8,    borderBottomWidth: StyleSheet.hairlineWidth,  },  searchInput: { flex: 1, height: 44, paddingVertical: 0 },  clear: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },  scroll: { flexGrow: 0, flexShrink: 1 },  listContent: { paddingBottom: 6 },  listDense: { padding: PANEL_PAD },  sep: { height: StyleSheet.hairlineWidth, marginVertical: 4, marginHorizontal: -PANEL_PAD },  groupTitle: { fontSize: 12, fontWeight: '400', letterSpacing: 0.2, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6 },  groupDense: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 4 },  empty: { alignItems: 'center', paddingVertical: 24, paddingHorizontal: 16 },  emptyDense: { alignItems: 'center', paddingVertical: 18, paddingHorizontal: 12 },  emptyIcon: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: 10 },  emptyIconDense: { width: 34, height: 34, borderRadius: 17, marginBottom: 8 },  emptyTitle: { fontWeight: '400', textAlign: 'center' },  emptyText: { fontSize: 13, marginTop: 3, textAlign: 'center' },  footer: { flexDirection: 'row', gap: 8, paddingTop: 10, paddingHorizontal: 4, borderTopWidth: StyleSheet.hairlineWidth },  footBtn: { height: 44, paddingHorizontal: 18, alignItems: 'center', justifyContent: 'center' },  footMain: { flex: 1 },  footText: { fontSize: 15, fontWeight: '400' },  footStrong: { fontWeight: '400' },  footDense: {    height: FOOT_H,    flexDirection: 'row',    alignItems: 'center',    paddingHorizontal: 8,    gap: 8,    borderTopWidth: StyleSheet.hairlineWidth,  },  footGhost: { height: 32, paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center' },  footSolid: { height: 32, paddingHorizontal: 16, alignItems: 'center', justifyContent: 'center' },  footMid: { flex: 1, alignItems: 'center', justifyContent: 'center' },  footDenseText: { fontSize: 14, fontWeight: '400' },  dropdown: {    position: 'absolute',    borderWidth: StyleSheet.hairlineWidth,    shadowColor: '#000',    shadowRadius: 28,    shadowOffset: { width: 0, height: 10 },    elevation: 10,    overflow: 'hidden',  },  row: { minHeight: 52, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, gap: 12 },  rowDense: { minHeight: 42, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, gap: 10 },  rowIcon: { minWidth: 24, alignItems: 'center' },  rowText: { flex: 1, paddingVertical: 8 },  rowLabel: { fontWeight: '400' },  rowLabelOn: { fontWeight: '400' },  rowDesc: { marginTop: 1, fontWeight: '400' },  match: { fontWeight: '400' },  checkWrap: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center' },  box: { borderWidth: 1.6, alignItems: 'center', justifyContent: 'center' },  boxFill: { position: 'absolute', top: -1.6, left: -1.6, right: -1.6, bottom: -1.6, alignItems: 'center', justifyContent: 'center' },});