panelui-native 0.77.0 → 0.78.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 (102) hide show
  1. package/lib/module/components/area-chart/index.js +32 -25
  2. package/lib/module/components/area-chart/index.js.map +1 -1
  3. package/lib/module/components/bottom-sheet/index.js +97 -15
  4. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  5. package/lib/module/components/funnel-chart/index.js +15 -2
  6. package/lib/module/components/funnel-chart/index.js.map +1 -1
  7. package/lib/module/components/heatmap-chart/index.js +19 -27
  8. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  9. package/lib/module/components/hex-chart/index.js +2 -0
  10. package/lib/module/components/hex-chart/index.js.map +1 -1
  11. package/lib/module/components/line-chart/index.js +38 -31
  12. package/lib/module/components/line-chart/index.js.map +1 -1
  13. package/lib/module/components/marquee/index.js +91 -5
  14. package/lib/module/components/marquee/index.js.map +1 -1
  15. package/lib/module/components/pie-chart/index.js +15 -2
  16. package/lib/module/components/pie-chart/index.js.map +1 -1
  17. package/lib/module/components/plot/index.js +42 -44
  18. package/lib/module/components/plot/index.js.map +1 -1
  19. package/lib/module/components/progress-button/index.js +390 -0
  20. package/lib/module/components/progress-button/index.js.map +1 -0
  21. package/lib/module/components/progress-button/progress-button-hold.js +57 -0
  22. package/lib/module/components/progress-button/progress-button-hold.js.map +1 -0
  23. package/lib/module/components/scatter-chart/index.js +6 -25
  24. package/lib/module/components/scatter-chart/index.js.map +1 -1
  25. package/lib/module/components/section-rail/index.js +40 -17
  26. package/lib/module/components/section-rail/index.js.map +1 -1
  27. package/lib/module/components/split-view/index.js +54 -10
  28. package/lib/module/components/split-view/index.js.map +1 -1
  29. package/lib/module/components/timeline/index.js +145 -14
  30. package/lib/module/components/timeline/index.js.map +1 -1
  31. package/lib/module/components/toast/index.js +45 -11
  32. package/lib/module/components/toast/index.js.map +1 -1
  33. package/lib/module/components/treemap-chart/index.js +63 -6
  34. package/lib/module/components/treemap-chart/index.js.map +1 -1
  35. package/lib/module/components/waterfall-chart/index.js +20 -4
  36. package/lib/module/components/waterfall-chart/index.js.map +1 -1
  37. package/lib/module/hooks/index.js +1 -0
  38. package/lib/module/hooks/index.js.map +1 -1
  39. package/lib/module/hooks/use-scroll-sections.js +44 -2
  40. package/lib/module/hooks/use-scroll-sections.js.map +1 -1
  41. package/lib/module/hooks/use-skeleton-handoff.js +58 -0
  42. package/lib/module/hooks/use-skeleton-handoff.js.map +1 -0
  43. package/lib/module/index.js +1 -0
  44. package/lib/module/index.js.map +1 -1
  45. package/lib/typescript/src/components/area-chart/index.d.ts +1 -1
  46. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  51. package/lib/typescript/src/components/line-chart/index.d.ts +1 -1
  52. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  53. package/lib/typescript/src/components/marquee/index.d.ts +55 -2
  54. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  55. package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/plot/index.d.ts +1 -3
  57. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/progress-button/index.d.ts +295 -0
  59. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -0
  60. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +40 -0
  61. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -0
  62. package/lib/typescript/src/components/scatter-chart/index.d.ts +1 -1
  63. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
  64. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  65. package/lib/typescript/src/components/split-view/index.d.ts +10 -1
  66. package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/timeline/index.d.ts +7 -0
  68. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
  72. package/lib/typescript/src/hooks/index.d.ts +1 -0
  73. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  74. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +1 -0
  75. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
  76. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts +11 -0
  77. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts.map +1 -0
  78. package/lib/typescript/src/index.d.ts +3 -2
  79. package/lib/typescript/src/index.d.ts.map +1 -1
  80. package/package.json +1 -1
  81. package/src/components/area-chart/index.tsx +49 -37
  82. package/src/components/bottom-sheet/index.tsx +109 -11
  83. package/src/components/funnel-chart/index.tsx +10 -2
  84. package/src/components/heatmap-chart/index.tsx +27 -22
  85. package/src/components/hex-chart/index.tsx +22 -6
  86. package/src/components/line-chart/index.tsx +57 -43
  87. package/src/components/marquee/index.tsx +129 -6
  88. package/src/components/pie-chart/index.tsx +10 -2
  89. package/src/components/plot/index.tsx +66 -50
  90. package/src/components/progress-button/index.tsx +465 -0
  91. package/src/components/progress-button/progress-button-hold.ts +57 -0
  92. package/src/components/scatter-chart/index.tsx +4 -25
  93. package/src/components/section-rail/index.tsx +39 -19
  94. package/src/components/split-view/index.tsx +53 -8
  95. package/src/components/timeline/index.tsx +143 -17
  96. package/src/components/toast/index.tsx +60 -11
  97. package/src/components/treemap-chart/index.tsx +65 -8
  98. package/src/components/waterfall-chart/index.tsx +14 -4
  99. package/src/hooks/index.ts +5 -0
  100. package/src/hooks/use-scroll-sections.ts +52 -3
  101. package/src/hooks/use-skeleton-handoff.ts +69 -0
  102. package/src/index.ts +10 -0
