panelui-native 0.63.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 (226) hide show
  1. package/README.md +5 -0
  2. package/lib/module/components/badge/index.js +1 -1
  3. package/lib/module/components/badge/index.js.map +1 -1
  4. package/lib/module/components/bar-chart/index.js +39 -2
  5. package/lib/module/components/bar-chart/index.js.map +1 -1
  6. package/lib/module/components/bottom-sheet/index.js +2 -2
  7. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  8. package/lib/module/components/button/index.js +19 -9
  9. package/lib/module/components/button/index.js.map +1 -1
  10. package/lib/module/components/carousel/carousel-lifecycle.js +51 -0
  11. package/lib/module/components/carousel/carousel-lifecycle.js.map +1 -0
  12. package/lib/module/components/carousel/index.js +86 -35
  13. package/lib/module/components/carousel/index.js.map +1 -1
  14. package/lib/module/components/checkbox/index.js +2 -3
  15. package/lib/module/components/checkbox/index.js.map +1 -1
  16. package/lib/module/components/context-menu/context-menu-invocation.js +23 -0
  17. package/lib/module/components/context-menu/context-menu-invocation.js.map +1 -0
  18. package/lib/module/components/context-menu/index.js +78 -9
  19. package/lib/module/components/context-menu/index.js.map +1 -1
  20. package/lib/module/components/dialog/index.js +2 -2
  21. package/lib/module/components/dialog/index.js.map +1 -1
  22. package/lib/module/components/drawer/index.js +2 -2
  23. package/lib/module/components/drawer/index.js.map +1 -1
  24. package/lib/module/components/fab/index.js +4 -3
  25. package/lib/module/components/fab/index.js.map +1 -1
  26. package/lib/module/components/field/index.js +41 -6
  27. package/lib/module/components/field/index.js.map +1 -1
  28. package/lib/module/components/flow/flow-accessibility.js +100 -0
  29. package/lib/module/components/flow/flow-accessibility.js.map +1 -0
  30. package/lib/module/components/flow/flow-identifiers.js +81 -0
  31. package/lib/module/components/flow/flow-identifiers.js.map +1 -0
  32. package/lib/module/components/flow/index.js +112 -57
  33. package/lib/module/components/flow/index.js.map +1 -1
  34. package/lib/module/components/form/use-form.js +93 -66
  35. package/lib/module/components/form/use-form.js.map +1 -1
  36. package/lib/module/components/heatmap-chart/index.js +2 -2
  37. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  38. package/lib/module/components/hex-chart/index.js +2 -2
  39. package/lib/module/components/hex-chart/index.js.map +1 -1
  40. package/lib/module/components/kpi/index.js +3 -1
  41. package/lib/module/components/kpi/index.js.map +1 -1
  42. package/lib/module/components/line-chart/index.js +3 -3
  43. package/lib/module/components/line-chart/index.js.map +1 -1
  44. package/lib/module/components/map/index.js +71 -8
  45. package/lib/module/components/map/index.js.map +1 -1
  46. package/lib/module/components/map/map-accessibility.js +20 -0
  47. package/lib/module/components/map/map-accessibility.js.map +1 -0
  48. package/lib/module/components/map/maplibre.js.map +1 -1
  49. package/lib/module/components/menu/index.js +4 -1
  50. package/lib/module/components/menu/index.js.map +1 -1
  51. package/lib/module/components/message-scroller/index.js +17 -9
  52. package/lib/module/components/message-scroller/index.js.map +1 -1
  53. package/lib/module/components/message-scroller/message-scroller-math.js +20 -0
  54. package/lib/module/components/message-scroller/message-scroller-math.js.map +1 -0
  55. package/lib/module/components/number-input/index.js +1 -23
  56. package/lib/module/components/number-input/index.js.map +1 -1
  57. package/lib/module/components/number-input/number-input-math.js +44 -0
  58. package/lib/module/components/number-input/number-input-math.js.map +1 -0
  59. package/lib/module/components/plot/index.js +2 -2
  60. package/lib/module/components/plot/index.js.map +1 -1
  61. package/lib/module/components/popover/index.js.map +1 -1
  62. package/lib/module/components/radio-group/index.js +1 -2
  63. package/lib/module/components/radio-group/index.js.map +1 -1
  64. package/lib/module/components/selection-mode/index.js +26 -31
  65. package/lib/module/components/selection-mode/index.js.map +1 -1
  66. package/lib/module/components/selection-mode/selection-mode-contracts.js +48 -0
  67. package/lib/module/components/selection-mode/selection-mode-contracts.js.map +1 -0
  68. package/lib/module/components/signature/index.js +62 -20
  69. package/lib/module/components/signature/index.js.map +1 -1
  70. package/lib/module/components/signature/signature-accessibility.js +58 -0
  71. package/lib/module/components/signature/signature-accessibility.js.map +1 -0
  72. package/lib/module/components/slider/index.js +22 -18
  73. package/lib/module/components/slider/index.js.map +1 -1
  74. package/lib/module/components/sortable/index.js +5 -55
  75. package/lib/module/components/sortable/index.js.map +1 -1
  76. package/lib/module/components/sortable/reorder.js +63 -0
  77. package/lib/module/components/sortable/reorder.js.map +1 -0
  78. package/lib/module/components/swipe/index.js +4 -4
  79. package/lib/module/components/swipe/index.js.map +1 -1
  80. package/lib/module/components/switch/index.js +36 -3
  81. package/lib/module/components/switch/index.js.map +1 -1
  82. package/lib/module/components/time-picker/accessibility.js +21 -0
  83. package/lib/module/components/time-picker/accessibility.js.map +1 -0
  84. package/lib/module/components/time-picker/index.js +34 -8
  85. package/lib/module/components/time-picker/index.js.map +1 -1
  86. package/lib/module/components/waterfall-chart/index.js +1325 -0
  87. package/lib/module/components/waterfall-chart/index.js.map +1 -0
  88. package/lib/module/index.js +1 -0
  89. package/lib/module/index.js.map +1 -1
  90. package/lib/module/primitives/modal-isolation-store.js +29 -0
  91. package/lib/module/primitives/modal-isolation-store.js.map +1 -0
  92. package/lib/module/primitives/portal.js +36 -4
  93. package/lib/module/primitives/portal.js.map +1 -1
  94. package/lib/module/primitives/scrim.js +94 -3
  95. package/lib/module/primitives/scrim.js.map +1 -1
  96. package/lib/module/providers/panel-ui-provider.js +27 -6
  97. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  98. package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
  99. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  100. package/lib/typescript/src/components/bottom-sheet/index.d.ts +3 -0
  101. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/carousel/carousel-lifecycle.d.ts +23 -0
  104. package/lib/typescript/src/components/carousel/carousel-lifecycle.d.ts.map +1 -0
  105. package/lib/typescript/src/components/carousel/index.d.ts +5 -2
  106. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -1
  107. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  108. package/lib/typescript/src/components/context-menu/context-menu-invocation.d.ts +20 -0
  109. package/lib/typescript/src/components/context-menu/context-menu-invocation.d.ts.map +1 -0
  110. package/lib/typescript/src/components/context-menu/index.d.ts +18 -8
  111. package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -1
  112. package/lib/typescript/src/components/dialog/index.d.ts +3 -0
  113. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  114. package/lib/typescript/src/components/drawer/index.d.ts +3 -0
  115. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
  116. package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
  117. package/lib/typescript/src/components/field/index.d.ts +8 -1
  118. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  119. package/lib/typescript/src/components/flow/flow-accessibility.d.ts +57 -0
  120. package/lib/typescript/src/components/flow/flow-accessibility.d.ts.map +1 -0
  121. package/lib/typescript/src/components/flow/flow-identifiers.d.ts +24 -0
  122. package/lib/typescript/src/components/flow/flow-identifiers.d.ts.map +1 -0
  123. package/lib/typescript/src/components/flow/index.d.ts +20 -7
  124. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  125. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
  126. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  127. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  128. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  129. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  130. package/lib/typescript/src/components/map/index.d.ts +13 -5
  131. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  132. package/lib/typescript/src/components/map/map-accessibility.d.ts +19 -0
  133. package/lib/typescript/src/components/map/map-accessibility.d.ts.map +1 -0
  134. package/lib/typescript/src/components/map/maplibre.d.ts +2 -0
  135. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
  136. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  137. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  138. package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts +8 -0
  139. package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts.map +1 -0
  140. package/lib/typescript/src/components/number-input/index.d.ts.map +1 -1
  141. package/lib/typescript/src/components/number-input/number-input-math.d.ts +9 -0
  142. package/lib/typescript/src/components/number-input/number-input-math.d.ts.map +1 -0
  143. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  144. package/lib/typescript/src/components/popover/index.d.ts +3 -0
  145. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  146. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  147. package/lib/typescript/src/components/selection-mode/index.d.ts +6 -4
  148. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
  149. package/lib/typescript/src/components/selection-mode/selection-mode-contracts.d.ts +18 -0
  150. package/lib/typescript/src/components/selection-mode/selection-mode-contracts.d.ts.map +1 -0
  151. package/lib/typescript/src/components/signature/index.d.ts +6 -0
  152. package/lib/typescript/src/components/signature/index.d.ts.map +1 -1
  153. package/lib/typescript/src/components/signature/signature-accessibility.d.ts +27 -0
  154. package/lib/typescript/src/components/signature/signature-accessibility.d.ts.map +1 -0
  155. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  156. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  157. package/lib/typescript/src/components/sortable/reorder.d.ts +14 -0
  158. package/lib/typescript/src/components/sortable/reorder.d.ts.map +1 -0
  159. package/lib/typescript/src/components/swipe/index.d.ts.map +1 -1
  160. package/lib/typescript/src/components/switch/index.d.ts +3 -3
  161. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  162. package/lib/typescript/src/components/time-picker/accessibility.d.ts +10 -0
  163. package/lib/typescript/src/components/time-picker/accessibility.d.ts.map +1 -0
  164. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  165. package/lib/typescript/src/components/waterfall-chart/index.d.ts +379 -0
  166. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -0
  167. package/lib/typescript/src/index.d.ts +3 -2
  168. package/lib/typescript/src/index.d.ts.map +1 -1
  169. package/lib/typescript/src/primitives/modal-isolation-store.d.ts +10 -0
  170. package/lib/typescript/src/primitives/modal-isolation-store.d.ts.map +1 -0
  171. package/lib/typescript/src/primitives/portal.d.ts +6 -0
  172. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  173. package/lib/typescript/src/primitives/scrim.d.ts +5 -3
  174. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -1
  175. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  176. package/package.json +11 -5
  177. package/src/components/badge/index.tsx +4 -1
  178. package/src/components/bar-chart/index.tsx +40 -3
  179. package/src/components/bottom-sheet/index.tsx +6 -3
  180. package/src/components/button/index.tsx +15 -21
  181. package/src/components/carousel/carousel-lifecycle.ts +74 -0
  182. package/src/components/carousel/index.tsx +118 -41
  183. package/src/components/checkbox/index.tsx +1 -2
  184. package/src/components/context-menu/context-menu-invocation.ts +44 -0
  185. package/src/components/context-menu/index.tsx +113 -13
  186. package/src/components/dialog/index.tsx +6 -3
  187. package/src/components/drawer/index.tsx +6 -3
  188. package/src/components/fab/index.tsx +8 -12
  189. package/src/components/field/index.tsx +52 -10
  190. package/src/components/flow/flow-accessibility.ts +123 -0
  191. package/src/components/flow/flow-identifiers.ts +98 -0
  192. package/src/components/flow/index.tsx +191 -69
  193. package/src/components/form/use-form.ts +101 -65
  194. package/src/components/heatmap-chart/index.tsx +2 -3
  195. package/src/components/hex-chart/index.tsx +2 -3
  196. package/src/components/kpi/index.tsx +3 -1
  197. package/src/components/line-chart/index.tsx +3 -2
  198. package/src/components/map/index.tsx +110 -7
  199. package/src/components/map/map-accessibility.ts +41 -0
  200. package/src/components/map/maplibre.ts +2 -0
  201. package/src/components/menu/index.tsx +4 -1
  202. package/src/components/message-scroller/index.tsx +39 -7
  203. package/src/components/message-scroller/message-scroller-math.ts +24 -0
  204. package/src/components/number-input/index.tsx +1 -26
  205. package/src/components/number-input/number-input-math.ts +50 -0
  206. package/src/components/plot/index.tsx +2 -1
  207. package/src/components/popover/index.tsx +3 -0
  208. package/src/components/radio-group/index.tsx +1 -2
  209. package/src/components/selection-mode/index.tsx +37 -30
  210. package/src/components/selection-mode/selection-mode-contracts.ts +53 -0
  211. package/src/components/signature/index.tsx +107 -20
  212. package/src/components/signature/signature-accessibility.ts +67 -0
  213. package/src/components/slider/index.tsx +28 -18
  214. package/src/components/sortable/index.tsx +12 -64
  215. package/src/components/sortable/reorder.ts +88 -0
  216. package/src/components/swipe/index.tsx +7 -4
  217. package/src/components/switch/index.tsx +42 -6
  218. package/src/components/time-picker/accessibility.ts +24 -0
  219. package/src/components/time-picker/index.tsx +48 -2
  220. package/src/components/waterfall-chart/index.tsx +1589 -0
  221. package/src/index.ts +25 -0
  222. package/src/primitives/modal-isolation-store.ts +31 -0
  223. package/src/primitives/portal.tsx +34 -4
  224. package/src/primitives/scrim.tsx +127 -7
  225. package/src/providers/panel-ui-provider.tsx +41 -9
  226. package/theme.css +30 -3
