panelui-native 0.63.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) 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/index.js +28 -14
  11. package/lib/module/components/carousel/index.js.map +1 -1
  12. package/lib/module/components/checkbox/index.js +2 -3
  13. package/lib/module/components/checkbox/index.js.map +1 -1
  14. package/lib/module/components/dialog/index.js +2 -2
  15. package/lib/module/components/dialog/index.js.map +1 -1
  16. package/lib/module/components/drawer/index.js +2 -2
  17. package/lib/module/components/drawer/index.js.map +1 -1
  18. package/lib/module/components/fab/index.js +4 -3
  19. package/lib/module/components/fab/index.js.map +1 -1
  20. package/lib/module/components/field/index.js +41 -6
  21. package/lib/module/components/field/index.js.map +1 -1
  22. package/lib/module/components/flow/flow-accessibility.js +84 -0
  23. package/lib/module/components/flow/flow-accessibility.js.map +1 -0
  24. package/lib/module/components/flow/index.js +100 -39
  25. package/lib/module/components/flow/index.js.map +1 -1
  26. package/lib/module/components/form/use-form.js +93 -66
  27. package/lib/module/components/form/use-form.js.map +1 -1
  28. package/lib/module/components/heatmap-chart/index.js +2 -2
  29. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  30. package/lib/module/components/hex-chart/index.js +2 -2
  31. package/lib/module/components/hex-chart/index.js.map +1 -1
  32. package/lib/module/components/kpi/index.js +3 -1
  33. package/lib/module/components/kpi/index.js.map +1 -1
  34. package/lib/module/components/line-chart/index.js +3 -3
  35. package/lib/module/components/line-chart/index.js.map +1 -1
  36. package/lib/module/components/map/index.js +71 -8
  37. package/lib/module/components/map/index.js.map +1 -1
  38. package/lib/module/components/map/map-accessibility.js +20 -0
  39. package/lib/module/components/map/map-accessibility.js.map +1 -0
  40. package/lib/module/components/map/maplibre.js.map +1 -1
  41. package/lib/module/components/menu/index.js +4 -1
  42. package/lib/module/components/menu/index.js.map +1 -1
  43. package/lib/module/components/number-input/index.js +1 -23
  44. package/lib/module/components/number-input/index.js.map +1 -1
  45. package/lib/module/components/number-input/number-input-math.js +44 -0
  46. package/lib/module/components/number-input/number-input-math.js.map +1 -0
  47. package/lib/module/components/plot/index.js +2 -2
  48. package/lib/module/components/plot/index.js.map +1 -1
  49. package/lib/module/components/radio-group/index.js +1 -2
  50. package/lib/module/components/radio-group/index.js.map +1 -1
  51. package/lib/module/components/sortable/index.js +5 -55
  52. package/lib/module/components/sortable/index.js.map +1 -1
  53. package/lib/module/components/sortable/reorder.js +63 -0
  54. package/lib/module/components/sortable/reorder.js.map +1 -0
  55. package/lib/module/components/swipe/index.js +4 -4
  56. package/lib/module/components/swipe/index.js.map +1 -1
  57. package/lib/module/components/switch/index.js +36 -3
  58. package/lib/module/components/switch/index.js.map +1 -1
  59. package/lib/module/components/time-picker/accessibility.js +21 -0
  60. package/lib/module/components/time-picker/accessibility.js.map +1 -0
  61. package/lib/module/components/time-picker/index.js +34 -8
  62. package/lib/module/components/time-picker/index.js.map +1 -1
  63. package/lib/module/components/waterfall-chart/index.js +1325 -0
  64. package/lib/module/components/waterfall-chart/index.js.map +1 -0
  65. package/lib/module/index.js +1 -0
  66. package/lib/module/index.js.map +1 -1
  67. package/lib/module/primitives/modal-isolation-store.js +29 -0
  68. package/lib/module/primitives/modal-isolation-store.js.map +1 -0
  69. package/lib/module/primitives/portal.js +36 -4
  70. package/lib/module/primitives/portal.js.map +1 -1
  71. package/lib/module/providers/panel-ui-provider.js +27 -6
  72. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  73. package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
  74. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  76. package/lib/typescript/src/components/carousel/index.d.ts +1 -1
  77. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -1
  78. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  79. package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
  80. package/lib/typescript/src/components/field/index.d.ts +8 -1
  81. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  82. package/lib/typescript/src/components/flow/flow-accessibility.d.ts +49 -0
  83. package/lib/typescript/src/components/flow/flow-accessibility.d.ts.map +1 -0
  84. package/lib/typescript/src/components/flow/index.d.ts +14 -2
  85. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
  87. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  88. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  90. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  91. package/lib/typescript/src/components/map/index.d.ts +13 -5
  92. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/map/map-accessibility.d.ts +19 -0
  94. package/lib/typescript/src/components/map/map-accessibility.d.ts.map +1 -0
  95. package/lib/typescript/src/components/map/maplibre.d.ts +2 -0
  96. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
  97. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  98. package/lib/typescript/src/components/number-input/index.d.ts.map +1 -1
  99. package/lib/typescript/src/components/number-input/number-input-math.d.ts +9 -0
  100. package/lib/typescript/src/components/number-input/number-input-math.d.ts.map +1 -0
  101. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  104. package/lib/typescript/src/components/sortable/reorder.d.ts +14 -0
  105. package/lib/typescript/src/components/sortable/reorder.d.ts.map +1 -0
  106. package/lib/typescript/src/components/swipe/index.d.ts.map +1 -1
  107. package/lib/typescript/src/components/switch/index.d.ts +3 -3
  108. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  109. package/lib/typescript/src/components/time-picker/accessibility.d.ts +10 -0
  110. package/lib/typescript/src/components/time-picker/accessibility.d.ts.map +1 -0
  111. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  112. package/lib/typescript/src/components/waterfall-chart/index.d.ts +379 -0
  113. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -0
  114. package/lib/typescript/src/index.d.ts +2 -1
  115. package/lib/typescript/src/index.d.ts.map +1 -1
  116. package/lib/typescript/src/primitives/modal-isolation-store.d.ts +10 -0
  117. package/lib/typescript/src/primitives/modal-isolation-store.d.ts.map +1 -0
  118. package/lib/typescript/src/primitives/portal.d.ts +6 -0
  119. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  120. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  121. package/package.json +5 -2
  122. package/src/components/badge/index.tsx +4 -1
  123. package/src/components/bar-chart/index.tsx +40 -3
  124. package/src/components/bottom-sheet/index.tsx +3 -3
  125. package/src/components/button/index.tsx +15 -21
  126. package/src/components/carousel/index.tsx +42 -20
  127. package/src/components/checkbox/index.tsx +1 -2
  128. package/src/components/dialog/index.tsx +3 -3
  129. package/src/components/drawer/index.tsx +3 -3
  130. package/src/components/fab/index.tsx +8 -12
  131. package/src/components/field/index.tsx +52 -10
  132. package/src/components/flow/flow-accessibility.ts +109 -0
  133. package/src/components/flow/index.tsx +162 -50
  134. package/src/components/form/use-form.ts +101 -65
  135. package/src/components/heatmap-chart/index.tsx +2 -3
  136. package/src/components/hex-chart/index.tsx +2 -3
  137. package/src/components/kpi/index.tsx +3 -1
  138. package/src/components/line-chart/index.tsx +3 -2
  139. package/src/components/map/index.tsx +110 -7
  140. package/src/components/map/map-accessibility.ts +41 -0
  141. package/src/components/map/maplibre.ts +2 -0
  142. package/src/components/menu/index.tsx +4 -1
  143. package/src/components/number-input/index.tsx +1 -26
  144. package/src/components/number-input/number-input-math.ts +50 -0
  145. package/src/components/plot/index.tsx +2 -1
  146. package/src/components/radio-group/index.tsx +1 -2
  147. package/src/components/sortable/index.tsx +12 -64
  148. package/src/components/sortable/reorder.ts +88 -0
  149. package/src/components/swipe/index.tsx +7 -4
  150. package/src/components/switch/index.tsx +42 -6
  151. package/src/components/time-picker/accessibility.ts +24 -0
  152. package/src/components/time-picker/index.tsx +48 -2
  153. package/src/components/waterfall-chart/index.tsx +1589 -0
  154. package/src/index.ts +23 -0
  155. package/src/primitives/modal-isolation-store.ts +31 -0
  156. package/src/primitives/portal.tsx +34 -4
  157. package/src/providers/panel-ui-provider.tsx +41 -9
  158. package/theme.css +30 -3
