panelui-native 0.68.0 → 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 (93) hide show
  1. package/README.md +12 -2
  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/button/index.js +14 -7
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/collapsible/index.js +280 -0
  7. package/lib/module/components/collapsible/index.js.map +1 -0
  8. package/lib/module/components/flow/index.js +66 -3
  9. package/lib/module/components/flow/index.js.map +1 -1
  10. package/lib/module/components/live-line-chart/index.js +103 -9
  11. package/lib/module/components/live-line-chart/index.js.map +1 -1
  12. package/lib/module/components/live-line-chart/live-line-accessibility.js +47 -0
  13. package/lib/module/components/live-line-chart/live-line-accessibility.js.map +1 -0
  14. package/lib/module/components/live-line-chart/live-line-lifecycle.js +33 -0
  15. package/lib/module/components/live-line-chart/live-line-lifecycle.js.map +1 -0
  16. package/lib/module/components/marquee/index.js +194 -0
  17. package/lib/module/components/marquee/index.js.map +1 -0
  18. package/lib/module/components/meter/index.js +38 -20
  19. package/lib/module/components/meter/index.js.map +1 -1
  20. package/lib/module/components/meter/meter-scale.js +62 -8
  21. package/lib/module/components/meter/meter-scale.js.map +1 -1
  22. package/lib/module/components/planner/index.js +133 -59
  23. package/lib/module/components/planner/index.js.map +1 -1
  24. package/lib/module/components/planner/planner-announcement.js +30 -0
  25. package/lib/module/components/planner/planner-announcement.js.map +1 -0
  26. package/lib/module/components/planner/planner-entries.js +15 -7
  27. package/lib/module/components/planner/planner-entries.js.map +1 -1
  28. package/lib/module/components/planner/planner-grid-navigation.js +33 -0
  29. package/lib/module/components/planner/planner-grid-navigation.js.map +1 -0
  30. package/lib/module/components/planner/planner-lifecycle.js +45 -0
  31. package/lib/module/components/planner/planner-lifecycle.js.map +1 -0
  32. package/lib/module/components/popover/index.js +2 -2
  33. package/lib/module/components/popover/index.js.map +1 -1
  34. package/lib/module/index.js +2 -0
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/module/primitives/focus-restoration-store.js +52 -0
  37. package/lib/module/primitives/focus-restoration-store.js.map +1 -0
  38. package/lib/module/primitives/portal.js +36 -2
  39. package/lib/module/primitives/portal.js.map +1 -1
  40. package/lib/typescript/src/components/accordion/index.d.ts +4 -0
  41. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/collapsible/index.d.ts +76 -0
  44. package/lib/typescript/src/components/collapsible/index.d.ts.map +1 -0
  45. package/lib/typescript/src/components/flow/index.d.ts +7 -1
  46. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/live-line-chart/index.d.ts +21 -7
  48. package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts +22 -0
  50. package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts.map +1 -0
  51. package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts +23 -0
  52. package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts.map +1 -0
  53. package/lib/typescript/src/components/marquee/index.d.ts +73 -0
  54. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/meter/index.d.ts +7 -4
  56. package/lib/typescript/src/components/meter/index.d.ts.map +1 -1
  57. package/lib/typescript/src/components/meter/meter-scale.d.ts +39 -0
  58. package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -1
  59. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/planner/planner-announcement.d.ts +15 -0
  61. package/lib/typescript/src/components/planner/planner-announcement.d.ts.map +1 -0
  62. package/lib/typescript/src/components/planner/planner-entries.d.ts +1 -1
  63. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
  64. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +4 -0
  65. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -0
  66. package/lib/typescript/src/components/planner/planner-lifecycle.d.ts +20 -0
  67. package/lib/typescript/src/components/planner/planner-lifecycle.d.ts.map +1 -0
  68. package/lib/typescript/src/index.d.ts +2 -0
  69. package/lib/typescript/src/index.d.ts.map +1 -1
  70. package/lib/typescript/src/primitives/focus-restoration-store.d.ts +24 -0
  71. package/lib/typescript/src/primitives/focus-restoration-store.d.ts.map +1 -0
  72. package/lib/typescript/src/primitives/portal.d.ts +4 -0
  73. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  74. package/package.json +36 -3
  75. package/src/components/accordion/index.tsx +31 -8
  76. package/src/components/button/index.tsx +11 -4
  77. package/src/components/collapsible/index.tsx +278 -0
  78. package/src/components/flow/index.tsx +93 -14
  79. package/src/components/live-line-chart/index.tsx +130 -13
  80. package/src/components/live-line-chart/live-line-accessibility.ts +61 -0
  81. package/src/components/live-line-chart/live-line-lifecycle.ts +63 -0
  82. package/src/components/marquee/index.tsx +232 -0
  83. package/src/components/meter/index.tsx +49 -26
  84. package/src/components/meter/meter-scale.ts +84 -8
  85. package/src/components/planner/index.tsx +182 -71
  86. package/src/components/planner/planner-announcement.ts +37 -0
  87. package/src/components/planner/planner-entries.ts +16 -7
  88. package/src/components/planner/planner-grid-navigation.ts +45 -0
  89. package/src/components/planner/planner-lifecycle.ts +76 -0
  90. package/src/components/popover/index.tsx +3 -3
  91. package/src/index.ts +13 -0
  92. package/src/primitives/focus-restoration-store.ts +70 -0
  93. package/src/primitives/portal.tsx +34 -2
