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
@@ -4,7 +4,8 @@
4
4
  * A `Menu` hangs off a control that exists to be pressed: a ⋯ button, a toolbar
5
5
  * item, something whose whole job is to open the menu. A context menu has no
6
6
  * such control. The target is the content itself — a message, a note, a photo,
7
- * a row — and the actions are reached by pressing and holding it.
7
+ * a row — and the actions are reached by holding it, by a named accessibility
8
+ * action, or from the keyboard.
8
9
  *
9
10
  * ```tsx
10
11
  * <ContextMenu>
@@ -31,9 +32,9 @@
31
32
  * them. The panel is `Menu`'s panel, which is `Popover`'s, so `presentation`,
32
33
  * submenus and edge-flipping all arrive already working.
33
34
  *
34
- * What this component owns is the two things a menu opened on content needs and
35
- * a menu opened from a button does not: the long press, and where the panel
36
- * goes.
35
+ * What this component owns is what a menu opened on content needs and a menu
36
+ * opened from a button does not: alternate invocation paths, and where the
37
+ * panel goes.
37
38
  *
38
39
  * ## Anchored to the finger, not to the target
39
40
  *
@@ -73,7 +74,11 @@ import {
73
74
  type ReactElement,
74
75
  type ReactNode,
75
76
  } from 'react';
76
- import { View, type ViewProps } from 'react-native';
77
+ import {
78
+ View,
79
+ type AccessibilityActionInfo,
80
+ type ViewProps,
81
+ } from 'react-native';
77
82
  import { Gesture, GestureDetector } from 'react-native-gesture-handler';
78
83
  import Animated, {
79
84
  FadeOut,
@@ -95,6 +100,12 @@ import {
95
100
  import { usePopoverAnchor } from '../popover';
96
101
  import { cn } from '../../utils/cn';
97
102
  import { selectionTick } from '../../utils/haptics';
103
+ import {
104
+ contextMenuAccessibilityInvocation,
105
+ contextMenuKeyInvocation,
106
+ type ContextMenuInvocation,
107
+ type ContextMenuKeyDownEvent,
108
+ } from './context-menu-invocation';
98
109
 
99
110
  /**
100
111
  * How long the target is held before the menu opens.
@@ -260,7 +271,7 @@ function ContextMenuRoot({ children, ...props }: ContextMenuProps) {
260
271
  /** Which rectangle the panel is placed against. */
261
272
  export type ContextMenuAnchor = 'point' | 'target';
262
273
 
263
- export interface ContextMenuTriggerProps extends Omit<ViewProps, 'children'> {
274
+ export interface ContextMenuTriggerProps extends Omit<ViewProps, 'children' | 'onKeyDown'> {
264
275
  /**
265
276
  * Classes on the wrapper the content sits in, which lays out like any other
266
277
  * view — it does not shrink to its child, because the things held are usually
@@ -280,7 +291,8 @@ export interface ContextMenuTriggerProps extends Omit<ViewProps, 'children'> {
280
291
  * Point is the default because a context menu's target is usually large, and
281
292
  * the middle of a whole message is not where the press was. Reach for
282
293
  * `target` when the target is small and list-shaped and the panel should read
283
- * as lining up with it.
294
+ * as lining up with it. Keyboard and accessibility opens always use the target
295
+ * bounds, because those modalities have no pointer coordinate.
284
296
  */
285
297
  anchor?: ContextMenuAnchor;
286
298
  /** How long the hold has to last, in milliseconds. 350 by default. */
@@ -310,10 +322,17 @@ export interface ContextMenuTriggerProps extends Omit<ViewProps, 'children'> {
310
322
  haptics?: boolean;
311
323
  /** Nothing opens the menu, and the short press stops firing too. */
312
324
  disabled?: boolean;
325
+ /**
326
+ * Called first for keyboard events. Prevent the event to keep ContextMenu
327
+ * from handling it. Context Menu and Shift+F10 open the menu; Enter and Space
328
+ * mirror the trigger's accessible activation.
329
+ */
330
+ onKeyDown?: (event: ContextMenuKeyDownEvent) => void;
313
331
  }
314
332
 
315
333
  /**
316
- * Wraps the content and opens the menu when it is held.
334
+ * Wraps the content and opens the menu when held, through accessibility
335
+ * actions, or from the keyboard.
317
336
  *
318
337
  * The wrapper is a plain view and lays out like one, stretching as a view does
319
338
  * rather than shrinking to its child. That is the opposite of what a tooltip's
@@ -334,6 +353,14 @@ function ContextMenuTrigger({
334
353
  onPress,
335
354
  haptics = false,
336
355
  disabled = false,
356
+ accessible,
357
+ accessibilityActions,
358
+ onAccessibilityAction,
359
+ accessibilityRole,
360
+ accessibilityState,
361
+ focusable,
362
+ tabIndex,
363
+ onKeyDown,
337
364
  ...props
338
365
  }: ContextMenuTriggerProps) {
339
366
  const { setOpen, anchorTo } = usePopoverAnchor('ContextMenu.Trigger');
@@ -359,8 +386,8 @@ function ContextMenuTrigger({
359
386
  * The target is measured either way, because the preview draws at that rect
360
387
  * whatever the panel is placed against.
361
388
  */
362
- const openAt = useCallback(
363
- (x: number, y: number) => {
389
+ const open = useCallback(
390
+ (point?: { x: number; y: number }) => {
364
391
  // Ticked here rather than in the gesture callback so it fires once the
365
392
  // hold has been accepted, which is the moment there is something to
366
393
  // confirm — and on the same side of the bridge as the opening.
@@ -382,8 +409,8 @@ function ContextMenuTrigger({
382
409
  */
383
410
  const grown = hasPreview ? (PREVIEW_SCALE - 1) / 2 : 0;
384
411
  anchorTo(
385
- against === 'point'
386
- ? { x, y, width: 0, height: 0 }
412
+ against === 'point' && point
413
+ ? { ...point, width: 0, height: 0 }
387
414
  : {
388
415
  x: mx - width * grown,
389
416
  y: my - height * grown,
@@ -397,6 +424,63 @@ function ContextMenuTrigger({
397
424
  [against, anchorTo, setOpen, haptics, setTarget, setContent, children, hasPreview]
398
425
  );
399
426
 
427
+ const openAt = useCallback((x: number, y: number) => open({ x, y }), [open]);
428
+ // Keyboard and accessibility actions have no pointer coordinate to honour.
429
+ // The measured target is the only real location they can anchor against.
430
+ const openFromTarget = useCallback(() => open(), [open]);
431
+
432
+ const invoke = useCallback(
433
+ (invocation: ContextMenuInvocation) => {
434
+ if (invocation === 'menu') openFromTarget();
435
+ else onPress?.();
436
+ },
437
+ [onPress, openFromTarget]
438
+ );
439
+
440
+ const triggerActions = useMemo<readonly AccessibilityActionInfo[] | undefined>(() => {
441
+ if (disabled) return undefined;
442
+ const reserved = new Set(['activate', 'showMenu']);
443
+ return [
444
+ { name: 'activate' },
445
+ { name: 'showMenu', label: 'Show menu' },
446
+ ...(accessibilityActions ?? []).filter(({ name }) => !reserved.has(name)),
447
+ ];
448
+ }, [accessibilityActions, disabled]);
449
+
450
+ const handleAccessibilityAction = useCallback<
451
+ NonNullable<ViewProps['onAccessibilityAction']>
452
+ >(
453
+ (event) => {
454
+ const actionName = event.nativeEvent.actionName;
455
+ const invocation = contextMenuAccessibilityInvocation(actionName, !!onPress, disabled);
456
+ if (invocation) {
457
+ invoke(invocation);
458
+ return;
459
+ }
460
+ // Reserved actions cannot bypass a disabled trigger through a consumer
461
+ // handler, while unrelated custom actions still compose normally.
462
+ if (actionName === 'activate' || actionName === 'showMenu') return;
463
+ onAccessibilityAction?.(event);
464
+ },
465
+ [disabled, invoke, onAccessibilityAction, onPress]
466
+ );
467
+
468
+ const handleKeyDown = useCallback(
469
+ (event: ContextMenuKeyDownEvent) => {
470
+ onKeyDown?.(event);
471
+ if (event.isDefaultPrevented()) return;
472
+ const invocation = contextMenuKeyInvocation(event.nativeEvent, !!onPress, disabled);
473
+ if (!invocation) return;
474
+ event.preventDefault();
475
+ invoke(invocation);
476
+ },
477
+ [disabled, invoke, onKeyDown, onPress]
478
+ );
479
+
480
+ // React Native exposes key events at runtime (and in its generated types),
481
+ // while its compatibility ViewProps declaration does not yet list them.
482
+ const keyboardProps = { onKeyDown: handleKeyDown } as ViewProps;
483
+
400
484
  const gesture = useMemo(() => {
401
485
  /*
402
486
  * The hold and the tap are given to the recogniser as alternatives, so it
@@ -432,7 +516,23 @@ function ContextMenuTrigger({
432
516
  a view that only groups children is otherwise flattened away — and a
433
517
  flattened view cannot be measured, which `anchor="target"` needs.
434
518
  */}
435
- <View ref={ref} collapsable={false} className={className} {...props}>
519
+ <View
520
+ ref={ref}
521
+ collapsable={false}
522
+ accessible={accessible ?? true}
523
+ accessibilityRole={accessibilityRole ?? 'button'}
524
+ accessibilityState={{
525
+ ...accessibilityState,
526
+ disabled: disabled || accessibilityState?.disabled,
527
+ }}
528
+ accessibilityActions={triggerActions}
529
+ onAccessibilityAction={handleAccessibilityAction}
530
+ focusable={focusable ?? !disabled}
531
+ tabIndex={tabIndex ?? (disabled ? -1 : 0)}
532
+ className={className}
533
+ {...keyboardProps}
534
+ {...props}
535
+ >
436
536
  {children}
437
537
  </View>
438
538
  </GestureDetector>
@@ -13,7 +13,7 @@ import {
13
13
  import { Pressable, View, type ViewProps } from 'react-native';
14
14
  import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
15
15
  import { tv, type VariantProps } from 'tailwind-variants';
16
- import { Portal } from '../../primitives/portal';
16
+ import { ModalPortal } from '../../primitives/portal';
17
17
  import { Scrim } from '../../primitives/scrim';
18
18
  import { Text, type TextProps, textChildren } from '../../primitives/text';
19
19
  import { useBackHandler } from '../../hooks/use-back-handler';
@@ -89,6 +89,9 @@ export interface DialogContentProps extends ViewProps {
89
89
  * Frost the screen behind the dialog instead of dimming it. Uses `expo-blur`
90
90
  * when installed and falls back to the dim when it is not, so it is safe to
91
91
  * pass either way.
92
+ *
93
+ * Someone who has Reduce Transparency switched on gets an opaque
94
+ * backdrop instead, which is the whole point of the setting.
92
95
  */
93
96
  blur?: boolean;
94
97
  children?: ReactNode;
@@ -111,7 +114,7 @@ function DialogContent({
111
114
  if (!open) return null;
112
115
 
113
116
  return (
114
- <Portal>
117
+ <ModalPortal>
115
118
  {/* Portal content mounts under PortalHost, outside this provider's
116
119
  subtree — re-provide the context so Dialog.Close etc. keep working. */}
117
120
  <DialogContext.Provider value={context}>
@@ -142,7 +145,7 @@ function DialogContent({
142
145
  </Animated.View>
143
146
  </Animated.View>
144
147
  </DialogContext.Provider>
145
- </Portal>
148
+ </ModalPortal>
146
149
  );
147
150
  }
148
151
 
@@ -84,7 +84,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
84
84
  import { tv } from 'tailwind-variants';
85
85
  import { useCSSVariable } from 'uniwind';
86
86
  import { XIcon } from '../../icons';
87
- import { Portal } from '../../primitives/portal';
87
+ import { ModalPortal } from '../../primitives/portal';
88
88
  import { Scrim } from '../../primitives/scrim';
89
89
  import { Text, textChildren } from '../../primitives/text';
90
90
  import { useBackHandler } from '../../hooks/use-back-handler';
@@ -285,6 +285,9 @@ export interface DrawerContentProps extends ViewProps {
285
285
  /**
286
286
  * Frost the screen behind the drawer instead of dimming it. Needs the
287
287
  * optional `expo-blur`; without it this dims, rather than failing.
288
+ *
289
+ * Someone who has Reduce Transparency switched on gets an opaque
290
+ * backdrop instead, which is the whole point of the setting.
288
291
  */
289
292
  blur?: boolean;
290
293
  children?: ReactNode;
@@ -458,7 +461,7 @@ function DrawerContent({
458
461
  };
459
462
 
460
463
  return (
461
- <Portal>
464
+ <ModalPortal>
462
465
  {/* Portal content mounts under PortalHost, outside this provider's
463
466
  subtree — re-provide the context so Drawer.Close keeps working. */}
464
467
  <DrawerContext.Provider value={{ open, setOpen }}>
@@ -561,7 +564,7 @@ function DrawerContent({
561
564
  </View>
562
565
  </DirectionContext.Provider>
563
566
  </DrawerContext.Provider>
564
- </Portal>
567
+ </ModalPortal>
565
568
  );
566
569
  }
567
570
 
@@ -129,7 +129,10 @@ const fabVariants = tv({
129
129
  primary: { root: 'bg-primary', label: 'text-primary-foreground' },
130
130
  secondary: { root: 'bg-secondary', label: 'text-secondary-foreground' },
131
131
  surface: { root: 'border border-border bg-popover', label: 'text-foreground' },
132
- destructive: { root: 'bg-destructive', label: 'text-white' },
132
+ destructive: {
133
+ root: 'bg-destructive',
134
+ label: 'text-destructive-solid-foreground',
135
+ },
133
136
  },
134
137
  disabled: {
135
138
  true: { root: 'opacity-[0.64] shadow-none' },
@@ -158,8 +161,9 @@ export type FabSize = NonNullable<FabVariantProps['size']>;
158
161
  export type FabVariant = NonNullable<FabVariantProps['variant']>;
159
162
 
160
163
  /** The theme token each variant's icon reads against. */
161
- const CONTENT_COLOR_VAR: Record<Exclude<FabVariant, 'destructive'>, string> = {
164
+ const CONTENT_COLOR_VAR: Record<FabVariant, string> = {
162
165
  primary: '--color-primary-foreground',
166
+ destructive: '--color-destructive-solid-foreground',
163
167
  secondary: '--color-secondary-foreground',
164
168
  surface: '--color-foreground',
165
169
  };
@@ -234,17 +238,9 @@ const FabRoot = forwardRef<View, FabProps>(
234
238
  disabled,
235
239
  });
236
240
 
237
- const themed = useCSSVariable(
238
- CONTENT_COLOR_VAR[
239
- variant === 'destructive' ? 'primary' : (variant ?? 'primary')
240
- ]
241
- );
241
+ const themed = useCSSVariable(CONTENT_COLOR_VAR[variant ?? 'primary']);
242
242
  const contentColor =
243
- variant === 'destructive'
244
- ? '#ffffff'
245
- : typeof themed === 'string'
246
- ? themed
247
- : undefined;
243
+ typeof themed === 'string' ? themed : undefined;
248
244
 
249
245
  const handlePress = useCallback(() => {
250
246
  if (haptics) selectionTick();
@@ -18,7 +18,12 @@
18
18
  * <Field.Title>Marketing emails</Field.Title>
19
19
  * <Field.Description>Product updates, at most weekly.</Field.Description>
20
20
  * </Field.Content>
21
- * <Switch value={subscribed} onValueChange={setSubscribed} />
21
+ * <Switch
22
+ * value={subscribed}
23
+ * onValueChange={setSubscribed}
24
+ * accessibilityLabel="Marketing emails"
25
+ * accessibilityHint="Product updates, at most weekly."
26
+ * />
22
27
  * </Field>
23
28
  * ```
