@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,646 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ Pressable,
5
+ StyleSheet,
6
+ Text,
7
+ View,
8
+ type AccessibilityActionEvent,
9
+ type LayoutChangeEvent,
10
+ type StyleProp,
11
+ type TextStyle,
12
+ type ViewStyle,
13
+ } from 'react-native';
14
+ import Svg, { Path } from 'react-native-svg';
15
+
16
+ import { alpha, motion, readableOn, useKit } from '@/lib/kit';
17
+
18
+ /**
19
+ * 'outline' two round outlined buttons with the number between (Airbnb guests) ·
20
+ * 'filled' one tinted capsule holding −, value and + (iOS / segmented) ·
21
+ * 'cart' a single + (or "+ Add") at 0 that springs open into − 1 +, with a trash icon at 1 ·
22
+ * 'compact' small tinted buttons for dense rows.
23
+ */
24
+ export type StepperVariant = 'outline' | 'filled' | 'cart' | 'compact';
25
+ /** Older names, still accepted: default → outline, pill / segmented → filled, vertical → filled + vertical. */
26
+ export type StepperLegacyVariant = 'default' | 'pill' | 'segmented' | 'vertical';
27
+ export type StepperSize = 'sm' | 'md' | 'lg';
28
+
29
+ export interface StepperProps {
30
+ value?: number;
31
+ defaultValue?: number;
32
+ onValueChange?: (value: number) => void;
33
+ min?: number;
34
+ max?: number;
35
+ step?: number;
36
+ variant?: StepperVariant | StepperLegacyVariant;
37
+ /** 'sm' 32 · 'md' 36 · 'lg' 44pt buttons (touch area is always ≥ 44pt); the number and icons scale too. */
38
+ size?: StepperSize;
39
+ /** Stack the buttons above and below the number (outline, filled, compact). */
40
+ orientation?: 'horizontal' | 'vertical';
41
+ /** Formats the number (e.g. `(n) => '₹' + n`). Every changed character rolls. */
42
+ format?: (value: number) => string;
43
+ /** Static muted text after the number: `'kg'` or `(n) => (n === 1 ? 'night' : 'nights')`. */
44
+ suffix?: string | ((value: number) => string);
45
+ /** Hold a button to keep stepping, accelerating the longer you hold (default true). */
46
+ repeat?: boolean;
47
+ disabled?: boolean;
48
+ /** Highlight colour: icons (filled, compact), press tint (outline), fill (cart). Default theme accent. */
49
+ accent?: string;
50
+ /** Overrides the −/+ icon colour. */
51
+ iconColor?: string;
52
+ /** cart: text next to the + while collapsed at 0, e.g. "Add". */
53
+ addLabel?: string;
54
+ /** Title shown to the left of the control (the control moves to the right edge). */
55
+ label?: string;
56
+ /** Muted line under `label`. */
57
+ description?: string;
58
+ /** Read by screen readers, e.g. "Guests" (defaults to `label`). */
59
+ accessibilityLabel?: string;
60
+ /** The control, or the whole row when `label` / `description` is set. */
61
+ style?: StyleProp<ViewStyle>;
62
+ /** Applied to each −/+ button. */
63
+ buttonStyle?: StyleProp<ViewStyle>;
64
+ /** Applied to the number (fontSize, colour, weight…). */
65
+ valueStyle?: StyleProp<TextStyle>;
66
+ }
67
+
68
+ /** Every dimension in one table so the sizes read clearly different. */
69
+ const SIZES: Record<StepperSize | 'xs', { btn: number; font: number; icon: number; num: number }> = {
70
+ xs: { btn: 28, font: 15, icon: 12, num: 26 },
71
+ sm: { btn: 32, font: 15, icon: 13, num: 30 },
72
+ md: { btn: 36, font: 17, icon: 15, num: 36 },
73
+ lg: { btn: 44, font: 20, icon: 17, num: 44 },
74
+ };
75
+
76
+ const LEGACY: Record<StepperLegacyVariant, StepperVariant> = {
77
+ default: 'outline',
78
+ pill: 'filled',
79
+ segmented: 'filled',
80
+ vertical: 'filled',
81
+ };
82
+
83
+ const ICON = {
84
+ plus: 'M5 12h14M12 5v14',
85
+ minus: 'M5 12h14',
86
+ trash: 'M4.5 7h15M9.5 7V5.2c0-.66.54-1.2 1.2-1.2h2.6c.66 0 1.2.54 1.2 1.2V7M6.5 7l.8 11.6c.07 1.05.95 1.9 2 1.9h5.4c1.05 0 1.93-.85 2-1.9L17.5 7M10.2 11v5.5M13.8 11v5.5',
87
+ };
88
+
89
+ const decimals = (n: number) => (String(n).split('.')[1] ?? '').length;
90
+ const hitFor = (size: number) => Math.max(0, Math.ceil((44 - size) / 2));
91
+
92
+ /**
93
+ * Quantity control whose number rolls — each changed digit slides out as the new one slides in,
94
+ * up or down with the change. Buttons spring on press, hold to repeat (it speeds up), and a press
95
+ * past `min` / `max` gives a small bump. The `cart` variant grows from a single + into − 1 +.
96
+ */
97
+ export function Stepper({
98
+ value,
99
+ defaultValue,
100
+ onValueChange,
101
+ min = 0,
102
+ max = Number.POSITIVE_INFINITY,
103
+ step = 1,
104
+ variant: variantProp = 'outline',
105
+ size = 'md',
106
+ orientation,
107
+ format,
108
+ suffix,
109
+ repeat = true,
110
+ disabled,
111
+ accent,
112
+ iconColor,
113
+ addLabel,
114
+ label: title,
115
+ description,
116
+ accessibilityLabel,
117
+ style,
118
+ buttonStyle,
119
+ valueStyle,
120
+ }: StepperProps) {
121
+ const { colors, radius } = useKit();
122
+ const variant: StepperVariant = variantProp in LEGACY ? LEGACY[variantProp as StepperLegacyVariant] : (variantProp as StepperVariant);
123
+ const vertical = variant !== 'cart' && (orientation ? orientation === 'vertical' : variantProp === 'vertical');
124
+ const tint = accent ?? colors.accent;
125
+ const s = SIZES[variant === 'compact' ? 'xs' : size];
126
+
127
+ const [inner, setInner] = useState(defaultValue ?? Math.max(min, 0));
128
+ const current = value ?? inner;
129
+ const valueRef = useRef(current);
130
+ valueRef.current = current;
131
+
132
+ // Direction of the last change, so digits roll the right way.
133
+ const [track, setTrack] = useState({ v: current, dir: 1 });
134
+ if (track.v !== current) setTrack({ v: current, dir: current > track.v ? 1 : -1 });
135
+
136
+ // Press past a limit: the number nudges toward the wall and springs back.
137
+ const bump = useRef(new Animated.Value(0)).current;
138
+ const nudge = (dir: 1 | -1) => {
139
+ Animated.sequence([
140
+ Animated.timing(bump, { toValue: dir * 4, duration: 60, useNativeDriver: true }),
141
+ Animated.spring(bump, { toValue: 0, useNativeDriver: true, ...motion.bouncy }),
142
+ ]).start();
143
+ };
144
+
145
+ const places = Math.max(decimals(step), decimals(min));
146
+ const change = (dir: 1 | -1) => {
147
+ const raw = valueRef.current + dir * step;
148
+ const next = Math.min(max, Math.max(min, Number(raw.toFixed(places))));
149
+ if (next === valueRef.current) return false;
150
+ valueRef.current = next;
151
+ if (value === undefined) setInner(next);
152
+ onValueChange?.(next);
153
+ return true;
154
+ };
155
+
156
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
157
+ const stopRepeat = () => {
158
+ if (timer.current) clearTimeout(timer.current);
159
+ timer.current = null;
160
+ };
161
+ useEffect(() => stopRepeat, []);
162
+ useEffect(() => {
163
+ if (disabled) stopRepeat();
164
+ }, [disabled]);
165
+
166
+ const startRepeat = (dir: 1 | -1) => {
167
+ stopRepeat();
168
+ if (!change(dir)) return nudge(dir);
169
+ if (!repeat) return;
170
+ let count = 0;
171
+ const tick = () => {
172
+ count += 1;
173
+ if (!change(dir)) {
174
+ nudge(dir);
175
+ return stopRepeat();
176
+ }
177
+ timer.current = setTimeout(tick, count > 14 ? 40 : count > 6 ? 75 : 130);
178
+ };
179
+ timer.current = setTimeout(tick, 420);
180
+ };
181
+
182
+ const atMin = current <= min;
183
+ const atMax = current >= max;
184
+ const text = format ? format(current) : current.toFixed(places);
185
+ const suffixText = typeof suffix === 'function' ? suffix(current) : suffix;
186
+
187
+ // cart: collapses to a lone + at 0 (only when it can reach 0), and − becomes a trash can at 1.
188
+ const isCart = variant === 'cart';
189
+ const collapsible = isCart && min <= 0;
190
+ const collapsed = collapsible && current <= 0;
191
+ const removes = collapsible && current - step <= 0;
192
+
193
+ const onCart = isCart ? (accent ? readableOn(tint) : colors.accentForeground) : colors.foreground;
194
+ const fg = iconColor ?? (variant === 'outline' ? colors.foreground : isCart ? onCart : tint);
195
+ const numColor = isCart ? onCart : colors.foreground;
196
+ const flatValue = StyleSheet.flatten(valueStyle) ?? {};
197
+ const fontSize = flatValue.fontSize ?? s.font;
198
+ const numStyle: TextStyle = {
199
+ fontSize,
200
+ lineHeight: Math.round(fontSize * 1.3),
201
+ color: numColor,
202
+ ...flatValue,
203
+ };
204
+
205
+ const capsule = radius.capsule(s.btn);
206
+ const look: Record<StepperVariant, { box: ViewStyle; btn: Partial<ButtonLook> }> = {
207
+ outline: {
208
+ box: { gap: 2 },
209
+ btn: { borderColor: colors.input, borderWidth: 1, pressBg: alpha(tint, 0.08), pressBorder: tint },
210
+ },
211
+ filled: {
212
+ box: { backgroundColor: colors.muted, borderRadius: capsule },
213
+ btn: { pressBg: alpha(tint, 0.14) },
214
+ },
215
+ compact: {
216
+ box: { gap: 2 },
217
+ btn: { bg: alpha(tint, 0.13), pressBg: alpha(tint, 0.22) },
218
+ },
219
+ cart: {
220
+ box: {},
221
+ btn: { pressBg: alpha(onCart, 0.16) },
222
+ },
223
+ };
224
+ const btnW = variant === 'filled' && !vertical ? s.btn + 4 : s.btn;
225
+ const btnH = variant === 'filled' && vertical ? s.btn + 2 : s.btn;
226
+
227
+ const button = (dir: 1 | -1, glyph?: string, a11y?: string) => (
228
+ <StepButton
229
+ width={btnW}
230
+ height={btnH}
231
+ icon={s.icon}
232
+ glyph={glyph ?? (dir === 1 ? ICON.plus : ICON.minus)}
233
+ radius={capsule}
234
+ fg={fg}
235
+ look={look[variant].btn}
236
+ limit={dir === 1 ? atMax : atMin}
237
+ disabled={!!disabled}
238
+ label={a11y ?? `${dir === 1 ? 'Increase' : 'Decrease'}${title ? ` ${title}` : ''}`}
239
+ style={buttonStyle}
240
+ onPressIn={() => startRepeat(dir)}
241
+ onPressOut={stopRepeat}
242
+ />
243
+ );
244
+
245
+ const number = (
246
+ <View style={styles.roll}>
247
+ <RollingText text={text} dir={track.dir} style={numStyle} />
248
+ {suffixText ? (
249
+ <Text style={[styles.num, styles.suffix, numStyle, { fontSize: fontSize * 0.82, color: isCart ? alpha(onCart, 0.8) : colors.mutedForeground }]}>
250
+ {suffixText}
251
+ </Text>
252
+ ) : null}
253
+ </View>
254
+ );
255
+
256
+ const a11y = {
257
+ accessible: true,
258
+ accessibilityRole: 'adjustable' as const,
259
+ accessibilityLabel: accessibilityLabel ?? title,
260
+ accessibilityValue: { min, max: Number.isFinite(max) ? max : undefined, now: current, text: suffixText ? `${text} ${suffixText}` : text },
261
+ accessibilityState: { disabled: !!disabled },
262
+ accessibilityActions: [
263
+ { name: 'increment', label: 'Increase' },
264
+ { name: 'decrement', label: 'Decrease' },
265
+ ],
266
+ onAccessibilityAction: (e: AccessibilityActionEvent) => {
267
+ if (disabled) return;
268
+ if (e.nativeEvent.actionName === 'increment') change(1);
269
+ if (e.nativeEvent.actionName === 'decrement') change(-1);
270
+ },
271
+ };
272
+
273
+ const control = isCart ? (
274
+ <CartControl
275
+ {...a11y}
276
+ s={s}
277
+ radius={capsule}
278
+ bg={tint}
279
+ fg={fg}
280
+ numStyle={numStyle}
281
+ number={number}
282
+ text={text}
283
+ suffixText={suffixText}
284
+ collapsed={collapsed}
285
+ addLabel={addLabel}
286
+ bump={bump}
287
+ disabled={!!disabled}
288
+ dec={button(-1, removes ? ICON.trash : ICON.minus, removes ? `Remove${title ? ` ${title}` : ''}` : undefined)}
289
+ inc={button(1)}
290
+ onAdd={() => startRepeat(1)}
291
+ onAddEnd={stopRepeat}
292
+ />
293
+ ) : (
294
+ <View {...a11y} style={[styles.control, vertical ? styles.column : styles.row, look[variant].box, disabled && styles.disabled]}>
295
+ {button(vertical ? 1 : -1)}
296
+ <Animated.View
297
+ style={[
298
+ styles.value,
299
+ vertical
300
+ ? { minHeight: s.num, minWidth: s.btn, paddingVertical: 2 }
301
+ : { minWidth: s.num, height: s.btn, paddingHorizontal: variant === 'outline' ? 8 : 4 },
302
+ { transform: [vertical ? { translateY: Animated.multiply(bump, -1) } : { translateX: bump }] },
303
+ ]}
304
+ >
305
+ {number}
306
+ </Animated.View>
307
+ {button(vertical ? -1 : 1)}
308
+ </View>
309
+ );
310
+
311
+ if (!title && !description) return <View style={[styles.self, style]}>{control}</View>;
312
+ return (
313
+ <View style={[styles.labelled, style]}>
314
+ <View style={styles.texts}>
315
+ {title ? <Text style={[styles.label, { color: colors.foreground }]}>{title}</Text> : null}
316
+ {description ? <Text style={[styles.description, { color: colors.mutedForeground }]}>{description}</Text> : null}
317
+ </View>
318
+ {control}
319
+ </View>
320
+ );
321
+ }
322
+
323
+ /**
324
+ * DoorDash-style control: an accent capsule whose width springs between a lone + (or "+ Add")
325
+ * and − value +. The + stays anchored to the right edge; − and the number fade in beside it.
326
+ * Width is JS-driven on the outer view; everything inside is native-driven on its own views.
327
+ */
328
+ function CartControl({
329
+ s,
330
+ radius,
331
+ bg,
332
+ fg,
333
+ numStyle,
334
+ number,
335
+ text,
336
+ suffixText,
337
+ collapsed,
338
+ addLabel,
339
+ bump,
340
+ disabled,
341
+ dec,
342
+ inc,
343
+ onAdd,
344
+ onAddEnd,
345
+ ...a11y
346
+ }: {
347
+ s: { btn: number; font: number; icon: number; num: number };
348
+ radius: number;
349
+ bg: string;
350
+ fg: string;
351
+ numStyle: TextStyle;
352
+ number: React.ReactNode;
353
+ text: string;
354
+ suffixText?: string;
355
+ collapsed: boolean;
356
+ addLabel?: string;
357
+ bump: Animated.Value;
358
+ disabled: boolean;
359
+ dec: React.ReactNode;
360
+ inc: React.ReactNode;
361
+ onAdd: () => void;
362
+ onAddEnd: () => void;
363
+ } & React.ComponentProps<typeof View>) {
364
+ // Natural widths, measured off-screen so the spring always lands on the real size.
365
+ const [textW, setTextW] = useState(0);
366
+ const [addW, setAddW] = useState(0);
367
+ const estimate = Math.ceil([...text].length * s.font * 0.64 + (suffixText ? (suffixText.length + 1) * s.font * 0.5 : 0));
368
+ const valueW = Math.max(s.num - 4, (textW || estimate) + 8);
369
+ const openW = s.btn * 2 + valueW;
370
+ const closedW = addLabel ? Math.max(s.btn, addW || s.btn * 2.2) : s.btn;
371
+ const target = collapsed ? closedW : openW;
372
+
373
+ const width = useRef(new Animated.Value(target)).current; // JS driver (layout)
374
+ const open = useRef(new Animated.Value(collapsed ? 0 : 1)).current; // native driver
375
+ const settled = useRef(false);
376
+
377
+ const measured = textW > 0 && (!addLabel || addW > 0);
378
+ useEffect(() => {
379
+ // Snap until the rulers have reported; after that every change springs.
380
+ if (!settled.current) {
381
+ width.setValue(target);
382
+ settled.current = measured;
383
+ return;
384
+ }
385
+ Animated.spring(width, { toValue: target, useNativeDriver: false, ...motion.snappy }).start();
386
+ }, [target, measured, width]);
387
+
388
+ useEffect(() => {
389
+ Animated.spring(open, { toValue: collapsed ? 0 : 1, useNativeDriver: true, ...motion.snappy }).start();
390
+ }, [collapsed, open]);
391
+
392
+ const measure = (set: (n: number) => void) => (e: LayoutChangeEvent) => set(Math.ceil(e.nativeEvent.layout.width));
393
+
394
+ return (
395
+ <Animated.View
396
+ {...a11y}
397
+ style={[
398
+ styles.cart,
399
+ { height: s.btn, width, borderRadius: radius, backgroundColor: bg },
400
+ disabled && styles.disabled,
401
+ ]}
402
+ >
403
+ {/* Off-screen rulers inside a wide box, so nothing squeezes them. */}
404
+ <View pointerEvents="none" style={[styles.ruler, styles.hidden]}>
405
+ <View onLayout={measure(setTextW)} style={styles.row}>
406
+ <Text style={[styles.num, numStyle]}>{text}</Text>
407
+ {suffixText ? <Text style={[styles.num, styles.suffix, numStyle, { fontSize: (numStyle.fontSize ?? s.font) * 0.82 }]}>{suffixText}</Text> : null}
408
+ </View>
409
+ </View>
410
+
411
+ <View style={[styles.cartRow, { width: openW }]} pointerEvents="box-none">
412
+ <Animated.View
413
+ pointerEvents={collapsed ? 'none' : 'auto'}
414
+ style={[styles.row, { opacity: open, transform: [{ translateX: open.interpolate({ inputRange: [0, 1], outputRange: [s.btn * 0.5, 0] }) }] }]}
415
+ >
416
+ {dec}
417
+ <Animated.View style={[styles.value, { width: valueW, height: s.btn, transform: [{ translateX: bump }] }]}>{number}</Animated.View>
418
+ </Animated.View>
419
+ <Animated.View pointerEvents={addLabel && collapsed ? 'none' : 'auto'} style={addLabel ? { opacity: open } : null}>
420
+ {inc}
421
+ </Animated.View>
422
+ </View>
423
+
424
+ {addLabel ? (
425
+ <View pointerEvents="box-none" style={styles.ruler}>
426
+ <Animated.View
427
+ onLayout={measure(setAddW)}
428
+ pointerEvents={collapsed ? 'auto' : 'none'}
429
+ style={{ opacity: open.interpolate({ inputRange: [0, 0.6], outputRange: [1, 0], extrapolate: 'clamp' }) }}
430
+ >
431
+ <Pressable
432
+ accessibilityRole="button"
433
+ accessibilityLabel={addLabel}
434
+ disabled={disabled}
435
+ hitSlop={hitFor(s.btn)}
436
+ onPressIn={onAdd}
437
+ onPressOut={onAddEnd}
438
+ style={({ pressed }) => [styles.add, { height: s.btn, paddingHorizontal: Math.round(s.btn * 0.36) }, pressed && { backgroundColor: alpha(fg, 0.16) }]}
439
+ >
440
+ <Glyph d={ICON.plus} size={s.icon} color={fg} />
441
+ <Text style={[styles.addText, { color: fg, fontSize: s.font - 2 }]}>{addLabel}</Text>
442
+ </Pressable>
443
+ </Animated.View>
444
+ </View>
445
+ ) : null}
446
+ </Animated.View>
447
+ );
448
+ }
449
+
450
+ interface ButtonLook {
451
+ bg: string;
452
+ borderColor: string;
453
+ borderWidth: number;
454
+ pressBg: string;
455
+ pressBorder: string;
456
+ }
457
+
458
+ function StepButton({
459
+ width,
460
+ height,
461
+ icon,
462
+ glyph,
463
+ radius,
464
+ fg,
465
+ look,
466
+ limit,
467
+ disabled,
468
+ label,
469
+ style,
470
+ onPressIn,
471
+ onPressOut,
472
+ }: {
473
+ width: number;
474
+ height: number;
475
+ icon: number;
476
+ glyph: string;
477
+ radius: number;
478
+ fg: string;
479
+ look: Partial<ButtonLook>;
480
+ /** At min / max: dimmed, but a press still gives the bump feedback. */
481
+ limit: boolean;
482
+ disabled: boolean;
483
+ label: string;
484
+ style?: StyleProp<ViewStyle>;
485
+ onPressIn: () => void;
486
+ onPressOut: () => void;
487
+ }) {
488
+ // Scale and dim share one native-driven view; nothing JS-driven touches it.
489
+ const scale = useRef(new Animated.Value(1)).current;
490
+ const dim = useRef(new Animated.Value(limit ? 0.32 : 1)).current;
491
+ const spring = (to: number) => Animated.spring(scale, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
492
+
493
+ useEffect(() => {
494
+ Animated.timing(dim, { toValue: limit ? 0.32 : 1, duration: motion.fast, useNativeDriver: true }).start();
495
+ // A held button can hit the limit before onPressOut fires.
496
+ if (limit) spring(1);
497
+ // eslint-disable-next-line react-hooks/exhaustive-deps
498
+ }, [limit]);
499
+
500
+ const hit = { top: hitFor(height), bottom: hitFor(height), left: hitFor(width), right: hitFor(width) };
501
+
502
+ return (
503
+ <Animated.View style={{ opacity: dim, transform: [{ scale }] }}>
504
+ <Pressable
505
+ accessibilityRole="button"
506
+ accessibilityLabel={label}
507
+ accessibilityState={{ disabled: disabled || limit }}
508
+ disabled={disabled}
509
+ hitSlop={hit}
510
+ onPressIn={() => {
511
+ spring(limit ? 0.94 : 0.86);
512
+ onPressIn();
513
+ }}
514
+ onPressOut={() => {
515
+ spring(1);
516
+ onPressOut();
517
+ }}
518
+ style={({ pressed }) => [
519
+ styles.button,
520
+ {
521
+ width,
522
+ height,
523
+ borderRadius: radius,
524
+ backgroundColor: look.bg ?? 'transparent',
525
+ borderColor: look.borderColor,
526
+ borderWidth: look.borderWidth ?? 0,
527
+ },
528
+ pressed && !limit && { backgroundColor: look.pressBg, borderColor: look.pressBorder ?? look.borderColor },
529
+ style,
530
+ ]}
531
+ >
532
+ <Glyph d={glyph} size={icon} color={fg} />
533
+ </Pressable>
534
+ </Animated.View>
535
+ );
536
+ }
537
+
538
+ /** Stroked icon; pops in when its shape changes (− ↔ trash). */
539
+ function Glyph({ d, size, color }: { d: string; size: number; color: string }) {
540
+ const pop = useRef(new Animated.Value(1)).current;
541
+ const prev = useRef(d);
542
+ useEffect(() => {
543
+ if (prev.current === d) return;
544
+ prev.current = d;
545
+ pop.setValue(0.55);
546
+ Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
547
+ }, [d, pop]);
548
+ const trash = d === ICON.trash;
549
+ return (
550
+ <Animated.View style={{ transform: [{ scale: pop }] }}>
551
+ <Svg width={trash ? size + 2 : size} height={trash ? size + 2 : size} viewBox="0 0 24 24" fill="none">
552
+ <Path d={d} stroke={color} strokeWidth={trash ? 2 : 2.4} strokeLinecap="round" strokeLinejoin="round" />
553
+ </Svg>
554
+ </Animated.View>
555
+ );
556
+ }
557
+
558
+ /** Text where every changed character slides out and its replacement slides in. */
559
+ function RollingText({ text, dir, style }: { text: string; dir: number; style: TextStyle }) {
560
+ const chars = [...text];
561
+ return (
562
+ <View style={styles.roll}>
563
+ {chars.map((c, i) => (
564
+ // Keyed from the right so the ones digit stays the ones digit when the number grows.
565
+ <RollingChar key={chars.length - i} char={c} dir={dir} style={style} />
566
+ ))}
567
+ </View>
568
+ );
569
+ }
570
+
571
+ function RollingChar({ char, dir, style }: { char: string; dir: number; style: TextStyle }) {
572
+ const [pair, setPair] = useState<{ cur: string; prev: string | null; dir: number }>({ cur: char, prev: null, dir });
573
+ if (pair.cur !== char) setPair({ cur: char, prev: pair.cur, dir });
574
+ const t = useRef(new Animated.Value(1)).current;
575
+ const h = (style.lineHeight as number) ?? 24;
576
+
577
+ useLayoutEffect(() => {
578
+ if (pair.prev === null) return;
579
+ t.setValue(0);
580
+ Animated.spring(t, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
581
+ }, [pair, t]);
582
+
583
+ const d = pair.dir * h * 0.8;
584
+ return (
585
+ <View style={[styles.charBox, { height: h }]}>
586
+ <Text style={[styles.num, style, styles.hidden]}>{pair.cur}</Text>
587
+ {pair.prev !== null ? (
588
+ <Animated.Text
589
+ style={[
590
+ styles.num,
591
+ styles.layer,
592
+ style,
593
+ {
594
+ opacity: t.interpolate({ inputRange: [0, 0.55], outputRange: [1, 0], extrapolate: 'clamp' }),
595
+ transform: [
596
+ { translateY: t.interpolate({ inputRange: [0, 1], outputRange: [0, -d] }) },
597
+ { scale: t.interpolate({ inputRange: [0, 1], outputRange: [1, 0.82] }) },
598
+ ],
599
+ },
600
+ ]}
601
+ >
602
+ {pair.prev}
603
+ </Animated.Text>
604
+ ) : null}
605
+ <Animated.Text
606
+ style={[
607
+ styles.num,
608
+ styles.layer,
609
+ style,
610
+ {
611
+ opacity: pair.prev === null ? 1 : t.interpolate({ inputRange: [0.15, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
612
+ transform: [{ translateY: t.interpolate({ inputRange: [0, 1], outputRange: [d, 0] }) }],
613
+ },
614
+ ]}
615
+ >
616
+ {pair.cur}
617
+ </Animated.Text>
618
+ </View>
619
+ );
620
+ }
621
+
622
+ const styles = StyleSheet.create({
623
+ // Hugs its content in a column without hijacking the cross-axis alignment of a row.
624
+ self: { alignItems: 'flex-start', justifyContent: 'center' },
625
+ labelled: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12, alignSelf: 'stretch' },
626
+ texts: { flex: 1, gap: 2 },
627
+ label: { fontSize: 15, lineHeight: 20, fontWeight: '400' },
628
+ description: { fontSize: 13, lineHeight: 18 },
629
+ control: { alignItems: 'center' },
630
+ row: { flexDirection: 'row', alignItems: 'center' },
631
+ column: { flexDirection: 'column' },
632
+ disabled: { opacity: 0.45 },
633
+ value: { alignItems: 'center', justifyContent: 'center' },
634
+ button: { alignItems: 'center', justifyContent: 'center' },
635
+ cart: { flexDirection: 'row', overflow: 'hidden' },
636
+ cartRow: { position: 'absolute', top: 0, bottom: 0, right: 0, flexDirection: 'row', alignItems: 'center' },
637
+ ruler: { position: 'absolute', top: 0, left: 0, width: 9999, flexDirection: 'row', alignItems: 'flex-start' },
638
+ add: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5 },
639
+ addText: { fontWeight: '400', letterSpacing: 0.1 },
640
+ roll: { flexDirection: 'row', alignItems: 'center' },
641
+ suffix: { fontWeight: '400', marginLeft: 4 },
642
+ charBox: { overflow: 'hidden', justifyContent: 'center' },
643
+ num: { fontWeight: '400', fontVariant: ['tabular-nums'], textAlign: 'center' },
644
+ layer: { position: 'absolute', left: 0, right: 0 },
645
+ hidden: { opacity: 0 },
646
+ });