panelui-native 0.75.1 → 0.76.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 (151) hide show
  1. package/lib/module/components/ai-input/ai-input-growth.js +62 -0
  2. package/lib/module/components/ai-input/ai-input-growth.js.map +1 -0
  3. package/lib/module/components/ai-input/index.js +1568 -0
  4. package/lib/module/components/ai-input/index.js.map +1 -0
  5. package/lib/module/components/alert/index.js +1 -1
  6. package/lib/module/components/alert/index.js.map +1 -1
  7. package/lib/module/components/avatar/avatar-source.js +15 -0
  8. package/lib/module/components/avatar/avatar-source.js.map +1 -0
  9. package/lib/module/components/avatar/index.js +32 -31
  10. package/lib/module/components/avatar/index.js.map +1 -1
  11. package/lib/module/components/bottom-sheet/index.js +3 -2
  12. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  13. package/lib/module/components/button-group/index.js +2 -3
  14. package/lib/module/components/button-group/index.js.map +1 -1
  15. package/lib/module/components/carousel/index.js +10 -7
  16. package/lib/module/components/carousel/index.js.map +1 -1
  17. package/lib/module/components/collapsible/index.js +1 -1
  18. package/lib/module/components/collapsible/index.js.map +1 -1
  19. package/lib/module/components/field/index.js +7 -8
  20. package/lib/module/components/field/index.js.map +1 -1
  21. package/lib/module/components/label/index.js +1 -1
  22. package/lib/module/components/label/index.js.map +1 -1
  23. package/lib/module/components/marquee/index.js +36 -11
  24. package/lib/module/components/marquee/index.js.map +1 -1
  25. package/lib/module/components/marquee/marquee-math.js +22 -6
  26. package/lib/module/components/marquee/marquee-math.js.map +1 -1
  27. package/lib/module/components/plan/index.js +2 -1
  28. package/lib/module/components/plan/index.js.map +1 -1
  29. package/lib/module/components/plot/index.js +27 -9
  30. package/lib/module/components/plot/index.js.map +1 -1
  31. package/lib/module/components/search-bar/index.js +11 -3
  32. package/lib/module/components/search-bar/index.js.map +1 -1
  33. package/lib/module/components/split-view/index.js +26 -27
  34. package/lib/module/components/split-view/index.js.map +1 -1
  35. package/lib/module/components/split-view/split-view-lifecycle.js +32 -0
  36. package/lib/module/components/split-view/split-view-lifecycle.js.map +1 -0
  37. package/lib/module/components/splitter/index.js +7 -13
  38. package/lib/module/components/splitter/index.js.map +1 -1
  39. package/lib/module/components/splitter/splitter-math.js +14 -0
  40. package/lib/module/components/splitter/splitter-math.js.map +1 -1
  41. package/lib/module/components/tabs/index.js +1 -1
  42. package/lib/module/components/tabs/index.js.map +1 -1
  43. package/lib/module/components/timeline/index.js +156 -42
  44. package/lib/module/components/timeline/index.js.map +1 -1
  45. package/lib/module/components/timeline/timeline-geometry.js +11 -0
  46. package/lib/module/components/timeline/timeline-geometry.js.map +1 -1
  47. package/lib/module/components/toast/index.js +1 -1
  48. package/lib/module/components/toast/index.js.map +1 -1
  49. package/lib/module/components/tooltip/index.js +2 -2
  50. package/lib/module/components/tooltip/index.js.map +1 -1
  51. package/lib/module/components/tree/index.js +2 -2
  52. package/lib/module/components/tree/index.js.map +1 -1
  53. package/lib/module/components/typography/index.js +2 -1
  54. package/lib/module/components/typography/index.js.map +1 -1
  55. package/lib/module/components/waterfall-chart/index.js +22 -3
  56. package/lib/module/components/waterfall-chart/index.js.map +1 -1
  57. package/lib/module/hooks/use-disclosure.js +10 -8
  58. package/lib/module/hooks/use-disclosure.js.map +1 -1
  59. package/lib/module/icons/index.js +135 -0
  60. package/lib/module/icons/index.js.map +1 -1
  61. package/lib/module/index.js +5 -1
  62. package/lib/module/index.js.map +1 -1
  63. package/lib/module/primitives/controllable-state.js +61 -0
  64. package/lib/module/primitives/controllable-state.js.map +1 -0
  65. package/lib/module/primitives/finite-chart.js +15 -0
  66. package/lib/module/primitives/finite-chart.js.map +1 -0
  67. package/lib/module/primitives/glass.js +136 -0
  68. package/lib/module/primitives/glass.js.map +1 -0
  69. package/lib/module/primitives/scrim.js +8 -1
  70. package/lib/module/primitives/scrim.js.map +1 -1
  71. package/lib/typescript/src/components/ai-input/ai-input-growth.d.ts +59 -0
  72. package/lib/typescript/src/components/ai-input/ai-input-growth.d.ts.map +1 -0
  73. package/lib/typescript/src/components/ai-input/index.d.ts +423 -0
  74. package/lib/typescript/src/components/ai-input/index.d.ts.map +1 -0
  75. package/lib/typescript/src/components/avatar/avatar-source.d.ts +4 -0
  76. package/lib/typescript/src/components/avatar/avatar-source.d.ts.map +1 -0
  77. package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
  78. package/lib/typescript/src/components/bottom-sheet/index.d.ts +10 -1
  79. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  80. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -1
  81. package/lib/typescript/src/components/marquee/index.d.ts +15 -6
  82. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/marquee/marquee-math.d.ts +6 -0
  84. package/lib/typescript/src/components/marquee/marquee-math.d.ts.map +1 -1
  85. package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/plot/index.d.ts +2 -1
  87. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  88. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
  90. package/lib/typescript/src/components/split-view/split-view-lifecycle.d.ts +14 -0
  91. package/lib/typescript/src/components/split-view/split-view-lifecycle.d.ts.map +1 -0
  92. package/lib/typescript/src/components/splitter/index.d.ts +2 -2
  93. package/lib/typescript/src/components/splitter/index.d.ts.map +1 -1
  94. package/lib/typescript/src/components/splitter/splitter-math.d.ts +2 -0
  95. package/lib/typescript/src/components/splitter/splitter-math.d.ts.map +1 -1
  96. package/lib/typescript/src/components/timeline/index.d.ts +24 -2
  97. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  98. package/lib/typescript/src/components/timeline/timeline-geometry.d.ts +2 -0
  99. package/lib/typescript/src/components/timeline/timeline-geometry.d.ts.map +1 -1
  100. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/waterfall-chart/index.d.ts +2 -1
  102. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
  103. package/lib/typescript/src/hooks/use-disclosure.d.ts.map +1 -1
  104. package/lib/typescript/src/icons/index.d.ts +18 -0
  105. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  106. package/lib/typescript/src/index.d.ts +3 -1
  107. package/lib/typescript/src/index.d.ts.map +1 -1
  108. package/lib/typescript/src/primitives/controllable-state.d.ts +33 -0
  109. package/lib/typescript/src/primitives/controllable-state.d.ts.map +1 -0
  110. package/lib/typescript/src/primitives/finite-chart.d.ts +5 -0
  111. package/lib/typescript/src/primitives/finite-chart.d.ts.map +1 -0
  112. package/lib/typescript/src/primitives/glass.d.ts +43 -0
  113. package/lib/typescript/src/primitives/glass.d.ts.map +1 -0
  114. package/lib/typescript/src/primitives/scrim.d.ts +7 -0
  115. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -1
  116. package/package.json +7 -2
  117. package/src/components/ai-input/ai-input-growth.ts +65 -0
  118. package/src/components/ai-input/index.tsx +1937 -0
  119. package/src/components/alert/index.tsx +1 -1
  120. package/src/components/avatar/avatar-source.ts +18 -0
  121. package/src/components/avatar/index.tsx +35 -27
  122. package/src/components/bottom-sheet/index.tsx +15 -3
  123. package/src/components/button-group/index.tsx +1 -1
  124. package/src/components/carousel/index.tsx +10 -11
  125. package/src/components/collapsible/index.tsx +1 -1
  126. package/src/components/field/index.tsx +4 -4
  127. package/src/components/label/index.tsx +1 -1
  128. package/src/components/marquee/index.tsx +56 -12
  129. package/src/components/marquee/marquee-math.ts +26 -3
  130. package/src/components/plan/index.tsx +6 -2
  131. package/src/components/plot/index.tsx +37 -11
  132. package/src/components/search-bar/index.tsx +9 -3
  133. package/src/components/split-view/index.tsx +24 -33
  134. package/src/components/split-view/split-view-lifecycle.ts +43 -0
  135. package/src/components/splitter/index.tsx +7 -11
  136. package/src/components/splitter/splitter-math.ts +28 -0
  137. package/src/components/tabs/index.tsx +1 -1
  138. package/src/components/timeline/index.tsx +189 -34
  139. package/src/components/timeline/timeline-geometry.ts +11 -0
  140. package/src/components/toast/index.tsx +6 -1
  141. package/src/components/tooltip/index.tsx +1 -1
  142. package/src/components/tree/index.tsx +2 -2
  143. package/src/components/typography/index.tsx +2 -2
  144. package/src/components/waterfall-chart/index.tsx +37 -4
  145. package/src/hooks/use-disclosure.ts +7 -12
  146. package/src/icons/index.tsx +106 -0
  147. package/src/index.ts +27 -0
  148. package/src/primitives/controllable-state.ts +112 -0
  149. package/src/primitives/finite-chart.ts +14 -0
  150. package/src/primitives/glass.tsx +171 -0
  151. package/src/primitives/scrim.tsx +7 -1
