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.
- package/lib/module/components/area-chart/index.js +32 -25
- package/lib/module/components/area-chart/index.js.map +1 -1
- package/lib/module/components/bottom-sheet/index.js +97 -15
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/funnel-chart/index.js +15 -2
- package/lib/module/components/funnel-chart/index.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +19 -27
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/hex-chart/index.js +2 -0
- package/lib/module/components/hex-chart/index.js.map +1 -1
- package/lib/module/components/line-chart/index.js +38 -31
- package/lib/module/components/line-chart/index.js.map +1 -1
- package/lib/module/components/marquee/index.js +91 -5
- package/lib/module/components/marquee/index.js.map +1 -1
- package/lib/module/components/pie-chart/index.js +15 -2
- package/lib/module/components/pie-chart/index.js.map +1 -1
- package/lib/module/components/plot/index.js +42 -44
- package/lib/module/components/plot/index.js.map +1 -1
- package/lib/module/components/progress-button/index.js +390 -0
- package/lib/module/components/progress-button/index.js.map +1 -0
- package/lib/module/components/progress-button/progress-button-hold.js +57 -0
- package/lib/module/components/progress-button/progress-button-hold.js.map +1 -0
- package/lib/module/components/scatter-chart/index.js +6 -25
- package/lib/module/components/scatter-chart/index.js.map +1 -1
- package/lib/module/components/section-rail/index.js +40 -17
- package/lib/module/components/section-rail/index.js.map +1 -1
- package/lib/module/components/split-view/index.js +54 -10
- package/lib/module/components/split-view/index.js.map +1 -1
- package/lib/module/components/timeline/index.js +145 -14
- package/lib/module/components/timeline/index.js.map +1 -1
- package/lib/module/components/toast/index.js +45 -11
- package/lib/module/components/toast/index.js.map +1 -1
- package/lib/module/components/treemap-chart/index.js +63 -6
- package/lib/module/components/treemap-chart/index.js.map +1 -1
- package/lib/module/components/waterfall-chart/index.js +20 -4
- package/lib/module/components/waterfall-chart/index.js.map +1 -1
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-scroll-sections.js +44 -2
- package/lib/module/hooks/use-scroll-sections.js.map +1 -1
- package/lib/module/hooks/use-skeleton-handoff.js +58 -0
- package/lib/module/hooks/use-skeleton-handoff.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/area-chart/index.d.ts +1 -1
- package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/marquee/index.d.ts +55 -2
- package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
- package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/plot/index.d.ts +1 -3
- package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
- package/lib/typescript/src/components/progress-button/index.d.ts +295 -0
- package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -0
- package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +40 -0
- package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -0
- package/lib/typescript/src/components/scatter-chart/index.d.ts +1 -1
- package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
- package/lib/typescript/src/components/split-view/index.d.ts +10 -1
- package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
- package/lib/typescript/src/components/timeline/index.d.ts +7 -0
- package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
- package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/index.d.ts +1 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts +1 -0
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts +11 -0
- package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/area-chart/index.tsx +49 -37
- package/src/components/bottom-sheet/index.tsx +109 -11
- package/src/components/funnel-chart/index.tsx +10 -2
- package/src/components/heatmap-chart/index.tsx +27 -22
- package/src/components/hex-chart/index.tsx +22 -6
- package/src/components/line-chart/index.tsx +57 -43
- package/src/components/marquee/index.tsx +129 -6
- package/src/components/pie-chart/index.tsx +10 -2
- package/src/components/plot/index.tsx +66 -50
- package/src/components/progress-button/index.tsx +465 -0
- package/src/components/progress-button/progress-button-hold.ts +57 -0
- package/src/components/scatter-chart/index.tsx +4 -25
- package/src/components/section-rail/index.tsx +39 -19
- package/src/components/split-view/index.tsx +53 -8
- package/src/components/timeline/index.tsx +143 -17
- package/src/components/toast/index.tsx +60 -11
- package/src/components/treemap-chart/index.tsx +65 -8
- package/src/components/waterfall-chart/index.tsx +14 -4
- package/src/hooks/index.ts +5 -0
- package/src/hooks/use-scroll-sections.ts +52 -3
- package/src/hooks/use-skeleton-handoff.ts +69 -0
- 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 =
|
|
62
|
-
const BAR_LEVEL_STEP =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
283
|
-
if (
|
|
284
|
-
// Still on the way.
|
|
285
|
-
//
|
|
286
|
-
if (value !== target) return;
|
|
287
|
-
|
|
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={
|
|
389
|
+
hitSlop={10}
|
|
370
390
|
className={cn(
|
|
371
|
-
'gap-
|
|
372
|
-
placement === 'right' ? 'items-end ps-
|
|
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-
|
|
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:
|
|
605
|
+
style={{ paddingStart: 10 + level * ITEM_INDENT }}
|
|
586
606
|
className={cn(
|
|
587
|
-
'rounded-lg py-
|
|
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
|
|
83
|
-
handle: '
|
|
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
|
|
845
|
-
const { completed, tone
|
|
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
|
-
/*
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
118
|
+
opacity: 0,
|
|
99
119
|
transform: [{ translateY: offset }, { scale: 0.97 }],
|
|
100
|
-
easing: Easing.bezier(0.
|
|
120
|
+
easing: Easing.bezier(0.23, 1, 0.32, 1),
|
|
101
121
|
},
|
|
102
|
-
}).duration(
|
|
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
|
-
//
|
|
395
|
-
const
|
|
396
|
-
const offset = interpolate(
|
|
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(
|
|
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
|
-
|
|
405
|
-
[
|
|
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
|
-
|
|
447
|
-
|
|
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
|