panelui-native 0.77.0 → 0.79.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/map/index.js +63 -39
- package/lib/module/components/map/index.js.map +1 -1
- package/lib/module/components/map/map-children.js +54 -0
- package/lib/module/components/map/map-children.js.map +1 -0
- package/lib/module/components/map/maplibre.js +31 -3
- package/lib/module/components/map/maplibre.js.map +1 -1
- package/lib/module/components/markdown-editor/index.js +288 -34
- package/lib/module/components/markdown-editor/index.js.map +1 -1
- package/lib/module/components/markdown-editor/markdown-transforms.js +272 -9
- package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -1
- package/lib/module/components/marquee/index.js +205 -46
- package/lib/module/components/marquee/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +162 -34
- package/lib/module/components/panelside/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 +617 -0
- package/lib/module/components/progress-button/index.js.map +1 -0
- package/lib/module/components/progress-button/progress-button-hold.js +91 -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/theme-selector/index.js +239 -0
- package/lib/module/components/theme-selector/index.js.map +1 -0
- package/lib/module/components/theme-selector/theme-preview.js +320 -0
- package/lib/module/components/theme-selector/theme-preview.js.map +1 -0
- 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 +2 -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/map/index.d.ts +8 -4
- package/lib/typescript/src/components/map/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/map-children.d.ts +41 -0
- package/lib/typescript/src/components/map/map-children.d.ts.map +1 -0
- package/lib/typescript/src/components/map/maplibre.d.ts +8 -4
- package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
- package/lib/typescript/src/components/markdown-editor/index.d.ts +64 -5
- package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -1
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +33 -5
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.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/panelside/index.d.ts +79 -4
- package/lib/typescript/src/components/panelside/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 +311 -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 +60 -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/theme-selector/index.d.ts +107 -0
- package/lib/typescript/src/components/theme-selector/index.d.ts.map +1 -0
- package/lib/typescript/src/components/theme-selector/theme-preview.d.ts +32 -0
- package/lib/typescript/src/components/theme-selector/theme-preview.d.ts.map +1 -0
- 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 +6 -4
- 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/map/index.tsx +63 -36
- package/src/components/map/map-children.ts +66 -0
- package/src/components/map/maplibre.ts +32 -4
- package/src/components/markdown-editor/index.tsx +413 -35
- package/src/components/markdown-editor/markdown-transforms.ts +274 -10
- package/src/components/marquee/index.tsx +254 -58
- package/src/components/panelside/index.tsx +179 -40
- package/src/components/pie-chart/index.tsx +10 -2
- package/src/components/plot/index.tsx +66 -50
- package/src/components/progress-button/index.tsx +684 -0
- package/src/components/progress-button/progress-button-hold.ts +88 -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/theme-selector/index.tsx +281 -0
- package/src/components/theme-selector/theme-preview.tsx +205 -0
- 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 +25 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three miniatures a theme selector is made of.
|
|
3
|
+
*
|
|
4
|
+
* Drawn from fixed neutrals rather than from theme tokens, which is the one
|
|
5
|
+
* thing about this file that is not obvious. A preview built out of the active
|
|
6
|
+
* theme's colours shows the reader the theme they already have, three times —
|
|
7
|
+
* the light option has to look light while the app around it is dark, or it is
|
|
8
|
+
* not a preview of anything.
|
|
9
|
+
*
|
|
10
|
+
* The greys are the ones a screenshot of an app would actually have: a ground,
|
|
11
|
+
* a surface sitting on it, a mark, and the lines of text. Two sets, and the
|
|
12
|
+
* system option draws both.
|
|
13
|
+
*/
|
|
14
|
+
import { useId, type ReactNode } from 'react';
|
|
15
|
+
import Svg, { Circle, ClipPath, Defs, G, Path, Rect } from 'react-native-svg';
|
|
16
|
+
|
|
17
|
+
/** Which of the three a preview is drawing. */
|
|
18
|
+
export type ThemePreviewMode = 'system' | 'light' | 'dark';
|
|
19
|
+
|
|
20
|
+
/** How the miniature is drawn. */
|
|
21
|
+
export type ThemePreviewVariant = 'window' | 'card';
|
|
22
|
+
|
|
23
|
+
interface Palette {
|
|
24
|
+
/** Behind everything — the desktop, the page, the space around the app. */
|
|
25
|
+
ground: string;
|
|
26
|
+
/** The app's own surface. */
|
|
27
|
+
surface: string;
|
|
28
|
+
/** A shape on it: an avatar, an image, a control. */
|
|
29
|
+
mark: string;
|
|
30
|
+
/** Lines of text. */
|
|
31
|
+
line: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PALETTE: Record<'light' | 'dark', Palette> = {
|
|
35
|
+
light: { ground: '#e5e5e5', surface: '#ffffff', mark: '#d4d4d4', line: '#e5e5e5' },
|
|
36
|
+
dark: { ground: '#171717', surface: '#262626', mark: '#525252', line: '#404040' },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The one colour that is not a grey.
|
|
41
|
+
*
|
|
42
|
+
* Fixed for the same reason the greys are, and warm rather than blue: it has to
|
|
43
|
+
* read as "a colour in the app" on both grounds without looking like the ring
|
|
44
|
+
* that marks the chosen option.
|
|
45
|
+
*/
|
|
46
|
+
const ACCENT = '#c1704f';
|
|
47
|
+
|
|
48
|
+
/** Everything is drawn in this box and scaled by the container. */
|
|
49
|
+
const VIEW_BOX = { width: 88, height: 70 };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The lower-right half of the box, cut from the top-right corner to the
|
|
53
|
+
* bottom-left one. Both text lines sit above it and the accent below it, which
|
|
54
|
+
* is what makes one card show a light screen and a dark one at the same time.
|
|
55
|
+
*/
|
|
56
|
+
const DIAGONAL = `M${VIEW_BOX.width} 0V${VIEW_BOX.height}H0Z`;
|
|
57
|
+
|
|
58
|
+
/* -------------------------------------------------------------------------- *
|
|
59
|
+
* window — an app screen, with the panel inset from the leading edge
|
|
60
|
+
* -------------------------------------------------------------------------- */
|
|
61
|
+
|
|
62
|
+
function WindowHalf({ palette, side }: { palette: Palette; side: 'left' | 'right' | 'full' }) {
|
|
63
|
+
if (side === 'full') {
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<Rect x={0} y={0} width={88} height={70} fill={palette.ground} />
|
|
67
|
+
<Path d="M10 12a4 4 0 0 1 4-4h74v62H10V12Z" fill={palette.surface} />
|
|
68
|
+
<Circle cx={28} cy={26} r={8} fill={palette.mark} />
|
|
69
|
+
<Rect x={20} y={42} width={58} height={4} rx={2} fill={palette.line} />
|
|
70
|
+
<Rect x={20} y={49} width={58} height={4} rx={2} fill={palette.line} />
|
|
71
|
+
<Rect x={20} y={56} width={29} height={4} rx={2} fill={palette.line} />
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/*
|
|
77
|
+
* Half a screen each, and the halves are not mirror images: the panel starts
|
|
78
|
+
* at the same inset on the left and runs to the edge on the right, so the
|
|
79
|
+
* seam falls where the two app surfaces meet rather than down the middle of
|
|
80
|
+
* one of them.
|
|
81
|
+
*/
|
|
82
|
+
const x = side === 'left' ? 0 : 44;
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
<Rect x={x} y={0} width={44} height={70} fill={palette.ground} />
|
|
86
|
+
{side === 'left' ? (
|
|
87
|
+
<>
|
|
88
|
+
<Path d="M10 12a4 4 0 0 1 4-4h30v62H10V12Z" fill={palette.surface} />
|
|
89
|
+
<Circle cx={28} cy={26} r={8} fill={palette.mark} />
|
|
90
|
+
<Rect x={20} y={42} width={24} height={4} rx={2} fill={palette.line} />
|
|
91
|
+
<Rect x={20} y={49} width={24} height={4} rx={2} fill={palette.line} />
|
|
92
|
+
<Rect x={20} y={56} width={24} height={4} rx={2} fill={palette.line} />
|
|
93
|
+
</>
|
|
94
|
+
) : (
|
|
95
|
+
<>
|
|
96
|
+
<Path d="M54 12a4 4 0 0 1 4-4h30v62H54V12Z" fill={palette.surface} />
|
|
97
|
+
<Circle cx={72} cy={26} r={8} fill={palette.mark} />
|
|
98
|
+
<Rect x={64} y={42} width={24} height={4} rx={2} fill={palette.line} />
|
|
99
|
+
<Rect x={64} y={49} width={24} height={4} rx={2} fill={palette.line} />
|
|
100
|
+
<Rect x={64} y={56} width={24} height={4} rx={2} fill={palette.line} />
|
|
101
|
+
</>
|
|
102
|
+
)}
|
|
103
|
+
</>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* -------------------------------------------------------------------------- *
|
|
108
|
+
* card — a framed card, with an accent on it
|
|
109
|
+
* -------------------------------------------------------------------------- */
|
|
110
|
+
|
|
111
|
+
/*
|
|
112
|
+
* The card fills its box rather than floating inside it.
|
|
113
|
+
*
|
|
114
|
+
* It used to carry a margin of its own, which then sat on top of the padding
|
|
115
|
+
* between the artwork and the ring around it — two gaps doing one job, and the
|
|
116
|
+
* bigger the miniature the further apart they held the two edges.
|
|
117
|
+
*/
|
|
118
|
+
const CARD_FRAME = { x: 0, y: 0, width: 88, height: 70, rx: 16 } as const;
|
|
119
|
+
const CARD_SURFACE = { x: 8, y: 8, width: 72, height: 54, rx: 10 } as const;
|
|
120
|
+
|
|
121
|
+
function CardBody({ palette }: { palette: Palette }) {
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<Rect {...CARD_FRAME} fill={palette.ground} />
|
|
125
|
+
<Rect {...CARD_SURFACE} fill={palette.surface} />
|
|
126
|
+
<Rect x={17} y={18} width={34} height={4} rx={2} fill={palette.line} />
|
|
127
|
+
<Rect x={17} y={26} width={23} height={4} rx={2} fill={palette.line} />
|
|
128
|
+
</>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ThemePreviewProps {
|
|
133
|
+
mode: ThemePreviewMode;
|
|
134
|
+
variant?: ThemePreviewVariant;
|
|
135
|
+
width: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One miniature.
|
|
140
|
+
*
|
|
141
|
+
* `width` rather than a scale factor: the option's box is what decides the
|
|
142
|
+
* size, and everything inside is drawn against a fixed box and scaled by the
|
|
143
|
+
* viewBox — so the geometry above never has to know how big it ended up.
|
|
144
|
+
*/
|
|
145
|
+
export function ThemePreview({ mode, variant = 'window', width }: ThemePreviewProps): ReactNode {
|
|
146
|
+
const height = Math.round((width / VIEW_BOX.width) * VIEW_BOX.height);
|
|
147
|
+
/*
|
|
148
|
+
* `useId` returns something with colons in it, which is not a name an SVG
|
|
149
|
+
* fragment reference accepts — a clip that silently fails to resolve draws
|
|
150
|
+
* nothing at all, and on a shape covering half the artwork that is the whole
|
|
151
|
+
* option missing.
|
|
152
|
+
*/
|
|
153
|
+
const clip = `panelui-theme-${useId().replace(/[^a-zA-Z0-9-]/g, '')}`;
|
|
154
|
+
|
|
155
|
+
if (variant === 'card') {
|
|
156
|
+
return (
|
|
157
|
+
<Svg width={width} height={height} viewBox="0 0 88 70">
|
|
158
|
+
<CardBody palette={PALETTE[mode === 'dark' ? 'dark' : 'light']} />
|
|
159
|
+
|
|
160
|
+
{mode === 'system' ? (
|
|
161
|
+
<>
|
|
162
|
+
{/*
|
|
163
|
+
The other half, cut corner to corner.
|
|
164
|
+
|
|
165
|
+
The same triangle twice, clipped once to the frame and once to
|
|
166
|
+
the card inside it, rather than one shape tracing where the
|
|
167
|
+
diagonal crosses both. Two clips is less arithmetic than one path
|
|
168
|
+
and stays right if either radius changes; an unclipped triangle
|
|
169
|
+
squares off the two corners the diagonal runs into.
|
|
170
|
+
*/}
|
|
171
|
+
<Defs>
|
|
172
|
+
<ClipPath id={`${clip}-frame`}>
|
|
173
|
+
<Rect {...CARD_FRAME} />
|
|
174
|
+
</ClipPath>
|
|
175
|
+
<ClipPath id={`${clip}-card`}>
|
|
176
|
+
<Rect {...CARD_SURFACE} />
|
|
177
|
+
</ClipPath>
|
|
178
|
+
</Defs>
|
|
179
|
+
<G clipPath={`url(#${clip}-frame)`}>
|
|
180
|
+
<Path d={DIAGONAL} fill={PALETTE.dark.ground} />
|
|
181
|
+
</G>
|
|
182
|
+
<G clipPath={`url(#${clip}-card)`}>
|
|
183
|
+
<Path d={DIAGONAL} fill={PALETTE.dark.surface} />
|
|
184
|
+
</G>
|
|
185
|
+
</>
|
|
186
|
+
) : null}
|
|
187
|
+
|
|
188
|
+
<Circle cx={64} cy={47} r={8} fill={ACCENT} />
|
|
189
|
+
</Svg>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<Svg width={width} height={height} viewBox="0 0 88 70">
|
|
195
|
+
{mode === 'system' ? (
|
|
196
|
+
<>
|
|
197
|
+
<WindowHalf palette={PALETTE.light} side="left" />
|
|
198
|
+
<WindowHalf palette={PALETTE.dark} side="right" />
|
|
199
|
+
</>
|
|
200
|
+
) : (
|
|
201
|
+
<WindowHalf palette={PALETTE[mode]} side="full" />
|
|
202
|
+
)}
|
|
203
|
+
</Svg>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -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
|