@@ -294,28 +294,31 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
294
294
 
295
295
  const handleSubmit = useCallback(
296
296
  (event: NativeSyntheticEvent<TextInputSubmitEditingEventData>) => {
297
+ if (disabled) return;
297
298
  // A return key is somebody saying they are done waiting, so the
298
299
  // pending pause is spent rather than waited out.
299
300
  flushSearchBarDebounce(debounceTimerRef, debouncedRef.current, text);
300
301
  onSubmit?.(text);
301
302
  onSubmitEditing?.(event);
302
303
  },
303
- [onSubmit, onSubmitEditing, text]
304
+ [disabled, onSubmit, onSubmitEditing, text]
304
305
  );
305
306
 
306
307
  const handleClear = useCallback(() => {
308
+ if (disabled) return;
307
309
  setText('');
308
310
  onClear?.();
309
311
  // The keyboard stays: clearing a query is the start of the next one.
310
312
  // Android takes focus away with the press, so it is asked back.
311
313
  inputRef.current?.focus();
312
- }, [onClear, setText]);
314
+ }, [disabled, onClear, setText]);
313
315
 
314
316
  const handleCancel = useCallback(() => {
317
+ if (disabled) return;
315
318
  setText('');
316
319
  inputRef.current?.blur();
317
320
  onCancel?.();
318
- }, [onCancel, setText]);
321
+ }, [disabled, onCancel, setText]);
319
322
 