@@ -58,10 +58,10 @@ import { selectionTick } from '../../utils/haptics';
58
58
 
59
59
  const SPRING = { damping: 20, stiffness: 260, mass: 0.6 } as const;
60
60
  /** Bar width at the top level, and how much each nested level takes off it. */
61
- const BAR_WIDTH = 16;
62
- const BAR_LEVEL_STEP = 4;
61
+ const BAR_WIDTH = 14;
62
+ const BAR_LEVEL_STEP = 3;
63
63
  /** How much wider the active bar gets, so position is readable at a glance. */
64
- const BAR_ACTIVE_EXTRA = 12;
64
+ const BAR_ACTIVE_EXTRA = 10;
65
65
  /**
66
66
  * How much of that extra the bars either side of the active one keep.
67
67
  *
@@ -83,8 +83,17 @@ const BAR_REST_OPACITY = 0.32;
83
83
  * that a jump which never lands does not mute the next one.
84
84
  */
85
85
  const JUMP_TIMEOUT = 900;
86
+ /**
87
+ * How long the rail stays quiet *after* a jump has landed.
88
+ *
89
+ * A caller may set the value optimistically, before the scroll it triggers has
90
+ * started — so arriving at the target is not proof the journey is over. This
91
+ * covers the tail of the animation, in which the scroller passes through the
92
+ * sections either side of the destination and reports each of them.
93
+ */
94
+ const JUMP_SETTLE = 260;
86
95
  /** Indent per level in the expanded panel. */
87
- const ITEM_INDENT = 12;
96
+ const ITEM_INDENT = 10;
88
97
  /**
89
98
  * How wide the panel may grow by default. A row spends at least 40pt on
90
99
  * indent, padding and the panel's own border before any text, so a tighter cap
@@ -92,7 +101,7 @@ const ITEM_INDENT = 12;
92
101
  */
93
102
  const PANEL_MAX_WIDTH = '78%' as const;
94
103
  /** …and a floor, so a rail with one short section still opens a readable panel. */
95
- const PANEL_MIN_WIDTH = 200;
104
+ const PANEL_MIN_WIDTH = 184;
96
105
 
97
106
  export type SectionRailPlacement = 'left' | 'right';
98
107
  export type SectionRailAlign = 'center' | 'top' | 'bottom';
