panelui-native 0.65.0 → 0.66.1

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 (94) hide show
  1. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  2. package/lib/module/components/carousel/carousel-lifecycle.js +51 -0
  3. package/lib/module/components/carousel/carousel-lifecycle.js.map +1 -0
  4. package/lib/module/components/carousel/index.js +61 -24
  5. package/lib/module/components/carousel/index.js.map +1 -1
  6. package/lib/module/components/context-menu/context-menu-invocation.js +23 -0
  7. package/lib/module/components/context-menu/context-menu-invocation.js.map +1 -0
  8. package/lib/module/components/context-menu/index.js +78 -9
  9. package/lib/module/components/context-menu/index.js.map +1 -1
  10. package/lib/module/components/dialog/index.js.map +1 -1
  11. package/lib/module/components/drawer/index.js.map +1 -1
  12. package/lib/module/components/flow/flow-accessibility.js +18 -2
  13. package/lib/module/components/flow/flow-accessibility.js.map +1 -1
  14. package/lib/module/components/flow/flow-identifiers.js +81 -0
  15. package/lib/module/components/flow/flow-identifiers.js.map +1 -0
  16. package/lib/module/components/flow/index.js +12 -18
  17. package/lib/module/components/flow/index.js.map +1 -1
  18. package/lib/module/components/message-scroller/index.js +17 -9
  19. package/lib/module/components/message-scroller/index.js.map +1 -1
  20. package/lib/module/components/message-scroller/message-scroller-math.js +20 -0
  21. package/lib/module/components/message-scroller/message-scroller-math.js.map +1 -0
  22. package/lib/module/components/popover/index.js.map +1 -1
  23. package/lib/module/components/selection-mode/index.js +26 -31
  24. package/lib/module/components/selection-mode/index.js.map +1 -1
  25. package/lib/module/components/selection-mode/selection-mode-contracts.js +48 -0
  26. package/lib/module/components/selection-mode/selection-mode-contracts.js.map +1 -0
  27. package/lib/module/components/signature/index.js +62 -20
  28. package/lib/module/components/signature/index.js.map +1 -1
  29. package/lib/module/components/signature/signature-accessibility.js +58 -0
  30. package/lib/module/components/signature/signature-accessibility.js.map +1 -0
  31. package/lib/module/components/slider/index.js +22 -18
  32. package/lib/module/components/slider/index.js.map +1 -1
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/primitives/scrim.js +94 -3
  35. package/lib/module/primitives/scrim.js.map +1 -1
  36. package/lib/typescript/src/components/bottom-sheet/index.d.ts +3 -0
  37. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  38. package/lib/typescript/src/components/carousel/carousel-lifecycle.d.ts +23 -0
  39. package/lib/typescript/src/components/carousel/carousel-lifecycle.d.ts.map +1 -0
  40. package/lib/typescript/src/components/carousel/index.d.ts +5 -2
  41. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/context-menu/context-menu-invocation.d.ts +20 -0
  43. package/lib/typescript/src/components/context-menu/context-menu-invocation.d.ts.map +1 -0
  44. package/lib/typescript/src/components/context-menu/index.d.ts +18 -8
  45. package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/dialog/index.d.ts +3 -0
  47. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/drawer/index.d.ts +3 -0
  49. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/flow/flow-accessibility.d.ts +8 -0
  51. package/lib/typescript/src/components/flow/flow-accessibility.d.ts.map +1 -1
  52. package/lib/typescript/src/components/flow/flow-identifiers.d.ts +24 -0
  53. package/lib/typescript/src/components/flow/flow-identifiers.d.ts.map +1 -0
  54. package/lib/typescript/src/components/flow/index.d.ts +6 -5
  55. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  57. package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts +8 -0
  58. package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts.map +1 -0
  59. package/lib/typescript/src/components/popover/index.d.ts +3 -0
  60. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/selection-mode/index.d.ts +6 -4
  62. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/selection-mode/selection-mode-contracts.d.ts +18 -0
  64. package/lib/typescript/src/components/selection-mode/selection-mode-contracts.d.ts.map +1 -0
  65. package/lib/typescript/src/components/signature/index.d.ts +6 -0
  66. package/lib/typescript/src/components/signature/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/signature/signature-accessibility.d.ts +27 -0
  68. package/lib/typescript/src/components/signature/signature-accessibility.d.ts.map +1 -0
  69. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  70. package/lib/typescript/src/index.d.ts +1 -1
  71. package/lib/typescript/src/index.d.ts.map +1 -1
  72. package/lib/typescript/src/primitives/scrim.d.ts +5 -3
  73. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -1
  74. package/package.json +7 -4
  75. package/src/components/bottom-sheet/index.tsx +3 -0
  76. package/src/components/carousel/carousel-lifecycle.ts +74 -0
  77. package/src/components/carousel/index.tsx +80 -25
  78. package/src/components/context-menu/context-menu-invocation.ts +44 -0
  79. package/src/components/context-menu/index.tsx +113 -13
  80. package/src/components/dialog/index.tsx +3 -0
  81. package/src/components/drawer/index.tsx +3 -0
  82. package/src/components/flow/flow-accessibility.ts +16 -2
  83. package/src/components/flow/flow-identifiers.ts +98 -0
  84. package/src/components/flow/index.tsx +29 -19
  85. package/src/components/message-scroller/index.tsx +39 -7
  86. package/src/components/message-scroller/message-scroller-math.ts +24 -0
  87. package/src/components/popover/index.tsx +3 -0
  88. package/src/components/selection-mode/index.tsx +37 -30
  89. package/src/components/selection-mode/selection-mode-contracts.ts +53 -0
  90. package/src/components/signature/index.tsx +107 -20
  91. package/src/components/signature/signature-accessibility.ts +67 -0
  92. package/src/components/slider/index.tsx +28 -18
  93. package/src/index.ts +2 -0
  94. package/src/primitives/scrim.tsx +127 -7