@@ -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
+ }
@@ -34,6 +34,7 @@ import { AnimatedPressable } from '../../primitives/animated-pressable';
34
34
  import { Text } from '../../primitives/text';
35
35
  import { selectionTick } from '../../utils/haptics';
36
36
  import { Label } from '../label';
37
+ import { normalize, precisionOf } from './number-input-math';
37
38
 
38
39
  /** Wait before a held button starts repeating — a tap must not trip it. */
39
40
  const REPEAT_DELAY = 400;
@@ -156,32 +157,6 @@ export interface NumberInputProps
156
157
  haptics?: boolean;
157
158
  }
158
159
 
159
- /** Decimals implied by `step`, so display and math round to the same place. */
160
- function precisionOf(step: number): number {
161
- if (!isFinite(step)) return 0;
162
- const s = String(step);
163
- const dot = s.indexOf('.');
164
- return dot === -1 ? 0 : s.length - dot - 1;
165
- }
166
-
167
- function clamp(value: number, min: number, max: number): number {
168
- return Math.min(Math.max(value, min), max);
169
- }
170
-
171
- /**
172
- * Snaps to the nearest step from a finite base (or 0 when `min` is open), then
173
- * rounds to the step's precision so 0.1 + 0.2 does not drift to 0.30000004.
174
- */
175
- function normalize(value: number, min: number, max: number, step: number): number {
176
- const base = isFinite(min) ? min : 0;
177
- const snapped = isFinite(step) && step > 0
178
- ? Math.round((value - base) / step) * step + base
179
- : value;
180
- const p = precisionOf(step);
181
- const factor = 10 ** p;
182
- return clamp(Math.round(snapped * factor) / factor, min, max);
183
- }
184
-
185
160
  /**
186
161
  * A number stepped by buttons or typed by hand, reconciled to one clamped,
187
162
  * step-snapped value. See the file header for why it keeps both affordances.
@@ -0,0 +1,50 @@
1
+ /** JavaScript numbers carry about fifteen reliable decimal digits. */
2
+ const MAX_DECIMAL_PRECISION = 15;
3
+
4
+ /** Decimals `step` implies, before the reliable-digits ceiling is applied. */
5
+ function requestedPrecision(step: number): number {
6
+ if (!Number.isFinite(step)) return 0;
7
+
8
+ const [coefficient = '', exponentText] = Math.abs(step).toString().toLowerCase().split('e');
9
+ const decimals = coefficient.split('.')[1]?.length ?? 0;
10
+ const exponent = exponentText === undefined ? 0 : Number(exponentText);
11
+
12
+ return Math.max(0, decimals - exponent);
13
+ }
14
+
15
+ /** Decimals implied by `step`, including numbers written with an exponent. */
16
+ export function precisionOf(step: number): number {
17
+ return Math.min(MAX_DECIMAL_PRECISION, requestedPrecision(step));
18
+ }
19
+
20
+ export function clamp(value: number, min: number, max: number): number {
21
+ return Math.min(Math.max(value, min), max);
22
+ }
23
+
24
+ /**
25
+ * Snaps to the nearest step from a finite base, then clears binary drift at
26
+ * the step's decimal precision.
27
+ */
28
+ export function normalize(value: number, min: number, max: number, step: number): number {
29
+ const base = Number.isFinite(min) ? min : 0;
30
+ const offset = (value - base) / step;
31
+ const snapped =
32
+ Number.isFinite(step) && step > 0 && Number.isFinite(offset)
33
+ ? Math.round(offset) * step + base
34
+ : value;
35
+
36
+ // A step finer than the reliable-digit ceiling cannot be tidied at its own
37
+ // precision: rounding at the ceiling instead lands on zero and takes the
38
+ // value with it. The snap already placed it on the step, so leave it there.
39
+ if (requestedPrecision(step) > MAX_DECIMAL_PRECISION) return clamp(snapped, min, max);
40
+
41
+ const precision = precisionOf(step);
42
+ const factor = 10 ** precision;
43
+ const scaled = snapped * factor;
44
+ // Keep the established rounding path while it is safe. toFixed avoids
45
+ // turning a finite large value into Infinity when scaling would overflow.
46
+ const rounded = Number.isFinite(scaled)
47
+ ? Math.round(scaled) / factor
48
+ : Number(snapped.toFixed(precision));
49
+ return clamp(rounded, min, max);
50
+ }
@@ -68,6 +68,7 @@ import {
68
68
  isValidElement,
69
69
  useContext,
70
70
  useEffect,
71
+ useId,
71
72
  useImperativeHandle,
72
73
  useMemo,
73
74
  useRef,
@@ -328,7 +329,7 @@ const PlotRoot = forwardRef<PlotHandle, PlotProps>(function PlotRoot(
328
329
  const [size, setSize] = useState({ width: 0, height: 0 });
329
330
  const [series, setSeries] = useState<[string, string][]>([]);
330
331
  const [activeIndexJS, setActiveIndexJS] = useState(-1);
331
- const clipId = useRef(`panelui-plot-${Math.random().toString(36).slice(2, 9)}`).current;
332
+ const clipId = `panelui-plot-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
332
333
 
333
334
  const reveal = useSharedValue(0);
334
335
  const domainMin = useSharedValue(0);
@@ -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
  /**
@@ -45,7 +45,7 @@ const itemVariants = tv({
45
45
  // `self-start` matters: the group is a column, so without it Yoga stretches
46
46
  // the row to the full width and the dead space to the right of the label
47
47
  // becomes part of the target. Tapping nothing would select the option.
48
- row: 'flex-row items-center gap-2.5 self-start',
48
+ row: 'min-h-12 min-w-12 flex-row items-center gap-2.5 self-start',
49
49
  indicator:
50
50
  'h-5 w-5 items-center justify-center rounded-full border border-input bg-background',
51
51
  label: 'text-sm text-foreground',
@@ -248,7 +248,6 @@ const RadioGroupItem = forwardRef<View, RadioGroupItemProps>(
248
248
  accessibilityLabel={label}
249
249
  disabled={disabled}
250
250
  onPress={() => context.onValueChange(value)}
251
- hitSlop={variant === 'card' ? undefined : 8}
252
251
  className={slots.row({ className })}
253
252
  >
254
253
  {body}
@@ -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
+ }
@@ -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