@@ -30,10 +30,17 @@
30
30
  * they mean opposite things.
31
31
  *
32
32
  * The cost is a frame callback for as long as the chart is mounted. It is
33
- * stopped by `paused`, by `status="loading"` and on unmount, and it is never
34
- * started when the platform asks for reduced motion — in that case the window
35
- * advances as each point arrives instead, which is the same picture sampled
36
- * less often.
33
+ * stopped by `paused`, by `status="loading"`, while the app is backgrounded
34
+ * and on unmount, and it is never started when the platform asks for reduced
35
+ * motion — in that case the window advances as each point arrives instead,
36
+ * which is the same picture sampled less often.
37
+ *
38
+ * Screen readers receive one image-role snapshot: its name, current or
39
+ * selected value, direction, time window and paused state. It changes when the
40
+ * React data changes, never on the UI-thread clock frame, and requests no live
41
+ * announcement. The visual axes, badges and tooltip repeat that snapshot and
42
+ * stay out of the accessibility tree; controls placed in Header remain normal
43
+ * controls.
37
44
  *
38
45
  * ## Colour follows the recent direction
39
46
  *
@@ -56,7 +63,13 @@ import {
56
63
  useState,
57
64
  type ReactNode,
58
65
  } from 'react';
59
- import { StyleSheet, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
66
+ import {
67
+ AppState,
68
+ StyleSheet,
69
+ View,
70
+ type LayoutChangeEvent,
71
+ type ViewProps,
72
+ } from 'react-native';
60
73
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
61
74
  import Animated, {
62
75
  runOnJS,
@@ -92,6 +105,13 @@ import {
92
105
  type Plot,
93
106
  } from '../../utils/chart';
94
107
  import { cn } from '../../utils/cn';
108
+ import { liveLineAccessibility } from './live-line-accessibility';
109
+ import {
110
+ liveLineClockRuns,
111
+ normalizeLiveLinePoints,
112
+ normalizeLiveLineWindow,
113
+ reconcileLiveLineActivePoint,
114
+ } from './live-line-lifecycle';
95
115
 
96
116
  const AnimatedPath = Animated.createAnimatedComponent(Path);
97
117
 
@@ -224,9 +244,16 @@ function runOf(
224
244
 
225
245
  export interface LiveLineChartProps extends ViewProps {
226
246
  className?: string;
227
- /** The readings so far, oldest first. Append to it as they arrive. */
247
+ /**
248
+ * Names the chart's single screen-reader snapshot. Falls back to the Header
249
+ * title, then to "Live line chart".
250
+ */
251
+ accessibilityLabel?: string;
252
+ /** Additional guidance after the snapshot. No gesture is invented for it. */
253
+ accessibilityHint?: string;
254
+ /** The readings so far. Invalid values are dropped and timestamps are ordered. */
228
255
  data: LiveLinePoint[];
229
- /** How much time the plot spans, in seconds. */
256
+ /** How much time the plot spans, in seconds. Invalid values use 30. */
230
257
  window?: number;
231
258
  /** Freeze the window where it is. The readings still arrive; the clock stops. */
232
259
  paused?: boolean;
@@ -239,7 +266,7 @@ export interface LiveLineChartProps extends ViewProps {
239
266
  /**
240
267
  * The most readings kept. Older ones are dropped, since they are off the
241
268
  * window and cannot come back — an unbounded feed otherwise grows an array
242
- * for as long as the screen is open.
269
+ * for as long as the screen is open. Must be positive and finite.
243
270
  */
244
271
  maxPoints?: number;
245
272
  /** Width ÷ height of the plot. */
@@ -276,6 +303,8 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
276
303
  momentumColors,
277
304
  color,
278
305
  onActivePointChange,
306
+ accessibilityLabel,
307
+ accessibilityHint,
279
308
  children,
280
309
  ...props
281
310
  },
@@ -290,12 +319,13 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
290
319
  const domainMax = useSharedValue(0);
291
320
  const activeTime = useSharedValue(-1);
292
321
  const reducedMotion = useReducedMotion();
322
+ const [appState, setAppState] = useState(AppState.currentState ?? 'active');
293
323
  // Stripped of punctuation: `useId` returns something like `:r1:`, and a
294
324
  // colon inside a `url(#…)` reference does not resolve — which in RN SVG is
295
325
  // a clip that silently does nothing rather than an error.
296
326
  const clipId = `panelui-live-clip-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
297
327
 
298
- const windowMs = Math.max(windowSeconds, 1) * 1000;
328
+ const windowMs = normalizeLiveLineWindow(windowSeconds) * 1000;
299
329
  const loading = status === 'loading';
300
330
 
301
331
  const hasYAxis = useMemo(() => {
@@ -308,6 +338,34 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
308
338
  return found;
309
339
  }, [children]);
310
340
 
341
+ const semanticParts = useMemo(() => {
342
+ let title: string | undefined;
343
+ let value: string | undefined;
344
+ let headerFormat: ((reading: number) => string) | undefined;
345
+ let tipFormat: ((reading: number) => string) | undefined;
346
+ let tooltipFormat: ((reading: number) => string) | undefined;
347
+ Children.forEach(children, (child) => {
348
+ if (!isValidElement(child)) return;
349
+ const part = (child.type as { displayName?: string }).displayName;
350
+ if (part === 'LiveLineChart.Header') {
351
+ const header = child.props as LiveLineChartHeaderProps;
352
+ title ??= header.title;
353
+ value ??= header.value;
354
+ headerFormat ??= header.formatValue;
355
+ } else if (part === 'LiveLineChart.Tip') {
356
+ tipFormat ??= (child.props as LiveLineChartTipProps).formatValue;
357
+ } else if (part === 'LiveLineChart.Tooltip') {
358
+ tooltipFormat ??= (child.props as LiveLineChartTooltipProps).formatValue;
359
+ }
360
+ });
361
+ return {
362
+ title,
363
+ value,
364
+ formatLatest: headerFormat ?? tipFormat,
365
+ formatActive: tooltipFormat ?? headerFormat ?? tipFormat,
366
+ };
367
+ }, [children]);
368
+
311
369
  const pad = { ...PADDING, left: hasYAxis ? Y_AXIS_WIDTH : PADDING.left };
312
370
  const plot: Plot = {
313
371
  left: pad.left,
@@ -322,7 +380,7 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
322
380
  * the one chart nobody bounded to be the one left running overnight.
323
381
  */
324
382
  const points = useMemo(
325
- () => (data.length > maxPoints ? data.slice(data.length - maxPoints) : data),
383
+ () => normalizeLiveLinePoints(data, maxPoints),
326
384
  [data, maxPoints]
327
385
  );
328
386
 
@@ -331,6 +389,11 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
331
389
  values.value = points.map((point) => point.value);
332
390
  }, [points, times, values]);
333
391
 
392
+ useEffect(() => {
393
+ const subscription = AppState.addEventListener('change', setAppState);
394
+ return () => subscription.remove();
395
+ }, []);
396
+
334
397
  const liveLatest = points.length ? points[points.length - 1]! : null;
335
398
 
336
399
  /*
@@ -393,7 +456,7 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
393
456
  now.value = Date.now();
394
457
  }, false);
395
458
 
396
- const running = !paused && !loading && !reducedMotion;
459
+ const running = liveLineClockRuns({ paused, loading, reducedMotion, appState });
397
460
 
398
461
  useEffect(() => {
399
462
  frame.setActive(running);
@@ -411,6 +474,13 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
411
474
  now.value = Date.now();
412
475
  }, [running, paused, points, now]);
413
476
 
477
+ // A frame callback is suspended with the app. Land on the wall clock as
478
+ // soon as it becomes active instead of showing the backgrounded window for
479
+ // one frame and relying on the platform to schedule a callback promptly.
480
+ useEffect(() => {
481
+ if (appState === 'active' && !paused) now.value = Date.now();
482
+ }, [appState, paused, now]);
483
+
414
484
  useImperativeHandle(
415
485
  ref,
416
486
  () => ({
@@ -434,6 +504,21 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
434
504
  if (!paused) heldMomentum.current = liveMomentum;
435
505
  const momentum = paused ? heldMomentum.current : liveMomentum;
436
506
 
507
+ const defaultFormat = (reading: number) => compactNumber(reading);
508
+ const semantic = liveLineAccessibility({
509
+ name: accessibilityLabel ?? semanticParts.title,
510
+ status,
511
+ latest,
512
+ activePoint,
513
+ momentum,
514
+ windowSeconds: windowMs / 1000,
515
+ paused,
516
+ now: Date.now(),
517
+ valueOverride: semanticParts.value,
518
+ formatLatest: semanticParts.formatLatest ?? defaultFormat,
519
+ formatActive: semanticParts.formatActive ?? defaultFormat,
520
+ });
521
+
437
522
  const base = useSeriesColor(color, 1);
438
523
  const successToken = useCSSVariable('--color-success');
439
524
  const destructiveToken = useCSSVariable('--color-destructive');
@@ -458,6 +543,13 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
458
543
  [onActivePointChange]
459
544
  );
460
545
 
546
+ useEffect(() => {
547
+ const next = reconcileLiveLineActivePoint(activePoint, points);
548
+ if (next === activePoint) return;
549
+ if (!next) activeTime.value = -1;
550
+ setActivePoint(next);
551
+ }, [activePoint, points, activeTime, setActivePoint]);
552
+
461
553
  const onLayout = (event: LayoutChangeEvent) => {
462
554
  const { width, height } = event.nativeEvent.layout;
463
555
  setSize((current) =>
@@ -519,8 +611,21 @@ const LiveLineChartRoot = forwardRef<LiveLineChartHandle, LiveLineChartProps>(
519
611
  return (
520
612
  <LiveLineChartContext.Provider value={context}>
521
613
  <View {...props} style={props.style} className={cn('w-full', className)}>
614
+ <View
615
+ accessible
616
+ accessibilityRole="image"
617
+ accessibilityLabel={semantic.label}
618
+ accessibilityHint={accessibilityHint}
619
+ style={{ position: 'absolute', left: -10_000, width: 1, height: 1 }}
620
+ />
522
621
  {header}
523
- <View onLayout={onLayout} style={{ aspectRatio }} className="w-full">
622
+ <View
623
+ onLayout={onLayout}
624
+ style={{ aspectRatio }}
625
+ className="w-full"
626
+ accessibilityElementsHidden
627
+ importantForAccessibility="no-hide-descendants"
628
+ >
524
629
  {plot.width > 0 ? (
525
630
  <>
526
631
  <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
@@ -1026,6 +1131,14 @@ function LiveLineChartTooltip({ formatValue, className }: LiveLineChartTooltipPr
1026
1131
  [setActivePoint]
1027
1132
  );
1028
1133
 
1134
+ useEffect(
1135
+ () => () => {
1136
+ activeTime.value = -1;
1137
+ setActivePoint(null);
1138
+ },
1139
+ [activeTime, setActivePoint]
1140
+ );
1141
+
1029
1142
  const pan = useMemo(() => {
1030
1143
  const resolve = (x: number) => {
1031
1144
  'worklet';
@@ -1259,7 +1372,11 @@ function LiveLineChartHeader({
1259
1372
  {...props}
1260
1373
  className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
1261
1374
  >
1262
- <View className="flex-1 gap-0.5">
1375
+ <View
1376
+ className="flex-1 gap-0.5"
1377
+ accessibilityElementsHidden
1378
+ importantForAccessibility="no-hide-descendants"
1379
+ >
1263
1380
  {title ? (
1264
1381
  <Text size="xs" muted>
1265
1382
  {title}
@@ -0,0 +1,61 @@
1
+ export interface LiveLineAccessiblePoint {
2
+ time: number;
3
+ value: number;
4
+ }
5
+
6
+ export interface LiveLineAccessibilityInput {
7
+ name?: string;
8
+ status: 'loading' | 'ready';
9
+ latest: LiveLineAccessiblePoint | null;
10
+ activePoint: LiveLineAccessiblePoint | null;
11
+ momentum: 'up' | 'down' | 'flat';
12
+ windowSeconds: number;
13
+ paused: boolean;
14
+ now: number;
15
+ valueOverride?: string;
16
+ formatLatest: (value: number) => string;
17
+ formatActive: (value: number) => string;
18
+ }
19
+
20
+ /** One bounded snapshot for a stream, rather than one node per arriving point. */
21
+ export function liveLineAccessibility({
22
+ name,
23
+ status,
24
+ latest,
25
+ activePoint,
26
+ momentum,
27
+ windowSeconds,
28
+ paused,
29
+ now,
30
+ valueOverride,
31
+ formatLatest,
32
+ formatActive,
33
+ }: LiveLineAccessibilityInput) {
34
+ const parts = [name?.trim() || 'Live line chart'];
35
+
36
+ if (status === 'loading') {
37
+ parts.push('Loading');
38
+ if (paused) parts.push('Paused');
39
+ return { label: parts.join('. ') };
40
+ }
41
+
42
+ if (!latest) {
43
+ parts.push('No readings');
44
+ if (paused) parts.push('Paused');
45
+ return { label: parts.join('. ') };
46
+ }
47
+
48
+ if (activePoint) {
49
+ const secondsAgo = Math.max(0, Math.round((now - activePoint.time) / 1000));
50
+ parts.push(`Selected value, ${formatActive(activePoint.value)}`);
51
+ parts.push(secondsAgo === 0 ? 'Just now' : `${secondsAgo} seconds ago`);
52
+ } else {
53
+ parts.push(`Current value, ${valueOverride ?? formatLatest(latest.value)}`);
54
+ const direction = momentum === 'up' ? 'rising' : momentum === 'down' ? 'falling' : 'steady';
55
+ parts.push(`Trend, ${direction}`);
56
+ }
57
+
58
+ parts.push(`${windowSeconds}-second window`);
59
+ if (paused) parts.push('Paused');
60
+ return { label: parts.join('. ') };
61
+ }
@@ -0,0 +1,63 @@
1
+ import type { AppStateStatus } from 'react-native';
2
+
3
+ export interface LiveLineReading {
4
+ time: number;
5
+ value: number;
6
+ }
7
+
8
+ export const DEFAULT_LIVE_LINE_WINDOW = 30;
9
+ export const DEFAULT_LIVE_LINE_MAX_POINTS = 500;
10
+
11
+ export function normalizeLiveLineWindow(seconds: number): number {
12
+ return Number.isFinite(seconds) && seconds > 0
13
+ ? Math.max(seconds, 1)
14
+ : DEFAULT_LIVE_LINE_WINDOW;
15
+ }
16
+
17
+ export function normalizeLiveLineMaxPoints(maxPoints: number): number {
18
+ return Number.isFinite(maxPoints) && maxPoints > 0
19
+ ? Math.max(1, Math.floor(maxPoints))
20
+ : DEFAULT_LIVE_LINE_MAX_POINTS;
21
+ }
22
+
23
+ /**
24
+ * Builds the one ordered, finite buffer every renderer and interaction reads.
25
+ * For duplicate timestamps the last input wins, matching a corrected reading
26
+ * arriving with the same identity as the one it replaces.
27
+ */
28
+ export function normalizeLiveLinePoints<T extends LiveLineReading>(
29
+ data: readonly T[],
30
+ maxPoints: number,
31
+ ): T[] {
32
+ const byTime = new Map<number, T>();
33
+ for (const point of data) {
34
+ if (Number.isFinite(point.time) && Number.isFinite(point.value)) {
35
+ byTime.set(point.time, point);
36
+ }
37
+ }
38
+ return [...byTime.values()]
39
+ .sort((left, right) => left.time - right.time)
40
+ .slice(-normalizeLiveLineMaxPoints(maxPoints));
41
+ }
42
+
43
+ export function liveLineClockRuns(options: {
44
+ paused: boolean;
45
+ loading: boolean;
46
+ reducedMotion: boolean;
47
+ appState: AppStateStatus;
48
+ }): boolean {
49
+ return (
50
+ !options.paused &&
51
+ !options.loading &&
52
+ !options.reducedMotion &&
53
+ options.appState === 'active'
54
+ );
55
+ }
56
+
57
+ export function reconcileLiveLineActivePoint<T extends LiveLineReading>(
58
+ active: T | null,
59
+ points: readonly T[],
60
+ ): T | null {
61
+ if (!active) return null;
62
+ return points.find((point) => point.time === active.time) ?? null;
63
+ }
@@ -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
+ });