panelui-native 0.66.1 → 0.71.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 (196) hide show
  1. package/README.md +31 -1
  2. package/lib/module/components/accordion/index.js +28 -9
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/area-chart/index.js +18 -1
  5. package/lib/module/components/area-chart/index.js.map +1 -1
  6. package/lib/module/components/bar-chart/index.js +18 -1
  7. package/lib/module/components/bar-chart/index.js.map +1 -1
  8. package/lib/module/components/button/index.js +14 -7
  9. package/lib/module/components/button/index.js.map +1 -1
  10. package/lib/module/components/candlestick-chart/index.js +18 -1
  11. package/lib/module/components/candlestick-chart/index.js.map +1 -1
  12. package/lib/module/components/collapsible/index.js +280 -0
  13. package/lib/module/components/collapsible/index.js.map +1 -0
  14. package/lib/module/components/flow/index.js +66 -3
  15. package/lib/module/components/flow/index.js.map +1 -1
  16. package/lib/module/components/form/index.js +7 -5
  17. package/lib/module/components/form/index.js.map +1 -1
  18. package/lib/module/components/form/typed-form.js +11 -0
  19. package/lib/module/components/form/typed-form.js.map +1 -0
  20. package/lib/module/components/form/use-form.js +4 -0
  21. package/lib/module/components/form/use-form.js.map +1 -1
  22. package/lib/module/components/heatmap-chart/index.js +27 -1
  23. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  24. package/lib/module/components/line-chart/index.js +18 -1
  25. package/lib/module/components/line-chart/index.js.map +1 -1
  26. package/lib/module/components/live-line-chart/index.js +103 -9
  27. package/lib/module/components/live-line-chart/index.js.map +1 -1
  28. package/lib/module/components/live-line-chart/live-line-accessibility.js +47 -0
  29. package/lib/module/components/live-line-chart/live-line-accessibility.js.map +1 -0
  30. package/lib/module/components/live-line-chart/live-line-lifecycle.js +33 -0
  31. package/lib/module/components/live-line-chart/live-line-lifecycle.js.map +1 -0
  32. package/lib/module/components/marquee/index.js +194 -0
  33. package/lib/module/components/marquee/index.js.map +1 -0
  34. package/lib/module/components/message-scroller/index.js +172 -10
  35. package/lib/module/components/message-scroller/index.js.map +1 -1
  36. package/lib/module/components/message-scroller/message-scroller-math.js +23 -0
  37. package/lib/module/components/message-scroller/message-scroller-math.js.map +1 -1
  38. package/lib/module/components/meter/index.js +268 -0
  39. package/lib/module/components/meter/index.js.map +1 -0
  40. package/lib/module/components/meter/meter-scale.js +142 -0
  41. package/lib/module/components/meter/meter-scale.js.map +1 -0
  42. package/lib/module/components/planner/index.js +788 -0
  43. package/lib/module/components/planner/index.js.map +1 -0
  44. package/lib/module/components/planner/planner-announcement.js +30 -0
  45. package/lib/module/components/planner/planner-announcement.js.map +1 -0
  46. package/lib/module/components/planner/planner-entries.js +132 -0
  47. package/lib/module/components/planner/planner-entries.js.map +1 -0
  48. package/lib/module/components/planner/planner-grid-navigation.js +33 -0
  49. package/lib/module/components/planner/planner-grid-navigation.js.map +1 -0
  50. package/lib/module/components/planner/planner-lifecycle.js +45 -0
  51. package/lib/module/components/planner/planner-lifecycle.js.map +1 -0
  52. package/lib/module/components/popover/index.js +2 -2
  53. package/lib/module/components/popover/index.js.map +1 -1
  54. package/lib/module/components/radar-chart/index.js +18 -1
  55. package/lib/module/components/radar-chart/index.js.map +1 -1
  56. package/lib/module/components/scatter-chart/index.js +18 -1
  57. package/lib/module/components/scatter-chart/index.js.map +1 -1
  58. package/lib/module/components/skeleton/index.js +45 -7
  59. package/lib/module/components/skeleton/index.js.map +1 -1
  60. package/lib/module/components/spinner/index.js +63 -14
  61. package/lib/module/components/spinner/index.js.map +1 -1
  62. package/lib/module/components/time-picker/index.js +46 -21
  63. package/lib/module/components/time-picker/index.js.map +1 -1
  64. package/lib/module/components/time-picker/ruler-window.js +25 -0
  65. package/lib/module/components/time-picker/ruler-window.js.map +1 -0
  66. package/lib/module/hooks/breakpoint-contract.js +29 -0
  67. package/lib/module/hooks/breakpoint-contract.js.map +1 -0
  68. package/lib/module/hooks/index.js +1 -1
  69. package/lib/module/hooks/index.js.map +1 -1
  70. package/lib/module/hooks/use-breakpoint.js +46 -1
  71. package/lib/module/hooks/use-breakpoint.js.map +1 -1
  72. package/lib/module/index.js +5 -1
  73. package/lib/module/index.js.map +1 -1
  74. package/lib/module/primitives/chart-accessibility.js +68 -0
  75. package/lib/module/primitives/chart-accessibility.js.map +1 -0
  76. package/lib/module/primitives/focus-restoration-store.js +52 -0
  77. package/lib/module/primitives/focus-restoration-store.js.map +1 -0
  78. package/lib/module/primitives/portal.js +36 -2
  79. package/lib/module/primitives/portal.js.map +1 -1
  80. package/lib/typescript/src/components/accordion/index.d.ts +4 -0
  81. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  82. package/lib/typescript/src/components/area-chart/index.d.ts +2 -1
  83. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  84. package/lib/typescript/src/components/bar-chart/index.d.ts +2 -1
  85. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  87. package/lib/typescript/src/components/candlestick-chart/index.d.ts +2 -1
  88. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/collapsible/index.d.ts +76 -0
  90. package/lib/typescript/src/components/collapsible/index.d.ts.map +1 -0
  91. package/lib/typescript/src/components/flow/index.d.ts +7 -1
  92. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/form/index.d.ts +18 -5
  94. package/lib/typescript/src/components/form/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/form/typed-form.d.ts +7 -0
  96. package/lib/typescript/src/components/form/typed-form.d.ts.map +1 -0
  97. package/lib/typescript/src/components/form/use-form.d.ts +4 -0
  98. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
  99. package/lib/typescript/src/components/heatmap-chart/index.d.ts +2 -1
  100. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/line-chart/index.d.ts +2 -1
  102. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/live-line-chart/index.d.ts +21 -7
  104. package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -1
  105. package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts +22 -0
  106. package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts.map +1 -0
  107. package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts +23 -0
  108. package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts.map +1 -0
  109. package/lib/typescript/src/components/marquee/index.d.ts +73 -0
  110. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -0
  111. package/lib/typescript/src/components/message-scroller/index.d.ts +30 -2
  112. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts +10 -0
  114. package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts.map +1 -1
  115. package/lib/typescript/src/components/meter/index.d.ts +273 -0
  116. package/lib/typescript/src/components/meter/index.d.ts.map +1 -0
  117. package/lib/typescript/src/components/meter/meter-scale.d.ts +96 -0
  118. package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -0
  119. package/lib/typescript/src/components/planner/index.d.ts +274 -0
  120. package/lib/typescript/src/components/planner/index.d.ts.map +1 -0
  121. package/lib/typescript/src/components/planner/planner-announcement.d.ts +15 -0
  122. package/lib/typescript/src/components/planner/planner-announcement.d.ts.map +1 -0
  123. package/lib/typescript/src/components/planner/planner-entries.d.ts +75 -0
  124. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -0
  125. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +4 -0
  126. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -0
  127. package/lib/typescript/src/components/planner/planner-lifecycle.d.ts +20 -0
  128. package/lib/typescript/src/components/planner/planner-lifecycle.d.ts.map +1 -0
  129. package/lib/typescript/src/components/radar-chart/index.d.ts +4 -0
  130. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -1
  131. package/lib/typescript/src/components/scatter-chart/index.d.ts +2 -1
  132. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
  133. package/lib/typescript/src/components/skeleton/index.d.ts +25 -2
  134. package/lib/typescript/src/components/skeleton/index.d.ts.map +1 -1
  135. package/lib/typescript/src/components/spinner/index.d.ts +25 -2
  136. package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
  137. package/lib/typescript/src/components/time-picker/index.d.ts +5 -6
  138. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  139. package/lib/typescript/src/components/time-picker/ruler-window.d.ts +12 -0
  140. package/lib/typescript/src/components/time-picker/ruler-window.d.ts.map +1 -0
  141. package/lib/typescript/src/hooks/breakpoint-contract.d.ts +6 -0
  142. package/lib/typescript/src/hooks/breakpoint-contract.d.ts.map +1 -0
  143. package/lib/typescript/src/hooks/index.d.ts +1 -1
  144. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  145. package/lib/typescript/src/hooks/use-breakpoint.d.ts +22 -0
  146. package/lib/typescript/src/hooks/use-breakpoint.d.ts.map +1 -1
  147. package/lib/typescript/src/index.d.ts +5 -1
  148. package/lib/typescript/src/index.d.ts.map +1 -1
  149. package/lib/typescript/src/primitives/chart-accessibility.d.ts +33 -0
  150. package/lib/typescript/src/primitives/chart-accessibility.d.ts.map +1 -0
  151. package/lib/typescript/src/primitives/focus-restoration-store.d.ts +24 -0
  152. package/lib/typescript/src/primitives/focus-restoration-store.d.ts.map +1 -0
  153. package/lib/typescript/src/primitives/portal.d.ts +4 -0
  154. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  155. package/lib/typescript/types/form-api.type-test.d.ts +2 -0
  156. package/lib/typescript/types/form-api.type-test.d.ts.map +1 -0
  157. package/package.json +63 -4
  158. package/src/components/accordion/index.tsx +31 -8
  159. package/src/components/area-chart/index.tsx +27 -2
  160. package/src/components/bar-chart/index.tsx +27 -2
  161. package/src/components/button/index.tsx +11 -4
  162. package/src/components/candlestick-chart/index.tsx +30 -2
  163. package/src/components/collapsible/index.tsx +278 -0
  164. package/src/components/flow/index.tsx +93 -14
  165. package/src/components/form/index.tsx +32 -5
  166. package/src/components/form/typed-form.ts +13 -0
  167. package/src/components/form/use-form.ts +9 -0
  168. package/src/components/heatmap-chart/index.tsx +49 -3
  169. package/src/components/line-chart/index.tsx +27 -2
  170. package/src/components/live-line-chart/index.tsx +130 -13
  171. package/src/components/live-line-chart/live-line-accessibility.ts +61 -0
  172. package/src/components/live-line-chart/live-line-lifecycle.ts +63 -0
  173. package/src/components/marquee/index.tsx +232 -0
  174. package/src/components/message-scroller/index.tsx +234 -6
  175. package/src/components/message-scroller/message-scroller-math.ts +38 -0
  176. package/src/components/meter/index.tsx +341 -0
  177. package/src/components/meter/meter-scale.ts +185 -0
  178. package/src/components/planner/index.tsx +997 -0
  179. package/src/components/planner/planner-announcement.ts +37 -0
  180. package/src/components/planner/planner-entries.ts +155 -0
  181. package/src/components/planner/planner-grid-navigation.ts +45 -0
  182. package/src/components/planner/planner-lifecycle.ts +76 -0
  183. package/src/components/popover/index.tsx +3 -3
  184. package/src/components/radar-chart/index.tsx +24 -0
  185. package/src/components/scatter-chart/index.tsx +27 -2
  186. package/src/components/skeleton/index.tsx +59 -5
  187. package/src/components/spinner/index.tsx +80 -13
  188. package/src/components/time-picker/index.tsx +57 -29
  189. package/src/components/time-picker/ruler-window.ts +24 -0
  190. package/src/hooks/breakpoint-contract.ts +31 -0
  191. package/src/hooks/index.ts +4 -0
  192. package/src/hooks/use-breakpoint.ts +61 -10
  193. package/src/index.ts +43 -0
  194. package/src/primitives/chart-accessibility.ts +102 -0
  195. package/src/primitives/focus-restoration-store.ts +70 -0
  196. package/src/primitives/portal.tsx +34 -2
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Marquee — content that travels across its container and never runs out.
3
+ *
4
+ * For a strip of logos, a ticker of prices, a row of testimonials: anything
5
+ * whose job is to keep moving past a boundary rather than to be scrolled.
6
+ *
7
+ * ```tsx
8
+ * <Marquee spacing={24} speed={40}>
9
+ * <View className="flex-row gap-6">
10
+ * {sponsors.map((s) => <Logo key={s.id} src={s.logo} />)}
11
+ * </View>
12
+ * </Marquee>
13
+ * ```
14
+ *
15
+ * ## How it loops
16
+ *
17
+ * The content is measured once, then laid out end to end enough times to cover
18
+ * the container twice over. One track holds every copy and it is that track,
19
+ * not the copies, that moves — a single animated node for any amount of
20
+ * content. It travels exactly one copy-and-gap and starts over, so the state it
21
+ * ends on is the state it began on and the seam never shows.
22
+ *
23
+ * The travel is a linear timing driven on the UI thread, so it costs nothing
24
+ * per frame in JavaScript and keeps running while the thread is busy.
25
+ *
26
+ * ## Measurement needs room
27
+ *
28
+ * The copy that gets measured sits in a hidden scroller, because that is what
29
+ * lets the content report the width it *wants* rather than the width the
30
+ * container would give it. Content with no intrinsic size of its own — a child
31
+ * stretched to `flex-1`, an image with no dimensions — measures as nothing and
32
+ * the marquee will not start.
33
+ *
34
+ * ## Reading direction and reduced motion
35
+ *
36
+ * A horizontal marquee travels toward the end of the line, so it reverses in a
37
+ * right-to-left subtree. `reverse` flips it again from wherever it landed.
38
+ *
39
+ * With the operating system set to reduce motion the content is rendered once
40
+ * and held still. A ticker that never stops is the exact thing that setting is
41
+ * there to turn off, so this is not a shorter animation — it is none.
42
+ *
43
+ * Screen readers get one copy. The rest are duplicates of content already
44
+ * announced, and hearing a sponsor list four times over is not thoroughness.
45
+ */
46
+ import { useEffect, useMemo, useState, type ReactNode } from 'react';
47
+ import {
48
+ ScrollView,
49
+ StyleSheet,
50
+ View,
51
+ type LayoutChangeEvent,
52
+ type ViewProps,
53
+ } from 'react-native';
54
+ import Animated, {
55
+ cancelAnimation,
56
+ Easing,
57
+ useAnimatedStyle,
58
+ useReducedMotion,
59
+ useSharedValue,
60
+ withRepeat,
61
+ withTiming,
62
+ } from 'react-native-reanimated';
63
+ import { useDirectionSign } from '../../hooks/use-direction';
64
+ import { cn } from '../../utils/cn';
65
+
66
+ /**
67
+ * Points per second. Slow enough that a word stays readable as it crosses,
68
+ * which is the speed a ticker is actually for.
69
+ */
70
+ const DEFAULT_SPEED = 40;
71
+
72
+ export type MarqueeDirection = 'horizontal' | 'vertical';
73
+
74
+ export interface MarqueeProps extends Omit<ViewProps, 'children'> {
75
+ className?: string;
76
+ /** The content to repeat. Measured once, then tiled along the axis. */
77
+ children: ReactNode;
78
+ /**
79
+ * Travel speed in points per second. 40 by default, which is slow enough
80
+ * that a word stays readable as it crosses. The cycle time follows from this
81
+ * and the measured content, so longer content takes proportionally longer
82
+ * rather than moving faster.
83
+ */
84
+ speed?: number;
85
+ /** Gap between the end of one copy and the start of the next. */
86
+ spacing?: number;
87
+ /** Axis the content travels along. */
88
+ direction?: MarqueeDirection;
89
+ /**
90
+ * Send it the other way: toward the start of the line, or upward. Applied
91
+ * after the reading direction, not instead of it.
92
+ */
93
+ reverse?: boolean;
94
+ /** Set false to hold the content where it is. */
95
+ playing?: boolean;
96
+ }
97
+
98
+ export function Marquee({
99
+ className,
100
+ children,
101
+ speed = DEFAULT_SPEED,
102
+ spacing = 0,
103
+ direction = 'horizontal',
104
+ reverse = false,
105
+ playing = true,
106
+ style,
107
+ onLayout,
108
+ ...props
109
+ }: MarqueeProps) {
110
+ const horizontal = direction === 'horizontal';
111
+ const reducedMotion = useReducedMotion();
112
+ // Only the horizontal axis has a reading direction to follow; up is up.
113
+ const sign = useDirectionSign();
114
+ const flip = horizontal ? sign : 1;
115
+
116
+ const [viewport, setViewport] = useState(0);
117
+ const [content, setContent] = useState(0);
118
+
119
+ // The distance from one copy to the same point on the next, and therefore
120
+ // both the layout step and the exact loop length. They are the same number
121
+ // on purpose: taking the gap from anywhere else is how a seam appears.
122
+ const period = content > 0 ? content + spacing : 0;
123
+
124
+ const copies = useMemo(() => {
125
+ if (period <= 0 || viewport <= 0) return [];
126
+ // Enough to span the container, plus one trailing into view and one
127
+ // already past it — the two the travel consumes before the loop restarts.
128
+ const count = Math.ceil(viewport / period) + 2;
129
+ return Array.from({ length: count }, (_, index) => index);
130
+ }, [period, viewport]);
131
+
132
+ const offset = useSharedValue(0);
133
+
134
+ useEffect(() => {
135
+ cancelAnimation(offset);
136
+ offset.value = 0;
137
+ if (reducedMotion || !playing || period <= 0 || speed <= 0) return undefined;
138
+ offset.value = withRepeat(
139
+ withTiming(period, {
140
+ duration: (period / speed) * 1000,
141
+ easing: Easing.linear,
142
+ }),
143
+ -1,
144
+ false
145
+ );
146
+ return () => cancelAnimation(offset);
147
+ }, [offset, period, playing, reducedMotion, speed]);
148
+
149
+ const trackStyle = useAnimatedStyle(() => {
150
+ const travel = (reverse ? offset.value : -offset.value) * flip;
151
+ return {
152
+ transform: [horizontal ? { translateX: travel } : { translateY: travel }],
153
+ };
154
+ }, [horizontal, reverse, flip]);
155
+
156
+ // The container's own measurement is what sizes the loop, so an `onLayout`
157
+ // passed in is called alongside it rather than replacing it.
158
+ const onContainerLayout = (event: LayoutChangeEvent) => {
159
+ const { width, height } = event.nativeEvent.layout;
160
+ setViewport(horizontal ? width : height);
161
+ onLayout?.(event);
162
+ };
163
+
164
+ const onContentLayout = (event: LayoutChangeEvent) => {
165
+ const { width, height } = event.nativeEvent.layout;
166
+ setContent(horizontal ? width : height);
167
+ };
168
+
169
+ // Nothing to loop, so nothing to clip or clone: render the content plainly
170
+ // and let it sit where it falls.
171
+ if (reducedMotion) {
172
+ return (
173
+ <View
174
+ className={cn('overflow-hidden', className)}
175
+ style={style}
176
+ onLayout={onLayout}
177
+ {...props}
178
+ >
179
+ {children}
180
+ </View>
181
+ );
182
+ }
183
+
184
+ return (
185
+ <View
186
+ className={cn('overflow-hidden', className)}
187
+ style={style}
188
+ onLayout={onContainerLayout}
189
+ {...props}
190
+ >
191
+ {/* Measured, never seen. A scroller on this axis is what frees the
192
+ content to report its own size instead of the container's. */}
193
+ <ScrollView
194
+ horizontal={horizontal}
195
+ scrollEnabled={false}
196
+ style={styles.measure}
197
+ pointerEvents="none"
198
+ accessibilityElementsHidden
199
+ importantForAccessibility="no-hide-descendants"
200
+ >
201
+ <View onLayout={onContentLayout}>{children}</View>
202
+ </ScrollView>
203
+
204
+ <Animated.View style={[StyleSheet.absoluteFill, trackStyle]} pointerEvents="box-none">
205
+ {copies.map((index) => {
206
+ // Index 1 is the copy that starts flush with the container's edge,
207
+ // and it is the one a screen reader is given.
208
+ const spoken = index === 1;
209
+ const at = (index - 1) * period;
210
+ return (
211
+ <View
212
+ key={index}
213
+ style={[styles.copy, horizontal ? { left: at } : { top: at }]}
214
+ pointerEvents="box-none"
215
+ accessibilityElementsHidden={!spoken}
216
+ importantForAccessibility={spoken ? 'auto' : 'no-hide-descendants'}
217
+ >
218
+ {children}
219
+ </View>
220
+ );
221
+ })}
222
+ </Animated.View>
223
+ </View>
224
+ );
225
+ }
226
+
227
+ const styles = StyleSheet.create({
228
+ /* Laid out so it measures, hidden so it does not draw, and behind everything
229
+ so it can never intercept anything. */
230
+ measure: { opacity: 0, zIndex: -1 },
231
+ copy: { position: 'absolute' },
232
+ });
@@ -39,9 +39,18 @@ import {
39
39
  useMemo,
40
40
  useRef,
41
41
  useState,
42
+ type ReactElement,
42
43
  type ReactNode,
43
44
  } from 'react';
