@expokit/cli 0.0.0-stage → 0.2.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 (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -2
  3. package/bin/expo-kit.js +1204 -0
  4. package/bin/lib.mjs +343 -0
  5. package/bin/mcp.mjs +338 -0
  6. package/package.json +44 -4
  7. package/registry/_docs/accordion.json +99 -0
  8. package/registry/_docs/action-sheet.json +110 -0
  9. package/registry/_docs/alert.json +127 -0
  10. package/registry/_docs/avatar.json +203 -0
  11. package/registry/_docs/badge.json +117 -0
  12. package/registry/_docs/bottom-nav.json +119 -0
  13. package/registry/_docs/bottom-sheet.json +158 -0
  14. package/registry/_docs/bullet.json +24 -0
  15. package/registry/_docs/button-3d.json +122 -0
  16. package/registry/_docs/button.json +115 -0
  17. package/registry/_docs/calendar.json +182 -0
  18. package/registry/_docs/card-stack.json +108 -0
  19. package/registry/_docs/card.json +111 -0
  20. package/registry/_docs/carousel.json +102 -0
  21. package/registry/_docs/checkbox.json +93 -0
  22. package/registry/_docs/checkout.json +102 -0
  23. package/registry/_docs/chip-group.json +116 -0
  24. package/registry/_docs/color-swatch.json +66 -0
  25. package/registry/_docs/confetti.json +82 -0
  26. package/registry/_docs/coupon.json +176 -0
  27. package/registry/_docs/cube-pager.json +84 -0
  28. package/registry/_docs/dashboard.json +120 -0
  29. package/registry/_docs/date-picker.json +249 -0
  30. package/registry/_docs/dialog.json +162 -0
  31. package/registry/_docs/drawer.json +171 -0
  32. package/registry/_docs/fab.json +120 -0
  33. package/registry/_docs/flip-card.json +111 -0
  34. package/registry/_docs/floating-input.json +191 -0
  35. package/registry/_docs/gift-card.json +108 -0
  36. package/registry/_docs/glass-button.json +118 -0
  37. package/registry/_docs/glass-nav-bar.json +78 -0
  38. package/registry/_docs/glass-search-bar.json +90 -0
  39. package/registry/_docs/glass-segmented.json +66 -0
  40. package/registry/_docs/glass-sheet.json +108 -0
  41. package/registry/_docs/glass-tab-bar.json +76 -0
  42. package/registry/_docs/glass.json +78 -0
  43. package/registry/_docs/inbox.json +72 -0
  44. package/registry/_docs/isometric-bar.json +72 -0
  45. package/registry/_docs/kit.json +11 -0
  46. package/registry/_docs/kpi-card.json +11 -0
  47. package/registry/_docs/like-button.json +72 -0
  48. package/registry/_docs/liquidglass-tab.json +27 -0
  49. package/registry/_docs/list-item.json +200 -0
  50. package/registry/_docs/loyalty-card.json +108 -0
  51. package/registry/_docs/morph-button.json +108 -0
  52. package/registry/_docs/number-ticker.json +96 -0
  53. package/registry/_docs/offers.json +78 -0
  54. package/registry/_docs/onboarding.json +66 -0
  55. package/registry/_docs/otp-input.json +134 -0
  56. package/registry/_docs/pagination-dots.json +88 -0
  57. package/registry/_docs/parallax-layers.json +84 -0
  58. package/registry/_docs/parallax-scroll.json +186 -0
  59. package/registry/_docs/password-input.json +92 -0
  60. package/registry/_docs/payment-card.json +153 -0
  61. package/registry/_docs/pixel-bar.json +66 -0
  62. package/registry/_docs/polar-area.json +78 -0
  63. package/registry/_docs/profile.json +84 -0
  64. package/registry/_docs/progress-steps.json +92 -0
  65. package/registry/_docs/progress.json +132 -0
  66. package/registry/_docs/radio-group.json +92 -0
  67. package/registry/_docs/rating.json +90 -0
  68. package/registry/_docs/ring-carousel.json +102 -0
  69. package/registry/_docs/scratch-card.json +90 -0
  70. package/registry/_docs/search-bar.json +140 -0
  71. package/registry/_docs/search-results.json +120 -0
  72. package/registry/_docs/segmented-control.json +103 -0
  73. package/registry/_docs/select.json +252 -0
  74. package/registry/_docs/settings.json +72 -0
  75. package/registry/_docs/shimmer-button.json +78 -0
  76. package/registry/_docs/sign-in.json +198 -0
  77. package/registry/_docs/skeleton.json +142 -0
  78. package/registry/_docs/slide-to-confirm.json +84 -0
  79. package/registry/_docs/slider.json +157 -0
  80. package/registry/_docs/spin-wheel.json +114 -0
  81. package/registry/_docs/spinner.json +91 -0
  82. package/registry/_docs/stepper.json +168 -0
  83. package/registry/_docs/swipe-deck.json +102 -0
  84. package/registry/_docs/swipeable-row.json +72 -0
  85. package/registry/_docs/switch.json +104 -0
  86. package/registry/_docs/tabs.json +105 -0
  87. package/registry/_docs/tag-input.json +128 -0
  88. package/registry/_docs/textarea.json +175 -0
  89. package/registry/_docs/ticket-card.json +174 -0
  90. package/registry/_docs/ticket-shape.json +11 -0
  91. package/registry/_docs/tilt-card.json +141 -0
  92. package/registry/_docs/time-picker.json +253 -0
  93. package/registry/_docs/timeline-chart.json +30 -0
  94. package/registry/_docs/timeline.json +156 -0
  95. package/registry/_docs/toast.json +161 -0
  96. package/registry/_docs/treemap.json +30 -0
  97. package/registry/_docs/waterfall.json +36 -0
  98. package/registry/_docs/wheel-list.json +102 -0
  99. package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
  100. package/registry/accordion/files/src/lib/kit.tsx +219 -0
  101. package/registry/accordion.json +17 -0
  102. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
  103. package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
  104. package/registry/action-sheet.json +18 -0
  105. package/registry/alert/files/src/components/ui/alert.tsx +654 -0
  106. package/registry/alert/files/src/components/ui/button.tsx +274 -0
  107. package/registry/alert/files/src/lib/kit.tsx +219 -0
  108. package/registry/alert.json +20 -0
  109. package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
  110. package/registry/avatar/files/src/lib/kit.tsx +219 -0
  111. package/registry/avatar.json +18 -0
  112. package/registry/badge/files/src/components/ui/badge.tsx +316 -0
  113. package/registry/badge/files/src/lib/kit.tsx +219 -0
  114. package/registry/badge.json +17 -0
  115. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
  116. package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
  117. package/registry/bottom-nav.json +18 -0
  118. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
  119. package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
  120. package/registry/bottom-sheet.json +17 -0
  121. package/registry/bullet.json +14 -0
  122. package/registry/button/files/src/components/ui/button.tsx +274 -0
  123. package/registry/button/files/src/lib/kit.tsx +219 -0
  124. package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
  125. package/registry/button-3d/files/src/lib/kit.tsx +219 -0
  126. package/registry/button-3d.json +18 -0
  127. package/registry/button.json +18 -0
  128. package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
  129. package/registry/calendar/files/src/lib/kit.tsx +219 -0
  130. package/registry/calendar.json +17 -0
  131. package/registry/card/files/src/components/ui/card.tsx +300 -0
  132. package/registry/card/files/src/lib/kit.tsx +219 -0
  133. package/registry/card-stack.json +15 -0
  134. package/registry/card.json +17 -0
  135. package/registry/carousel.json +15 -0
  136. package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
  137. package/registry/checkbox/files/src/lib/kit.tsx +219 -0
  138. package/registry/checkbox.json +17 -0
  139. package/registry/checkout.json +26 -0
  140. package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
  141. package/registry/chip-group/files/src/lib/kit.tsx +219 -0
  142. package/registry/chip-group.json +17 -0
  143. package/registry/color-swatch.json +17 -0
  144. package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
  145. package/registry/confetti/files/src/lib/kit.tsx +219 -0
  146. package/registry/confetti.json +15 -0
  147. package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
  148. package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
  149. package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
  150. package/registry/coupon/files/src/lib/kit.tsx +219 -0
  151. package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
  152. package/registry/coupon.json +23 -0
  153. package/registry/cube-pager.json +15 -0
  154. package/registry/dashboard.json +32 -0
  155. package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
  156. package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
  157. package/registry/date-picker/files/src/lib/kit.tsx +219 -0
  158. package/registry/date-picker.json +19 -0
  159. package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
  160. package/registry/dialog/files/src/lib/kit.tsx +219 -0
  161. package/registry/dialog.json +18 -0
  162. package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
  163. package/registry/drawer/files/src/lib/kit.tsx +219 -0
  164. package/registry/drawer.json +18 -0
  165. package/registry/fab.json +18 -0
  166. package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
  167. package/registry/flip-card/files/src/lib/kit.tsx +219 -0
  168. package/registry/flip-card.json +15 -0
  169. package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
  170. package/registry/floating-input/files/src/lib/kit.tsx +219 -0
  171. package/registry/floating-input.json +17 -0
  172. package/registry/gift-card.json +21 -0
  173. package/registry/glass-button.json +22 -0
  174. package/registry/glass-nav-bar.json +21 -0
  175. package/registry/glass-search-bar.json +21 -0
  176. package/registry/glass-segmented.json +22 -0
  177. package/registry/glass-sheet.json +22 -0
  178. package/registry/glass-tab-bar.json +22 -0
  179. package/registry/glass.json +19 -0
  180. package/registry/inbox.json +30 -0
  181. package/registry/isometric-bar.json +14 -0
  182. package/registry/kit/files/src/lib/kit.tsx +219 -0
  183. package/registry/kit.json +11 -0
  184. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
  185. package/registry/kpi-card.json +14 -0
  186. package/registry/like-button.json +18 -0
  187. package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
  188. package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
  189. package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
  190. package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
  191. package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
  192. package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
  193. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
  194. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
  195. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
  196. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
  197. package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
  198. package/registry/liquidglass-tab.json +23 -0
  199. package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
  200. package/registry/list-item/files/src/lib/kit.tsx +219 -0
  201. package/registry/list-item.json +17 -0
  202. package/registry/loyalty-card.json +23 -0
  203. package/registry/morph-button.json +18 -0
  204. package/registry/number-ticker.json +15 -0
  205. package/registry/offers.json +24 -0
  206. package/registry/onboarding.json +17 -0
  207. package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
  208. package/registry/otp-input/files/src/lib/kit.tsx +219 -0
  209. package/registry/otp-input.json +17 -0
  210. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
  211. package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
  212. package/registry/pagination-dots.json +15 -0
  213. package/registry/parallax-layers.json +15 -0
  214. package/registry/parallax-scroll.json +17 -0
  215. package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
  216. package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
  217. package/registry/password-input/files/src/lib/kit.tsx +219 -0
  218. package/registry/password-input.json +19 -0
  219. package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
  220. package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
  221. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
  222. package/registry/payment-card/files/src/lib/kit.tsx +219 -0
  223. package/registry/payment-card.json +21 -0
  224. package/registry/pixel-bar.json +14 -0
  225. package/registry/polar-area.json +14 -0
  226. package/registry/profile.json +17 -0
  227. package/registry/progress/files/src/components/ui/progress.tsx +883 -0
  228. package/registry/progress/files/src/lib/kit.tsx +219 -0
  229. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
  230. package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
  231. package/registry/progress-steps.json +18 -0
  232. package/registry/progress.json +17 -0
  233. package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
  234. package/registry/radio-group/files/src/lib/kit.tsx +219 -0
  235. package/registry/radio-group.json +18 -0
  236. package/registry/rating.json +18 -0
  237. package/registry/ring-carousel.json +15 -0
  238. package/registry/scratch-card.json +18 -0
  239. package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
  240. package/registry/search-bar/files/src/lib/kit.tsx +219 -0
  241. package/registry/search-bar.json +17 -0
  242. package/registry/search-results.json +30 -0
  243. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
  244. package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
  245. package/registry/segmented-control.json +18 -0
  246. package/registry/select/files/src/components/ui/select.tsx +1398 -0
  247. package/registry/select/files/src/lib/kit.tsx +219 -0
  248. package/registry/select.json +17 -0
  249. package/registry/settings.json +26 -0
  250. package/registry/shimmer-button.json +17 -0
  251. package/registry/sign-in.json +26 -0
  252. package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
  253. package/registry/skeleton/files/src/lib/kit.tsx +219 -0
  254. package/registry/skeleton.json +17 -0
  255. package/registry/slide-to-confirm.json +18 -0
  256. package/registry/slider/files/src/components/ui/slider.tsx +716 -0
  257. package/registry/slider/files/src/lib/kit.tsx +219 -0
  258. package/registry/slider.json +17 -0
  259. package/registry/spin-wheel.json +18 -0
  260. package/registry/spinner/files/src/components/ui/spinner.tsx +335 -0
  261. package/registry/spinner/files/src/lib/kit.tsx +219 -0
  262. package/registry/spinner.json +17 -0
  263. package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
  264. package/registry/stepper/files/src/lib/kit.tsx +219 -0
  265. package/registry/stepper.json +17 -0
  266. package/registry/swipe-deck.json +15 -0
  267. package/registry/swipeable-row.json +15 -0
  268. package/registry/switch/files/src/components/ui/switch.tsx +344 -0
  269. package/registry/switch/files/src/lib/kit.tsx +219 -0
  270. package/registry/switch.json +18 -0
  271. package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
  272. package/registry/tabs/files/src/lib/kit.tsx +219 -0
  273. package/registry/tabs.json +17 -0
  274. package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
  275. package/registry/tag-input/files/src/lib/kit.tsx +219 -0
  276. package/registry/tag-input.json +17 -0
  277. package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
  278. package/registry/textarea/files/src/lib/kit.tsx +219 -0
  279. package/registry/textarea.json +17 -0
  280. package/registry/ticket-card.json +24 -0
  281. package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
  282. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
  283. package/registry/ticket-shape.json +17 -0
  284. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
  285. package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
  286. package/registry/tilt-card.json +17 -0
  287. package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
  288. package/registry/time-picker/files/src/lib/kit.tsx +219 -0
  289. package/registry/time-picker.json +17 -0
  290. package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
  291. package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
  292. package/registry/timeline/files/src/lib/kit.tsx +219 -0
  293. package/registry/timeline-chart.json +14 -0
  294. package/registry/timeline.json +20 -0
  295. package/registry/toast/files/src/components/ui/button.tsx +274 -0
  296. package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
  297. package/registry/toast/files/src/lib/kit.tsx +219 -0
  298. package/registry/toast.json +20 -0
  299. package/registry/treemap.json +14 -0
  300. package/registry/waterfall.json +14 -0
  301. package/registry/wheel-list.json +17 -0
  302. package/schema/components.schema.json +13 -0
@@ -0,0 +1,796 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ Modal,
6
+ PanResponder,
7
+ Pressable,
8
+ ScrollView,
9
+ StyleSheet,
10
+ Text,
11
+ View,
12
+ useWindowDimensions,
13
+ type StyleProp,
14
+ type TextStyle,
15
+ type ViewStyle,
16
+ } from 'react-native';
17
+ import Svg, { Circle, Path, Rect } from 'react-native-svg';
18
+
19
+ import {
20
+ Calendar,
21
+ addDays,
22
+ isSameDay,
23
+ startOfDay,
24
+ type CalendarMarks,
25
+ type CalendarProps,
26
+ type DateRange,
27
+ } from '@/components/ui/calendar';
28
+ import { alpha, motion, readableOn, useKit } from '@/lib/kit';
29
+
30
+ export type { DateRange } from '@/components/ui/calendar';
31
+ export type DatePickerSize = 'sm' | 'md' | 'lg';
32
+ export type DatePickerPresentation = 'auto' | 'sheet' | 'dialog' | 'popover';
33
+
34
+ /** A one-tap shortcut. `value` may be a function so relative presets are computed when tapped. */
35
+ export interface DatePickerPreset<V> {
36
+ label: string;
37
+ value: V | (() => V);
38
+ }
39
+
40
+ export interface DatePickerBaseProps {
41
+ /** Rests inside the field, floats onto the border once open or filled. */
42
+ label: string;
43
+ /** `false` puts the label above the field as static text (no notch) and shows `placeholder` inside. */
44
+ floatingLabel?: boolean;
45
+ /** Shown in the field while nothing is picked (only while open when the label floats). */
46
+ placeholder?: string;
47
+ /** Sheet heading (default: label). */
48
+ title?: string;
49
+ /** 'auto' = sheet on phones, dialog on wide screens · 'sheet' always slides up (drag to dismiss) · 'dialog' always a centred modal · 'popover' drops under the field. */
50
+ presentation?: DatePickerPresentation;
51
+ size?: DatePickerSize;
52
+ /** Custom formatter for each date in the field. */
53
+ format?: (date: Date) => string;
54
+ /** Intl options for the field text (default `{ day: 'numeric', month: 'short', year: 'numeric' }`). */
55
+ formatOptions?: Intl.DateTimeFormatOptions;
56
+ /** BCP 47 locale for the field and the calendar. */
57
+ locale?: string;
58
+ /** Stage the choice and show Clear / Done; otherwise picking closes the picker. */
59
+ confirm?: boolean;
60
+ /** Show an × in the field that clears the value. */
61
+ clearable?: boolean;
62
+ minDate?: Date;
63
+ maxDate?: Date;
64
+ isDateDisabled?: (date: Date) => boolean;
65
+ markedDates?: CalendarMarks;
66
+ weekStartsOn?: 0 | 1;
67
+ showOutsideDays?: boolean;
68
+ /** Error message: red border, icon message and a single shake. */
69
+ error?: string;
70
+ /** Valid state: success border and a check. A string is shown under the field. */
71
+ success?: boolean | string;
72
+ helper?: string;
73
+ disabled?: boolean;
74
+ /** Focus, selection and halo colour (default: theme accent). */
75
+ accent?: string;
76
+ /** Background behind the field — the floating label cuts its notch with this colour. */
77
+ surface?: string;
78
+ onOpenChange?: (open: boolean) => void;
79
+ /** Root wrapper (the one that shakes) — margins, width, flex. */
80
+ style?: StyleProp<ViewStyle>;
81
+ /**
82
+ * The trigger box: background, border width / colour, radius, height, padding. Applied after the
83
+ * computed style, so it wins. A `borderColor` replaces the resting border only — open (accent),
84
+ * error and success colours still take over. A numeric `height` / `borderRadius` also re-centres
85
+ * the label and resizes the focus halo.
86
+ */
87
+ fieldStyle?: StyleProp<ViewStyle>;
88
+ /** The formatted date text in the trigger. */
89
+ inputStyle?: StyleProp<TextStyle>;
90
+ /**
91
+ * The label, floating or above the field. Its `color` is the resting colour; open (accent), error
92
+ * and success colours still take over. A `fontSize` sets the resting size and the label floats at
93
+ * the same ratio; the notch follows the rendered label width.
94
+ */
95
+ labelStyle?: StyleProp<TextStyle>;
96
+ /** Helper / error / success text under the field. `color` applies to the helper; error and success keep their tone colour. */
97
+ messageStyle?: StyleProp<TextStyle>;
98
+ /** Colour of the placeholder in the trigger (default: theme mutedForeground). */
99
+ placeholderTextColor?: string;
100
+ /** Calendar icon and clear ✕ colour at rest (the calendar turns accent while open). */
101
+ iconColor?: string;
102
+ /** The sheet / dialog / popover panel — background, border, radius, padding, shadow. Don't pass `transform` or `opacity`: they animate. */
103
+ menuStyle?: StyleProp<ViewStyle>;
104
+ }
105
+
106
+ export interface DatePickerSingleProps {
107
+ mode?: 'single';
108
+ value?: Date | null;
109
+ defaultValue?: Date | null;
110
+ onChange?: (date: Date | null) => void;
111
+ /** `true` for Today / Tomorrow / Next Monday / In a week / In a month, or your own list. */
112
+ presets?: boolean | DatePickerPreset<Date>[];
113
+ }
114
+
115
+ export interface DatePickerRangeProps {
116
+ mode: 'range';
117
+ value?: DateRange | null;
118
+ defaultValue?: DateRange | null;
119
+ onChange?: (range: DateRange | null) => void;
120
+ /** `true` for Today / Next 7 days / Next weekend / This month / Last 7 days / Last 30 days, or your own list. */
121
+ presets?: boolean | DatePickerPreset<DateRange>[];
122
+ }
123
+
124
+ export type DatePickerProps = DatePickerBaseProps & (DatePickerSingleProps | DatePickerRangeProps);
125
+
126
+ type Picked = Date | DateRange | null;
127
+ type FlatProps = DatePickerBaseProps & {
128
+ mode?: 'single' | 'range';
129
+ value?: Picked;
130
+ defaultValue?: Picked;
131
+ onChange?: (value: Picked) => void;
132
+ presets?: boolean | DatePickerPreset<Picked>[];
133
+ };
134
+
135
+ const SIZES: Record<DatePickerSize, { h: number; font: number; pad: number; icon: number }> = {
136
+ sm: { h: 46, font: 15, pad: 12, icon: 17 },
137
+ md: { h: 56, font: 16, pad: 16, icon: 19 },
138
+ lg: { h: 66, font: 17.5, pad: 18, icon: 21 },
139
+ };
140
+ const FLOAT_SCALE = 0.78;
141
+ /** Font size of the static label used when `floatingLabel` is false. */
142
+ const LABEL_ABOVE_FONT: Record<DatePickerSize, number> = { sm: 13, md: 14, lg: 14.5 };
143
+ const EMPTY_RANGE: DateRange = { start: null, end: null };
144
+
145
+ const isRange = (v: Picked): v is DateRange => !!v && !(v instanceof Date);
146
+
147
+ /** Built-in single-date shortcuts, computed relative to now. */
148
+ export function singleDatePresets(): DatePickerPreset<Date>[] {
149
+ const today = () => startOfDay(new Date());
150
+ return [
151
+ { label: 'Today', value: today },
152
+ { label: 'Tomorrow', value: () => addDays(today(), 1) },
153
+ { label: 'Next Monday', value: () => addDays(today(), ((8 - today().getDay()) % 7) || 7) },
154
+ { label: 'In a week', value: () => addDays(today(), 7) },
155
+ { label: 'In a month', value: () => new Date(today().getFullYear(), today().getMonth() + 1, today().getDate()) },
156
+ ];
157
+ }
158
+
159
+ /** Built-in range shortcuts, computed relative to now. */
160
+ export function rangeDatePresets(): DatePickerPreset<DateRange>[] {
161
+ const today = () => startOfDay(new Date());
162
+ return [
163
+ { label: 'Today', value: () => ({ start: today(), end: today() }) },
164
+ { label: 'Next 7 days', value: () => ({ start: today(), end: addDays(today(), 6) }) },
165
+ {
166
+ label: 'Next weekend',
167
+ value: () => {
168
+ const sat = addDays(today(), ((6 - today().getDay() + 7) % 7) || 7);
169
+ return { start: sat, end: addDays(sat, 1) };
170
+ },
171
+ },
172
+ {
173
+ label: 'This month',
174
+ value: () => {
175
+ const t = today();
176
+ return { start: new Date(t.getFullYear(), t.getMonth(), 1), end: new Date(t.getFullYear(), t.getMonth() + 1, 0) };
177
+ },
178
+ },
179
+ { label: 'Last 7 days', value: () => ({ start: addDays(today(), -6), end: today() }) },
180
+ { label: 'Last 30 days', value: () => ({ start: addDays(today(), -29), end: today() }) },
181
+ ];
182
+ }
183
+
184
+ /**
185
+ * Date field with a floating label and focus halo that opens the Calendar in a spring sheet
186
+ * (drag the grabber to dismiss, a dialog on wide screens) or an anchored popover. Single or
187
+ * range mode, one-tap presets, optional Clear / Done confirmation, Intl formatting, error
188
+ * shake and success check.
189
+ */
190
+ export function DatePicker(props: DatePickerProps) {
191
+ const {
192
+ mode = 'single',
193
+ value,
194
+ defaultValue = null,
195
+ onChange,
196
+ presets,
197
+ label,
198
+ floatingLabel = true,
199
+ placeholder,
200
+ title,
201
+ presentation = 'auto',
202
+ size = 'md',
203
+ format,
204
+ formatOptions,
205
+ locale,
206
+ confirm = false,
207
+ clearable = false,
208
+ minDate,
209
+ maxDate,
210
+ isDateDisabled,
211
+ markedDates,
212
+ weekStartsOn,
213
+ showOutsideDays,
214
+ error,
215
+ success,
216
+ helper,
217
+ disabled,
218
+ accent,
219
+ surface,
220
+ onOpenChange,
221
+ style,
222
+ fieldStyle,
223
+ inputStyle,
224
+ labelStyle,
225
+ messageStyle,
226
+ placeholderTextColor,
227
+ iconColor,
228
+ menuStyle,
229
+ } = props as unknown as FlatProps;
230
+ const { colors, radius, font } = useKit();
231
+ const ff = font.family ? { fontFamily: font.family } : null;
232
+ const tint = accent ?? colors.accent;
233
+ const bg = surface ?? colors.background;
234
+ // The user's border colour only replaces the resting one; numeric height / radius drive the layout maths.
235
+ const { borderColor: userBorder, ...userField } = StyleSheet.flatten(fieldStyle) ?? {};
236
+ const userLabelColor = StyleSheet.flatten(labelStyle)?.color as string | undefined;
237
+ const base = SIZES[size];
238
+ const sz = typeof userField.height === 'number' ? { ...base, h: userField.height } : base;
239
+ const fieldRadius =
240
+ typeof userField.borderRadius === 'number' ? userField.borderRadius : size === 'sm' ? radius.md - 2 : size === 'lg' ? radius.lg : radius.md;
241
+ const restIcon = iconColor ?? colors.mutedForeground;
242
+ const { width: winW, height: winH } = useWindowDimensions();
243
+ const ranged = mode === 'range';
244
+
245
+ const [inner, setInner] = useState<Picked>(defaultValue);
246
+ const current = value !== undefined ? value : inner;
247
+ const hasValue = isRange(current) ? !!current.start : !!current;
248
+
249
+ const [open, setOpen] = useState(false);
250
+ const [mounted, setMounted] = useState(false);
251
+ const [draft, setDraft] = useState<Picked>(null);
252
+ const [calMonth, setCalMonth] = useState(new Date());
253
+ const [anchor, setAnchor] = useState({ x: 0, y: 0, w: 0, h: 0 });
254
+ const fieldRef = useRef<View>(null);
255
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
256
+
257
+ const ok = !error && !!success;
258
+ const floating = open || hasValue;
259
+ const float = useRef(new Animated.Value(floating ? 1 : 0)).current; // native
260
+ const focus = useRef(new Animated.Value(0)).current; // JS: border + label colour
261
+ const halo = useRef(new Animated.Value(0)).current; // native
262
+ const show = useRef(new Animated.Value(0)).current; // native: backdrop + sheet
263
+ const drag = useRef(new Animated.Value(0)).current; // native
264
+ const shake = useRef(new Animated.Value(0)).current; // native
265
+ const check = useRef(new Animated.Value(ok ? 1 : 0)).current; // native
266
+
267
+ const asSheet = presentation !== 'popover';
268
+ // Centred dialog instead of a bottom sheet.
269
+ const wide = presentation === 'dialog' || (presentation === 'auto' && winW >= 640);
270
+
271
+ useEffect(() => {
272
+ Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
273
+ }, [floating, float]);
274
+ useEffect(() => {
275
+ Animated.timing(focus, { toValue: open ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
276
+ Animated.spring(halo, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
277
+ }, [open, focus, halo]);
278
+ useEffect(() => {
279
+ Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
280
+ }, [ok, check]);
281
+ useEffect(() => {
282
+ if (!error) return;
283
+ shake.setValue(0);
284
+ Animated.sequence([10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))).start();
285
+ }, [error, shake]);
286
+ useEffect(() => {
287
+ if (open) {
288
+ drag.setValue(0);
289
+ Animated.spring(show, { toValue: 1, useNativeDriver: true, ...(asSheet ? motion.gentle : motion.snappy) }).start();
290
+ } else if (mounted) {
291
+ Animated.timing(show, { toValue: 0, duration: motion.fast + 40, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(
292
+ ({ finished }) => finished && setMounted(false)
293
+ );
294
+ }
295
+ // eslint-disable-next-line react-hooks/exhaustive-deps
296
+ }, [open]);
297
+ useEffect(() => () => {
298
+ if (closeTimer.current) clearTimeout(closeTimer.current);
299
+ }, []);
300
+
301
+ const setOpenState = (next: boolean) => {
302
+ setOpen(next);
303
+ onOpenChange?.(next);
304
+ };
305
+ const close = () => {
306
+ if (closeTimer.current) clearTimeout(closeTimer.current);
307
+ setOpenState(false);
308
+ };
309
+ const closeSoon = (ms: number) => {
310
+ if (closeTimer.current) clearTimeout(closeTimer.current);
311
+ closeTimer.current = setTimeout(() => setOpenState(false), ms);
312
+ };
313
+
314
+ const openPicker = () => {
315
+ if (disabled) return;
316
+ setDraft(current);
317
+ setCalMonth((isRange(current) ? current.start : current) ?? (minDate && minDate > new Date() ? minDate : new Date()));
318
+ const go = () => {
319
+ setMounted(true);
320
+ setOpenState(true);
321
+ };
322
+ if (asSheet) return go();
323
+ fieldRef.current?.measureInWindow((x, y, w, h) => {
324
+ setAnchor({ x, y, w, h });
325
+ go();
326
+ });
327
+ };
328
+
329
+ const commit = (next: Picked) => {
330
+ if (value === undefined) setInner(next);
331
+ onChange?.(next);
332
+ };
333
+
334
+ const pick = (next: Date | DateRange) => {
335
+ setDraft(next);
336
+ if (confirm) return;
337
+ if (!isRange(next)) {
338
+ commit(next);
339
+ closeSoon(220);
340
+ } else if (next.start && next.end) {
341
+ commit(next);
342
+ closeSoon(360);
343
+ }
344
+ };
345
+
346
+ const done = () => {
347
+ let next = draft;
348
+ if (isRange(next)) next = next.start ? { start: next.start, end: next.end ?? next.start } : null;
349
+ commit(next);
350
+ close();
351
+ };
352
+
353
+ /* formatting */
354
+ const fmt = useMemo(() => {
355
+ const make = (o: Intl.DateTimeFormatOptions) => {
356
+ try {
357
+ const f = new Intl.DateTimeFormat(locale, o);
358
+ return (d: Date) => f.format(d);
359
+ } catch {
360
+ return (d: Date) => d.toDateString();
361
+ }
362
+ };
363
+ const opts = formatOptions ?? { day: 'numeric', month: 'short', year: 'numeric' };
364
+ const noYear: Intl.DateTimeFormatOptions = { ...opts };
365
+ delete noYear.year;
366
+ return {
367
+ full: format ?? make(opts),
368
+ short: format ?? make(formatOptions ? opts : noYear),
369
+ header: make({ weekday: 'short', day: 'numeric', month: 'short' }),
370
+ };
371
+ }, [format, formatOptions, locale]);
372
+
373
+ const describe = (v: Picked, f: (d: Date) => string = fmt.full) => {
374
+ if (!v) return '';
375
+ if (!isRange(v)) return f(v);
376
+ if (!v.start) return '';
377
+ if (!v.end) return `${f(v.start)} – …`;
378
+ if (isSameDay(v.start, v.end)) return f(v.start);
379
+ const sameYear = v.start.getFullYear() === v.end.getFullYear();
380
+ return `${(sameYear ? fmt.short : fmt.full)(v.start)} – ${fmt.full(v.end)}`;
381
+ };
382
+
383
+ const presetList = useMemo<DatePickerPreset<Picked>[]>(() => {
384
+ if (!presets) return [];
385
+ if (presets === true) return (ranged ? rangeDatePresets() : singleDatePresets()) as DatePickerPreset<Picked>[];
386
+ return presets;
387
+ }, [presets, ranged]);
388
+ const resolve = (p: DatePickerPreset<Picked>) => (typeof p.value === 'function' ? p.value() : p.value);
389
+ const presetOk = (v: Picked) => {
390
+ const ds = isRange(v) ? [v.start, v.end] : [v];
391
+ return ds.every(
392
+ (d) => !d || ((!minDate || d >= startOfDay(minDate)) && (!maxDate || d <= startOfDay(maxDate)) && !isDateDisabled?.(d))
393
+ );
394
+ };
395
+ const same = (a: Picked, b: Picked) =>
396
+ isRange(a) && isRange(b) ? isSameDay(a.start, b.start) && isSameDay(a.end, b.end) : !isRange(a) && !isRange(b) && isSameDay(a, b);
397
+
398
+ const pan = useMemo(
399
+ () =>
400
+ PanResponder.create({
401
+ onMoveShouldSetPanResponder: (_, g) => g.dy > 4 && Math.abs(g.dy) > Math.abs(g.dx),
402
+ onPanResponderMove: (_, g) => drag.setValue(Math.max(0, g.dy)),
403
+ onPanResponderRelease: (_, g) => {
404
+ if (g.dy > 110 || g.vy > 1.1) {
405
+ setOpen(false);
406
+ onOpenChange?.(false);
407
+ } else Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
408
+ },
409
+ onPanResponderTerminate: () => Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start(),
410
+ }),
411
+ [drag, onOpenChange]
412
+ );
413
+
414
+ const active = error ? colors.danger : tint;
415
+ const rest = error ? colors.danger : ok ? colors.success : ((userBorder as string | undefined) ?? colors.input);
416
+ const borderColor = focus.interpolate({ inputRange: [0, 1], outputRange: [rest, active] });
417
+ const labelColor = focus.interpolate({
418
+ inputRange: [0, 1],
419
+ outputRange: [error ? colors.danger : ok ? colors.success : (userLabelColor ?? colors.mutedForeground), active],
420
+ });
421
+ const topLabelColor = focus.interpolate({ inputRange: [0, 1], outputRange: [error ? colors.danger : (userLabelColor ?? colors.foreground), active] });
422
+
423
+ const draftDays =
424
+ isRange(draft) && draft.start && draft.end ? Math.round((draft.end.getTime() - draft.start.getTime()) / 86400000) + 1 : 0;
425
+ const calendarProps = (
426
+ ranged
427
+ ? { mode: 'range', value: isRange(draft) ? draft : EMPTY_RANGE, onChange: pick }
428
+ : { mode: 'single', value: isRange(draft) ? null : draft, onChange: pick }
429
+ ) as CalendarProps;
430
+
431
+ const subtitle = isRange(draft)
432
+ ? draft.start
433
+ ? `${fmt.header(draft.start)} → ${draft.end ? fmt.header(draft.end) : 'pick an end date'}`
434
+ : 'Pick a start date'
435
+ : draft
436
+ ? fmt.header(draft)
437
+ : placeholder ?? null;
438
+
439
+ const body = (
440
+ <>
441
+ {presetList.length ? (
442
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.presets} style={styles.presetScroll}>
443
+ {presetList.map((p) => {
444
+ const v = resolve(p);
445
+ const allowed = presetOk(v);
446
+ const on = same(v, draft);
447
+ return (
448
+ <Pressable
449
+ key={p.label}
450
+ disabled={!allowed}
451
+ onPress={() => {
452
+ const d = isRange(v) ? v.start : v;
453
+ if (d) setCalMonth(d);
454
+ setDraft(v);
455
+ if (!confirm) {
456
+ commit(v);
457
+ closeSoon(260);
458
+ }
459
+ }}
460
+ accessibilityRole="button"
461
+ accessibilityState={{ selected: on, disabled: !allowed }}
462
+ style={({ pressed }) => [
463
+ styles.preset,
464
+ {
465
+ borderRadius: radius.capsule(34),
466
+ backgroundColor: on ? alpha(tint, 0.14) : pressed ? colors.muted : 'transparent',
467
+ borderColor: on ? alpha(tint, 0.5) : colors.border,
468
+ opacity: allowed ? 1 : 0.4,
469
+ },
470
+ ]}
471
+ >
472
+ <Text style={[styles.presetText, ff, { color: on ? tint : colors.foreground }]}>{p.label}</Text>
473
+ </Pressable>
474
+ );
475
+ })}
476
+ </ScrollView>
477
+ ) : null}
478
+ <ScrollView style={styles.calendarScroll} contentContainerStyle={styles.calendar} showsVerticalScrollIndicator={false} bounces={false}>
479
+ <Calendar
480
+ {...calendarProps}
481
+ month={calMonth}
482
+ onMonthChange={setCalMonth}
483
+ minDate={minDate}
484
+ maxDate={maxDate}
485
+ isDateDisabled={isDateDisabled}
486
+ markedDates={markedDates}
487
+ weekStartsOn={weekStartsOn}
488
+ showOutsideDays={showOutsideDays}
489
+ locale={locale}
490
+ accent={tint}
491
+ />
492
+ </ScrollView>
493
+ {confirm ? (
494
+ <View style={[styles.footer, { borderTopColor: colors.border }]}>
495
+ <Pressable
496
+ onPress={() => setDraft(null)}
497
+ disabled={!draft}
498
+ accessibilityRole="button"
499
+ style={({ pressed }) => [styles.footBtn, { borderRadius: radius.md, opacity: draft ? (pressed ? 0.6 : 1) : 0.4 }]}
500
+ >
501
+ <Text style={[styles.footText, ff, { color: colors.foreground }]}>Clear</Text>
502
+ </Pressable>
503
+ <Pressable
504
+ onPress={done}
505
+ accessibilityRole="button"
506
+ style={({ pressed }) => [styles.footBtn, styles.footMain, { backgroundColor: tint, borderRadius: radius.md, opacity: pressed ? 0.85 : 1 }]}
507
+ >
508
+ <Text style={[styles.footText, ff, { color: readableOn(tint) }]}>
509
+ {draftDays > 1 ? `Done · ${draftDays} days` : 'Done'}
510
+ </Text>
511
+ </Pressable>
512
+ </View>
513
+ ) : null}
514
+ </>
515
+ );
516
+
517
+ const message = error ?? (typeof success === 'string' && ok ? success : undefined) ?? helper;
518
+ const tone = error ? 'error' : typeof success === 'string' && ok ? 'success' : 'helper';
519
+ const text = describe(current);
520
+ const popW = Math.min(winW - 24, Math.max(anchor.w, 328));
521
+ const popLeft = Math.max(12, Math.min(anchor.x, winW - popW - 12));
522
+ const popH = 400 + (presetList.length ? 52 : 0) + (confirm ? 64 : 0);
523
+ const roomBelow = winH - (anchor.y + anchor.h + 6) - 12;
524
+ const roomAbove = anchor.y - 6 - 12;
525
+ const below = popH <= roomBelow || roomBelow >= roomAbove;
526
+ const popMaxH = Math.max(220, below ? roomBelow : roomAbove);
527
+ const rightIcons = (clearable && hasValue ? 30 : 0) + (ok ? 28 : 0);
528
+
529
+ return (
530
+ <Animated.View style={[{ transform: [{ translateX: shake }] }, style]}>
531
+ {floatingLabel ? null : (
532
+ <Animated.Text
533
+ numberOfLines={1}
534
+ // The field already carries the label for screen readers.
535
+ accessible={false}
536
+ importantForAccessibility="no"
537
+ accessibilityElementsHidden
538
+ style={[styles.topLabel, ff, { fontSize: LABEL_ABOVE_FONT[size] }, labelStyle, { color: topLabelColor, opacity: disabled ? 0.5 : 1 }]}
539
+ >
540
+ {label}
541
+ </Animated.Text>
542
+ )}
543
+ <Pressable
544
+ ref={fieldRef}
545
+ onPress={openPicker}
546
+ disabled={disabled}
547
+ accessibilityRole="button"
548
+ accessibilityLabel={label}
549
+ accessibilityValue={{ text: text || 'Not set' }}
550
+ accessibilityState={{ expanded: open, disabled: !!disabled }}
551
+ style={{ opacity: disabled ? 0.5 : 1 }}
552
+ >
553
+ <Animated.View
554
+ pointerEvents="none"
555
+ style={[
556
+ styles.halo,
557
+ {
558
+ borderRadius: fieldRadius + 4,
559
+ borderColor: alpha(active, 0.2),
560
+ opacity: halo,
561
+ transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }],
562
+ },
563
+ ]}
564
+ />
565
+ <Animated.View style={[styles.field, { height: sz.h, borderRadius: fieldRadius, backgroundColor: bg }, userField, { borderColor }]}>
566
+ <Text
567
+ numberOfLines={1}
568
+ style={[
569
+ styles.value,
570
+ ff,
571
+ { fontSize: sz.font, color: text ? colors.foreground : colors.mutedForeground, paddingLeft: sz.pad, paddingRight: sz.icon + sz.pad + 8 + rightIcons },
572
+ inputStyle,
573
+ !text && { color: placeholderTextColor ?? colors.mutedForeground },
574
+ ]}
575
+ >
576
+ {text || (open || !floatingLabel ? placeholder ?? (ranged ? 'Select dates' : 'Select a date') : '')}
577
+ </Text>
578
+ {floatingLabel ? (
579
+ <Animated.View
580
+ pointerEvents="none"
581
+ style={[
582
+ styles.labelWrap,
583
+ {
584
+ left: sz.pad,
585
+ height: sz.h - 3,
586
+ transform: [
587
+ { translateY: float.interpolate({ inputRange: [0, 1], outputRange: [0, -sz.h / 2] }) },
588
+ { translateX: float.interpolate({ inputRange: [0, 1], outputRange: [0, -4] }) },
589
+ { scale: float.interpolate({ inputRange: [0, 1], outputRange: [1, FLOAT_SCALE] }) },
590
+ ],
591
+ },
592
+ ]}
593
+ >
594
+ {/* The notch: the label paints the surface over the border, so it matches the rendered width. */}
595
+ <Animated.Text numberOfLines={1} style={[styles.label, ff, { fontSize: sz.font, backgroundColor: bg }, labelStyle, { color: labelColor }]}>
596
+ {label}
597
+ </Animated.Text>
598
+ </Animated.View>
599
+ ) : null}
600
+ <View style={[styles.adorn, { right: sz.pad - 2 }]} pointerEvents="box-none">
601
+ <Animated.View
602
+ pointerEvents="none"
603
+ style={{
604
+ display: ok ? 'flex' : 'none',
605
+ opacity: check,
606
+ transform: [{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) }],
607
+ }}
608
+ >
609
+ <Icon name="check" size={sz.icon} color={colors.success} />
610
+ </Animated.View>
611
+ {clearable && hasValue && !disabled ? (
612
+ <Pressable
613
+ onPress={() => commit(null)}
614
+ hitSlop={12}
615
+ accessibilityRole="button"
616
+ accessibilityLabel={`Clear ${label}`}
617
+ style={[styles.clear, { backgroundColor: alpha(restIcon, 0.22) }]}
618
+ >
619
+ <Icon name="x" size={11} color={iconColor ?? colors.foreground} strokeWidth={2.4} />
620
+ </Pressable>
621
+ ) : null}
622
+ <Icon name="calendar" size={sz.icon} color={open ? active : restIcon} />
623
+ </View>
624
+ </Animated.View>
625
+ </Pressable>
626
+ {message ? <FieldMessage key={tone + message} text={message} tone={tone} style={messageStyle} /> : null}
627
+
628
+ <Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={close}>
629
+ <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: asSheet ? colors.overlay : 'transparent', opacity: show }]}>
630
+ <Pressable style={StyleSheet.absoluteFill} onPress={close} accessibilityLabel="Close" accessibilityRole="button" />
631
+ </Animated.View>
632
+ {asSheet ? (
633
+ <View style={[styles.sheetHost, wide && styles.centered]} pointerEvents="box-none">
634
+ <Animated.View
635
+ accessibilityViewIsModal
636
+ style={[
637
+ styles.sheet,
638
+ { backgroundColor: colors.popover, borderColor: colors.border, maxHeight: winH * 0.92 },
639
+ wide
640
+ ? { width: Math.min(420, winW - 48), borderRadius: radius.xl, paddingBottom: 14 }
641
+ : { alignSelf: 'stretch', borderTopLeftRadius: radius.xl + 4, borderTopRightRadius: radius.xl + 4, paddingBottom: 30 },
642
+ menuStyle,
643
+ wide
644
+ ? {
645
+ opacity: show,
646
+ transform: [
647
+ { scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.94, 1] }) },
648
+ { translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [16, 0] }), drag) },
649
+ ],
650
+ }
651
+ : { transform: [{ translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [winH * 0.85, 0] }), drag) }] },
652
+ ]}
653
+ >
654
+ <View {...pan.panHandlers} style={styles.sheetHeader}>
655
+ {!wide ? <View style={[styles.grabber, { backgroundColor: colors.border }]} /> : null}
656
+ <Text style={[styles.title, ff, { color: colors.foreground }]}>{title ?? label}</Text>
657
+ {subtitle ? (
658
+ <Text numberOfLines={1} style={[styles.subtitle, ff, { color: draft ? tint : colors.mutedForeground }]}>
659
+ {subtitle}
660
+ </Text>
661
+ ) : null}
662
+ </View>
663
+ {body}
664
+ </Animated.View>
665
+ </View>
666
+ ) : (
667
+ <Animated.View
668
+ accessibilityViewIsModal
669
+ style={[
670
+ styles.popover,
671
+ {
672
+ left: popLeft,
673
+ width: popW,
674
+ maxHeight: popMaxH,
675
+ backgroundColor: colors.popover,
676
+ borderColor: colors.border,
677
+ borderRadius: radius.xl,
678
+ transformOrigin: below ? 'top' : 'bottom',
679
+ },
680
+ menuStyle,
681
+ {
682
+ opacity: show,
683
+ transform: [
684
+ { translateY: show.interpolate({ inputRange: [0, 1], outputRange: [below ? -8 : 8, 0] }) },
685
+ { scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }) },
686
+ ],
687
+ },
688
+ below ? { top: anchor.y + anchor.h + 6 } : { bottom: Math.max(12, winH - anchor.y + 6) },
689
+ ]}
690
+ >
691
+ {body}
692
+ </Animated.View>
693
+ )}
694
+ </Modal>
695
+ </Animated.View>
696
+ );
697
+ }
698
+
699
+ /** Helper / error / success line that slides in; error and success carry an icon. */
700
+ function FieldMessage({ text, tone, style }: { text: string; tone: 'error' | 'success' | 'helper'; style?: StyleProp<TextStyle> }) {
701
+ const { colors, font } = useKit();
702
+ const enter = useRef(new Animated.Value(tone === 'helper' ? 1 : 0)).current; // native
703
+ useEffect(() => {
704
+ Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
705
+ }, [enter]);
706
+ const color = tone === 'error' ? colors.danger : tone === 'success' ? colors.success : colors.mutedForeground;
707
+ return (
708
+ <Animated.View
709
+ accessibilityLiveRegion="polite"
710
+ style={[styles.message, { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] }]}
711
+ >
712
+ {tone !== 'helper' ? <Icon name={tone === 'error' ? 'alert' : 'checkCircle'} size={14} color={color} /> : null}
713
+ <Text style={[styles.helper, font.family ? { fontFamily: font.family } : null, { color }, style, tone !== 'helper' && { color }]}>{text}</Text>
714
+ </Animated.View>
715
+ );
716
+ }
717
+
718
+ type IconName = 'calendar' | 'check' | 'checkCircle' | 'x' | 'alert';
719
+
720
+ function Icon({ name, size, color, strokeWidth = 1.8 }: { name: IconName; size: number; color: string; strokeWidth?: number }) {
721
+ const p = { stroke: color, strokeWidth, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
722
+ return (
723
+ <Svg width={size} height={size} viewBox="0 0 24 24">
724
+ {name === 'calendar' ? (
725
+ <>
726
+ <Rect x={3.5} y={5} width={17} height={15.5} rx={3} {...p} />
727
+ <Path d="M3.5 10h17M8 3v4M16 3v4" {...p} />
728
+ <Circle cx={8.5} cy={14.5} r={0.6} {...p} strokeWidth={2} />
729
+ <Circle cx={12} cy={14.5} r={0.6} {...p} strokeWidth={2} />
730
+ </>
731
+ ) : null}
732
+ {name === 'check' ? <Path d="m5 12.5 4.5 4.5L19 7.5" {...p} strokeWidth={2.2} /> : null}
733
+ {name === 'checkCircle' ? (
734
+ <>
735
+ <Circle cx={12} cy={12} r={9} {...p} />
736
+ <Path d="m8 12.3 2.7 2.7 5.3-5.6" {...p} />
737
+ </>
738
+ ) : null}
739
+ {name === 'x' ? <Path d="M6 6l12 12M18 6 6 18" {...p} /> : null}
740
+ {name === 'alert' ? (
741
+ <>
742
+ <Circle cx={12} cy={12} r={9} {...p} />
743
+ <Path d="M12 7.5v5.5M12 16.5v.01" {...p} strokeWidth={2.2} />
744
+ </>
745
+ ) : null}
746
+ </Svg>
747
+ );
748
+ }
749
+
750
+ const styles = StyleSheet.create({
751
+ field: { borderWidth: 1.5, justifyContent: 'center' },
752
+ halo: { position: 'absolute', top: -4, left: -4, right: -4, bottom: -4, borderWidth: 4 },
753
+ value: { fontVariant: ['tabular-nums'] },
754
+ labelWrap: { position: 'absolute', top: 0, right: 44, justifyContent: 'center', alignItems: 'flex-start', transformOrigin: 'left center' },
755
+ label: { fontWeight: '400', paddingHorizontal: 5, marginLeft: -5 },
756
+ topLabel: { fontWeight: '400', marginBottom: 6, marginHorizontal: 2 },
757
+ adorn: { position: 'absolute', top: 0, bottom: 0, flexDirection: 'row', alignItems: 'center', gap: 10 },
758
+ clear: { width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
759
+ message: { flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 6, marginLeft: 4 },
760
+ helper: { flex: 1, fontSize: 12.5 },
761
+ sheetHost: { flex: 1, justifyContent: 'flex-end' },
762
+ centered: { justifyContent: 'center', alignItems: 'center' },
763
+ sheet: {
764
+ borderWidth: StyleSheet.hairlineWidth,
765
+ shadowColor: '#000',
766
+ shadowOpacity: 0.18,
767
+ shadowRadius: 24,
768
+ shadowOffset: { width: 0, height: -4 },
769
+ elevation: 16,
770
+ },
771
+ sheetHeader: { alignItems: 'center', flexShrink: 0, paddingTop: 10, paddingBottom: 8, paddingHorizontal: 16 },
772
+ grabber: { width: 40, height: 5, borderRadius: 3, marginBottom: 12 },
773
+ title: { fontSize: 17, fontWeight: '400', letterSpacing: -0.2, marginTop: 4 },
774
+ subtitle: { fontSize: 13.5, fontWeight: '400', marginTop: 3, fontVariant: ['tabular-nums'] },
775
+ presetScroll: { flexGrow: 0, flexShrink: 0 },
776
+ calendarScroll: { flexGrow: 0, flexShrink: 1 },
777
+ presets: { gap: 8, paddingHorizontal: 16, paddingVertical: 8 },
778
+ preset: { height: 34, paddingHorizontal: 14, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
779
+ presetText: { fontSize: 13.5, fontWeight: '400' },
780
+ calendar: { paddingHorizontal: 16, paddingTop: 4, paddingBottom: 6 },
781
+ footer: { flexDirection: 'row', flexShrink: 0, gap: 8, paddingTop: 12, paddingHorizontal: 16, borderTopWidth: StyleSheet.hairlineWidth },
782
+ footBtn: { height: 46, paddingHorizontal: 20, alignItems: 'center', justifyContent: 'center' },
783
+ footMain: { flex: 1 },
784
+ footText: { fontSize: 15, fontWeight: '400' },
785
+ popover: {
786
+ position: 'absolute',
787
+ paddingTop: 10,
788
+ paddingBottom: 10,
789
+ borderWidth: StyleSheet.hairlineWidth,
790
+ shadowColor: '#000',
791
+ shadowOpacity: 0.16,
792
+ shadowRadius: 20,
793
+ shadowOffset: { width: 0, height: 10 },
794
+ elevation: 14,
795
+ },
796
+ });