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
@@ -1083,6 +1083,15 @@ function BarChartTooltip({ formatValue, formatX, className }: BarChartTooltipPro
1083
1083
  const width = plot.width;
1084
1084
  const height = plot.height;
1085
1085
 
1086
+ /*
1087
+ * The readout's own height, measured rather than assumed. Sideways it has to
1088
+ * be clamped inside the plot vertically, and how tall it is depends on how
1089
+ * many series are listed in it — a constant here would either let a
1090
+ * three-series readout hang off the bottom or reserve room a one-series
1091
+ * readout never uses.
1092
+ */
1093
+ const labelHeight = useSharedValue(0);
1094
+
1086
1095
  /*
1087
1096
  * Declared inside the memo, next to its callers: a worklet may only call
1088
1097
  * another worklet, and the rule is enforced by crashing rather than warning.
@@ -1118,8 +1127,17 @@ function BarChartTooltip({ formatValue, formatX, className }: BarChartTooltipPro
1118
1127
  });
1119
1128
  }, [total, left, top, width, height, horizontal, activeIndex, setActiveIndexJS]);
1120
1129
 
1121
- // The readout centres over its band and is clamped inside the plot, so it
1122
- // never runs off the edge at the first or last one.
1130
+ /*
1131
+ * The readout centres over its band and is clamped inside the plot, so it
1132
+ * never runs off the edge at the first or last one.
1133
+ *
1134
+ * Which axis the band runs along is the whole difference between the two
1135
+ * orientations. Upright, the bands are side by side, so the readout slides
1136
+ * across and sits above them all. Sideways, the bands are stacked down the
1137
+ * plot, so it slides *down* to the row it is describing and stays centred
1138
+ * across — held at the top instead, it would name the row under the finger
1139
+ * while covering the first one, which is the row a reader checks it against.
1140
+ */
1123
1141
  const labelStyle = useAnimatedStyle(() => {
1124
1142
  const index = activeIndex.value;
1125
1143
  if (index < 0 || !total) return { opacity: 0 };
@@ -1130,7 +1148,23 @@ function BarChartTooltip({ formatValue, formatX, className }: BarChartTooltipPro
1130
1148
  plot.left + plot.width - half,
1131
1149
  Math.max(plot.left + half, horizontal ? plot.left + plot.width / 2 : centre)
1132
1150
  );
1133
- return { opacity: 1, transform: [{ translateX: clamped - half }] };
1151
+
1152
+ if (!horizontal) {
1153
+ return { opacity: 1, transform: [{ translateX: clamped - half }] };
1154
+ }
1155
+
1156
+ // Until the first measurement lands the height is zero, which clamps to
1157
+ // the top of the plot — the same place it used to sit, rather than a jump
1158
+ // from somewhere it never was.
1159
+ const tall = labelHeight.value;
1160
+ const y = Math.min(
1161
+ plot.top + Math.max(plot.height - tall, 0),
1162
+ Math.max(plot.top, centre - tall / 2)
1163
+ );
1164
+ return {
1165
+ opacity: 1,
1166
+ transform: [{ translateX: clamped - half }, { translateY: y }],
1167
+ };
1134
1168
  });
1135
1169
 
1136
1170
  const active = activeIndexJS >= 0 ? data[activeIndexJS] : null;
@@ -1151,6 +1185,9 @@ function BarChartTooltip({ formatValue, formatX, className }: BarChartTooltipPro
1151
1185
  >
1152
1186
  {active ? (
1153
1187
  <View
1188
+ onLayout={(event) => {
1189
+ labelHeight.value = event.nativeEvent.layout.height;
1190
+ }}
1154
1191
  className={cn(
1155
1192
  'rounded-xl border border-border bg-popover px-2.5 py-1.5 shadow-lg',
1156
1193
  className
@@ -31,7 +31,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
31
31
  import { tv } from 'tailwind-variants';
32
32
  import { getNativeUI } from '../../native';
33
33
  import { XIcon } from '../../icons';
34
- import { Portal } from '../../primitives/portal';
34
+ import { ModalPortal } from '../../primitives/portal';
35
35
  import { Scrim } from '../../primitives/scrim';
36
36
  import { Text, textChildren } from '../../primitives/text';
37
37
  import { useBackHandler } from '../../hooks/use-back-handler';
@@ -435,7 +435,7 @@ function BottomSheetContent({
435
435
  if (!open) return null;
436
436
 
437
437
  return (
438
- <Portal>
438
+ <ModalPortal>
439
439
  {/* Portal content mounts under PortalHost, outside this provider's
440
440
  subtree — re-provide the context so nested consumers keep working. */}
441
441
  <BottomSheetContext.Provider value={context}>
@@ -507,7 +507,7 @@ function BottomSheetContent({
507
507
  </GestureDetector>
508
508
  </View>
509
509
  </BottomSheetContext.Provider>
510
- </Portal>
510
+ </ModalPortal>
511
511
  );
512
512
  }
513
513
 
@@ -42,8 +42,9 @@ const buttonVariants = tv({
42
42
  },
43
43
  destructive: {
44
44
  root: 'border-destructive bg-destructive shadow-sm',
45
- label: 'text-white',
46
- spinner: 'border-white/32 border-t-white',
45
+ label: 'text-destructive-solid-foreground',
46
+ spinner:
47
+ 'border-destructive-solid-foreground/32 border-t-destructive-solid-foreground',
47
48
  },
48
49
  /** Neutral surface for third-party sign-in, sized for a full-width stack. */
49
50
  social: {
@@ -53,8 +54,8 @@ const buttonVariants = tv({
53
54
  },
54
55
  },
55
56
  size: {
56
- sm: { root: 'h-9 gap-1.5 px-2.5', label: 'text-sm' },
57
- md: { root: 'h-11 px-4', label: 'text-base' },
57
+ sm: { root: 'h-9 min-w-9 gap-1.5 px-2.5', label: 'text-sm' },
58
+ md: { root: 'h-11 min-w-11 px-4', label: 'text-base' },
58
59
  lg: { root: 'h-12 px-6', label: 'text-lg' },
59
60
  icon: { root: 'h-11 w-11 px-0' },
60
61
  },
@@ -134,18 +135,16 @@ const ATTACHED = 'rounded-none border-transparent shadow-none active:bg-accent';
134
135
 
135
136
  const SPINNER_SIZE = { sm: 'sm', md: 'sm', lg: 'md', icon: 'sm' } as const;
136
137
 
138
+ /** Extra room around the styled sizes so even the compact button reaches 48dp. */
139
+ const BUTTON_HIT_SLOP = { sm: 6, md: 2, lg: 0, icon: 2 } as const;
140
+
137
141
  /**
138
142
  * The theme token each variant's content reads against. Icons in the content
139
143
  * slots inherit the resolved value, so they follow the theme automatically.
140
- *
141
- * `destructive` is absent on purpose: its background is a saturated red in
142
- * every theme, so its content is always white rather than a themed token.
143
144
  */
144
- const CONTENT_COLOR_VAR: Record<
145
- Exclude<NonNullable<ButtonVariantProps['variant']>, 'destructive'>,
146
- string
147
- > = {
145
+ const CONTENT_COLOR_VAR: Record<NonNullable<ButtonVariantProps['variant']>, string> = {
148
146
  primary: '--color-primary-foreground',
147
+ destructive: '--color-destructive-solid-foreground',
149
148
  secondary: '--color-secondary-foreground',
150
149
  outline: '--color-foreground',
151
150
  ghost: '--color-foreground',
@@ -328,17 +327,9 @@ export const Button = forwardRef<View, ButtonProps>(
328
327
  // Icons in the content slots inherit this, so they stay legible when the
329
328
  // theme inverts the button's background. Without it every caller has to
330
329
  // hardcode a hex that is wrong in one theme or the other.
331
- const themedColor = useCSSVariable(
332
- CONTENT_COLOR_VAR[
333
- resolvedVariant === 'destructive' ? 'primary' : (resolvedVariant ?? 'primary')
334
- ]
335
- );
330
+ const themedColor = useCSSVariable(CONTENT_COLOR_VAR[resolvedVariant ?? 'primary']);
336
331
  const contentColor =
337
- resolvedVariant === 'destructive'
338
- ? '#ffffff'
339
- : typeof themedColor === 'string'
340
- ? themedColor
341
- : undefined;
332
+ typeof themedColor === 'string' ? themedColor : undefined;
342
333
 
343
334
  if (nativeUI) {
344
335
  const { Host, Button: NativeButton, RNHostView } = nativeUI;
@@ -446,6 +437,9 @@ export const Button = forwardRef<View, ButtonProps>(
446
437
  accessibilityRole="button"
447
438
  accessibilityState={{ disabled: isDisabled, busy: loading }}
448
439
  disabled={isDisabled}
440
+ // Joined segments cannot borrow room from each other without making
441
+ // two actions claim the same pixels. ButtonGroup owns that layout.
442
+ hitSlop={attached ? undefined : BUTTON_HIT_SLOP[resolvedSize ?? 'md']}
449
443
  // Before the spread, so a caller can still ask for the scale back.
450
444
  pressScale={attached ? 1 : undefined}
451
445
  className={root({ className: cn(attached && ATTACHED, className) })}
@@ -207,7 +207,7 @@ export interface CarouselProps extends ViewProps {
207
207
  autoplay?: boolean;
208
208
  /** Milliseconds each slide is held when `autoplay` is set. */
209
209
  autoplayInterval?: number;
210
- /** Controlled active slide. */
210
+ /** Controlled active slide. Requests move visually only after this value changes. */
211
211
  index?: number;
212
212
  /** Starting slide when uncontrolled. */
213
213
  defaultIndex?: number;
@@ -269,28 +269,37 @@ const CarouselRoot = forwardRef<CarouselHandle, CarouselProps>(function Carousel
269
269
  [isControlled, onIndexChange]
270
270
  );
271
271
 
272
- const scrollTo = useCallback(
272
+ const animateTo = useCallback(
273
273
  (target: number) => {
274
- if (count <= 0) return;
275
- const settled = loop ? wrap(target, count) : Math.max(0, Math.min(target, count - 1));
276
-
277
274
  if (reducedMotion) {
278
- progress.value = settled;
275
+ progress.value = target;
279
276
  } else if (loop) {
280
277
  // Spring to the nearest representation of the target rather than to the
281
278
  // target itself, so a wrap from the last slide to the first travels one
282
279
  // step forward instead of winding all the way back through the run.
283
- const shortest = progress.value + distance(settled, progress.value, count, true);
280
+ const shortest = progress.value + distance(target, progress.value, count, true);
284
281
  progress.value = withSpring(shortest, SPRING, (finished) => {
285
282
  if (finished) progress.value = wrap(progress.value, count);
286
283
  });
287
284
  } else {
288
- progress.value = withSpring(settled, SPRING);
285
+ progress.value = withSpring(target, SPRING);
289
286
  }
287
+ },
288
+ [count, loop, progress, reducedMotion]
289
+ );
290
+
291
+ const scrollTo = useCallback(
292
+ (target: number) => {
293
+ if (count <= 0) return;
294
+ const settled = loop ? wrap(target, count) : Math.max(0, Math.min(target, count - 1));
290
295
 
296
+ // A controlled request belongs to its owner. The finger may move the run,
297
+ // but after release it returns to the current prop until the owner accepts
298
+ // the request by changing that prop.
299
+ animateTo(isControlled ? index : settled);
291
300
  setIndex(settled);
292
301
  },
293
- [count, loop, progress, reducedMotion, setIndex]
302
+ [animateTo, count, index, isControlled, loop, setIndex]
294
303
  );
295
304
 
296
305
  const next = useCallback(() => scrollTo(index + 1), [index, scrollTo]);
@@ -302,8 +311,8 @@ const CarouselRoot = forwardRef<CarouselHandle, CarouselProps>(function Carousel
302
311
  useEffect(() => {
303
312
  if (!isControlled || indexProp === undefined || count <= 0) return;
304
313
  if (Math.abs(progress.value - indexProp) < 0.001) return;
305
- progress.value = reducedMotion ? indexProp : withSpring(indexProp, SPRING);
306
- }, [isControlled, indexProp, count, progress, reducedMotion]);
314
+ animateTo(indexProp);
315
+ }, [animateTo, isControlled, indexProp, count, progress]);
307
316
 
308
317
  /*
309
318
  * Autoplay stops for good the first time a finger lands, rather than pausing.
@@ -688,16 +697,30 @@ function CarouselDots({
688
697
  key={dot}
689
698
  disabled={!interactive}
690
699
  onPress={() => scrollTo(dot)}
691
- hitSlop={6}
692
700
  accessibilityRole="tab"
693
701
  accessibilityLabel={`Slide ${dot + 1} of ${count}`}
694
702
  accessibilityState={{ selected: active }}
695
- className={cn(
696
- 'rounded-full',
697
- horizontal ? (active ? 'h-1 w-4' : 'h-1 w-1') : active ? 'h-4 w-1' : 'h-1 w-1',
698
- active ? 'bg-foreground' : 'bg-foreground/30'
699
- )}
700
- />
703
+ // 24, not the 48 the buttons get. Dots sit a few points apart, so
704
+ // a 48 box would either overlap its neighbours — making a tap near
705
+ // the join land on the wrong slide — or push a five-slide run out
706
+ // to the width of the screen. 24 clears the minimum with the pitch
707
+ // still wider than the target, so no two dots contend for a touch.
708
+ className={cn('items-center justify-center', interactive && 'h-6 w-6')}
709
+ >
710
+ <View
711
+ className={cn(
712
+ 'rounded-full',
713
+ horizontal
714
+ ? active
715
+ ? 'h-1 w-4'
716
+ : 'h-1 w-1'
717
+ : active
718
+ ? 'h-4 w-1'
719
+ : 'h-1 w-1',
720
+ active ? 'bg-foreground' : 'bg-foreground/30'
721
+ )}
722
+ />
723
+ </Pressable>
701
724
  );
702
725
  })}
703
726
  </View>
@@ -723,12 +746,11 @@ function makeArrow(direction: 'previous' | 'next') {
723
746
  <Pressable
724
747
  disabled={disabled}
725
748
  onPress={direction === 'next' ? next : previous}
726
- hitSlop={8}
727
749
  accessibilityRole="button"
728
750
  accessibilityLabel={direction === 'next' ? 'Next slide' : 'Previous slide'}
729
751
  accessibilityState={{ disabled }}
730
752
  className={cn(
731
- 'items-center justify-center rounded-full p-1',
753
+ 'h-12 w-12 items-center justify-center rounded-full',
732
754
  disabled ? 'opacity-30' : 'active:bg-foreground/10',
733
755
  className
734
756
  )}
@@ -13,7 +13,7 @@ import { Text } from '../../primitives/text';
13
13
 
14
14
  const checkboxVariants = tv({
15
15
  slots: {
16
- row: 'flex-row items-center gap-2.5',
16
+ row: 'min-h-12 min-w-12 flex-row items-center gap-2.5',
17
17
  box: 'h-5 w-5 items-center justify-center rounded-md border border-input bg-background',
18
18
  fill: 'absolute inset-0 items-center justify-center rounded-md bg-primary',
19
19
  content: 'flex-1 gap-0.5',
@@ -130,7 +130,6 @@ export const Checkbox = forwardRef<View, CheckboxProps>(
130
130
  disabled={disabled}
131
131
  // An indeterminate box resolves upward: the press turns the group on.
132
132
  onPress={() => onCheckedChange?.(indeterminate ? true : !checked)}
133
- hitSlop={8}
134
133
  // A description makes the row two lines tall — align the box to the
135
134
  // label rather than centring it against the whole block.
136
135
  className={slots.row({
@@ -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';
@@ -111,7 +111,7 @@ function DialogContent({
111
111
  if (!open) return null;
112
112
 
113
113
  return (
114
- <Portal>
114
+ <ModalPortal>
115
115
  {/* Portal content mounts under PortalHost, outside this provider's
116
116
  subtree — re-provide the context so Dialog.Close etc. keep working. */}
117
117
  <DialogContext.Provider value={context}>
@@ -142,7 +142,7 @@ function DialogContent({
142
142
  </Animated.View>
143
143
  </Animated.View>
144
144
  </DialogContext.Provider>
145
- </Portal>
145
+ </ModalPortal>
146
146
  );
147
147
  }
148
148
 
@@ -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';
@@ -458,7 +458,7 @@ function DrawerContent({
458
458
  };
459
459
 
460
460
  return (
461
- <Portal>
461
+ <ModalPortal>
462
462
  {/* Portal content mounts under PortalHost, outside this provider's
463
463
  subtree — re-provide the context so Drawer.Close keeps working. */}
464
464
  <DrawerContext.Provider value={{ open, setOpen }}>
@@ -561,7 +561,7 @@ function DrawerContent({
561
561
  </View>
562
562
  </DirectionContext.Provider>
563
563
  </DrawerContext.Provider>
564
- </Portal>
564
+ </ModalPortal>
565
565
  );
566
566
  }
567
567
 
@@ -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,109 @@
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
+ /** Keep pointer and assistive-technology movement inside the same group bounds. */
33
+ export function clampNodePosition(
34
+ rect: FlowRect,
35
+ bounds: FlowRect | undefined,
36
+ position: FlowNodePosition
37
+ ): FlowNodePosition {
38
+ 'worklet';
39
+ if (!bounds) return position;
40
+
41
+ return {
42
+ x: Math.min(Math.max(position.x, bounds.x), bounds.x + bounds.width - rect.width),
43
+ y: Math.min(Math.max(position.y, bounds.y), bounds.y + bounds.height - rect.height),
44
+ };
45
+ }
46
+
47
+ export function moveNodePosition(
48
+ rect: FlowRect,
49
+ bounds: FlowRect | undefined,
50
+ actionName: string,
51
+ step: number
52
+ ): FlowNodePosition | undefined {
53
+ const action = FLOW_MOVE_ACTIONS.find((entry) => entry.name === actionName);
54
+ if (!action) return undefined;
55
+
56
+ return clampNodePosition(rect, bounds, {
57
+ x: rect.x + action.dx * Math.max(1, step),
58
+ y: rect.y + action.dy * Math.max(1, step),
59
+ });
60
+ }
61
+
62
+ /** Build connection choices from the same registered nodes and handles used by drag hit-testing. */
63
+ export function getFlowConnectionActions(
64
+ sourceNode: string,
65
+ nodes: FlowAccessibilityNode[],
66
+ handles: FlowAccessibilityHandle[]
67
+ ): FlowConnectionAction[] {
68
+ const sources = handles.filter(
69
+ (handle) => handle.node === sourceNode && handle.type !== 'target'
70
+ );
71
+ if (sources.length === 0) return [];
72
+
73
+ const actions: FlowConnectionAction[] = [];
74
+ for (const source of sources) {
75
+ for (const targetNode of nodes) {
76
+ if (targetNode.id === sourceNode) continue;
77
+ const targets = handles.filter(
78
+ (handle) => handle.node === targetNode.id && handle.type !== 'source'
79
+ );
80
+
81
+ if (targets.length === 0) {
82
+ actions.push({
83
+ name: `flow-connect:${source.key}->${targetNode.id}`,
84
+ label: `Connect ${source.label} to ${targetNode.label}`,
85
+ connection: {
86
+ source: sourceNode,
87
+ sourceHandle: source.id,
88
+ target: targetNode.id,
89
+ },
90
+ });
91
+ continue;
92
+ }
93
+
94
+ for (const target of targets) {
95
+ actions.push({
96
+ name: `flow-connect:${source.key}->${target.key}`,
97
+ label: `Connect ${source.label} to ${targetNode.label}, ${target.label}`,
98
+ connection: {
99
+ source: sourceNode,
100
+ sourceHandle: source.id,
101
+ target: target.node,
102
+ targetHandle: target.id,
103
+ },
104
+ });
105
+ }
106
+ }
107
+ }
108
+ return actions;
109
+ }