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
@@ -0,0 +1,98 @@
1
+ const FLOW_TOKEN_PREFIX = 'panelui-flow:';
2
+
3
+ export interface FlowEndpoint {
4
+ node: string;
5
+ handle?: string;
6
+ }
7
+
8
+ export type FlowEndpointReference = string | FlowEndpoint;
9
+
10
+ type EncodedReference = ['legacy', string] | ['endpoint', string] | ['endpoint', string, string];
11
+
12
+ function encodedReference(reference: FlowEndpointReference): EncodedReference {
13
+ if (typeof reference === 'string') return ['legacy', reference];
14
+ return reference.handle === undefined
15
+ ? ['endpoint', reference.node]
16
+ : ['endpoint', reference.node, reference.handle];
17
+ }
18
+
19
+ function decodedReference(value: unknown): FlowEndpointReference | undefined {
20
+ if (!Array.isArray(value)) return undefined;
21
+ if (value.length === 2 && value[0] === 'legacy' && typeof value[1] === 'string') {
22
+ return value[1];
23
+ }
24
+ if (
25
+ (value.length === 2 || value.length === 3) &&
26
+ value[0] === 'endpoint' &&
27
+ typeof value[1] === 'string' &&
28
+ (value.length === 2 || typeof value[2] === 'string')
29
+ ) {
30
+ return value.length === 2 ? { node: value[1] } : { node: value[1], handle: value[2] };
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ function decodeToken(token: string): unknown[] | undefined {
36
+ if (!token.startsWith(FLOW_TOKEN_PREFIX)) return undefined;
37
+ try {
38
+ const value: unknown = JSON.parse(token.slice(FLOW_TOKEN_PREFIX.length));
39
+ return Array.isArray(value) ? value : undefined;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ /** Opaque identity for a registered handle. Node and handle text remain untouched. */
46
+ export function encodeFlowHandleKey(node: string, handle: string): string {
47
+ return FLOW_TOKEN_PREFIX + JSON.stringify(['handle', node, handle]);
48
+ }
49
+
50
+ export function decodeFlowHandleKey(token: string): FlowEndpoint | undefined {
51
+ const value = decodeToken(token);
52
+ if (
53
+ value?.length !== 3 ||
54
+ value[0] !== 'handle' ||
55
+ typeof value[1] !== 'string' ||
56
+ typeof value[2] !== 'string'
57
+ ) {
58
+ return undefined;
59
+ }
60
+ return { node: value[1], handle: value[2] };
61
+ }
62
+
63
+ /** Opaque identity for an edge registration or accessibility connection action. */
64
+ export function encodeFlowEdgeKey(
65
+ from: FlowEndpointReference,
66
+ to: FlowEndpointReference
67
+ ): string {
68
+ return FLOW_TOKEN_PREFIX + JSON.stringify(['edge', encodedReference(from), encodedReference(to)]);
69
+ }
70
+
71
+ export function decodeFlowEdgeKey(
72
+ token: string
73
+ ): { from: FlowEndpointReference; to: FlowEndpointReference } | undefined {
74
+ const value = decodeToken(token);
75
+ if (value?.length !== 3 || value[0] !== 'edge') return undefined;
76
+ const from = decodedReference(value[1]);
77
+ const to = decodedReference(value[2]);
78
+ return from === undefined || to === undefined ? undefined : { from, to };
79
+ }
80
+
81
+ /**
82
+ * Resolve a public endpoint without splitting its identifiers. Exact node ids
83
+ * win; legacy `node.handle` text is accepted only when it names one registered
84
+ * handle. Structured endpoints cover the otherwise ambiguous case.
85
+ */
86
+ export function resolveFlowEndpoint(
87
+ reference: FlowEndpointReference,
88
+ nodes: readonly string[],
89
+ handles: readonly { node: string; id: string }[]
90
+ ): FlowEndpoint {
91
+ if (typeof reference !== 'string') return reference;
92
+ if (nodes.includes(reference)) return { node: reference };
93
+
94
+ const matches = handles.filter((handle) => `${handle.node}.${handle.id}` === reference);
95
+ return matches.length === 1
96
+ ? { node: matches[0]!.node, handle: matches[0]!.id }
97
+ : { node: reference };
98
+ }
@@ -107,6 +107,13 @@ import {
107
107
  type FlowAccessibilityHandle,
108
108
  type FlowAccessibilityNode,
109
109
  } from './flow-accessibility';
110
+ import {
111
+ encodeFlowEdgeKey,
112
+ encodeFlowHandleKey,
113
+ resolveFlowEndpoint,
114
+ type FlowEndpoint,
115
+ type FlowEndpointReference,
116
+ } from './flow-identifiers';
110
117
 
111
118
  const AnimatedPath = Animated.createAnimatedComponent(Path);
112
119
  const AnimatedRect = Animated.createAnimatedComponent(Rect);
@@ -1314,7 +1321,7 @@ function FlowHandle({
1314
1321
  unregisterHandle,
1315
1322
  connectNodes,
1316
1323
  } = flow;
1317
- const key = `${node}.${id}`;
1324
+ const key = encodeFlowHandleKey(node, id);
1318
1325
 
1319
1326
  useEffect(() => {
1320
1327
  registerHandle({
@@ -1481,10 +1488,10 @@ FlowHandle.displayName = 'Flow.Handle';
1481
1488
  /* -------------------------------------------------------------------------- */
1482
1489
 
1483
1490
  export interface FlowEdgeProps {
1484
- /** Source, as `"nodeId"` or `"nodeId.handleId"`. */
1485
- from: string;
1486
- /** Target, same shape. */
1487
- to: string;
1491
+ /** Source node or handle. Strings retain the `"nodeId.handleId"` shorthand. */
1492
+ from: FlowEndpointReference;
1493
+ /** Target node or handle, in the same shape. */
1494
+ to: FlowEndpointReference;
1488
1495
  /** How the edge is routed. */
1489
1496
  variant?: FlowEdgeVariant;
1490
1497
  /**
@@ -1530,7 +1537,7 @@ export interface FlowEdgeProps {
1530
1537
  */
1531
1538
  function FlowEdge(props: FlowEdgeProps) {
1532
1539
  const { registerEdge, unregisterEdge } = useFlow('Flow.Edge');
1533
- const key = `${props.from}->${props.to}`;
1540
+ const key = encodeFlowEdgeKey(props.from, props.to);
1534
1541
 
1535
1542
  // Re-registered whenever any prop changes, so `animated` toggling or a
1536
1543
  // variant switch reaches the layer that draws it.
@@ -1571,8 +1578,9 @@ function FlowEdgePath({
1571
1578
  tint: string;
1572
1579
  origin: { x: number; y: number };
1573
1580
  }) {
1574
- const source = resolveEnd(from, fromSide, handles);
1575
- const target = resolveEnd(to, toSide, handles);
1581
+ const nodes = Object.keys(boxes);
1582
+ const source = resolveEnd(from, fromSide, nodes, handles);
1583
+ const target = resolveEnd(to, toSide, nodes, handles);
1576
1584
 
1577
1585
  const fromRect = boxes[source.node];
1578
1586
  const toRect = boxes[target.node];
@@ -1631,22 +1639,24 @@ function FlowEdgePath({
1631
1639
  }
1632
1640
 
1633
1641
  /**
1634
- * Splits `"node"` or `"node.handle"` into the node and, when a handle was
1635
- * named, the face and offset that handle registered.
1642
+ * Resolves a node or handle without parsing either identifier. Legacy
1643
+ * `"node.handle"` references are matched against the structured registry.
1636
1644
  */
1637
1645
  function resolveEnd(
1638
- reference: string,
1646
+ reference: FlowEndpointReference,
1639
1647
  explicit: FlowSide | undefined,
1648
+ nodes: string[],
1640
1649
  handles: HandleEntry[]
1641
1650
  ): { node: string; side: FlowSide | undefined; offset: number } {
1642
- const dot = reference.indexOf('.');
1643
- if (dot === -1) {
1644
- return { node: reference, side: explicit, offset: 0.5 };
1645
- }
1646
- const node = reference.slice(0, dot);
1647
- const entry = handles.find((handle) => handle.key === reference);
1651
+ const endpoint = resolveFlowEndpoint(reference, nodes, handles);
1652
+ const entry =
1653
+ endpoint.handle === undefined
1654
+ ? undefined
1655
+ : handles.find(
1656
+ (handle) => handle.node === endpoint.node && handle.id === endpoint.handle
1657
+ );
1648
1658
  return {
1649
- node,
1659
+ node: endpoint.node,
1650
1660
  side: explicit ?? entry?.side,
1651
1661
  offset: entry?.offset ?? 0.5,
1652
1662
  };
@@ -2199,4 +2209,4 @@ export const Flow = Object.assign(FlowRoot, {
2199
2209
  MiniMap: FlowMiniMap,
2200
2210
  });
2201
2211
 
2202
- export type { FlowSide, FlowRect, FlowPoint };
2212
+ export type { FlowEndpoint, FlowEndpointReference, FlowSide, FlowRect, FlowPoint };
@@ -57,9 +57,12 @@ import { ChevronDownIcon } from '../../icons';
57
57
  import { AnimatedPressable } from '../../primitives/animated-pressable';
58
58
  import { cn } from '../../utils/cn';
59
59
  import { textChildren } from '../../primitives/text';
60
+ import {
61
+ distanceFromMessageScrollerTarget,
62
+ isMessageScrollerTargetVisible,
63
+ MESSAGE_SCROLLER_EDGE_THRESHOLD,
64
+ } from './message-scroller-math';
60
65
 
61
- /** Within this many pixels of the bottom still counts as being at the bottom. */
62
- const END_THRESHOLD = 24;
63
66
  /**
64
67
  * How much of the previous turn is left showing above an anchored one. Scrolling
65
68
  * a turn flush to the top reads as content having been cut off; a sliver of the
@@ -71,8 +74,11 @@ export type MessageScrollerPosition = 'start' | 'end' | 'last-anchor';
71
74
 
72
75
  interface MessageScrollerContextValue {
73
76
  scrollRef: ReturnType<typeof useAnimatedRef<Animated.ScrollView>>;
74
- /** 1 while the reader is at the live edge, 0 otherwise. Drives the button. */
77
+ /** 1 while the reader is at the live edge, 0 otherwise. Drives follow state. */
75
78
  atEnd: ReturnType<typeof useSharedValue<number>>;
79
+ /** Live distance from each edge, used by target-specific jump controls. */
80
+ distanceFromStart: ReturnType<typeof useSharedValue<number>>;
81
+ distanceFromEnd: ReturnType<typeof useSharedValue<number>>;
76
82
  atEndJS: boolean;
77
83
  setAtEndJS: (atEnd: boolean) => void;
78
84
  autoScroll: boolean;
@@ -150,6 +156,11 @@ function MessageScrollerRoot({
150
156
  }: MessageScrollerProps) {
151
157
  const scrollRef = useAnimatedRef<Animated.ScrollView>();
152
158
  const atEnd = useSharedValue(1);
159
+ const awayFromEdge = MESSAGE_SCROLLER_EDGE_THRESHOLD + 1;
160
+ const distanceFromStart = useSharedValue(
161
+ defaultScrollPosition === 'start' ? 0 : awayFromEdge
162
+ );
163
+ const distanceFromEnd = useSharedValue(defaultScrollPosition === 'end' ? 0 : awayFromEdge);
153
164
  const [atEndJS, setAtEndJS] = useState(true);
154
165
  const [currentAnchorId, setCurrentAnchorId] = useState<string | null>(null);
155
166
 
@@ -199,6 +210,8 @@ function MessageScrollerRoot({
199
210
  () => ({
200
211
  scrollRef,
201
212
  atEnd,
213
+ distanceFromStart,
214
+ distanceFromEnd,
202
215
  atEndJS,
203
216
  setAtEndJS,
204
217
  autoScroll,
@@ -218,6 +231,8 @@ function MessageScrollerRoot({
218
231
  [
219
232
  scrollRef,
220
233
  atEnd,
234
+ distanceFromStart,
235
+ distanceFromEnd,
221
236
  atEndJS,
222
237
  autoScroll,
223
238
  preserveScrollOnPrepend,
@@ -268,6 +283,8 @@ function MessageScrollerViewport({
268
283
  const {
269
284
  scrollRef,
270
285
  atEnd,
286
+ distanceFromStart,
287
+ distanceFromEnd,
271
288
  setAtEndJS,
272
289
  autoScroll,
273
290
  preserveScrollOnPrepend,
@@ -310,8 +327,19 @@ function MessageScrollerViewport({
310
327
  const { contentOffset, contentSize, layoutMeasurement } = event;
311
328
  offsetY.value = contentOffset.y;
312
329
 
313
- const distance = contentSize.height - layoutMeasurement.height - contentOffset.y;
314
- const next = distance <= END_THRESHOLD ? 1 : 0;
330
+ distanceFromStart.value = distanceFromMessageScrollerTarget(
331
+ 'start',
332
+ contentOffset.y,
333
+ contentSize.height,
334
+ layoutMeasurement.height
335
+ );
336
+ distanceFromEnd.value = distanceFromMessageScrollerTarget(
337
+ 'end',
338
+ contentOffset.y,
339
+ contentSize.height,
340
+ layoutMeasurement.height
341
+ );
342
+ const next = isMessageScrollerTargetVisible(distanceFromEnd.value) ? 0 : 1;
315
343
  if (next !== atEnd.value) {
316
344
  atEnd.value = next;
317
345
  runOnJS(publishAtEnd)(next === 1);
@@ -515,12 +543,16 @@ function MessageScrollerButton({
515
543
  children,
516
544
  ...props
517
545
  }: MessageScrollerButtonProps) {
518
- const { atEnd, scrollToEnd, scrollToStart } = useScroller('MessageScroller.Button');
546
+ const { distanceFromStart, distanceFromEnd, scrollToEnd, scrollToStart } =
547
+ useScroller('MessageScroller.Button');
519
548
  const tint = useCSSVariable('--color-foreground');
520
549
 
550
+ const targetDistance = target === 'end' ? distanceFromEnd : distanceFromStart;
521
551
  // Shown when away from the edge the button points at.
522
552
  const shown = useDerivedValue(() =>
523
- withTiming(target === 'end' ? 1 - atEnd.value : atEnd.value, { duration: 160 })
553
+ withTiming(isMessageScrollerTargetVisible(targetDistance.value) ? 1 : 0, {
554
+ duration: 160,
555
+ })
524
556
  );
525
557
 
526
558
  const style = useAnimatedStyle(() => ({
@@ -0,0 +1,24 @@
1
+ type MessageScrollerTarget = 'start' | 'end';
2
+
3
+ export const MESSAGE_SCROLLER_EDGE_THRESHOLD = 24;
4
+
5
+ /** Distance from the viewport to the edge a jump button targets. */
6
+ export function distanceFromMessageScrollerTarget(
7
+ target: MessageScrollerTarget,
8
+ contentOffsetY: number,
9
+ contentHeight: number,
10
+ viewportHeight: number
11
+ ): number {
12
+ 'worklet';
13
+
14
+ const maximumOffset = Math.max(0, contentHeight - viewportHeight);
15
+ const boundedOffset = Math.min(maximumOffset, Math.max(0, contentOffsetY));
16
+ return target === 'start' ? boundedOffset : maximumOffset - boundedOffset;
17
+ }
18
+
19
+ /** Whether the control has enough distance to its target edge to be useful. */
20
+ export function isMessageScrollerTargetVisible(distance: number): boolean {
21
+ 'worklet';
22
+
23
+ return distance > MESSAGE_SCROLLER_EDGE_THRESHOLD;
24
+ }
@@ -321,6 +321,9 @@ export interface PopoverContentProps extends ViewProps {
321
321
  * Frost the background behind the panel instead of dimming it. Uses
322
322
  * `expo-blur` when installed and falls back to the dimmed scrim when it is
323
323
  * not, so it is safe to pass either way.
324
+ *
325
+ * Someone who has Reduce Transparency switched on gets an opaque
326
+ * backdrop instead, which is the whole point of the setting.
324
327
  */
325
328
  blur?: boolean;
326
329
  /**
@@ -88,6 +88,15 @@ import { Text, textChildren } from '../../primitives/text';
88
88
  import { cn } from '../../utils/cn';
89
89
  import { selectionTick } from '../../utils/haptics';
90
90
  import { BottomSheet } from '../bottom-sheet';
91
+ import {
92
+ canEnterSelection,
93
+ handleSelectionItemPress,
94
+ selectAllValues,
95
+ selectionOwnsPress,
96
+ selectionTarget,
97
+ selectValue,
98
+ toggleValue,
99
+ } from './selection-mode-contracts';
91
100
 
92
101
  /** How long the circle takes to come and go, in milliseconds. */
93
102
  const REVEAL_DURATION = 180;
@@ -293,11 +302,12 @@ function SelectionModeRoot({
293
302
  // Entering with the row that was pressed already picked. Entering with
294
303
  // nothing picked leaves the reader in a list that has changed under them
295
304
  // with nothing to show for it.
296
- if (value !== undefined && !selectedRef.current.includes(value)) {
297
- setSelected([...selectedRef.current, value]);
305
+ if (value !== undefined) {
306
+ const next = selectValue(selectedRef.current, value, max);
307
+ if (next !== selectedRef.current) setSelected(next);
298
308
  }
299
309
  },
300
- [haptics, setActive, setSelected]
310
+ [haptics, max, setActive, setSelected]
301
311
  );
302
312
 
303
313
  /*
@@ -321,13 +331,10 @@ function SelectionModeRoot({
321
331
  const toggle = useCallback(
322
332
  (value: string) => {
323
333
  const current = selectedRef.current;
324
- if (current.includes(value)) {
325
- setSelected(current.filter((entry) => entry !== value));
326
- } else {
327
- if (max !== undefined && current.length >= max) return;
328
- if (haptics) selectionTick();
329
- setSelected([...current, value]);
330
- }
334
+ const next = toggleValue(current, value, max);
335
+ if (next === current) return;
336
+ if (!current.includes(value) && haptics) selectionTick();
337
+ setSelected(next);
331
338
  },
332
339
  [max, haptics, setSelected]
333
340
  );
@@ -336,15 +343,15 @@ function SelectionModeRoot({
336
343
  if (!values) return;
337
344
  // At the limit rather than refusing: somebody who asked for all of them and
338
345
  // can only have twenty wants the twenty, not an error.
339
- setSelected(max === undefined ? [...values] : values.slice(0, max));
346
+ setSelected(selectAllValues(values, max));
340
347
  }, [values, max, setSelected]);
341
348
 
342
349
  const clear = useCallback(() => setSelected([]), [setSelected]);
343
350
 
344
351
  const total = values?.length ?? 0;
345
352
  const count = selected.length;
346
- const allSelected =
347
- total > 0 && count >= (max === undefined ? total : Math.min(total, max));
353
+ const target = selectionTarget(total, max);
354
+ const allSelected = target > 0 && count >= target;
348
355
 
349
356
  // An open mode owns the back button: back should leave the mode, not the
350
357
  // screen the list is on.
@@ -459,10 +466,11 @@ export interface SelectionModeItemProps extends Omit<ViewProps, 'children'> {
459
466
  className?: string;
460
467
  /** This row's id. What ends up in `selected`. */
461
468
  value: string;
462
- /** What the row does when it is pressed and the mode is off. */
469
+ /** What the row does whenever selection does not own its press. */
463
470
  onPress?: () => void;
464
471
  /**
465
- * Stop this row entering selection mode, and being picked once in it. For a
472
+ * Stop this row entering selection mode, and being picked once in it. Its
473
+ * ordinary `onPress` still runs, including while selection is active. For a
466
474
  * header row, an advert, a "load more" — anything in the list that is not one
467
475
  * of the things being chosen between.
468
476
  */
@@ -487,8 +495,9 @@ export interface SelectionModeItemProps extends Omit<ViewProps, 'children'> {
487
495
  *
488
496
  * The press behaviour is the whole component: off mode, a press is the row's
489
497
  * own and a long press turns the mode on with this row picked; in it, a press
490
- * picks and unpicks and the row's own press is unreachable. Two meanings for
491
- * one gesture is exactly why the mode has to be visible from the header.
498
+ * picks and unpicks. A row excluded from selection keeps its ordinary press in
499
+ * both states. Two meanings for one gesture is exactly why the mode has to be
500
+ * visible from the header.
492
501
  */
493
502
  function SelectionModeItem({
494
503
  className,
@@ -503,6 +512,7 @@ function SelectionModeItem({
503
512
  const { active, enter, toggle, isSelected, sheet } = useSelectionMode();
504
513
  const selected = isSelected(value);
505
514
  const showing = alwaysShowIndicator || active;
515
+ const selecting = selectionOwnsPress(active, disabled);
506
516
  const reducedMotion = useReducedMotion();
507
517
 
508
518
  const context = useMemo(() => ({ value }), [value]);
@@ -537,23 +547,20 @@ function SelectionModeItem({
537
547
  return (
538
548
  <SelectionModeItemContext.Provider value={context}>
539
549
  <Pressable
540
- accessibilityRole={showing ? 'checkbox' : 'button'}
541
- accessibilityState={showing ? { checked: selected, disabled } : { disabled }}
542
- // The row is unreachable in selection mode, so a screen reader is told
543
- // what pressing it does now rather than what it used to do.
550
+ accessibilityRole={selecting ? 'checkbox' : 'button'}
551
+ accessibilityState={selecting ? { checked: selected } : undefined}
552
+ // Eligible rows become checkboxes only while selection owns their
553
+ // press. Excluded rows remain ordinary buttons because their action is
554
+ // still available.
544
555
  accessibilityHint={
545
- showing ? undefined : disabled ? undefined : 'Long press to select'
556
+ active || disabled ? undefined : 'Long press to select'
546
557
  }
547
- disabled={disabled && showing}
548
558
  onPress={() => {
549
- if (disabled) return;
550
- if (showing) toggle(value);
551
- else onPress?.();
552
- }}
553
- onLongPress={sheet ? undefined : () => {
554
- if (disabled || showing) return;
555
- enter(value);
559
+ handleSelectionItemPress(active, disabled, () => toggle(value), onPress);
556
560
  }}
561
+ onLongPress={
562
+ canEnterSelection(active, disabled, sheet) ? () => enter(value) : undefined
563
+ }
557
564
  className={cn(
558
565
  indicator === 'leading' ? 'flex-row items-center gap-3 px-4 py-2.5' : '',
559
566
  className
@@ -0,0 +1,53 @@
1
+ /** The effective selection cap. Invalid and negative caps allow no additions. */
2
+ function selectionLimit(max?: number): number {
3
+ if (max === undefined || max === Infinity) return Infinity;
4
+ return Number.isFinite(max) ? Math.max(0, Math.floor(max)) : 0;
5
+ }
6
+
7
+ /** Add one value without duplicating it or crossing the configured cap. */
8
+ export function selectValue(selected: string[], value: string, max?: number): string[] {
9
+ if (selected.includes(value) || !(selected.length < selectionLimit(max))) return selected;
10
+ return [...selected, value];
11
+ }
12
+
13
+ /** Remove an existing value, or add it under the same contract used by entry. */
14
+ export function toggleValue(selected: string[], value: string, max?: number): string[] {
15
+ return selected.includes(value)
16
+ ? selected.filter((entry) => entry !== value)
17
+ : selectValue(selected, value, max);
18
+ }
19
+
20
+ /** The values produced by select-all, capped by the same normalized limit. */
21
+ export function selectAllValues(values: string[], max?: number): string[] {
22
+ const limit = selectionLimit(max);
23
+ return limit === Infinity ? [...values] : values.slice(0, limit);
24
+ }
25
+
26
+ /** How many selections constitute all selectable values. */
27
+ export function selectionTarget(total: number, max?: number): number {
28
+ return Math.min(total, selectionLimit(max));
29
+ }
30
+
31
+ /**
32
+ * A selection-ineligible row keeps its ordinary action in every mode. Only an
33
+ * active, eligible row gives its press to selection.
34
+ */
35
+ export function selectionOwnsPress(active: boolean, disabled: boolean): boolean {
36
+ return active && !disabled;
37
+ }
38
+
39
+ /** Execute the one action owned by a row press under the interaction contract. */
40
+ export function handleSelectionItemPress(
41
+ active: boolean,
42
+ disabled: boolean,
43
+ toggle: () => void,
44
+ action?: () => void
45
+ ): void {
46
+ if (selectionOwnsPress(active, disabled)) toggle();
47
+ else action?.();
48
+ }
49
+
50
+ /** Long press enters only from an inactive screen row that can be selected. */
51
+ export function canEnterSelection(active: boolean, disabled: boolean, sheet: boolean): boolean {
52
+ return !active && !disabled && !sheet;
53
+ }