@expokit/cli 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/bin/expo-kit.js +1266 -0
  4. package/bin/lib.mjs +343 -0
  5. package/bin/mcp.mjs +345 -0
  6. package/package.json +49 -4
  7. package/registry/_docs/accordion.json +115 -0
  8. package/registry/_docs/action-sheet.json +138 -0
  9. package/registry/_docs/alert.json +155 -0
  10. package/registry/_docs/avatar.json +227 -0
  11. package/registry/_docs/badge.json +148 -0
  12. package/registry/_docs/bottom-nav.json +143 -0
  13. package/registry/_docs/bottom-sheet.json +186 -0
  14. package/registry/_docs/bullet.json +27 -0
  15. package/registry/_docs/button-3d.json +138 -0
  16. package/registry/_docs/button.json +131 -0
  17. package/registry/_docs/calendar.json +198 -0
  18. package/registry/_docs/card-stack.json +124 -0
  19. package/registry/_docs/card.json +131 -0
  20. package/registry/_docs/carousel.json +122 -0
  21. package/registry/_docs/chat.json +184 -0
  22. package/registry/_docs/checkbox.json +109 -0
  23. package/registry/_docs/checkout.json +105 -0
  24. package/registry/_docs/chip-group.json +132 -0
  25. package/registry/_docs/color-swatch.json +82 -0
  26. package/registry/_docs/confetti.json +102 -0
  27. package/registry/_docs/coupon.json +192 -0
  28. package/registry/_docs/cube-pager.json +100 -0
  29. package/registry/_docs/dashboard.json +123 -0
  30. package/registry/_docs/date-picker.json +269 -0
  31. package/registry/_docs/dialog.json +186 -0
  32. package/registry/_docs/drawer.json +199 -0
  33. package/registry/_docs/fab.json +144 -0
  34. package/registry/_docs/flip-card.json +127 -0
  35. package/registry/_docs/floating-input.json +211 -0
  36. package/registry/_docs/gift-card.json +124 -0
  37. package/registry/_docs/glass-button.json +142 -0
  38. package/registry/_docs/glass-nav-bar.json +102 -0
  39. package/registry/_docs/glass-search-bar.json +114 -0
  40. package/registry/_docs/glass-segmented.json +94 -0
  41. package/registry/_docs/glass-sheet.json +136 -0
  42. package/registry/_docs/glass-tab-bar.json +100 -0
  43. package/registry/_docs/glass.json +94 -0
  44. package/registry/_docs/inbox.json +75 -0
  45. package/registry/_docs/isometric-bar.json +75 -0
  46. package/registry/_docs/kit.json +14 -0
  47. package/registry/_docs/kpi-card.json +14 -0
  48. package/registry/_docs/like-button.json +92 -0
  49. package/registry/_docs/liquidglass-tab.json +30 -0
  50. package/registry/_docs/list-item.json +216 -0
  51. package/registry/_docs/loyalty-card.json +124 -0
  52. package/registry/_docs/morph-button.json +128 -0
  53. package/registry/_docs/number-ticker.json +112 -0
  54. package/registry/_docs/offers.json +81 -0
  55. package/registry/_docs/onboarding.json +69 -0
  56. package/registry/_docs/otp-input.json +158 -0
  57. package/registry/_docs/pagination-dots.json +108 -0
  58. package/registry/_docs/parallax-layers.json +104 -0
  59. package/registry/_docs/parallax-scroll.json +210 -0
  60. package/registry/_docs/password-input.json +112 -0
  61. package/registry/_docs/payment-card.json +169 -0
  62. package/registry/_docs/pixel-bar.json +69 -0
  63. package/registry/_docs/polar-area.json +81 -0
  64. package/registry/_docs/profile.json +87 -0
  65. package/registry/_docs/progress-steps.json +116 -0
  66. package/registry/_docs/progress.json +152 -0
  67. package/registry/_docs/radio-group.json +112 -0
  68. package/registry/_docs/rating.json +114 -0
  69. package/registry/_docs/ring-carousel.json +122 -0
  70. package/registry/_docs/scratch-card.json +110 -0
  71. package/registry/_docs/search-bar.json +160 -0
  72. package/registry/_docs/search-results.json +123 -0
  73. package/registry/_docs/segmented-control.json +123 -0
  74. package/registry/_docs/select.json +277 -0
  75. package/registry/_docs/settings.json +75 -0
  76. package/registry/_docs/shimmer-button.json +98 -0
  77. package/registry/_docs/sign-in.json +201 -0
  78. package/registry/_docs/skeleton.json +162 -0
  79. package/registry/_docs/slide-to-confirm.json +108 -0
  80. package/registry/_docs/slider.json +177 -0
  81. package/registry/_docs/spin-wheel.json +138 -0
  82. package/registry/_docs/spinner.json +121 -0
  83. package/registry/_docs/stateful-button.json +184 -0
  84. package/registry/_docs/stepper.json +184 -0
  85. package/registry/_docs/swipe-deck.json +118 -0
  86. package/registry/_docs/swipeable-row.json +95 -0
  87. package/registry/_docs/switch.json +124 -0
  88. package/registry/_docs/tabs.json +129 -0
  89. package/registry/_docs/tag-input.json +148 -0
  90. package/registry/_docs/text-animation.json +141 -0
  91. package/registry/_docs/textarea.json +191 -0
  92. package/registry/_docs/ticket-card.json +194 -0
  93. package/registry/_docs/ticket-shape.json +14 -0
  94. package/registry/_docs/tilt-card.json +157 -0
  95. package/registry/_docs/time-picker.json +269 -0
  96. package/registry/_docs/timeline-chart.json +33 -0
  97. package/registry/_docs/timeline.json +172 -0
  98. package/registry/_docs/toast.json +185 -0
  99. package/registry/_docs/treemap.json +33 -0
  100. package/registry/_docs/waterfall.json +39 -0
  101. package/registry/_docs/wheel-list.json +122 -0
  102. package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
  103. package/registry/accordion/files/src/lib/kit.tsx +219 -0
  104. package/registry/accordion.json +17 -0
  105. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
  106. package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
  107. package/registry/action-sheet.json +18 -0
  108. package/registry/alert/files/src/components/ui/alert.tsx +654 -0
  109. package/registry/alert/files/src/components/ui/button.tsx +274 -0
  110. package/registry/alert/files/src/lib/kit.tsx +219 -0
  111. package/registry/alert.json +20 -0
  112. package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
  113. package/registry/avatar/files/src/lib/kit.tsx +219 -0
  114. package/registry/avatar.json +18 -0
  115. package/registry/badge/files/src/components/ui/badge.tsx +626 -0
  116. package/registry/badge/files/src/lib/kit.tsx +219 -0
  117. package/registry/badge.json +17 -0
  118. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
  119. package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
  120. package/registry/bottom-nav.json +18 -0
  121. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
  122. package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
  123. package/registry/bottom-sheet.json +17 -0
  124. package/registry/bullet.json +14 -0
  125. package/registry/button/files/src/components/ui/button.tsx +274 -0
  126. package/registry/button/files/src/lib/kit.tsx +219 -0
  127. package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
  128. package/registry/button-3d/files/src/lib/kit.tsx +219 -0
  129. package/registry/button-3d.json +18 -0
  130. package/registry/button.json +18 -0
  131. package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
  132. package/registry/calendar/files/src/lib/kit.tsx +219 -0
  133. package/registry/calendar.json +17 -0
  134. package/registry/card/files/src/components/ui/card.tsx +300 -0
  135. package/registry/card/files/src/lib/kit.tsx +219 -0
  136. package/registry/card-stack.json +15 -0
  137. package/registry/card.json +17 -0
  138. package/registry/carousel.json +15 -0
  139. package/registry/chat.json +18 -0
  140. package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
  141. package/registry/checkbox/files/src/lib/kit.tsx +219 -0
  142. package/registry/checkbox.json +17 -0
  143. package/registry/checkout.json +26 -0
  144. package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
  145. package/registry/chip-group/files/src/lib/kit.tsx +219 -0
  146. package/registry/chip-group.json +17 -0
  147. package/registry/color-swatch.json +17 -0
  148. package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
  149. package/registry/confetti/files/src/lib/kit.tsx +219 -0
  150. package/registry/confetti.json +15 -0
  151. package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
  152. package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
  153. package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
  154. package/registry/coupon/files/src/lib/kit.tsx +219 -0
  155. package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
  156. package/registry/coupon.json +23 -0
  157. package/registry/cube-pager.json +15 -0
  158. package/registry/dashboard.json +32 -0
  159. package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
  160. package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
  161. package/registry/date-picker/files/src/lib/kit.tsx +219 -0
  162. package/registry/date-picker.json +19 -0
  163. package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
  164. package/registry/dialog/files/src/lib/kit.tsx +219 -0
  165. package/registry/dialog.json +18 -0
  166. package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
  167. package/registry/drawer/files/src/lib/kit.tsx +219 -0
  168. package/registry/drawer.json +18 -0
  169. package/registry/fab.json +18 -0
  170. package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
  171. package/registry/flip-card/files/src/lib/kit.tsx +219 -0
  172. package/registry/flip-card.json +15 -0
  173. package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
  174. package/registry/floating-input/files/src/lib/kit.tsx +219 -0
  175. package/registry/floating-input.json +17 -0
  176. package/registry/gift-card.json +21 -0
  177. package/registry/glass-button.json +22 -0
  178. package/registry/glass-nav-bar.json +21 -0
  179. package/registry/glass-search-bar.json +21 -0
  180. package/registry/glass-segmented.json +22 -0
  181. package/registry/glass-sheet.json +22 -0
  182. package/registry/glass-tab-bar.json +22 -0
  183. package/registry/glass.json +19 -0
  184. package/registry/inbox.json +30 -0
  185. package/registry/isometric-bar.json +14 -0
  186. package/registry/kit/files/src/lib/kit.tsx +219 -0
  187. package/registry/kit.json +11 -0
  188. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
  189. package/registry/kpi-card.json +14 -0
  190. package/registry/like-button.json +18 -0
  191. package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
  192. package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
  193. package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
  194. package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
  195. package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
  196. package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
  197. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
  198. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
  199. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
  200. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
  201. package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
  202. package/registry/liquidglass-tab.json +23 -0
  203. package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
  204. package/registry/list-item/files/src/lib/kit.tsx +219 -0
  205. package/registry/list-item.json +17 -0
  206. package/registry/loyalty-card.json +23 -0
  207. package/registry/morph-button.json +18 -0
  208. package/registry/number-ticker.json +15 -0
  209. package/registry/offers.json +24 -0
  210. package/registry/onboarding.json +17 -0
  211. package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
  212. package/registry/otp-input/files/src/lib/kit.tsx +219 -0
  213. package/registry/otp-input.json +17 -0
  214. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
  215. package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
  216. package/registry/pagination-dots.json +15 -0
  217. package/registry/parallax-layers.json +15 -0
  218. package/registry/parallax-scroll.json +17 -0
  219. package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
  220. package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
  221. package/registry/password-input/files/src/lib/kit.tsx +219 -0
  222. package/registry/password-input.json +19 -0
  223. package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
  224. package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
  225. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
  226. package/registry/payment-card/files/src/lib/kit.tsx +219 -0
  227. package/registry/payment-card.json +21 -0
  228. package/registry/pixel-bar.json +14 -0
  229. package/registry/polar-area.json +14 -0
  230. package/registry/profile.json +17 -0
  231. package/registry/progress/files/src/components/ui/progress.tsx +883 -0
  232. package/registry/progress/files/src/lib/kit.tsx +219 -0
  233. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
  234. package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
  235. package/registry/progress-steps.json +18 -0
  236. package/registry/progress.json +17 -0
  237. package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
  238. package/registry/radio-group/files/src/lib/kit.tsx +219 -0
  239. package/registry/radio-group.json +18 -0
  240. package/registry/rating.json +18 -0
  241. package/registry/ring-carousel.json +15 -0
  242. package/registry/scratch-card.json +18 -0
  243. package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
  244. package/registry/search-bar/files/src/lib/kit.tsx +219 -0
  245. package/registry/search-bar.json +17 -0
  246. package/registry/search-results.json +30 -0
  247. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
  248. package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
  249. package/registry/segmented-control.json +18 -0
  250. package/registry/select/files/src/components/ui/select.tsx +1398 -0
  251. package/registry/select/files/src/lib/kit.tsx +219 -0
  252. package/registry/select.json +17 -0
  253. package/registry/settings.json +26 -0
  254. package/registry/shimmer-button.json +17 -0
  255. package/registry/sign-in.json +26 -0
  256. package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
  257. package/registry/skeleton/files/src/lib/kit.tsx +219 -0
  258. package/registry/skeleton.json +17 -0
  259. package/registry/slide-to-confirm.json +18 -0
  260. package/registry/slider/files/src/components/ui/slider.tsx +716 -0
  261. package/registry/slider/files/src/lib/kit.tsx +219 -0
  262. package/registry/slider.json +17 -0
  263. package/registry/spin-wheel.json +18 -0
  264. package/registry/spinner/files/src/components/ui/spinner.tsx +748 -0
  265. package/registry/spinner/files/src/lib/kit.tsx +219 -0
  266. package/registry/spinner.json +17 -0
  267. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
  268. package/registry/stateful-button/files/src/lib/kit.tsx +219 -0
  269. package/registry/stateful-button.json +18 -0
  270. package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
  271. package/registry/stepper/files/src/lib/kit.tsx +219 -0
  272. package/registry/stepper.json +17 -0
  273. package/registry/swipe-deck.json +15 -0
  274. package/registry/swipeable-row.json +15 -0
  275. package/registry/switch/files/src/components/ui/switch.tsx +344 -0
  276. package/registry/switch/files/src/lib/kit.tsx +219 -0
  277. package/registry/switch.json +18 -0
  278. package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
  279. package/registry/tabs/files/src/lib/kit.tsx +219 -0
  280. package/registry/tabs.json +17 -0
  281. package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
  282. package/registry/tag-input/files/src/lib/kit.tsx +219 -0
  283. package/registry/tag-input.json +17 -0
  284. package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
  285. package/registry/text-animation/files/src/lib/kit.tsx +219 -0
  286. package/registry/text-animation.json +15 -0
  287. package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
  288. package/registry/textarea/files/src/lib/kit.tsx +219 -0
  289. package/registry/textarea.json +17 -0
  290. package/registry/ticket-card.json +24 -0
  291. package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
  292. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
  293. package/registry/ticket-shape.json +17 -0
  294. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
  295. package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
  296. package/registry/tilt-card.json +17 -0
  297. package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
  298. package/registry/time-picker/files/src/lib/kit.tsx +219 -0
  299. package/registry/time-picker.json +17 -0
  300. package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
  301. package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
  302. package/registry/timeline/files/src/lib/kit.tsx +219 -0
  303. package/registry/timeline-chart.json +14 -0
  304. package/registry/timeline.json +20 -0
  305. package/registry/toast/files/src/components/ui/button.tsx +274 -0
  306. package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
  307. package/registry/toast/files/src/lib/kit.tsx +219 -0
  308. package/registry/toast.json +20 -0
  309. package/registry/treemap.json +14 -0
  310. package/registry/waterfall.json +14 -0
  311. package/registry/wheel-list.json +17 -0
  312. package/schema/components.schema.json +13 -0
