panelui-native 0.62.0 → 0.63.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 (69) hide show
  1. package/lib/module/components/area-chart/index.js +14 -4
  2. package/lib/module/components/area-chart/index.js.map +1 -1
  3. package/lib/module/components/bar-chart/index.js +14 -4
  4. package/lib/module/components/bar-chart/index.js.map +1 -1
  5. package/lib/module/components/candlestick-chart/index.js +14 -4
  6. package/lib/module/components/candlestick-chart/index.js.map +1 -1
  7. package/lib/module/components/card/index.js +26 -1
  8. package/lib/module/components/card/index.js.map +1 -1
  9. package/lib/module/components/dialog/index.js +49 -3
  10. package/lib/module/components/dialog/index.js.map +1 -1
  11. package/lib/module/components/hex-chart/index.js +2 -2
  12. package/lib/module/components/hex-chart/index.js.map +1 -1
  13. package/lib/module/components/line-chart/index.js +14 -4
  14. package/lib/module/components/line-chart/index.js.map +1 -1
  15. package/lib/module/components/pie-chart/index.js +2 -2
  16. package/lib/module/components/pie-chart/index.js.map +1 -1
  17. package/lib/module/components/plot/index.js +1329 -0
  18. package/lib/module/components/plot/index.js.map +1 -0
  19. package/lib/module/components/polar-area-chart/index.js +2 -2
  20. package/lib/module/components/polar-area-chart/index.js.map +1 -1
  21. package/lib/module/components/radar-chart/index.js +14 -4
  22. package/lib/module/components/radar-chart/index.js.map +1 -1
  23. package/lib/module/components/ring-chart/index.js +2 -2
  24. package/lib/module/components/ring-chart/index.js.map +1 -1
  25. package/lib/module/components/section-rail/index.js +120 -15
  26. package/lib/module/components/section-rail/index.js.map +1 -1
  27. package/lib/module/components/selection-mode/index.js +129 -18
  28. package/lib/module/components/selection-mode/index.js.map +1 -1
  29. package/lib/module/components/sortable/index.js +15 -5
  30. package/lib/module/components/sortable/index.js.map +1 -1
  31. package/lib/module/index.js +7 -0
  32. package/lib/module/index.js.map +1 -1
  33. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  35. package/lib/typescript/src/components/candlestick-chart/index.d.ts +6 -3
  36. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -1
  37. package/lib/typescript/src/components/card/index.d.ts +48 -1
  38. package/lib/typescript/src/components/card/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/dialog/index.d.ts +47 -2
  40. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/plot/index.d.ts +471 -0
  43. package/lib/typescript/src/components/plot/index.d.ts.map +1 -0
  44. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
  46. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/selection-mode/index.d.ts +82 -6
  48. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  50. package/lib/typescript/src/index.d.ts +2 -0
  51. package/lib/typescript/src/index.d.ts.map +1 -1
  52. package/package.json +1 -1
  53. package/src/components/area-chart/index.tsx +14 -4
  54. package/src/components/bar-chart/index.tsx +14 -4
  55. package/src/components/candlestick-chart/index.tsx +20 -7
  56. package/src/components/card/index.tsx +32 -7
  57. package/src/components/dialog/index.tsx +55 -2
  58. package/src/components/hex-chart/index.tsx +2 -2
  59. package/src/components/line-chart/index.tsx +14 -4
  60. package/src/components/pie-chart/index.tsx +2 -2
  61. package/src/components/plot/index.tsx +1564 -0
  62. package/src/components/polar-area-chart/index.tsx +2 -2
  63. package/src/components/radar-chart/index.tsx +14 -4
  64. package/src/components/ring-chart/index.tsx +2 -2
  65. package/src/components/section-rail/index.tsx +133 -14
  66. package/src/components/selection-mode/index.tsx +163 -26
  67. package/src/components/sortable/index.tsx +15 -5
  68. package/src/index.ts +46 -0
  69. package/theme.css +24 -0
@@ -233,7 +233,7 @@ const PolarAreaChartRoot = forwardRef<PolarAreaChartHandle, PolarAreaChartProps>
233
233
  startAngle = 0,
234
234
  padAngle = 0,
235
235
  status = 'ready',
236
- animationDuration = 800,
236
+ animationDuration = 620,
237
237
  activeIndex: activeIndexProp,
