panelui-native 0.88.2 → 0.90.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/animated-badge/index.js +10 -2
- package/lib/module/components/animated-badge/index.js.map +1 -1
- package/lib/module/components/avatar/index.js +10 -1
- package/lib/module/components/avatar/index.js.map +1 -1
- package/lib/module/components/search-bar/index.js +35 -7
- package/lib/module/components/search-bar/index.js.map +1 -1
- package/lib/module/components/section-progress/index.js +637 -0
- package/lib/module/components/section-progress/index.js.map +1 -0
- package/lib/module/components/selection-mode/index.js +33 -3
- package/lib/module/components/selection-mode/index.js.map +1 -1
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-scroll-sections.js +58 -4
- package/lib/module/hooks/use-scroll-sections.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/animated-badge/index.d.ts +19 -0
- package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -1
- package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
- package/lib/typescript/src/components/search-bar/index.d.ts +4 -1
- package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
- package/lib/typescript/src/components/section-progress/index.d.ts +156 -0
- package/lib/typescript/src/components/section-progress/index.d.ts.map +1 -0
- package/lib/typescript/src/components/selection-mode/index.d.ts +63 -0
- package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/index.d.ts +1 -1
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts +24 -0
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/animated-badge/index.tsx +24 -2
- package/src/components/avatar/index.tsx +10 -1
- package/src/components/search-bar/index.tsx +33 -8
- package/src/components/section-progress/index.tsx +813 -0
- package/src/components/selection-mode/index.tsx +30 -3
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-scroll-sections.ts +84 -5
- package/src/index.ts +8 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SectionProgress — a floating pill saying how far through a screen you are,
|
|
3
|
+
* and which part of it you are in.
|
|
4
|
+
*
|
|
5
|
+
* A ring filled to the scroll position, and beside it the title of the section
|
|
6
|
+
* being read. Pressed, it opens into the list of sections and jumps to any of
|
|
7
|
+
* them.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const sections = useScrollSections({ ids: SECTIONS.map((s) => s.id) });
|
|
11
|
+
*
|
|
12
|
+
* <SectionProgress
|
|
13
|
+
* scroll={sections.scroll}
|
|
14
|
+
* value={sections.active}
|
|
15
|
+
* onValueChange={sections.scrollTo}
|
|
16
|
+
* >
|
|
17
|
+
* <SectionProgress.Item value="intro">Introduction</SectionProgress.Item>
|
|
18
|
+
* <SectionProgress.Item value="setup">Setup</SectionProgress.Item>
|
|
19
|
+
* </SectionProgress>
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* ## Two readings, one control
|
|
23
|
+
*
|
|
24
|
+
* The ring is continuous and the label is not, and that is the point: a
|
|
25
|
+
* percentage says how much is left, a section name says what is being read.
|
|
26
|
+
* Either on its own leaves the other question open — a bar at 60% of an
|
|
27
|
+
* unfamiliar page means nothing in particular, and a heading with no sense of
|
|
28
|
+
* depth is a position without a scale.
|
|
29
|
+
*
|
|
30
|
+
* ## It arrives, and then it stays
|
|
31
|
+
*
|
|
32
|
+
* Nothing is drawn on the first screen. Past `revealAt` the pill fades in and
|
|
33
|
+
* remains for the rest of the scroll — it does not hide again on the way back
|
|
34
|
+
* up. A label that comes and goes with the scroll direction is one the reader
|
|
35
|
+
* has to catch rather than read.
|
|
36
|
+
*
|
|
37
|
+
* ## One surface, not a card above a button
|
|
38
|
+
*
|
|
39
|
+
* Open, the list and the pill are a single bordered box: the pill's row is the
|
|
40
|
+
* end of the card rather than a control sitting under a panel of its own. Two
|
|
41
|
+
* boxes would draw two outlines a few points apart, and the pill would read as
|
|
42
|
+
* something the list had landed on top of rather than as the thing it grew
|
|
43
|
+
* out of.
|
|
44
|
+
*
|
|
45
|
+
* The card is the only thing carrying a border, a background and a shadow.
|
|
46
|
+
* Everything inside it is a row.
|
|
47
|
+
*
|
|
48
|
+
* ## The section, and the colour it brings
|
|
49
|
+
*
|
|
50
|
+
* An `Item` may carry a `color`, and the active one's colour is taken by the
|
|
51
|
+
* ring, the label and a wash across the pill, crossfading as the reader moves
|
|
52
|
+
* between sections. It turns the pill into a second, peripheral signal — the
|
|
53
|
+
* part of the page you are in, readable without the words.
|
|
54
|
+
*/
|
|
55
|
+
import {
|
|
56
|
+
Children,
|
|
57
|
+
createContext,
|
|
58
|
+
isValidElement,
|
|
59
|
+
useCallback,
|
|
60
|
+
useContext,
|
|
61
|
+
useEffect,
|
|
62
|
+
useMemo,
|
|
63
|
+
useRef,
|
|
64
|
+
useState,
|
|
65
|
+
type ReactNode,
|
|
66
|
+
} from 'react';
|
|
67
|
+
import { Pressable, ScrollView, StyleSheet, View, type ViewProps } from 'react-native';
|
|
68
|
+
import Animated, {
|
|
69
|
+
FadeIn,
|
|
70
|
+
interpolate,
|
|
71
|
+
interpolateColor,
|
|
72
|
+
runOnJS,
|
|
73
|
+
useAnimatedProps,
|
|
74
|
+
useAnimatedReaction,
|
|
75
|
+
useAnimatedStyle,
|
|
76
|
+
useReducedMotion,
|
|
77
|
+
useSharedValue,
|
|
78
|
+
withTiming,
|
|
79
|
+
type SharedValue,
|
|
80
|
+
} from 'react-native-reanimated';
|
|
81
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
82
|
+
import Svg, { Circle, G } from 'react-native-svg';
|
|
83
|
+
import { useCSSVariable } from 'uniwind';
|
|
84
|
+
import { useBackHandler } from '../../hooks/use-back-handler';
|
|
85
|
+
import { useScrollProgress } from '../../primitives/scroll-progress';
|
|
86
|
+
import { Text, textChildren } from '../../primitives/text';
|
|
87
|
+
import { cn } from '../../utils/cn';
|
|
88
|
+
import { selectionTick } from '../../utils/haptics';
|
|
89
|
+
|
|
90
|
+
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
|
91
|
+
|
|
92
|
+
/** Diameter of the ring, and the weight of its stroke. */
|
|
93
|
+
const RING_SIZE = 22;
|
|
94
|
+
const RING_STROKE = 2.5;
|
|
95
|
+
/**
|
|
96
|
+
* How long the ring takes to reach a new scroll position.
|
|
97
|
+
*
|
|
98
|
+
* The position arrives from a scroll handler on the JavaScript thread, so it
|
|
99
|
+
* comes in steps rather than continuously. Easing towards each step turns that
|
|
100
|
+
* back into a glide — and because the easing itself runs on the UI thread, a
|
|
101
|
+
* busy JavaScript thread costs the ring some lag rather than the whole motion.
|
|
102
|
+
*/
|
|
103
|
+
const PROGRESS_EASE = 160;
|
|
104
|
+
/** How long the pill takes to arrive, and to take on a new section's colour. */
|
|
105
|
+
const REVEAL_DURATION = 200;
|
|
106
|
+
const TINT_DURATION = 240;
|
|
107
|
+
/** How far the pill rises as it appears. */
|
|
108
|
+
const REVEAL_RISE = 10;
|
|
109
|
+
/**
|
|
110
|
+
* How long a jump from the panel is given to arrive before section changes
|
|
111
|
+
* start being felt again. Only reached when the scroll had nowhere to go.
|
|
112
|
+
*/
|
|
113
|
+
const JUMP_TIMEOUT = 900;
|
|
114
|
+
/** List width: a floor so one short section still reads, and a cap so long
|
|
115
|
+
titles wrap instead of pushing the card across the screen. */
|
|
116
|
+
const LIST_MIN_WIDTH = 180;
|
|
117
|
+
const LIST_MAX_WIDTH = '86%' as const;
|
|
118
|
+
/**
|
|
119
|
+
* How tall the list may grow before it scrolls: about six rows.
|
|
120
|
+
*
|
|
121
|
+
* A cap, and a `flexGrow: 0` beside it, because a `ScrollView` carries
|
|
122
|
+
* `flexGrow: 1` in its own base style — inside a card whose height comes from
|
|
123
|
+
* its contents, that is a list which fills every point the screen will give it
|
|
124
|
+
* and a card stretched from edge to edge behind six rows.
|
|
125
|
+
*/
|
|
126
|
+
const LIST_MAX_HEIGHT = 260;
|
|
127
|
+
/**
|
|
128
|
+
* The collapsed pill's height, from what it is built out of: the ring and the
|
|
129
|
+
* padding either side of it.
|
|
130
|
+
*
|
|
131
|
+
* Halved, it is the closed corner radius — and it is a number rather than a
|
|
132
|
+
* `rounded-full` because a radius of 9999 on a bordered shape draws a border
|
|
133
|
+
* that thickens through the curve at each end and thins along the straight
|
|
134
|
+
* top and bottom. A radius that is exactly half the height curves once.
|
|
135
|
+
*/
|
|
136
|
+
const PILL_HEIGHT = RING_SIZE + 12;
|
|
137
|
+
/** The card's radius once it has opened into a list. */
|
|
138
|
+
const CARD_RADIUS = 20;
|
|
139
|
+
/** How long the corner takes to round off into a card, and the list to arrive. */
|
|
140
|
+
const EXPAND_DURATION = 220;
|
|
141
|
+
const LIST_FADE = 160;
|
|
142
|
+
/** The border the card draws, and the radius the wash inside it takes. */
|
|
143
|
+
const CARD_BORDER = 1;
|
|
144
|
+
|
|
145
|
+
export type SectionProgressPlacement =
|
|
146
|
+
| 'top-left'
|
|
147
|
+
| 'top-center'
|
|
148
|
+
| 'top-right'
|
|
149
|
+
| 'bottom-left'
|
|
150
|
+
| 'bottom-center'
|
|
151
|
+
| 'bottom-right';
|
|
152
|
+
|
|
153
|
+
/** The colour an `Item` can bring with it. */
|
|
154
|
+
export type SectionProgressColor =
|
|
155
|
+
| 'primary'
|
|
156
|
+
| 'success'
|
|
157
|
+
| 'warning'
|
|
158
|
+
| 'danger'
|
|
159
|
+
| 'info'
|
|
160
|
+
| 'foreground';
|
|
161
|
+
|
|
162
|
+
/** Which side of the pill's row the content sits on, per placement. */
|
|
163
|
+
const ALIGNMENT: Record<SectionProgressPlacement, string> = {
|
|
164
|
+
'top-left': 'items-start',
|
|
165
|
+
'top-center': 'items-center',
|
|
166
|
+
'top-right': 'items-end',
|
|
167
|
+
'bottom-left': 'items-start',
|
|
168
|
+
'bottom-center': 'items-center',
|
|
169
|
+
'bottom-right': 'items-end',
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The tokens, read in one call, and where each colour name sits in the result.
|
|
174
|
+
*
|
|
175
|
+
* One array call rather than one hook per name: an `Item`'s colour is a prop
|
|
176
|
+
* on a child element, and a hook cannot be run per child without the number of
|
|
177
|
+
* hooks changing with the number of sections.
|
|
178
|
+
*/
|
|
179
|
+
const COLOR_TOKENS = [
|
|
180
|
+
'--color-foreground',
|
|
181
|
+
'--color-primary',
|
|
182
|
+
'--color-success',
|
|
183
|
+
'--color-warning',
|
|
184
|
+
'--color-destructive',
|
|
185
|
+
'--color-info',
|
|
186
|
+
'--color-muted',
|
|
187
|
+
];
|
|
188
|
+
const COLOR_INDEX: Record<SectionProgressColor, number> = {
|
|
189
|
+
foreground: 0,
|
|
190
|
+
primary: 1,
|
|
191
|
+
success: 2,
|
|
192
|
+
warning: 3,
|
|
193
|
+
danger: 4,
|
|
194
|
+
info: 5,
|
|
195
|
+
};
|
|
196
|
+
/** Where the track colour sits in the same result. */
|
|
197
|
+
const TRACK_INDEX = 6;
|
|
198
|
+
/** Drawn if a token cannot be resolved — a theme that has not loaded yet. */
|
|
199
|
+
const COLOR_FALLBACK = '#f5f5f5';
|
|
200
|
+
const TRACK_FALLBACK = '#3f3f46';
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Where the scroller is.
|
|
204
|
+
*
|
|
205
|
+
* Three values rather than one fraction, because the fraction is not the only
|
|
206
|
+
* thing that is wanted: the reveal threshold is a distance in points, and a
|
|
207
|
+
* distance cannot be recovered from a percentage.
|
|
208
|
+
*
|
|
209
|
+
* Both `useScrollSections().scroll` and the `ScrollProgress` primitive's
|
|
210
|
+
* context satisfy this shape.
|
|
211
|
+
*/
|
|
212
|
+
export interface SectionProgressScroll {
|
|
213
|
+
/** Distance scrolled, in points. */
|
|
214
|
+
offset: SharedValue<number>;
|
|
215
|
+
/** Height of the visible area. */
|
|
216
|
+
viewport: SharedValue<number>;
|
|
217
|
+
/** Total height of the content. */
|
|
218
|
+
content: SharedValue<number>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface SectionProgressContextValue {
|
|
222
|
+
value: string | undefined;
|
|
223
|
+
onValueChange: (value: string) => void;
|
|
224
|
+
close: () => void;
|
|
225
|
+
/** The active section's colour, already resolved to something drawable. */
|
|
226
|
+
tint: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const SectionProgressContext = createContext<SectionProgressContextValue | null>(null);
|
|
230
|
+
|
|
231
|
+
function useSectionProgress(component: string): SectionProgressContextValue {
|
|
232
|
+
const context = useContext(SectionProgressContext);
|
|
233
|
+
if (!context) {
|
|
234
|
+
throw new Error(`${component} must be used within a <SectionProgress>`);
|
|
235
|
+
}
|
|
236
|
+
return context;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface SectionProgressProps extends Omit<ViewProps, 'children'> {
|
|
240
|
+
className?: string;
|
|
241
|
+
/**
|
|
242
|
+
* The scroll position the ring is filled from. `useScrollSections` returns
|
|
243
|
+
* one as `scroll`; without it the component falls back to the nearest
|
|
244
|
+
* `ScrollProgress`, and with neither the ring stays empty.
|
|
245
|
+
*/
|
|
246
|
+
scroll?: SectionProgressScroll;
|
|
247
|
+
/**
|
|
248
|
+
* Fill the ring from a value of your own, between 0 and 1. Nothing is
|
|
249
|
+
* derived when this is passed.
|
|
250
|
+
*/
|
|
251
|
+
progress?: SharedValue<number> | number;
|
|
252
|
+
/** Active section id. Controlled — usually driven by a scroll handler. */
|
|
253
|
+
value?: string;
|
|
254
|
+
/** Starting section when uncontrolled. */
|
|
255
|
+
defaultValue?: string;
|
|
256
|
+
/** Fires when a section is chosen from the panel. Scroll there. */
|
|
257
|
+
onValueChange?: (value: string) => void;
|
|
258
|
+
/** Controlled expansion of the panel. */
|
|
259
|
+
open?: boolean;
|
|
260
|
+
/** Whether the panel starts open when uncontrolled. */
|
|
261
|
+
defaultOpen?: boolean;
|
|
262
|
+
/** Fires when the panel opens or closes, however it was done. */
|
|
263
|
+
onOpenChange?: (open: boolean) => void;
|
|
264
|
+
/** Which corner or edge the pill floats in. */
|
|
265
|
+
placement?: SectionProgressPlacement;
|
|
266
|
+
/** Gap between the pill and the edge of the safe area. */
|
|
267
|
+
offset?: number;
|
|
268
|
+
/**
|
|
269
|
+
* How far the reader must scroll, in points, before the pill appears. `0`
|
|
270
|
+
* shows it from the first frame. It never hides again.
|
|
271
|
+
*/
|
|
272
|
+
revealAt?: number;
|
|
273
|
+
/**
|
|
274
|
+
* Tick under the finger on every change of section, however it was made.
|
|
275
|
+
* Nothing between a tap in the panel and its arrival counts as a change.
|
|
276
|
+
* Needs the optional `expo-haptics` package; without it this does nothing.
|
|
277
|
+
*/
|
|
278
|
+
haptics?: boolean;
|
|
279
|
+
/**
|
|
280
|
+
* What the pill is called to a screen reader. The section being read and
|
|
281
|
+
* the percentage are announced after it, so this names the control rather
|
|
282
|
+
* than describing the state.
|
|
283
|
+
*/
|
|
284
|
+
label?: string;
|
|
285
|
+
/** One `SectionProgress.Item` per section, in the order they appear. */
|
|
286
|
+
children: ReactNode;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function SectionProgressRoot({
|
|
290
|
+
className,
|
|
291
|
+
scroll,
|
|
292
|
+
progress,
|
|
293
|
+
value: valueProp,
|
|
294
|
+
defaultValue,
|
|
295
|
+
onValueChange,
|
|
296
|
+
open: openProp,
|
|
297
|
+
defaultOpen = false,
|
|
298
|
+
onOpenChange,
|
|
299
|
+
placement = 'bottom-center',
|
|
300
|
+
offset = 16,
|
|
301
|
+
revealAt = 64,
|
|
302
|
+
haptics = false,
|
|
303
|
+
label = 'Sections',
|
|
304
|
+
children,
|
|
305
|
+
...props
|
|
306
|
+
}: SectionProgressProps) {
|
|
307
|
+
const insets = useSafeAreaInsets();
|
|
308
|
+
const reduceMotion = useReducedMotion();
|
|
309
|
+
|
|
310
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
311
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
312
|
+
const isControlled = valueProp !== undefined;
|
|
313
|
+
const value = isControlled ? valueProp : internalValue;
|
|
314
|
+
const isOpenControlled = openProp !== undefined;
|
|
315
|
+
const open = isOpenControlled ? openProp : internalOpen;
|
|
316
|
+
|
|
317
|
+
const setOpen = useCallback(
|
|
318
|
+
(next: boolean) => {
|
|
319
|
+
if (!isOpenControlled) setInternalOpen(next);
|
|
320
|
+
onOpenChange?.(next);
|
|
321
|
+
},
|
|
322
|
+
[isOpenControlled, onOpenChange]
|
|
323
|
+
);
|
|
324
|
+
const close = useCallback(() => setOpen(false), [setOpen]);
|
|
325
|
+
|
|
326
|
+
// The panel owns the back button while it is up — back should put the list
|
|
327
|
+
// away, not leave the screen behind it.
|
|
328
|
+
useBackHandler(open, close);
|
|
329
|
+
|
|
330
|
+
/*
|
|
331
|
+
* The sections, read straight off the children.
|
|
332
|
+
*
|
|
333
|
+
* The pill has to draw the active section's own label and colour while the
|
|
334
|
+
* panel that holds the rows is closed and unmounted. Reading the elements is
|
|
335
|
+
* synchronous, so the first frame is already right — a registration effect
|
|
336
|
+
* would leave the pill blank until after mount, which is the frame the
|
|
337
|
+
* reveal animation is playing on.
|
|
338
|
+
*/
|
|
339
|
+
const items = useMemo(() => {
|
|
340
|
+
const found: { value: string; label: ReactNode; color?: SectionProgressColor }[] = [];
|
|
341
|
+
Children.forEach(children, (child) => {
|
|
342
|
+
if (!isValidElement(child) || child.type !== SectionProgressItem) return;
|
|
343
|
+
const itemProps = child.props as SectionProgressItemProps;
|
|
344
|
+
if (typeof itemProps.value !== 'string') return;
|
|
345
|
+
found.push({
|
|
346
|
+
value: itemProps.value,
|
|
347
|
+
label: itemProps.children,
|
|
348
|
+
color: itemProps.color,
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
return found;
|
|
352
|
+
}, [children]);
|
|
353
|
+
|
|
354
|
+
const active = items.find((item) => item.value === value) ?? items[0];
|
|
355
|
+
|
|
356
|
+
// Narrowed on the way out because `useCSSVariable` resolves to a number for
|
|
357
|
+
// any token that happens to be one.
|
|
358
|
+
const tokens = useCSSVariable(COLOR_TOKENS);
|
|
359
|
+
const resolved = tokens[COLOR_INDEX[active?.color ?? 'foreground']];
|
|
360
|
+
const tint = typeof resolved === 'string' ? resolved : COLOR_FALLBACK;
|
|
361
|
+
const trackToken = tokens[TRACK_INDEX];
|
|
362
|
+
const track = typeof trackToken === 'string' ? trackToken : TRACK_FALLBACK;
|
|
363
|
+
|
|
364
|
+
/* ------------------------------------------------------------------ *
|
|
365
|
+
* The ring
|
|
366
|
+
* ------------------------------------------------------------------ */
|
|
367
|
+
|
|
368
|
+
// Called unconditionally, used only as a fallback: a hook cannot sit behind
|
|
369
|
+
// a `??`, and the context returns null outside a provider anyway.
|
|
370
|
+
const inherited = useScrollProgress();
|
|
371
|
+
const source = scroll ?? inherited;
|
|
372
|
+
const filled = useSharedValue(0);
|
|
373
|
+
|
|
374
|
+
useAnimatedReaction(
|
|
375
|
+
() => {
|
|
376
|
+
if (typeof progress === 'number') return progress;
|
|
377
|
+
if (progress) return progress.value;
|
|
378
|
+
if (!source) return 0;
|
|
379
|
+
const span = source.content.value - source.viewport.value;
|
|
380
|
+
return span > 0 ? source.offset.value / span : 0;
|
|
381
|
+
},
|
|
382
|
+
(next) => {
|
|
383
|
+
const clamped = next < 0 ? 0 : next > 1 ? 1 : next;
|
|
384
|
+
filled.value = withTiming(clamped, { duration: PROGRESS_EASE });
|
|
385
|
+
},
|
|
386
|
+
[progress, source]
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const radius = (RING_SIZE - RING_STROKE) / 2;
|
|
390
|
+
const circumference = 2 * Math.PI * radius;
|
|
391
|
+
const centre = RING_SIZE / 2;
|
|
392
|
+
|
|
393
|
+
/*
|
|
394
|
+
* A circle's stroke starts at three o'clock, so it is turned back a quarter
|
|
395
|
+
* to start at twelve. An arc that begins at three reads as a gauge already
|
|
396
|
+
* part-way along.
|
|
397
|
+
*/
|
|
398
|
+
const rotate = `rotate(-90 ${centre} ${centre})`;
|
|
399
|
+
|
|
400
|
+
/* ------------------------------------------------------------------ *
|
|
401
|
+
* The reveal
|
|
402
|
+
* ------------------------------------------------------------------ */
|
|
403
|
+
|
|
404
|
+
/*
|
|
405
|
+
* A React state, not only a shared value: the pill has to stop taking
|
|
406
|
+
* touches while it is invisible, and `pointerEvents` is not a style. The
|
|
407
|
+
* reaction fires when the threshold is crossed rather than every frame, so
|
|
408
|
+
* this costs one hop to the JavaScript thread per scroll, not per event.
|
|
409
|
+
*/
|
|
410
|
+
const [revealed, setRevealed] = useState(revealAt <= 0);
|
|
411
|
+
useAnimatedReaction(
|
|
412
|
+
() => (source ? source.offset.value >= revealAt : true),
|
|
413
|
+
(next, previous) => {
|
|
414
|
+
if (next !== previous) runOnJS(setRevealed)(next);
|
|
415
|
+
},
|
|
416
|
+
[revealAt, source]
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
const shown = useSharedValue(revealAt <= 0 ? 1 : 0);
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
const to = revealed ? 1 : 0;
|
|
422
|
+
shown.value = reduceMotion ? to : withTiming(to, { duration: REVEAL_DURATION });
|
|
423
|
+
}, [revealed, reduceMotion, shown]);
|
|
424
|
+
|
|
425
|
+
const revealStyle = useAnimatedStyle(() => ({
|
|
426
|
+
opacity: shown.value,
|
|
427
|
+
transform: [
|
|
428
|
+
{
|
|
429
|
+
translateY: interpolate(
|
|
430
|
+
shown.value,
|
|
431
|
+
[0, 1],
|
|
432
|
+
[placement.startsWith('top') ? -REVEAL_RISE : REVEAL_RISE, 0]
|
|
433
|
+
),
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
}));
|
|
437
|
+
|
|
438
|
+
/* ------------------------------------------------------------------ *
|
|
439
|
+
* The colour the section brings
|
|
440
|
+
* ------------------------------------------------------------------ */
|
|
441
|
+
|
|
442
|
+
/*
|
|
443
|
+
* Two colours and a scalar between them, rather than one colour swapped: a
|
|
444
|
+
* section change is a change of state the reader did not ask for, and one
|
|
445
|
+
* that lands instantly reads as a flicker rather than as a transition.
|
|
446
|
+
*/
|
|
447
|
+
const [fade, setFade] = useState({ from: tint, to: tint });
|
|
448
|
+
const crossfade = useSharedValue(1);
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
setFade((current) => (current.to === tint ? current : { from: current.to, to: tint }));
|
|
451
|
+
}, [tint]);
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
if (fade.from === fade.to) return;
|
|
454
|
+
crossfade.value = 0;
|
|
455
|
+
crossfade.value = reduceMotion ? 1 : withTiming(1, { duration: TINT_DURATION });
|
|
456
|
+
}, [fade, crossfade, reduceMotion]);
|
|
457
|
+
|
|
458
|
+
const ringProps = useAnimatedProps(() => ({
|
|
459
|
+
strokeDasharray: [circumference * filled.value, circumference],
|
|
460
|
+
stroke: interpolateColor(crossfade.value, [0, 1], [fade.from, fade.to]),
|
|
461
|
+
}));
|
|
462
|
+
const tintStyle = useAnimatedStyle(() => ({
|
|
463
|
+
color: interpolateColor(crossfade.value, [0, 1], [fade.from, fade.to]),
|
|
464
|
+
}));
|
|
465
|
+
/*
|
|
466
|
+
* The corner, from a pill to a card.
|
|
467
|
+
*
|
|
468
|
+
* A number rather than `rounded-full`, because a radius far larger than the
|
|
469
|
+
* shape draws a border that thickens through each curved end and thins along
|
|
470
|
+
* the straight edges between them. Exactly half the height curves once, and
|
|
471
|
+
* the hairline stays one weight the whole way round.
|
|
472
|
+
*/
|
|
473
|
+
const expanded = useSharedValue(open ? 1 : 0);
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
const to = open ? 1 : 0;
|
|
476
|
+
expanded.value = reduceMotion ? to : withTiming(to, { duration: EXPAND_DURATION });
|
|
477
|
+
}, [open, reduceMotion, expanded]);
|
|
478
|
+
|
|
479
|
+
const cardStyle = useAnimatedStyle(() => ({
|
|
480
|
+
borderRadius: interpolate(expanded.value, [0, 1], [PILL_HEIGHT / 2, CARD_RADIUS]),
|
|
481
|
+
}));
|
|
482
|
+
const washStyle = useAnimatedStyle(() => ({
|
|
483
|
+
backgroundColor: interpolateColor(crossfade.value, [0, 1], [fade.from, fade.to]),
|
|
484
|
+
// One point tighter than the card's, since it sits inside the border.
|
|
485
|
+
borderRadius:
|
|
486
|
+
interpolate(expanded.value, [0, 1], [PILL_HEIGHT / 2, CARD_RADIUS]) - CARD_BORDER,
|
|
487
|
+
}));
|
|
488
|
+
|
|
489
|
+
/* ------------------------------------------------------------------ *
|
|
490
|
+
* Choosing a section, and feeling the ones scrolled past
|
|
491
|
+
* ------------------------------------------------------------------ */
|
|
492
|
+
|
|
493
|
+
/*
|
|
494
|
+
* The section a tap asked for, while the screen is still travelling to it.
|
|
495
|
+
* A jump passes every section in between, and each of those arrives here as
|
|
496
|
+
* a change of section — one tap, three ticks, none of them a place the
|
|
497
|
+
* reader went.
|
|
498
|
+
*/
|
|
499
|
+
const jumpTo = useRef<string | null>(null);
|
|
500
|
+
const jumpTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
501
|
+
const endJump = useCallback(() => {
|
|
502
|
+
jumpTo.current = null;
|
|
503
|
+
if (jumpTimer.current) {
|
|
504
|
+
clearTimeout(jumpTimer.current);
|
|
505
|
+
jumpTimer.current = null;
|
|
506
|
+
}
|
|
507
|
+
}, []);
|
|
508
|
+
useEffect(() => () => endJump(), [endJump]);
|
|
509
|
+
|
|
510
|
+
const handleValueChange = useCallback(
|
|
511
|
+
(next: string) => {
|
|
512
|
+
jumpTo.current = next;
|
|
513
|
+
if (jumpTimer.current) clearTimeout(jumpTimer.current);
|
|
514
|
+
// A backstop, not the usual way out: a jump to a section the scroller
|
|
515
|
+
// cannot reach never arrives, and without this nothing would tick again.
|
|
516
|
+
jumpTimer.current = setTimeout(endJump, JUMP_TIMEOUT);
|
|
517
|
+
if (!isControlled) setInternalValue(next);
|
|
518
|
+
onValueChange?.(next);
|
|
519
|
+
setOpen(false);
|
|
520
|
+
},
|
|
521
|
+
[isControlled, onValueChange, setOpen, endJump]
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
/*
|
|
525
|
+
* Fired from the resolved value rather than from the handler, so a section
|
|
526
|
+
* arrived at by scrolling is felt as well as one that was tapped. The ref
|
|
527
|
+
* skips the first run — mounting is not a change of section.
|
|
528
|
+
*/
|
|
529
|
+
const ticked = useRef(false);
|
|
530
|
+
useEffect(() => {
|
|
531
|
+
if (!ticked.current) {
|
|
532
|
+
ticked.current = true;
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (jumpTo.current !== null) {
|
|
536
|
+
if (value !== jumpTo.current) return;
|
|
537
|
+
endJump();
|
|
538
|
+
}
|
|
539
|
+
if (haptics) selectionTick();
|
|
540
|
+
}, [value, haptics, endJump]);
|
|
541
|
+
|
|
542
|
+
/* ------------------------------------------------------------------ *
|
|
543
|
+
* What a screen reader is told
|
|
544
|
+
* ------------------------------------------------------------------ */
|
|
545
|
+
|
|
546
|
+
/*
|
|
547
|
+
* Rounded to five, and pushed across only when it changes: the ring is a
|
|
548
|
+
* continuous value and an announcement is not, so the number is coarse on
|
|
549
|
+
* purpose rather than accurate to a percent nobody can act on.
|
|
550
|
+
*/
|
|
551
|
+
const [percent, setPercent] = useState(0);
|
|
552
|
+
useAnimatedReaction(
|
|
553
|
+
() => Math.round(filled.value * 20) * 5,
|
|
554
|
+
(next, previous) => {
|
|
555
|
+
if (next !== previous) runOnJS(setPercent)(next);
|
|
556
|
+
}
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const activeLabel = typeof active?.label === 'string' ? active.label : undefined;
|
|
560
|
+
|
|
561
|
+
const context = useMemo<SectionProgressContextValue>(
|
|
562
|
+
() => ({ value: active?.value, onValueChange: handleValueChange, close, tint }),
|
|
563
|
+
[active?.value, handleValueChange, close, tint]
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
/*
|
|
567
|
+
* Where the card sits, as padding on a full-screen overlay rather than as a
|
|
568
|
+
* position on the card itself.
|
|
569
|
+
*
|
|
570
|
+
* The overlay is what the dismiss layer needs: a press anywhere outside the
|
|
571
|
+
* card has to put the list away, and "anywhere" is the whole screen. It
|
|
572
|
+
* takes no touches of its own, so everything under it still scrolls.
|
|
573
|
+
*/
|
|
574
|
+
const atTop = placement.startsWith('top');
|
|
575
|
+
|
|
576
|
+
return (
|
|
577
|
+
<SectionProgressContext.Provider value={context}>
|
|
578
|
+
<View
|
|
579
|
+
pointerEvents={revealed ? 'box-none' : 'none'}
|
|
580
|
+
style={[
|
|
581
|
+
StyleSheet.absoluteFill,
|
|
582
|
+
{
|
|
583
|
+
paddingTop: insets.top + offset,
|
|
584
|
+
paddingBottom: insets.bottom + offset,
|
|
585
|
+
paddingLeft: insets.left + offset,
|
|
586
|
+
paddingRight: insets.right + offset,
|
|
587
|
+
justifyContent: atTop ? 'flex-start' : 'flex-end',
|
|
588
|
+
},
|
|
589
|
+
]}
|
|
590
|
+
className={cn(ALIGNMENT[placement], className)}
|
|
591
|
+
{...props}
|
|
592
|
+
>
|
|
593
|
+
{open ? (
|
|
594
|
+
<Pressable
|
|
595
|
+
accessibilityRole="button"
|
|
596
|
+
accessibilityLabel="Close"
|
|
597
|
+
onPress={close}
|
|
598
|
+
/* Outside the card's padding, so it covers the screen rather than
|
|
599
|
+
the space left inside the insets. */
|
|
600
|
+
style={{
|
|
601
|
+
position: 'absolute',
|
|
602
|
+
top: -(insets.top + offset),
|
|
603
|
+
bottom: -(insets.bottom + offset),
|
|
604
|
+
left: -(insets.left + offset),
|
|
605
|
+
right: -(insets.right + offset),
|
|
606
|
+
}}
|
|
607
|
+
/>
|
|
608
|
+
) : null}
|
|
609
|
+
|
|
610
|
+
{/*
|
|
611
|
+
* One box around the list and the pill.
|
|
612
|
+
*
|
|
613
|
+
* It carries the border, the background and the shadow; everything
|
|
614
|
+
* inside it is a row. Drawn as two boxes the pill would read as a
|
|
615
|
+
* control the list had landed on rather than as the thing the list
|
|
616
|
+
* grew out of — and two outlines a few points apart is the seam that
|
|
617
|
+
* makes a floating control look assembled.
|
|
618
|
+
*/}
|
|
619
|
+
<Animated.View
|
|
620
|
+
/*
|
|
621
|
+
* No layout animation on this box, deliberately.
|
|
622
|
+
*
|
|
623
|
+
* A layout animation here animates every change of its size, and the
|
|
624
|
+
* size changes on every change of section: the label is a different
|
|
625
|
+
* word and the pill is a different width. What that draws is the
|
|
626
|
+
* surface arriving at the old width and closing in on the new word
|
|
627
|
+
* over a few hundred milliseconds — a band of empty card down each
|
|
628
|
+
* side of the label, once per section, on a control whose entire job
|
|
629
|
+
* is to be glanced at.
|
|
630
|
+
*
|
|
631
|
+
* The width belongs to the word, so it changes with the word. Only
|
|
632
|
+
* the opening is animated, and it is animated by the parts that
|
|
633
|
+
* open: the corner rounds off through `cardStyle`, and the list
|
|
634
|
+
* fades in over it.
|
|
635
|
+
*/
|
|
636
|
+
style={[
|
|
637
|
+
revealStyle,
|
|
638
|
+
cardStyle,
|
|
639
|
+
{
|
|
640
|
+
borderWidth: CARD_BORDER,
|
|
641
|
+
minWidth: open ? LIST_MIN_WIDTH : undefined,
|
|
642
|
+
maxWidth: LIST_MAX_WIDTH,
|
|
643
|
+
},
|
|
644
|
+
]}
|
|
645
|
+
className="border-border bg-popover shadow-lg"
|
|
646
|
+
>
|
|
647
|
+
{/* The section's colour, washed across the whole card. Inset by the
|
|
648
|
+
border rather than over it, and rounded one point tighter, so the
|
|
649
|
+
outline stays a single even hairline through the curves. */}
|
|
650
|
+
<Animated.View
|
|
651
|
+
pointerEvents="none"
|
|
652
|
+
style={[washStyle, StyleSheet.absoluteFill, { opacity: 0.09 }]}
|
|
653
|
+
/>
|
|
654
|
+
|
|
655
|
+
{open && !atTop ? (
|
|
656
|
+
<SectionProgressList reduceMotion={reduceMotion}>{children}</SectionProgressList>
|
|
657
|
+
) : null}
|
|
658
|
+
|
|
659
|
+
<Pressable
|
|
660
|
+
accessibilityRole="button"
|
|
661
|
+
accessibilityLabel={
|
|
662
|
+
activeLabel ? `${label}. ${activeLabel}. ${percent}% read.` : label
|
|
663
|
+
}
|
|
664
|
+
accessibilityState={{ expanded: open }}
|
|
665
|
+
onPress={() => setOpen(!open)}
|
|
666
|
+
className="flex-row items-center gap-2.5 py-1.5 pe-4 ps-2"
|
|
667
|
+
>
|
|
668
|
+
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
|
|
669
|
+
<Svg width={RING_SIZE} height={RING_SIZE}>
|
|
670
|
+
<G>
|
|
671
|
+
<Circle
|
|
672
|
+
cx={centre}
|
|
673
|
+
cy={centre}
|
|
674
|
+
r={radius}
|
|
675
|
+
stroke={track}
|
|
676
|
+
strokeWidth={RING_STROKE}
|
|
677
|
+
fill="none"
|
|
678
|
+
/>
|
|
679
|
+
<AnimatedCircle
|
|
680
|
+
animatedProps={ringProps}
|
|
681
|
+
cx={centre}
|
|
682
|
+
cy={centre}
|
|
683
|
+
r={radius}
|
|
684
|
+
strokeWidth={RING_STROKE}
|
|
685
|
+
strokeLinecap="round"
|
|
686
|
+
fill="none"
|
|
687
|
+
transform={rotate}
|
|
688
|
+
/>
|
|
689
|
+
</G>
|
|
690
|
+
</Svg>
|
|
691
|
+
</View>
|
|
692
|
+
|
|
693
|
+
<Animated.Text
|
|
694
|
+
accessibilityElementsHidden
|
|
695
|
+
importantForAccessibility="no-hide-descendants"
|
|
696
|
+
style={tintStyle}
|
|
697
|
+
// No `flex-1`: the card is sized by its contents, and a flex
|
|
698
|
+
// child inside an auto-width row takes a basis of zero — which
|
|
699
|
+
// is a label of no width at all.
|
|
700
|
+
className="text-sm font-medium"
|
|
701
|
+
numberOfLines={1}
|
|
702
|
+
>
|
|
703
|
+
{active?.label}
|
|
704
|
+
</Animated.Text>
|
|
705
|
+
</Pressable>
|
|
706
|
+
|
|
707
|
+
{open && atTop ? (
|
|
708
|
+
<SectionProgressList reduceMotion={reduceMotion}>{children}</SectionProgressList>
|
|
709
|
+
) : null}
|
|
710
|
+
</Animated.View>
|
|
711
|
+
</View>
|
|
712
|
+
</SectionProgressContext.Provider>
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* The sections.
|
|
718
|
+
*
|
|
719
|
+
* No rule between the list and the pill's row. The rows are already separated
|
|
720
|
+
* from the row below by their own gap and by the fill on the active one, and a
|
|
721
|
+
* hairline across a card this small draws a second edge a few points inside
|
|
722
|
+
* the one the card already has.
|
|
723
|
+
*
|
|
724
|
+
* It fades in and is gone on close, with no exit animation. An exiting
|
|
725
|
+
* animation keeps the view mounted as a snapshot while the card re-lays-out
|
|
726
|
+
* around it — the card shrinks to the pill under a list that is still on
|
|
727
|
+
* screen, so the control shifts and settles back over the following frames.
|
|
728
|
+
* Closing is one change, in one frame.
|
|
729
|
+
*/
|
|
730
|
+
function SectionProgressList({
|
|
731
|
+
children,
|
|
732
|
+
reduceMotion,
|
|
733
|
+
}: {
|
|
734
|
+
children: ReactNode;
|
|
735
|
+
reduceMotion: boolean;
|
|
736
|
+
}) {
|
|
737
|
+
return (
|
|
738
|
+
<Animated.View
|
|
739
|
+
entering={reduceMotion ? undefined : FadeIn.duration(LIST_FADE)}
|
|
740
|
+
accessibilityRole="menu"
|
|
741
|
+
>
|
|
742
|
+
<ScrollView
|
|
743
|
+
bounces={false}
|
|
744
|
+
showsVerticalScrollIndicator={false}
|
|
745
|
+
// The list is as tall as its rows, up to six of them. Both halves are
|
|
746
|
+
// needed: `flexGrow: 0` to undo the ScrollView's own base style, and
|
|
747
|
+
// the cap so a screen with twenty sections still opens a list rather
|
|
748
|
+
// than a full-height column.
|
|
749
|
+
style={{ flexGrow: 0, maxHeight: LIST_MAX_HEIGHT }}
|
|
750
|
+
contentContainerClassName="gap-0.5 p-1.5"
|
|
751
|
+
>
|
|
752
|
+
{textChildren(children)}
|
|
753
|
+
</ScrollView>
|
|
754
|
+
</Animated.View>
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
export interface SectionProgressItemProps {
|
|
760
|
+
className?: string;
|
|
761
|
+
/** Section this row jumps to. Matches the root's `value`. */
|
|
762
|
+
value: string;
|
|
763
|
+
/**
|
|
764
|
+
* The colour this section brings to the pill. Left out, the section takes
|
|
765
|
+
* the foreground colour like every other.
|
|
766
|
+
*/
|
|
767
|
+
color?: SectionProgressColor;
|
|
768
|
+
/** The section's title. It is what the collapsed pill shows. */
|
|
769
|
+
children: ReactNode;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* One section: a row in the panel, and the label the pill shows while that
|
|
774
|
+
* section is the one being read.
|
|
775
|
+
*/
|
|
776
|
+
function SectionProgressItem({ className, value, children }: SectionProgressItemProps) {
|
|
777
|
+
const { value: active, onValueChange, tint } = useSectionProgress('SectionProgress.Item');
|
|
778
|
+
const selected = active === value;
|
|
779
|
+
|
|
780
|
+
return (
|
|
781
|
+
<Pressable
|
|
782
|
+
accessibilityRole="menuitem"
|
|
783
|
+
accessibilityState={{ selected }}
|
|
784
|
+
onPress={() => onValueChange(value)}
|
|
785
|
+
className={cn('flex-row items-center gap-3 rounded-xl px-3 py-2 active:bg-accent', selected && 'bg-accent', className)}
|
|
786
|
+
>
|
|
787
|
+
{/* The dot is the position marker the pill's ring cannot be at this
|
|
788
|
+
size — filled and in the section's own colour when it is the one
|
|
789
|
+
being read, a hairline dot otherwise. */}
|
|
790
|
+
<View
|
|
791
|
+
style={selected ? { backgroundColor: tint } : undefined}
|
|
792
|
+
className={cn('h-1.5 w-1.5 rounded-full', !selected && 'bg-muted-foreground/40')}
|
|
793
|
+
/>
|
|
794
|
+
<Text
|
|
795
|
+
size="sm"
|
|
796
|
+
weight={selected ? 'medium' : 'normal'}
|
|
797
|
+
className={selected ? 'text-foreground' : 'text-muted-foreground'}
|
|
798
|
+
// Two lines rather than one: an ellipsis in a navigator hides the very
|
|
799
|
+
// word that says which section the row would jump to.
|
|
800
|
+
numberOfLines={2}
|
|
801
|
+
>
|
|
802
|
+
{children}
|
|
803
|
+
</Text>
|
|
804
|
+
</Pressable>
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
SectionProgressRoot.displayName = 'SectionProgress';
|
|
809
|
+
SectionProgressItem.displayName = 'SectionProgress.Item';
|
|
810
|
+
|
|
811
|
+
export const SectionProgress = Object.assign(SectionProgressRoot, {
|
|
812
|
+
Item: SectionProgressItem,
|
|
813
|
+
});
|