@expokit/cli 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/bin/expo-kit.js +1266 -0
  4. package/bin/lib.mjs +343 -0
  5. package/bin/mcp.mjs +345 -0
  6. package/package.json +49 -4
  7. package/registry/_docs/accordion.json +115 -0
  8. package/registry/_docs/action-sheet.json +138 -0
  9. package/registry/_docs/alert.json +155 -0
  10. package/registry/_docs/avatar.json +227 -0
  11. package/registry/_docs/badge.json +148 -0
  12. package/registry/_docs/bottom-nav.json +143 -0
  13. package/registry/_docs/bottom-sheet.json +186 -0
  14. package/registry/_docs/bullet.json +27 -0
  15. package/registry/_docs/button-3d.json +138 -0
  16. package/registry/_docs/button.json +131 -0
  17. package/registry/_docs/calendar.json +198 -0
  18. package/registry/_docs/card-stack.json +124 -0
  19. package/registry/_docs/card.json +131 -0
  20. package/registry/_docs/carousel.json +122 -0
  21. package/registry/_docs/chat.json +184 -0
  22. package/registry/_docs/checkbox.json +109 -0
  23. package/registry/_docs/checkout.json +105 -0
  24. package/registry/_docs/chip-group.json +132 -0
  25. package/registry/_docs/color-swatch.json +82 -0
  26. package/registry/_docs/confetti.json +102 -0
  27. package/registry/_docs/coupon.json +192 -0
  28. package/registry/_docs/cube-pager.json +100 -0
  29. package/registry/_docs/dashboard.json +123 -0
  30. package/registry/_docs/date-picker.json +269 -0
  31. package/registry/_docs/dialog.json +186 -0
  32. package/registry/_docs/drawer.json +199 -0
  33. package/registry/_docs/fab.json +144 -0
  34. package/registry/_docs/flip-card.json +127 -0
  35. package/registry/_docs/floating-input.json +211 -0
  36. package/registry/_docs/gift-card.json +124 -0
  37. package/registry/_docs/glass-button.json +142 -0
  38. package/registry/_docs/glass-nav-bar.json +102 -0
  39. package/registry/_docs/glass-search-bar.json +114 -0
  40. package/registry/_docs/glass-segmented.json +94 -0
  41. package/registry/_docs/glass-sheet.json +136 -0
  42. package/registry/_docs/glass-tab-bar.json +100 -0
  43. package/registry/_docs/glass.json +94 -0
  44. package/registry/_docs/inbox.json +75 -0
  45. package/registry/_docs/isometric-bar.json +75 -0
  46. package/registry/_docs/kit.json +14 -0
  47. package/registry/_docs/kpi-card.json +14 -0
  48. package/registry/_docs/like-button.json +92 -0
  49. package/registry/_docs/liquidglass-tab.json +30 -0
  50. package/registry/_docs/list-item.json +216 -0
  51. package/registry/_docs/loyalty-card.json +124 -0
  52. package/registry/_docs/morph-button.json +128 -0
  53. package/registry/_docs/number-ticker.json +112 -0
  54. package/registry/_docs/offers.json +81 -0
  55. package/registry/_docs/onboarding.json +69 -0
  56. package/registry/_docs/otp-input.json +158 -0
  57. package/registry/_docs/pagination-dots.json +108 -0
  58. package/registry/_docs/parallax-layers.json +104 -0
  59. package/registry/_docs/parallax-scroll.json +210 -0
  60. package/registry/_docs/password-input.json +112 -0
  61. package/registry/_docs/payment-card.json +169 -0
  62. package/registry/_docs/pixel-bar.json +69 -0
  63. package/registry/_docs/polar-area.json +81 -0
  64. package/registry/_docs/profile.json +87 -0
  65. package/registry/_docs/progress-steps.json +116 -0
  66. package/registry/_docs/progress.json +152 -0
  67. package/registry/_docs/radio-group.json +112 -0
  68. package/registry/_docs/rating.json +114 -0
  69. package/registry/_docs/ring-carousel.json +122 -0
  70. package/registry/_docs/scratch-card.json +110 -0
  71. package/registry/_docs/search-bar.json +160 -0
  72. package/registry/_docs/search-results.json +123 -0
  73. package/registry/_docs/segmented-control.json +123 -0
  74. package/registry/_docs/select.json +277 -0
  75. package/registry/_docs/settings.json +75 -0
  76. package/registry/_docs/shimmer-button.json +98 -0
  77. package/registry/_docs/sign-in.json +201 -0
  78. package/registry/_docs/skeleton.json +162 -0
  79. package/registry/_docs/slide-to-confirm.json +108 -0
  80. package/registry/_docs/slider.json +177 -0
  81. package/registry/_docs/spin-wheel.json +138 -0
  82. package/registry/_docs/spinner.json +121 -0
  83. package/registry/_docs/stateful-button.json +184 -0
  84. package/registry/_docs/stepper.json +184 -0
  85. package/registry/_docs/swipe-deck.json +118 -0
  86. package/registry/_docs/swipeable-row.json +95 -0
  87. package/registry/_docs/switch.json +124 -0
  88. package/registry/_docs/tabs.json +129 -0
  89. package/registry/_docs/tag-input.json +148 -0
  90. package/registry/_docs/text-animation.json +141 -0
  91. package/registry/_docs/textarea.json +191 -0
  92. package/registry/_docs/ticket-card.json +194 -0
  93. package/registry/_docs/ticket-shape.json +14 -0
  94. package/registry/_docs/tilt-card.json +157 -0
  95. package/registry/_docs/time-picker.json +269 -0
  96. package/registry/_docs/timeline-chart.json +33 -0
  97. package/registry/_docs/timeline.json +172 -0
  98. package/registry/_docs/toast.json +185 -0
  99. package/registry/_docs/treemap.json +33 -0
  100. package/registry/_docs/waterfall.json +39 -0
  101. package/registry/_docs/wheel-list.json +122 -0
  102. package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
  103. package/registry/accordion/files/src/lib/kit.tsx +219 -0
  104. package/registry/accordion.json +17 -0
  105. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
  106. package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
  107. package/registry/action-sheet.json +18 -0
  108. package/registry/alert/files/src/components/ui/alert.tsx +654 -0
  109. package/registry/alert/files/src/components/ui/button.tsx +274 -0
  110. package/registry/alert/files/src/lib/kit.tsx +219 -0
  111. package/registry/alert.json +20 -0
  112. package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
  113. package/registry/avatar/files/src/lib/kit.tsx +219 -0
  114. package/registry/avatar.json +18 -0
  115. package/registry/badge/files/src/components/ui/badge.tsx +626 -0
  116. package/registry/badge/files/src/lib/kit.tsx +219 -0
  117. package/registry/badge.json +17 -0
  118. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
  119. package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
  120. package/registry/bottom-nav.json +18 -0
  121. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
  122. package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
  123. package/registry/bottom-sheet.json +17 -0
  124. package/registry/bullet.json +14 -0
  125. package/registry/button/files/src/components/ui/button.tsx +274 -0
  126. package/registry/button/files/src/lib/kit.tsx +219 -0
  127. package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
  128. package/registry/button-3d/files/src/lib/kit.tsx +219 -0
  129. package/registry/button-3d.json +18 -0
  130. package/registry/button.json +18 -0
  131. package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
  132. package/registry/calendar/files/src/lib/kit.tsx +219 -0
  133. package/registry/calendar.json +17 -0
  134. package/registry/card/files/src/components/ui/card.tsx +300 -0
  135. package/registry/card/files/src/lib/kit.tsx +219 -0
  136. package/registry/card-stack.json +15 -0
  137. package/registry/card.json +17 -0
  138. package/registry/carousel.json +15 -0
  139. package/registry/chat.json +18 -0
  140. package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
  141. package/registry/checkbox/files/src/lib/kit.tsx +219 -0
  142. package/registry/checkbox.json +17 -0
  143. package/registry/checkout.json +26 -0
  144. package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
  145. package/registry/chip-group/files/src/lib/kit.tsx +219 -0
  146. package/registry/chip-group.json +17 -0
  147. package/registry/color-swatch.json +17 -0
  148. package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
  149. package/registry/confetti/files/src/lib/kit.tsx +219 -0
  150. package/registry/confetti.json +15 -0
  151. package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
  152. package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
  153. package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
  154. package/registry/coupon/files/src/lib/kit.tsx +219 -0
  155. package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
  156. package/registry/coupon.json +23 -0
  157. package/registry/cube-pager.json +15 -0
  158. package/registry/dashboard.json +32 -0
  159. package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
  160. package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
  161. package/registry/date-picker/files/src/lib/kit.tsx +219 -0
  162. package/registry/date-picker.json +19 -0
  163. package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
  164. package/registry/dialog/files/src/lib/kit.tsx +219 -0
  165. package/registry/dialog.json +18 -0
  166. package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
  167. package/registry/drawer/files/src/lib/kit.tsx +219 -0
  168. package/registry/drawer.json +18 -0
  169. package/registry/fab.json +18 -0
  170. package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
  171. package/registry/flip-card/files/src/lib/kit.tsx +219 -0
  172. package/registry/flip-card.json +15 -0
  173. package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
  174. package/registry/floating-input/files/src/lib/kit.tsx +219 -0
  175. package/registry/floating-input.json +17 -0
  176. package/registry/gift-card.json +21 -0
  177. package/registry/glass-button.json +22 -0
  178. package/registry/glass-nav-bar.json +21 -0
  179. package/registry/glass-search-bar.json +21 -0
  180. package/registry/glass-segmented.json +22 -0
  181. package/registry/glass-sheet.json +22 -0
  182. package/registry/glass-tab-bar.json +22 -0
  183. package/registry/glass.json +19 -0
  184. package/registry/inbox.json +30 -0
  185. package/registry/isometric-bar.json +14 -0
  186. package/registry/kit/files/src/lib/kit.tsx +219 -0
  187. package/registry/kit.json +11 -0
  188. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
  189. package/registry/kpi-card.json +14 -0
  190. package/registry/like-button.json +18 -0
  191. package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
  192. package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
  193. package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
  194. package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
  195. package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
  196. package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
  197. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
  198. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
  199. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
  200. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
  201. package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
  202. package/registry/liquidglass-tab.json +23 -0
  203. package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
  204. package/registry/list-item/files/src/lib/kit.tsx +219 -0
  205. package/registry/list-item.json +17 -0
  206. package/registry/loyalty-card.json +23 -0
  207. package/registry/morph-button.json +18 -0
  208. package/registry/number-ticker.json +15 -0
  209. package/registry/offers.json +24 -0
  210. package/registry/onboarding.json +17 -0
  211. package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
  212. package/registry/otp-input/files/src/lib/kit.tsx +219 -0
  213. package/registry/otp-input.json +17 -0
  214. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
  215. package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
  216. package/registry/pagination-dots.json +15 -0
  217. package/registry/parallax-layers.json +15 -0
  218. package/registry/parallax-scroll.json +17 -0
  219. package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
  220. package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
  221. package/registry/password-input/files/src/lib/kit.tsx +219 -0
  222. package/registry/password-input.json +19 -0
  223. package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
  224. package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
  225. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
  226. package/registry/payment-card/files/src/lib/kit.tsx +219 -0
  227. package/registry/payment-card.json +21 -0
  228. package/registry/pixel-bar.json +14 -0
  229. package/registry/polar-area.json +14 -0
  230. package/registry/profile.json +17 -0
  231. package/registry/progress/files/src/components/ui/progress.tsx +883 -0
  232. package/registry/progress/files/src/lib/kit.tsx +219 -0
  233. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
  234. package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
  235. package/registry/progress-steps.json +18 -0
  236. package/registry/progress.json +17 -0
  237. package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
  238. package/registry/radio-group/files/src/lib/kit.tsx +219 -0
  239. package/registry/radio-group.json +18 -0
  240. package/registry/rating.json +18 -0
  241. package/registry/ring-carousel.json +15 -0
  242. package/registry/scratch-card.json +18 -0
  243. package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
  244. package/registry/search-bar/files/src/lib/kit.tsx +219 -0
  245. package/registry/search-bar.json +17 -0
  246. package/registry/search-results.json +30 -0
  247. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
  248. package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
  249. package/registry/segmented-control.json +18 -0
  250. package/registry/select/files/src/components/ui/select.tsx +1398 -0
  251. package/registry/select/files/src/lib/kit.tsx +219 -0
  252. package/registry/select.json +17 -0
  253. package/registry/settings.json +26 -0
  254. package/registry/shimmer-button.json +17 -0
  255. package/registry/sign-in.json +26 -0
  256. package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
  257. package/registry/skeleton/files/src/lib/kit.tsx +219 -0
  258. package/registry/skeleton.json +17 -0
  259. package/registry/slide-to-confirm.json +18 -0
  260. package/registry/slider/files/src/components/ui/slider.tsx +716 -0
  261. package/registry/slider/files/src/lib/kit.tsx +219 -0
  262. package/registry/slider.json +17 -0
  263. package/registry/spin-wheel.json +18 -0
  264. package/registry/spinner/files/src/components/ui/spinner.tsx +748 -0
  265. package/registry/spinner/files/src/lib/kit.tsx +219 -0
  266. package/registry/spinner.json +17 -0
  267. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
  268. package/registry/stateful-button/files/src/lib/kit.tsx +219 -0
  269. package/registry/stateful-button.json +18 -0
  270. package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
  271. package/registry/stepper/files/src/lib/kit.tsx +219 -0
  272. package/registry/stepper.json +17 -0
  273. package/registry/swipe-deck.json +15 -0
  274. package/registry/swipeable-row.json +15 -0
  275. package/registry/switch/files/src/components/ui/switch.tsx +344 -0
  276. package/registry/switch/files/src/lib/kit.tsx +219 -0
  277. package/registry/switch.json +18 -0
  278. package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
  279. package/registry/tabs/files/src/lib/kit.tsx +219 -0
  280. package/registry/tabs.json +17 -0
  281. package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
  282. package/registry/tag-input/files/src/lib/kit.tsx +219 -0
  283. package/registry/tag-input.json +17 -0
  284. package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
  285. package/registry/text-animation/files/src/lib/kit.tsx +219 -0
  286. package/registry/text-animation.json +15 -0
  287. package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
  288. package/registry/textarea/files/src/lib/kit.tsx +219 -0
  289. package/registry/textarea.json +17 -0
  290. package/registry/ticket-card.json +24 -0
  291. package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
  292. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
  293. package/registry/ticket-shape.json +17 -0
  294. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
  295. package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
  296. package/registry/tilt-card.json +17 -0
  297. package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
  298. package/registry/time-picker/files/src/lib/kit.tsx +219 -0
  299. package/registry/time-picker.json +17 -0
  300. package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
  301. package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
  302. package/registry/timeline/files/src/lib/kit.tsx +219 -0
  303. package/registry/timeline-chart.json +14 -0
  304. package/registry/timeline.json +20 -0
  305. package/registry/toast/files/src/components/ui/button.tsx +274 -0
  306. package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
  307. package/registry/toast/files/src/lib/kit.tsx +219 -0
  308. package/registry/toast.json +20 -0
  309. package/registry/treemap.json +14 -0
  310. package/registry/waterfall.json +14 -0
  311. package/registry/wheel-list.json +17 -0
  312. package/schema/components.schema.json +13 -0