238
238
  onActiveIndexChange,
239
239
  children,
@@ -311,7 +311,7 @@ const PolarAreaChartRoot = forwardRef<PolarAreaChartHandle, PolarAreaChartProps>
311
311
  reveal.value = 0;
312
312
  reveal.value = withTiming(1, {
313
313
  duration: animationDuration,
314
- easing: Easing.bezier(0.85, 0, 0.15, 1),
314
+ easing: Easing.out(Easing.cubic),
315
315
  });
316
316
  },
317
317
  [reducedMotion, animationDuration, reveal]
@@ -85,7 +85,7 @@ const LABEL_ROOM = { x: 62, y: 26 };
85
85
  const BARE_ROOM = { x: 6, y: 6 };
86
86
 
87
87
  /** How long the polygons take to grow out of the centre. */
88
- const REVEAL_DURATION = 900;
88
+ const REVEAL_DURATION = 620;
89
89
  /** …and how long one profile takes to travel to the next when the data changes. */
90
90
  const MORPH_DURATION = 420;
91
91
 
@@ -312,17 +312,27 @@ const RadarChartRoot = forwardRef<RadarChartHandle, RadarChartProps>(
312
312
  reveal.value = 0;
313
313
  reveal.value = withTiming(1, {
314
314
  duration: animationDuration,
315
- easing: Easing.bezier(0.85, 0, 0.15, 1),
315
+ easing: Easing.out(Easing.cubic),
316
316
  });
317
317
  },
318
318
  [reducedMotion, animationDuration, reveal]
319
319
  );
320
320
 
321
321
  useEffect(() => {
322
- if (revealed.current || loading || radius <= 0 || !data.length) return;
322
+ /*
323
+ * Going back to `loading` arms the reveal again. Without this a chart
324
+ * that is refetched comes back fully drawn on the frame the data lands,
325
+ * which reads as the loading state having been for nothing.
326
+ */
327
+ if (loading) {
328
+ revealed.current = false;
329
+ reveal.value = 0;
330
+ return;
331
+ }
332
+ if (revealed.current || radius <= 0 || !data.length) return;
323
333
  revealed.current = true;
324
334
  playReveal();
325
- }, [loading, radius, data.length, playReveal]);
335
+ }, [loading, radius, data.length, playReveal, reveal]);
326
336
 
327
337
  useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
328
338
 
