panelui-native 0.59.0 → 0.60.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.
@@ -0,0 +1,1027 @@
1
+ /**
2
+ * SelectionMode — turning a list into one you can pick several things out of.
3
+ *
4
+ * ```tsx
5
+ * <SelectionMode values={ids} onSelectedChange={setSelected}>
6
+ * <SelectionMode.Header title="Choose" />
7
+ * <FlashList
8
+ * data={threads}
9
+ * renderItem={({ item }) => (
10
+ * <SelectionMode.Item value={item.id} onPress={() => open(item)}>
11
+ * <Item>…</Item>
12
+ * </SelectionMode.Item>
13
+ * )}
14
+ * />
15
+ * <SelectionMode.Bar>
16
+ * <SelectionMode.Action icon={<TrashIcon size={20} />} destructive onPress={remove}>
17
+ * Delete
18
+ * </SelectionMode.Action>
19
+ * </SelectionMode.Bar>
20
+ * </SelectionMode>
21
+ * ```
22
+ *
23
+ * ## Two ways to present it
24
+ *
25
+ * On a screen it is a *mode*: the list is there to be read, and a long press
26
+ * turns it into one you can pick from. In a sheet it is a *picker*:
27
+ * `SelectionMode.Sheet` was opened in order to choose something, so it is
28
+ * choosing from the moment it appears, with the actions in the sheet's footer.
29
+ *
30
+ * ## The items stay yours
31
+ *
32
+ * `SelectionMode.Item` wraps whatever you put in it rather than replacing it.
33
+ * It adds the circle and takes over what a press means; what the item looks
34
+ * like is yours. That is what lets one component hold a row of people, a grid
35
+ * of colours, a run of slides and a list of files without growing a prop for
36
+ * each of them.
37
+ *
38
+ * ## A mode has to be obvious
39
+ *
40
+ * There are two states and the list behaves differently in each: normally a tap
41
+ * opens a row, and in selection a tap picks it. That is only safe if leaving is
42
+ * always available and never hidden — hence a cancel in the header, the Android
43
+ * back button, and the count in front of the reader the whole time.
44
+ *
45
+ * Entering is a long press on a row, which is the gesture the platform has used
46
+ * for this for fifteen years, and the row you pressed is the first one picked.
47
+ * Entering with nothing selected leaves the reader in a changed list with no
48
+ * explanation of what changed.
49
+ *
50
+ * ## Selection is a set of values, not of rows
51
+ *
52
+ * The component holds ids, never indices or elements. A list that reorders,
53
+ * pages in more rows or drops one underneath the reader would invalidate
54
+ * anything positional; a set of ids survives all three, and is also the shape
55
+ * the action at the end needs — deleting takes ids.
56
+ */
57
+ import {
58
+ Children,
59
+ createContext,
60
+ isValidElement,
61
+ useCallback,
62
+ useContext,
63
+ useEffect,
64
+ useMemo,
65
+ useRef,
66
+ useState,
67
+ type ReactNode,
68
+ } from 'react';
69
+ import { Pressable, View, type ViewProps } from 'react-native';
70
+ import Animated, {
71
+ FadeIn,
72
+ FadeOut,
73
+ SlideInDown,
74
+ SlideOutDown,
75
+ useAnimatedStyle,
76
+ useReducedMotion,
77
+ useSharedValue,
78
+ withSpring,
79
+ withTiming,
80
+ } from 'react-native-reanimated';
81
+ import { tv, type VariantProps } from 'tailwind-variants';
82
+ import { useCSSVariable } from 'uniwind';
83
+ import { useBackHandler } from '../../hooks/use-back-handler';
84
+ import { CheckIcon, IconColorProvider, XIcon } from '../../icons';
85
+ import { AnimatedPressable } from '../../primitives/animated-pressable';
86
+ import { Text, textChildren } from '../../primitives/text';
87
+ import { cn } from '../../utils/cn';
88
+ import { selectionTick } from '../../utils/haptics';
89
+ import { BottomSheet } from '../bottom-sheet';
90
+
91
+ /** How long the circle takes to come and go, in milliseconds. */
92
+ const REVEAL_DURATION = 180;
93
+
94
+ /** The spring the tick lands with — the same one the checkbox uses. */
95
+ const TICK_SPRING = { damping: 15, stiffness: 300, mass: 0.5 } as const;
96
+
97
+ /** How far a floating action bar sits from the edges, in points. */
98
+ const DEFAULT_BAR_OFFSET = 16;
99
+
100
+ /**
101
+ * Fill a height that is offered, and take the content's own when none is.
102
+ *
103
+ * `flexBasis: 'auto'` rather than the `0` that `flex: 1` sets. A `flex: 1` box
104
+ * inside a parent of indefinite height resolves to *nothing* — its basis is
105
+ * zero and there is no free space to grow into — so a selection list dropped
106
+ * into a scrolling page would collapse to a hairline instead of showing its
107
+ * rows.
108
+ */
109
+ const FILL = { flexGrow: 1, flexShrink: 1, flexBasis: 'auto' } as const;
110
+
111
+ const selectionVariants = tv({
112
+ slots: {
113
+ circle: 'h-6 w-6 items-center justify-center rounded-full border-2 border-muted-foreground',
114
+ fill: 'absolute inset-0 items-center justify-center rounded-full bg-primary',
115
+ header: 'h-14 flex-row items-center gap-3 border-b border-border px-4',
116
+ title: 'flex-1 text-center text-base font-semibold text-foreground',
117
+ close: 'h-10 w-10 items-center justify-center rounded-full bg-muted',
118
+ group: 'overflow-hidden rounded-2xl bg-card',
119
+ ring: 'rounded-full border-2 border-transparent p-0.5',
120
+ bar: 'flex-row items-stretch',
121
+ action: 'flex-1 items-center justify-center gap-1.5 px-2 py-3',
122
+ actionLabel: 'text-xs font-medium text-foreground',
123
+ },
124
+ variants: {
125
+ selected: {
126
+ true: { circle: 'border-primary', ring: 'border-foreground' },
127
+ },
128
+ destructive: {
129
+ true: { actionLabel: 'text-destructive' },
130
+ },
131
+ disabled: {
132
+ true: { action: 'opacity-[0.44]' },
133
+ },
134
+ /**
135
+ * Flush to the bottom edge, or lifted off it.
136
+ *
137
+ * `bar` is the platform shape — full width against the edge, a hairline
138
+ * along the top, and the same background as the screen's own chrome. It is
139
+ * the default because it is what a list with a selection in it does on both
140
+ * platforms, and because it does not take width away from the list.
141
+ */
142
+ placement: {
143
+ bar: { bar: 'border-t border-border bg-popover' },
144
+ floating: { bar: 'rounded-2xl border border-border bg-popover shadow-lg' },
145
+ },
146
+ },
147
+ defaultVariants: {
148
+ placement: 'bar',
149
+ },
150
+ });
151
+
152
+ type SelectionVariantProps = VariantProps<typeof selectionVariants>;
153
+
154
+ interface SelectionModeContextValue {
155
+ active: boolean;
156
+ enter: (value?: string) => void;
157
+ exit: () => void;
158
+ selected: string[];
159
+ isSelected: (value: string) => boolean;
160
+ toggle: (value: string) => void;
161
+ selectAll: () => void;
162
+ clear: () => void;
163
+ /** True when everything selectable is picked, and there is something to pick. */
164
+ allSelected: boolean;
165
+ count: number;
166
+ /** How many rows `values` says there are, or 0 when it was not given. */
167
+ total: number;
168
+ max?: number;
169
+ haptics: boolean;
170
+ /**
171
+ * Whether the selection is being presented in a sheet.
172
+ *
173
+ * A sheet is opened *in order to* pick something, so there is no mode to
174
+ * enter and nothing to long-press for — and the action bar belongs to the
175
+ * sheet's footer rather than floating over the screen.
176
+ */
177
+ sheet: boolean;
178
+ }
179
+
180
+ const SelectionModeContext = createContext<SelectionModeContextValue | null>(null);
181
+
182
+ /**
183
+ * Read the selection from anywhere inside a `SelectionMode` — for a header of
184
+ * your own, a count somewhere else on the screen, or an action that has to know
185
+ * what is picked.
186
+ */
187
+ export function useSelectionMode(): SelectionModeContextValue {
188
+ const context = useContext(SelectionModeContext);
189
+ if (!context) {
190
+ throw new Error('useSelectionMode must be used within a <SelectionMode>');
191
+ }
192
+ return context;
193
+ }
194
+
195
+ export interface SelectionModeProps extends ViewProps {
196
+ className?: string;
197
+ /**
198
+ * Every value that can be picked, in list order.
199
+ *
200
+ * Only "select all" and the "n of m" in the header need it — picking rows one
201
+ * at a time works without it. Give it the same ids you give the list.
202
+ */
203
+ values?: string[];
204
+ /** Controlled selection mode. Leave it out and a long press turns it on. */
205
+ active?: boolean;
206
+ /** Whether selection mode starts on. */
207
+ defaultActive?: boolean;
208
+ onActiveChange?: (active: boolean) => void;
209
+ /** Controlled selection. */
210
+ selected?: string[];
211
+ defaultSelected?: string[];
212
+ onSelectedChange?: (selected: string[]) => void;
213
+ /**
214
+ * The most that can be picked at once.
215
+ *
216
+ * A row that would go over it does not toggle on, and "select all" stops at
217
+ * the limit rather than refusing. Leave it out for no limit.
218
+ */
219
+ max?: number;
220
+ /**
221
+ * A tick when a row is picked and when the mode is entered. Off by default —
222
+ * needs the optional `expo-haptics`, and is silent without it.
223
+ */
224
+ haptics?: boolean;
225
+ children: ReactNode;
226
+ }
227
+
228
+ function SelectionModeRoot({
229
+ className,
230
+ values,
231
+ active: activeProp,
232
+ defaultActive = false,
233
+ onActiveChange,
234
+ selected: selectedProp,
235
+ defaultSelected,
236
+ onSelectedChange,
237
+ max,
238
+ haptics = false,
239
+ children,
240
+ ...props
241
+ }: SelectionModeProps) {
242
+ const [internalActive, setInternalActive] = useState(defaultActive);
243
+ const [internalSelected, setInternalSelected] = useState<string[]>(defaultSelected ?? []);
244
+
245
+ const active = activeProp ?? internalActive;
246
+ const selected = selectedProp ?? internalSelected;
247
+
248
+ const selectedRef = useRef(selected);
249
+ selectedRef.current = selected;
250
+
251
+ const setSelected = useCallback(
252
+ (next: string[]) => {
253
+ if (selectedProp === undefined) setInternalSelected(next);
254
+ onSelectedChange?.(next);
255
+ },
256
+ [selectedProp, onSelectedChange]
257
+ );
258
+
259
+ const setActive = useCallback(
260
+ (next: boolean) => {
261
+ if (activeProp === undefined) setInternalActive(next);
262
+ onActiveChange?.(next);
263
+ },
264
+ [activeProp, onActiveChange]
265
+ );
266
+
267
+ const enter = useCallback(
268
+ (value?: string) => {
269
+ if (haptics) selectionTick();
270
+ setActive(true);
271
+ // Entering with the row that was pressed already picked. Entering with
272
+ // nothing picked leaves the reader in a list that has changed under them
273
+ // with nothing to show for it.
274
+ if (value !== undefined && !selectedRef.current.includes(value)) {
275
+ setSelected([...selectedRef.current, value]);
276
+ }
277
+ },
278
+ [haptics, setActive, setSelected]
279
+ );
280
+
281
+ /*
282
+ * Leaving clears the selection.
283
+ *
284
+ * A selection that outlived the mode would come back the next time it was
285
+ * entered, and the reader who left by pressing cancel is exactly the reader
286
+ * who meant "not those". Keep it across a mode change by controlling
287
+ * `selected` yourself.
288
+ */
289
+ const exit = useCallback(() => {
290
+ setActive(false);
291
+ setSelected([]);
292
+ }, [setActive, setSelected]);
293
+
294
+ const isSelected = useCallback(
295
+ (value: string) => selectedRef.current.includes(value),
296
+ []
297
+ );
298
+
299
+ const toggle = useCallback(
300
+ (value: string) => {
301
+ const current = selectedRef.current;
302
+ if (current.includes(value)) {
303
+ setSelected(current.filter((entry) => entry !== value));
304
+ } else {
305
+ if (max !== undefined && current.length >= max) return;
306
+ if (haptics) selectionTick();
307
+ setSelected([...current, value]);
308
+ }
309
+ },
310
+ [max, haptics, setSelected]
311
+ );
312
+
313
+ const selectAll = useCallback(() => {
314
+ if (!values) return;
315
+ // At the limit rather than refusing: somebody who asked for all of them and
316
+ // can only have twenty wants the twenty, not an error.
317
+ setSelected(max === undefined ? [...values] : values.slice(0, max));
318
+ }, [values, max, setSelected]);
319
+
320
+ const clear = useCallback(() => setSelected([]), [setSelected]);
321
+
322
+ const total = values?.length ?? 0;
323
+ const count = selected.length;
324
+ const allSelected =
325
+ total > 0 && count >= (max === undefined ? total : Math.min(total, max));
326
+
327
+ // An open mode owns the back button: back should leave the mode, not the
328
+ // screen the list is on.
329
+ useBackHandler(active, exit);
330
+
331
+ const context = useMemo<SelectionModeContextValue>(
332
+ () => ({
333
+ active,
334
+ enter,
335
+ exit,
336
+ selected,
337
+ isSelected,
338
+ toggle,
339
+ selectAll,
340
+ clear,
341
+ allSelected,
342
+ count,
343
+ total,
344
+ max,
345
+ haptics,
346
+ sheet: false,
347
+ }),
348
+ [
349
+ active,
350
+ enter,
351
+ exit,
352
+ selected,
353
+ isSelected,
354
+ toggle,
355
+ selectAll,
356
+ clear,
357
+ allSelected,
358
+ count,
359
+ total,
360
+ max,
361
+ haptics,
362
+ ]
363
+ );
364
+
365
+ return (
366
+ <SelectionModeContext.Provider value={context}>
367
+ <View style={FILL} className={className} {...props}>
368
+ {textChildren(children)}
369
+ </View>
370
+ </SelectionModeContext.Provider>
371
+ );
372
+ }
373
+
374
+ /* -------------------------------------------------------------------------- *
375
+ * Indicator
376
+ * -------------------------------------------------------------------------- */
377
+
378
+ export interface SelectionModeIndicatorProps {
379
+ className?: string;
380
+ /** Which row this stands for. Defaults to the row it is inside. */
381
+ value?: string;
382
+ }
383
+
384
+ /**
385
+ * The circle at the left of a row.
386
+ *
387
+ * Round rather than square, and that is the convention doing real work: a
388
+ * square box is a form control the reader is filling in, a round one is a thing
389
+ * they are picking out of a list. `Checkbox` is the former and stays that way.
390
+ *
391
+ * `Item` draws one for you. This is exported for a row that wants it somewhere
392
+ * else — over a photo's corner, at the end instead of the start.
393
+ */
394
+ function SelectionModeIndicator({ className, value }: SelectionModeIndicatorProps) {
395
+ const { isSelected } = useSelectionMode();
396
+ const row = useContext(SelectionModeItemContext);
397
+ const target = value ?? row?.value;
398
+ const selected = target !== undefined && isSelected(target);
399
+
400
+ const reducedMotion = useReducedMotion();
401
+ const progress = useSharedValue(selected ? 1 : 0);
402
+ const tickColor = useCSSVariable('--color-primary-foreground');
403
+ const slots = selectionVariants({ selected });
404
+
405
+ useEffect(() => {
406
+ if (reducedMotion) {
407
+ progress.value = selected ? 1 : 0;
408
+ return;
409
+ }
410
+ progress.value = selected
411
+ ? withSpring(1, TICK_SPRING)
412
+ : withTiming(0, { duration: 120 });
413
+ }, [selected, reducedMotion, progress]);
414
+
415
+ const fillStyle = useAnimatedStyle(() => ({
416
+ opacity: progress.value,
417
+ transform: [{ scale: 0.6 + progress.value * 0.4 }],
418
+ }));
419
+
420
+ return (
421
+ <View className={cn(slots.circle(), className)}>
422
+ <Animated.View style={fillStyle} className={slots.fill()}>
423
+ <CheckIcon size={14} color={typeof tickColor === 'string' ? tickColor : '#fff'} />
424
+ </Animated.View>
425
+ </View>
426
+ );
427
+ }
428
+
429
+ /* -------------------------------------------------------------------------- *
430
+ * Item
431
+ * -------------------------------------------------------------------------- */
432
+
433
+ /** What an indicator inside a row needs to know, without being told twice. */
434
+ const SelectionModeItemContext = createContext<{ value: string } | null>(null);
435
+
436
+ export interface SelectionModeItemProps extends Omit<ViewProps, 'children'> {
437
+ className?: string;
438
+ /** This row's id. What ends up in `selected`. */
439
+ value: string;
440
+ /** What the row does when it is pressed and the mode is off. */
441
+ onPress?: () => void;
442
+ /**
443
+ * Stop this row entering selection mode, and being picked once in it. For a
444
+ * header row, an advert, a "load more" — anything in the list that is not one
445
+ * of the things being chosen between.
446
+ */
447
+ disabled?: boolean;
448
+ /** Draw the circle without waiting for the mode. */
449
+ alwaysShowIndicator?: boolean;
450
+ /**
451
+ * How being picked is drawn.
452
+ *
453
+ * `leading` puts the circle in front of the item, which is what a row wants.
454
+ * `ring` draws a ring around whatever you gave it instead — for a swatch, a
455
+ * thumbnail or a photo, where a circle beside it would be a second thing to
456
+ * look at and the item itself can carry the state. `none` draws nothing and
457
+ * leaves it to you; read `useSelectionMode().isSelected`.
458
+ */
459
+ indicator?: 'leading' | 'ring' | 'none';
460
+ children: ReactNode;
461
+ }
462
+
463
+ /**
464
+ * One row, with the circle in front of it.
465
+ *
466
+ * The press behaviour is the whole component: off mode, a press is the row's
467
+ * own and a long press turns the mode on with this row picked; in it, a press
468
+ * picks and unpicks and the row's own press is unreachable. Two meanings for
469
+ * one gesture is exactly why the mode has to be visible from the header.
470
+ */
471
+ function SelectionModeItem({
472
+ className,
473
+ value,
474
+ onPress,
475
+ disabled = false,
476
+ alwaysShowIndicator = false,
477
+ indicator = 'leading',
478
+ children,
479
+ ...props
480
+ }: SelectionModeItemProps) {
481
+ const { active, enter, toggle, isSelected, sheet } = useSelectionMode();
482
+ const selected = isSelected(value);
483
+ const showing = alwaysShowIndicator || active;
484
+ const reducedMotion = useReducedMotion();
485
+
486
+ const context = useMemo(() => ({ value }), [value]);
487
+
488
+ return (
489
+ <SelectionModeItemContext.Provider value={context}>
490
+ <Pressable
491
+ accessibilityRole={showing ? 'checkbox' : 'button'}
492
+ accessibilityState={showing ? { checked: selected, disabled } : { disabled }}
493
+ // The row is unreachable in selection mode, so a screen reader is told
494
+ // what pressing it does now rather than what it used to do.
495
+ accessibilityHint={
496
+ showing ? undefined : disabled ? undefined : 'Long press to select'
497
+ }
498
+ disabled={disabled && showing}
499
+ onPress={() => {
500
+ if (disabled) return;
501
+ if (showing) toggle(value);
502
+ else onPress?.();
503
+ }}
504
+ onLongPress={sheet ? undefined : () => {
505
+ if (disabled || showing) return;
506
+ enter(value);
507
+ }}
508
+ className={cn(
509
+ indicator === 'leading' ? 'flex-row items-center gap-3 px-4 py-2.5' : '',
510
+ className
511
+ )}
512
+ {...props}
513
+ >
514
+ {indicator === 'leading' ? (
515
+ <>
516
+ {showing ? (
517
+ <Animated.View
518
+ entering={reducedMotion ? undefined : FadeIn.duration(REVEAL_DURATION)}
519
+ exiting={reducedMotion ? undefined : FadeOut.duration(REVEAL_DURATION)}
520
+ >
521
+ <SelectionModeIndicator value={value} />
522
+ </Animated.View>
523
+ ) : null}
524
+ {/*
525
+ * `minWidth: 0` as well as growing. Without it a long title refuses
526
+ * to be narrower than its own text, pushes the row past the screen
527
+ * and takes the layout with it — which is what a flex child does by
528
+ * default, and why a name ends up broken across lines mid-word.
529
+ */}
530
+ <View style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
531
+ {textChildren(children)}
532
+ </View>
533
+ </>
534
+ ) : indicator === 'ring' && showing ? (
535
+ <View className={selectionVariants({ selected }).ring()}>
536
+ {textChildren(children)}
537
+ </View>
538
+ ) : (
539
+ textChildren(children)
540
+ )}
541
+ </Pressable>
542
+ </SelectionModeItemContext.Provider>
543
+ );
544
+ }
545
+
546
+ /* -------------------------------------------------------------------------- *
547
+ * Group
548
+ * -------------------------------------------------------------------------- */
549
+
550
+ export interface SelectionModeGroupProps extends ViewProps {
551
+ className?: string;
552
+ /**
553
+ * Lay the items out in a grid this many across instead of stacking them.
554
+ *
555
+ * For things recognised by sight rather than read — swatches, thumbnails,
556
+ * slides. A grid of six colours is one glance; the same six as rows is a
557
+ * scroll.
558
+ */
559
+ columns?: number;
560
+ /** Space between items in a grid, in points. */
561
+ gap?: number;
562
+ /** Hairlines between stacked items. On by default; off in a grid. */
563
+ separators?: boolean;
564
+ children: ReactNode;
565
+ }
566
+
567
+ /**
568
+ * A rounded card holding a run of items.
569
+ *
570
+ * Grouping is what makes a sheet of choices readable: one card of options with
571
+ * hairlines between them reads as a set, and the same rows loose on the sheet's
572
+ * background read as a list that has not finished loading. It is also what the
573
+ * platform's own sheets do.
574
+ *
575
+ * Stacked by default, with a rule between each item. Pass `columns` for a grid.
576
+ */
577
+ function SelectionModeGroup({
578
+ className,
579
+ columns,
580
+ gap = 12,
581
+ separators = true,
582
+ children,
583
+ style,
584
+ ...props
585
+ }: SelectionModeGroupProps) {
586
+ const items = Children.toArray(children).filter(Boolean);
587
+ const slots = selectionVariants({});
588
+
589
+ if (columns && columns > 0) {
590
+ /*
591
+ * The gap is padding inside each cell, not `gap` on the row.
592
+ *
593
+ * A row of cells `100 / columns` wide with a gap between them is wider than
594
+ * the row by the gaps, so the last column wraps and the grid loses a
595
+ * column. Padding inside the cell keeps every cell an exact share of the
596
+ * width, and the negative margin cancels the outer half so the grid still
597
+ * sits flush against whatever it is in.
598
+ */
599
+ const half = gap / 2;
600
+ return (
601
+ <View
602
+ style={[{ flexDirection: 'row', flexWrap: 'wrap', margin: -half }, style]}
603
+ className={className}
604
+ {...props}
605
+ >
606
+ {items.map((item, index) => (
607
+ <View key={index} style={{ width: `${100 / columns}%`, padding: half }}>
608
+ {item}
609
+ </View>
610
+ ))}
611
+ </View>
612
+ );
613
+ }
614
+
615
+ return (
616
+ <View className={cn(slots.group(), className)} style={style} {...props}>
617
+ {items.map((item, index) => (
618
+ <View key={index}>
619
+ {separators && index > 0 ? (
620
+ // Inset from the left so the rule starts under the text rather than
621
+ // under the circle, which is what a grouped list does.
622
+ <View className="ml-14 h-px bg-border" />
623
+ ) : null}
624
+ {item}
625
+ </View>
626
+ ))}
627
+ </View>
628
+ );
629
+ }
630
+
631
+ /* -------------------------------------------------------------------------- *
632
+ * Header
633
+ * -------------------------------------------------------------------------- */
634
+
635
+ export interface SelectionModeHeaderProps extends ViewProps {
636
+ className?: string;
637
+ /** The word in front of the count. */
638
+ title?: string;
639
+ /** Hide the select-all control, for a list where picking everything is wrong. */
640
+ hideSelectAll?: boolean;
641
+ /** Replaces the whole header's contents, keeping only its layout. */
642
+ children?: ReactNode;
643
+ }
644
+
645
+ /**
646
+ * The bar that says the mode is on: a way out, how many are picked, and all
647
+ * of them at once.
648
+ *
649
+ * Rendered only while the mode is on, and it is the thing that makes the mode
650
+ * legible — a list whose rows have quietly changed what a tap does, with no
651
+ * banner saying so, is a list that loses somebody's work.
652
+ */
653
+ function SelectionModeHeader({
654
+ className,
655
+ title = 'Select',
656
+ hideSelectAll = false,
657
+ children,
658
+ ...props
659
+ }: SelectionModeHeaderProps) {
660
+ const { active, exit, count, total, allSelected, selectAll, clear, sheet } =
661
+ useSelectionMode();
662
+ const reducedMotion = useReducedMotion();
663
+ const slots = selectionVariants({});
664
+
665
+ if (!active) return null;
666
+
667
+ return (
668
+ <Animated.View
669
+ entering={reducedMotion ? undefined : FadeIn.duration(REVEAL_DURATION)}
670
+ exiting={reducedMotion ? undefined : FadeOut.duration(REVEAL_DURATION)}
671
+ className={cn(slots.header(), className)}
672
+ {...props}
673
+ >
674
+ {children ? (
675
+ textChildren(children)
676
+ ) : (
677
+ <>
678
+ {/*
679
+ * The two ends are the same width, so the title between them is
680
+ * centred on the screen rather than on whatever is left over. A title
681
+ * that shifts sideways as the count goes from 9 to 10 reads as the
682
+ * header being rebuilt.
683
+ */}
684
+ <View className="w-20 items-start">
685
+ {/* A sheet dismisses itself — by its handle, its scrim or the back
686
+ gesture — so a second way out inside it is one too many. */}
687
+ {sheet ? null : (
688
+ <Pressable
689
+ accessibilityRole="button"
690
+ accessibilityLabel="Cancel selection"
691
+ onPress={exit}
692
+ hitSlop={12}
693
+ className={slots.close()}
694
+ >
695
+ <XIcon size={20} />
696
+ </Pressable>
697
+ )}
698
+ </View>
699
+
700
+ <Text numberOfLines={1} className={slots.title()}>
701
+ {count > 0 ? `${title} (${count})` : title}
702
+ </Text>
703
+
704
+ <View className="w-20 items-end">
705
+ {hideSelectAll || total === 0 ? null : (
706
+ <Pressable
707
+ accessibilityRole="button"
708
+ accessibilityLabel={allSelected ? 'Clear selection' : 'Select all'}
709
+ accessibilityState={{ checked: allSelected }}
710
+ onPress={allSelected ? clear : selectAll}
711
+ hitSlop={12}
712
+ >
713
+ <Text size="sm" weight="medium" className="text-primary">
714
+ {allSelected ? 'Clear' : 'All'}
715
+ </Text>
716
+ </Pressable>
717
+ )}
718
+ </View>
719
+ </>
720
+ )}
721
+ </Animated.View>
722
+ );
723
+ }
724
+
725
+ /* -------------------------------------------------------------------------- *
726
+ * Bar
727
+ * -------------------------------------------------------------------------- */
728
+
729
+ export interface SelectionModeBarProps
730
+ extends ViewProps,
731
+ Pick<SelectionVariantProps, 'placement'> {
732
+ className?: string;
733
+ /**
734
+ * Room under the actions, in points — your safe-area inset.
735
+ *
736
+ * A bar against the bottom edge sits over the home indicator on a phone that
737
+ * has one, and an action under a home indicator is an action that takes two
738
+ * tries. `floating` uses it as the gap on all four sides instead.
739
+ */
740
+ inset?: number;
741
+ /**
742
+ * Keep the bar up with nothing picked.
743
+ *
744
+ * Off by default: every action on it needs something to act on, and a row of
745
+ * buttons that all refuse is worse than a row that is not there yet.
746
+ */
747
+ showWhenEmpty?: boolean;
748
+ children: ReactNode;
749
+ }
750
+
751
+ /**
752
+ * The actions, across the bottom of the list.
753
+ *
754
+ * Over the list rather than under it, because the list is as long as it is and
755
+ * a bar in the flow would be somewhere off the end of it. **Pad the bottom of
756
+ * your list so the last row can clear this** — nothing here can work out how
757
+ * tall the list is.
758
+ *
759
+ * Flush to the edge by default. A bar inset from the sides is a card floating
760
+ * over a list, which reads as something that arrived rather than as the mode
761
+ * the screen is in — and it takes width away from the actions, which are the
762
+ * one row of controls on screen that must not be cramped.
763
+ */
764
+ function SelectionModeBar({
765
+ className,
766
+ placement,
767
+ inset = 0,
768
+ showWhenEmpty = false,
769
+ children,
770
+ style,
771
+ ...props
772
+ }: SelectionModeBarProps) {
773
+ const { active, count, sheet } = useSelectionMode();
774
+ const reducedMotion = useReducedMotion();
775
+ const floating = placement === 'floating';
776
+ const slots = selectionVariants({ placement });
777
+
778
+ if (!active || (count === 0 && !showWhenEmpty)) return null;
779
+
780
+ /*
781
+ * In a sheet the bar is the sheet's footer: it is already at the bottom of
782
+ * something, already the width of it, and the footer draws the rule above it.
783
+ * Positioning it absolutely there would take it out of the sheet's layout and
784
+ * hang it over the content instead of under it.
785
+ */
786
+ if (sheet) {
787
+ return (
788
+ <View className={cn(slots.bar(), 'border-t-0', className)} {...props}>
789
+ {textChildren(children)}
790
+ </View>
791
+ );
792
+ }
793
+
794
+ const edge = floating ? inset || DEFAULT_BAR_OFFSET : 0;
795
+
796
+ return (
797
+ <Animated.View
798
+ entering={reducedMotion ? FadeIn : SlideInDown.duration(220)}
799
+ exiting={reducedMotion ? FadeOut : SlideOutDown.duration(180)}
800
+ style={[{ position: 'absolute', left: edge, right: edge, bottom: edge }, style]}
801
+ {...props}
802
+ >
803
+ <View
804
+ // Padding rather than a margin, so the bar's own background runs all
805
+ // the way to the edge and the safe area is filled rather than left as a
806
+ // stripe of whatever is behind it.
807
+ style={floating ? undefined : { paddingBottom: inset }}
808
+ className={cn(slots.bar(), className)}
809
+ >
810
+ {textChildren(children)}
811
+ </View>
812
+ </Animated.View>
813
+ );
814
+ }
815
+
816
+ /* -------------------------------------------------------------------------- *
817
+ * Action
818
+ * -------------------------------------------------------------------------- */
819
+
820
+ export interface SelectionModeActionProps
821
+ extends Omit<ViewProps, 'children'>,
822
+ Pick<SelectionVariantProps, 'destructive'> {
823
+ className?: string;
824
+ /** The glyph above the label. */
825
+ icon?: ReactNode;
826
+ /**
827
+ * What it does. Handed the selection, so the common case needs no other
828
+ * wiring — and leaving the mode afterwards is up to you, because whether the
829
+ * list still makes sense depends on what you did to it.
830
+ */
831
+ onPress?: (selected: string[]) => void;
832
+ /** Leave selection mode after the action runs. */
833
+ exitOnPress?: boolean;
834
+ disabled?: boolean;
835
+ /** Extra classes for the label. */
836
+ labelClassName?: string;
837
+ children?: ReactNode;
838
+ }
839
+
840
+ /**
841
+ * One action in the bar: a glyph with its name under it.
842
+ *
843
+ * Labelled, always. A row of bare glyphs at the bottom of a screen is a row of
844
+ * guesses, and one of them usually deletes something.
845
+ */
846
+ function SelectionModeAction({
847
+ className,
848
+ icon,
849
+ onPress,
850
+ exitOnPress = false,
851
+ disabled = false,
852
+ destructive,
853
+ labelClassName,
854
+ children,
855
+ ...props
856
+ }: SelectionModeActionProps) {
857
+ const { selected, exit, count } = useSelectionMode();
858
+ const destructiveColor = useCSSVariable('--color-destructive');
859
+ // Nothing picked is nothing to act on, so the action is off rather than
860
+ // pressable-and-inert.
861
+ const off = disabled || count === 0;
862
+ const slots = selectionVariants({ destructive, disabled: off });
863
+
864
+ return (
865
+ <AnimatedPressable
866
+ accessibilityRole="button"
867
+ accessibilityState={{ disabled: off }}
868
+ disabled={off}
869
+ onPress={() => {
870
+ onPress?.(selected);
871
+ if (exitOnPress) exit();
872
+ }}
873
+ className={cn(slots.action(), className)}
874
+ {...props}
875
+ >
876
+ <IconColorProvider
877
+ color={destructive && typeof destructiveColor === 'string' ? destructiveColor : undefined}
878
+ >
879
+ {icon}
880
+ </IconColorProvider>
881
+ {textChildren(children, (text) => (
882
+ <Text className={cn(slots.actionLabel(), labelClassName)}>{text}</Text>
883
+ ))}
884
+ </AnimatedPressable>
885
+ );
886
+ }
887
+
888
+ /* -------------------------------------------------------------------------- *
889
+ * Sheet
890
+ * -------------------------------------------------------------------------- */
891
+
892
+ export interface SelectionModeSheetProps {
893
+ className?: string;
894
+ /** Controlled open state of the sheet. */
895
+ open?: boolean;
896
+ defaultOpen?: boolean;
897
+ onOpenChange?: (open: boolean) => void;
898
+ /** The word in front of the count. */
899
+ title?: string;
900
+ /** Hide the select-all control. */
901
+ hideSelectAll?: boolean;
902
+ /**
903
+ * How tall the sheet opens.
904
+ *
905
+ * `half` by default, and deliberately not `auto`. A sheet that sizes to its
906
+ * content gives its scrolling body no height to fill, and a list inside a box
907
+ * of no height draws nothing — which looks like an empty sheet rather than
908
+ * like a missing style. A selection is a list; give it the room.
909
+ */
910
+ size?: 'auto' | 'half' | 'full';
911
+ /**
912
+ * The things to pick between, and optionally a `SelectionMode.Bar` of
913
+ * actions. The bar is lifted into the sheet's footer wherever it is written.
914
+ */
915
+ children: ReactNode;
916
+ }
917
+
918
+ /**
919
+ * The whole selection, presented in a bottom sheet.
920
+ *
921
+ * A picker rather than a mode. The list on a screen has to be *turned into* one
922
+ * you can pick from — hence the long press, the cancel and the count — but a
923
+ * sheet was opened in order to pick something, so it is picking from the moment
924
+ * it appears and there is nothing to enter or leave.
925
+ *
926
+ * What goes in it is anything: a column of friends, a grid of colours, a run of
927
+ * slides. `SelectionMode.Item` wraps whatever you give it, so the sheet does
928
+ * not need to know what it is holding.
929
+ *
930
+ * ```tsx
931
+ * <SelectionMode values={ids} selected={selected} onSelectedChange={setSelected}>
932
+ * <SelectionMode.Sheet open={open} onOpenChange={setOpen} title="Share with">
933
+ * {people.map((person) => (
934
+ * <SelectionMode.Item key={person.id} value={person.id}>
935
+ * <Item>…</Item>
936
+ * </SelectionMode.Item>
937
+ * ))}
938
+ * <SelectionMode.Bar>
939
+ * <SelectionMode.Action icon={<SendIcon size={20} />} onPress={share}>Send</SelectionMode.Action>
940
+ * </SelectionMode.Bar>
941
+ * </SelectionMode.Sheet>
942
+ * </SelectionMode>
943
+ * ```
944
+ */
945
+ function SelectionModeSheet({
946
+ className,
947
+ open,
948
+ defaultOpen,
949
+ onOpenChange,
950
+ title = 'Select',
951
+ hideSelectAll = false,
952
+ size = 'half',
953
+ children,
954
+ }: SelectionModeSheetProps) {
955
+ const parent = useSelectionMode();
956
+
957
+ /*
958
+ * The bar is pulled out of the children and put in the sheet's footer,
959
+ * wherever it was written. A footer is a place in the sheet's layout rather
960
+ * than a thing you can position into from the middle of the body — and
961
+ * writing the bar last, after the items, is how it reads.
962
+ */
963
+ const { bar, rest } = useMemo(() => {
964
+ const others: ReactNode[] = [];
965
+ let found: ReactNode = null;
966
+ Children.forEach(children, (child) => {
967
+ if (isValidElement(child) && child.type === SelectionModeBar) found = child;
968
+ else others.push(child);
969
+ });
970
+ return { bar: found, rest: others };
971
+ }, [children]);
972
+
973
+ // Picking from the moment it opens: a sheet is not a mode to be entered.
974
+ const context = useMemo<SelectionModeContextValue>(
975
+ () => ({ ...parent, active: true, sheet: true }),
976
+ [parent]
977
+ );
978
+
979
+ return (
980
+ <BottomSheet open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
981
+ <BottomSheet.Content size={size} className={className}>
982
+ {/*
983
+ * The provider goes *inside* the sheet's content, not around the sheet.
984
+ *
985
+ * A sheet renders its content through a portal, which mounts it at the
986
+ * app root — nowhere below this component. A provider wrapped around
987
+ * the outside is therefore not an ancestor of anything in the sheet,
988
+ * and every part inside it throws for want of a context that is on
989
+ * screen but in the wrong branch of the tree.
990
+ */}
991
+ <SelectionModeContext.Provider value={context}>
992
+ <BottomSheet.Header>
993
+ {/* The sheet already draws the rule and the padding. */}
994
+ <SelectionModeHeader
995
+ title={title}
996
+ hideSelectAll={hideSelectAll}
997
+ className="h-auto border-b-0 px-0"
998
+ />
999
+ </BottomSheet.Header>
1000
+ <BottomSheet.Body contentContainerStyle={{ gap: 16, paddingBottom: 8 }}>
1001
+ {textChildren(rest)}
1002
+ </BottomSheet.Body>
1003
+ {bar ? <BottomSheet.Footer>{bar}</BottomSheet.Footer> : null}
1004
+ </SelectionModeContext.Provider>
1005
+ </BottomSheet.Content>
1006
+ </BottomSheet>
1007
+ );
1008
+ }
1009
+
1010
+ SelectionModeRoot.displayName = 'SelectionMode';
1011
+ SelectionModeSheet.displayName = 'SelectionMode.Sheet';
1012
+ SelectionModeItem.displayName = 'SelectionMode.Item';
1013
+ SelectionModeGroup.displayName = 'SelectionMode.Group';
1014
+ SelectionModeIndicator.displayName = 'SelectionMode.Indicator';
1015
+ SelectionModeHeader.displayName = 'SelectionMode.Header';
1016
+ SelectionModeBar.displayName = 'SelectionMode.Bar';
1017
+ SelectionModeAction.displayName = 'SelectionMode.Action';
1018
+
1019
+ export const SelectionMode = Object.assign(SelectionModeRoot, {
1020
+ Sheet: SelectionModeSheet,
1021
+ Group: SelectionModeGroup,
1022
+ Item: SelectionModeItem,
1023
+ Indicator: SelectionModeIndicator,
1024
+ Header: SelectionModeHeader,
1025
+ Bar: SelectionModeBar,
1026
+ Action: SelectionModeAction,
1027
+ });