@@ -48,6 +48,7 @@ import {
48
48
  useCallback,
49
49
  useContext,
50
50
  useEffect,
51
+ useId,
51
52
  useMemo,
52
53
  useRef,
53
54
  useState,
@@ -97,6 +98,15 @@ import {
97
98
  type FlowRect,
98
99
  type FlowSide,
99
100
  } from './flow-paths';
101
+ import {
102
+ clampNodePosition,
103
+ FLOW_DELETE_ACTION,
104
+ FLOW_MOVE_ACTIONS,
105
+ getFlowConnectionActions,
106
+ moveNodePosition,
107
+ type FlowAccessibilityHandle,
108
+ type FlowAccessibilityNode,
109
+ } from './flow-accessibility';
100
110
 
101
111
  const AnimatedPath = Animated.createAnimatedComponent(Path);
102
112
  const AnimatedRect = Animated.createAnimatedComponent(Rect);
@@ -167,13 +177,9 @@ export interface FlowConnection {
167
177
  targetHandle?: string;
168
178
  }
169
179
 
170
- interface HandleEntry {
171
- key: string;
172
- node: string;
173
- id: string;
180
+ interface HandleEntry extends FlowAccessibilityHandle {
174
181
  side: FlowSide;
175
182
  offset: number;
176
- type: 'source' | 'target' | 'both';
177
183
  }
178
184
 
179
185
  interface FlowContextValue {
@@ -215,7 +221,7 @@ interface FlowContextValue {
215
221
  maxZoom: number;
216
222
  locked: boolean;
217
223
  setLocked: (locked: boolean) => void;
218
- nodeIds: string[];
224
+ nodes: FlowAccessibilityNode[];
219
225
  /**
220
226
  * Which of `nodeIds` are containers rather than nodes. A group writes its box
221
227
  * into the same registry — that is what lets an edge name one and the minimap
@@ -224,7 +230,7 @@ interface FlowContextValue {
224
230
  groupIds: string[];
225
231
  handles: HandleEntry[];
226
232
  edges: { key: string; props: FlowEdgeProps }[];
227
- registerNode: (id: string) => void;
233
+ registerNode: (id: string, label?: string) => void;
228
234
  unregisterNode: (id: string) => void;
229
235
  registerGroup: (id: string) => void;
230
236
  unregisterGroup: (id: string) => void;
@@ -232,8 +238,8 @@ interface FlowContextValue {
232
238
  unregisterHandle: (key: string) => void;
233
239
  registerEdge: (key: string, props: FlowEdgeProps) => void;
234
240
  unregisterEdge: (key: string) => void;
235
- onConnect?: (connection: FlowConnection) => void;
236
- isValidConnection?: (connection: FlowConnection) => boolean;
241
+ connectNodes: (connection: FlowConnection) => void;
242
+ canConnect: boolean;
237
243
  onNodeDragEnd?: (id: string, position: FlowNodePosition) => void;
238
244
  fitView: () => void;
239
245
  zoomBy: (factor: number) => void;
@@ -332,7 +338,7 @@ function FlowRoot({
332
338
  const size = useSharedValue({ width: 0, height: 0 });
333
339
 
334
340
  const [locked, setLocked] = useState(false);
335
- const [nodeIds, setNodeIds] = useState<string[]>([]);
341
+ const [nodes, setNodes] = useState<FlowAccessibilityNode[]>([]);
336
342
  const [groupIds, setGroupIds] = useState<string[]>([]);
337
343
  const [handles, setHandles] = useState<HandleEntry[]>([]);
338
344
  const [edges, setEdges] = useState<{ key: string; props: FlowEdgeProps }[]>([]);
@@ -441,12 +447,12 @@ function FlowRoot({
441
447
  viewportChangeRef.current?.({ x, y, zoom: z });
442
448
  }, []);
443
449
 
444
- const registerNode = useCallback((id: string) => {
445
- setNodeIds((current) => (current.includes(id) ? current : [...current, id]));
450
+ const registerNode = useCallback((id: string, label = id) => {
451
+ setNodes((current) => [...current.filter((entry) => entry.id !== id), { id, label }]);
446
452
  }, []);
447
453
 
448
454
  const unregisterNode = useCallback((id: string) => {
449
- setNodeIds((current) => current.filter((entry) => entry !== id));
455
+ setNodes((current) => current.filter((entry) => entry.id !== id));
450
456
  }, []);
451
457
 
452
458
  const registerGroup = useCallback((id: string) => {
@@ -476,6 +482,15 @@ function FlowRoot({
476
482
  setEdges((current) => current.filter((edge) => edge.key !== key));
477
483
  }, []);
478
484
 
485
+ const connectRef = useRef(onConnect);
486
+ connectRef.current = onConnect;
487
+ const validConnectionRef = useRef(isValidConnection);
488
+ validConnectionRef.current = isValidConnection;
489
+ const connectNodes = useCallback((next: FlowConnection) => {
490
+ if (validConnectionRef.current && !validConnectionRef.current(next)) return;
491
+ connectRef.current?.(next);
492
+ }, []);
493
+
479
494
  const fitView = useCallback(() => {
480
495
  const { width, height } = size.value;
481
496
  const all = Object.values(rects.value);
@@ -549,10 +564,10 @@ function FlowRoot({
549
564
  // mount has nothing to fit to yet. One frame later they do.
550
565
  const fitOnMount = useRef(fitViewOnMount);
551
566
  useEffect(() => {
552
- if (!fitOnMount.current || nodeIds.length === 0) return;
567
+ if (!fitOnMount.current || nodes.length === 0) return;
553
568
  const timer = setTimeout(fitView, 32);
554
569
  return () => clearTimeout(timer);
555
- }, [fitView, nodeIds.length]);
570
+ }, [fitView, nodes.length]);
556
571
 
557
572
  const panStart = useSharedValue({ x: 0, y: 0 });
558
573
  const pinchStart = useSharedValue({ zoom: 1, x: 0, y: 0 });
@@ -647,7 +662,7 @@ function FlowRoot({
647
662
  maxZoom,
648
663
  locked,
649
664
  setLocked,
650
- nodeIds,
665
+ nodes,
651
666
  groupIds,
652
667
  handles,
653
668
  edges,
@@ -659,8 +674,8 @@ function FlowRoot({
659
674
  unregisterHandle,
660
675
  registerEdge,
661
676
  unregisterEdge,
662
- onConnect,
663
- isValidConnection,
677
+ connectNodes,
678
+ canConnect: Boolean(onConnect),
664
679
  onNodeDragEnd,
665
680
  fitView,
666
681
  zoomBy,
@@ -678,11 +693,11 @@ function FlowRoot({
678
693
  handles,
679
694
  registerEdge,
680
695
  unregisterEdge,
681
- isValidConnection,
696
+ connectNodes,
682
697
  locked,
683
698
  maxZoom,
684
699
  minZoom,
685
- nodeIds,
700
+ nodes,
686
701
  groupIds,
687
702
  onConnect,
688
703
  onNodeDragEnd,
@@ -856,7 +871,7 @@ function FlowBackground({
856
871
  const token = useCSSVariable('--color-muted-foreground');
857
872
  const tint = color ?? (typeof token === 'string' ? token : '#737373');
858
873
  // A pattern is referenced by id, and two canvases on one screen would collide.
859
- const id = useRef(`panelui-flow-${Math.random().toString(36).slice(2, 7)}`).current;
874
+ const id = `panelui-flow-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
860
875
 
861
876
  /*
862
877
  * The grid is a tile that follows the viewport in whole-cell steps.
@@ -972,6 +987,13 @@ export interface FlowNodeProps extends Omit<ViewProps, 'children'> {
972
987
  selected?: boolean;
973
988
  /** Tapping the node — separate from dragging it. */
974
989
  onPress?: () => void;
990
+ /**
991
+ * Delete the node when assistive technology requests the advertised Delete
992
+ * node action. No delete action is exposed when this is omitted.
993
+ */
994
+ onDelete?: () => void;
995
+ /** Graph points covered by each Move up, right, down or left accessibility action. */
996
+ accessibilityMoveStep?: number;
975
997
  /** Spoken name. Defaults to the node's id. */
976
998
  accessibilityLabel?: string;
977
999
  children?: ReactNode;
@@ -991,7 +1013,11 @@ function FlowNode({
991
1013
  pinned = false,
992
1014
  selected = false,
993
1015
  onPress,
1016
+ onDelete,
1017
+ accessibilityMoveStep = 24,
994
1018
  accessibilityLabel,
1019
+ accessibilityActions,
1020
+ onAccessibilityAction,
995
1021
  children,
996
1022
  ...props
997
1023
  }: FlowNodeProps) {
@@ -1009,6 +1035,10 @@ function FlowNode({
1009
1035
  registerGroup,
1010
1036
  unregisterGroup,
1011
1037
  onNodeDragEnd,
1038
+ nodes,
1039
+ handles,
1040
+ connectNodes,
1041
+ canConnect,
1012
1042
  } = flow;
1013
1043
 
1014
1044
  /*
@@ -1018,13 +1048,13 @@ function FlowNode({
1018
1048
  * layer both need to know it is a container and not a card.
1019
1049
  */
1020
1050
  useEffect(() => {
1021
- registerNode(id);
1051
+ registerNode(id, accessibilityLabel ?? id);
1022
1052
  registerGroup(id);
1023
1053
  return () => {
1024
1054
  unregisterNode(id);
1025
1055
  unregisterGroup(id);
1026
1056
  };
1027
- }, [id, registerGroup, registerNode, unregisterGroup, unregisterNode]);
1057
+ }, [accessibilityLabel, id, registerGroup, registerNode, unregisterGroup, unregisterNode]);
1028
1058
 
1029
1059
  // Writing on every render would fight the drag, snapping a node back to the
1030
1060
  // position it was first given. Only an actual change to the prop moves it.
@@ -1065,11 +1095,70 @@ function FlowNode({
1065
1095
  );
1066
1096
 
1067
1097
  const start = useSharedValue({ x: 0, y: 0 });
1068
-
1069
1098
  const pressRef = useRef(onPress);
1070
1099
  pressRef.current = onPress;
1071
1100
  const press = useCallback(() => pressRef.current?.(), []);
1072
1101
 
1102
+ const moveNode = useCallback(
1103
+ (actionName: string) => {
1104
+ const rect = rects.value[id];
1105
+ if (!rect) return;
1106
+ const bounds = confine && group ? rects.value[group] : undefined;
1107
+ const next = moveNodePosition(rect, bounds, actionName, accessibilityMoveStep);
1108
+ if (!next) return;
1109
+ setNodeRect(id, next);
1110
+ dragEnd(next.x, next.y);
1111
+ },
1112
+ [accessibilityMoveStep, confine, dragEnd, group, id, rects, setNodeRect]
1113
+ );
1114
+
1115
+ const connectionActions = useMemo(
1116
+ () => (canConnect && !locked ? getFlowConnectionActions(id, nodes, handles) : []),
1117
+ [canConnect, handles, id, locked, nodes]
1118
+ );
1119
+ const connectionByAction = useMemo(
1120
+ () => new Map(connectionActions.map((entry) => [entry.name, entry.connection])),
1121
+ [connectionActions]
1122
+ );
1123
+ const movable = draggable && !pinned && !locked;
1124
+ const nodeActions = useMemo(
1125
+ () => [
1126
+ ...(onPress ? [{ name: 'activate' }] : []),
1127
+ ...(movable ? FLOW_MOVE_ACTIONS : []),
1128
+ ...connectionActions.map(({ name, label }) => ({ name, label })),
1129
+ ...(onDelete ? [FLOW_DELETE_ACTION] : []),
1130
+ ...(accessibilityActions ?? []),
1131
+ ],
1132
+ [accessibilityActions, connectionActions, movable, onDelete, onPress]
1133
+ );
1134
+
1135
+ const handleAccessibilityAction = useCallback<
1136
+ NonNullable<ViewProps['onAccessibilityAction']>
1137
+ >(
1138
+ (event) => {
1139
+ const action = event.nativeEvent.actionName;
1140
+ if (action === 'activate' && onPress) {
1141
+ press();
1142
+ return;
1143
+ }
1144
+ if (movable && FLOW_MOVE_ACTIONS.some((entry) => entry.name === action)) {
1145
+ moveNode(action);
1146
+ return;
1147
+ }
1148
+ const nextConnection = connectionByAction.get(action);
1149
+ if (nextConnection) {
1150
+ connectNodes(nextConnection);
1151
+ return;
1152
+ }
1153
+ if (action === FLOW_DELETE_ACTION.name && onDelete) {
1154
+ onDelete();
1155
+ return;
1156
+ }
1157
+ onAccessibilityAction?.(event);
1158
+ },
1159
+ [connectNodes, connectionByAction, movable, moveNode, onAccessibilityAction, onDelete, onPress, press]
1160
+ );
1161
+
1073
1162
  const drag = useMemo(
1074
1163
  () =>
1075
1164
  Gesture.Pan()
@@ -1096,13 +1185,9 @@ function FlowNode({
1096
1185
  * would leave the edges drawn to a position the node never reached.
1097
1186
  */
1098
1187
  if (confine && group) {
1099
- const box = rects.value[group];
1100
- if (box) {
1101
- const maxX = box.x + box.width - rect.width;
1102
- const maxY = box.y + box.height - rect.height;
1103
- x = x < box.x ? box.x : x > maxX ? maxX : x;
1104
- y = y < box.y ? box.y : y > maxY ? maxY : y;
1105
- }
1188
+ const next = clampNodePosition(rect, rects.value[group], { x, y });
1189
+ x = next.x;
1190
+ y = next.y;
1106
1191
  }
1107
1192
 
1108
1193
  rects.value = { ...rects.value, [id]: { ...rect, x, y } };
@@ -1158,6 +1243,8 @@ function FlowNode({
1158
1243
  accessibilityRole={onPress ? 'button' : 'none'}
1159
1244
  accessibilityLabel={accessibilityLabel ?? id}
1160
1245
  accessibilityState={{ selected }}
1246
+ accessibilityActions={nodeActions}
1247
+ onAccessibilityAction={handleAccessibilityAction}
1161
1248
  {...props}
1162
1249
  >
1163
1250
  {textChildren(children)}
@@ -1185,6 +1272,11 @@ export interface FlowHandleProps {
1185
1272
  /** Where along the face, 0–1. For more than one handle on a side. */
1186
1273
  offset?: number;
1187
1274
  className?: string;
1275
+ /**
1276
+ * Spoken handle name used in the parent node's connection actions. Defaults
1277
+ * to the handle's id.
1278
+ */
1279
+ accessibilityLabel?: string;
1188
1280
  /** Draw nothing. The handle still anchors edges and still accepts a drop. */
1189
1281
  hidden?: boolean;
1190
1282
  }
@@ -1203,6 +1295,7 @@ function FlowHandle({
1203
1295
  type = 'both',
1204
1296
  offset = 0.5,
1205
1297
  className,
1298
+ accessibilityLabel,
1206
1299
  hidden = false,
1207
1300
  }: FlowHandleProps) {
1208
1301
  const flow = useFlow('Flow.Handle');
@@ -1211,31 +1304,51 @@ function FlowHandle({
1211
1304
  throw new Error('Flow.Handle must be used within a <Flow.Node>');
1212
1305
  }
1213
1306
 
1214
- const { rects, zoom, connection, handles, locked, registerHandle, unregisterHandle } = flow;
1307
+ const {
1308
+ rects,
1309
+ zoom,
1310
+ connection,
1311
+ handles,
1312
+ locked,
1313
+ registerHandle,
1314
+ unregisterHandle,
1315
+ connectNodes,
1316
+ } = flow;
1215
1317
  const key = `${node}.${id}`;
1216
1318
 
1217
1319
  useEffect(() => {
1218
- registerHandle({ key, node, id, side: position, offset, type });
1320
+ registerHandle({
1321
+ key,
1322
+ node,
1323
+ id,
1324
+ label: accessibilityLabel ?? id,
1325
+ side: position,
1326
+ offset,
1327
+ type,
1328
+ });
1219
1329
  return () => unregisterHandle(key);
1220
- }, [id, key, node, offset, position, registerHandle, type, unregisterHandle]);
1221
-
1222
- const connectRef = useRef(flow.onConnect);
1223
- connectRef.current = flow.onConnect;
1224
- const validRef = useRef(flow.isValidConnection);
1225
- validRef.current = flow.isValidConnection;
1330
+ }, [
1331
+ accessibilityLabel,
1332
+ id,
1333
+ key,
1334
+ node,
1335
+ offset,
1336
+ position,
1337
+ registerHandle,
1338
+ type,
1339
+ unregisterHandle,
1340
+ ]);
1226
1341
 
1227
1342
  const land = useCallback(
1228
1343
  (target: string, targetHandle: string | undefined) => {
1229
- const payload: FlowConnection = {
1344
+ connectNodes({
1230
1345
  source: node,
1231
1346
  sourceHandle: id,
1232
1347
  target,
1233
1348
  targetHandle,
1234
- };
1235
- if (validRef.current && !validRef.current(payload)) return;
1236
- connectRef.current?.(payload);
1349
+ });
1237
1350
  },
1238
- [id, node]
1351
+ [connectNodes, id, node]
1239
1352
  );
1240
1353
 
1241
1354
  // A snapshot of what a drag can land on, rebuilt only when handles come and
@@ -1354,8 +1467,7 @@ function FlowHandle({
1354
1467
  <View
1355
1468
  collapsable={false}
1356
1469
  hitSlop={12}
1357
- accessibilityRole="button"
1358
- accessibilityLabel={`${position} connection point`}
1470
+ accessible={false}
1359
1471
  style={placement}
1360
1472
  className={flowVariants().handle({ className })}
1361
1473
  />
@@ -1645,9 +1757,9 @@ function FlowGroup({
1645
1757
  } = flow;
1646
1758
 
1647
1759
  useEffect(() => {
1648
- registerNode(id);
1760
+ registerNode(id, label ?? id);
1649
1761
  return () => unregisterNode(id);
1650
- }, [id, registerNode, unregisterNode]);
1762
+ }, [id, label, registerNode, unregisterNode]);
1651
1763
 
1652
1764
  useEffect(() => {
1653
1765
  setNodeRect(id, {
@@ -1919,7 +2031,7 @@ function FlowMiniMap({
1919
2031
  height = 84,
1920
2032
  nodeColor,
1921
2033
  }: FlowMiniMapProps) {
1922
- const { rects, nodeIds, groupIds, translateX, translateY, zoom, size } =
2034
+ const { rects, nodes, groupIds, translateX, translateY, zoom, size } =
1923
2035
  useFlow('Flow.MiniMap');
1924
2036
  const token = useCSSVariable('--color-muted-foreground');
1925
2037
  const tint = nodeColor ?? (typeof token === 'string' ? token : '#737373');
@@ -1999,7 +2111,7 @@ function FlowMiniMap({
1999
2111
  {/* Containers first and drawn as outlines, so the map reads the way
2000
2112
  the canvas does — boxes with things in them, rather than a wash of
2001
2113
  identical rectangles where the largest happens to be a group. */}
2002
- {nodeIds.map((id) => {
2114
+ {nodes.map(({ id }) => {
2003
2115
  const group = groups.has(id);
2004
2116
  return (
2005
2117
  <MiniMapNode
@@ -174,6 +174,8 @@ export function useForm<T extends Record<string, any>>({
174
174
  const validatorsRef = useRef<
175
175
  Partial<Record<keyof T, (value: any, values: T) => string | undefined | Promise<string | undefined>>>
176
176
  >({});
177
+ const validationOwnersRef = useRef<Partial<Record<keyof T, symbol>>>({});
178
+ const submissionLockedRef = useRef(false);
177
179
 
178
180
  const [state, dispatch] = useReducer(reducer<T>, {
179
181
  values: defaultValues,
@@ -196,31 +198,46 @@ export function useForm<T extends Record<string, any>>({
196
198
  const valuesRef = useRef(state.values);
197
199
  valuesRef.current = state.values;
198
200
 
199
- const setFieldValue = useCallback(<K extends keyof T>(name: K, value: T[K]) => {
200
- valuesRef.current = { ...valuesRef.current, [name]: value } as T;
201
- dispatch({ type: 'SET_VALUE', name, value });
201
+ /* Only the latest owner of a field may commit validation state. A value,
202
+ * validator or manual-error change claims ownership too, invalidating any
203
+ * result that was calculated from the state before that change. */
204
+ const claimFieldValidation = useCallback(<K extends keyof T>(name: K) => {
205
+ const owner = Symbol();
206
+ validationOwnersRef.current[name] = owner;
207
+ return owner;
202
208
  }, []);
203
209
 
210
+ const setFieldValue = useCallback(
211
+ <K extends keyof T>(name: K, value: T[K]) => {
212
+ claimFieldValidation(name);
213
+ valuesRef.current = { ...valuesRef.current, [name]: value } as T;
214
+ dispatch({ type: 'SET_VALUE', name, value });
215
+ },
216
+ [claimFieldValidation]
217
+ );
218
+
204
219
  const setFieldTouched = useCallback(<K extends keyof T>(name: K, touched = true) => {
205
220
  dispatch({ type: 'SET_TOUCHED', name, touched });
206
221
  }, []);
207
222
 
208
223
  const setFieldError = useCallback(
209
224
  <K extends keyof T>(name: K, error: string | undefined) => {
225
+ claimFieldValidation(name);
210
226
  dispatch({ type: 'SET_ERROR', name, error });
211
227
  },
212
- []
228
+ [claimFieldValidation]
213
229
  );
214
230
 
215
231
  const registerValidator = useCallback(
216
232
  <K extends keyof T>(name: K, validator?: Validator<T, K>) => {
233
+ claimFieldValidation(name);
217
234
  if (validator) {
218
235
  validatorsRef.current[name] = validator;
219
236
  } else {
220
237
  delete validatorsRef.current[name];
221
238
  }
222
239
  },
223
- []
240
+ [claimFieldValidation]
224
241
  );
225
242
 
226
243
  const getFieldState = useCallback(
@@ -235,81 +252,100 @@ export function useForm<T extends Record<string, any>>({
235
252
  const validateField = useCallback(async <K extends keyof T>(name: K) => {
236
253
  const validator = validatorsRef.current[name];
237
254
  if (!validator) return true;
255
+ const owner = claimFieldValidation(name);
238
256
  const values = valuesRef.current;
239
257
  const error = await runValidator(name, validator, values);
240
- dispatch({ type: 'SET_ERROR', name, error });
258
+ if (validationOwnersRef.current[name] === owner) {
259
+ dispatch({ type: 'SET_ERROR', name, error });
260
+ }
241
261
  return !error;
242
- }, []);
262
+ }, [claimFieldValidation]);
243
263
 
244
264
  const handleSubmit = useCallback(async () => {
245
- const values = valuesRef.current;
246
- /*
247
- * Every field the form knows of, which is not the same as every key of
248
- * `defaultValues`: a field can register a validator under a name that was
249
- * never declared there. Submitting only the declared ones meant such a
250
- * field was never validated and never blocked anything, so a form of
251
- * required-but-undeclared fields submitted itself while still empty.
252
- *
253
- * It is still a mistake to name a field that `defaultValues` does not
254
- * declare — its value stays undefined and never reaches `onSubmit` —
255
- * which is why `useField` says so. Validating it anyway is what turns
256
- * that mistake into a visible error instead of a silent submit.
257
- */
258
- const names = Array.from(
259
- new Set([...Object.keys(values), ...Object.keys(validatorsRef.current)])
260
- ) as (keyof T)[];
261
- dispatch({ type: 'TOUCH_ALL', names });
265
+ // Reducer state cannot guard two calls made before React renders again.
266
+ if (submissionLockedRef.current) return;
267
+ submissionLockedRef.current = true;
268
+ try {
269
+ const values = valuesRef.current;
270
+ /*
271
+ * Every field the form knows of, which is not the same as every key of
272
+ * `defaultValues`: a field can register a validator under a name that was
273
+ * never declared there. Submitting only the declared ones meant such a
274
+ * field was never validated and never blocked anything, so a form of
275
+ * required-but-undeclared fields submitted itself while still empty.
276
+ *
277
+ * It is still a mistake to name a field that `defaultValues` does not
278
+ * declare — its value stays undefined and never reaches `onSubmit` —
279
+ * which is why `useField` says so. Validating it anyway is what turns
280
+ * that mistake into a visible error instead of a silent submit.
281
+ */
282
+ const names = Array.from(
283
+ new Set([...Object.keys(values), ...Object.keys(validatorsRef.current)])
284
+ ) as (keyof T)[];
285
+ const validationOwners = new Map(
286
+ names.map((name) => [name, claimFieldValidation(name)] as const)
287
+ );
288
+ dispatch({ type: 'TOUCH_ALL', names });
262
289
 
263
- const fieldErrorEntries = await Promise.all(
264
- names.map(async (name) => {
265
- const validator = validatorsRef.current[name];
266
- const error = validator
267
- ? await runValidator(name, validator, values)
268
- : undefined;
269
- return [name, error] as const;
270
- })
271
- );
290
+ const fieldErrorEntries = await Promise.all(
291
+ names.map(async (name) => {
292
+ const validator = validatorsRef.current[name];
293
+ const error = validator
294
+ ? await runValidator(name, validator, values)
295
+ : undefined;
296
+ return [name, error] as const;
297
+ })
298
+ );
272
299
 
273
- /*
274
- * Same reasoning as a field's rule, one level up: a cross-field check that
275
- * throws must not take the screen, and must not let the submit through.
276
- * It belongs to no single field, so it blocks without marking one — there
277
- * is no field whose error line would be the honest place to put it.
278
- */
279
- let formErrors: FieldErrors<T> = {};
280
- let formValidateThrew = false;
281
- if (validate) {
282
- try {
283
- formErrors = await validate(values);
284
- } catch (error) {
285
- console.error(
286
- '[PanelUI] The form-level validate() threw, so the form is being ' +
287
- 'treated as invalid and the submit was not run.',
288
- error
289
- );
290
- formValidateThrew = true;
300
+ /*
301
+ * Same reasoning as a field's rule, one level up: a cross-field check that
302
+ * throws must not take the screen, and must not let the submit through.
303
+ * It belongs to no single field, so it blocks without marking one — there
304
+ * is no field whose error line would be the honest place to put it.
305
+ */
306
+ let formErrors: FieldErrors<T> = {};
307
+ let formValidateThrew = false;
308
+ if (validate) {
309
+ try {
310
+ formErrors = await validate(values);
311
+ } catch (error) {
312
+ console.error(
313
+ '[PanelUI] The form-level validate() threw, so the form is being ' +
314
+ 'treated as invalid and the submit was not run.',
315
+ error
316
+ );
317
+ formValidateThrew = true;
318
+ }
291
319
  }
292
- }
293
320
 
294
- const cleared = Object.fromEntries(names.map((name) => [name, undefined]));
295
- const nextErrors: FieldErrors<T> = {
296
- ...cleared,
297
- ...Object.fromEntries(fieldErrorEntries.filter(([, error]) => error !== undefined)),
298
- ...formErrors,
299
- };
300
- dispatch({ type: 'SET_ERRORS', errors: nextErrors });
321
+ const cleared = Object.fromEntries(names.map((name) => [name, undefined]));
322
+ const nextErrors: FieldErrors<T> = {
323
+ ...cleared,
324
+ ...Object.fromEntries(fieldErrorEntries.filter(([, error]) => error !== undefined)),
325
+ ...formErrors,
326
+ };
327
+ const ownedErrors = Object.fromEntries(
328
+ names
329
+ .filter((name) => validationOwnersRef.current[name] === validationOwners.get(name))
330
+ .map((name) => [name, nextErrors[name]])
331
+ ) as FieldErrors<T>;
332
+ dispatch({ type: 'SET_ERRORS', errors: ownedErrors });
301
333
 
302
- if (formValidateThrew || Object.values(nextErrors).some(Boolean)) return;
334
+ if (formValidateThrew || Object.values(nextErrors).some(Boolean)) return;
303
335
 
304
- dispatch({ type: 'SUBMIT_START' });
305
- try {
306
- await onSubmit(values);
336
+ dispatch({ type: 'SUBMIT_START' });
337
+ try {
338
+ await onSubmit(values);
339
+ } finally {
340
+ dispatch({ type: 'SUBMIT_END' });
341
+ }
307
342
  } finally {
308
- dispatch({ type: 'SUBMIT_END' });
343
+ submissionLockedRef.current = false;
309
344
  }
310
- }, [validate, onSubmit]);
345
+ }, [claimFieldValidation, validate, onSubmit]);
311
346
 
312
347
  const reset = useCallback((values: T = defaultsRef.current) => {
348
+ validationOwnersRef.current = {};
313
349
  valuesRef.current = values;
314
350
  dispatch({ type: 'RESET', values });
315
351
  }, []);
@@ -42,6 +42,7 @@ import {
42
42
  isValidElement,
43
43
  useContext,
44
44
  useEffect,
45
+ useId,
45
46
  useMemo,
46
47
  useRef,
47
48
  useState,
@@ -402,9 +403,7 @@ const HeatmapChartRoot = forwardRef<View, HeatmapChartProps>(function HeatmapCha
402
403
  ) {
403
404
  const [width, setWidth] = useState(0);
404
405
  const [activeCell, setActiveCellState] = useState<HeatmapCell | null>(null);
405
- const clipId = useRef(
406
- `panelui-heat-${Math.random().toString(36).slice(2, 9)}`
407
- ).current;
406
+ const clipId = `panelui-heat-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
408
407
 
409
408
  const reveal = useSharedValue(0);
410
409
  const reducedMotion = useReducedMotion();