{"slug":"multi-select","name":"Multi Select","description":"Searchable options that become removable chips.","group":"Forms","detail_url":"https://beui.dev/native/r/multi-select","page_url":"https://beui.dev/react-native/multi-select","expo_go_url":"exp://u.expo.dev/93a3a95c-6dc2-4adb-b57c-efbecf859f25/--/multi-select?runtime-version=exposdk%3A57.0.0&channel-name=production","source_url":"https://github.com/starc007/beui-native/tree/main/src/components/multi-select","libraries":["react","react-native","react-native-reanimated","react-native-worklets","uniwind"],"install":["npx expo install react-native-reanimated react-native-worklets","npm install uniwind tailwindcss"],"setup":["Quickest: run `npx beui-native add <slug>` from the app's root. It writes the files below and installs the libraries.","Copy the files into the app and keep their folder layout: components import the shared files by relative path.","Add the theme tokens once, imported after Tailwind and Uniwind in the CSS file Uniwind reads.","Wrap the app in GestureHandlerRootView if it uses react-native-gesture-handler and isn't already.","Works in Expo and in bare React Native. Every component honors the system Reduce Motion setting."],"files":[{"path":"src/components/multi-select/context.tsx","content":"import {\n  createContext,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useContext,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { type TextInput, View, type ViewProps } from \"react-native\";\n\nexport type Anchor = { x: number; y: number; width: number; height: number };\n\nimport { useReducedMotionPreference } from \"../../hooks/use-reduced-motion\";\n\nexport type MultiSelectFilter = (\n  value: string,\n  query: string,\n  keywords: string[],\n) => boolean;\n\n/** Matches when the query's characters appear in order, like the web version. */\nconst defaultFilter: MultiSelectFilter = (value, query, keywords) => {\n  const needle = query.trim().toLocaleLowerCase();\n  if (!needle) return true;\n  const haystack = [value, ...keywords].join(\" \").toLocaleLowerCase();\n  let at = 0;\n  for (const character of haystack) {\n    if (character === needle[at]) at += 1;\n    if (at === needle.length) return true;\n  }\n  return false;\n};\n\nexport type RegisteredItem = {\n  value: string;\n  label: string;\n  keywords: string[];\n  disabled: boolean;\n};\n\nexport type MultiSelectContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  values: string[];\n  toggle: (value: string) => void;\n  remove: (value: string) => void;\n  query: string;\n  setQuery: (query: string) => void;\n  register: (item: RegisteredItem) => void;\n  unregister: (value: string) => void;\n  labelFor: (value: string) => string;\n  isVisible: (value: string) => boolean;\n  visibleCount: number;\n  /** The first visible option that can be picked, for the return key. */\n  firstMatch: string | null;\n  disabled: boolean;\n  reduce: boolean;\n  inputRef: RefObject<TextInput | null>;\n  triggerRef: RefObject<View | null>;\n  /** The trigger's frame in the window, measured as the panel opens. */\n  anchor: Anchor | null;\n  /** Measures the trigger again, such as after chips change its height. */\n  measure: () => void;\n};\n\nconst MultiSelectContext = createContext<MultiSelectContextValue | null>(null);\n\n/**\n * True inside the hidden copy of the options that MultiSelectContent keeps\n * mounted, so chips know every label before the panel first opens.\n */\nexport const RegistryContext = createContext(false);\n\n/** Lets a group hide itself while a query leaves none of its options. */\nexport const MultiSelectGroupContext = createContext<\n  ((value: string, visible: boolean) => void) | null\n>(null);\n\nexport function useMultiSelectContext(component: string) {\n  const context = useContext(MultiSelectContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <MultiSelect>`);\n  }\n  return context;\n}\n\nexport type MultiSelectProps = Omit<ViewProps, \"children\"> & {\n  children: ReactNode;\n  /** Controlled selection. Omit it and use `defaultValue` for uncontrolled use. */\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n  /** Decides which options a query shows. Defaults to an in-order character match. */\n  filter?: MultiSelectFilter;\n  disabled?: boolean;\n  /** Moves without springing, independently of the system preference. */\n  disableAnimation?: boolean;\n  className?: string;\n};\n\n/**\n * Picks several options from a searchable list. Compose MultiSelectTrigger\n * (with MultiSelectValue) and MultiSelectContent (with MultiSelectInput and\n * MultiSelectList) inside.\n */\nexport function MultiSelect({\n  children,\n  value: controlledValue,\n  defaultValue = [],\n  onValueChange,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  query: controlledQuery,\n  defaultQuery = \"\",\n  onQueryChange,\n  filter = defaultFilter,\n  disabled = false,\n  disableAnimation = false,\n  className = \"\",\n  ...props\n}: MultiSelectProps) {\n  const reducedMotion = useReducedMotionPreference();\n  const reduce = reducedMotion || disableAnimation;\n  const inputRef = useRef<TextInput>(null);\n  const triggerRef = useRef<View>(null);\n  const [anchor, setAnchor] = useState<Anchor | null>(null);\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internalQuery, setInternalQuery] = useState(defaultQuery);\n  const [items, setItems] = useState<Map<string, RegisteredItem>>(new Map());\n\n  const values = controlledValue ?? internalValue;\n  const open = controlledOpen ?? internalOpen;\n  const query = controlledQuery ?? internalQuery;\n  const valueControlled = controlledValue !== undefined;\n  const openControlled = controlledOpen !== undefined;\n  const queryControlled = controlledQuery !== undefined;\n\n  const setQuery = useCallback(\n    (next: string) => {\n      if (!queryControlled) setInternalQuery(next);\n      onQueryChange?.(next);\n    },\n    [onQueryChange, queryControlled],\n  );\n\n  const measure = useCallback(() => {\n    triggerRef.current?.measureInWindow((x, y, width, height) => {\n      setAnchor((current) =>\n        current?.x === x &&\n        current.y === y &&\n        current.width === width &&\n        current.height === height\n          ? current\n          : { x, y, width, height },\n      );\n    });\n  }, []);\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (disabled && next) return;\n      if (next === open) return;\n      if (next) {\n        // Find the trigger on screen before the panel appears beside it.\n        triggerRef.current?.measureInWindow((x, y, width, height) => {\n          setAnchor({ x, y, width, height });\n          if (!openControlled) setInternalOpen(true);\n          onOpenChange?.(true);\n        });\n        return;\n      }\n      if (!openControlled) setInternalOpen(false);\n      onOpenChange?.(false);\n      setQuery(\"\");\n    },\n    [disabled, onOpenChange, open, openControlled, setQuery],\n  );\n\n  const register = useCallback((item: RegisteredItem) => {\n    setItems((current) => {\n      const existing = current.get(item.value);\n      if (\n        existing?.label === item.label &&\n        existing.disabled === item.disabled &&\n        existing.keywords.join(\"\\u0000\") === item.keywords.join(\"\\u0000\")\n      ) {\n        return current;\n      }\n      const next = new Map(current);\n      next.set(item.value, item);\n      return next;\n    });\n  }, []);\n\n  const unregister = useCallback((value: string) => {\n    setItems((current) => {\n      if (!current.has(value)) return current;\n      const next = new Map(current);\n      next.delete(value);\n      return next;\n    });\n  }, []);\n\n  // While the panel folds closed, it keeps filtering by the query it was open\n  // with, so its rows don't jump back to the full list mid-fold.\n  const [openQuery, setOpenQuery] = useState(query);\n  if (open && openQuery !== query) setOpenQuery(query);\n  const listQuery = open ? query : openQuery;\n\n  const visible = useMemo(\n    () =>\n      Array.from(items.values()).filter((item) =>\n        filter(item.value, listQuery, [item.label, ...item.keywords]),\n      ),\n    [filter, items, listQuery],\n  );\n  const visibleValues = useMemo(\n    () => new Set(visible.map((item) => item.value)),\n    [visible],\n  );\n\n  const commit = useCallback(\n    (next: string[]) => {\n      if (!valueControlled) setInternalValue(next);\n      onValueChange?.(next);\n    },\n    [onValueChange, valueControlled],\n  );\n\n  const toggle = useCallback(\n    (value: string) => {\n      if (items.get(value)?.disabled) return;\n      commit(\n        values.includes(value)\n          ? values.filter((item) => item !== value)\n          : [...values, value],\n      );\n      setQuery(\"\");\n    },\n    [commit, items, setQuery, values],\n  );\n\n  const remove = useCallback(\n    (value: string) => {\n      if (!values.includes(value)) return;\n      commit(values.filter((item) => item !== value));\n    },\n    [commit, values],\n  );\n\n  const context = useMemo<MultiSelectContextValue>(\n    () => ({\n      open,\n      setOpen,\n      values,\n      toggle,\n      remove,\n      query,\n      setQuery,\n      register,\n      unregister,\n      labelFor: (value) => items.get(value)?.label ?? value,\n      isVisible: (value) => !listQuery.trim() || visibleValues.has(value),\n      visibleCount: visible.length,\n      firstMatch: visible.find((item) => !item.disabled)?.value ?? null,\n      disabled,\n      reduce,\n      inputRef,\n      triggerRef,\n      anchor,\n      measure,\n    }),\n    [\n      anchor,\n      disabled,\n      items,\n      listQuery,\n      measure,\n      open,\n      query,\n      reduce,\n      register,\n      remove,\n      setOpen,\n      setQuery,\n      toggle,\n      unregister,\n      values,\n      visible,\n      visibleValues,\n    ],\n  );\n\n  return (\n    <MultiSelectContext.Provider value={context}>\n      <View {...props} className={`w-full ${className}`}>\n        {children}\n      </View>\n    </MultiSelectContext.Provider>\n  );\n}\n","type":"component"},{"path":"src/components/multi-select/index.ts","content":"export type { MultiSelectFilter, MultiSelectProps } from \"./context\";\nexport { MultiSelect } from \"./context\";\nexport type {\n  MultiSelectContentProps,\n  MultiSelectGroupProps,\n  MultiSelectItemProps,\n  MultiSelectListProps,\n} from \"./list\";\nexport {\n  MultiSelectContent,\n  MultiSelectEmpty,\n  MultiSelectGroup,\n  MultiSelectItem,\n  MultiSelectLabel,\n  MultiSelectList,\n  MultiSelectSeparator,\n} from \"./list\";\nexport type {\n  MultiSelectInputProps,\n  MultiSelectTriggerProps,\n  MultiSelectValueProps,\n} from \"./trigger\";\nexport {\n  MultiSelectInput,\n  MultiSelectTrigger,\n  MultiSelectValue,\n} from \"./trigger\";\n","type":"component"},{"path":"src/components/multi-select/list.tsx","content":"import {\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport {\n  Keyboard,\n  Modal,\n  Platform,\n  Pressable,\n  ScrollView,\n  StyleSheet,\n  Text,\n  useWindowDimensions,\n  View,\n} from \"react-native\";\nimport Animated, {\n  cancelAnimation,\n  ReduceMotion,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n  withTiming,\n} from \"react-native-reanimated\";\nimport { scheduleOnRN } from \"react-native-worklets\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { DrawnPath, type PathSegment } from \"../../primitives/drawn-path\";\nimport { EASE_OUT } from \"../../styles/motion\";\nimport {\n  MultiSelectGroupContext,\n  RegistryContext,\n  useMultiSelectContext,\n} from \"./context\";\n\n// The web version's morph: the panel grows out of the field and pulls away\n// from it on a spring with a light bounce, then folds back quickly.\nconst MORPH = {\n  duration: 500,\n  dampingRatio: 0.78,\n  reduceMotion: ReduceMotion.System,\n};\nconst FOLD = { duration: 200, easing: EASE_OUT };\nconst FADE = { duration: 150 };\nconst CHECK_FADE = { duration: 140, easing: EASE_OUT };\n// How far the open panel sits from the field.\nconst GAP = 6;\n// Space kept from the window's bottom edge, or the keyboard, and from its top.\nconst EDGE = 16;\nconst TOP_EDGE = 56;\n// With less room than this below the field, the panel opens above it if\n// there's more room there.\nconst MIN_BELOW = 220;\nconst MAX_LIST = 256;\nconst CHECK: PathSegment[] = [\n  [20, 6, 9, 17],\n  [9, 17, 4, 12],\n];\n\n/** The on-screen keyboard's height while `active`, or 0. */\nfunction useKeyboardHeight(active: boolean) {\n  const [height, setHeight] = useState(0);\n  useEffect(() => {\n    if (!active) return;\n    const ios = Platform.OS === \"ios\";\n    const show = Keyboard.addListener(\n      ios ? \"keyboardWillShow\" : \"keyboardDidShow\",\n      (event) => setHeight(event.endCoordinates.height),\n    );\n    const hide = Keyboard.addListener(\n      ios ? \"keyboardWillHide\" : \"keyboardDidHide\",\n      () => setHeight(0),\n    );\n    // React Native Web has no keyboard metrics; it reports no keyboard.\n    if (typeof Keyboard.metrics === \"function\") {\n      setHeight(Keyboard.metrics()?.height ?? 0);\n    }\n    return () => {\n      show.remove();\n      hide.remove();\n    };\n  }, [active]);\n  return height;\n}\n\nexport type MultiSelectContentProps = {\n  children: ReactNode;\n  className?: string;\n};\n\n/**\n * The panel. It floats over the page beside the field: below it, unless the\n * room there, keyboard included, is tight and there's more above. A tap\n * outside, or Android's back button, closes it.\n */\nexport function MultiSelectContent({\n  children,\n  className = \"\",\n}: MultiSelectContentProps) {\n  const context = useMultiSelectContext(\"MultiSelectContent\");\n  const { open, anchor, reduce } = context;\n  const window = useWindowDimensions();\n  const keyboard = useKeyboardHeight(open);\n  // The modal stays up while the panel folds away.\n  const [present, setPresent] = useState(open);\n  if (open && !present) setPresent(true);\n  const [height, setHeight] = useState(0);\n  const progress = useSharedValue(0);\n\n  const hide = useCallback(() => setPresent(false), []);\n\n  useEffect(() => {\n    if (!present) return;\n    cancelAnimation(progress);\n    if (open) {\n      progress.value = reduce ? withTiming(1, FADE) : withSpring(1, MORPH);\n    } else {\n      progress.value = withTiming(0, reduce ? FADE : FOLD, (done) => {\n        if (done) scheduleOnRN(hide);\n      });\n    }\n  }, [hide, open, present, progress, reduce]);\n\n  const below = anchor\n    ? window.height - keyboard - (anchor.y + anchor.height) - GAP - EDGE\n    : 0;\n  const above = anchor ? anchor.y - GAP - TOP_EDGE : 0;\n  const isTop = below < MIN_BELOW && above > below;\n  const room = Math.max(120, isTop ? above : below);\n\n  const panelStyle = useAnimatedStyle(() => {\n    const p = progress.value;\n    return {\n      // A spring past 1 grows the panel a little past its size: the bounce.\n      height: reduce ? height : height * Math.max(0, p),\n      opacity: Math.min(1, Math.max(0, p * 3)),\n      transform: [\n        { translateY: (isTop ? -GAP : GAP) * Math.min(1, Math.max(0, p)) },\n      ],\n    };\n  }, [height, isTop, reduce]);\n\n  const position =\n    anchor === null\n      ? null\n      : isTop\n        ? {\n            left: anchor.x,\n            width: anchor.width,\n            bottom: window.height - anchor.y,\n          }\n        : {\n            left: anchor.x,\n            width: anchor.width,\n            top: anchor.y + anchor.height,\n          };\n\n  return (\n    <>\n      {/* A hidden copy, so chips know every label before the first open. */}\n      <View style={styles.registry} accessibilityElementsHidden>\n        <RegistryContext.Provider value={true}>\n          {children}\n        </RegistryContext.Provider>\n      </View>\n\n      <Modal\n        transparent\n        visible={present}\n        animationType=\"none\"\n        statusBarTranslucent\n        navigationBarTranslucent\n        onRequestClose={() => context.setOpen(false)}\n      >\n        <Pressable\n          accessibilityLabel=\"Close\"\n          accessibilityRole=\"button\"\n          style={StyleSheet.absoluteFill}\n          onPress={() => context.setOpen(false)}\n        />\n        {position ? (\n          <Animated.View\n            accessibilityViewIsModal\n            className={`overflow-hidden rounded-xl border border-border bg-background ${className}`}\n            style={[styles.panel, position, panelStyle]}\n          >\n            {/* Pinned to the edge nearest the field and measured at its own\n                height, so the panel uncovers it from there. */}\n            <View\n              style={[\n                styles.content,\n                isTop ? styles.pinBottom : styles.pinTop,\n                { maxHeight: room - 2 },\n              ]}\n              onLayout={({ nativeEvent }) =>\n                // The panel's 1-point border on each side.\n                setHeight(nativeEvent.layout.height + 2)\n              }\n            >\n              {children}\n            </View>\n          </Animated.View>\n        ) : null}\n      </Modal>\n    </>\n  );\n}\n\nexport type MultiSelectListProps = {\n  children: ReactNode;\n  accessibilityLabel?: string;\n  className?: string;\n};\n\n/** Scrolls past 256 points, or sooner when the room beside the field is tight. */\nexport function MultiSelectList({\n  children,\n  accessibilityLabel = \"Options\",\n  className = \"\",\n}: MultiSelectListProps) {\n  const registering = useContext(RegistryContext);\n  if (registering) return <>{children}</>;\n  return (\n    <ScrollView\n      accessibilityLabel={accessibilityLabel}\n      keyboardShouldPersistTaps=\"handled\"\n      style={styles.list}\n      contentContainerClassName={`p-1.5 ${className}`}\n    >\n      {children}\n    </ScrollView>\n  );\n}\n\nexport type MultiSelectGroupProps = {\n  children: ReactNode;\n  className?: string;\n};\n\n/** Hides itself while a query leaves none of its options. */\nexport function MultiSelectGroup({\n  children,\n  className = \"\",\n}: MultiSelectGroupProps) {\n  const registering = useContext(RegistryContext);\n  const [shown, setShown] = useState<Map<string, boolean>>(new Map());\n  const report = useCallback((value: string, visible: boolean) => {\n    setShown((current) => {\n      if (current.get(value) === visible) return current;\n      const next = new Map(current);\n      next.set(value, visible);\n      return next;\n    });\n  }, []);\n  const empty = useMemo(\n    () => shown.size > 0 && ![...shown.values()].some(Boolean),\n    [shown],\n  );\n\n  if (registering) return <>{children}</>;\n\n  return (\n    <MultiSelectGroupContext.Provider value={report}>\n      <View\n        style={empty ? styles.hidden : null}\n        className={`py-0.5 ${className}`}\n      >\n        {children}\n      </View>\n    </MultiSelectGroupContext.Provider>\n  );\n}\n\nexport function MultiSelectLabel({\n  children,\n  className = \"\",\n}: {\n  children: ReactNode;\n  className?: string;\n}) {\n  const registering = useContext(RegistryContext);\n  if (registering) return null;\n  return (\n    <Text\n      accessibilityRole=\"header\"\n      className={`px-2 py-1.5 text-[11px] font-medium uppercase tracking-widest text-muted ${className}`}\n    >\n      {children}\n    </Text>\n  );\n}\n\nexport type MultiSelectItemProps = {\n  value: string;\n  children: ReactNode;\n  /** The label for chips, search and screen readers when children aren't a string. */\n  textValue?: string;\n  /** Extra words a query can match. */\n  keywords?: string[];\n  disabled?: boolean;\n  onSelect?: (value: string) => void;\n  className?: string;\n};\n\nexport function MultiSelectItem(props: MultiSelectItemProps) {\n  const registering = useContext(RegistryContext);\n  return registering ? <RegisteredItem {...props} /> : <ItemRow {...props} />;\n}\n\n/** The hidden copy's half: tells the root about the option. */\nfunction RegisteredItem({\n  value,\n  children,\n  textValue,\n  keywords = [],\n  disabled = false,\n}: MultiSelectItemProps) {\n  const { register, unregister } = useMultiSelectContext(\"MultiSelectItem\");\n  const label = textValue ?? (typeof children === \"string\" ? children : value);\n  const keywordKey = keywords.join(\"\\u0000\");\n  const words = useMemo(\n    () => (keywordKey ? keywordKey.split(\"\\u0000\") : []),\n    [keywordKey],\n  );\n  useEffect(() => {\n    register({ value, label, keywords: words, disabled });\n  }, [disabled, label, register, value, words]);\n  useEffect(() => () => unregister(value), [unregister, value]);\n  return null;\n}\n\n/** The panel's half: a checkbox row. */\nfunction ItemRow({\n  value,\n  children,\n  textValue,\n  disabled = false,\n  onSelect,\n  className = \"\",\n}: MultiSelectItemProps) {\n  const context = useMultiSelectContext(\"MultiSelectItem\");\n  const report = useContext(MultiSelectGroupContext);\n  const { color: foreground } = useResolveClassNames(\"text-foreground\");\n  const label = textValue ?? (typeof children === \"string\" ? children : value);\n  const selected = context.values.includes(value);\n  const visible = context.isVisible(value);\n\n  useEffect(() => {\n    report?.(value, visible);\n  }, [report, value, visible]);\n\n  const mark = useSharedValue(selected ? 1 : 0);\n  const drawn = useSharedValue(1);\n  useEffect(() => {\n    const target = selected ? 1 : 0;\n    mark.value = context.reduce ? target : withTiming(target, CHECK_FADE);\n  }, [context.reduce, mark, selected]);\n  const markStyle = useAnimatedStyle(() => ({\n    opacity: mark.value,\n    transform: [{ scale: 0.82 + 0.18 * mark.value }],\n  }));\n\n  if (!visible) return null;\n\n  return (\n    <Pressable\n      accessibilityRole=\"checkbox\"\n      accessibilityLabel={label}\n      // The aria props reach every platform, including React Native Web,\n      // which ignores accessibilityState.checked.\n      aria-checked={selected}\n      aria-disabled={disabled}\n      disabled={disabled}\n      onPress={() => {\n        onSelect?.(value);\n        context.toggle(value);\n      }}\n      className={`min-h-11 flex-row items-center gap-2 rounded-lg px-2 py-2 active:bg-surface ${disabled ? \"opacity-45\" : \"\"} ${className}`}\n    >\n      <View className=\"min-w-0 flex-1\">\n        {typeof children === \"string\" ? (\n          <Text\n            className={`text-sm ${selected ? \"text-foreground\" : \"text-muted\"}`}\n          >\n            {children}\n          </Text>\n        ) : (\n          children\n        )}\n      </View>\n      <Animated.View style={[styles.check, markStyle]}>\n        <DrawnPath\n          segments={CHECK}\n          size={16}\n          strokeWidth={2.2}\n          color={foreground}\n          progress={drawn}\n        />\n      </Animated.View>\n    </Pressable>\n  );\n}\n\nexport function MultiSelectEmpty({\n  children = \"No options found.\",\n  className = \"\",\n}: {\n  children?: ReactNode;\n  className?: string;\n}) {\n  const registering = useContext(RegistryContext);\n  const context = useMultiSelectContext(\"MultiSelectEmpty\");\n  if (registering || context.visibleCount > 0) return null;\n  return (\n    <Text\n      accessibilityLiveRegion=\"polite\"\n      className={`px-3 py-8 text-center text-sm text-muted ${className}`}\n    >\n      {children}\n    </Text>\n  );\n}\n\nexport function MultiSelectSeparator({\n  className = \"\",\n}: {\n  className?: string;\n}) {\n  const registering = useContext(RegistryContext);\n  if (registering) return null;\n  return (\n    <View\n      accessibilityElementsHidden\n      importantForAccessibility=\"no\"\n      className={`-mx-1 my-1 h-px bg-border ${className}`}\n    />\n  );\n}\n\nconst styles = StyleSheet.create({\n  registry: { display: \"none\" },\n  // Lifts the floating panel off the page it covers.\n  panel: {\n    position: \"absolute\",\n    boxShadow: \"0 10px 30px -8px rgba(0, 0, 0, 0.18)\",\n  },\n  content: { position: \"absolute\", left: 0, right: 0 },\n  pinTop: { top: 0 },\n  pinBottom: { bottom: 0 },\n  list: { flexGrow: 0, flexShrink: 1, maxHeight: MAX_LIST },\n  hidden: { display: \"none\" },\n  check: {\n    width: 20,\n    height: 20,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n});\n","type":"component"},{"path":"src/components/multi-select/trigger.tsx","content":"import { type ReactNode, useContext, useEffect } from \"react\";\nimport {\n  Platform,\n  Pressable,\n  StyleSheet,\n  Text,\n  TextInput,\n  type TextInputProps,\n  View,\n} from \"react-native\";\nimport Animated, {\n  Keyframe,\n  LayoutAnimationConfig,\n  LinearTransition,\n  useAnimatedStyle,\n  useSharedValue,\n  withSpring,\n} from \"react-native-reanimated\";\nimport { useResolveClassNames } from \"uniwind\";\nimport { DrawnPath, type PathSegment } from \"../../primitives/drawn-path\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"../../styles/motion\";\nimport { RegistryContext, useMultiSelectContext } from \"./context\";\n\nconst CHEVRON: PathSegment[] = [\n  [6, 9, 12, 15],\n  [12, 15, 18, 9],\n];\nconst REMOVE: PathSegment[] = [\n  [18, 6, 6, 18],\n  [6, 6, 18, 18],\n];\nconst SEARCH: PathSegment[] = [[16, 16, 21, 21]];\n\n// A picked chip rises into place; a removed one shrinks away while the rest\n// spring to their new spots.\nconst CHIP_IN = new Keyframe({\n  0: { opacity: 0, transform: [{ translateY: 6 }, { scale: 0.92 }] },\n  100: {\n    opacity: 1,\n    transform: [{ translateY: 0 }, { scale: 1 }],\n    easing: EASE_OUT,\n  },\n}).duration(220);\nconst CHIP_OUT = new Keyframe({\n  0: { opacity: 1, transform: [{ scale: 1 }] },\n  100: { opacity: 0, transform: [{ scale: 0.86 }], easing: EASE_OUT },\n}).duration(160);\nconst CHIP_MOVE = LinearTransition.springify()\n  .stiffness(SPRING_LAYOUT.stiffness)\n  .damping(SPRING_LAYOUT.damping)\n  .mass(SPRING_LAYOUT.mass);\n\nexport type MultiSelectTriggerProps = {\n  children: ReactNode;\n  /** Names the field for screen readers, such as \"Teams\". */\n  accessibilityLabel?: string;\n  className?: string;\n};\n\n/** The field: picked chips wrap inside it. Tapping it opens the list. */\nexport function MultiSelectTrigger({\n  children,\n  accessibilityLabel,\n  className = \"\",\n}: MultiSelectTriggerProps) {\n  const context = useMultiSelectContext(\"MultiSelectTrigger\");\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const turn = useSharedValue(context.open ? 1 : 0);\n  const drawn = useSharedValue(1);\n  const { measure, open } = context;\n\n  useEffect(() => {\n    const target = context.open ? 1 : 0;\n    turn.value = context.reduce ? target : withSpring(target, SPRING_SWAP);\n  }, [context.open, context.reduce, turn]);\n\n  const chevronStyle = useAnimatedStyle(() => ({\n    transform: [{ rotate: `${turn.value * 180}deg` }],\n  }));\n\n  const summary =\n    context.values.length === 0\n      ? undefined\n      : context.values.map((value) => context.labelFor(value)).join(\", \");\n\n  return (\n    <View\n      ref={context.triggerRef}\n      collapsable={false}\n      // Chips can change the field's height while the panel is open.\n      onLayout={() => {\n        if (open) measure();\n      }}\n      className={`min-h-11 w-full flex-row items-center gap-2 rounded-xl border bg-background py-1.5 pr-1 pl-2.5 ${context.open ? \"border-border-strong\" : \"border-border\"} ${context.disabled ? \"opacity-50\" : \"\"} ${className}`}\n    >\n      {/* Covers the field, so a tap anywhere but a chip's X opens the list. */}\n      <Pressable\n        accessibilityRole=\"button\"\n        accessibilityLabel={accessibilityLabel}\n        accessibilityHint={context.open ? undefined : \"Shows the options\"}\n        accessibilityValue={summary ? { text: summary } : undefined}\n        accessibilityState={{\n          expanded: context.open,\n          disabled: context.disabled,\n        }}\n        disabled={context.disabled}\n        onPress={() => context.setOpen(!context.open)}\n        style={StyleSheet.absoluteFill}\n      />\n      <View\n        pointerEvents=\"box-none\"\n        className=\"min-w-0 flex-1 flex-row flex-wrap items-center gap-1.5\"\n      >\n        {children}\n      </View>\n      <Animated.View\n        pointerEvents=\"none\"\n        style={[styles.chevron, chevronStyle]}\n      >\n        <DrawnPath\n          segments={CHEVRON}\n          size={16}\n          strokeWidth={2}\n          color={muted}\n          progress={drawn}\n        />\n      </Animated.View>\n    </View>\n  );\n}\n\nexport type MultiSelectValueProps = {\n  /** Shown while nothing is picked. */\n  placeholder?: string;\n  /** Renders a chip's label. Defaults to the option's label. */\n  children?: (value: string, label: string) => ReactNode;\n  chipClassName?: string;\n};\n\n/** A removable chip for each picked option. */\nexport function MultiSelectValue({\n  placeholder = \"Select options\",\n  children,\n  chipClassName = \"\",\n}: MultiSelectValueProps) {\n  const context = useMultiSelectContext(\"MultiSelectValue\");\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const drawn = useSharedValue(1);\n\n  if (context.values.length === 0) {\n    return (\n      <Text\n        pointerEvents=\"none\"\n        numberOfLines={1}\n        className=\"py-1 text-sm text-muted\"\n      >\n        {placeholder}\n      </Text>\n    );\n  }\n\n  return (\n    // Chips already picked on the first render appear without animating in.\n    <LayoutAnimationConfig skipEntering>\n      {context.values.map((value) => {\n        const label = context.labelFor(value);\n        return (\n          <Animated.View\n            key={value}\n            // Taps on the label fall through to the field and open the list;\n            // only the X is a button of its own.\n            pointerEvents=\"box-none\"\n            entering={context.reduce ? undefined : CHIP_IN}\n            exiting={context.reduce ? undefined : CHIP_OUT}\n            layout={context.reduce ? undefined : CHIP_MOVE}\n            className={`h-7 max-w-full flex-row items-center gap-1 rounded-lg bg-surface pr-1 pl-2 ${chipClassName}`}\n          >\n            <View pointerEvents=\"none\" className=\"shrink\">\n              {children ? (\n                children(value, label)\n              ) : (\n                <Text\n                  numberOfLines={1}\n                  className=\"text-xs font-medium text-foreground\"\n                >\n                  {label}\n                </Text>\n              )}\n            </View>\n            <Pressable\n              accessibilityRole=\"button\"\n              accessibilityLabel={`Remove ${label}`}\n              disabled={context.disabled}\n              hitSlop={8}\n              onPress={() => context.remove(value)}\n              className=\"size-5 items-center justify-center rounded-md active:bg-foreground/10\"\n            >\n              <DrawnPath\n                segments={REMOVE}\n                size={12}\n                strokeWidth={2.2}\n                color={muted}\n                progress={drawn}\n              />\n            </Pressable>\n          </Animated.View>\n        );\n      })}\n    </LayoutAnimationConfig>\n  );\n}\n\nexport type MultiSelectInputProps = Omit<\n  TextInputProps,\n  \"value\" | \"defaultValue\" | \"onChangeText\"\n> & {\n  onChangeText?: (text: string) => void;\n};\n\n/**\n * A search field for the top of MultiSelectContent. Typing filters the list,\n * return picks the top match, and Backspace in an empty field removes the\n * last chip.\n */\nexport function MultiSelectInput({\n  accessibilityLabel = \"Search options\",\n  placeholder = \"Search…\",\n  onChangeText,\n  onKeyPress,\n  onSubmitEditing,\n  style,\n  ...props\n}: MultiSelectInputProps) {\n  const registering = useContext(RegistryContext);\n  const context = useMultiSelectContext(\"MultiSelectInput\");\n  const { color: foreground } = useResolveClassNames(\"text-foreground\");\n  const { color: muted } = useResolveClassNames(\"text-muted\");\n  const drawn = useSharedValue(1);\n\n  if (registering) return null;\n\n  return (\n    <View className=\"flex-row items-center gap-2 border-b border-border px-3\">\n      <View pointerEvents=\"none\" style={styles.searchIcon}>\n        <View style={[styles.lens, { borderColor: muted }]} />\n        <View style={StyleSheet.absoluteFill}>\n          <DrawnPath\n            segments={SEARCH}\n            size={14}\n            strokeWidth={2.4}\n            color={muted}\n            progress={drawn}\n          />\n        </View>\n      </View>\n      <TextInput\n        {...props}\n        ref={context.inputRef}\n        accessibilityLabel={accessibilityLabel}\n        editable={!context.disabled}\n        value={context.query}\n        placeholder={placeholder}\n        placeholderTextColor={muted}\n        autoCapitalize=\"none\"\n        autoCorrect={false}\n        returnKeyType=\"done\"\n        submitBehavior=\"submit\"\n        onChangeText={(text) => {\n          context.setQuery(text);\n          onChangeText?.(text);\n        }}\n        onKeyPress={(event) => {\n          onKeyPress?.(event);\n          if (\n            event.nativeEvent.key === \"Backspace\" &&\n            !context.query &&\n            context.values.length > 0\n          ) {\n            context.remove(context.values[context.values.length - 1] ?? \"\");\n          }\n        }}\n        onSubmitEditing={(event) => {\n          onSubmitEditing?.(event);\n          if (context.query && context.firstMatch) {\n            context.toggle(context.firstMatch);\n          }\n        }}\n        style={[styles.input, { color: foreground }, WEB_NO_OUTLINE, style]}\n      />\n    </View>\n  );\n}\n\n// Web draws a focus outline around inputs; the panel already frames this one.\n// React Native's types don't list \"none\", but React Native Web passes it on.\nconst WEB_NO_OUTLINE =\n  Platform.OS === \"web\" ? { outlineStyle: \"none\" as \"solid\" } : null;\n\nconst styles = StyleSheet.create({\n  chevron: {\n    width: 32,\n    height: 32,\n    alignItems: \"center\",\n    justifyContent: \"center\",\n  },\n  searchIcon: { width: 14, height: 14 },\n  lens: {\n    position: \"absolute\",\n    top: 0,\n    left: 0,\n    width: 10,\n    height: 10,\n    borderRadius: 5,\n    borderWidth: 1.4,\n  },\n  input: {\n    flex: 1,\n    height: 44,\n    fontSize: 16,\n    paddingVertical: 0,\n    textAlignVertical: \"center\",\n    includeFontPadding: false,\n  },\n});\n","type":"component"},{"path":"src/hooks/use-reduced-motion.ts","content":"import { useEffect, useState } from \"react\";\nimport { AccessibilityInfo } from \"react-native\";\nimport { useReducedMotion } from \"react-native-reanimated\";\n\n/** Reanimated supplies a synchronous initial value; native events keep it current. */\nexport function useReducedMotionPreference() {\n  const initialValue = useReducedMotion();\n  const [reducedMotion, setReducedMotion] = useState(initialValue);\n\n  useEffect(() => {\n    let active = true;\n    let receivedEvent = false;\n    const subscription = AccessibilityInfo.addEventListener(\n      \"reduceMotionChanged\",\n      (value) => {\n        receivedEvent = true;\n        setReducedMotion(value);\n      },\n    );\n    void AccessibilityInfo.isReduceMotionEnabled().then((value) => {\n      if (active && !receivedEvent) setReducedMotion(value);\n    });\n    return () => {\n      active = false;\n      subscription.remove();\n    };\n  }, []);\n\n  return reducedMotion;\n}\n","type":"shared"},{"path":"src/primitives/drawn-path.tsx","content":"import { type ColorValue, StyleSheet, View } from \"react-native\";\nimport Animated, {\n  interpolate,\n  type SharedValue,\n  useAnimatedStyle,\n} from \"react-native-reanimated\";\n\n/** A straight line in a square viewBox: [x1, y1, x2, y2]. */\nexport type PathSegment = [x1: number, y1: number, x2: number, y2: number];\n\nexport type DrawnPathProps = {\n  /** Straight segments drawn in order, like an SVG polyline. */\n  segments: PathSegment[];\n  /** Rendered width and height in points. */\n  size: number;\n  /** Stroke width in viewBox units. */\n  strokeWidth: number;\n  /** Side of the square viewBox the segments are written in. Defaults to 24. */\n  viewBox?: number;\n  color: ColorValue | undefined;\n  /** 0 draws nothing and 1 draws every segment, like SVG `pathLength`. */\n  progress: SharedValue<number>;\n};\n\nfunction length([x1, y1, x2, y2]: PathSegment) {\n  return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/**\n * Draws a polyline on with rotated Views, so shared components need no SVG\n * library. Round caps extend half a stroke past each end, like SVG's linecap.\n */\nexport function DrawnPath({\n  segments,\n  size,\n  strokeWidth,\n  viewBox = 24,\n  color,\n  progress,\n}: DrawnPathProps) {\n  const unit = size / viewBox;\n  const stroke = strokeWidth * unit;\n  const total = segments.reduce((sum, segment) => sum + length(segment), 0);\n  let start = 0;\n\n  return (\n    <View style={{ width: size, height: size }}>\n      {segments.map((segment) => {\n        const from = start / total;\n        start += length(segment);\n        return (\n          <Stroke\n            key={segment.join()}\n            segment={segment}\n            unit={unit}\n            stroke={stroke}\n            from={from}\n            to={start / total}\n            progress={progress}\n            color={color}\n          />\n        );\n      })}\n    </View>\n  );\n}\n\nfunction Stroke({\n  segment,\n  unit,\n  stroke,\n  from,\n  to,\n  progress,\n  color,\n}: {\n  segment: PathSegment;\n  unit: number;\n  stroke: number;\n  from: number;\n  to: number;\n  progress: SharedValue<number>;\n  color: ColorValue | undefined;\n}) {\n  const [x1, y1, x2, y2] = segment;\n  const full = length(segment) * unit;\n  const angle = Math.atan2(y2 - y1, x2 - x1);\n\n  const animatedStyle = useAnimatedStyle(() => {\n    const drawn = interpolate(progress.value, [from, to], [0, 1], \"clamp\");\n    return { opacity: drawn > 0 ? 1 : 0, width: stroke + full * drawn };\n  });\n\n  return (\n    <Animated.View\n      style={[\n        styles.stroke,\n        {\n          left: x1 * unit - stroke / 2,\n          top: y1 * unit - stroke / 2,\n          height: stroke,\n          borderRadius: stroke / 2,\n          backgroundColor: color,\n          // Rotate around the start point so the stroke grows from it.\n          transformOrigin: [stroke / 2, stroke / 2, 0],\n          transform: [{ rotate: `${angle}rad` }],\n        },\n        animatedStyle,\n      ]}\n    />\n  );\n}\n\nconst styles = StyleSheet.create({\n  stroke: { position: \"absolute\" },\n});\n","type":"shared"},{"path":"src/styles/motion.ts","content":"import { Easing, ReduceMotion } from \"react-native-reanimated\";\n\n// Mirrors beUI's lib/ease.ts so native motion matches the web library.\n\nexport const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);\nexport const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Content swaps: label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Shared-layout glides: pills, indicators and panels moving between positions. */\nexport const SPRING_LAYOUT = {\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\n/** Dragged handles and fills; critically damped so values never rebound off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n  reduceMotion: ReduceMotion.System,\n} as const;\n\nexport const motion = {\n  press: SPRING_PRESS,\n  swap: SPRING_SWAP,\n  layout: SPRING_LAYOUT,\n  glide: SPRING_GLIDE,\n  exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },\n  fade: { duration: 150, reduceMotion: ReduceMotion.System },\n} as const;\n","type":"shared"},{"path":"src/styles/theme.css","content":"/* Matches the default beUI palette in ui-components/lib/themes.ts.\n   Native surface maps to card; muted maps to muted-foreground. */\n@layer theme {\n  :root {\n    @variant light {\n      --color-background: oklch(99% 0 0);\n      --color-surface: oklch(97% 0 0);\n      --color-foreground: oklch(15% 0 0);\n      --color-muted: oklch(50% 0 0);\n      --color-border: oklch(15% 0 0 / 0.06);\n      --color-border-strong: oklch(15% 0 0 / 0.16);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #dc2626;\n      --color-success: #059669;\n      --color-warning: #d97706;\n    }\n  }\n\n  :root {\n    @variant dark {\n      --color-background: #151515;\n      --color-surface: #1c1c1c;\n      --color-foreground: oklch(96% 0 0);\n      --color-muted: oklch(62% 0 0);\n      --color-border: rgb(255 255 255 / 0.05);\n      --color-border-strong: rgb(255 255 255 / 0.14);\n      --color-primary: #0285f7;\n      --color-on-primary: #ffffff;\n      --color-destructive: #f87171;\n      --color-success: #34d399;\n      --color-warning: #fbbf24;\n    }\n  }\n}\n","type":"theme"}]}