panelui-native 0.22.6 → 0.28.1

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 (258) hide show
  1. package/README.md +1 -0
  2. package/lib/module/components/accordion/index.js +8 -8
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/alert/index.js +2 -2
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/area-chart/index.js +958 -0
  7. package/lib/module/components/area-chart/index.js.map +1 -0
  8. package/lib/module/components/attachment/index.js +2 -1
  9. package/lib/module/components/attachment/index.js.map +1 -1
  10. package/lib/module/components/avatar/index.js +4 -4
  11. package/lib/module/components/avatar/index.js.map +1 -1
  12. package/lib/module/components/badge/index.js +4 -4
  13. package/lib/module/components/badge/index.js.map +1 -1
  14. package/lib/module/components/bar-chart/index.js +994 -0
  15. package/lib/module/components/bar-chart/index.js.map +1 -0
  16. package/lib/module/components/bottom-sheet/index.js +213 -24
  17. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  18. package/lib/module/components/breadcrumb/index.js +275 -0
  19. package/lib/module/components/breadcrumb/index.js.map +1 -0
  20. package/lib/module/components/button/index.js +11 -6
  21. package/lib/module/components/button/index.js.map +1 -1
  22. package/lib/module/components/calendar/index.js +557 -0
  23. package/lib/module/components/calendar/index.js.map +1 -0
  24. package/lib/module/components/carousel/index.js +680 -0
  25. package/lib/module/components/carousel/index.js.map +1 -0
  26. package/lib/module/components/chip/index.js +6 -6
  27. package/lib/module/components/chip/index.js.map +1 -1
  28. package/lib/module/components/date-picker/index.js +158 -0
  29. package/lib/module/components/date-picker/index.js.map +1 -0
  30. package/lib/module/components/dialog/index.js +7 -2
  31. package/lib/module/components/dialog/index.js.map +1 -1
  32. package/lib/module/components/direction/index.js +15 -20
  33. package/lib/module/components/direction/index.js.map +1 -1
  34. package/lib/module/components/empty-state/index.js +2 -2
  35. package/lib/module/components/empty-state/index.js.map +1 -1
  36. package/lib/module/components/field/index.js +5 -5
  37. package/lib/module/components/field/index.js.map +1 -1
  38. package/lib/module/components/flow/index.js +3 -3
  39. package/lib/module/components/flow/index.js.map +1 -1
  40. package/lib/module/components/frame/index.js +22 -5
  41. package/lib/module/components/frame/index.js.map +1 -1
  42. package/lib/module/components/heatmap-chart/index.js +28 -8
  43. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  44. package/lib/module/components/input-group/index.js +5 -4
  45. package/lib/module/components/input-group/index.js.map +1 -1
  46. package/lib/module/components/item/index.js +9 -9
  47. package/lib/module/components/item/index.js.map +1 -1
  48. package/lib/module/components/label/index.js +4 -4
  49. package/lib/module/components/label/index.js.map +1 -1
  50. package/lib/module/components/line-chart/index.js +165 -184
  51. package/lib/module/components/line-chart/index.js.map +1 -1
  52. package/lib/module/components/loader/index.js +758 -0
  53. package/lib/module/components/loader/index.js.map +1 -0
  54. package/lib/module/components/map/basemap.js +265 -0
  55. package/lib/module/components/map/basemap.js.map +1 -0
  56. package/lib/module/components/map/index.js +850 -0
  57. package/lib/module/components/map/index.js.map +1 -0
  58. package/lib/module/components/map/maplibre.js +87 -0
  59. package/lib/module/components/map/maplibre.js.map +1 -0
  60. package/lib/module/components/marker/index.js +3 -3
  61. package/lib/module/components/marker/index.js.map +1 -1
  62. package/lib/module/components/message/index.js +15 -14
  63. package/lib/module/components/message/index.js.map +1 -1
  64. package/lib/module/components/message-scroller/index.js +5 -4
  65. package/lib/module/components/message-scroller/index.js.map +1 -1
  66. package/lib/module/components/otp-input/index.js +2 -2
  67. package/lib/module/components/popover/index.js +39 -7
  68. package/lib/module/components/popover/index.js.map +1 -1
  69. package/lib/module/components/progress/index.js +8 -2
  70. package/lib/module/components/progress/index.js.map +1 -1
  71. package/lib/module/components/radio-group/index.js +2 -2
  72. package/lib/module/components/radio-group/index.js.map +1 -1
  73. package/lib/module/components/ring-chart/index.js +423 -0
  74. package/lib/module/components/ring-chart/index.js.map +1 -0
  75. package/lib/module/components/section-rail/index.js +6 -6
  76. package/lib/module/components/section-rail/index.js.map +1 -1
  77. package/lib/module/components/select/index.js +10 -4
  78. package/lib/module/components/select/index.js.map +1 -1
  79. package/lib/module/components/separator/index.js +76 -22
  80. package/lib/module/components/separator/index.js.map +1 -1
  81. package/lib/module/components/shimmer/index.js +10 -1
  82. package/lib/module/components/shimmer/index.js.map +1 -1
  83. package/lib/module/components/slider/index.js +19 -5
  84. package/lib/module/components/slider/index.js.map +1 -1
  85. package/lib/module/components/steps/index.js +5 -5
  86. package/lib/module/components/steps/index.js.map +1 -1
  87. package/lib/module/components/switch/index.js +6 -1
  88. package/lib/module/components/switch/index.js.map +1 -1
  89. package/lib/module/components/tabs/index.js +7 -7
  90. package/lib/module/components/tabs/index.js.map +1 -1
  91. package/lib/module/components/timeline/index.js +4 -4
  92. package/lib/module/components/timeline/index.js.map +1 -1
  93. package/lib/module/components/toast/index.js +2 -2
  94. package/lib/module/components/toast/index.js.map +1 -1
  95. package/lib/module/components/toggle-button/index.js +5 -5
  96. package/lib/module/components/toggle-button/index.js.map +1 -1
  97. package/lib/module/components/tooltip/index.js +492 -0
  98. package/lib/module/components/tooltip/index.js.map +1 -0
  99. package/lib/module/components/typography/index.js +7 -2
  100. package/lib/module/components/typography/index.js.map +1 -1
  101. package/lib/module/hooks/index.js +1 -0
  102. package/lib/module/hooks/index.js.map +1 -1
  103. package/lib/module/hooks/use-back-handler.js +37 -0
  104. package/lib/module/hooks/use-back-handler.js.map +1 -0
  105. package/lib/module/hooks/use-direction.js +43 -0
  106. package/lib/module/hooks/use-direction.js.map +1 -0
  107. package/lib/module/icons/index.js +180 -2
  108. package/lib/module/icons/index.js.map +1 -1
  109. package/lib/module/index.js +12 -2
  110. package/lib/module/index.js.map +1 -1
  111. package/lib/module/primitives/keyboard-avoider.js +2 -1
  112. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  113. package/lib/module/primitives/text.js +55 -11
  114. package/lib/module/primitives/text.js.map +1 -1
  115. package/lib/module/utils/chart.js +279 -0
  116. package/lib/module/utils/chart.js.map +1 -0
  117. package/lib/module/utils/date.js +386 -0
  118. package/lib/module/utils/date.js.map +1 -0
  119. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  120. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  121. package/lib/typescript/src/components/area-chart/index.d.ts +237 -0
  122. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -0
  123. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -1
  124. package/lib/typescript/src/components/bar-chart/index.d.ts +270 -0
  125. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -0
  126. package/lib/typescript/src/components/bottom-sheet/index.d.ts +75 -4
  127. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  128. package/lib/typescript/src/components/breadcrumb/index.d.ts +100 -0
  129. package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -0
  130. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  131. package/lib/typescript/src/components/calendar/index.d.ts +108 -0
  132. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -0
  133. package/lib/typescript/src/components/carousel/index.d.ts +179 -0
  134. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -0
  135. package/lib/typescript/src/components/chip/index.d.ts.map +1 -1
  136. package/lib/typescript/src/components/date-picker/index.d.ts +91 -0
  137. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -0
  138. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  139. package/lib/typescript/src/components/direction/index.d.ts +11 -10
  140. package/lib/typescript/src/components/direction/index.d.ts.map +1 -1
  141. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  142. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  143. package/lib/typescript/src/components/frame/index.d.ts +17 -0
  144. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  145. package/lib/typescript/src/components/heatmap-chart/index.d.ts +22 -3
  146. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  147. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  148. package/lib/typescript/src/components/item/index.d.ts.map +1 -1
  149. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  150. package/lib/typescript/src/components/line-chart/index.d.ts +42 -2
  151. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  152. package/lib/typescript/src/components/loader/index.d.ts +38 -0
  153. package/lib/typescript/src/components/loader/index.d.ts.map +1 -0
  154. package/lib/typescript/src/components/map/basemap.d.ts +76 -0
  155. package/lib/typescript/src/components/map/basemap.d.ts.map +1 -0
  156. package/lib/typescript/src/components/map/index.d.ts +367 -0
  157. package/lib/typescript/src/components/map/index.d.ts.map +1 -0
  158. package/lib/typescript/src/components/map/maplibre.d.ts +193 -0
  159. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -0
  160. package/lib/typescript/src/components/marker/index.d.ts.map +1 -1
  161. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  162. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  163. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  164. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  165. package/lib/typescript/src/components/ring-chart/index.d.ts +156 -0
  166. package/lib/typescript/src/components/ring-chart/index.d.ts.map +1 -0
  167. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  168. package/lib/typescript/src/components/separator/index.d.ts +36 -1
  169. package/lib/typescript/src/components/separator/index.d.ts.map +1 -1
  170. package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
  171. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  172. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  173. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  174. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  175. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  176. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  177. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
  178. package/lib/typescript/src/components/tooltip/index.d.ts +125 -0
  179. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -0
  180. package/lib/typescript/src/components/typography/index.d.ts +6 -0
  181. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  182. package/lib/typescript/src/hooks/index.d.ts +1 -0
  183. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  184. package/lib/typescript/src/hooks/use-back-handler.d.ts +19 -0
  185. package/lib/typescript/src/hooks/use-back-handler.d.ts.map +1 -0
  186. package/lib/typescript/src/hooks/use-direction.d.ts +20 -0
  187. package/lib/typescript/src/hooks/use-direction.d.ts.map +1 -0
  188. package/lib/typescript/src/icons/index.d.ts +8 -0
  189. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  190. package/lib/typescript/src/index.d.ts +14 -4
  191. package/lib/typescript/src/index.d.ts.map +1 -1
  192. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  193. package/lib/typescript/src/primitives/text.d.ts +20 -0
  194. package/lib/typescript/src/primitives/text.d.ts.map +1 -1
  195. package/lib/typescript/src/utils/chart.d.ts +96 -0
  196. package/lib/typescript/src/utils/chart.d.ts.map +1 -0
  197. package/lib/typescript/src/utils/date.d.ts +116 -0
  198. package/lib/typescript/src/utils/date.d.ts.map +1 -0
  199. package/package.json +9 -1
  200. package/src/components/accordion/index.tsx +8 -12
  201. package/src/components/alert/index.tsx +2 -2
  202. package/src/components/area-chart/index.tsx +1147 -0
  203. package/src/components/attachment/index.tsx +2 -2
  204. package/src/components/avatar/index.tsx +4 -4
  205. package/src/components/badge/index.tsx +6 -6
  206. package/src/components/bar-chart/index.tsx +1208 -0
  207. package/src/components/bottom-sheet/index.tsx +282 -21
  208. package/src/components/breadcrumb/index.tsx +363 -0
  209. package/src/components/button/index.tsx +12 -8
  210. package/src/components/calendar/index.tsx +703 -0
  211. package/src/components/carousel/index.tsx +788 -0
  212. package/src/components/chip/index.tsx +6 -8
  213. package/src/components/date-picker/index.tsx +237 -0
  214. package/src/components/dialog/index.tsx +7 -2
  215. package/src/components/direction/index.tsx +22 -22
  216. package/src/components/empty-state/index.tsx +2 -2
  217. package/src/components/field/index.tsx +3 -5
  218. package/src/components/flow/index.tsx +3 -3
  219. package/src/components/frame/index.tsx +22 -7
  220. package/src/components/heatmap-chart/index.tsx +58 -11
  221. package/src/components/input-group/index.tsx +5 -4
  222. package/src/components/item/index.tsx +9 -9
  223. package/src/components/label/index.tsx +2 -2
  224. package/src/components/line-chart/index.tsx +194 -223
  225. package/src/components/loader/index.tsx +812 -0
  226. package/src/components/map/basemap.ts +304 -0
  227. package/src/components/map/index.tsx +1116 -0
  228. package/src/components/map/maplibre.ts +201 -0
  229. package/src/components/marker/index.tsx +3 -3
  230. package/src/components/message/index.tsx +15 -14
  231. package/src/components/message-scroller/index.tsx +5 -4
  232. package/src/components/otp-input/index.tsx +2 -2
  233. package/src/components/popover/index.tsx +43 -7
  234. package/src/components/progress/index.tsx +10 -2
  235. package/src/components/radio-group/index.tsx +2 -2
  236. package/src/components/ring-chart/index.tsx +543 -0
  237. package/src/components/section-rail/index.tsx +6 -6
  238. package/src/components/select/index.tsx +10 -4
  239. package/src/components/separator/index.tsx +77 -19
  240. package/src/components/shimmer/index.tsx +10 -1
  241. package/src/components/slider/index.tsx +20 -5
  242. package/src/components/steps/index.tsx +5 -5
  243. package/src/components/switch/index.tsx +6 -1
  244. package/src/components/tabs/index.tsx +7 -9
  245. package/src/components/timeline/index.tsx +4 -4
  246. package/src/components/toast/index.tsx +2 -2
  247. package/src/components/toggle-button/index.tsx +5 -7
  248. package/src/components/tooltip/index.tsx +610 -0
  249. package/src/components/typography/index.tsx +7 -2
  250. package/src/hooks/index.ts +1 -0
  251. package/src/hooks/use-back-handler.ts +37 -0
  252. package/src/hooks/use-direction.ts +45 -0
  253. package/src/icons/index.tsx +111 -0
  254. package/src/index.ts +129 -0
  255. package/src/primitives/keyboard-avoider.tsx +2 -1
  256. package/src/primitives/text.tsx +53 -8
  257. package/src/utils/chart.ts +355 -0
  258. package/src/utils/date.ts +446 -0