320
323
  /*
321
324
  * Cancel's width is measured once and driven from a shared value, so the
@@ -426,8 +429,11 @@ export const SearchBar = forwardRef<TextInput, SearchBarProps>(
426
429
  <AnimatedPressable
427
430
  onLayout={handleCancelLayout}
428
431
  onPress={handleCancel}
432
+ disabled={disabled}
433
+ focusable={!disabled}
429
434
  hitSlop={8}
430
435
  accessibilityRole="button"
436
+ accessibilityState={{ disabled: !!disabled }}
431
437
  className={slots.cancelButton()}
432
438
  >
433
439
  <Text className={slots.cancelLabel()}>{cancelLabel}</Text>
@@ -56,10 +56,10 @@ import {
56
56
  DEFAULT_MIN_HEIGHT,
57
57
  clamp,
58
58
  nearestSnapIndex,
59
- normalizeSnapIndex,
60
59
  resolveLength,
61
60
  resolveSnapPoints,
62
61
  } from './split-view-math';
62
+ import { useSplitViewIndexLifecycle } from './split-view-lifecycle';
63
63
 
64
64
  /** Settles the pane onto a snap point without overshooting past its limits. */
65
65
  const SPRING = {
@@ -189,14 +189,16 @@ function SplitViewRoot({
189
189
  [pointsKey]
190
190
  );
191
191
 
192
- const controlled = snapIndexProp !== undefined;
193
- const [internalIndex, setInternalIndex] = useState(() =>
194
- normalizeSnapIndex(defaultSnapIndex, 1)
195
- );
196
- const snapIndex = normalizeSnapIndex(
197
- controlled ? snapIndexProp : internalIndex,
198
- points.length || 1
199
- );
192
+ const {
193
+ index: snapIndex,
194
+ requestIndex,
195
+ requestToken,
196
+ } = useSplitViewIndexLifecycle({
197
+ snapIndex: snapIndexProp,
198
+ defaultSnapIndex,
199
+ count: points.length,
200
+ onSnapIndexChange,
201
+ });
200
202
 
201
203
  const onSnapRef = useRef(onSnap);
202
204
  onSnapRef.current = onSnap;
@@ -207,30 +209,15 @@ function SplitViewRoot({
207
209
  }, []);
208
210
 
209
211
  /*
210
- * Moving the seam is one function whether a drag, a press or a screen reader
211
- * asked for it — the alternative is three places that each have to remember
212
- * to spring, to report, and to leave a controlled caller's index alone.
212
+ * Every modality makes the same request. The synchronization effect below
213
+ * moves only to the index the owner accepts, then reports that settlement.
213
214
  */
214
215
  const moveTo = useCallback(
215
- (index: number, options?: { animated?: boolean }) => {
216
- const target = points[index];
217
- if (target === undefined) return;
218
-
219
- if (!controlled) setInternalIndex(index);
220
- onSnapIndexChange?.(index);
221
-
222
- const finish = () => report(index, target);
223
- if (options?.animated === false || !animate) {
224
- topHeight.value = target;
225
- finish();
226
- return;
227
- }
228
- topHeight.value = withSpring(target, SPRING, (finished) => {
229
- 'worklet';
230
- if (finished) runOnJS(finish)();
231
- });
216
+ (index: number) => {
217
+ if (points[index] === undefined) return;
218
+ requestIndex(index);
232
219
  },
233
- [animate, controlled, onSnapIndexChange, points, report, topHeight]
220
+ [points, requestIndex]
234
221
  );
235
222
 
236
223
  const snapTo = useCallback((index: number) => moveTo(index), [moveTo]);
@@ -260,11 +247,15 @@ function SplitViewRoot({
260
247
  report(snapIndex, target);
261
248
  return;
262
249
  }
263
- topHeight.value = withSpring(target, SPRING);
250
+ const finish = () => report(snapIndex, target);
251
+ topHeight.value = withSpring(target, SPRING, (finished) => {
252
+ 'worklet';
253
+ if (finished) runOnJS(finish)();
254
+ });
264
255
  // Only when the resolved layout moves — a re-render that changes nothing
265
256
  // must not restart a spring the reader is watching.
266
257
  // eslint-disable-next-line react-hooks/exhaustive-deps
267
- }, [points, snapIndex, animate, animateOnMount]);
258
+ }, [points, snapIndex, animate, animateOnMount, requestToken]);
268
259
 
