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.
Files changed (39) hide show
  1. package/lib/module/components/animated-badge/index.js +10 -2
  2. package/lib/module/components/animated-badge/index.js.map +1 -1
  3. package/lib/module/components/avatar/index.js +10 -1
  4. package/lib/module/components/avatar/index.js.map +1 -1
  5. package/lib/module/components/search-bar/index.js +35 -7
  6. package/lib/module/components/search-bar/index.js.map +1 -1
  7. package/lib/module/components/section-progress/index.js +637 -0
  8. package/lib/module/components/section-progress/index.js.map +1 -0
  9. package/lib/module/components/selection-mode/index.js +33 -3
  10. package/lib/module/components/selection-mode/index.js.map +1 -1
  11. package/lib/module/hooks/index.js.map +1 -1
  12. package/lib/module/hooks/use-scroll-sections.js +58 -4
  13. package/lib/module/hooks/use-scroll-sections.js.map +1 -1
  14. package/lib/module/index.js +1 -0
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/typescript/src/components/animated-badge/index.d.ts +19 -0
  17. package/lib/typescript/src/components/animated-badge/index.d.ts.map +1 -1
  18. package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
  19. package/lib/typescript/src/components/search-bar/index.d.ts +4 -1
  20. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  21. package/lib/typescript/src/components/section-progress/index.d.ts +156 -0
  22. package/lib/typescript/src/components/section-progress/index.d.ts.map +1 -0
  23. package/lib/typescript/src/components/selection-mode/index.d.ts +63 -0
  24. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
  25. package/lib/typescript/src/hooks/index.d.ts +1 -1
  26. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  27. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +24 -0
  28. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
  29. package/lib/typescript/src/index.d.ts +1 -0
  30. package/lib/typescript/src/index.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/components/animated-badge/index.tsx +24 -2
  33. package/src/components/avatar/index.tsx +10 -1
  34. package/src/components/search-bar/index.tsx +33 -8
  35. package/src/components/section-progress/index.tsx +813 -0
  36. package/src/components/selection-mode/index.tsx +30 -3
  37. package/src/hooks/index.ts +1 -0
  38. package/src/hooks/use-scroll-sections.ts +84 -5
  39. 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
+ });