@@ -0,0 +1,539 @@
1
+ import * as Haptics from 'expo-haptics';
2
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ AccessibilityInfo,
5
+ Animated,
6
+ PanResponder,
7
+ Pressable,
8
+ ScrollView,
9
+ StyleSheet,
10
+ View,
11
+ type LayoutChangeEvent,
12
+ type StyleProp,
13
+ type ViewStyle,
14
+ } from 'react-native';
15
+
16
+ import { alpha, motion, readableOn, useKit } from '@/lib/kit';
17
+
18
+ /**
19
+ * Tabs are underline tabs with content panes. `pill` and `soft` are @deprecated and render as
20
+ * `underline` — use SegmentedControl for a pill switch.
21
+ */
22
+ export type TabsVariant = 'underline' | 'pill' | 'soft';
23
+
24
+ export interface TabItem {
25
+ value: string;
26
+ label: string;
27
+ /** Icon node, or a function that receives the current state and text colour. */
28
+ icon?: React.ReactNode | ((state: { active: boolean; color: string }) => React.ReactNode);
29
+ /** Count or short label shown in a small bubble beside the label. */
30
+ badge?: number | string;
31
+ /** What to show below the bar while this tab is active. */
32
+ content?: React.ReactNode;
33
+ disabled?: boolean;
34
+ accessibilityLabel?: string;
35
+ }
36
+
37
+ export interface TabsProps {
38
+ items: TabItem[];
39
+ value?: string;
40
+ defaultValue?: string;
41
+ onValueChange?: (value: string) => void;
42
+ /** @deprecated Only `underline` remains; `pill` and `soft` render as `underline`. */
43
+ variant?: TabsVariant;
44
+ /** 'sm' 36pt · 'md' 44pt tall. */
45
+ size?: 'sm' | 'md';
46
+ /**
47
+ * `true`: tabs size to their labels and scroll. `false`: equal widths, never scroll.
48
+ * Default: fill the width while they fit, scroll once they overflow.
49
+ */
50
+ scrollable?: boolean;
51
+ /** Drag the content sideways to move between tabs (default true). */
52
+ swipeable?: boolean;
53
+ /** How content changes on a tap: slide the panes, cross-fade, or swap instantly. */
54
+ transition?: 'slide' | 'fade' | 'none';
55
+ /** Selection tick on change (default true). */
56
+ haptics?: boolean;
57
+ accent?: string;
58
+ style?: StyleProp<ViewStyle>;
59
+ /** Style of each content pane. */
60
+ contentStyle?: StyleProp<ViewStyle>;
61
+ }
62
+
63
+ type Box = { x: number; w: number };
64
+
65
+ // Liquid move: the edge in the direction of travel leads, the other one trails behind.
66
+ const LEAD = motion.snappy;
67
+ const TRAIL = { damping: 20, stiffness: 150, mass: 0.9 };
68
+ const UNDERLINE = 3;
69
+
70
+ /**
71
+ * Underline tabs with swipeable content panes. The underline is measured to each label and moves
72
+ * like liquid — leading edge first, trailing edge catching up — and while you drag the panes it
73
+ * follows your finger, stretching toward the next label. Count badges pop, the bar scrolls (and
74
+ * keeps the active tab centred) once the tabs overflow, and every change ticks a haptic.
75
+ */
76
+ export function Tabs({
77
+ items,
78
+ value,
79
+ defaultValue,
80
+ onValueChange,
81
+ size = 'md',
82
+ scrollable,
83
+ swipeable = true,
84
+ transition = 'slide',
85
+ haptics = true,
86
+ accent,
87
+ style,
88
+ contentStyle,
89
+ }: TabsProps) {
90
+ const { colors, radius, dark } = useKit();
91
+ const reduce = useReduceMotion();
92
+ const tint = accent ?? colors.accent;
93
+ const [inner, setInner] = useState(defaultValue ?? items.find((t) => !t.disabled)?.value);
94
+ const active = value ?? inner;
95
+ const index = Math.max(0, items.findIndex((t) => t.value === active));
96
+ const hasContent = items.some((t) => t.content !== undefined);
97
+ const mode: 'fill' | 'auto' | 'scroll' = scrollable === true ? 'scroll' : scrollable === false ? 'fill' : 'auto';
98
+
99
+ // ---- measurements ----
100
+ const tabs = useRef<Box[]>([]);
101
+ const labels = useRef<Box[]>([]);
102
+ const heights = useRef<number[]>([]);
103
+ const [measured, setMeasured] = useState(0);
104
+ const bump = () => setMeasured((n) => n + 1);
105
+ const [paneW, setPaneW] = useState(0);
106
+ const [barW, setBarW] = useState(0);
107
+ const [contentW, setContentW] = useState(0);
108
+ const overflow = mode !== 'fill' && contentW > barW + 1;
109
+
110
+ // ---- animated values ----
111
+ // Underline edges + pane height: JS driver (left / width / height).
112
+ const lx = useRef(new Animated.Value(0)).current;
113
+ const rx = useRef(new Animated.Value(0)).current;
114
+ const hv = useRef(new Animated.Value(0)).current;
115
+ // Pager offset + fade: native driver, on the pager row.
116
+ const pan = useRef(new Animated.Value(0)).current;
117
+ const fade = useRef(new Animated.Value(1)).current;
118
+ const [lineShown, setLineShown] = useState(false);
119
+ const [heightShown, setHeightShown] = useState(false);
120
+ const linePlaced = useRef(false);
121
+ const pagerPlaced = useRef(false);
122
+ const lastIndex = useRef(index);
123
+ const release = useRef({ v: 0, dragged: false });
124
+ const paging = useRef(false); // pager spring in flight (its JS value is stale until it ends)
125
+ const [settle, setSettle] = useState(0);
126
+ const scrollRef = useRef<ScrollView>(null);
127
+
128
+ const edgesOf = (i: number): [number, number] | null => {
129
+ const t = tabs.current[i];
130
+ const l = labels.current[i];
131
+ return t && l ? [t.x + l.x, t.x + l.x + l.w] : null;
132
+ };
133
+
134
+ const select = (t: TabItem) => {
135
+ if (t.disabled || t.value === active) return;
136
+ if (haptics) Haptics.selectionAsync().catch(() => {});
137
+ if (value === undefined) setInner(t.value);
138
+ onValueChange?.(t.value);
139
+ };
140
+
141
+ // Go to the active tab: underline, pager, height, bar scroll. Also runs after a drag ends
142
+ // (settle) so a controlled parent that ignores the change still snaps back.
143
+ useEffect(() => {
144
+ const e = edgesOf(index);
145
+ // First placement happens in the layout callbacks (placeFirst); from then on, animate.
146
+ if (e && linePlaced.current) {
147
+ if (reduce) {
148
+ Animated.parallel([
149
+ Animated.timing(lx, { toValue: e[0], duration: 160, useNativeDriver: false }),
150
+ Animated.timing(rx, { toValue: e[1], duration: 160, useNativeDriver: false }),
151
+ ]).start();
152
+ } else {
153
+ const right = index >= lastIndex.current;
154
+ Animated.parallel([
155
+ Animated.spring(lx, { toValue: e[0], useNativeDriver: false, ...(right ? TRAIL : LEAD) }),
156
+ Animated.spring(rx, { toValue: e[1], useNativeDriver: false, ...(right ? LEAD : TRAIL) }),
157
+ ]).start();
158
+ }
159
+ }
160
+
161
+ const { v, dragged } = release.current;
162
+ release.current = { v: 0, dragged: false };
163
+ if (paneW) {
164
+ const to = -index * paneW;
165
+ if (!pagerPlaced.current || transition === 'none') {
166
+ pan.setValue(to);
167
+ pagerPlaced.current = true;
168
+ } else if (transition === 'fade' && !dragged && lastIndex.current !== index) {
169
+ pan.setValue(to);
170
+ fade.setValue(0);
171
+ Animated.timing(fade, { toValue: 1, duration: 200, useNativeDriver: true }).start();
172
+ } else if (reduce) {
173
+ Animated.timing(pan, { toValue: to, duration: 180, useNativeDriver: true }).start();
174
+ } else {
175
+ paging.current = true;
176
+ Animated.spring(pan, { toValue: to, velocity: v, useNativeDriver: true, ...motion.snappy, damping: 24 }).start(
177
+ ({ finished }) => {
178
+ if (finished) paging.current = false;
179
+ }
180
+ );
181
+ }
182
+ }
183
+
184
+ const h = heights.current[index];
185
+ if (h && heightShown) {
186
+ if (reduce) {
187
+ Animated.timing(hv, { toValue: h, duration: 180, useNativeDriver: false }).start();
188
+ } else {
189
+ Animated.spring(hv, { toValue: h, useNativeDriver: false, ...motion.gentle }).start();
190
+ }
191
+ }
192
+
193
+ const t = tabs.current[index];
194
+ if (overflow && t && lastIndex.current !== index) {
195
+ const x = Math.max(0, Math.min(contentW - barW, t.x + t.w / 2 - barW / 2));
196
+ scrollRef.current?.scrollTo({ x, animated: !reduce });
197
+ }
198
+ lastIndex.current = index;
199
+ // eslint-disable-next-line react-hooks/exhaustive-deps
200
+ }, [index, settle, paneW, measured]);
201
+
202
+ // ---- swipe between panes ----
203
+ const live = useRef({ index, items, paneW, swipe: false, select, edgesOf });
204
+ live.current = { index, items, paneW, swipe: swipeable && hasContent && paneW > 0, select, edgesOf };
205
+ const drag = useRef({ base: 0, off: 0 });
206
+ const neighbour = (i: number, dir: 1 | -1) => {
207
+ const t = live.current.items[i + dir];
208
+ return t && !t.disabled ? i + dir : -1;
209
+ };
210
+
211
+ const responder = useMemo(
212
+ () =>
213
+ PanResponder.create({
214
+ onMoveShouldSetPanResponder: (_, g) => live.current.swipe && Math.abs(g.dx) > 10 && Math.abs(g.dx) > Math.abs(g.dy) * 1.4,
215
+ onPanResponderTerminationRequest: () => false,
216
+ onPanResponderGrant: () => {
217
+ const { index: i, paneW: W } = live.current;
218
+ lx.stopAnimation();
219
+ rx.stopAnimation();
220
+ hv.stopAnimation();
221
+ // Grabbed mid-flight: continue from where the pane is; at rest it sits exactly on its slot.
222
+ drag.current.base = 0;
223
+ if (paging.current) pan.stopAnimation((v) => (drag.current.base = Math.max(-W, Math.min(W, v + i * W))));
224
+ paging.current = false;
225
+ drag.current.off = drag.current.base;
226
+ },
227
+ onPanResponderMove: (_, g) => {
228
+ const { index: i, paneW: W, edgesOf: edges } = live.current;
229
+ let off = drag.current.base + g.dx;
230
+ const prev = neighbour(i, -1);
231
+ const next = neighbour(i, 1);
232
+ if (off > 0 && prev < 0) off = rubber(off, W);
233
+ if (off < 0 && next < 0) off = -rubber(-off, W);
234
+ off = Math.max(-W, Math.min(W, off));
235
+ drag.current.off = off;
236
+ pan.setValue(-i * W + off);
237
+
238
+ // Underline + height follow the finger toward the neighbour.
239
+ const j = off < 0 ? next : prev;
240
+ const a = edges(i);
241
+ const b = j >= 0 ? edges(j) : null;
242
+ if (!a || !b) return;
243
+ const p = Math.min(1, Math.abs(off) / W);
244
+ const lead = 1 - (1 - p) * (1 - p); // ease-out: the leading edge runs ahead
245
+ const trail = p * p; // ease-in: the trailing edge hangs back
246
+ const toRight = j > i;
247
+ lx.setValue(a[0] + (b[0] - a[0]) * (toRight ? trail : lead));
248
+ rx.setValue(a[1] + (b[1] - a[1]) * (toRight ? lead : trail));
249
+ const ha = heights.current[i];
250
+ const hb = heights.current[j];
251
+ if (ha && hb) hv.setValue(ha + (hb - ha) * p);
252
+ },
253
+ onPanResponderRelease: (_, g) => finish(g.vx),
254
+ onPanResponderTerminate: () => finish(0),
255
+ }),
256
+ // eslint-disable-next-line react-hooks/exhaustive-deps
257
+ []
258
+ );
259
+
260
+ const finish = (vx: number) => {
261
+ const { index: i, paneW: W, items: list, select: pick } = live.current;
262
+ const off = drag.current.off;
263
+ let target = i;
264
+ if (off < 0 && (off < -W * 0.22 || vx < -0.35)) target = neighbour(i, 1);
265
+ else if (off > 0 && (off > W * 0.22 || vx > 0.35)) target = neighbour(i, -1);
266
+ if (target < 0) target = i;
267
+ release.current = { v: vx, dragged: true };
268
+ if (target !== i) pick(list[target]);
269
+ setSettle((n) => n + 1);
270
+ };
271
+
272
+ // ---- layout callbacks ----
273
+ // The first underline position and pane height are set here, not in an effect, so the first
274
+ // frame after measuring is already right.
275
+ const placeFirst = () => {
276
+ const i = live.current.index;
277
+ const e = edgesOf(i);
278
+ if (!linePlaced.current && e) {
279
+ lx.setValue(e[0]);
280
+ rx.setValue(e[1]);
281
+ linePlaced.current = true;
282
+ lastIndex.current = i;
283
+ setLineShown(true);
284
+ }
285
+ const h = heights.current[i];
286
+ if (!heightShown && h) {
287
+ hv.setValue(h);
288
+ setHeightShown(true);
289
+ }
290
+ };
291
+ const onTabLayout = (i: number) => (e: LayoutChangeEvent) => {
292
+ const { x, width } = e.nativeEvent.layout;
293
+ const p = tabs.current[i];
294
+ if (p && p.x === x && p.w === width) return;
295
+ tabs.current[i] = { x, w: width };
296
+ placeFirst();
297
+ bump();
298
+ };
299
+ const onLabelLayout = (i: number) => (e: LayoutChangeEvent) => {
300
+ const { x, width } = e.nativeEvent.layout;
301
+ const p = labels.current[i];
302
+ if (p && p.x === x && p.w === width) return;
303
+ labels.current[i] = { x, w: width };
304
+ placeFirst();
305
+ bump();
306
+ };
307
+ const onPaneLayout = (i: number) => (e: LayoutChangeEvent) => {
308
+ const h = Math.ceil(e.nativeEvent.layout.height);
309
+ if (heights.current[i] === h) return;
310
+ heights.current[i] = h;
311
+ placeFirst();
312
+ bump();
313
+ };
314
+
315
+ const h = size === 'sm' ? 36 : 44;
316
+ const hairline = alpha(colors.foreground, dark ? 0.1 : 0.06);
317
+
318
+ const row = (
319
+ <View accessibilityRole="tablist" style={[styles.row, mode !== 'scroll' && styles.grow]}>
320
+ {items.map((t, i) => (
321
+ <Tab
322
+ key={t.value}
323
+ item={t}
324
+ active={i === index}
325
+ mode={mode}
326
+ height={h}
327
+ fontSize={size === 'sm' ? 13.5 : 14.5}
328
+ tint={tint}
329
+ reduce={reduce}
330
+ onPress={() => select(t)}
331
+ onLayout={onTabLayout(i)}
332
+ onLabelLayout={onLabelLayout(i)}
333
+ />
334
+ ))}
335
+ <Animated.View
336
+ pointerEvents="none"
337
+ style={[
338
+ styles.underline,
339
+ {
340
+ left: lx,
341
+ width: Animated.subtract(rx, lx),
342
+ opacity: lineShown ? 1 : 0,
343
+ backgroundColor: tint,
344
+ borderTopLeftRadius: radius.capsule(UNDERLINE * 2),
345
+ borderTopRightRadius: radius.capsule(UNDERLINE * 2),
346
+ },
347
+ ]}
348
+ />
349
+ </View>
350
+ );
351
+
352
+ // ---- panes ----
353
+ let panes: React.ReactNode = null;
354
+ if (hasContent) {
355
+ const current = items[index];
356
+ panes = (
357
+ <Animated.View
358
+ {...responder.panHandlers}
359
+ onLayout={(e) => {
360
+ const w = e.nativeEvent.layout.width;
361
+ if (Math.abs(w - paneW) > 0.5) setPaneW(w);
362
+ }}
363
+ style={[styles.viewport, heightShown && paneW ? { height: hv } : null]}
364
+ >
365
+ {paneW ? (
366
+ <Animated.View
367
+ style={[
368
+ styles.pager,
369
+ { width: paneW * items.length, opacity: fade, transform: [{ translateX: pan }] },
370
+ ]}
371
+ >
372
+ {items.map((t, i) => (
373
+ <View
374
+ key={t.value}
375
+ onLayout={onPaneLayout(i)}
376
+ accessibilityElementsHidden={i !== index}
377
+ importantForAccessibility={i === index ? 'auto' : 'no-hide-descendants'}
378
+ style={[styles.pane, { width: paneW }, contentStyle]}
379
+ >
380
+ {t.content}
381
+ </View>
382
+ ))}
383
+ </Animated.View>
384
+ ) : (
385
+ <View style={[styles.pane, contentStyle]}>{current?.content}</View>
386
+ )}
387
+ </Animated.View>
388
+ );
389
+ }
390
+
391
+ return (
392
+ <View style={style}>
393
+ <View
394
+ onLayout={(e) => setBarW(e.nativeEvent.layout.width)}
395
+ style={{ borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: hairline }}
396
+ >
397
+ <ScrollView
398
+ ref={scrollRef}
399
+ horizontal
400
+ scrollEnabled={overflow}
401
+ bounces={overflow}
402
+ showsHorizontalScrollIndicator={false}
403
+ onContentSizeChange={(w) => setContentW(w)}
404
+ contentContainerStyle={styles.scroll}
405
+ >
406
+ {row}
407
+ </ScrollView>
408
+ </View>
409
+ {panes}
410
+ </View>
411
+ );
412
+ }
413
+
414
+ interface TabProps {
415
+ item: TabItem;
416
+ active: boolean;
417
+ mode: 'fill' | 'auto' | 'scroll';
418
+ height: number;
419
+ fontSize: number;
420
+ tint: string;
421
+ reduce: boolean;
422
+ onPress: () => void;
423
+ onLayout: (e: LayoutChangeEvent) => void;
424
+ onLabelLayout: (e: LayoutChangeEvent) => void;
425
+ }
426
+
427
+ function Tab({ item, active, mode, height, fontSize, tint, reduce, onPress, onLayout, onLabelLayout }: TabProps) {
428
+ const { colors } = useKit();
429
+ const a = useRef(new Animated.Value(active ? 1 : 0)).current; // label colour (JS)
430
+ const press = useRef(new Animated.Value(1)).current; // squish (native)
431
+
432
+ useEffect(() => {
433
+ Animated.timing(a, { toValue: active ? 1 : 0, duration: reduce ? 0 : 180, useNativeDriver: false }).start();
434
+ }, [active, a, reduce]);
435
+
436
+ const pressTo = (to: number) =>
437
+ Animated.spring(press, { toValue: to, useNativeDriver: true, ...(to < 1 ? motion.snappy : motion.bouncy) }).start();
438
+
439
+ const fg = active ? colors.foreground : colors.mutedForeground;
440
+ const color = a.interpolate({ inputRange: [0, 1], outputRange: [colors.mutedForeground, colors.foreground] });
441
+ const icon = typeof item.icon === 'function' ? item.icon({ active, color: fg }) : item.icon;
442
+ const slop = height < 44 ? (44 - height) / 2 : 0;
443
+
444
+ return (
445
+ <Pressable
446
+ accessibilityRole="tab"
447
+ accessibilityLabel={item.accessibilityLabel ?? (item.badge !== undefined ? `${item.label}, ${item.badge}` : item.label)}
448
+ accessibilityState={{ selected: active, disabled: !!item.disabled }}
449
+ disabled={item.disabled}
450
+ onPress={onPress}
451
+ onPressIn={() => pressTo(0.94)}
452
+ onPressOut={() => pressTo(1)}
453
+ onLayout={onLayout}
454
+ hitSlop={slop ? { top: slop, bottom: slop } : undefined}
455
+ style={[
456
+ styles.tab,
457
+ { height, paddingHorizontal: mode === 'fill' ? 8 : 14, opacity: item.disabled ? 0.4 : 1 },
458
+ mode === 'fill' && styles.fill,
459
+ mode === 'auto' && styles.grow,
460
+ ]}
461
+ >
462
+ {/* Measured: the underline spans exactly this (icon + label). */}
463
+ <Animated.View onLayout={onLabelLayout} style={[styles.labelRow, { transform: [{ scale: press }] }]}>
464
+ {icon}
465
+ <Animated.Text numberOfLines={1} style={[styles.label, { color, fontSize }]}>
466
+ {item.label}
467
+ </Animated.Text>
468
+ </Animated.View>
469
+ {item.badge !== undefined ? <TabBadge value={item.badge} active={active} tint={tint} reduce={reduce} /> : null}
470
+ </Pressable>
471
+ );
472
+ }
473
+
474
+ /** Count bubble: accent when its tab is active, muted otherwise. Pops when the value changes. */
475
+ function TabBadge({ value, active, tint, reduce }: { value: number | string; active: boolean; tint: string; reduce: boolean }) {
476
+ const { colors, radius, dark } = useKit();
477
+ const s = useRef(new Animated.Value(1)).current;
478
+ const first = useRef(true);
479
+ useEffect(() => {
480
+ if (first.current || reduce) {
481
+ first.current = false;
482
+ return;
483
+ }
484
+ s.setValue(0.5);
485
+ Animated.spring(s, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
486
+ }, [value, reduce, s]);
487
+ const text = typeof value === 'number' && value > 999 ? `${Math.floor(value / 1000)}k` : String(value);
488
+ return (
489
+ <Animated.View
490
+ style={[
491
+ styles.badge,
492
+ {
493
+ borderRadius: radius.capsule(18),
494
+ backgroundColor: active ? tint : alpha(colors.mutedForeground, dark ? 0.22 : 0.14),
495
+ transform: [{ scale: s }],
496
+ },
497
+ ]}
498
+ >
499
+ <Animated.Text style={[styles.badgeText, { color: active ? readableOn(tint) : colors.mutedForeground }]}>{text}</Animated.Text>
500
+ </Animated.View>
501
+ );
502
+ }
503
+
504
+ /** Resistance past the ends: moves less the further you pull, never more than ~half a pane. */
505
+ function rubber(d: number, w: number) {
506
+ return (1 - 1 / ((d * 0.55) / Math.max(1, w) + 1)) * w * 0.5;
507
+ }
508
+
509
+ function useReduceMotion() {
510
+ const [reduce, setReduce] = useState(false);
511
+ useEffect(() => {
512
+ let alive = true;
513
+ AccessibilityInfo.isReduceMotionEnabled()
514
+ .then((v) => alive && setReduce(v))
515
+ .catch(() => {});
516
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
517
+ return () => {
518
+ alive = false;
519
+ sub.remove();
520
+ };
521
+ }, []);
522
+ return reduce;
523
+ }
524
+
525
+ const styles = StyleSheet.create({
526
+ scroll: { flexGrow: 1 },
527
+ row: { flexDirection: 'row', alignItems: 'center' },
528
+ grow: { flexGrow: 1 },
529
+ fill: { flex: 1 },
530
+ tab: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6 },
531
+ labelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
532
+ label: { fontWeight: '400', letterSpacing: -0.1 },
533
+ underline: { position: 'absolute', bottom: 0, height: UNDERLINE },
534
+ badge: { minWidth: 18, height: 18, paddingHorizontal: 5, alignItems: 'center', justifyContent: 'center' },
535
+ badgeText: { fontSize: 11, fontWeight: '400', fontVariant: ['tabular-nums'] },
536
+ viewport: { overflow: 'hidden' },
537
+ pager: { flexDirection: 'row', alignItems: 'flex-start' },
538
+ pane: { paddingTop: 16 },
539
+ });