269
260
  const handleLayout = useCallback(
270
261
  (event: LayoutChangeEvent) => {
@@ -471,6 +462,7 @@ function SplitViewDragArea({
471
462
  return (
472
463
  <GestureDetector gesture={pan}>
473
464
  <View
465
+ {...props}
474
466
  accessible
475
467
  accessibilityRole="adjustable"
476
468
  accessibilityLabel={accessibilityLabel}
@@ -493,7 +485,6 @@ function SplitViewDragArea({
493
485
  }
494
486
  className={dragArea({ className })}
495
487
  onLayout={handleLayout}
496
- {...props}
497
488
  >
498
489
  {children}
499
490
  </View>
@@ -0,0 +1,43 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ function normalizeIndex(index: number, count: number): number {
4
+ if (!Number.isFinite(index) || count <= 0) return 0;
5
+ return Math.max(0, Math.min(Math.round(index), count - 1));
6
+ }
7
+
8
+ interface SplitViewIndexLifecycleOptions {
9
+ snapIndex: number | undefined;
10
+ defaultSnapIndex: number;
11
+ count: number;
12
+ onSnapIndexChange?: (index: number) => void;
13
+ }
14
+
15
+ /** Keeps requested indices intact until the measured snap-point list exists. */
16
+ export function useSplitViewIndexLifecycle({
17
+ snapIndex,
18
+ defaultSnapIndex,
19
+ count,
20
+ onSnapIndexChange,
21
+ }: SplitViewIndexLifecycleOptions) {
22
+ const controlled = snapIndex !== undefined;
23
+ const [internalIndex, setInternalIndex] = useState(defaultSnapIndex);
24
+ // Every request must settle, including one that resolves to the current index
25
+ // after a drag and a controlled request its owner rejects.
26
+ const [requestToken, setRequestToken] = useState(0);
27
+
28
+ const index = normalizeIndex(
29
+ controlled ? snapIndex : internalIndex,
30
+ count || 1
31
+ );
32
+
33
+ const requestIndex = useCallback(
34
+ (next: number) => {
35
+ if (!controlled) setInternalIndex(next);
36
+ setRequestToken((token) => token + 1);
37
+ onSnapIndexChange?.(next);
38
+ },
39
+ [controlled, onSnapIndexChange]
40
+ );
41
+
42
+ return { index, requestIndex, requestToken };
43
+ }
@@ -58,6 +58,7 @@ import {
58
58
  isCollapsed,
59
59
  layoutOffset,
60
60
  normalizeConstraint,
61
+ resetLayout,
61
62
  resizeLayout,
62
63
  resolveLayout,
63
64
  type SplitterConstraint,
@@ -388,6 +389,7 @@ function SplitterHandle({
388
389
  disabled,
389
390
  withGrip = true,
390
391
  accessibilityLabel = 'Resize panels',
392
+ style,
391
393
  ...props
392
394
  }: SplitterHandleProps) {
393
395
  const context = useSplitter('Splitter.Handle');
@@ -468,17 +470,10 @@ function SplitterHandle({
468
470
  * they already have between them.
469
471
  */
470
472
  const reset = useCallback(() => {
471
- const pair = boundary >= 0 ? boundary : 0;
472
- const next = layout.value.slice();
473
- const room = (next[pair] ?? 0) + (next[pair + 1] ?? 0);
474
- const first = initial[pair] ?? 0;
475
- const span = first + (initial[pair + 1] ?? 0);
476
- if (span <= 0) return;
477
- next[pair] = (first / span) * room;
478
- next[pair + 1] = room - next[pair]!;
473
+ const next = resetLayout(layout.value, initial, boundary, constraints);
479
474
  layout.value = next;
480
475
  commit(next);
481
- }, [boundary, commit, initial, layout]);
476
+ }, [boundary, commit, constraints, initial, layout]);
482
477
 
483
478
  const doubleTap = useMemo(
484
479
  () =>
@@ -548,6 +543,7 @@ function SplitterHandle({
548
543
  return (
549
544
  <GestureDetector gesture={gesture}>
550
545
  <Animated.View
546
+ {...props}
551
547
  accessible
552
548
  accessibilityRole="adjustable"
553
549
  accessibilityLabel={accessibilityLabel}
@@ -556,8 +552,7 @@ function SplitterHandle({
556
552
  accessibilityActions={frozen ? undefined : [{ name: 'increment' }, { name: 'decrement' }]}
557
553
  onAccessibilityAction={frozen ? undefined : onAccessibilityAction}
558
554
  className={handle({ className })}
559
- style={animatedStyle}
560
- {...props}
555
+ style={[style, animatedStyle]}
561
556
  >
562
557
  <View className={line()} />
563
558
  {withGrip ? <View className={grip()} /> : null}
@@ -582,6 +577,7 @@ export {
582
577
  layoutOffset,
583
578
  normalizeConstraint,
584
579
  panelFloor,
580
+ resetLayout,
585
581
  resizeLayout,
586
582
  resolveLayout,
587
583
  type SplitterConstraint,
@@ -227,6 +227,34 @@ export function resizeLayout(
227
227
  return next;
228
228
  }
229
229
 
230
+ /** Restores one pair's initial ratio without escaping its current constraints. */
231
+ export function resetLayout(
232
+ layout: number[],
233
+ initial: number[],
234
+ boundary: number,
235
+ constraints: SplitterConstraint[]
236
+ ): number[] {
237
+ const next = layout.slice();
238
+ if (boundary < 0 || boundary + 1 >= next.length) return next;
239
+
240
+ const pair = next[boundary]! + next[boundary + 1]!;
241
+ const first = initial[boundary];
242
+ const second = initial[boundary + 1];
243
+ if (
244
+ !Number.isFinite(pair) ||
245
+ typeof first !== 'number' ||
246
+ !Number.isFinite(first) ||
247
+ typeof second !== 'number' ||
248
+ !Number.isFinite(second) ||
249
+ first + second <= 0
250
+ ) {
251
+ return next;
252
+ }
253
+
254
+ const target = (first / (first + second)) * pair;
255
+ return resizeLayout(next, boundary, target - next[boundary]!, constraints);
256
+ }
257
+
230
258
  /** Where a seam sits, in percent, measured from the start of the splitter. */
231
259
  export function layoutOffset(layout: number[], boundary: number): number {
232
260
  'worklet';
@@ -778,7 +778,7 @@ function TabsList({ className, scrollable = false, children, ...props }: TabsLis
778
778
  }, [scrollable, activeLayout?.x, activeLayout]);
779
779
 
780
780
  const row = (
781
- <View accessibilityRole="tablist" className={cn(list(), className)} {...props}>
781
+ <View {...props} accessibilityRole="tablist" className={cn(list(), className)}>
782
782
  {/* Nothing to slide: in `expanding` every tab draws its own pill, and the
783
783
  open one is the shape that moves. */}
784
784
  {variant === 'expanding' ? null : <TabsIndicator />}
@@ -45,14 +45,23 @@
45
45
  */
46
46
  import {
47
47
  Children,
48
+ cloneElement,
48
49
  createContext,
49
50
  forwardRef,
50
51
  isValidElement,
51
52
  useContext,
52
53
  useMemo,
54
+ type ReactElement,
53
55
  type ReactNode,
54
56
  } from 'react';
55
- import { ScrollView, View, type Text as RNText, type ViewProps } from 'react-native';
57
+ import {
58
+ ScrollView,
59
+ View,
60
+ type FlatListProps,
61
+ type ListRenderItemInfo,
62
+ type Text as RNText,
63
+ type ViewProps,
64
+ } from 'react-native';
56
65
  import Animated, {
57
66
  interpolate,
58
67
  useAnimatedScrollHandler,
@@ -65,7 +74,7 @@ import { tv } from 'tailwind-variants';
65
74
  import { useCSSVariable } from 'uniwind';
66
75
  import { IconColorProvider } from '../../icons';
67
76
  import { Text, type TextProps, textChildren } from '../../primitives/text';
68
- import { timelineColumnWidth } from './timeline-geometry';
77
+ import { TIMELINE_WIDE_COLUMN, timelineColumnOffsets, timelineColumnWidth } from './timeline-geometry';
69
78
 
70
79
  export type TimelineVariant = 'dot' | 'icon' | 'numbered' | 'card' | 'compact';
71
80
  /** Semantic colour for a single event, independent of progress. */
@@ -74,6 +83,7 @@ export type TimelineTone = 'default' | 'info' | 'success' | 'warning' | 'danger'
74
83
  export type TimelineOrientation = 'vertical' | 'horizontal';
75
84
 
76
85
  const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
86
+ const TimelineColumnOffsetContext = createContext<number | null>(null);
77
87
 
78
88
  /*
79
89
  * Horizontal geometry.
@@ -252,6 +262,8 @@ interface TimelineContextValue {
252
262
  offsets: number[];
253
263
  /** Horizontal only: false when the reader has asked for less movement. */
254
264
  animate: boolean;
265
+ /** A virtualized list draws one rail segment per mounted cell. */
266
+ virtualized: boolean;
255
267
  }
256
268
 
257
269
  interface TimelineItemContextValue {
@@ -330,17 +342,28 @@ const TimelineRoot = forwardRef<View, TimelineProps>(
330
342
  * themselves by. These are the snap points, and they are what an item reads
331
343
  * to know how far it is from the reading edge.
332
344
  */
333
- const offsets = useMemo(() => {
334
- if (!horizontal) return [];
335
- const result: number[] = [];
336
- let sum = 0;
345
+ const columns = useMemo(() => {
346
+ if (!horizontal) return { offsets: [], children };
347
+ const items: ReactElement<TimelineItemProps>[] = [];
337
348
  Children.forEach(children, (child) => {
338
- if (!isValidElement(child)) return;
339
- result.push(sum);
340
- sum += itemWidth(child.props as TimelineItemProps);
349
+ if (isValidElement(child) && child.type === TimelineItem) {
350
+ items.push(child as ReactElement<TimelineItemProps>);
351
+ }
341
352
  });
342
- return result;
353
+ const offsets = timelineColumnOffsets(items.map((item) => itemWidth(item.props)));
354
+ let position = 0;
355
+ const rendered = Children.map(children, (child) => {
356
+ if (!isValidElement(child) || child.type !== TimelineItem) return child;
357
+ const offset = offsets[position++] ?? 0;
358
+ return (
359
+ <TimelineColumnOffsetContext.Provider value={offset}>
360
+ {child}
361
+ </TimelineColumnOffsetContext.Provider>
362
+ );
363
+ });
364
+ return { offsets, children: rendered };
343
365
  }, [children, horizontal]);
366
+ const { offsets } = columns;
344
367
 
345
368
  const context = useMemo(
346
369
  () => ({
@@ -350,6 +373,7 @@ const TimelineRoot = forwardRef<View, TimelineProps>(
350
373
  scrollX,
351
374
  offsets,
352
375
  animate: !reducedMotion,
376
+ virtualized: false,
353
377
  }),
354
378
  [value, variant, orientation, scrollX, offsets, reducedMotion]
355
379
  );
@@ -402,7 +426,7 @@ const TimelineRoot = forwardRef<View, TimelineProps>(
402
426
  borderColor: typeof border === 'string' ? border : undefined,
403
427
  }}
404
428
  />
405
- {textChildren(children)}
429
+ {textChildren(columns.children)}
406
430
  </View>
407
431
  </AnimatedScrollView>
408
432
  </View>
@@ -412,6 +436,128 @@ const TimelineRoot = forwardRef<View, TimelineProps>(
412
436
  );
413
437
  TimelineRoot.displayName = 'Timeline';
414
438
 
439
+ export interface TimelineListProps<T>
440
+ extends Omit<
441
+ FlatListProps<T>,
442
+ | 'data'
443
+ | 'renderItem'
444
+ | 'horizontal'
445
+ | 'getItemLayout'
446
+ | 'onScroll'
447
+ | 'snapToOffsets'
448
+ | 'CellRendererComponent'
449
+ > {
450
+ /** Complete event collection; rows outside the native window stay unmounted. */
451
+ data: readonly T[];
452
+ /** Render one `Timeline.Item`. Its step, width, and last marker are owned by the list. */
453
+ renderItem: (info: ListRenderItemInfo<T>) => ReactElement<TimelineItemProps>;
454
+ /** Width of each column, or a resolver for mixed-width histories. */
455
+ itemWidth?: number | ((item: T, index: number) => number);
456
+ /** Steps at or below this index render as completed. */
457
+ value?: number;
458
+ variant?: TimelineVariant;
459
+ snap?: boolean;
460
+ }
461
+
462
+ /**
463
+ * Opt-in bounded mount path for long horizontal histories. The compound
464
+ * `Timeline` API remains the simpler choice for short or mixed-content lists.
465
+ */
466
+ function TimelineList<T>({
467
+ data,
468
+ renderItem,
469
+ itemWidth: requestedWidth = TIMELINE_WIDE_COLUMN,
470
+ value = 0,
471
+ variant = 'dot',
472
+ snap = true,
473
+ initialNumToRender = 6,
474
+ maxToRenderPerBatch = 6,
475
+ windowSize = 5,
476
+ className,
477
+ ...props
478
+ }: TimelineListProps<T>) {
479
+ const reducedMotion = useReducedMotion();
480
+ const scrollX = useSharedValue(0);
481
+ const widths = useMemo(
482
+ () =>
483
+ data.map((item, index) =>
484
+ timelineColumnWidth(
485
+ typeof requestedWidth === 'function' ? requestedWidth(item, index) : requestedWidth,
486
+ true
487
+ )
488
+ ),
489
+ [data, requestedWidth]
490
+ );
491
+ const offsets = useMemo(() => {
492
+ let offset = 0;
493
+ return widths.map((width) => {
494
+ const current = offset;
495
+ offset += width;
496
+ return current;
497
+ });
498
+ }, [widths]);
499
+ const context = useMemo(
500
+ () => ({
501
+ activeStep: value,
502
+ variant,
503
+ orientation: 'horizontal' as const,
504
+ scrollX,
505
+ offsets,
506
+ animate: !reducedMotion,
507
+ virtualized: true,
508
+ }),
509
+ [value, variant, scrollX, offsets, reducedMotion]
510
+ );
511
+ const { root } = timelineVariants({ variant, orientation: 'horizontal' });
512
+ const onScroll = useAnimatedScrollHandler((event) => {
513
+ scrollX.value = event.contentOffset.x;
514
+ });
515
+
516
+ return (
517
+ <TimelineContext.Provider value={context}>
518
+ <Animated.FlatList
519
+ data={data}
520
+ horizontal
521
+ accessibilityRole="list"
522
+ showsHorizontalScrollIndicator={false}
523
+ decelerationRate="fast"
524
+ snapToAlignment="start"
525
+ initialNumToRender={initialNumToRender}
526
+ maxToRenderPerBatch={maxToRenderPerBatch}
527
+ windowSize={windowSize}
528
+ removeClippedSubviews
529
+ className={root({ className })}
530
+ {...props}
531
+ getItemLayout={(_, index) => ({
532
+ index,
533
+ length: widths[index] ?? TIMELINE_WIDE_COLUMN,
534
+ offset: offsets[index] ?? 0,
535
+ })}
536
+ snapToOffsets={snap ? offsets : undefined}
537
+ onScroll={onScroll}
538
+ scrollEventThrottle={16}
539
+ renderItem={(info) => (
540
+ /*
541
+ * The list owns the order, so it hands each column its own left edge
542
+ * the same way the compound root does. An item reads its offset from
543
+ * here rather than indexing by `step`, and a virtualized item that
544
+ * was given none would sit at zero and never recede.
545
+ */
546
+ <TimelineColumnOffsetContext.Provider value={offsets[info.index] ?? 0}>
547
+ {cloneElement(renderItem(info), {
548
+ step: info.index,
549
+ width: widths[info.index],
550
+ last: info.index === data.length - 1,
551
+ role: 'listitem',
552
+ })}
553
+ </TimelineColumnOffsetContext.Provider>
554
+ )}
555
+ />
556
+ </TimelineContext.Provider>
557
+ );
558
+ }
559
+ TimelineList.displayName = 'Timeline.List';
560
+
415
561
  export interface TimelineItemProps extends ViewProps {
416
562
  className?: string;
417
563
  /** Position in the sequence, zero-based. */
@@ -450,8 +596,16 @@ const TimelineItem = forwardRef<View, TimelineItemProps>(
450
596
  },
451
597
  ref
452
598
  ) => {
453
- const { activeStep, variant, orientation, scrollX, offsets, animate } =
599
+ const { activeStep, variant, orientation, scrollX, animate, virtualized } =
454
600
  useTimeline('Timeline.Item');
601
+ /*
602
+ * Where this column starts, from its position among the rendered items
603
+ * rather than from `step`. `step` is the semantic progress value: it may
604
+ * be sparse, repeated or reordered, and indexing the offsets by it lands a
605
+ * column on another column's snap point.
606
+ */
607
+ const columnOffset = useContext(TimelineColumnOffsetContext);
608
+ const border = useCSSVariable('--color-border');
455
609
  const isCompleted = completed ?? step <= activeStep;
456
610
  const { item } = timelineVariants({
457
611
  variant,
@@ -464,7 +618,7 @@ const TimelineItem = forwardRef<View, TimelineItemProps>(
464
618
  const columnWidth = horizontal
465
619
  ? itemWidth({ step, width, children } as TimelineItemProps)
466
620
  : undefined;
467
- const offset = offsets[step] ?? 0;
621
+ const offset = columnOffset ?? 0;
468
622
 
469
623
  const context = useMemo(
470
624
  () => ({
@@ -479,8 +633,8 @@ const TimelineItem = forwardRef<View, TimelineItemProps>(
479
633
  );
480
634
 
481
635
  /*
482
- * A column recedes as it leaves the reading edge, so the one being read is
483
- * the one that looks read.
636
+ * A column scales and drops as it leaves the reading edge, so the one being
637
+ * read is distinct without fading informative text below its contrast.
484
638
  *
485
639
  * One column's width either side, not two: a window wide enough to hold
486
640
  * three columns near full strength is a row where nothing is picked out,
@@ -494,12 +648,11 @@ const TimelineItem = forwardRef<View, TimelineItemProps>(
494
648
  * the rest.
495
649
  */
496
650
  const columnStyle = useAnimatedStyle(() => {
497
- if (!horizontal || !animate) return { opacity: 1, transform: [] };
651
+ if (!horizontal || !animate) return { transform: [] };
498
652
  const distance = Math.abs(scrollX.value - offset);
499
653
  const window = Math.max(columnWidth ?? timelineColumnWidth(undefined, true), 1);
500
654
  const away = interpolate(distance, [0, window], [0, 1], 'clamp');
501
655
  return {
502
- opacity: 1 - away * 0.4,
503
656
  transform: [{ scale: 1 - away * 0.04 }, { translateY: away * 4 }],
504
657
  };
505
658
  });
@@ -522,6 +675,22 @@ const TimelineItem = forwardRef<View, TimelineItemProps>(
522
675
  {...props}
523
676
  style={[style, { width: columnWidth }, columnStyle]}
524
677
  >
678
+ {virtualized && !last ? (
679
+ <View
680
+ accessibilityElementsHidden
681
+ importantForAccessibility="no-hide-descendants"
682
+ pointerEvents="none"
683
+ style={{
684
+ position: 'absolute',
685
+ left: 0,
686
+ right: 0,
687
+ top: HORIZONTAL_RAIL_TOP,
688
+ borderTopWidth: 1,
689
+ borderStyle: 'dashed',
690
+ borderColor: typeof border === 'string' ? border : undefined,
691
+ }}
692
+ />
693
+ ) : null}
525
694
  {textChildren(children)}
526
695
  </Animated.View>
527
696
  </TimelineItemContext.Provider>
@@ -678,22 +847,7 @@ const TimelineContent = forwardRef<View, ViewProps & { className?: string }>(
678
847
  const { body, panel } = timelineVariants({ variant, tone, completed, orientation });
679
848
  const horizontal = orientation === 'horizontal';
680
849
 
681
- /*
682
- * Horizontal: the body recedes further than the column around it.
683
- *
684
- * The rail is a continuous thing and has to stay readable across the whole
685
- * track — the years either side of the one being read are half of what a
686
- * timeline is for. The prose under them is not: several columns of it at
687
- * equal weight is a wall, and no amount of fading the column as a whole
688
- * fixes that without taking the years down with it. So the body takes a
689
- * second, steeper curve and the dates keep the first one.
690
- */
691
- const bodyStyle = useAnimatedStyle(() => {
692
- if (!horizontal || !animate) return { opacity: 1 };
693
- const distance = Math.abs(scrollX.value - offset);
694
- const window = Math.max(columnWidth, 1);
695
- return { opacity: interpolate(distance, [0, window], [1, 0.3], 'clamp') };
696
- });
850
+ /* Horizontal content stays fully opaque; scale and translation provide focus. */
697
851
 
698
852
  const inner =
699
853
  variant === 'card' ? (
@@ -715,7 +869,7 @@ const TimelineContent = forwardRef<View, ViewProps & { className?: string }>(
715
869
  ref={ref}
716
870
  className={body({ className })}
717
871
  {...props}
718
- style={[style, bodyStyle]}
872
+ style={style}
719
873
  >
720
874
  {inner}
721
875
  </Animated.View>
@@ -825,6 +979,7 @@ const TimelineStat = forwardRef<View, TimelineStatProps>(
825
979
  TimelineStat.displayName = 'Timeline.Stat';
826
980
 
827
981
  export const Timeline = Object.assign(TimelineRoot, {
982
+ List: TimelineList,
828
983
  Item: TimelineItem,
829
984
  Aside: TimelineAside,
830
985
  Indicator: TimelineIndicator,
@@ -7,3 +7,14 @@ export function timelineColumnWidth(requested: number | undefined, filled: boole
7
7
  }
8
8
  return filled ? TIMELINE_WIDE_COLUMN : TIMELINE_NARROW_COLUMN;
9
9
  }
10
+
11
+ /** Left edge of each column in render order. */
12
+ export function timelineColumnOffsets(widths: readonly number[]): number[] {
13
+ const offsets: number[] = [];
14
+ let sum = 0;
15
+ for (const width of widths) {
16
+ offsets.push(sum);
17
+ sum += width;
18
+ }
19
+ return offsets;
20
+ }
@@ -175,7 +175,12 @@ const ToastRoot = ({ className, variant = 'default', onHide, children, ...props
175
175
 
176
176
  return (
177
177
  <ToastContext.Provider value={context}>
178
- <View accessibilityRole="alert" accessibilityLiveRegion="polite" className={root({ className })} {...props}>
178
+ <View
179
+ {...props}
180
+ accessibilityRole="alert"
181
+ accessibilityLiveRegion="polite"
182
+ className={root({ className })}
183
+ >
179
184
  {textChildren(children)}
180
185
  </View>
181
186
  </ToastContext.Provider>
@@ -623,7 +623,7 @@ function TooltipTitle({ className, ...props }: TooltipTitleProps) {
623
623
  const { title } = tooltipVariants({ variant });
624
624
 
625
625
  return (
626
- <Text accessibilityRole="header" className={cn(title(), className)} {...props} />
626
+ <Text {...props} accessibilityRole="header" className={cn(title(), className)} />
627
627
  );
628
628
  }
629
629