24
29
  */
@@ -26,6 +31,7 @@ import {
26
31
  createContext,
27
32
  forwardRef,
28
33
  useContext,
34
+ useId,
29
35
  useMemo,
30
36
  type ReactNode,
31
37
  } from 'react';
@@ -73,14 +79,22 @@ interface FieldState {
73
79
  invalid: boolean;
74
80
  disabled: boolean;
75
81
  required: boolean;
82
+ relationshipIds: string[];
76
83
  }
77
84
 
78
85
  const FieldContext = createContext<FieldState>({
79
86
  invalid: false,
80
87
  disabled: false,
81
88
  required: false,
89
+ relationshipIds: [],
82
90
  });
83
91
 
92
+ /** Android can announce the label, help and error text rendered beside a control. */
93
+ export function useFieldLabelledBy(): string[] | undefined {
94
+ const { relationshipIds } = useContext(FieldContext);
95
+ return relationshipIds.length > 0 ? relationshipIds : undefined;
96
+ }
97
+
84
98
  export interface FieldProps extends ViewProps {
85
99
  className?: string;
86
100
  /** `horizontal` puts a label beside the control instead of above it. */
@@ -107,9 +121,19 @@ const FieldRoot = forwardRef<View, FieldProps>(
107
121
  ref
108
122
  ) => {
109
123
  const { root } = fieldVariants({ orientation, disabled });
124
+ const id = useId().replace(/:/g, '');
125
+ const labelId = `panelui-field-${id}-label`;
126
+ const titleId = `panelui-field-${id}-title`;
127
+ const descriptionId = `panelui-field-${id}-description`;
128
+ const errorId = `panelui-field-${id}-error`;
110
129
  const state = useMemo(
111
- () => ({ invalid, disabled, required }),
112
- [invalid, disabled, required]
130
+ () => ({
131
+ invalid,
132
+ disabled,
133
+ required,
134
+ relationshipIds: [labelId, titleId, descriptionId, errorId],
135
+ }),
136
+ [invalid, disabled, required, labelId, titleId, descriptionId, errorId]
113
137
  );
114
138
 
115
139
  return (
@@ -145,7 +169,7 @@ FieldContent.displayName = 'Field.Content';
145
169
  export interface FieldLabelProps extends LabelProps {}
146
170
 
147
171
  const FieldLabel = forwardRef<View, FieldLabelProps>(
148
- ({ isRequired, isInvalid, isDisabled, ...props }, ref) => {
172
+ ({ isRequired, isInvalid, isDisabled, nativeID, ...props }, ref) => {
149
173
  const ctx = useContext(FieldContext);
150
174
  return (
151
175
  <Label
@@ -153,6 +177,7 @@ const FieldLabel = forwardRef<View, FieldLabelProps>(
153
177
  isRequired={isRequired ?? ctx.required}
154
178
  isInvalid={isInvalid ?? ctx.invalid}
155
179
  isDisabled={isDisabled ?? ctx.disabled}
180
+ nativeID={nativeID ?? ctx.relationshipIds[0]}
156
181
  {...props}
157
182
  />
158
183
  );
@@ -165,10 +190,17 @@ export interface FieldDescriptionProps extends TextProps {
165
190
  }
166
191
 
167
192
  const FieldDescription = forwardRef<RNText, FieldDescriptionProps>(
168
- ({ className, ...props }, ref) => {
169
- const { disabled } = useContext(FieldContext);
193
+ ({ className, nativeID, ...props }, ref) => {
194
+ const { disabled, relationshipIds } = useContext(FieldContext);
170
195
  const { description } = fieldVariants({ disabled });
171
- return <Text ref={ref} className={description({ className })} {...props} />;
196
+ return (
197
+ <Text
198
+ ref={ref}
199
+ nativeID={nativeID ?? relationshipIds[2]}
200
+ className={description({ className })}
201
+ {...props}
202
+ />
203
+ );
172
204
  }
173
205
  );
174
206
  FieldDescription.displayName = 'Field.Description';
@@ -186,7 +218,8 @@ export interface FieldErrorProps extends Omit<ViewProps, 'children'> {
186
218
  }
187
219
 
188
220
  const FieldError = forwardRef<View, FieldErrorProps>(
189
- ({ className, errors, children, ...props }, ref) => {
221
+ ({ className, errors, children, nativeID, ...props }, ref) => {
222
+ const { relationshipIds } = useContext(FieldContext);
190
223
  const messages = useMemo(() => {
191
224
  if (!errors) return null;
192
225
  const seen = new Set<string>();
@@ -213,6 +246,7 @@ const FieldError = forwardRef<View, FieldErrorProps>(
213
246
  // accessibilityRole list has no alert, the ARIA-aligned one does.
214
247
  role="alert"
215
248
  accessibilityLiveRegion="polite"
249
+ nativeID={nativeID ?? relationshipIds[3]}
216
250
  className={error({ className })}
217
251
  {...props}
218
252
  >
@@ -307,9 +341,17 @@ export interface FieldTitleProps extends TextProps {
307
341
  }
308
342
 
309
343
  const FieldTitle = forwardRef<RNText, FieldTitleProps>(
310
- ({ className, ...props }, ref) => {
344
+ ({ className, nativeID, ...props }, ref) => {
311
345
  const { title } = fieldVariants();
312
- return <Text ref={ref} className={title({ className })} {...props} />;
346
+ const { relationshipIds } = useContext(FieldContext);
347
+ return (
348
+ <Text
349
+ ref={ref}
350
+ nativeID={nativeID ?? relationshipIds[1]}
351
+ className={title({ className })}
352
+ {...props}
353
+ />
354
+ );
313
355
  }
314
356
  );
315
357
  FieldTitle.displayName = 'Field.Title';
@@ -0,0 +1,123 @@
1
+ import type { FlowConnection, FlowNodePosition } from '.';
2
+ import type { FlowRect } from './flow-paths';
3
+
4
+ export const FLOW_MOVE_ACTIONS = [
5
+ { name: 'flow-move-up', label: 'Move up', dx: 0, dy: -1 },
6
+ { name: 'flow-move-right', label: 'Move right', dx: 1, dy: 0 },
7
+ { name: 'flow-move-down', label: 'Move down', dx: 0, dy: 1 },
8
+ { name: 'flow-move-left', label: 'Move left', dx: -1, dy: 0 },
9
+ ] as const;
10
+
11
+ export const FLOW_DELETE_ACTION = { name: 'flow-delete', label: 'Delete node' } as const;
12
+
13
+ export interface FlowAccessibilityNode {
14
+ id: string;
15
+ label: string;
16
+ }
17
+
18
+ export interface FlowAccessibilityHandle {
19
+ key: string;
20
+ node: string;
21
+ id: string;
22
+ label: string;
23
+ type: 'source' | 'target' | 'both';
24
+ }
25
+
26
+ export interface FlowConnectionAction {
27
+ name: string;
28
+ label: string;
29
+ connection: FlowConnection;
30
+ }
31
+
32
+ /** Opaque, collision-free name for the native accessibility action map. */
33
+ export function flowConnectionActionName(
34
+ source: { node: string; handle?: string },
35
+ target: { node: string; handle?: string }
36
+ ): string {
37
+ return `flow-connect:${JSON.stringify([source, target])}`;
38
+ }
39
+
40
+ /** Keep pointer and assistive-technology movement inside the same group bounds. */
41
+ export function clampNodePosition(
42
+ rect: FlowRect,
43
+ bounds: FlowRect | undefined,
44
+ position: FlowNodePosition
45
+ ): FlowNodePosition {
46
+ 'worklet';
47
+ if (!bounds) return position;
48
+
49
+ return {
50
+ x: Math.min(Math.max(position.x, bounds.x), bounds.x + bounds.width - rect.width),
51
+ y: Math.min(Math.max(position.y, bounds.y), bounds.y + bounds.height - rect.height),
52
+ };
53
+ }
54
+
55
+ export function moveNodePosition(
56
+ rect: FlowRect,
57
+ bounds: FlowRect | undefined,
58
+ actionName: string,
59
+ step: number
60
+ ): FlowNodePosition | undefined {
61
+ const action = FLOW_MOVE_ACTIONS.find((entry) => entry.name === actionName);
62
+ if (!action) return undefined;
63
+
64
+ return clampNodePosition(rect, bounds, {
65
+ x: rect.x + action.dx * Math.max(1, step),
66
+ y: rect.y + action.dy * Math.max(1, step),
67
+ });
68
+ }
69
+
70
+ /** Build connection choices from the same registered nodes and handles used by drag hit-testing. */
71
+ export function getFlowConnectionActions(
72
+ sourceNode: string,
73
+ nodes: FlowAccessibilityNode[],
74
+ handles: FlowAccessibilityHandle[]
75
+ ): FlowConnectionAction[] {
76
+ const sources = handles.filter(
77
+ (handle) => handle.node === sourceNode && handle.type !== 'target'
78
+ );
79
+ if (sources.length === 0) return [];
80
+
81
+ const actions: FlowConnectionAction[] = [];
82
+ for (const source of sources) {
83
+ for (const targetNode of nodes) {
84
+ if (targetNode.id === sourceNode) continue;
85
+ const targets = handles.filter(
86
+ (handle) => handle.node === targetNode.id && handle.type !== 'source'
87
+ );
88
+
89
+ if (targets.length === 0) {
90
+ actions.push({
91
+ name: flowConnectionActionName(
92
+ { node: source.node, handle: source.id },
93
+ { node: targetNode.id }
94
+ ),
95
+ label: `Connect ${source.label} to ${targetNode.label}`,
96
+ connection: {
97
+ source: sourceNode,
98
+ sourceHandle: source.id,
99
+ target: targetNode.id,
100
+ },
101
+ });
102
+ continue;
103
+ }
104
+
105
+ for (const target of targets) {
106
+ actions.push({
107
+ name: flowConnectionActionName(
108
+ { node: source.node, handle: source.id },
109
+ { node: target.node, handle: target.id }
110
+ ),
111
+ label: `Connect ${source.label} to ${targetNode.label}, ${target.label}`,
112
+ connection: {
113
+ source: sourceNode,
114
+ sourceHandle: source.id,
115
+ target: target.node,
116
+ targetHandle: target.id,
117
+ },
118
+ });
119
+ }
120
+ }
121
+ }
122
+ return actions;
123
+ }
@@ -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
+ }