@@ -167,7 +167,7 @@ const RingChartRoot = forwardRef<RingChartHandle, RingChartProps>(function RingC
167
167
  ringGap = 6,
168
168
  startAngle = 0,
169
169
  endAngle = 360,
170
- animationDuration = 1100,
170
+ animationDuration = 700,
171
171
  activeIndex: activeIndexProp,
172
172
  onActiveIndexChange,
173
173
  children,
@@ -219,7 +219,7 @@ const RingChartRoot = forwardRef<RingChartHandle, RingChartProps>(function RingC
219
219
  reveal.value = 0;
220
220
  reveal.value = withTiming(1, {
221
221
  duration: animationDuration,
222
- easing: Easing.bezier(0.85, 0, 0.15, 1),
222
+ easing: Easing.out(Easing.cubic),
223
223
  });
224
224
  },
225
225
  [reducedMotion, animationDuration, reveal]
@@ -28,7 +28,9 @@
28
28
  * everywhere it is not drawing, so the content underneath still scrolls.
29
29
  */
30
30
  import {
31
+ Children,
31
32
  createContext,
33
+ isValidElement,
32
34
  useCallback,
33
35
  useContext,
34
36
  useEffect,
@@ -59,7 +61,28 @@ const SPRING = { damping: 20, stiffness: 260, mass: 0.6 } as const;
59
61
  const BAR_WIDTH = 16;
60
62
  const BAR_LEVEL_STEP = 4;
61
63
  /** How much wider the active bar gets, so position is readable at a glance. */
62
- const BAR_ACTIVE_EXTRA = 8;
64
+ const BAR_ACTIVE_EXTRA = 12;
65
+ /**
66
+ * How much of that extra the bars either side of the active one keep.
67
+ *
68
+ * The rail is read by shape, and one long bar in a column of identical short
69
+ * ones only says *which* one — it takes a second look to see where that is in
70
+ * the run. A step down on each side gives the active bar a slope to sit on, so
71
+ * position is legible from the silhouette alone.
72
+ *
73
+ * Only the immediate neighbours. Two steps of falloff is a taper down the whole
74
+ * rail, which reads as a gradient the bars happen to sit in rather than as a
75
+ * mark on one of them.
76
+ */
77
+ const BAR_NEIGHBOUR = 0.45;
78
+ /** Opacity of a bar with nothing near it, and how much proximity adds. */
79
+ const BAR_REST_OPACITY = 0.32;
80
+ /**
81
+ * How long a jump from the panel is given to arrive before the rail starts
82
+ * ticking again. Long enough for a scroll across a whole screen, short enough
83
+ * that a jump which never lands does not mute the next one.
84
+ */
85
+ const JUMP_TIMEOUT = 900;
63
86
  /** Indent per level in the expanded panel. */
64
87
  const ITEM_INDENT = 12;
65
88
  /**
@@ -93,6 +116,18 @@ interface SectionRailContextValue {
93
116
 
94
117
  const SectionRailContext = createContext<SectionRailContextValue | null>(null);
95
118
 
119
+ /**
120
+ * The bars' values, in the order they are written.
121
+ *
122
+ * A bar knows whether it is the active one; it cannot know how far it is *from*
123
+ * the active one, which is what the falloff either side needs. The trigger
124
+ * reads it off its own children — the only place in the tree where the run is
125
+ * visible at all — rather than having each bar register itself, since a
126
+ * registration order is whatever order the rows happened to mount in and the
127
+ * rail is drawn in the order they were written.
128
+ */
129
+ const SectionRailBarsContext = createContext<string[]>([]);
130
+
96
131
  function useSectionRail(component: string): SectionRailContextValue {
97
132
  const context = useContext(SectionRailContext);
98
133
  if (!context) {
@@ -157,6 +192,7 @@ function SectionRailRoot({
157
192
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
158
193
  const insets = useSafeAreaInsets();
159
194
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
195
+ const jumpTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
160
196
 
161
197
  const isControlled = valueProp !== undefined;
162
198
  const value = isControlled ? valueProp : internalValue;
@@ -177,16 +213,46 @@ function SectionRailRoot({
177
213
 
178
214
  const close = useCallback(() => setOpen(false), [setOpen]);
179
215
 
180
- // A pending close must not fire after the rail has gone.
216
+ // A pending close, or a jump still being waited on, must not fire after the
217
+ // rail has gone.
181
218
  useEffect(
182
219
  () => () => {
183
220
  if (timer.current) clearTimeout(timer.current);
221
+ if (jumpTimer.current) clearTimeout(jumpTimer.current);
184
222
  },
185
223
  []
186
224
  );
187
225
 
226
+ /*
227
+ * The section a tap asked for, while the screen is still travelling to it.
228
+ *
229
+ * A jump is animated, so the scroll handler driving `value` reports every
230
+ * section the screen passes on the way — each of which is a change of section
231
+ * as far as the tick below can tell. One tap became two or three ticks, and
232
+ * the panel lit up a row nobody chose. Nothing between the tap and the
233
+ * arrival is a section the reader went to, so nothing between them ticks.
234
+ */
235
+ const jumpTo = useRef<string | null>(null);
236
+
237
+ const endJump = useCallback(() => {
238
+ jumpTo.current = null;
239
+ if (jumpTimer.current) {
240
+ clearTimeout(jumpTimer.current);
241
+ jumpTimer.current = null;
242
+ }
243
+ }, []);
244
+
188
245
  const handleValueChange = useCallback(
189
246
  (next: string) => {
247
+ jumpTo.current = next;
248
+ if (jumpTimer.current) clearTimeout(jumpTimer.current);
249
+ /*
250
+ * A backstop, not the normal way out. A jump to a section the scroller
251
+ * cannot reach — the last one on a screen shorter than the viewport —
252
+ * never arrives, and without this the rail would stay silent for good.
253
+ */
254
+ jumpTimer.current = setTimeout(endJump, JUMP_TIMEOUT);
255
+
190
256
  if (!isControlled) setInternalValue(next);
191
257
  onValueChange?.(next);
192
258
 
@@ -197,7 +263,7 @@ function SectionRailRoot({
197
263
  if (timer.current) clearTimeout(timer.current);
198
264
  timer.current = setTimeout(() => setOpen(false), closeDelay);
199
265
  },
200
- [isControlled, onValueChange, closeDelay, setOpen]
266
+ [isControlled, onValueChange, closeDelay, setOpen, endJump]
201
267
  );
202
268
 
203
269
  /*
@@ -208,13 +274,21 @@ function SectionRailRoot({
208
274
  */
209
275
  const ticked = useRef(false);
210
276
  useEffect(() => {
211
- if (!haptics) return;
212
277
  if (!ticked.current) {
213
278
  ticked.current = true;
214
279
  return;
215
280
  }
216
- selectionTick();
217
- }, [value, haptics]);
281
+
282
+ const target = jumpTo.current;
283
+ if (target !== null) {
284
+ // Still on the way. Only the section that was asked for ends the jump,
285
+ // and only it is worth feeling.
286
+ if (value !== target) return;
287
+ endJump();
288
+ }
289
+
290
+ if (haptics) selectionTick();
291
+ }, [value, haptics, endJump]);
218
292
 
219
293
  const context = useMemo(
220
294
  () => ({
@@ -269,6 +343,22 @@ export interface SectionRailTriggerProps extends ViewProps {
269
343
  function SectionRailTrigger({ className, children, ...props }: SectionRailTriggerProps) {
270
344
  const { open, setOpen, placement } = useSectionRail('SectionRail.Trigger');
271
345
 
346
+ /*
347
+ * The run of bars, read off the children. Only the values are taken, so a bar
348
+ * wrapped in anything of the caller's is simply not found and falls back to
349
+ * drawing itself from `selected` alone.
350
+ */
351
+ const values = useMemo(() => {
352
+ const found: string[] = [];
353
+ Children.forEach(children, (child) => {
354
+ if (isValidElement(child) && child.type === SectionRailBar) {
355
+ const value = (child.props as SectionRailBarProps).value;
356
+ if (typeof value === 'string') found.push(value);
357
+ }
358
+ });
359
+ return found;
360
+ }, [children]);
361
+
272
362
  return (
273
363
  <Pressable
274
364
  accessibilityRole="button"
@@ -284,7 +374,9 @@ function SectionRailTrigger({ className, children, ...props }: SectionRailTrigge
284
374
  )}
285
375
  {...props}
286
376
  >
287
- {textChildren(children)}
377
+ <SectionRailBarsContext.Provider value={values}>
378
+ {textChildren(children)}
379
+ </SectionRailBarsContext.Provider>
288
380
  </Pressable>
289
381
  );
290
382
  }
@@ -297,29 +389,56 @@ export interface SectionRailBarProps {
297
389
  level?: number;
298
390
  }
299
391
 
300
- /** One section, drawn as a bar. Widens and brightens when it is the active one. */
392
+ /**
393
+ * One section, drawn as a bar.
394
+ *
395
+ * Three lengths rather than two: the active bar is longest and brightest, the
396
+ * bars either side of it keep a share of that, and everything further away sits
397
+ * at the resting length. What the reader gets from the extra step is *where* in
398
+ * the run they are without counting bars — the slope points at the middle of it.
399
+ */
301
400
  function SectionRailBar({ className, value, level = 0 }: SectionRailBarProps) {
302
401
  const { value: active } = useSectionRail('SectionRail.Bar');
402
+ const values = useContext(SectionRailBarsContext);
303
403
  const selected = active === value;
304
404
 
305
405
  const restColor = useCSSVariable('--color-muted-foreground');
306
406
  const activeColor = useCSSVariable('--color-foreground');
307
407
 
408
+ /*
409
+ * How near this bar is to the one that is active: 1 for the active bar itself,
410
+ * a share of it for its neighbours, 0 for the rest. A bar the trigger did not
411
+ * find — one the caller wrapped in something of their own — has no position to
412
+ * measure from, so it falls back to the plain selected-or-not it always had.
413
+ */
414
+ const index = values.indexOf(value);
415
+ const activeIndex = active === undefined ? -1 : values.indexOf(active);
416
+ const proximity =
417
+ index < 0 || activeIndex < 0
418
+ ? selected
419
+ ? 1
420
+ : 0
421
+ : Math.abs(index - activeIndex) === 0
422
+ ? 1
423
+ : Math.abs(index - activeIndex) === 1
424
+ ? BAR_NEIGHBOUR
425
+ : 0;
426
+
308
427
  const base = Math.max(BAR_WIDTH - level * BAR_LEVEL_STEP, 6);
309
- const progress = useSharedValue(selected ? 1 : 0);
428
+ const progress = useSharedValue(proximity);
310
429
 
311
430
  useEffect(() => {
312
- progress.value = withSpring(selected ? 1 : 0, SPRING);
313
- }, [selected, progress]);
431
+ progress.value = withSpring(proximity, SPRING);
432
+ }, [proximity, progress]);
314
433
 
315
434
  const idle = typeof restColor === 'string' ? restColor : '#818181';
316
435
  const on = typeof activeColor === 'string' ? activeColor : '#f5f5f5';
317
436
 
318
437
  const style = useAnimatedStyle(() => ({
319
438
  width: base + progress.value * BAR_ACTIVE_EXTRA,
320
- // The inactive bars are dim on purpose — the rail is a position
321
- // indicator, so only one of them is meant to be read.
322
- opacity: 0.4 + progress.value * 0.6,
439
+ // The far bars are dim on purpose — the rail is a position indicator, so
440
+ // only the part of it the reader is in is meant to be read.
441
+ opacity: BAR_REST_OPACITY + progress.value * (1 - BAR_REST_OPACITY),
323
442
  backgroundColor: interpolateColor(progress.value, [0, 1], [idle, on]),
324
443
  }));
325
444
 
@@ -66,7 +66,7 @@ import {
66
66
  useState,
67
67
  type ReactNode,
68
68
  } from 'react';
69
- import { Pressable, View, type ViewProps } from 'react-native';
69
+ import { Pressable, ScrollView, View, type ViewProps } from 'react-native';
70
70
  import Animated, {
71
71
  FadeIn,
72
72
  FadeOut,
@@ -83,6 +83,7 @@ import { useCSSVariable } from 'uniwind';
83
83
  import { useBackHandler } from '../../hooks/use-back-handler';
84
84
  import { CheckIcon, IconColorProvider, XIcon } from '../../icons';
85
85
  import { AnimatedPressable } from '../../primitives/animated-pressable';
86
+ import { Collapse } from '../../primitives/collapse';
86
87
  import { Text, textChildren } from '../../primitives/text';
87
88
  import { cn } from '../../utils/cn';
88
89
  import { selectionTick } from '../../utils/haptics';
@@ -91,6 +92,15 @@ import { BottomSheet } from '../bottom-sheet';
91
92
  /** How long the circle takes to come and go, in milliseconds. */
92
93
  const REVEAL_DURATION = 180;
93
94
 
95
+ /**
96
+ * The circle's width, and the gap after it — both in points, and both mirrors
97
+ * of classes on the parts below (`h-6 w-6`, `gap-3`). Written out because the
98
+ * circle's slot is animated between nothing and its full size, and an animation
99
+ * needs the number rather than the class.
100
+ */
101
+ const INDICATOR_SIZE = 24;
102
+ const ROW_GAP = 12;
103
+
94
104
  /** The spring the tick lands with — the same one the checkbox uses. */
95
105
  const TICK_SPRING = { damping: 15, stiffness: 300, mass: 0.5 } as const;
96
106
 
@@ -120,11 +130,23 @@ const selectionVariants = tv({
120
130
  bar: 'flex-row items-stretch',
121
131
  action: 'flex-1 items-center justify-center gap-1.5 px-2 py-3',
122
132
  actionLabel: 'text-xs font-medium text-foreground',
133
+ groupLabel: 'pb-2 ps-1',
123
134
  },
124
135
  variants: {
125
136
  selected: {
126
137
  true: { circle: 'border-primary', ring: 'border-foreground' },
127
138
  },
139
+ /**
140
+ * The action laid out along its label instead of above it.
141
+ *
142
+ * A screen's bar carries three or four actions side by side, so each one is
143
+ * a narrow column and the label belongs under the glyph. A sheet's footer
144
+ * usually carries one, full width — stacked there it is a tall block that
145
+ * costs the list a row of its own for no gain.
146
+ */
147
+ compact: {
148
+ true: { action: 'flex-row gap-2 px-4 py-2.5', actionLabel: 'text-sm' },
149
+ },
128
150
  destructive: {
129
151
  true: { actionLabel: 'text-destructive' },
130
152
  },
@@ -485,6 +507,33 @@ function SelectionModeItem({
485
507
 
486
508
  const context = useMemo(() => ({ value }), [value]);
487
509
 
510
+ /*
511
+ * The circle's arrival and departure, as width rather than as opacity.
512
+ *
513
+ * Fading a circle that has already been taken out of the row's layout fades
514
+ * it over content that has finished moving: the row snaps left the frame the
515
+ * mode ends, and a ghost of the circle dissolves where it used to be. The
516
+ * space is what the reader sees change, so the space is what animates, and
517
+ * the negative margin takes the row's own gap with it — a zero-width child
518
+ * still costs a gap, so without it the row would still jump the last twelve
519
+ * points.
520
+ */
521
+ const reveal = useSharedValue(showing ? 1 : 0);
522
+
523
+ useEffect(() => {
524
+ if (reducedMotion) {
525
+ reveal.value = showing ? 1 : 0;
526
+ return;
527
+ }
528
+ reveal.value = withTiming(showing ? 1 : 0, { duration: REVEAL_DURATION });
529
+ }, [showing, reducedMotion, reveal]);
530
+
531
+ const slotStyle = useAnimatedStyle(() => ({
532
+ width: reveal.value * INDICATOR_SIZE,
533
+ opacity: reveal.value,
534
+ marginEnd: (reveal.value - 1) * ROW_GAP,
535
+ }));
536
+
488
537
  return (
489
538
  <SelectionModeItemContext.Provider value={context}>
490
539
  <Pressable
@@ -513,14 +562,9 @@ function SelectionModeItem({
513
562
  >
514
563
  {indicator === 'leading' ? (
515
564
  <>
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}
565
+ <Animated.View style={slotStyle} className="overflow-hidden">
566
+ <SelectionModeIndicator value={value} />
567
+ </Animated.View>
524
568
  {/*
525
569
  * `minWidth: 0` as well as growing. Without it a long title refuses
526
570
  * to be narrower than its own text, pushes the row past the screen
@@ -555,11 +599,37 @@ export interface SelectionModeGroupProps extends ViewProps {
555
599
  * For things recognised by sight rather than read — swatches, thumbnails,
556
600
  * slides. A grid of six colours is one glance; the same six as rows is a
557
601
  * scroll.
602
+ *
603
+ * Ignored when `horizontal` is set.
558
604
  */
559
605
  columns?: number;
560
- /** Space between items in a grid, in points. */
606
+ /**
607
+ * Lay the items out in one row that scrolls sideways.
608
+ *
609
+ * For a strip of small things next to other controls — swatches above a
610
+ * slider, filters above a list. A grid of the same items claims as many rows
611
+ * as it needs and pushes everything below it off the sheet; a strip costs one
612
+ * row whatever the count.
613
+ *
614
+ * Wins over `columns`, which asks for the opposite arrangement.
615
+ */
616
+ horizontal?: boolean;
617
+ /** How wide each item is in a horizontal strip, in points. */
618
+ itemWidth?: number;
619
+ /** Space between items in a grid or a strip, in points. */
561
620
  gap?: number;
562
- /** Hairlines between stacked items. On by default; off in a grid. */
621
+ /**
622
+ * A caption above the items, on the leading edge.
623
+ *
624
+ * Worth setting on anything picked by sight. A strip of colours with nothing
625
+ * in front of it is a row of circles the reader has to work out the purpose
626
+ * of, and a screen reader has nothing at all to announce it by — so this is
627
+ * also the group's accessibility label.
628
+ */
629
+ label?: string;
630
+ /** Extra classes for that caption. */
631
+ labelClassName?: string;
632
+ /** Hairlines between stacked items. On by default; off in a grid or a strip. */
563
633
  separators?: boolean;
564
634
  children: ReactNode;
565
635
  }
@@ -577,7 +647,11 @@ export interface SelectionModeGroupProps extends ViewProps {
577
647
  function SelectionModeGroup({
578
648
  className,
579
649
  columns,
650
+ horizontal = false,
651
+ itemWidth = 44,
580
652
  gap = 12,
653
+ label,
654
+ labelClassName,
581
655
  separators = true,
582
656
  children,
583
657
  style,
@@ -586,6 +660,49 @@ function SelectionModeGroup({
586
660
  const items = Children.toArray(children).filter(Boolean);
587
661
  const slots = selectionVariants({});
588
662
 
663
+ /**
664
+ * The caption, and the wrapper that carries it. A group with no `label` is
665
+ * the view it always was, so nothing gains a level of nesting for a prop it
666
+ * did not pass.
667
+ */
668
+ const captioned = (content: ReactNode) =>
669
+ label === undefined ? (
670
+ content
671
+ ) : (
672
+ <View accessibilityLabel={label}>
673
+ <Text size="sm" weight="medium" muted className={cn(slots.groupLabel(), labelClassName)}>
674
+ {label}
675
+ </Text>
676
+ {content}
677
+ </View>
678
+ );
679
+
680
+ if (horizontal) {
681
+ /*
682
+ * `gap` on the row rather than padding inside each cell, which is the
683
+ * opposite of the grid below. A strip is not dividing a fixed width between
684
+ * its items, so there is no wrap to protect against — and the gap has to be
685
+ * between them rather than around them, or the strip starts inset from
686
+ * whatever it is in.
687
+ */
688
+ return captioned(
689
+ <ScrollView
690
+ horizontal
691
+ showsHorizontalScrollIndicator={false}
692
+ contentContainerStyle={{ flexDirection: 'row', gap }}
693
+ style={style}
694
+ className={className}
695
+ {...props}
696
+ >
697
+ {items.map((item, index) => (
698
+ <View key={index} style={{ width: itemWidth }}>
699
+ {item}
700
+ </View>
701
+ ))}
702
+ </ScrollView>
703
+ );
704
+ }
705
+
589
706
  if (columns && columns > 0) {
590
707
  /*
591
708
  * The gap is padding inside each cell, not `gap` on the row.
@@ -597,7 +714,7 @@ function SelectionModeGroup({
597
714
  * sits flush against whatever it is in.
598
715
  */
599
716
  const half = gap / 2;
600
- return (
717
+ return captioned(
601
718
  <View
602
719
  style={[{ flexDirection: 'row', flexWrap: 'wrap', margin: -half }, style]}
603
720
  className={className}
@@ -612,7 +729,7 @@ function SelectionModeGroup({
612
729
  );
613
730
  }
614
731
 
615
- return (
732
+ return captioned(
616
733
  <View className={cn(slots.group(), className)} style={style} {...props}>
617
734
  {items.map((item, index) => (
618
735
  <View key={index}>
@@ -659,16 +776,23 @@ function SelectionModeHeader({
659
776
  }: SelectionModeHeaderProps) {
660
777
  const { active, exit, count, total, allSelected, selectAll, clear, sheet } =
661
778
  useSelectionMode();
662
- const reducedMotion = useReducedMotion();
663
779
  const slots = selectionVariants({});
664
780
 
665
- if (!active) return null;
666
-
781
+ /*
782
+ * Collapsed rather than unmounted.
783
+ *
784
+ * A fade on a view that has already been taken out of the flow fades nothing
785
+ * — the list under it has jumped up 56 points on the frame the mode ended,
786
+ * and what is left dissolving is a header nobody is looking at any more.
787
+ * Giving up the height *is* the transition, so that is the part that animates.
788
+ */
667
789
  return (
668
- <Animated.View
669
- entering={reducedMotion ? undefined : FadeIn.duration(REVEAL_DURATION)}
670
- exiting={reducedMotion ? undefined : FadeOut.duration(REVEAL_DURATION)}
790
+ <Collapse
791
+ open={active}
792
+ duration={REVEAL_DURATION}
671
793
  className={cn(slots.header(), className)}
794
+ accessibilityElementsHidden={!active}
795
+ importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
672
796
  {...props}
673
797
  >
674
798
  {children ? (
@@ -718,7 +842,7 @@ function SelectionModeHeader({
718
842
  </View>
719
843
  </>
720
844
  )}
721
- </Animated.View>
845
+ </Collapse>
722
846
  );
723
847
  }
724
848
 
@@ -854,12 +978,13 @@ function SelectionModeAction({
854
978
  children,
855
979
  ...props
856
980
  }: SelectionModeActionProps) {
857
- const { selected, exit, count } = useSelectionMode();
981
+ const { selected, exit, count, sheet } = useSelectionMode();
858
982
  const destructiveColor = useCSSVariable('--color-destructive');
859
983
  // Nothing picked is nothing to act on, so the action is off rather than
860
984
  // pressable-and-inert.
861
985
  const off = disabled || count === 0;
862
- const slots = selectionVariants({ destructive, disabled: off });
986
+ // A sheet's footer is one action wide, not four, so it lies down.
987
+ const slots = selectionVariants({ destructive, disabled: off, compact: sheet });
863
988
 
864
989
  return (
865
990
  <AnimatedPressable
@@ -902,10 +1027,15 @@ export interface SelectionModeSheetProps {
902
1027
  /**
903
1028
  * How tall the sheet opens.
904
1029
  *
905
- * `half` by default, and deliberately not `auto`. A sheet that sizes to its
1030
+ * `full` by default, and deliberately not `auto`. A sheet that sizes to its
906
1031
  * content gives its scrolling body no height to fill, and a list inside a box
907
1032
  * 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.
1033
+ * like a missing style.
1034
+ *
1035
+ * Full rather than half because a picker spends a header and a footer before
1036
+ * it draws a single row. At half a screen that leaves four or five rows for
1037
+ * the thing the sheet was opened to do, and the reader scrolls a list that
1038
+ * would have fitted. Pass `half` for a sheet of two or three choices.
909
1039
  */
910
1040
  size?: 'auto' | 'half' | 'full';
911
1041
  /**
@@ -949,7 +1079,7 @@ function SelectionModeSheet({
949
1079
  onOpenChange,
950
1080
  title = 'Select',
951
1081
  hideSelectAll = false,
952
- size = 'half',
1082
+ size = 'full',
953
1083
  children,
954
1084
  }: SelectionModeSheetProps) {
955
1085
  const parent = useSelectionMode();
@@ -978,7 +1108,14 @@ function SelectionModeSheet({
978
1108
 
979
1109
  return (
980
1110
  <BottomSheet open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
981
- <BottomSheet.Content size={size} className={className}>
1111
+ {/*
1112
+ * No close button. The sheet's own sits in the top trailing corner, which
1113
+ * is where the header puts "All" — two targets a few points apart, one of
1114
+ * which throws the selection away. The sheet is still dismissed by its
1115
+ * handle, by the scrim and by the back gesture, which is the same reason
1116
+ * the header does not draw an X of its own in here.
1117
+ */}
1118
+ <BottomSheet.Content size={size} showClose={false} className={className}>
982
1119
  {/*
983
1120
  * The provider goes *inside* the sheet's content, not around the sheet.
984
1121
  *
@@ -563,16 +563,26 @@ function SortableRoot({
563
563
  * the transforms that were holding rows in their new slots are no longer
564
564
  * needed — the layout puts them there now. Both orders are reset together,
565
565
  * which is what makes the re-render after a drop invisible.
566
+ *
567
+ * During render, and not in an effect. An effect runs *after* the commit that
568
+ * moved the rows, so for the frame in between, the dropped row sat in its new
569
+ * slot still carrying the transform that had carried it there — drawn a whole
570
+ * row's height away from where it belongs, which is what the flash after a
571
+ * drop was. Written here it lands on the same commit as the reorder, and there
572
+ * is no frame in which the two disagree.
573
+ *
574
+ * Writing a shared value is not React state, so this is not a render with a
575
+ * side effect on the tree; the ref is only there to make it happen once per
576
+ * change of order rather than on every render of the screen around it.
566
577
  */
567
- useEffect(() => {
578
+ const applied = useRef(key);
579
+ if (applied.current !== key) {
580
+ applied.current = key;
568
581
  order.value = value;
569
582
  rendered.value = value;
570
583
  translate.value = 0;
571
584
  lift.value = 0;
572
- // `value` is covered by `key`; depending on the array itself would fire
573
- // this on every render of the screen around it.
574
- // eslint-disable-next-line react-hooks/exhaustive-deps
575
- }, [key, order, rendered, translate, lift]);
585
+ }
576
586
 
577
587
  const indices = useMemo(() => {
578
588
  const map = new Map<string, number>();