@@ -58,7 +58,12 @@ import {
58
58
  type ReactNode,
59
59
  type Ref,
60
60
  } from 'react';
61
- import { View, type ViewProps } from 'react-native';
61
+ import {
62
+ AccessibilityInfo,
63
+ View,
64
+ type AccessibilityActionEvent,
65
+ type ViewProps,
66
+ } from 'react-native';
62
67
  import { useCSSVariable } from 'uniwind';
63
68
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
64
69
  import Animated, {
@@ -81,6 +86,12 @@ import {
81
86
  } from '../../primitives/animated-pressable';
82
87
  import { Text } from '../../primitives/text';
83
88
  import { cn } from '../../utils/cn';
89
+ import {
90
+ signatureAccessibilityActions,
91
+ signatureAccessibilityValue,
92
+ signatureAnnouncement,
93
+ type SignatureChangeKind,
94
+ } from './signature-accessibility';
84
95
 
85
96
  const AnimatedPath = Animated.createAnimatedComponent(Path);
86
97
 
@@ -175,6 +186,12 @@ export interface SignatureProps
175
186
  placeholder?: ReactNode;
176
187
  /** Take no input. The strokes already there stay visible. */
177
188
  disabled?: boolean;
189
+ /**
190
+ * Opens a product-provided non-drawing method, such as typing a legal name,
191
+ * uploading an image, or asking for assisted signing. Exposed as a screen
192
+ * reader action; provide the same choice as a visible control too.
193
+ */
194
+ onRequestAlternative?: () => void;
178
195
  /** A stroke has started. */
179
196
  onBegin?: () => void;
180
197
  /** A stroke has finished. */
@@ -292,12 +309,17 @@ function SignatureRoot(
292
309
  guidelineLabel,
293
310
  placeholder,
294
311
  disabled = false,
312
+ onRequestAlternative,
295
313
  onBegin,
296
314
  onEnd,
297
315
  onChange,
298
316
  padClassName,
299
317
  placeholderClassName,
300
318
  guideClassName,
319
+ accessibilityLabel,
320
+ accessibilityHint,
321
+ accessibilityActions,
322
+ onAccessibilityAction,
301
323
  ...props
302
324
  }: SignatureProps,
303
325
  ref: Ref<SignatureHandle>
@@ -325,19 +347,30 @@ function SignatureRoot(
325
347
  beginRef.current = onBegin;
326
348
  const endRef = useRef(onEnd);
327
349
  endRef.current = onEnd;
350
+ const alternativeRef = useRef(onRequestAlternative);
351
+ alternativeRef.current = onRequestAlternative;
328
352
 
329
353
  // `onChange` fires from an effect rather than from inside a state updater.
330
354
  // An updater can be replayed during a render, and calling a parent's setState
331
355
  // from there is the "cannot update a component while rendering a different
332
356
  // component" warning — earned, not spurious.
333
357
  const reported = useRef(0);
358
+ const changeKind = useRef<SignatureChangeKind>('draw');
334
359
  useEffect(() => {
335
360
  if (reported.current === strokes.length) return;
361
+ const previous = reported.current;
336
362
  reported.current = strokes.length;
337
363
  changeRef.current?.(strokes.length);
364
+ const announcement = signatureAnnouncement(
365
+ previous,
366
+ strokes.length,
367
+ changeKind.current
368
+ );
369
+ if (announcement) AccessibilityInfo.announceForAccessibility(announcement);
338
370
  }, [strokes.length]);
339
371
 
340
372
  const commit = useCallback((d: string) => {
373
+ changeKind.current = 'draw';
341
374
  setStrokes((current) => [...current, d]);
342
375
  // Drawing again is a new branch of history, so what was undone is gone.
343
376
  setUndone([]);
@@ -419,28 +452,37 @@ function SignatureRoot(
419
452
  [layout.height, layout.width]
420
453
  );
421
454
 
455
+ const clear = useCallback(() => {
456
+ changeKind.current = 'clear';
457
+ setStrokes([]);
458
+ setUndone([]);
459
+ }, []);
460
+
461
+ const undo = useCallback(() => {
462
+ const last = strokes[strokes.length - 1];
463
+ if (last === undefined) return;
464
+ changeKind.current = 'undo';
465
+ setUndone((stack) => [...stack, last]);
466
+ setStrokes(strokes.slice(0, -1));
467
+ }, [strokes]);
468
+
469
+ const redo = useCallback(() => {
470
+ const restored = undone[undone.length - 1];
471
+ if (restored === undefined) return;
472
+ changeKind.current = 'redo';
473
+ setUndone(undone.slice(0, -1));
474
+ setStrokes([...strokes, restored]);
475
+ }, [strokes, undone]);
476
+
422
477
  useImperativeHandle(
423
478
  ref,
424
479
  (): SignatureHandle => ({
425
480
  // These read the current arrays and set flat values rather than nesting
426
481
  // one updater inside another — a nested updater runs during the render
427
482
  // pass, which is not a safe place to schedule another component's update.
428
- clear() {
429
- setStrokes([]);
430
- setUndone([]);
431
- },
432
- undo() {
433
- const last = strokes[strokes.length - 1];
434
- if (last === undefined) return;
435
- setUndone((stack) => [...stack, last]);
436
- setStrokes(strokes.slice(0, -1));
437
- },
438
- redo() {
439
- const restored = undone[undone.length - 1];
440
- if (restored === undefined) return;
441
- setUndone(undone.slice(0, -1));
442
- setStrokes([...strokes, restored]);
443
- },
483
+ clear,
484
+ undo,
485
+ redo,
444
486
  isEmpty: () => strokes.length === 0,
445
487
  strokeCount: () => strokes.length,
446
488
  toSVG: svgDocument,
@@ -524,10 +566,47 @@ function SignatureRoot(
524
566
  };
525
567
  },
526
568
  }),
527
- [layout.height, layout.width, rasterise, strokes, undone, svgDocument]
569
+ [clear, layout.height, layout.width, rasterise, redo, strokes, svgDocument, undo]
528
570
  );
529
571
 
530
572
  const empty = strokes.length === 0;
573
+ const padActions = useMemo(
574
+ () =>
575
+ disabled
576
+ ? []
577
+ : [
578
+ ...(accessibilityActions ?? []),
579
+ ...signatureAccessibilityActions(
580
+ strokes.length,
581
+ undone.length,
582
+ Boolean(onRequestAlternative),
583
+ false
584
+ ),
585
+ ],
586
+ [accessibilityActions, disabled, onRequestAlternative, strokes.length, undone.length]
587
+ );
588
+ const handleAccessibilityAction = useCallback(
589
+ (event: AccessibilityActionEvent) => {
590
+ if (disabled) return;
591
+ switch (event.nativeEvent.actionName) {
592
+ case 'signature-undo':
593
+ undo();
594
+ return;
595
+ case 'signature-redo':
596
+ redo();
597
+ return;
598
+ case 'signature-clear':
599
+ clear();
600
+ return;
601
+ case 'signature-alternative':
602
+ alternativeRef.current?.();
603
+ return;
604
+ default:
605
+ onAccessibilityAction?.(event);
606
+ }
607
+ },
608
+ [clear, disabled, onAccessibilityAction, redo, undo]
609
+ );
531
610
 
532
611
  return (
533
612
  <View className={slots.root({ className })} {...props}>
@@ -541,9 +620,17 @@ function SignatureRoot(
541
620
  onLayout={(event) => setLayout(event.nativeEvent.layout)}
542
621
  accessible
543
622
  accessibilityRole="image"
544
- accessibilityLabel="Signature pad"
545
- accessibilityHint="Draw your signature with your finger"
623
+ accessibilityLabel={accessibilityLabel ?? 'Signature pad'}
624
+ accessibilityHint={
625
+ accessibilityHint ??
626
+ (onRequestAlternative
627
+ ? 'Direct touch drawing requires tracing a path. Use actions to edit or choose another signature method.'
628
+ : 'Direct touch drawing requires tracing a path. Use actions to undo, redo, or clear strokes.')
629
+ }
546
630
  accessibilityState={{ disabled }}
631
+ accessibilityValue={signatureAccessibilityValue(strokes.length)}
632
+ accessibilityActions={padActions}
633
+ onAccessibilityAction={handleAccessibilityAction}
547
634
  >
548
635
  {guideline && layout.height > 0 ? (
549
636
  <View
@@ -0,0 +1,67 @@
1
+ export type SignatureChangeKind = 'draw' | 'undo' | 'redo' | 'clear';
2
+
3
+ export interface SignatureAccessibilityAction {
4
+ name: string;
5
+ label: string;
6
+ }
7
+
8
+ /**
9
+ * What the pad reads as when it is focused.
10
+ *
11
+ * It counts strokes and says nothing about whether the signature is finished,
12
+ * because the component cannot know that — one mark may be the whole signature
13
+ * or the first letter of it. Announcing "complete" after the first stroke tells
14
+ * somebody who cannot see the pad that they are done when they may not be.
15
+ */
16
+ export function signatureAccessibilityValue(strokeCount: number) {
17
+ return {
18
+ text:
19
+ strokeCount === 0
20
+ ? 'Empty signature'
21
+ : `Signed, ${strokeCount} ${strokeCount === 1 ? 'stroke' : 'strokes'}`,
22
+ };
23
+ }
24
+
25
+ export function signatureAccessibilityActions(
26
+ strokeCount: number,
27
+ undoneCount: number,
28
+ alternative: boolean,
29
+ disabled: boolean
30
+ ): SignatureAccessibilityAction[] {
31
+ if (disabled) return [];
32
+
33
+ const actions: SignatureAccessibilityAction[] = [];
34
+ if (strokeCount > 0) {
35
+ actions.push(
36
+ { name: 'signature-undo', label: 'Undo last stroke' },
37
+ { name: 'signature-clear', label: 'Clear signature' }
38
+ );
39
+ }
40
+ if (undoneCount > 0) {
41
+ actions.push({ name: 'signature-redo', label: 'Redo last stroke' });
42
+ }
43
+ if (alternative) {
44
+ actions.push({ name: 'signature-alternative', label: 'Use another signature method' });
45
+ }
46
+ return actions;
47
+ }
48
+
49
+ /**
50
+ * Spoken when the pad crosses between empty and not, and nowhere else.
51
+ *
52
+ * Every stroke is silent on purpose: a running commentary during drawing is
53
+ * noise over the one gesture the reader is concentrating on. The two crossings
54
+ * are the ones that cannot be felt — that the pad now holds something, and that
55
+ * it no longer does.
56
+ */
57
+ export function signatureAnnouncement(
58
+ previousCount: number,
59
+ strokeCount: number,
60
+ change: SignatureChangeKind
61
+ ): string | null {
62
+ if (previousCount === strokeCount) return null;
63
+ if (change === 'clear') return 'Signature cleared. Signature is empty.';
64
+ if (strokeCount === 0) return 'Signature is empty.';
65
+ if (previousCount === 0) return 'Signature started.';
66
+ return null;
67
+ }
@@ -186,6 +186,12 @@ function snap(value: number, min: number, max: number, step: number) {
186
186
  /** Which thumb a drag has hold of. A one-thumb slider only ever has `high`. */
187
187
  type Thumb = 'low' | 'high';
188
188
 
189
+ function recordStep(last: Record<Thumb, number>, thumb: Thumb, next: number) {
190
+ if (next === last[thumb]) return false;
191
+ last[thumb] = next;
192
+ return true;
193
+ }
194
+
189
195
  export const Slider = forwardRef<View, SliderProps>(
190
196
  (
191
197
  {
@@ -278,9 +284,12 @@ export const Slider = forwardRef<View, SliderProps>(
278
284
  rangeChangeRef.current = onRangeChange;
279
285
  const rangeCommitRef = useRef(onRangeCommit);
280
286
  rangeCommitRef.current = onRangeCommit;
281
- // The last step a drag ticked on, so haptics fire once per step, not per
282
- // pixel of movement within it.
283
- const lastTick = useRef(value);
287
+ // Each thumb owns its last tick. Sharing one scalar lets the stationary
288
+ // endpoint suppress the thumb under the finger on a range slider.
289
+ const lastTick = useRef<Record<Thumb, number>>({
290
+ low,
291
+ high: isRange ? high : value,
292
+ });
284
293
  /*
285
294
  * True from the moment a finger lands until it lifts. A controlled parent
286
295
  * echoes every change straight back as a new prop, and springing the thumb
@@ -329,8 +338,7 @@ export const Slider = forwardRef<View, SliderProps>(
329
338
  (p: number, commit: boolean) => {
330
339
  const raw = min + p * span;
331
340
  const snapped = snap(raw, min, max, step);
332
- if (haptics && snapped !== lastTick.current) {
333
- lastTick.current = snapped;
341
+ if (haptics && recordStep(lastTick.current, 'high', snapped)) {
334
342
  selectionTick();
335
343
  }
336
344
  emitChange(snapped);
@@ -340,16 +348,13 @@ export const Slider = forwardRef<View, SliderProps>(
340
348
  );
341
349
 
342
350
  const commitRangeFromProgress = useCallback(
343
- (pLow: number, pHigh: number, commit: boolean) => {
351
+ (pLow: number, pHigh: number, commit: boolean, thumb: Thumb) => {
344
352
  const next: [number, number] = [
345
353
  snap(min + pLow * span, min, max, step),
346
354
  snap(min + pHigh * span, min, max, step),
347
355
  ];
348
- // Either end crossing onto a new step is a step crossed — the tick is
349
- // about the thumb under the finger, and only one of them is.
350
- const moved = next[0] !== lastTick.current && next[1] !== lastTick.current;
351
- if (haptics && moved) {
352
- lastTick.current = next[0];
356
+ const nextStep = thumb === 'low' ? next[0] : next[1];
357
+ if (haptics && recordStep(lastTick.current, thumb, nextStep)) {
353
358
  selectionTick();
354
359
  }
355
360
  emitRangeChange(next);
@@ -418,12 +423,12 @@ export const Slider = forwardRef<View, SliderProps>(
418
423
  const ceiling = Math.max(highProgress.value - gap, 0);
419
424
  const next = Math.min(Math.max(dragStartLow.value + delta, 0), ceiling);
420
425
  lowProgress.value = next;
421
- runOnJS(commitRangeFromProgress)(next, highProgress.value, false);
426
+ runOnJS(commitRangeFromProgress)(next, highProgress.value, false, 'low');
422
427
  } else {
423
428
  const floor = Math.min(lowProgress.value + gap, 1);
424
429
  const next = Math.min(Math.max(dragStartHigh.value + delta, floor), 1);
425
430
  highProgress.value = next;
426
- runOnJS(commitRangeFromProgress)(lowProgress.value, next, false);
431
+ runOnJS(commitRangeFromProgress)(lowProgress.value, next, false, 'high');
427
432
  }
428
433
  })
429
434
  .onFinalize(() => {
@@ -431,7 +436,12 @@ export const Slider = forwardRef<View, SliderProps>(
431
436
  pressedLow.value = withSpring(0, KNOB_SPRING);
432
437
  runOnJS(setDragging)(false);
433
438
  if (isRange) {
434
- runOnJS(commitRangeFromProgress)(lowProgress.value, highProgress.value, true);
439
+ runOnJS(commitRangeFromProgress)(
440
+ lowProgress.value,
441
+ highProgress.value,
442
+ true,
443
+ activeThumb.value
444
+ );
435
445
  } else {
436
446
  runOnJS(commitFromProgress)(highProgress.value, true);
437
447
  }
@@ -456,11 +466,11 @@ export const Slider = forwardRef<View, SliderProps>(
456
466
  if (pickThumb(event.x) === 'low') {
457
467
  const bounded = Math.min(next, Math.max(highProgress.value - gap, 0));
458
468
  lowProgress.value = withSpring(bounded, SPRING);
459
- runOnJS(commitRangeFromProgress)(bounded, highProgress.value, true);
469
+ runOnJS(commitRangeFromProgress)(bounded, highProgress.value, true, 'low');
460
470
  } else {
461
471
  const bounded = Math.max(next, Math.min(lowProgress.value + gap, 1));
462
472
  highProgress.value = withSpring(bounded, SPRING);
463
- runOnJS(commitRangeFromProgress)(lowProgress.value, bounded, true);
473
+ runOnJS(commitRangeFromProgress)(lowProgress.value, bounded, true, 'high');
464
474
  }
465
475
  });
466
476
 
@@ -503,7 +513,7 @@ export const Slider = forwardRef<View, SliderProps>(
503
513
  const next = snap(value + dir * (step || 1), min, max, step);
504
514
  if (next === value) return;
505
515
  if (haptics) {
506
- lastTick.current = next;
516
+ recordStep(lastTick.current, 'high', next);
507
517
  selectionTick();
508
518
  }
509
519
  highProgress.value = withSpring(toFraction(next), SPRING);
@@ -521,7 +531,7 @@ export const Slider = forwardRef<View, SliderProps>(
521
531
  const next = snap(bounded, min, max, step);
522
532
  if (next === current) return;
523
533
  if (haptics) {
524
- lastTick.current = next;
534
+ recordStep(lastTick.current, thumb, next);
525
535
  selectionTick();
526
536
  }
527
537
  const pair: [number, number] = thumb === 'low' ? [next, high] : [low, next];
package/src/index.ts CHANGED
@@ -209,6 +209,8 @@ export {
209
209
  type FlowControlsProps,
210
210
  type FlowMiniMapProps,
211
211
  type FlowConnection,
212
+ type FlowEndpoint,
213
+ type FlowEndpointReference,
212
214
  type FlowViewport,
213
215
  type FlowNodePosition,
214
216
  type FlowEdgeVariant,
@@ -10,17 +10,20 @@
10
10
  * that never blurs anything installs nothing. When it is asked for and the
11
11
  * package is missing, this falls back to a dim rather than failing: a blur you
12
12
  * cannot draw is better shown as a darkened screen than as a crash.
13
+ * Reduce Transparency replaces the blur with an opaque, tint-aware surface.
13
14
  *
14
15
  * It fills its parent and does not intercept touches itself — the overlay layers
15
16
  * its own dismiss `Pressable` over it — so it is purely the visual backdrop.
16
17
  */
17
- import { type ComponentType } from 'react';
18
- import { StyleSheet, View, type ViewProps } from 'react-native';
18
+ import { useEffect, useState, type ComponentType } from 'react';
19
+ import { AccessibilityInfo, StyleSheet, View, type ViewProps } from 'react-native';
19
20
  import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
20
21
 
22
+ type BlurTint = 'light' | 'dark' | 'default' | 'systemMaterial';
23
+
21
24
  interface BlurViewProps {
22
25
  intensity?: number;
23
- tint?: 'light' | 'dark' | 'default' | 'systemMaterial';
26
+ tint?: BlurTint;
24
27
  style?: unknown;
25
28
  children?: React.ReactNode;
26
29
  }
@@ -43,16 +46,112 @@ const BlurView: ComponentType<BlurViewProps> | null = (() => {
43
46
  /** True when a real blur can be drawn — for a caller that wants to know. */
44
47
  export const hasBlur = BlurView !== null;
45
48
 
49
+ type ReduceTransparencySource = {
50
+ isReduceTransparencyEnabled?: () => Promise<boolean>;
51
+ addEventListener?: (
52
+ event: 'reduceTransparencyChanged',
53
+ listener: (enabled: boolean) => void
54
+ ) => { remove?: () => void } | undefined;
55
+ };
56
+
57
+ function observeReduceTransparency(
58
+ source: ReduceTransparencySource,
59
+ onChange: (enabled: boolean) => void
60
+ ) {
61
+ let active = true;
62
+ let changed = false;
63
+ const update = (enabled: boolean) => {
64
+ changed = true;
65
+ if (active) onChange(Boolean(enabled));
66
+ };
67
+ /*
68
+ * A platform that cannot report the preference has no preference to respect,
69
+ * so blur stays available. A platform that has the API and failed to answer
70
+ * might have it switched on, and that is the case worth being careful about.
71
+ */
72
+ const fallback = (queryable: boolean) => () => {
73
+ if (active && !changed) onChange(queryable);
74
+ };
75
+
76
+ let subscription: { remove?: () => void } | undefined;
77
+ try {
78
+ subscription = source.addEventListener?.('reduceTransparencyChanged', update);
79
+ } catch {
80
+ // Some web and test shims expose AccessibilityInfo without every event.
81
+ }
82
+
83
+ try {
84
+ if (typeof source.isReduceTransparencyEnabled !== 'function') fallback(false)();
85
+ else {
86
+ void source.isReduceTransparencyEnabled().then((enabled) => {
87
+ // A preference event that arrived while the query was pending is newer.
88
+ if (active && !changed) update(enabled);
89
+ }, fallback(true));
90
+ }
91
+ } catch {
92
+ fallback(true)();
93
+ }
94
+
95
+ return () => {
96
+ active = false;
97
+ try {
98
+ subscription?.remove?.();
99
+ } catch {
100
+ // Cleanup stays safe for partial platform shims.
101
+ }
102
+ };
103
+ }
104
+
105
+ /**
106
+ * The last answer the platform gave, shared by every scrim in the process.
107
+ *
108
+ * The query is asynchronous and the preference belongs to the device, not to
109
+ * any one overlay. Without this, each overlay opens not knowing it and draws
110
+ * opaque until the answer arrives — a flash on every dialog for the many people
111
+ * who have the preference off. Remembered here, only the first overlay of a
112
+ * launch can see it.
113
+ */
114
+ let knownReduceTransparency: boolean | null = null;
115
+
116
+ function useReduceTransparency() {
117
+ // Not knowing is deliberately conservative: draw opaque until the platform
118
+ // says blur is allowed, rather than flashing blur at someone who opted out.
119
+ const [enabled, setEnabled] = useState<boolean | null>(knownReduceTransparency);
120
+ useEffect(
121
+ () =>
122
+ observeReduceTransparency(AccessibilityInfo, (next) => {
123
+ knownReduceTransparency = next;
124
+ setEnabled(next);
125
+ }),
126
+ []
127
+ );
128
+ return enabled;
129
+ }
130
+
131
+ type ScrimMode = 'blur' | 'dim' | 'opaque';
132
+
133
+ function scrimMode(blur: boolean, canBlur: boolean, reduceTransparency: boolean | null): ScrimMode {
134
+ if (!blur) return 'dim';
135
+ if (reduceTransparency !== false) return 'opaque';
136
+ return canBlur ? 'blur' : 'dim';
137
+ }
138
+
139
+ function opaqueClassName(tint: BlurTint) {
140
+ if (tint === 'light') return 'bg-white';
141
+ if (tint === 'dark') return 'bg-black';
142
+ return 'bg-background';
143
+ }
144
+
46
145
  export interface ScrimProps extends Omit<ViewProps, 'children'> {
47
146
  /** Frost the backdrop instead of dimming it. Falls back to a dim if it can't. */
48
147
  blur?: boolean;
49
148
  /** Blur strength, 0–100. */
50
149
  intensity?: number;
51
150
  /** Which way the blur tints. */
52
- tint?: 'light' | 'dark' | 'default' | 'systemMaterial';
151
+ tint?: BlurTint;
53
152
  /**
54
- * The dim used when not blurring — and when blurring is asked for but
55
- * unavailable. A popover passes a lighter one than a dialog.
153
+ * The dim used when not blurring, when blur is unavailable, and over the
154
+ * opaque Reduce Transparency fallback. A popover passes a lighter one than a dialog.
56
155
  */
57
156
  dimClassName?: string;
58
157
  }
@@ -65,7 +164,10 @@ export function Scrim({
65
164
  style,
66
165
  ...props
67
166
  }: ScrimProps) {
68
- if (blur && BlurView) {
167
+ const reduceTransparency = useReduceTransparency();
168
+ const mode = scrimMode(blur, BlurView !== null, reduceTransparency);
169
+
170
+ if (mode === 'blur' && BlurView) {
69
171
  return (
70
172
  <Animated.View
71
173
  entering={FadeIn.duration(180)}
@@ -81,6 +183,24 @@ export function Scrim({
81
183
  );
82
184
  }
83
185
 
186
+ if (mode === 'opaque') {
187
+ return (
188
+ <Animated.View
189
+ entering={FadeIn.duration(180)}
190
+ exiting={FadeOut.duration(150)}
191
+ style={[StyleSheet.absoluteFill, style]}
192
+ {...props}
193
+ >
194
+ <View
195
+ pointerEvents="none"
196
+ style={StyleSheet.absoluteFill}
197
+ className={opaqueClassName(tint)}
198
+ />
199
+ <View pointerEvents="none" style={StyleSheet.absoluteFill} className={dimClassName} />
200
+ </Animated.View>
201
+ );
202
+ }
203
+
84
204
  return (
85
205
  <Animated.View
86
206
  entering={FadeIn.duration(150)}