44
- import { View, type LayoutChangeEvent, type ViewProps } from 'react-native';
45
+ import {
46
+ FlatList,
47
+ View,
48
+ type FlatListProps,
49
+ type LayoutChangeEvent,
50
+ type ListRenderItemInfo,
51
+ type ViewProps,
52
+ type ViewToken,
53
+ } from 'react-native';
45
54
  import Animated, {
46
55
  runOnJS,
47
56
  useAnimatedRef,
@@ -59,7 +68,10 @@ import { cn } from '../../utils/cn';
59
68
  import { textChildren } from '../../primitives/text';
60
69
  import {
61
70
  distanceFromMessageScrollerTarget,
71
+ initialMessageScrollerIndex,
62
72
  isMessageScrollerTargetVisible,
73
+ messageScrollerAnchorAt,
74
+ messageScrollerIndex,
63
75
  MESSAGE_SCROLLER_EDGE_THRESHOLD,
64
76
  } from './message-scroller-math';
65
77
 
@@ -72,8 +84,15 @@ const ANCHOR_PEEK = 24;
72
84
 
73
85
  export type MessageScrollerPosition = 'start' | 'end' | 'last-anchor';
74
86
 
87
+ interface MessageScrollerDriver {
88
+ scrollToEnd: (animated: boolean) => void;
89
+ scrollToStart: (animated: boolean) => void;
90
+ scrollToMessage: (id: string, animated: boolean) => boolean;
91
+ }
92
+
75
93
  interface MessageScrollerContextValue {
76
94
  scrollRef: ReturnType<typeof useAnimatedRef<Animated.ScrollView>>;
95
+ registerDriver: (driver: MessageScrollerDriver | null) => void;
77
96
  /** 1 while the reader is at the live edge, 0 otherwise. Drives follow state. */
78
97
  atEnd: ReturnType<typeof useSharedValue<number>>;
79
98
  /** Live distance from each edge, used by target-specific jump controls. */
@@ -167,6 +186,10 @@ function MessageScrollerRoot({
167
186
  const itemY = useRef(new Map<string, number>());
168
187
  const anchors = useRef<string[]>([]);
169
188
  const following = useRef(autoScroll);
189
+ const driver = useRef<MessageScrollerDriver | null>(null);
190
+ const registerDriver = useCallback((next: MessageScrollerDriver | null) => {
191
+ driver.current = next;
192
+ }, []);
170
193
 
171
194
  const registerItem = useCallback((id: string, y: number, anchor: boolean) => {
172
195
  itemY.current.set(id, y);
@@ -183,7 +206,8 @@ function MessageScrollerRoot({
183
206
  const scrollToEnd = useCallback(
184
207
  (animated = true) => {
185
208
  following.current = true;
186
- scrollRef.current?.scrollToEnd({ animated });
209
+ if (driver.current) driver.current.scrollToEnd(animated);
210
+ else scrollRef.current?.scrollToEnd({ animated });
187
211
  },
188
212
  [scrollRef]
189
213
  );
@@ -191,17 +215,23 @@ function MessageScrollerRoot({
191
215
  const scrollToStart = useCallback(
192
216
  (animated = true) => {
193
217
  following.current = false;
194
- scrollRef.current?.scrollTo({ y: 0, animated });
218
+ if (driver.current) driver.current.scrollToStart(animated);
219
+ else scrollRef.current?.scrollTo({ y: 0, animated });
195
220
  },
196
221
  [scrollRef]
197
222
  );
198
223
 
199
224
  const scrollToMessage = useCallback(
200
225
  (id: string, animated = true) => {
226
+ if (driver.current) {
227
+ if (driver.current.scrollToMessage(id, animated)) following.current = false;
228
+ return;
229
+ }
201
230
  const y = itemY.current.get(id);
202
- if (y === undefined) return;
203
- following.current = false;
204
- scrollRef.current?.scrollTo({ y: Math.max(0, y - ANCHOR_PEEK), animated });
231
+ if (y !== undefined) {
232
+ following.current = false;
233
+ scrollRef.current?.scrollTo({ y: Math.max(0, y - ANCHOR_PEEK), animated });
234
+ }
205
235
  },
206
236
  [scrollRef]
207
237
  );
@@ -209,6 +239,7 @@ function MessageScrollerRoot({
209
239
  const context = useMemo<MessageScrollerContextValue>(
210
240
  () => ({
211
241
  scrollRef,
242
+ registerDriver,
212
243
  atEnd,
213
244
  distanceFromStart,
214
245
  distanceFromEnd,
@@ -230,6 +261,7 @@ function MessageScrollerRoot({
230
261
  }),
231
262
  [
232
263
  scrollRef,
264
+ registerDriver,
233
265
  atEnd,
234
266
  distanceFromStart,
235
267
  distanceFromEnd,
@@ -439,6 +471,201 @@ function MessageScrollerViewport({
439
471
  }
440
472
  MessageScrollerViewport.displayName = 'MessageScroller.Viewport';
441
473
 
474
+ export interface MessageScrollerListItem {
475
+ /** Stable id used by `scrollToMessage` and as the default React key. */
476
+ messageId: string;
477
+ /** Marks the start of a turn for `last-anchor` and visibility reporting. */
478
+ scrollAnchor?: boolean;
479
+ }
480
+
481
+ export interface MessageScrollerListProps<T extends MessageScrollerListItem = MessageScrollerListItem>
482
+ extends Omit<
483
+ FlatListProps<T>,
484
+ | 'data'
485
+ | 'renderItem'
486
+ | 'keyExtractor'
487
+ | 'onScroll'
488
+ | 'onContentSizeChange'
489
+ | 'onViewableItemsChanged'
490
+ | 'maintainVisibleContentPosition'
491
+ | 'onScrollToIndexFailed'
492
+ | 'onScrollEndDrag'
493
+ | 'onMomentumScrollEnd'
494
+ | 'CellRendererComponent'
495
+ > {
496
+ className?: string;
497
+ /** Classes on the virtualized list's padded transcript column. */
498
+ contentContainerClassName?: string;
499
+ /**
500
+ * The complete transcript. Rows outside the native render window stay
501
+ * unmounted; each item therefore carries its stable navigation metadata.
502
+ */
503
+ data: readonly T[];
504
+ /** Draw one turn from the native list window. */
505
+ renderItem: (info: ListRenderItemInfo<T>) => ReactElement | null;
506
+ }
507
+
508
+ /**
509
+ * The real virtualized transcript path. Only its render window mounts; native
510
+ * visible-content maintenance keeps prepends still without measuring offscreen
511
+ * rows in JavaScript.
512
+ */
513
+ function MessageScrollerList<T extends MessageScrollerListItem>({
514
+ className,
515
+ contentContainerClassName,
516
+ data,
517
+ renderItem,
518
+ initialNumToRender = 12,
519
+ maxToRenderPerBatch = 8,
520
+ windowSize = 7,
521
+ ...props
522
+ }: MessageScrollerListProps<T>) {
523
+ const {
524
+ atEnd,
525
+ distanceFromStart,
526
+ distanceFromEnd,
527
+ setAtEndJS,
528
+ autoScroll,
529
+ preserveScrollOnPrepend,
530
+ defaultScrollPosition,
531
+ following,
532
+ registerDriver,
533
+ setCurrentAnchorId,
534
+ } = useScroller('MessageScroller.List');
535
+ const listRef = useRef<FlatList<T>>(null);
536
+ const items = useRef(data);
537
+ items.current = data;
538
+ const opened = useRef(false);
539
+ const firstVisibleIndex = useRef<number | null>(null);
540
+
541
+ const publishAtEnd = useCallback(
542
+ (next: boolean) => {
543
+ following.current = autoScroll && next;
544
+ setAtEndJS(next);
545
+ },
546
+ [autoScroll, following, setAtEndJS]
547
+ );
548
+
549
+ const scrollHandler = useAnimatedScrollHandler({
550
+ onScroll: (event) => {
551
+ const { contentOffset, contentSize, layoutMeasurement } = event;
552
+ distanceFromStart.value = distanceFromMessageScrollerTarget(
553
+ 'start',
554
+ contentOffset.y,
555
+ contentSize.height,
556
+ layoutMeasurement.height
557
+ );
558
+ distanceFromEnd.value = distanceFromMessageScrollerTarget(
559
+ 'end',
560
+ contentOffset.y,
561
+ contentSize.height,
562
+ layoutMeasurement.height
563
+ );
564
+ const next = isMessageScrollerTargetVisible(distanceFromEnd.value) ? 0 : 1;
565
+ if (next !== atEnd.value) {
566
+ atEnd.value = next;
567
+ runOnJS(publishAtEnd)(next === 1);
568
+ }
569
+ },
570
+ });
571
+
572
+ const scrollToIndex = useCallback((index: number, animated: boolean) => {
573
+ listRef.current?.scrollToIndex({ index, viewOffset: ANCHOR_PEEK, animated });
574
+ }, []);
575
+
576
+ const setAnchorAtIndex = useCallback(
577
+ (index: number) => setCurrentAnchorId(messageScrollerAnchorAt(items.current, index)),
578
+ [setCurrentAnchorId]
579
+ );
580
+
581
+ useEffect(() => {
582
+ registerDriver({
583
+ scrollToEnd: (animated) => {
584
+ listRef.current?.scrollToEnd({ animated });
585
+ setAnchorAtIndex(items.current.length - 1);
586
+ },
587
+ scrollToStart: (animated) => {
588
+ listRef.current?.scrollToOffset({ offset: 0, animated });
589
+ setAnchorAtIndex(0);
590
+ },
591
+ scrollToMessage: (id, animated) => {
592
+ const index = messageScrollerIndex(items.current, id);
593
+ if (index === undefined) return false;
594
+ scrollToIndex(index, animated);
595
+ setAnchorAtIndex(index);
596
+ return true;
597
+ },
598
+ });
599
+ return () => registerDriver(null);
600
+ }, [registerDriver, scrollToIndex, setAnchorAtIndex]);
601
+
602
+ useEffect(() => {
603
+ following.current = autoScroll && atEnd.value === 1;
604
+ }, [atEnd, autoScroll, following]);
605
+
606
+ const onContentSizeChange = () => {
607
+ if (!opened.current) {
608
+ opened.current = true;
609
+ const index = initialMessageScrollerIndex(data, defaultScrollPosition);
610
+ const target = index ?? data.length - 1;
611
+ if (index === undefined) listRef.current?.scrollToEnd({ animated: false });
612
+ else if (index > 0) scrollToIndex(index, false);
613
+ setAnchorAtIndex(target);
614
+ return;
615
+ }
616
+ if (following.current) listRef.current?.scrollToEnd({ animated: true });
617
+ };
618
+
619
+ const onViewableItemsChanged = useRef(
620
+ ({ viewableItems }: { viewableItems: ViewToken<T>[] }) => {
621
+ const first = viewableItems.reduce(
622
+ (best, token) => (token.index !== null && token.index < best ? token.index : best),
623
+ Infinity
624
+ );
625
+ firstVisibleIndex.current = Number.isFinite(first) ? first : null;
626
+ }
627
+ ).current;
628
+
629
+ const settleAnchor = () => {
630
+ if (firstVisibleIndex.current !== null) {
631
+ setCurrentAnchorId(messageScrollerAnchorAt(items.current, firstVisibleIndex.current));
632
+ }
633
+ };
634
+
635
+ return (
636
+ <Animated.FlatList
637
+ ref={listRef}
638
+ data={data}
639
+ renderItem={renderItem}
640
+ keyExtractor={(item) => item.messageId}
641
+ accessibilityRole="list"
642
+ accessibilityLabel="Messages"
643
+ accessibilityLiveRegion="polite"
644
+ scrollEventThrottle={16}
645
+ showsVerticalScrollIndicator={false}
646
+ initialNumToRender={initialNumToRender}
647
+ maxToRenderPerBatch={maxToRenderPerBatch}
648
+ windowSize={windowSize}
649
+ maintainVisibleContentPosition={
650
+ preserveScrollOnPrepend ? { minIndexForVisible: 0 } : undefined
651
+ }
652
+ className={cn('flex-1', className)}
653
+ contentContainerClassName={cn('gap-3 p-4 pb-16', contentContainerClassName)}
654
+ {...props}
655
+ onScroll={scrollHandler}
656
+ onContentSizeChange={onContentSizeChange}
657
+ onViewableItemsChanged={onViewableItemsChanged}
658
+ onScrollEndDrag={settleAnchor}
659
+ onMomentumScrollEnd={settleAnchor}
660
+ onScrollToIndexFailed={({ index, averageItemLength }) => {
661
+ listRef.current?.scrollToOffset({ offset: index * averageItemLength, animated: false });
662
+ setTimeout(() => scrollToIndex(index, false), 0);
663
+ }}
664
+ />
665
+ );
666
+ }
667
+ MessageScrollerList.displayName = 'MessageScroller.List';
668
+
442
669
  /**
443
670
  * How far the transcript shifted, measured on the message closest to the top
444
671
  * that was there before and is still there.
@@ -601,6 +828,7 @@ MessageScrollerButton.displayName = 'MessageScroller.Button';
601
828
 
602
829
  export const MessageScroller = Object.assign(MessageScrollerRoot, {
603
830
  Viewport: MessageScrollerViewport,
831
+ List: MessageScrollerList,
604
832
  Content: MessageScrollerContent,
605
833
  Item: MessageScrollerItem,
606
834
  Button: MessageScrollerButton,
@@ -22,3 +22,41 @@ export function isMessageScrollerTargetVisible(distance: number): boolean {
22
22
 
23
23
  return distance > MESSAGE_SCROLLER_EDGE_THRESHOLD;
24
24
  }
25
+
26
+ export interface MessageScrollerIndexedItem {
27
+ messageId: string;
28
+ scrollAnchor?: boolean;
29
+ }
30
+
31
+ /** Index used by initial positioning and id-addressed virtualized jumps. */
32
+ export function messageScrollerIndex(
33
+ items: readonly MessageScrollerIndexedItem[],
34
+ id: string
35
+ ): number | undefined {
36
+ const index = items.findIndex((item) => item.messageId === id);
37
+ return index < 0 ? undefined : index;
38
+ }
39
+
40
+ /** Initial target for a list; `undefined` means use the physical end. */
41
+ export function initialMessageScrollerIndex(
42
+ items: readonly MessageScrollerIndexedItem[],
43
+ position: 'start' | 'end' | 'last-anchor'
44
+ ): number | undefined {
45
+ if (position === 'end') return undefined;
46
+ if (position === 'start') return items.length ? 0 : undefined;
47
+ for (let index = items.length - 1; index >= 0; index -= 1) {
48
+ if (items[index]!.scrollAnchor) return index;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /** Anchor at or before the first visible virtualized row. */
54
+ export function messageScrollerAnchorAt(
55
+ items: readonly MessageScrollerIndexedItem[],
56
+ firstVisibleIndex: number
57
+ ): string | null {
58
+ for (let index = Math.min(firstVisibleIndex, items.length - 1); index >= 0; index -= 1) {
59
+ if (items[index]!.scrollAnchor) return items[index]!.messageId;
60
+ }
61
+ return null;
62
+ }