@@ -0,0 +1,1398 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ Dimensions,
5
+ Easing,
6
+ Keyboard,
7
+ KeyboardAvoidingView,
8
+ Modal,
9
+ PanResponder,
10
+ Platform,
11
+ Pressable,
12
+ ScrollView,
13
+ StyleSheet,
14
+ Text,
15
+ TextInput,
16
+ View,
17
+ useWindowDimensions,
18
+ type StyleProp,
19
+ type TextStyle,
20
+ type ViewStyle,
21
+ } from 'react-native';
22
+ import Svg, { Circle, Path } from 'react-native-svg';
23
+
24
+ import { alpha, motion, readableOn, useKit } from '@/lib/kit';
25
+
26
+ export type SelectPresentation = 'dropdown' | 'sheet';
27
+ /** @deprecated Use `SelectPresentation`. */
28
+ export type SelectVariant = SelectPresentation;
29
+ export type SelectSize = 'sm' | 'md' | 'lg';
30
+
31
+ export interface SelectOption<T extends string | number = string> {
32
+ label: string;
33
+ value: T;
34
+ /** Second line under the label. */
35
+ description?: string;
36
+ /** Icon, flag or avatar shown before the label. */
37
+ icon?: React.ReactNode;
38
+ /** Options sharing a group are listed together under a small heading. */
39
+ group?: string;
40
+ disabled?: boolean;
41
+ }
42
+
43
+ export interface SelectProps<T extends string | number = string> {
44
+ /** Plain strings or `{ label, value, description?, icon?, group?, disabled? }`. */
45
+ options: (T | SelectOption<T>)[];
46
+ value?: T | null;
47
+ defaultValue?: T | null;
48
+ onValueChange?: (value: T, option: SelectOption<T>) => void;
49
+ /** Pick several options with checkboxes. Use `values` / `defaultValues` / `onValuesChange`. */
50
+ multiple?: boolean;
51
+ /** Controlled selection in `multiple` mode. */
52
+ values?: T[];
53
+ defaultValues?: T[];
54
+ onValuesChange?: (values: T[], options: SelectOption<T>[]) => void;
55
+ /** In `multiple` mode, up to this many selections show as chips; the rest collapse into a "+N" chip (0: one "N selected" chip). */
56
+ maxChips?: number;
57
+ /** Rests inside the field like a placeholder, floats onto the border once open or filled. */
58
+ label: string;
59
+ /** `false` puts the label above the field as static text (no notch) and shows `placeholder` inside. */
60
+ floatingLabel?: boolean;
61
+ /** Shown in the field while nothing is selected yet (only while open when the label floats). */
62
+ placeholder?: string;
63
+ /**
64
+ * 'dropdown' (default) anchors a menu right under the field (above it when there's no room) ·
65
+ * 'sheet' slides up from the bottom (a centred dialog on wide screens).
66
+ */
67
+ presentation?: SelectPresentation;
68
+ /** @deprecated Use `presentation`. */
69
+ variant?: SelectVariant;
70
+ /** Trigger height, text and icon size. */
71
+ size?: SelectSize;
72
+ /** Adds a filter field above the options; matches are highlighted. */
73
+ searchable?: boolean;
74
+ searchPlaceholder?: string;
75
+ /** Hint under "No results" when the search matches nothing. */
76
+ emptyText?: string;
77
+ /** Sheet heading (default: label). */
78
+ title?: string;
79
+ error?: string;
80
+ helper?: string;
81
+ disabled?: boolean;
82
+ /** Focus, label and check colour (default: theme accent). */
83
+ accent?: string;
84
+ /** Background behind the field — the floating label cuts its notch with this colour. */
85
+ surface?: string;
86
+ /** Minimum dropdown width; the menu otherwise matches the field. */
87
+ minMenuWidth?: number;
88
+ /** Maximum dropdown height before it scrolls (it also shrinks to the room on screen). */
89
+ maxMenuHeight?: number;
90
+ /** In `multiple` mode, show a small ✕ on each chip in the field to remove it. */
91
+ removableChips?: boolean;
92
+ /** Root wrapper (the one that shakes) — margins, width, flex. */
93
+ style?: StyleProp<ViewStyle>;
94
+ /**
95
+ * The trigger box: background, border width / colour, radius, height, padding. Applied after the
96
+ * computed style, so it wins. A `borderColor` replaces the resting border only — the open
97
+ * (accent) and error colours still take over. A numeric `height` / `borderRadius` also
98
+ * re-centres the label and resizes the focus halo.
99
+ */
100
+ fieldStyle?: StyleProp<ViewStyle>;
101
+ /** The selected value / placeholder text in the trigger. */
102
+ inputStyle?: StyleProp<TextStyle>;
103
+ /**
104
+ * The label, floating or above the field. Its `color` is the resting colour; open (accent) and
105
+ * error (danger) colours still take over. A `fontSize` sets the resting size and the label floats
106
+ * at the same ratio; the notch follows the rendered label width.
107
+ */
108
+ labelStyle?: StyleProp<TextStyle>;
109
+ /** Helper / error text under the field. `color` applies to the helper; errors stay danger. */
110
+ messageStyle?: StyleProp<TextStyle>;
111
+ /** Colour of the placeholder in the trigger (default: theme mutedForeground). */
112
+ placeholderTextColor?: string;
113
+ /** Chevron colour at rest (it turns accent while open). */
114
+ iconColor?: string;
115
+ /** The dropdown / sheet panel — background, border, radius, padding, shadow. Don't pass `transform` or `opacity`: they animate. */
116
+ menuStyle?: StyleProp<ViewStyle>;
117
+ /** Each option row — padding, minHeight, radius, background (press / keyboard highlight still show). */
118
+ optionStyle?: StyleProp<ViewStyle>;
119
+ /** Each option's label text — fontSize, fontWeight, color… */
120
+ optionTextStyle?: StyleProp<TextStyle>;
121
+ }
122
+
123
+ /** Heights and corners match FloatingInput so forms line up. */
124
+ const SIZES: Record<SelectSize, { h: number; font: number; pad: number; icon: number; chip: number; chipFont: number; corner: number }> = {
125
+ sm: { h: 44, font: 15, pad: 12, icon: 16, chip: 24, chipFont: 12.5, corner: -2 },
126
+ md: { h: 54, font: 16, pad: 16, icon: 18, chip: 28, chipFont: 13.5, corner: 0 },
127
+ lg: { h: 64, font: 17.5, pad: 18, icon: 20, chip: 32, chipFont: 15, corner: 4 },
128
+ };
129
+ const FLOAT_SCALE = 0.78;
130
+ /** Font size of the static label used when `floatingLabel` is false. */
131
+ const LABEL_ABOVE_FONT: Record<SelectSize, number> = { sm: 13, md: 14, lg: 14.5 };
132
+ const ICON_GAP = 32;
133
+ const GAP = 8; // space between field and dropdown
134
+ const EDGE = 12; // keep the dropdown this far from the window edges
135
+ const PANEL_PAD = 6;
136
+ const SEARCH_H = 46;
137
+ const FOOT_H = 48;
138
+
139
+ const normalize = <T extends string | number>(o: T | SelectOption<T>): SelectOption<T> =>
140
+ typeof o === 'object' ? o : { label: String(o), value: o };
141
+ const webInput = Platform.OS === 'web' ? ({ outlineStyle: 'none', outlineWidth: 0 } as object) : null;
142
+ const isWeb = Platform.OS === 'web';
143
+
144
+ /**
145
+ * A form field that opens a list of options. By default it's a dropdown anchored right under
146
+ * the field (flipping above when there's no room, scrolling inside when long) that fades and
147
+ * scales out of the field's edge; `presentation="sheet"` slides up a drag-to-dismiss sheet
148
+ * (a centred dialog on wide screens). The label floats like FloatingInput, a focus halo glows
149
+ * while open and the chevron turns. Rows take icons, descriptions, groups and disabled states;
150
+ * `searchable` filters with highlighted matches, `multiple` switches to checkboxes with chips in
151
+ * the field. On web: arrow keys move the highlight, Enter picks, Escape closes.
152
+ */
153
+ export function Select<T extends string | number = string>({
154
+ options,
155
+ value,
156
+ defaultValue = null,
157
+ onValueChange,
158
+ multiple,
159
+ values,
160
+ defaultValues,
161
+ onValuesChange,
162
+ maxChips = 2,
163
+ label,
164
+ floatingLabel = true,
165
+ placeholder = 'Select…',
166
+ presentation,
167
+ variant,
168
+ size = 'md',
169
+ searchable,
170
+ searchPlaceholder = 'Search',
171
+ emptyText,
172
+ title,
173
+ error,
174
+ helper,
175
+ disabled,
176
+ accent,
177
+ surface,
178
+ minMenuWidth = 200,
179
+ maxMenuHeight = 320,
180
+ removableChips = true,
181
+ style,
182
+ fieldStyle,
183
+ inputStyle,
184
+ labelStyle,
185
+ messageStyle,
186
+ placeholderTextColor,
187
+ iconColor,
188
+ menuStyle,
189
+ optionStyle,
190
+ optionTextStyle,
191
+ }: SelectProps<T>) {
192
+ const { colors, radius, dark, font } = useKit();
193
+ const ff = font.family ? { fontFamily: font.family } : null;
194
+ const tint = accent ?? colors.accent;
195
+ const bg = surface ?? colors.background;
196
+ const sz = SIZES[size];
197
+ // The user's border colour only replaces the resting one; numeric height / radius drive the layout maths.
198
+ const { borderColor: userBorder, ...userField } = StyleSheet.flatten(fieldStyle) ?? {};
199
+ const userLabelColor = StyleSheet.flatten(labelStyle)?.color as string | undefined;
200
+ const HEIGHT = typeof userField.height === 'number' ? userField.height : sz.h;
201
+ const fieldRadius = typeof userField.borderRadius === 'number' ? userField.borderRadius : radius.scale(12 + sz.corner);
202
+ const panelRadius = Math.max(fieldRadius, radius.scale(14));
203
+ const { width: winW, height: winH } = useWindowDimensions();
204
+ const items = useMemo(() => options.map(normalize), [options]);
205
+ const asSheet = (presentation ?? variant ?? 'dropdown') === 'sheet';
206
+ const wide = winW >= 640;
207
+
208
+ const [inner, setInner] = useState<T | null>(defaultValue);
209
+ const [innerMany, setInnerMany] = useState<T[]>(defaultValues ?? []);
210
+ const selected = value !== undefined ? value : inner;
211
+ const many = values ?? innerMany;
212
+ const current = multiple ? undefined : items.find((o) => o.value === selected);
213
+ const picked = multiple ? items.filter((o) => many.includes(o.value)) : current ? [current] : [];
214
+ const isSelected = (o: SelectOption<T>) => (multiple ? many.includes(o.value) : o.value === selected);
215
+
216
+ const [open, setOpen] = useState(false); // target state
217
+ const [mounted, setMounted] = useState(false); // Modal visible (stays true while closing)
218
+ const [query, setQuery] = useState('');
219
+ const [anchor, setAnchor] = useState({ x: 0, y: 0, w: 0, h: 0 });
220
+ const [hi, setHi] = useState(-1); // highlighted row (keyboard / hover, web)
221
+ const [kb, setKb] = useState(0); // on-screen keyboard height while the dropdown is open
222
+ const fieldRef = useRef<View>(null);
223
+ const scrollRef = useRef<ScrollView>(null);
224
+ const rowBox = useRef<Record<string, { y: number; h: number }>>({});
225
+ const scrollY = useRef(0);
226
+ const viewH = useRef(0);
227
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
228
+ const alive = useRef(true);
229
+
230
+ const floating = open || picked.length > 0;
231
+ const float = useRef(new Animated.Value(floating ? 1 : 0)).current; // native
232
+ const focus = useRef(new Animated.Value(0)).current; // JS: colours
233
+ const halo = useRef(new Animated.Value(0)).current; // native: focus halo
234
+ const show = useRef(new Animated.Value(0)).current; // native: backdrop + sheet/dropdown
235
+ const drag = useRef(new Animated.Value(0)).current; // native: sheet drag offset
236
+ const shake = useRef(new Animated.Value(0)).current; // native
237
+
238
+ useEffect(() => {
239
+ alive.current = true;
240
+ return () => {
241
+ alive.current = false;
242
+ if (closeTimer.current) clearTimeout(closeTimer.current);
243
+ };
244
+ }, []);
245
+
246
+ useEffect(() => {
247
+ Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
248
+ }, [floating, float]);
249
+
250
+ useEffect(() => {
251
+ Animated.timing(focus, { toValue: open ? 1 : 0, duration: 160, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
252
+ Animated.spring(halo, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
253
+ }, [open, focus, halo]);
254
+
255
+ useEffect(() => {
256
+ if (!error) return;
257
+ shake.setValue(0);
258
+ Animated.sequence(
259
+ [10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))
260
+ ).start();
261
+ }, [error, shake]);
262
+
263
+ useEffect(() => {
264
+ if (open) {
265
+ drag.setValue(0);
266
+ if (asSheet) Animated.spring(show, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start();
267
+ else Animated.timing(show, { toValue: 1, duration: 170, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
268
+ } else if (mounted) {
269
+ Animated.timing(show, { toValue: 0, duration: asSheet ? motion.fast + 40 : 120, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(
270
+ ({ finished }) => finished && setMounted(false)
271
+ );
272
+ }
273
+ // eslint-disable-next-line react-hooks/exhaustive-deps
274
+ }, [open]);
275
+
276
+ // Track the on-screen keyboard so a dropdown with a search field shrinks instead of hiding behind it.
277
+ useEffect(() => {
278
+ if (!open || asSheet || isWeb) return;
279
+ const a = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', (e) => setKb(e.endCoordinates.height));
280
+ const b = Keyboard.addListener(Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', () => setKb(0));
281
+ return () => {
282
+ a.remove();
283
+ b.remove();
284
+ setKb(0);
285
+ };
286
+ }, [open, asSheet]);
287
+
288
+ // Where the field sits on screen. Only commits a change, so re-measuring is cheap.
289
+ const measure = useCallback((then?: () => void) => {
290
+ const node = fieldRef.current;
291
+ if (!node) return then?.();
292
+ node.measureInWindow((x, y, w, h) => {
293
+ if (!alive.current) return;
294
+ setAnchor((a) => (a.x === x && a.y === y && a.w === w && a.h === h ? a : { x, y, w, h }));
295
+ then?.();
296
+ });
297
+ }, []);
298
+
299
+ // Keep following the field while open: rotation, browser / iframe resize (a docs preview grows
300
+ // after the menu appears), the keyboard, and a couple of late passes for layout that settles
301
+ // after the first frame. The side and max height are derived from the anchor on every render.
302
+ useEffect(() => {
303
+ if (!open || asSheet) return;
304
+ measure();
305
+ const t1 = setTimeout(() => measure(), 150);
306
+ const t2 = setTimeout(() => measure(), 400);
307
+ const sub = Dimensions.addEventListener('change', () => measure());
308
+ return () => {
309
+ clearTimeout(t1);
310
+ clearTimeout(t2);
311
+ sub.remove();
312
+ };
313
+ }, [open, asSheet, winW, winH, kb, measure]);
314
+
315
+ const q = query.trim().toLowerCase();
316
+ const filtered = useMemo(
317
+ () => (q ? items.filter((o) => `${o.label} ${o.description ?? ''}`.toLowerCase().includes(q)) : items),
318
+ [items, q]
319
+ );
320
+ const sections = useMemo(() => sectionsOf(filtered), [filtered]);
321
+ const flat = useMemo(() => sections.flatMap((s) => s.rows), [sections]);
322
+
323
+ // Estimated natural height of the dropdown, used to decide whether it fits below.
324
+ const estimate = (list: SelectOption<T>[], groups: number) =>
325
+ Math.min(
326
+ maxMenuHeight,
327
+ PANEL_PAD * 2 +
328
+ (searchable ? SEARCH_H : 0) +
329
+ (multiple ? FOOT_H : 0) +
330
+ groups * 30 +
331
+ (list.length ? list.reduce((n, o) => n + (o.description ? 56 : 44), 0) : 96)
332
+ );
333
+
334
+ const firstEnabled = (list: SelectOption<T>[]) => list.findIndex((o) => !o.disabled);
335
+
336
+ const openMenu = () => {
337
+ if (disabled) return;
338
+ if (closeTimer.current) clearTimeout(closeTimer.current);
339
+ setQuery('');
340
+ rowBox.current = {};
341
+ scrollY.current = 0;
342
+ const all = sectionsOf(items).flatMap((s) => s.rows);
343
+ const sel = multiple ? -1 : all.findIndex((o) => o.value === selected);
344
+ setHi(isWeb ? sel : -1);
345
+ const go = () => {
346
+ setMounted(true);
347
+ setOpen(true);
348
+ };
349
+ if (asSheet) return go();
350
+ measure(go);
351
+ };
352
+ const close = () => {
353
+ if (closeTimer.current) clearTimeout(closeTimer.current);
354
+ setOpen(false);
355
+ };
356
+
357
+ const setMany = (next: T[]) => {
358
+ if (values === undefined) setInnerMany(next);
359
+ onValuesChange?.(next, items.filter((o) => next.includes(o.value)));
360
+ };
361
+
362
+ const choose = (o: SelectOption<T>) => {
363
+ if (o.disabled) return;
364
+ if (multiple) {
365
+ setMany(many.includes(o.value) ? many.filter((v) => v !== o.value) : [...many, o.value]);
366
+ return;
367
+ }
368
+ if (value === undefined) setInner(o.value);
369
+ onValueChange?.(o.value, o);
370
+ // Let the check pop before the menu leaves.
371
+ if (closeTimer.current) clearTimeout(closeTimer.current);
372
+ closeTimer.current = setTimeout(() => setOpen(false), asSheet ? 160 : 90);
373
+ };
374
+
375
+ const reveal = (i: number) => {
376
+ const o = flat[i];
377
+ const box = o && rowBox.current[String(o.value)];
378
+ if (!box || !viewH.current) return;
379
+ if (box.y < scrollY.current) scrollRef.current?.scrollTo({ y: Math.max(0, box.y - PANEL_PAD), animated: false });
380
+ else if (box.y + box.h > scrollY.current + viewH.current)
381
+ scrollRef.current?.scrollTo({ y: box.y + box.h - viewH.current + PANEL_PAD, animated: false });
382
+ };
383
+
384
+ // Scroll the highlighted (selected) row into view once rows have laid out.
385
+ useEffect(() => {
386
+ if (!open || hi < 0) return;
387
+ const t = setTimeout(() => reveal(hi), 60);
388
+ return () => clearTimeout(t);
389
+ // eslint-disable-next-line react-hooks/exhaustive-deps
390
+ }, [open]);
391
+
392
+ // Typing in the search keeps the first match highlighted (web) so Enter picks it.
393
+ const onSearch = (text: string) => {
394
+ setQuery(text);
395
+ if (!isWeb) return;
396
+ const t = text.trim().toLowerCase();
397
+ const next = t ? items.filter((o) => `${o.label} ${o.description ?? ''}`.toLowerCase().includes(t)) : items;
398
+ setHi(t ? firstEnabled(sectionsOf(next).flatMap((x) => x.rows)) : -1);
399
+ };
400
+
401
+ const step = (dir: 1 | -1, from = hi) => {
402
+ const n = flat.length;
403
+ if (!n) return;
404
+ let i = from < 0 ? (dir > 0 ? -1 : n) : from;
405
+ for (let k = 0; k < n; k++) {
406
+ i = (i + dir + n) % n;
407
+ if (!flat[i].disabled) {
408
+ setHi(i);
409
+ reveal(i);
410
+ return;
411
+ }
412
+ }
413
+ };
414
+
415
+ // Web keyboard: ↑/↓ move, Home/End jump, Enter picks, Escape/Tab close.
416
+ const onKey = useRef<(e: KeyboardEvent) => void>(() => {});
417
+ onKey.current = (e) => {
418
+ switch (e.key) {
419
+ case 'ArrowDown':
420
+ case 'ArrowUp':
421
+ e.preventDefault();
422
+ step(e.key === 'ArrowDown' ? 1 : -1);
423
+ break;
424
+ case 'Home':
425
+ case 'End':
426
+ if (searchable && query) return; // let the caret move in the search field
427
+ e.preventDefault();
428
+ step(e.key === 'Home' ? 1 : -1, e.key === 'Home' ? -1 : flat.length);
429
+ break;
430
+ case 'Enter':
431
+ if (hi >= 0 && flat[hi]) {
432
+ e.preventDefault();
433
+ choose(flat[hi]);
434
+ }
435
+ break;
436
+ case 'Escape':
437
+ e.preventDefault();
438
+ e.stopPropagation();
439
+ close();
440
+ break;
441
+ case 'Tab':
442
+ close();
443
+ break;
444
+ }
445
+ };
446
+ useEffect(() => {
447
+ if (!open || !isWeb || typeof document === 'undefined') return;
448
+ const handler = (e: KeyboardEvent) => onKey.current(e);
449
+ document.addEventListener('keydown', handler);
450
+ return () => document.removeEventListener('keydown', handler);
451
+ }, [open]);
452
+
453
+ // Drag the sheet's grabber down to dismiss.
454
+ const pan = useMemo(
455
+ () =>
456
+ PanResponder.create({
457
+ onMoveShouldSetPanResponder: (_, g) => g.dy > 4 && Math.abs(g.dy) > Math.abs(g.dx),
458
+ onPanResponderMove: (_, g) => drag.setValue(Math.max(0, g.dy)),
459
+ onPanResponderRelease: (_, g) => {
460
+ if (g.dy > 110 || g.vy > 1.1) setOpen(false);
461
+ else Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
462
+ },
463
+ onPanResponderTerminate: () => Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start(),
464
+ }),
465
+ [drag]
466
+ );
467
+
468
+ const active = error ? colors.danger : tint;
469
+ const borderColor = focus.interpolate({
470
+ inputRange: [0, 1],
471
+ outputRange: [error ? colors.danger : ((userBorder as string | undefined) ?? colors.input), active],
472
+ });
473
+ const labelColor = focus.interpolate({ inputRange: [0, 1], outputRange: [error ? colors.danger : (userLabelColor ?? colors.mutedForeground), active] });
474
+ const topLabelColor = focus.interpolate({
475
+ inputRange: [0, 1],
476
+ outputRange: [error ? colors.danger : (userLabelColor ?? colors.mutedForeground), active],
477
+ });
478
+ const dense = !asSheet;
479
+ const rowRadius = Math.max(0, panelRadius - PANEL_PAD - 1);
480
+
481
+ const searchField = searchable ? (
482
+ <View
483
+ style={
484
+ dense
485
+ ? [styles.searchDense, { borderBottomColor: colors.border }]
486
+ : [styles.search, { backgroundColor: colors.muted, borderRadius: radius.md }]
487
+ }
488
+ >
489
+ <Icon name="search" size={dense ? 16 : 17} color={colors.mutedForeground} />
490
+ <TextInput
491
+ value={query}
492
+ onChangeText={onSearch}
493
+ placeholder={searchPlaceholder}
494
+ placeholderTextColor={colors.mutedForeground}
495
+ selectionColor={tint}
496
+ cursorColor={tint}
497
+ autoCorrect={false}
498
+ autoCapitalize="none"
499
+ autoFocus={isWeb}
500
+ returnKeyType="done"
501
+ accessibilityLabel={searchPlaceholder}
502
+ style={[styles.searchInput, ff, { color: colors.foreground, fontSize: dense ? 15 : 16 }, webInput]}
503
+ />
504
+ {query ? (
505
+ <Pressable
506
+ onPress={() => onSearch('')}
507
+ hitSlop={12}
508
+ accessibilityRole="button"
509
+ accessibilityLabel="Clear search"
510
+ style={[styles.clear, { backgroundColor: alpha(colors.mutedForeground, 0.22) }]}
511
+ >
512
+ <Icon name="x" size={11} color={colors.foreground} strokeWidth={2.4} />
513
+ </Pressable>
514
+ ) : null}
515
+ </View>
516
+ ) : null;
517
+
518
+ let rowIndex = -1;
519
+ const body = (
520
+ <>
521
+ {searchField}
522
+ <ScrollView
523
+ ref={scrollRef}
524
+ keyboardShouldPersistTaps="handled"
525
+ bounces={false}
526
+ style={styles.scroll}
527
+ contentContainerStyle={dense ? styles.listDense : styles.listContent}
528
+ scrollEventThrottle={32}
529
+ onScroll={(e) => (scrollY.current = e.nativeEvent.contentOffset.y)}
530
+ onLayout={(e) => (viewH.current = e.nativeEvent.layout.height)}
531
+ accessibilityRole="list"
532
+ >
533
+ {flat.length ? (
534
+ sections.map((sec, si) => (
535
+ <React.Fragment key={sec.title ?? `s${si}`}>
536
+ {sec.title ? (
537
+ <>
538
+ {si > 0 ? <View style={[styles.sep, { backgroundColor: colors.border }]} /> : null}
539
+ <Text style={[styles.groupTitle, dense ? styles.groupDense : null, ff, { color: colors.mutedForeground }]}>{sec.title}</Text>
540
+ </>
541
+ ) : null}
542
+ {sec.rows.map((o) => {
543
+ rowIndex += 1;
544
+ const i = rowIndex;
545
+ return (
546
+ <OptionRow
547
+ key={String(o.value)}
548
+ option={o}
549
+ selected={isSelected(o)}
550
+ highlighted={isWeb && i === hi}
551
+ multiple={!!multiple}
552
+ dense={dense}
553
+ rowRadius={rowRadius}
554
+ query={q}
555
+ tint={tint}
556
+ rowStyle={optionStyle}
557
+ textStyle={optionTextStyle}
558
+ onPress={() => choose(o)}
559
+ onHover={() => !o.disabled && setHi(i)}
560
+ onLayout={(y, h) => (rowBox.current[String(o.value)] = { y, h })}
561
+ />
562
+ );
563
+ })}
564
+ </React.Fragment>
565
+ ))
566
+ ) : (
567
+ <View style={dense ? styles.emptyDense : styles.empty}>
568
+ <View style={[styles.emptyIcon, dense && styles.emptyIconDense, { backgroundColor: colors.muted }]}>
569
+ <Icon name="search" size={dense ? 16 : 20} color={colors.mutedForeground} />
570
+ </View>
571
+ <Text style={[styles.emptyTitle, ff, { color: colors.foreground, fontSize: dense ? 14 : 15 }]}>
572
+ {q ? 'No results' : 'No options'}
573
+ </Text>
574
+ {q ? (
575
+ <Text style={[styles.emptyText, ff, { color: colors.mutedForeground }]}>
576
+ {emptyText ?? `Nothing matches “${query.trim()}”.`}
577
+ </Text>
578
+ ) : null}
579
+ </View>
580
+ )}
581
+ </ScrollView>
582
+ {multiple ? (
583
+ dense ? (
584
+ <View style={[styles.footDense, { borderTopColor: colors.border }]}>
585
+ <Pressable
586
+ onPress={() => setMany([])}
587
+ disabled={!many.length}
588
+ accessibilityRole="button"
589
+ style={({ pressed }) => [
590
+ styles.footGhost,
591
+ { borderRadius: radius.capsule(32), opacity: many.length ? 1 : 0.4, backgroundColor: pressed ? colors.muted : 'transparent' },
592
+ ]}
593
+ >
594
+ <Text style={[styles.footDenseText, ff, { color: colors.mutedForeground }]}>Clear</Text>
595
+ </Pressable>
596
+ <View style={styles.footMid}>
597
+ <CountBadge count={many.length} tint={tint} />
598
+ </View>
599
+ <Pressable
600
+ onPress={close}
601
+ accessibilityRole="button"
602
+ style={({ pressed }) => [styles.footSolid, { backgroundColor: tint, borderRadius: radius.capsule(32), opacity: pressed ? 0.85 : 1 }]}
603
+ >
604
+ <Text style={[styles.footDenseText, styles.footStrong, ff, { color: readableOn(tint) }]}>Done</Text>
605
+ </Pressable>
606
+ </View>
607
+ ) : (
608
+ <View style={[styles.footer, { borderTopColor: colors.border }]}>
609
+ <Pressable
610
+ onPress={() => setMany([])}
611
+ disabled={!many.length}
612
+ accessibilityRole="button"
613
+ style={({ pressed }) => [styles.footBtn, { borderRadius: radius.md, opacity: many.length ? (pressed ? 0.6 : 1) : 0.4 }]}
614
+ >
615
+ <Text style={[styles.footText, ff, { color: colors.foreground }]}>Clear</Text>
616
+ </Pressable>
617
+ <Pressable
618
+ onPress={close}
619
+ accessibilityRole="button"
620
+ style={({ pressed }) => [styles.footBtn, styles.footMain, { backgroundColor: tint, borderRadius: radius.md, opacity: pressed ? 0.85 : 1 }]}
621
+ >
622
+ <Text style={[styles.footText, styles.footStrong, styles.tabular, ff, { color: readableOn(tint) }]}>
623
+ {many.length ? `Done · ${many.length}` : 'Done'}
624
+ </Text>
625
+ </Pressable>
626
+ </View>
627
+ )
628
+ ) : null}
629
+ </>
630
+ );
631
+
632
+ // Dropdown geometry: match the field, never narrower than minMenuWidth, clamped to the window.
633
+ const popW = Math.max(0, Math.min(winW - EDGE * 2, Math.max(anchor.w, minMenuWidth)));
634
+ const popLeft = Math.max(EDGE, Math.min(anchor.x, winW - popW - EDGE));
635
+ const roomBelow = winH - kb - (anchor.y + anchor.h + GAP) - EDGE;
636
+ const roomAbove = anchor.y - GAP - EDGE;
637
+ // Sized from all options (not the filtered ones) so typing a search never flips the menu.
638
+ const need = estimate(items, new Set(items.map((o) => o.group).filter(Boolean)).size);
639
+ // Prefer below — the list scrolls inside maxHeight. Only flip above when below can't fit a
640
+ // usable strip (~120pt) and above clearly has more room (e.g. field near the screen bottom).
641
+ const minUsable = Math.min(120, Math.max(72, Math.floor(need * 0.35)));
642
+ const below = roomBelow >= minUsable || roomBelow + 32 >= roomAbove;
643
+ const popMaxH = Math.max(96, Math.min(maxMenuHeight, below ? Math.max(0, roomBelow) : Math.max(0, roomAbove)));
644
+
645
+ const hasIcon = !multiple && !!current?.icon;
646
+ const textLeft = hasIcon ? sz.pad + ICON_GAP : sz.pad;
647
+ const message = error ?? helper;
648
+ const remove = (v: T) => setMany(many.filter((x) => x !== v));
649
+ // Multiple mode: the first `maxChips` picks as chips, the rest as a "+N" chip. Removed chips stay
650
+ // rendered (collapsing) until their exit animation finishes.
651
+ const chipItems: { key: string; item: { option?: SelectOption<T>; more: number } }[] = [];
652
+ if (multiple) {
653
+ picked.slice(0, maxChips).forEach((o) => chipItems.push({ key: `o:${String(o.value)}`, item: { option: o, more: 0 } }));
654
+ if (picked.length > maxChips) chipItems.push({ key: 'more', item: { more: picked.length - maxChips } });
655
+ }
656
+ const [chips, chipGone] = usePresence(chipItems);
657
+ const showChips = chips.length > 0;
658
+ const chipBg = alpha(tint, dark ? 0.2 : 0.12);
659
+
660
+ return (
661
+ <Animated.View style={[{ transform: [{ translateX: shake }] }, style]}>
662
+ {floatingLabel ? null : (
663
+ <Animated.Text
664
+ numberOfLines={1}
665
+ // The trigger already carries the label for screen readers.
666
+ accessible={false}
667
+ importantForAccessibility="no"
668
+ accessibilityElementsHidden
669
+ style={[styles.topLabel, ff, { fontSize: LABEL_ABOVE_FONT[size] }, labelStyle, { color: topLabelColor, opacity: disabled ? 0.5 : 1 }]}
670
+ >
671
+ {label}
672
+ </Animated.Text>
673
+ )}
674
+ <Pressable
675
+ ref={fieldRef}
676
+ onPress={() => (open ? close() : openMenu())}
677
+ disabled={disabled}
678
+ accessibilityRole="combobox"
679
+ accessibilityLabel={label}
680
+ accessibilityValue={{ text: picked.length ? picked.map((o) => o.label).join(', ') : 'None selected' }}
681
+ accessibilityState={{ expanded: open, disabled: !!disabled }}
682
+ style={{ opacity: disabled ? 0.5 : 1 }}
683
+ >
684
+ {({ hovered }: { pressed: boolean; hovered?: boolean }) => (
685
+ <>
686
+ <Animated.View
687
+ pointerEvents="none"
688
+ style={[
689
+ styles.halo,
690
+ {
691
+ borderRadius: fieldRadius + 4,
692
+ borderColor: alpha(active, 0.2),
693
+ opacity: halo,
694
+ transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }],
695
+ },
696
+ ]}
697
+ />
698
+ <Animated.View
699
+ style={[
700
+ styles.field,
701
+ { height: HEIGHT, borderRadius: fieldRadius, backgroundColor: bg },
702
+ userField,
703
+ { borderColor: hovered && !open && !error ? colors.mutedForeground : borderColor },
704
+ ]}
705
+ >
706
+ {hasIcon ? <View style={[styles.icon, { left: sz.pad - 2 }]}>{current!.icon}</View> : null}
707
+ {showChips ? (
708
+ <View style={[styles.chips, { paddingLeft: sz.pad - 6, paddingRight: sz.pad + sz.icon + 8 }]}>
709
+ {chips.map(({ key, item, leaving, appear }) => (
710
+ <AnimatedChip key={key} leaving={leaving} appear={appear} gap={6} shrink={!!item.option} onGone={() => chipGone(key)}>
711
+ {item.option ? (
712
+ <View
713
+ style={[
714
+ styles.chip,
715
+ {
716
+ height: sz.chip,
717
+ paddingLeft: item.option.icon ? 3 : 10,
718
+ paddingRight: removableChips && !disabled ? 4 : 10,
719
+ backgroundColor: chipBg,
720
+ borderRadius: radius.capsule(sz.chip),
721
+ },
722
+ ]}
723
+ >
724
+ {item.option.icon ? (
725
+ <View style={[styles.chipIcon, { width: sz.chip - 6, height: sz.chip - 6, borderRadius: radius.capsule(sz.chip - 6) }]}>
726
+ {item.option.icon}
727
+ </View>
728
+ ) : null}
729
+ <Text numberOfLines={1} style={[styles.chipText, ff, { color: tint, fontSize: sz.chipFont }]}>
730
+ {item.option.label}
731
+ </Text>
732
+ {removableChips && !disabled ? (
733
+ <Pressable
734
+ onPress={() => item.option && remove(item.option.value)}
735
+ hitSlop={12}
736
+ accessibilityRole="button"
737
+ accessibilityLabel={`Remove ${item.option.label}`}
738
+ style={({ pressed }) => [
739
+ styles.chipX,
740
+ {
741
+ width: sz.chip - 8,
742
+ height: sz.chip - 8,
743
+ borderRadius: radius.capsule(sz.chip - 8),
744
+ backgroundColor: pressed ? alpha(tint, 0.2) : 'transparent',
745
+ },
746
+ ]}
747
+ >
748
+ <Icon name="x" size={Math.round(sz.chip * 0.45)} color={tint} strokeWidth={2.2} />
749
+ </Pressable>
750
+ ) : null}
751
+ </View>
752
+ ) : (
753
+ <View
754
+ accessible
755
+ accessibilityLabel={maxChips > 0 ? `${item.more} more` : `${item.more} selected`}
756
+ style={[styles.chip, styles.more, { height: sz.chip, backgroundColor: chipBg, borderRadius: radius.capsule(sz.chip) }]}
757
+ >
758
+ {maxChips > 0 ? <Text style={[styles.chipText, styles.tabular, ff, { color: tint, fontSize: sz.chipFont }]}>+</Text> : null}
759
+ <RollingNumber value={item.more} distance={sz.chipFont} style={[styles.chipText, ff, { color: tint, fontSize: sz.chipFont }]} />
760
+ {maxChips > 0 ? null : (
761
+ <Text style={[styles.chipText, ff, { color: tint, fontSize: sz.chipFont, marginLeft: 3 }]}>selected</Text>
762
+ )}
763
+ </View>
764
+ )}
765
+ </AnimatedChip>
766
+ ))}
767
+ </View>
768
+ ) : (
769
+ <Text
770
+ numberOfLines={1}
771
+ style={[
772
+ ff,
773
+ {
774
+ fontSize: sz.font,
775
+ fontWeight: '400',
776
+ color: current ? colors.foreground : colors.mutedForeground,
777
+ paddingLeft: textLeft,
778
+ paddingRight: sz.pad + sz.icon + 8,
779
+ },
780
+ inputStyle,
781
+ !current && { color: placeholderTextColor ?? colors.mutedForeground },
782
+ ]}
783
+ >
784
+ {current ? current.label : open || !floatingLabel ? placeholder : ''}
785
+ </Text>
786
+ )}
787
+ {floatingLabel ? (
788
+ <Animated.View
789
+ pointerEvents="none"
790
+ style={[
791
+ styles.labelWrap,
792
+ {
793
+ left: textLeft,
794
+ right: sz.pad + sz.icon + 8,
795
+ height: HEIGHT - 3,
796
+ transform: [
797
+ { translateY: float.interpolate({ inputRange: [0, 1], outputRange: [0, -HEIGHT / 2] }) },
798
+ { translateX: float.interpolate({ inputRange: [0, 1], outputRange: [0, -(textLeft - sz.pad) - 4] }) },
799
+ { scale: float.interpolate({ inputRange: [0, 1], outputRange: [1, FLOAT_SCALE] }) },
800
+ ],
801
+ },
802
+ ]}
803
+ >
804
+ {/* The notch: the label paints the surface over the border, so it matches the rendered width. */}
805
+ <Animated.Text numberOfLines={1} style={[styles.label, ff, { fontSize: sz.font, backgroundColor: bg }, labelStyle, { color: labelColor }]}>
806
+ {label}
807
+ </Animated.Text>
808
+ </Animated.View>
809
+ ) : null}
810
+ <Chevron open={open} size={sz.icon} right={sz.pad - 4} color={open ? active : (iconColor ?? colors.mutedForeground)} />
811
+ </Animated.View>
812
+ </>
813
+ )}
814
+ </Pressable>
815
+ {message ? <FieldMessage text={message} error={!!error} style={messageStyle} /> : null}
816
+
817
+ <Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={close}>
818
+ <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: asSheet ? colors.overlay : 'transparent', opacity: show }]}>
819
+ <Pressable style={StyleSheet.absoluteFill} onPress={close} accessibilityLabel="Close" accessibilityRole="button" />
820
+ </Animated.View>
821
+
822
+ {asSheet ? (
823
+ <KeyboardAvoidingView
824
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
825
+ style={[styles.sheetHost, wide && styles.centered]}
826
+ pointerEvents="box-none"
827
+ >
828
+ <Animated.View
829
+ accessibilityViewIsModal
830
+ style={[
831
+ styles.sheet,
832
+ { backgroundColor: colors.popover, maxHeight: winH * (wide ? 0.7 : 0.78), borderColor: colors.border },
833
+ wide
834
+ ? { width: Math.min(460, winW - 48), borderRadius: radius.xl, paddingBottom: 8 }
835
+ : { alignSelf: 'stretch', borderTopLeftRadius: radius.xl + 4, borderTopRightRadius: radius.xl + 4, paddingBottom: 28 },
836
+ menuStyle,
837
+ wide
838
+ ? {
839
+ opacity: show,
840
+ transform: [
841
+ { scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.94, 1] }) },
842
+ { translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [16, 0] }), drag) },
843
+ ],
844
+ }
845
+ : { transform: [{ translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [winH * 0.8, 0] }), drag) }] },
846
+ ]}
847
+ >
848
+ <View {...pan.panHandlers} style={styles.sheetHeader}>
849
+ {!wide ? <View style={[styles.grabber, { backgroundColor: colors.border }]} /> : null}
850
+ <Text style={[styles.title, ff, { color: colors.foreground }]}>{title ?? label}</Text>
851
+ {multiple ? (
852
+ <View style={styles.subtitleRow}>
853
+ {many.length ? (
854
+ <CountBadge count={many.length} tint={tint} />
855
+ ) : (
856
+ <Text style={[styles.subtitle, ff, { color: colors.mutedForeground }]}>Choose one or more</Text>
857
+ )}
858
+ </View>
859
+ ) : null}
860
+ </View>
861
+ {body}
862
+ </Animated.View>
863
+ </KeyboardAvoidingView>
864
+ ) : (
865
+ <Animated.View
866
+ accessibilityViewIsModal
867
+ style={[
868
+ styles.dropdown,
869
+ {
870
+ left: popLeft,
871
+ width: popW,
872
+ maxHeight: popMaxH,
873
+ backgroundColor: colors.popover,
874
+ borderColor: colors.border,
875
+ borderRadius: panelRadius,
876
+ shadowOpacity: dark ? 0.5 : 0.12,
877
+ transformOrigin: below ? 'top' : 'bottom',
878
+ },
879
+ menuStyle,
880
+ {
881
+ opacity: show,
882
+ transform: [
883
+ { translateY: show.interpolate({ inputRange: [0, 1], outputRange: [below ? -6 : 6, 0] }) },
884
+ { scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) },
885
+ ],
886
+ },
887
+ below ? { top: anchor.y + anchor.h + GAP } : { bottom: Math.max(EDGE, winH - anchor.y + GAP) },
888
+ ]}
889
+ >
890
+ {body}
891
+ </Animated.View>
892
+ )}
893
+ </Modal>
894
+ </Animated.View>
895
+ );
896
+ }
897
+
898
+ /** Groups options by `group` (first-appearance order); a single untitled section when none are grouped. */
899
+ function sectionsOf<T extends string | number>(list: SelectOption<T>[]): { title?: string; rows: SelectOption<T>[] }[] {
900
+ if (!list.some((o) => o.group)) return [{ rows: list }];
901
+ const map = new Map<string, SelectOption<T>[]>();
902
+ list.forEach((o) => map.set(o.group ?? '', [...(map.get(o.group ?? '') ?? []), o]));
903
+ return [...map].map(([title, rows]) => ({ title: title || undefined, rows }));
904
+ }
905
+
906
+ function OptionRow<T extends string | number>({
907
+ option,
908
+ selected,
909
+ highlighted,
910
+ multiple,
911
+ dense,
912
+ rowRadius,
913
+ query,
914
+ tint,
915
+ rowStyle,
916
+ textStyle,
917
+ onPress,
918
+ onHover,
919
+ onLayout,
920
+ }: {
921
+ option: SelectOption<T>;
922
+ selected: boolean;
923
+ highlighted: boolean;
924
+ multiple: boolean;
925
+ dense: boolean;
926
+ rowRadius: number;
927
+ query: string;
928
+ tint: string;
929
+ rowStyle?: StyleProp<ViewStyle>;
930
+ textStyle?: StyleProp<TextStyle>;
931
+ onPress: () => void;
932
+ onHover: () => void;
933
+ onLayout: (y: number, h: number) => void;
934
+ }) {
935
+ const { colors, radius, font, dark } = useKit();
936
+ const ff = font.family ? { fontFamily: font.family } : null;
937
+ const check = useRef(new Animated.Value(selected ? 1 : 0)).current; // native: scale + opacity
938
+ useEffect(() => {
939
+ Animated.spring(check, { toValue: selected ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
940
+ }, [selected, check]);
941
+ const on = readableOn(tint);
942
+ const box = dense ? 18 : 22;
943
+ const hoverBg = alpha(colors.foreground, 0.06);
944
+ const pressBg = alpha(colors.foreground, 0.1);
945
+ const selectedBg = alpha(tint, dark ? 0.14 : 0.08);
946
+
947
+ return (
948
+ <Pressable
949
+ onPress={onPress}
950
+ onHoverIn={onHover}
951
+ onLayout={(e) => onLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)}
952
+ disabled={option.disabled}
953
+ accessibilityRole={multiple ? 'checkbox' : 'menuitem'}
954
+ accessibilityState={multiple ? { checked: selected, disabled: !!option.disabled } : { selected, disabled: !!option.disabled }}
955
+ style={({ pressed }) => [
956
+ dense ? styles.rowDense : styles.row,
957
+ {
958
+ borderRadius: dense ? rowRadius : radius.scale(10),
959
+ backgroundColor: pressed ? pressBg : highlighted ? hoverBg : selected ? selectedBg : 'transparent',
960
+ opacity: option.disabled ? 0.4 : 1,
961
+ },
962
+ rowStyle,
963
+ // Press and keyboard highlight still show over a custom row background.
964
+ (pressed || highlighted) && { backgroundColor: pressed ? pressBg : hoverBg },
965
+ ]}
966
+ >
967
+ {multiple ? (
968
+ <View style={[styles.box, { width: box, height: box, borderColor: selected ? tint : colors.input, borderRadius: radius.scale(5) }]}>
969
+ <Animated.View style={[styles.boxFill, { backgroundColor: tint, borderRadius: radius.scale(5), opacity: check, transform: [{ scale: check }] }]}>
970
+ <Icon name="check" size={dense ? 12 : 14} color={on} strokeWidth={2.8} />
971
+ </Animated.View>
972
+ </View>
973
+ ) : null}
974
+ {option.icon ? <View style={styles.rowIcon}>{option.icon}</View> : null}
975
+ <View style={styles.rowText}>
976
+ <Highlight
977
+ text={option.label}
978
+ query={query}
979
+ tint={tint}
980
+ style={[styles.rowLabel, ff, { color: colors.foreground, fontSize: dense ? 15 : 16 }, selected && styles.rowLabelOn, textStyle]}
981
+ />
982
+ {option.description ? (
983
+ <Highlight
984
+ text={option.description}
985
+ query={query}
986
+ tint={tint}
987
+ style={[styles.rowDesc, ff, { color: colors.mutedForeground, fontSize: dense ? 12.5 : 13 }]}
988
+ />
989
+ ) : null}
990
+ </View>
991
+ {!multiple ? (
992
+ <Animated.View
993
+ style={[
994
+ styles.checkWrap,
995
+ {
996
+ opacity: check,
997
+ transform: [{ scale: check }, { rotate: check.interpolate({ inputRange: [0, 1], outputRange: ['-30deg', '0deg'] }) }],
998
+ },
999
+ ]}
1000
+ >
1001
+ <Icon name="check" size={dense ? 17 : 18} color={tint} strokeWidth={2.4} />
1002
+ </Animated.View>
1003
+ ) : null}
1004
+ </Pressable>
1005
+ );
1006
+ }
1007
+
1008
+ type Present<V> = { key: string; item: V; leaving: boolean; appear: boolean };
1009
+
1010
+ /**
1011
+ * Keeps entries that drop out of `items` rendered in their old place, flagged `leaving`, until
1012
+ * `done(key)` says their exit animation finished. Entries present on the first render don't animate in.
1013
+ */
1014
+ function usePresence<V>(items: { key: string; item: V }[]) {
1015
+ const prev = useRef<Present<V>[]>([]);
1016
+ const finished = useRef(new Set<string>());
1017
+ const ready = useRef(false);
1018
+ const alive = useRef(true);
1019
+ const [, rerender] = useState(0);
1020
+ useEffect(() => {
1021
+ ready.current = true;
1022
+ alive.current = true;
1023
+ return () => {
1024
+ alive.current = false;
1025
+ };
1026
+ }, []);
1027
+
1028
+ const live = new Set(items.map((e) => e.key));
1029
+ live.forEach((k) => finished.current.delete(k));
1030
+ const out: Present<V>[] = [];
1031
+ let next = 0;
1032
+ const emitUpTo = (end: number) => {
1033
+ for (; next <= end; next++) out.push({ ...items[next], leaving: false, appear: ready.current });
1034
+ };
1035
+ for (const e of prev.current) {
1036
+ if (live.has(e.key)) emitUpTo(items.findIndex((x) => x.key === e.key));
1037
+ else if (!finished.current.has(e.key)) out.push({ ...e, leaving: true });
1038
+ }
1039
+ emitUpTo(items.length - 1);
1040
+ prev.current = out;
1041
+
1042
+ const done = useCallback((key: string) => {
1043
+ finished.current.add(key);
1044
+ if (alive.current) rerender((n) => n + 1);
1045
+ }, []);
1046
+ return [out, done] as const;
1047
+ }
1048
+
1049
+ /**
1050
+ * A chip that pops in (native scale + opacity spring) and, when `leaving`, fades and shrinks while
1051
+ * an outer view collapses its width and margin on the JS driver so neighbours slide over smoothly.
1052
+ */
1053
+ function AnimatedChip({
1054
+ leaving,
1055
+ appear,
1056
+ gap,
1057
+ shrink,
1058
+ onGone,
1059
+ children,
1060
+ }: {
1061
+ leaving: boolean;
1062
+ appear: boolean;
1063
+ gap: number;
1064
+ /** Whether the slot may shrink when the row is tight (chips yes, the "+N" counter no). */
1065
+ shrink: boolean;
1066
+ onGone: () => void;
1067
+ children: React.ReactNode;
1068
+ }) {
1069
+ const pop = useRef(new Animated.Value(appear ? 0 : 1)).current; // native: scale + opacity
1070
+ const size = useRef(new Animated.Value(1)).current; // JS: width + margin of the outer slot
1071
+ const [w, setW] = useState(0);
1072
+ const gone = useRef(onGone);
1073
+ gone.current = onGone;
1074
+
1075
+ useEffect(() => {
1076
+ let alive = true;
1077
+ let anims: Animated.CompositeAnimation[];
1078
+ if (leaving) {
1079
+ let left = 2;
1080
+ const end = ({ finished }: { finished: boolean }) => {
1081
+ if (finished && alive && --left === 0) gone.current();
1082
+ };
1083
+ anims = [
1084
+ Animated.timing(pop, { toValue: 0, duration: 150, easing: Easing.out(Easing.quad), useNativeDriver: true }),
1085
+ Animated.timing(size, { toValue: 0, duration: 230, easing: Easing.inOut(Easing.cubic), useNativeDriver: false }),
1086
+ ];
1087
+ anims.forEach((a) => a.start(end));
1088
+ } else {
1089
+ size.setValue(1);
1090
+ anims = [Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.snappy })];
1091
+ anims.forEach((a) => a.start());
1092
+ }
1093
+ return () => {
1094
+ alive = false;
1095
+ anims.forEach((a) => a.stop());
1096
+ };
1097
+ }, [leaving, pop, size]);
1098
+
1099
+ const collapsing = leaving && w > 0;
1100
+ return (
1101
+ <Animated.View
1102
+ pointerEvents={leaving ? 'none' : 'auto'}
1103
+ style={[
1104
+ styles.chipSlot,
1105
+ !shrink && { flexShrink: 0 },
1106
+ collapsing
1107
+ ? {
1108
+ overflow: 'hidden',
1109
+ width: size.interpolate({ inputRange: [0, 1], outputRange: [0, w] }),
1110
+ marginRight: size.interpolate({ inputRange: [0, 1], outputRange: [0, gap] }),
1111
+ }
1112
+ : { marginRight: gap },
1113
+ ]}
1114
+ >
1115
+ <Animated.View
1116
+ onLayout={leaving ? undefined : (e) => setW(e.nativeEvent.layout.width)}
1117
+ style={[
1118
+ styles.chipPop,
1119
+ collapsing && { width: w, flexShrink: 0 },
1120
+ {
1121
+ opacity: pop.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
1122
+ transform: [{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.85, 1] }) }],
1123
+ },
1124
+ ]}
1125
+ >
1126
+ {children}
1127
+ </Animated.View>
1128
+ </Animated.View>
1129
+ );
1130
+ }
1131
+
1132
+ /** A number that rolls (native translateY + opacity) up when it grows and down when it shrinks. */
1133
+ function RollingNumber({ value, style, distance = 10 }: { value: number; style?: StyleProp<TextStyle>; distance?: number }) {
1134
+ const [state, setState] = useState({ cur: value, prev: value, dir: 1 });
1135
+ if (state.cur !== value) setState({ cur: value, prev: state.cur, dir: value > state.cur ? 1 : -1 });
1136
+ // A fresh value per change starts at 0 in the same render, so there's no one-frame flash.
1137
+ const roll = useMemo(() => new Animated.Value(state.cur === state.prev ? 1 : 0), [state]);
1138
+ useEffect(() => {
1139
+ const a = Animated.spring(roll, { toValue: 1, useNativeDriver: true, ...motion.snappy });
1140
+ a.start();
1141
+ return () => a.stop();
1142
+ }, [roll]);
1143
+ const moving = state.cur !== state.prev;
1144
+ return (
1145
+ <View style={styles.roll}>
1146
+ {moving ? (
1147
+ <Animated.Text
1148
+ accessible={false}
1149
+ importantForAccessibility="no"
1150
+ style={[
1151
+ style,
1152
+ styles.tabular,
1153
+ styles.rollPrev,
1154
+ {
1155
+ opacity: roll.interpolate({ inputRange: [0, 1], outputRange: [1, 0], extrapolate: 'clamp' }),
1156
+ transform: [{ translateY: roll.interpolate({ inputRange: [0, 1], outputRange: [0, -state.dir * distance] }) }],
1157
+ },
1158
+ ]}
1159
+ >
1160
+ {state.prev}
1161
+ </Animated.Text>
1162
+ ) : null}
1163
+ <Animated.Text
1164
+ style={[
1165
+ style,
1166
+ styles.tabular,
1167
+ {
1168
+ opacity: roll.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
1169
+ transform: [{ translateY: roll.interpolate({ inputRange: [0, 1], outputRange: [state.dir * distance, 0] }) }],
1170
+ },
1171
+ ]}
1172
+ >
1173
+ {state.cur}
1174
+ </Animated.Text>
1175
+ </View>
1176
+ );
1177
+ }
1178
+
1179
+ /** "N selected" pill whose number rolls; it pops out when the count drops to 0. */
1180
+ function CountBadge({ count, tint }: { count: number; tint: string }) {
1181
+ const { radius, font } = useKit();
1182
+ const ff = font.family ? { fontFamily: font.family } : null;
1183
+ const on = count > 0;
1184
+ const vis = useRef(new Animated.Value(on ? 1 : 0)).current; // native
1185
+ const last = useRef(count);
1186
+ if (on) last.current = count;
1187
+ useEffect(() => {
1188
+ const a = Animated.spring(vis, { toValue: on ? 1 : 0, useNativeDriver: true, ...motion.snappy });
1189
+ a.start();
1190
+ return () => a.stop();
1191
+ }, [on, vis]);
1192
+ const text = [styles.badgeText, ff, { color: tint }];
1193
+ return (
1194
+ <Animated.View
1195
+ accessible
1196
+ accessibilityLabel={`${last.current} selected`}
1197
+ accessibilityLiveRegion="polite"
1198
+ style={[
1199
+ styles.badge,
1200
+ {
1201
+ backgroundColor: alpha(tint, 0.12),
1202
+ borderRadius: radius.capsule(24),
1203
+ opacity: vis.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
1204
+ transform: [{ scale: vis.interpolate({ inputRange: [0, 1], outputRange: [0.85, 1] }) }],
1205
+ },
1206
+ ]}
1207
+ >
1208
+ <RollingNumber value={last.current} distance={9} style={text} />
1209
+ <Text style={[text, styles.badgeSuffix]}>selected</Text>
1210
+ </Animated.View>
1211
+ );
1212
+ }
1213
+
1214
+ /** Renders `text` with the first match of `query` highlighted. */
1215
+ function Highlight({ text, query, tint, style }: { text: string; query: string; tint: string; style: StyleProp<TextStyle> }) {
1216
+ const i = query ? text.toLowerCase().indexOf(query) : -1;
1217
+ if (i < 0) {
1218
+ return (
1219
+ <Text numberOfLines={1} style={style}>
1220
+ {text}
1221
+ </Text>
1222
+ );
1223
+ }
1224
+ return (
1225
+ <Text numberOfLines={1} style={style}>
1226
+ {text.slice(0, i)}
1227
+ <Text style={[styles.match, { color: tint, backgroundColor: alpha(tint, 0.14) }]}>{text.slice(i, i + query.length)}</Text>
1228
+ {text.slice(i + query.length)}
1229
+ </Text>
1230
+ );
1231
+ }
1232
+
1233
+ /** Helper / error line that fades and slides in; errors get an icon and are announced. */
1234
+ function FieldMessage({ text, error, style }: { text: string; error: boolean; style?: StyleProp<TextStyle> }) {
1235
+ const { colors, font } = useKit();
1236
+ const enter = useRef(new Animated.Value(0)).current; // native
1237
+ useEffect(() => {
1238
+ enter.setValue(0);
1239
+ Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
1240
+ }, [text, error, enter]);
1241
+ const color = error ? colors.danger : colors.mutedForeground;
1242
+ return (
1243
+ <Animated.View
1244
+ accessibilityLiveRegion="polite"
1245
+ style={[
1246
+ styles.message,
1247
+ { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] },
1248
+ ]}
1249
+ >
1250
+ {error ? <Icon name="alert" size={14} color={color} /> : null}
1251
+ <Text style={[styles.helper, font.family ? { fontFamily: font.family } : null, { color }, style, error && { color }]}>{text}</Text>
1252
+ </Animated.View>
1253
+ );
1254
+ }
1255
+
1256
+ function Chevron({ open, color, size, right }: { open: boolean; color: string; size: number; right: number }) {
1257
+ const turn = useRef(new Animated.Value(open ? 1 : 0)).current;
1258
+ useEffect(() => {
1259
+ Animated.spring(turn, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
1260
+ }, [open, turn]);
1261
+ return (
1262
+ <Animated.View
1263
+ pointerEvents="none"
1264
+ style={[
1265
+ styles.chevronWrap,
1266
+ { right, width: size + 4, height: size + 4, transform: [{ rotate: turn.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] }) }] },
1267
+ ]}
1268
+ >
1269
+ <Icon name="chevron" size={size} color={color} />
1270
+ </Animated.View>
1271
+ );
1272
+ }
1273
+
1274
+ function Icon({ name, size, color, strokeWidth = 1.8 }: { name: 'chevron' | 'check' | 'search' | 'x' | 'alert'; size: number; color: string; strokeWidth?: number }) {
1275
+ const p = { stroke: color, strokeWidth, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
1276
+ return (
1277
+ <Svg width={size} height={size} viewBox="0 0 24 24">
1278
+ {name === 'chevron' ? <Path d="m6 9 6 6 6-6" {...p} /> : null}
1279
+ {name === 'check' ? <Path d="m5 12.5 4.5 4.5L19 7.5" {...p} /> : null}
1280
+ {name === 'x' ? <Path d="M6 6l12 12M18 6 6 18" {...p} /> : null}
1281
+ {name === 'search' ? (
1282
+ <>
1283
+ <Circle cx={11} cy={11} r={6.5} {...p} />
1284
+ <Path d="m20 20-4.2-4.2" {...p} />
1285
+ </>
1286
+ ) : null}
1287
+ {name === 'alert' ? (
1288
+ <>
1289
+ <Circle cx={12} cy={12} r={9} {...p} />
1290
+ <Path d="M12 7.5v5.5M12 16.5v.01" {...p} strokeWidth={2.2} />
1291
+ </>
1292
+ ) : null}
1293
+ </Svg>
1294
+ );
1295
+ }
1296
+
1297
+ const styles = StyleSheet.create({
1298
+ field: { borderWidth: 1.5, justifyContent: 'center' },
1299
+ halo: { position: 'absolute', top: -5, left: -5, right: -5, bottom: -5, borderWidth: 3.5 },
1300
+ icon: { position: 'absolute', top: 0, bottom: 0, justifyContent: 'center' },
1301
+ chips: { flexDirection: 'row', alignItems: 'center', overflow: 'hidden' },
1302
+ chipSlot: { flexDirection: 'row', flexShrink: 1, minWidth: 0 },
1303
+ chipPop: { flexDirection: 'row', flexShrink: 1, minWidth: 0 },
1304
+ chip: { flexDirection: 'row', alignItems: 'center', gap: 4, maxWidth: 150, flexShrink: 1 },
1305
+ more: { gap: 0, paddingHorizontal: 9, flexShrink: 0 },
1306
+ chipIcon: { overflow: 'hidden', alignItems: 'center', justifyContent: 'center' },
1307
+ chipText: { fontWeight: '400', flexShrink: 1 },
1308
+ tabular: { fontVariant: ['tabular-nums'], fontWeight: '400' },
1309
+ roll: { overflow: 'hidden' },
1310
+ rollPrev: { position: 'absolute', left: 0, top: 0 },
1311
+ badge: { flexDirection: 'row', alignItems: 'center', height: 24, paddingHorizontal: 9 },
1312
+ badgeText: { fontSize: 12.5, fontWeight: '400' },
1313
+ badgeSuffix: { marginLeft: 3 },
1314
+ chipX: { alignItems: 'center', justifyContent: 'center' },
1315
+ labelWrap: { position: 'absolute', top: 0, justifyContent: 'center', alignItems: 'flex-start', transformOrigin: 'left center' },
1316
+ label: { fontWeight: '400', paddingHorizontal: 5, marginLeft: -5 },
1317
+ topLabel: { fontWeight: '400', marginBottom: 7, marginHorizontal: 1, letterSpacing: 0.01 },
1318
+ message: { flexDirection: 'row', alignItems: 'flex-start', gap: 5, marginTop: 7, marginLeft: 2 },
1319
+ helper: { flex: 1, fontSize: 12.5, lineHeight: 17 },
1320
+ chevronWrap: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
1321
+ sheetHost: { flex: 1, justifyContent: 'flex-end' },
1322
+ centered: { justifyContent: 'center', alignItems: 'center' },
1323
+ sheet: {
1324
+ paddingHorizontal: 12,
1325
+ borderWidth: StyleSheet.hairlineWidth,
1326
+ shadowColor: '#000',
1327
+ shadowOpacity: 0.18,
1328
+ shadowRadius: 24,
1329
+ shadowOffset: { width: 0, height: -4 },
1330
+ elevation: 16,
1331
+ },
1332
+ sheetHeader: { alignItems: 'center', paddingTop: 10, paddingBottom: 12 },
1333
+ grabber: { width: 40, height: 5, borderRadius: 3, marginBottom: 12 },
1334
+ title: { fontSize: 17, fontWeight: '400', letterSpacing: -0.2, marginTop: 4 },
1335
+ subtitleRow: { height: 26, marginTop: 4, alignItems: 'center', justifyContent: 'center' },
1336
+ subtitle: { fontSize: 13 },
1337
+ search: { height: 44, marginBottom: 8, marginHorizontal: 4, flexDirection: 'row', alignItems: 'center', paddingLeft: 12, paddingRight: 10, gap: 8 },
1338
+ searchDense: {
1339
+ height: SEARCH_H,
1340
+ flexDirection: 'row',
1341
+ alignItems: 'center',
1342
+ paddingLeft: 13,
1343
+ paddingRight: 11,
1344
+ gap: 8,
1345
+ borderBottomWidth: StyleSheet.hairlineWidth,
1346
+ },
1347
+ searchInput: { flex: 1, height: 44, paddingVertical: 0 },
1348
+ clear: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
1349
+ scroll: { flexGrow: 0, flexShrink: 1 },
1350
+ listContent: { paddingBottom: 6 },
1351
+ listDense: { padding: PANEL_PAD },
1352
+ sep: { height: StyleSheet.hairlineWidth, marginVertical: 4, marginHorizontal: -PANEL_PAD },
1353
+ groupTitle: { fontSize: 12, fontWeight: '400', letterSpacing: 0.2, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 6 },
1354
+ groupDense: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 4 },
1355
+ empty: { alignItems: 'center', paddingVertical: 24, paddingHorizontal: 16 },
1356
+ emptyDense: { alignItems: 'center', paddingVertical: 18, paddingHorizontal: 12 },
1357
+ emptyIcon: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', marginBottom: 10 },
1358
+ emptyIconDense: { width: 34, height: 34, borderRadius: 17, marginBottom: 8 },
1359
+ emptyTitle: { fontWeight: '400', textAlign: 'center' },
1360
+ emptyText: { fontSize: 13, marginTop: 3, textAlign: 'center' },
1361
+ footer: { flexDirection: 'row', gap: 8, paddingTop: 10, paddingHorizontal: 4, borderTopWidth: StyleSheet.hairlineWidth },
1362
+ footBtn: { height: 44, paddingHorizontal: 18, alignItems: 'center', justifyContent: 'center' },
1363
+ footMain: { flex: 1 },
1364
+ footText: { fontSize: 15, fontWeight: '400' },
1365
+ footStrong: { fontWeight: '400' },
1366
+ footDense: {
1367
+ height: FOOT_H,
1368
+ flexDirection: 'row',
1369
+ alignItems: 'center',
1370
+ paddingHorizontal: 8,
1371
+ gap: 8,
1372
+ borderTopWidth: StyleSheet.hairlineWidth,
1373
+ },
1374
+ footGhost: { height: 32, paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center' },
1375
+ footSolid: { height: 32, paddingHorizontal: 16, alignItems: 'center', justifyContent: 'center' },
1376
+ footMid: { flex: 1, alignItems: 'center', justifyContent: 'center' },
1377
+ footDenseText: { fontSize: 14, fontWeight: '400' },
1378
+ dropdown: {
1379
+ position: 'absolute',
1380
+ borderWidth: StyleSheet.hairlineWidth,
1381
+ shadowColor: '#000',
1382
+ shadowRadius: 28,
1383
+ shadowOffset: { width: 0, height: 10 },
1384
+ elevation: 10,
1385
+ overflow: 'hidden',
1386
+ },
1387
+ row: { minHeight: 52, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, gap: 12 },
1388
+ rowDense: { minHeight: 42, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, gap: 10 },
1389
+ rowIcon: { minWidth: 24, alignItems: 'center' },
1390
+ rowText: { flex: 1, paddingVertical: 8 },
1391
+ rowLabel: { fontWeight: '400' },
1392
+ rowLabelOn: { fontWeight: '400' },
1393
+ rowDesc: { marginTop: 1, fontWeight: '400' },
1394
+ match: { fontWeight: '400' },
1395
+ checkWrap: { width: 22, height: 22, alignItems: 'center', justifyContent: 'center' },
1396
+ box: { borderWidth: 1.6, alignItems: 'center', justifyContent: 'center' },
1397
+ boxFill: { position: 'absolute', top: -1.6, left: -1.6, right: -1.6, bottom: -1.6, alignItems: 'center', justifyContent: 'center' },
1398
+ });