@@ -0,0 +1,788 @@
1
+ /**
2
+ * Carousel — a run of slides, one at a time, dragged with a finger.
3
+ *
4
+ * ```tsx
5
+ * <Carousel loop>
6
+ * <Carousel.Content className="h-56">
7
+ * {photos.map((photo) => (
8
+ * <Carousel.Item key={photo.id}>
9
+ * <Image source={{ uri: photo.uri }} className="h-full w-full" />
10
+ * </Carousel.Item>
11
+ * ))}
12
+ * </Carousel.Content>
13
+ * <Carousel.Controls />
14
+ * </Carousel>
15
+ * ```
16
+ *
17
+ * ## One shared value, four layouts
18
+ *
19
+ * Everything is driven from a single `progress` — the position in the run, as a
20
+ * fractional index. `2.4` is two-fifths of the way from the third slide to the
21
+ * fourth, and every slide styles itself from its own distance to that number.
22
+ * A pan writes to it, a spring settles it onto a whole number, and the dots
23
+ * read it.
24
+ *
25
+ * That is why the track is a pan gesture rather than a paging `ScrollView`. A
26
+ * scroll view carries its offset natively and would serve `default` well
27
+ * enough, but `coverflow` and `stack` do not lay their slides along a track at
28
+ * all — they hold them in one place and pull them apart with transforms — so
29
+ * there would be nothing for it to scroll. One mechanism all four read beats a
30
+ * native scroller for one of them and something else for the rest.
31
+ *
32
+ * ## What each layout is for
33
+ *
34
+ * - **`default`** — a track. The honest choice for content that is read rather
35
+ * than admired: a row of cards, a gallery, an onboarding run.
36
+ * - **`interactive`** — the run fans out around the middle slide and tilts away
37
+ * on both sides, opening wider while a finger is down. For a small set worth
38
+ * showing off.
39
+ * - **`coverflow`** — the neighbours turn away from you in perspective. Best
40
+ * with art: covers, posters, photographs.
41
+ * - **`stack`** — a deck. The active card is on top with the next two peeking
42
+ * out behind it, and dragging takes the top one away. For cards dealt with
43
+ * one at a time, where the pile is the point.
44
+ *
45
+ * Depth is carried by scale, opacity and z-order rather than by moving slides
46
+ * along z: React Native's transform has `perspective` and `rotateY` but no
47
+ * `translateZ`, so a slide is made to *look* further away rather than put there.
48
+ */
49
+ import {
50
+ Children,
51
+ createContext,
52
+ forwardRef,
53
+ useCallback,
54
+ useContext,
55
+ useEffect,
56
+ useImperativeHandle,
57
+ useMemo,
58
+ useState,
59
+ type ReactNode,
60
+ } from 'react';
61
+ import { Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
62
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
63
+ import Animated, {
64
+ Extrapolation,
65
+ interpolate,
66
+ runOnJS,
67
+ useAnimatedStyle,
68
+ useReducedMotion,
69
+ useSharedValue,
70
+ withSpring,
71
+ withTiming,
72
+ type SharedValue,
73
+ } from 'react-native-reanimated';
74
+ import { ChevronLeftIcon, ChevronRightIcon } from '../../icons';
75
+ import { Text, textChildren } from '../../primitives/text';
76
+ import { cn } from '../../utils/cn';
77
+
78
+ /** Settles the run onto a whole index. Tuned to stop rather than to bounce. */
79
+ const SPRING = { damping: 22, stiffness: 190, mass: 0.55 } as const;
80
+
81
+ /**
82
+ * Fraction of a slide that has to be dragged past for the move to count, and
83
+ * the velocity that carries it there regardless. Together they are what lets a
84
+ * short fast flick and a long slow drag both advance exactly one slide.
85
+ */
86
+ const SNAP_FRACTION = 0.28;
87
+ const SNAP_VELOCITY = 500;
88
+
89
+ /** How far past the ends the run may be pulled when it does not loop. */
90
+ const OVERSCROLL = 0.4;
91
+ /** Fraction of the finger's travel that lands past an end. */
92
+ const RUBBER = 0.35;
93
+
94
+ /** Slides either side of the active one that `coverflow` still draws. */
95
+ const COVERFLOW_DEPTH = 2;
96
+
97
+ /**
98
+ * Gap between coverflow's slides, as a fraction of one slide's length.
99
+ *
100
+ * Proportional rather than a fixed number of points: the neighbours have to
101
+ * clear the middle slide by enough to be read as separate cards, and how much
102
+ * that is depends entirely on how wide the cards are.
103
+ */
104
+ const COVERFLOW_SPREAD = 0.55;
105
+
106
+ /** Cards behind the top one in `stack`. Two is a pile; five is a mess. */
107
+ const STACK_DEPTH = 2;
108
+
109
+ export type CarouselVariant = 'default' | 'interactive' | 'coverflow' | 'stack';
110
+ export type CarouselOrientation = 'horizontal' | 'vertical';
111
+ export type CarouselAlign = 'start' | 'center';
112
+
113
+ interface CarouselContextValue {
114
+ /** Position in the run as a fractional index. The whole component reads it. */
115
+ progress: SharedValue<number>;
116
+ /** 0 to 1 as a finger lands and lifts. Drives the resting and open states. */
117
+ engaged: SharedValue<number>;
118
+ count: number;
119
+ setCount: (count: number) => void;
120
+ /** Nearest whole index, in JS — a frame behind `progress`, by design. */
121
+ index: number;
122
+ scrollTo: (index: number) => void;
123
+ next: () => void;
124
+ previous: () => void;
125
+ variant: CarouselVariant;
126
+ orientation: CarouselOrientation;
127
+ align: CarouselAlign;
128
+ loop: boolean;
129
+ itemSize: number;
130
+ }
131
+
132
+ const CarouselContext = createContext<CarouselContextValue | null>(null);
133
+
134
+ function useCarousel(component: string): CarouselContextValue {
135
+ const context = useContext(CarouselContext);
136
+ if (!context) {
137
+ throw new Error(`${component} must be used within a <Carousel>`);
138
+ }
139
+ return context;
140
+ }
141
+
142
+ /**
143
+ * A slide's own position in the run, handed down by `Content` rather than
144
+ * counted up by each slide.
145
+ *
146
+ * The alternative is for a slide to register itself and be told its number
147
+ * back, which works right up until one is inserted in the middle and every
148
+ * slide after it is wrong until the next render settles.
149
+ */
150
+ const ItemIndexContext = createContext(0);
151
+
152
+ /** Position and controls, for a control of your own outside the built-in ones. */
153
+ export function useCarouselState() {
154
+ const { index, count, scrollTo, next, previous } = useCarousel('useCarouselState');
155
+ return { index, count, scrollTo, next, previous };
156
+ }
157
+
158
+ /**
159
+ * Wraps a fractional index into the run. A worklet because the pan needs it
160
+ * per frame on the UI thread, and the imperative handle needs it in JS.
161
+ */
162
+ function wrap(value: number, count: number) {
163
+ 'worklet';
164
+ if (count <= 0) return 0;
165
+ return ((value % count) + count) % count;
166
+ }
167
+
168
+ /**
169
+ * Signed distance from a slide to the current position, taking the short way
170
+ * round when the run loops.
171
+ *
172
+ * Without the wrap the last slide sits `count - 1` away from the first, and
173
+ * every layout throws it off screen at the exact moment it should be sliding
174
+ * in from the other side.
175
+ */
176
+ function distance(index: number, progress: number, count: number, loop: boolean) {
177
+ 'worklet';
178
+ const raw = index - progress;
179
+ if (!loop || count <= 1) return raw;
180
+ const half = count / 2;
181
+ if (raw > half) return raw - count;
182
+ if (raw < -half) return raw + count;
183
+ return raw;
184
+ }
185
+
186
+ export interface CarouselProps extends ViewProps {
187
+ className?: string;
188
+ /** How the slides are arranged, and how they move. */
189
+ variant?: CarouselVariant;
190
+ /** Which way the run travels. `stack` is always dealt sideways. */
191
+ orientation?: CarouselOrientation;
192
+ /** Run past the last slide back to the first, and the other way. */
193
+ loop?: boolean;
194
+ /**
195
+ * Where the active slide sits. `center` is what the fanned layouts want;
196
+ * `start` suits a row of cards running off the trailing edge. `coverflow`
197
+ * and `stack` are always centred.
198
+ */
199
+ align?: CarouselAlign;
200
+ /**
201
+ * Length of one slide along the direction of travel, in points. Measured from
202
+ * the carousel's own box when omitted, which is what a full-width slide
203
+ * wants; set it for a run that shows more than one at a time.
204
+ */
205
+ itemSize?: number;
206
+ /** Advance on a timer. Stops for good the first time a finger lands. */
207
+ autoplay?: boolean;
208
+ /** Milliseconds each slide is held when `autoplay` is set. */
209
+ autoplayInterval?: number;
210
+ /** Controlled active slide. */
211
+ index?: number;
212
+ /** Starting slide when uncontrolled. */
213
+ defaultIndex?: number;
214
+ onIndexChange?: (index: number) => void;
215
+ /** Let go of the gesture, for a carousel inside something else that drags. */
216
+ scrollEnabled?: boolean;
217
+ children?: ReactNode;
218
+ }
219
+
220
+ /** Imperative handle, for driving the run from outside its own controls. */
221
+ export interface CarouselHandle {
222
+ next: () => void;
223
+ previous: () => void;
224
+ scrollTo: (index: number) => void;
225
+ }
226
+
227
+ const CarouselRoot = forwardRef<CarouselHandle, CarouselProps>(function CarouselRoot(
228
+ {
229
+ className,
230
+ variant = 'default',
231
+ orientation = 'horizontal',
232
+ loop = false,
233
+ align = 'center',
234
+ itemSize: itemSizeProp,
235
+ autoplay = false,
236
+ autoplayInterval = 4000,
237
+ index: indexProp,
238
+ defaultIndex = 0,
239
+ onIndexChange,
240
+ scrollEnabled = true,
241
+ children,
242
+ ...props
243
+ },
244
+ ref
245
+ ) {
246
+ const [size, setSize] = useState({ width: 0, height: 0 });
247
+ const [internalIndex, setInternalIndex] = useState(defaultIndex);
248
+ const [count, setCount] = useState(0);
249
+ const [touched, setTouched] = useState(false);
250
+ const reducedMotion = useReducedMotion();
251
+
252
+ const isControlled = indexProp !== undefined;
253
+ const index = isControlled ? indexProp : internalIndex;
254
+
255
+ const progress = useSharedValue(defaultIndex);
256
+ const engaged = useSharedValue(0);
257
+
258
+ // A deck is dealt from the top of a pile, so it is dragged sideways whatever
259
+ // the run's own direction is — there is no track for it to travel along.
260
+ const axis: CarouselOrientation = variant === 'stack' ? 'horizontal' : orientation;
261
+ const along = axis === 'horizontal' ? size.width : size.height;
262
+ const itemSize = itemSizeProp ?? along ?? 0;
263
+
264
+ const setIndex = useCallback(
265
+ (next: number) => {
266
+ if (!isControlled) setInternalIndex(next);
267
+ onIndexChange?.(next);
268
+ },
269
+ [isControlled, onIndexChange]
270
+ );
271
+
272
+ const scrollTo = useCallback(
273
+ (target: number) => {
274
+ if (count <= 0) return;
275
+ const settled = loop ? wrap(target, count) : Math.max(0, Math.min(target, count - 1));
276
+
277
+ if (reducedMotion) {
278
+ progress.value = settled;
279
+ } else if (loop) {
280
+ // Spring to the nearest representation of the target rather than to the
281
+ // target itself, so a wrap from the last slide to the first travels one
282
+ // step forward instead of winding all the way back through the run.
283
+ const shortest = progress.value + distance(settled, progress.value, count, true);
284
+ progress.value = withSpring(shortest, SPRING, (finished) => {
285
+ if (finished) progress.value = wrap(progress.value, count);
286
+ });
287
+ } else {
288
+ progress.value = withSpring(settled, SPRING);
289
+ }
290
+
291
+ setIndex(settled);
292
+ },
293
+ [count, loop, progress, reducedMotion, setIndex]
294
+ );
295
+
296
+ const next = useCallback(() => scrollTo(index + 1), [index, scrollTo]);
297
+ const previous = useCallback(() => scrollTo(index - 1), [index, scrollTo]);
298
+
299
+ useImperativeHandle(ref, () => ({ next, previous, scrollTo }), [next, previous, scrollTo]);
300
+
301
+ // A controlled index moving underneath us animates rather than jumps.
302
+ useEffect(() => {
303
+ if (!isControlled || indexProp === undefined || count <= 0) return;
304
+ if (Math.abs(progress.value - indexProp) < 0.001) return;
305
+ progress.value = reducedMotion ? indexProp : withSpring(indexProp, SPRING);
306
+ }, [isControlled, indexProp, count, progress, reducedMotion]);
307
+
308
+ /*
309
+ * Autoplay stops for good the first time a finger lands, rather than pausing.
310
+ * Someone who has taken hold of the run is reading it, and having it start
311
+ * moving again a few seconds later is the behaviour everybody hates.
312
+ */
313
+ useEffect(() => {
314
+ if (!autoplay || touched || count <= 1) return;
315
+ const timer = setInterval(() => scrollTo(index + 1), autoplayInterval);
316
+ return () => clearInterval(timer);
317
+ }, [autoplay, touched, count, index, autoplayInterval, scrollTo]);
318
+
319
+ const settle = useCallback(
320
+ (target: number) => {
321
+ setTouched(true);
322
+ scrollTo(target);
323
+ },
324
+ [scrollTo]
325
+ );
326
+
327
+ const pan = useMemo(
328
+ () =>
329
+ Gesture.Pan()
330
+ .enabled(scrollEnabled && count > 1 && itemSize > 0)
331
+ // A carousel inside a scroll view has to let the cross-axis drags
332
+ // through, or the two fight over every diagonal.
333
+ .activeOffsetX(axis === 'horizontal' ? [-10, 10] : [-10000, 10000])
334
+ .activeOffsetY(axis === 'horizontal' ? [-10000, 10000] : [-10, 10])
335
+ .onBegin(() => {
336
+ engaged.value = withTiming(1, { duration: 160 });
337
+ })
338
+ .onUpdate((event) => {
339
+ const moved = axis === 'horizontal' ? event.translationX : event.translationY;
340
+ const raw = progress.value - moved / itemSize;
341
+ if (loop) {
342
+ progress.value = raw;
343
+ return;
344
+ }
345
+ // Off the ends the run follows the finger at a fraction of the
346
+ // distance, so the edge is felt rather than hit.
347
+ const last = count - 1;
348
+ progress.value =
349
+ raw < 0
350
+ ? Math.max(-OVERSCROLL, raw * RUBBER)
351
+ : raw > last
352
+ ? Math.min(last + OVERSCROLL, last + (raw - last) * RUBBER)
353
+ : raw;
354
+ })
355
+ .onEnd((event) => {
356
+ const velocity = axis === 'horizontal' ? event.velocityX : event.velocityY;
357
+ const moved = axis === 'horizontal' ? event.translationX : event.translationY;
358
+
359
+ // The slide it started on, not the one it is nearest now: rounding
360
+ // the current position would let a slow drag that never reached the
361
+ // threshold still count as a move.
362
+ const from = Math.round(progress.value + moved / itemSize);
363
+ const past = Math.abs(moved) / itemSize > SNAP_FRACTION;
364
+ const flicked = Math.abs(velocity) > SNAP_VELOCITY;
365
+ const step = past || flicked ? (moved < 0 ? 1 : -1) : 0;
366
+
367
+ runOnJS(settle)(from + step);
368
+ })
369
+ .onFinalize(() => {
370
+ engaged.value = withTiming(0, { duration: 220 });
371
+ }),
372
+ [scrollEnabled, count, itemSize, axis, engaged, progress, loop, settle]
373
+ );
374
+
375
+ const onLayout = (event: LayoutChangeEvent) => {
376
+ const { width, height } = event.nativeEvent.layout;
377
+ setSize((current) =>
378
+ Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1
379
+ ? current
380
+ : { width, height }
381
+ );
382
+ props.onLayout?.(event);
383
+ };
384
+
385
+ const context = useMemo(
386
+ () => ({
387
+ progress,
388
+ engaged,
389
+ count,
390
+ setCount,
391
+ index,
392
+ scrollTo: settle,
393
+ next,
394
+ previous,
395
+ variant,
396
+ orientation: axis,
397
+ align,
398
+ loop,
399
+ itemSize,
400
+ }),
401
+ [progress, engaged, count, index, settle, next, previous, variant, axis, align, loop, itemSize]
402
+ );
403
+
404
+ return (
405
+ <CarouselContext.Provider value={context}>
406
+ <GestureDetector gesture={pan}>
407
+ <View
408
+ accessibilityRole="list"
409
+ {...props}
410
+ onLayout={onLayout}
411
+ className={cn('w-full', className)}
412
+ >
413
+ {textChildren(children)}
414
+ </View>
415
+ </GestureDetector>
416
+ </CarouselContext.Provider>
417
+ );
418
+ });
419
+ CarouselRoot.displayName = 'Carousel';
420
+
421
+ /* -------------------------------------------------------------------------- */
422
+ /* Track */
423
+ /* -------------------------------------------------------------------------- */
424
+
425
+ export interface CarouselContentProps extends ViewProps {
426
+ className?: string;
427
+ children?: ReactNode;
428
+ }
429
+
430
+ /**
431
+ * The box the slides live in. Give it a height — nothing inside it is in the
432
+ * layout flow, so it has no height of its own to take.
433
+ *
434
+ * It does not move. Every slide places itself from `progress`, which is what
435
+ * lets `default` and `coverflow` be one component with different arithmetic
436
+ * rather than two different trees. The alignment here is the *resting* place
437
+ * every slide is offset from.
438
+ */
439
+ function CarouselContent({ className, children, ...props }: CarouselContentProps) {
440
+ const { setCount, variant, orientation, align } = useCarousel('Carousel.Content');
441
+ const slides = Children.count(children);
442
+ const horizontal = orientation === 'horizontal';
443
+ const centred = align === 'center' || variant === 'coverflow' || variant === 'stack';
444
+
445
+ useEffect(() => {
446
+ setCount(slides);
447
+ }, [setCount, slides]);
448
+
449
+ return (
450
+ <View
451
+ {...props}
452
+ className={cn(
453
+ 'w-full overflow-hidden',
454
+ // Absolutely positioned children with no insets still take the
455
+ // parent's alignment in Yoga, which is what puts a slide at rest.
456
+ horizontal
457
+ ? cn('justify-center', centred ? 'items-center' : 'items-start')
458
+ : cn('items-center', centred ? 'justify-center' : 'justify-start'),
459
+ className
460
+ )}
461
+ style={[
462
+ // `perspective` on the container is what makes coverflow's rotation
463
+ // read as depth rather than as a squash.
464
+ variant === 'coverflow' ? { transform: [{ perspective: 1000 }] } : null,
465
+ props.style,
466
+ ]}
467
+ >
468
+ {Children.map(children, (child, index) => (
469
+ <ItemIndexContext.Provider value={index}>{child}</ItemIndexContext.Provider>
470
+ ))}
471
+ </View>
472
+ );
473
+ }
474
+
475
+ export interface CarouselItemProps extends ViewProps {
476
+ className?: string;
477
+ children?: ReactNode;
478
+ }
479
+
480
+ /** One slide. Its transform is whatever the root's `variant` asks for. */
481
+ function CarouselItem({ className, children, style, ...props }: CarouselItemProps) {
482
+ const { progress, engaged, count, index: active, variant, orientation, loop, itemSize } =
483
+ useCarousel('Carousel.Item');
484
+ const index = useContext(ItemIndexContext);
485
+ const horizontal = orientation === 'horizontal';
486
+
487
+ /*
488
+ * In the layouts that pile slides on top of each other, only the one on top
489
+ * takes touches.
490
+ *
491
+ * `zIndex` reorders what is *drawn* but, on iOS, not what is *hit*: hit
492
+ * testing walks the subviews in the order they were added, so the last slide
493
+ * rendered sits in front of every gesture regardless of its z-order — and in
494
+ * a deck that slide is the one at the bottom of the pile, drawn at zero
495
+ * opacity. An invisible card was swallowing every drag.
496
+ */
497
+ const stacked = variant === 'coverflow' || variant === 'stack';
498
+ const inert = stacked && index !== active;
499
+
500
+ const animated = useAnimatedStyle(() => {
501
+ const d = distance(index, progress.value, count, loop);
502
+ const a = Math.abs(d);
503
+
504
+ if (variant === 'coverflow') {
505
+ return {
506
+ opacity: a > COVERFLOW_DEPTH ? 0 : Math.max(0, 1 - a * 0.25),
507
+ zIndex: Math.round(100 - a * 10),
508
+ transform: [
509
+ { perspective: 1000 },
510
+ { translateX: d * itemSize * COVERFLOW_SPREAD },
511
+ // Turned away from the middle and back towards it as it arrives.
512
+ // Interpolated rather than switched, or a slide would snap flat.
513
+ { rotateY: `${interpolate(d, [-1, 0, 1], [38, 0, -38], Extrapolation.CLAMP)}deg` },
514
+ { scale: interpolate(a, [0, 1], [1.1, 0.92], Extrapolation.CLAMP) },
515
+ ],
516
+ };
517
+ }
518
+
519
+ if (variant === 'stack') {
520
+ // The pile behind the top card is stepped, not spread: each card back is
521
+ // a little smaller and a little lower. Only the top one is dragged, and
522
+ // it leaves sideways with a tilt.
523
+ const behind = Math.min(Math.max(0, d), STACK_DEPTH);
524
+ const leaving = Math.min(0, d);
525
+ return {
526
+ opacity: d < -1 || d > STACK_DEPTH ? 0 : 1,
527
+ zIndex: Math.round(100 - Math.max(0, d) * 10),
528
+ transform: [
529
+ { translateX: leaving * itemSize * 1.15 },
530
+ { translateY: behind * 14 },
531
+ { rotate: `${leaving * 12}deg` },
532
+ { scale: 1 - behind * 0.06 },
533
+ ],
534
+ };
535
+ }
536
+
537
+ if (variant === 'interactive') {
538
+ /*
539
+ * Two states blended by `engaged`, rather than switched between: at rest
540
+ * the run is a tidy fan, and it opens wider under a finger. Blended, so
541
+ * taking hold of it opens it; switched, it would jump open.
542
+ */
543
+ const open = engaged.value;
544
+ const spread = d * itemSize;
545
+ const lift = d * 24 * open;
546
+ const inactive = 0.78 - open * 0.15;
547
+ return {
548
+ opacity: a > 3 ? 0 : 1,
549
+ zIndex: Math.round(100 - a * 10),
550
+ transform: [
551
+ { translateX: horizontal ? spread : lift },
552
+ { translateY: horizontal ? lift : spread },
553
+ { rotate: `${d * (5 + open * 15)}deg` },
554
+ /*
555
+ * The slide in the middle rests at exactly 1, and the fan's depth
556
+ * comes from the others shrinking rather than from it growing.
557
+ *
558
+ * Scaling it up instead is what made its caption soft. Text is
559
+ * rasterised at its layout size and then scaled by the compositor,
560
+ * so a label on a slide held at 1.05 is drawn at the wrong raster
561
+ * size for as long as it is the active one — which is the whole time
562
+ * it is readable, since every other caption has faded out.
563
+ */
564
+ { scale: interpolate(a, [0, 1], [1, inactive], Extrapolation.CLAMP) },
565
+ ],
566
+ };
567
+ }
568
+
569
+ // `default` — a plain track, each slide one length from the last.
570
+ return {
571
+ transform: [
572
+ { translateX: horizontal ? d * itemSize : 0 },
573
+ { translateY: horizontal ? 0 : d * itemSize },
574
+ ],
575
+ };
576
+ });
577
+
578
+ // `coverflow` and `stack` are sized by whatever is put in them: they are
579
+ // built around a card, and a card that had to be the width of the screen
580
+ // would have nothing to stack behind.
581
+ const sized = variant === 'default' || variant === 'interactive';
582
+
583
+ return (
584
+ <Animated.View
585
+ {...props}
586
+ style={[
587
+ { position: 'absolute', pointerEvents: inert ? 'none' : 'auto' },
588
+ sized ? (horizontal ? { width: itemSize } : { height: itemSize }) : null,
589
+ sized && variant === 'default'
590
+ ? horizontal
591
+ ? { height: '100%' }
592
+ : { width: '100%' }
593
+ : null,
594
+ animated,
595
+ style,
596
+ ]}
597
+ className={cn(className)}
598
+ >
599
+ {textChildren(children)}
600
+ </Animated.View>
601
+ );
602
+ }
603
+
604
+ /* -------------------------------------------------------------------------- */
605
+ /* Controls */
606
+ /* -------------------------------------------------------------------------- */
607
+
608
+ export interface CarouselCaptionProps extends ViewProps {
609
+ className?: string;
610
+ children?: ReactNode;
611
+ }
612
+
613
+ /**
614
+ * A slide's label, shown only while that slide is the active one.
615
+ *
616
+ * It lives inside the slide rather than beside the run, so it travels with what
617
+ * it names — a caption that stays put while the picture moves belongs to the
618
+ * carousel rather than to the picture.
619
+ */
620
+ function CarouselCaption({ className, children, ...props }: CarouselCaptionProps) {
621
+ const { progress, count, loop } = useCarousel('Carousel.Caption');
622
+ const index = useContext(ItemIndexContext);
623
+
624
+ /*
625
+ * Gone well before the next slide arrives, rather than fading across the
626
+ * whole step. The layouts that use a caption overlap their slides, so a
627
+ * caption still at a third of its opacity halfway through a drag is a line
628
+ * of grey text sitting on top of the neighbouring picture — which reads as a
629
+ * rendering fault rather than as a transition.
630
+ */
631
+ /*
632
+ * Opacity only. A scale here would compound with the slide's own — the
633
+ * caption is inside it — and the text would be resampled twice, once for
634
+ * each. The slide already carries all the movement this needs to read as
635
+ * belonging to the picture.
636
+ */
637
+ const animated = useAnimatedStyle(() => {
638
+ const a = Math.abs(distance(index, progress.value, count, loop));
639
+ return { opacity: interpolate(a, [0, 0.3], [1, 0], Extrapolation.CLAMP) };
640
+ });
641
+
642
+ return (
643
+ <Animated.View {...props} style={[animated, props.style]} className={cn(className)}>
644
+ {textChildren(children, (text) => (
645
+ <Text size="xs" weight="semibold" className="text-center">
646
+ {text}
647
+ </Text>
648
+ ))}
649
+ </Animated.View>
650
+ );
651
+ }
652
+
653
+ export interface CarouselDotsProps extends ViewProps {
654
+ className?: string;
655
+ /** Lay the dots down the side instead of across. */
656
+ orientation?: CarouselOrientation;
657
+ /** Jump to a slide by tapping its dot. */
658
+ interactive?: boolean;
659
+ }
660
+
661
+ /**
662
+ * One dot per slide, the active one drawn as a bar.
663
+ *
664
+ * Length rather than colour alone carries the position: a row that differs only
665
+ * in opacity is unreadable at a glance, and invisible to anyone who cannot
666
+ * separate the two greys.
667
+ */
668
+ function CarouselDots({
669
+ className,
670
+ orientation = 'horizontal',
671
+ interactive = true,
672
+ ...props
673
+ }: CarouselDotsProps) {
674
+ const { count, index, scrollTo } = useCarousel('Carousel.Dots');
675
+ const horizontal = orientation === 'horizontal';
676
+ if (count <= 1) return null;
677
+
678
+ return (
679
+ <View
680
+ accessibilityRole="tablist"
681
+ {...props}
682
+ className={cn('items-center gap-1', horizontal ? 'flex-row' : 'flex-col', className)}
683
+ >
684
+ {Array.from({ length: count }, (_unused, dot) => {
685
+ const active = dot === index;
686
+ return (
687
+ <Pressable
688
+ key={dot}
689
+ disabled={!interactive}
690
+ onPress={() => scrollTo(dot)}
691
+ hitSlop={6}
692
+ accessibilityRole="tab"
693
+ accessibilityLabel={`Slide ${dot + 1} of ${count}`}
694
+ accessibilityState={{ selected: active }}
695
+ className={cn(
696
+ 'rounded-full',
697
+ horizontal ? (active ? 'h-1 w-4' : 'h-1 w-1') : active ? 'h-4 w-1' : 'h-1 w-1',
698
+ active ? 'bg-foreground' : 'bg-foreground/30'
699
+ )}
700
+ />
701
+ );
702
+ })}
703
+ </View>
704
+ );
705
+ }
706
+
707
+ export interface CarouselArrowProps extends ViewProps {
708
+ className?: string;
709
+ children?: ReactNode;
710
+ }
711
+
712
+ /** Body of the two arrows — they differ only in icon, label and direction. */
713
+ function makeArrow(direction: 'previous' | 'next') {
714
+ const name = direction === 'next' ? 'Next' : 'Previous';
715
+
716
+ function Arrow({ className, children, ...props }: CarouselArrowProps) {
717
+ const { count, index, loop, next, previous } = useCarousel(`Carousel.${name}`);
718
+ // Without a loop the ends are dead. An arrow that stays live and does
719
+ // nothing is worse than one that says it cannot.
720
+ const disabled = !loop && (direction === 'next' ? index >= count - 1 : index <= 0);
721
+
722
+ return (
723
+ <Pressable
724
+ disabled={disabled}
725
+ onPress={direction === 'next' ? next : previous}
726
+ hitSlop={8}
727
+ accessibilityRole="button"
728
+ accessibilityLabel={direction === 'next' ? 'Next slide' : 'Previous slide'}
729
+ accessibilityState={{ disabled }}
730
+ className={cn(
731
+ 'items-center justify-center rounded-full p-1',
732
+ disabled ? 'opacity-30' : 'active:bg-foreground/10',
733
+ className
734
+ )}
735
+ {...props}
736
+ >
737
+ {children ??
738
+ (direction === 'next' ? (
739
+ <ChevronRightIcon size={14} />
740
+ ) : (
741
+ <ChevronLeftIcon size={14} />
742
+ ))}
743
+ </Pressable>
744
+ );
745
+ }
746
+ Arrow.displayName = `Carousel.${name}`;
747
+ return Arrow;
748
+ }
749
+
750
+ const CarouselPrevious = makeArrow('previous');
751
+ const CarouselNext = makeArrow('next');
752
+
753
+ export interface CarouselControlsProps extends ViewProps {
754
+ className?: string;
755
+ }
756
+
757
+ /**
758
+ * The arrows and the dots in one pill.
759
+ *
760
+ * Together rather than scattered, because they answer the same question — where
761
+ * am I in this, and how do I move — and a bar that reads as one object can sit
762
+ * over the content instead of taking a strip of the layout for itself.
763
+ */
764
+ function CarouselControls({ className, ...props }: CarouselControlsProps) {
765
+ return (
766
+ <View
767
+ {...props}
768
+ className={cn(
769
+ 'flex-row items-center justify-center gap-2 self-center rounded-full border border-border bg-background/80 px-1.5 py-0.5',
770
+ className
771
+ )}
772
+ >
773
+ <CarouselPrevious />
774
+ <CarouselDots />
775
+ <CarouselNext />
776
+ </View>
777
+ );
778
+ }
779
+
780
+ export const Carousel = Object.assign(CarouselRoot, {
781
+ Content: CarouselContent,
782
+ Item: CarouselItem,
783
+ Caption: CarouselCaption,
784
+ Dots: CarouselDots,
785
+ Previous: CarouselPrevious,
786
+ Next: CarouselNext,
787
+ Controls: CarouselControls,
788
+ });