@@ -232,7 +241,7 @@ function SectionRailRoot({
232
241
  * the panel lit up a row nobody chose. Nothing between the tap and the
233
242
  * arrival is a section the reader went to, so nothing between them ticks.
234
243
  */
235
- const jumpTo = useRef<string | null>(null);
244
+ const jumpTo = useRef<{ target: string; landed: boolean } | null>(null);
236
245
 
237
246
  const endJump = useCallback(() => {
238
247
  jumpTo.current = null;
@@ -244,7 +253,7 @@ function SectionRailRoot({
244
253
 
245
254
  const handleValueChange = useCallback(
246
255
  (next: string) => {
247
- jumpTo.current = next;
256
+ jumpTo.current = { target: next, landed: false };
248
257
  if (jumpTimer.current) clearTimeout(jumpTimer.current);
249
258
  /*
250
259
  * A backstop, not the normal way out. A jump to a section the scroller
@@ -279,12 +288,23 @@ function SectionRailRoot({
279
288
  return;
280
289
  }
281
290
 
282
- const target = jumpTo.current;
283
- if (target !== null) {
284
- // Still on the way. Only the section that was asked for ends the jump,
285
- // and only it is worth feeling.
286
- if (value !== target) return;
287
- endJump();
291
+ const jump = jumpTo.current;
292
+ if (jump !== null) {
293
+ // Still on the way. Nothing the screen passes through is a section the
294
+ // reader went to, so nothing between the tap and the arrival ticks.
295
+ if (value !== jump.target) return;
296
+
297
+ // Arrived — worth feeling, once. The window stays armed a moment longer
298
+ // rather than closing here, because a caller that sets the value
299
+ // optimistically hands it to us *before* the scroll starts: closing on
300
+ // the first match would disarm the rail for the whole animation, which
301
+ // is the entire journey it was there to cover. Every section the screen
302
+ // then passed lit a row and fired a tick of its own, several of them
303
+ // inside a few hundred milliseconds.
304
+ if (jump.landed) return;
305
+ jump.landed = true;
306
+ if (jumpTimer.current) clearTimeout(jumpTimer.current);
307
+ jumpTimer.current = setTimeout(endJump, JUMP_SETTLE);
288
308
  }
289
309
 
290
310
  if (haptics) selectionTick();
@@ -366,10 +386,10 @@ function SectionRailTrigger({ className, children, ...props }: SectionRailTrigge
366
386
  accessibilityState={{ expanded: open }}
367
387
  onPress={() => setOpen(!open)}
368
388
  // Generous padding is the hit target; the bars themselves are hairlines.
369
- hitSlop={8}
389
+ hitSlop={10}
370
390
  className={cn(
371
- 'gap-2 py-3',
372
- placement === 'right' ? 'items-end ps-6 pe-2' : 'items-start ps-2 pe-6',
391
+ 'gap-1.5 py-2.5',
392
+ placement === 'right' ? 'items-end ps-5 pe-2' : 'items-start ps-2 pe-5',
373
393
  className
374
394
  )}
375
395
  {...props}
@@ -549,7 +569,7 @@ function SectionRailPanel({
549
569
  style={[style, { maxWidth, minWidth: PANEL_MIN_WIDTH }]}
550
570
  accessibilityRole="menu"
551
571
  className={cn(
552
- 'gap-0.5 rounded-2xl border border-border bg-popover p-2 shadow-lg',
572
+ 'gap-0.5 rounded-xl border border-border bg-popover p-1.5 shadow-lg',
553
573
  className
554
574
  )}
555
575
  {...props}
@@ -582,9 +602,9 @@ function SectionRailItem({ className, value, level = 0, children }: SectionRailI
582
602
  onPress={() => onValueChange?.(value)}
583
603
  // `paddingStart`, not `paddingLeft`: the indent has to fall on the same
584
604
  // side as the bar it belongs to, which is the trailing edge under RTL.
585
- style={{ paddingStart: 12 + level * ITEM_INDENT }}
605
+ style={{ paddingStart: 10 + level * ITEM_INDENT }}
586
606
  className={cn(
587
- 'rounded-lg py-2 pe-2.5 active:bg-accent',
607
+ 'rounded-lg py-1.5 pe-2 active:bg-accent',
588
608
  selected && 'bg-accent',
589
609
  className
590
610
  )}
@@ -79,11 +79,45 @@ const splitViewVariants = tv({
79
79
  root: 'w-full flex-col overflow-hidden',
80
80
  top: 'w-full overflow-hidden',
81
81
  bottom: 'w-full flex-1 overflow-hidden',
82
- dragArea: 'w-full items-center justify-center py-2',
83
- handle: 'h-1 w-10 rounded-full bg-muted-foreground/30',
82
+ dragArea: 'w-full items-center justify-center',
83
+ handle: 'rounded-full',
84
+ },
85
+ variants: {
86
+ /*
87
+ * How the split is drawn. The mechanics are the same either way — this is
88
+ * only what the reader sees, and what they see decides what they think the
89
+ * control is.
90
+ *
91
+ * `panes` gives each half a surface of its own on a recessed ground, with
92
+ * the grip in the gap between them. Two things you can resize, and a
93
+ * handle that belongs to neither.
94
+ *
95
+ * `seam` is a hairline grip on a shared background: no surfaces, no gap,
96
+ * the two halves reading as one region with a line through it. It suits a
97
+ * split inside something that already has a surface — a card, a sheet —
98
+ * where a second one is a box in a box.
99
+ */
100
+ variant: {
101
+ panes: {
102
+ root: 'bg-inset',
103
+ top: 'rounded-2xl bg-surface',
104
+ bottom: 'rounded-2xl bg-surface',
105
+ dragArea: 'py-3',
106
+ handle: 'h-1.5 w-12 bg-muted-foreground/50',
107
+ },
108
+ seam: {
109
+ dragArea: 'py-2',
110
+ handle: 'h-1 w-10 bg-muted-foreground/30',
111
+ },
112
+ },
113
+ },
114
+ defaultVariants: {
115
+ variant: 'panes',
84
116
  },
85
117
  });
86
118
 
119
+ export type SplitViewVariant = 'panes' | 'seam';
120
+
87
121
  interface SplitViewContextValue {
88
122
  /** The top pane's height in points. Written by drags, read by both panes. */
89
123
  topHeight: SharedValue<number>;
@@ -101,6 +135,7 @@ interface SplitViewContextValue {
101
135
  snapTo: (index: number) => void;
102
136
  measureDragArea: (height: number) => void;
103
137
  dragAreaHeight: number;
138
+ variant: SplitViewVariant;
104
139
  }
105
140
 
106
141
  const SplitViewContext = createContext<SplitViewContextValue | null>(null);
@@ -123,6 +158,13 @@ export function useSplitView() {
123
158
 
124
159
  export interface SplitViewProps extends ViewProps {
125
160
  className?: string;
161
+ /**
162
+ * How the split is drawn. `panes` gives each half its own rounded surface on
163
+ * a recessed ground, with the grip in the gap between them; `seam` is a
164
+ * hairline grip on a shared background, for a split inside something that
165
+ * already has a surface of its own.
166
+ */
167
+ variant?: SplitViewVariant;
126
168
  /**
127
169
  * Heights the seam settles on. A number at or below `1` is a fraction of the
128
170
  * room the panes share; anything larger is points. Defaults to
@@ -154,6 +196,7 @@ export interface SplitViewProps extends ViewProps {
154
196
 
155
197
  function SplitViewRoot({
156
198
  className,
199
+ variant = 'panes',
157
200
  snapPoints,
158
201
  minHeight,
159
202
  maxHeight,
@@ -167,7 +210,7 @@ function SplitViewRoot({
167
210
  onLayout,
168
211
  ...props
169
212
  }: SplitViewProps) {
170
- const { root } = splitViewVariants();
213
+ const { root } = splitViewVariants({ variant });
171
214
  const reducedMotion = useReducedMotion();
172
215
  const animate = !reducedMotion;
173
216
 
@@ -284,6 +327,7 @@ function SplitViewRoot({
284
327
  snapTo,
285
328
  measureDragArea,
286
329
  dragAreaHeight,
330
+ variant,
287
331
  }),
288
332
  [
289
333
  topHeight,
@@ -298,6 +342,7 @@ function SplitViewRoot({
298
342
  snapTo,
299
343
  measureDragArea,
300
344
  dragAreaHeight,
345
+ variant,
301
346
  ]
302
347
  );
303
348
 
@@ -325,7 +370,7 @@ export interface SplitViewPaneProps extends ViewProps {
325
370
  */
326
371
  function SplitViewTop({ className, children, style, ...props }: SplitViewPaneProps) {
327
372
  const context = useSplitViewContext('SplitView.Top');
328
- const { top } = splitViewVariants();
373
+ const { top } = splitViewVariants({ variant: context.variant });
329
374
 
330
375
  const animatedStyle = useAnimatedStyle(() => ({
331
376
  height: context.topHeight.value,
@@ -356,8 +401,8 @@ function SplitViewTop({ className, children, style, ...props }: SplitViewPanePro
356
401
  * every frame of a drag rather than only at rest.
357
402
  */
358
403
  function SplitViewBottom({ className, children, ...props }: SplitViewPaneProps) {
359
- useSplitViewContext('SplitView.Bottom');
360
- const { bottom } = splitViewVariants();
404
+ const context = useSplitViewContext('SplitView.Bottom');
405
+ const { bottom } = splitViewVariants({ variant: context.variant });
361
406
 
362
407
  return (
363
408
  <View className={bottom({ className })} {...props}>
@@ -390,7 +435,7 @@ function SplitViewDragArea({
390
435
  ...props
391
436
  }: SplitViewDragAreaProps) {
392
437
  const context = useSplitViewContext('SplitView.DragArea');
393
- const { dragArea } = splitViewVariants();
438
+ const { dragArea } = splitViewVariants({ variant: context.variant });
394
439
  const { topHeight, dragging, points, minPx, maxPx, room, disabled, snapTo } = context;
395
440
 
396
441
  const start = useSharedValue(0);
@@ -504,7 +549,7 @@ export interface SplitViewHandleProps extends ViewProps {
504
549
  */
505
550
  function SplitViewHandle({ className, children, style, ...props }: SplitViewHandleProps) {
506
551
  const context = useSplitViewContext('SplitView.Handle');
507
- const { handle } = splitViewVariants();
552
+ const { handle } = splitViewVariants({ variant: context.variant });
508
553
  const { animate } = context;
509
554
 
510
555
  const animatedStyle = useAnimatedStyle(() => {
@@ -64,6 +64,9 @@ import {
64
64
  } from 'react-native';
65
65
  import Animated, {
66
66
  interpolate,
67
+ interpolateColor,
68
+ runOnJS,
69
+ useAnimatedReaction,
67
70
  useAnimatedScrollHandler,
68
71
  useAnimatedStyle,
69
72
  useReducedMotion,
@@ -73,9 +76,100 @@ import Animated, {
73
76
  import { tv } from 'tailwind-variants';
74
77
  import { useCSSVariable } from 'uniwind';
75
78
  import { IconColorProvider } from '../../icons';
79
+ import { selectionTick } from '../../utils/haptics';
76
80
  import { Text, type TextProps, textChildren } from '../../primitives/text';
77
81
  import { TIMELINE_WIDE_COLUMN, timelineColumnOffsets, timelineColumnWidth } from './timeline-geometry';
78
82
 
83
+ const AnimatedText = Animated.createAnimatedComponent(Text);
84
+
85
+ /**
86
+ * A tick as the reading edge passes from one column to the next.
87
+ *
88
+ * The comparison runs on the UI thread every frame; the call back to
89
+ * JavaScript happens once per column crossed, which is the difference between
90
+ * feedback and a haptic per frame. Fired at the crossing itself rather than
91
+ * when the scroll settles, so the tick and the column arriving are the same
92
+ * moment — a haptic that lags what caused it reads as a glitch.
93
+ */
94
+ function useColumnDetent(
95
+ scrollX: SharedValue<number>,
96
+ offsets: number[],
97
+ enabled: boolean
98
+ ) {
99
+ const nearest = useSharedValue(0);
100
+
101
+ useAnimatedReaction(
102
+ () => {
103
+ if (!enabled || offsets.length === 0) return 0;
104
+ let index = 0;
105
+ let best = Infinity;
106
+ for (let i = 0; i < offsets.length; i += 1) {
107
+ const distance = Math.abs(scrollX.value - (offsets[i] ?? 0));
108
+ if (distance < best) {
109
+ best = distance;
110
+ index = i;
111
+ }
112
+ }
113
+ return index;
114
+ },
115
+ (index, previous) => {
116
+ if (!enabled || previous === null || index === previous) return;
117
+ nearest.value = index;
118
+ runOnJS(selectionTick)();
119
+ }
120
+ );
121
+ }
122
+
123
+
124
+ /*
125
+ * Fallbacks for the two ends of the focus ramp, used only before the theme's
126
+ * variables have resolved. `interpolateColor` needs two real colours on the
127
+ * very first frame, and a missing one is a crash rather than a wrong shade.
128
+ */
129
+ const INK_FALLBACK = { foreground: '#09090b', muted: '#71717a' };
130
+
131
+ /**
132
+ * A column's text at full strength while it is the one being read, and muted
133
+ * while it is not.
134
+ *
135
+ * Horizontal only, and it is the piece that was missing. Scale and a small drop
136
+ * already picked the focused column out of the row, but every word in every
137
+ * column stayed the same muted grey — so the column you had scrolled to was
138
+ * nearer and no easier to read than the ones either side of it. Colour is what
139
+ * says *this is the one*, and it is the only cue that survives being looked at
140
+ * rather than glanced at.
141
+ *
142
+ * The ramp runs to the foreground token, which is what a heading uses. Anything
143
+ * softer is another grey, and the whole complaint about the old drawing was
144
+ * that it was grey.
145
+ *
146
+ * It is not disabled under reduced motion. The value is driven by scroll
147
+ * position rather than by a clock — the reader's own finger is what moves it —
148
+ * so there is no motion here to reduce. What that setting turns off is the
149
+ * scale and the drop, which is handled where those are.
150
+ */
151
+ function useColumnInk(component: string) {
152
+ const { orientation, scrollX } = useTimeline(component);
153
+ const item = useContext(TimelineItemContext);
154
+ const foregroundToken = useCSSVariable('--color-foreground');
155
+ const mutedToken = useCSSVariable('--color-muted-foreground');
156
+
157
+ const horizontal = orientation === 'horizontal';
158
+ const offset = item?.offset ?? 0;
159
+ const width = Math.max(item?.columnWidth || TIMELINE_WIDE_COLUMN, 1);
160
+ const foreground =
161
+ typeof foregroundToken === 'string' ? foregroundToken : INK_FALLBACK.foreground;
162
+ const muted = typeof mutedToken === 'string' ? mutedToken : INK_FALLBACK.muted;
163
+
164
+ const style = useAnimatedStyle(() => {
165
+ const distance = Math.abs(scrollX.value - offset);
166
+ const away = interpolate(distance, [0, width], [0, 1], 'clamp');
167
+ return { color: interpolateColor(away, [0, 1], [foreground, muted]) };
168
+ });
169
+
170
+ return { horizontal, style };
171
+ }
172
+
79
173
  export type TimelineVariant = 'dot' | 'icon' | 'numbered' | 'card' | 'compact';
80
174
  /** Semantic colour for a single event, independent of progress. */
81
175
  export type TimelineTone = 'default' | 'info' | 'success' | 'warning' | 'danger';
@@ -315,6 +409,13 @@ export interface TimelineProps extends ViewProps {
315
409
  * halfway shows two half-columns and no whole one.
316
410
  */
317
411
  snap?: boolean;
412
+ /**
413
+ * Horizontal only: a tick as the reading edge passes from one column to the
414
+ * next. Needs `snap`, since a scroll that lands anywhere has no detents to
415
+ * feel. Off by default — a haptic per column is a lot for a long history, and
416
+ * whether this one is worth feeling is the caller's call.
417
+ */
418
+ haptics?: boolean;
318
419
  children?: ReactNode;
319
420
  }
320
421
 
@@ -326,6 +427,7 @@ const TimelineRoot = forwardRef<View, TimelineProps>(
326
427
  variant = 'dot',
327
428
  orientation = 'vertical',
328
429
  snap = true,
430
+ haptics = false,
329
431
  children,
330
432
  ...props
331
433
  },
@@ -382,6 +484,8 @@ const TimelineRoot = forwardRef<View, TimelineProps>(
382
484
  scrollX.value = event.contentOffset.x;
383
485
  });
384
486
 
487
+ useColumnDetent(scrollX, offsets, horizontal && snap && haptics);
488
+
385
489
  if (!horizontal) {
386
490
  return (
387
491
  <TimelineContext.Provider value={context}>
@@ -841,13 +945,17 @@ TimelineIndicator.displayName = 'Timeline.Indicator';
841
945
  */
842
946
  const TimelineContent = forwardRef<View, ViewProps & { className?: string }>(
843
947
  ({ className, children, style, ...props }, ref) => {
844
- const { variant, orientation, scrollX, animate } = useTimeline('Timeline.Content');
845
- const { completed, tone, offset, columnWidth } =
846
- useTimelineItem('Timeline.Content');
948
+ const { variant, orientation } = useTimeline('Timeline.Content');
949
+ const { completed, tone } = useTimelineItem('Timeline.Content');
847
950
  const { body, panel } = timelineVariants({ variant, tone, completed, orientation });
848
951
  const horizontal = orientation === 'horizontal';
849
952
 
850
- /* Horizontal content stays fully opaque; scale and translation provide focus. */
953
+ /*
954
+ * Horizontal content is not dimmed as a block. The column already carries
955
+ * the focus — it is nearer, and its text runs to the foreground as it comes
956
+ * to the reading edge — and fading the whole slot on top of that would take
957
+ * the neighbours out of the timeline rather than out of the way.
958
+ */
851
959
 
852
960
  const inner =
853
961
  variant === 'card' ? (
@@ -865,14 +973,9 @@ const TimelineContent = forwardRef<View, ViewProps & { className?: string }>(
865
973
  }
866
974
 
867
975
  return (
868
- <Animated.View
869
- ref={ref}
870
- className={body({ className })}
871
- {...props}
872
- style={style}
873
- >
976
+ <View ref={ref} className={body({ className })} style={style} {...props}>
874
977
  {inner}
875
- </Animated.View>
978
+ </View>
876
979
  );
877
980
  }
878
981
  );
@@ -898,10 +1001,16 @@ const TimelineHeading = forwardRef<View, ViewProps & { className?: string }>(
898
1001
  );
899
1002
  TimelineHeading.displayName = 'Timeline.Heading';
900
1003
 
901
- const TimelineDate = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => {
1004
+ const TimelineDate = forwardRef<RNText, TextProps>(({ className, style, ...props }, ref) => {
902
1005
  const { variant, orientation } = useTimeline('Timeline.Date');
903
1006
  const { date } = timelineVariants({ variant, orientation });
904
- return <Text ref={ref} className={date({ className })} {...props} />;
1007
+ const ink = useColumnInk('Timeline.Date');
1008
+ if (!ink.horizontal) {
1009
+ return <Text ref={ref} className={date({ className })} style={style} {...props} />;
1010
+ }
1011
+ return (
1012
+ <AnimatedText ref={ref} className={date({ className })} style={[style, ink.style]} {...props} />
1013
+ );
905
1014
  });
906
1015
  TimelineDate.displayName = 'Timeline.Date';
907
1016
 
@@ -915,10 +1024,16 @@ const TimelineLabel = forwardRef<RNText, TextProps>(({ className, ...props }, re
915
1024
  TimelineLabel.displayName = 'Timeline.Label';
916
1025
 
917
1026
  /** Muted supporting line — a person's name, a source. */
918
- const TimelineMeta = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => {
1027
+ const TimelineMeta = forwardRef<RNText, TextProps>(({ className, style, ...props }, ref) => {
919
1028
  const { variant, orientation } = useTimeline('Timeline.Meta');
920
1029
  const { meta } = timelineVariants({ variant, orientation });
921
- return <Text ref={ref} className={meta({ className })} {...props} />;
1030
+ const ink = useColumnInk('Timeline.Meta');
1031
+ if (!ink.horizontal) {
1032
+ return <Text ref={ref} className={meta({ className })} style={style} {...props} />;
1033
+ }
1034
+ return (
1035
+ <AnimatedText ref={ref} className={meta({ className })} style={[style, ink.style]} {...props} />
1036
+ );
922
1037
  });
923
1038
  TimelineMeta.displayName = 'Timeline.Meta';
924
1039
 
@@ -940,10 +1055,21 @@ const TimelineTrailing = forwardRef<RNText, TextProps>(
940
1055
  TimelineTrailing.displayName = 'Timeline.Trailing';
941
1056
 
942
1057
  const TimelineDescription = forwardRef<RNText, TextProps>(
943
- ({ className, ...props }, ref) => {
1058
+ ({ className, style, ...props }, ref) => {
944
1059
  const { variant, orientation } = useTimeline('Timeline.Description');
945
1060
  const { content } = timelineVariants({ variant, orientation });
946
- return <Text ref={ref} className={content({ className })} {...props} />;
1061
+ const ink = useColumnInk('Timeline.Description');
1062
+ if (!ink.horizontal) {
1063
+ return <Text ref={ref} className={content({ className })} style={style} {...props} />;
1064
+ }
1065
+ return (
1066
+ <AnimatedText
1067
+ ref={ref}
1068
+ className={content({ className })}
1069
+ style={[style, ink.style]}
1070
+ {...props}
1071
+ />
1072
+ );
947
1073
  }
948
1074
  );
949
1075
  TimelineDescription.displayName = 'Timeline.Description';
@@ -28,12 +28,16 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
28
28
  import Animated, {
29
29
  Easing,
30
30
  Extrapolation,
31
+ FadeIn,
31
32
  FadeInDown,
32
33
  FadeInUp,
34
+ FadeOut,
33
35
  Keyframe,
34
36
  interpolate,
35
37
  runOnJS,
36
38
  useAnimatedStyle,
39
+ useDerivedValue,
40
+ useReducedMotion,
37
41
  useSharedValue,
38
42
  withDecay,
39
43
  withSpring,
@@ -58,6 +62,13 @@ import {
58
62
  } from './toast-store';
59
63
 
60
64
  const SPRING = { damping: 20, stiffness: 260, mass: 0.6 } as const;
65
+
66
+ /**
67
+ * The deck reshuffling after a dismissal. Slower and softer than the drag's
68
+ * spring: the cards behind are not what the reader is acting on, and a stack
69
+ * that snaps draws the eye to the wrong thing.
70
+ */
71
+ const STACK_SPRING = { duration: 350, dampingRatio: 0.9 } as const;
61
72
  /** Drag distance past which a release dismisses. */
62
73
  const DISMISS_DISTANCE = 50;
63
74
  /** Fling speed past which a release dismisses regardless of distance. */
@@ -91,15 +102,24 @@ const enteringBottom = FadeInDown.springify()
91
102
  .withInitialValues({ opacity: 1, transform: [{ translateY: ENTER_OFFSET }] })
92
103
  .mass(3);
93
104
 
105
+ /*
106
+ * It leaves the way it arrived, a little quicker.
107
+ *
108
+ * The curve was an ease-*in*, which starts slow — it spends the first frames of
109
+ * the exit barely moving, which is exactly the moment the reader is looking at
110
+ * the thing that just changed. This is the ease-out used everywhere else in the
111
+ * library, and the duration is a fifth under the entrance: the arrival earns
112
+ * the time, the departure does not.
113
+ */
94
114
  const exitKeyframe = (offset: number) =>
95
115
  new Keyframe({
96
116
  0: { opacity: 1, transform: [{ translateY: 0 }, { scale: 1 }] },
97
117
  100: {
98
- opacity: 0.5,
118
+ opacity: 0,
99
119
  transform: [{ translateY: offset }, { scale: 0.97 }],
100
- easing: Easing.bezier(0.4, 0, 1, 1),
120
+ easing: Easing.bezier(0.23, 1, 0.32, 1),
101
121
  },
102
- }).duration(150);
122
+ }).duration(240);
103
123
 
104
124
  const exitingTop = exitKeyframe(-ENTER_OFFSET);
105
125
  const exitingBottom = exitKeyframe(ENTER_OFFSET);
@@ -315,6 +335,7 @@ function ToastSlot({
315
335
  const placement = item.placement ?? 'bottom';
316
336
  const translateY = useSharedValue(0);
317
337
  const scale = useSharedValue(1);
338
+ const reducedMotion = useReducedMotion();
318
339
  const hide = useCallback(() => toastStore.hide(item.id), [item.id]);
319
340
 
320
341
  // Drop this toast's measurement when it leaves, so the map cannot grow
@@ -387,22 +408,35 @@ function ToastSlot({
387
408
  * The whole deck takes the *newest* toast's height so the cards behind read
388
409
  * as a uniform stack rather than a ragged pile.
389
410
  */
411
+ /*
412
+ * How far back in the deck this toast is: 0 is the front one.
413
+ *
414
+ * Sprung rather than read straight off the props. Position used to be
415
+ * computed from `index` and `total` directly, so dismissing a toast in the
416
+ * middle teleported every card behind it forward a step between two frames.
417
+ * The deck reshuffling is the one moment the stack is worth watching, and it
418
+ * was the one moment it did not move.
419
+ */
420
+ const depth = useDerivedValue(() =>
421
+ reducedMotion ? total - 1 - index : withSpring(total - 1 - index, STACK_SPRING)
422
+ );
423
+
390
424
  const stackStyle = useAnimatedStyle(() => {
391
425
  // Fall back to this toast's own height until the front one is measured.
392
426
  const frontHeight = heights.value[frontId] ?? heights.value[item.id];
393
427
 
394
- // Newest sits at 0; each one behind steps back by STACK_OFFSET and shrinks.
395
- const inputRange = [total - 1, total - 2];
396
- const offset = interpolate(index, inputRange, [0, STACK_OFFSET * stackSign], {
428
+ // Front sits at 0; each one behind steps back by STACK_OFFSET and shrinks.
429
+ const back = depth.value;
430
+ const offset = interpolate(back, [0, 1], [0, STACK_OFFSET * stackSign], {
397
431
  extrapolateLeft: Extrapolation.CLAMP,
398
432
  });
399
- const stackScale = interpolate(index, inputRange, [1, STACK_SCALE_STEP], {
433
+ const stackScale = interpolate(back, [0, 1], [1, STACK_SCALE_STEP], {
400
434
  extrapolateLeft: Extrapolation.CLAMP,
401
435
  });
402
436
  // Anything past MAX_VISIBLE fades out rather than piling up forever.
403
437
  const opacity = interpolate(
404
- index,
405
- [total - MAX_VISIBLE, total - MAX_VISIBLE - 1],
438
+ back,
439
+ [MAX_VISIBLE - 1, MAX_VISIBLE],
406
440
  [1, 0],
407
441
  Extrapolation.CLAMP
408
442
  );
@@ -443,8 +477,23 @@ function ToastSlot({
443
477
 
444
478
  return (
445
479
  <Animated.View
446
- entering={placement === 'top' ? enteringTop : enteringBottom}
447
- exiting={placement === 'top' ? exitingTop : exitingBottom}
480
+ /* Reduced motion keeps the fade — a toast that appears with no transition
481
+ at all is a jump-cut, and the arrival still has to be noticed — and
482
+ drops the travel and the scale. */
483
+ entering={
484
+ reducedMotion
485
+ ? FadeIn.duration(150)
486
+ : placement === 'top'
487
+ ? enteringTop
488
+ : enteringBottom
489
+ }
490
+ exiting={
491
+ reducedMotion
492
+ ? FadeOut.duration(120)
493
+ : placement === 'top'
494
+ ? exitingTop
495
+ : exitingBottom
496
+ }
448
497
  pointerEvents="box-none"
449
498
  // Every toast is pinned to the same edge, so they overlap as a deck.
450
499
  // Newest on top. Horizontal inset lives here rather than as padding on