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
@@ -73,6 +73,7 @@ import {
73
73
  isValidElement,
74
74
  useContext,
75
75
  useEffect,
76
+ useId,
76
77
  useImperativeHandle,
77
78
  useMemo,
78
79
  useRef,
@@ -282,9 +283,7 @@ const HexChartRoot = forwardRef<HexChartHandle, HexChartProps>(function HexChart
282
283
  const [internalActive, setInternalActive] = useState(-1);
283
284
  const reveal = useSharedValue(0);
284
285
  const reducedMotion = useReducedMotion();
285
- const clipId = useRef(
286
- `panelui-hex-${Math.random().toString(36).slice(2, 9)}`
287
- ).current;
286
+ const clipId = `panelui-hex-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
288
287
 
289
288
  const controlled = activeIndexProp !== undefined;
290
289
  const activeIndex = controlled ? activeIndexProp : internalActive;
@@ -28,7 +28,9 @@
28
28
  */
29
29
  import { createContext, forwardRef, useContext, useMemo, type ReactNode } from 'react';
30
30
  import { View, type ViewProps } from 'react-native';
31
- import { ArrowDown, ArrowUp, Minus } from 'lucide-react-native';
31
+ import ArrowDown from 'lucide-react-native/icons/arrow-down';
32
+ import ArrowUp from 'lucide-react-native/icons/arrow-up';
33
+ import Minus from 'lucide-react-native/icons/minus';
32
34
  import { tv, type VariantProps } from 'tailwind-variants';
33
35
  import { useCSSVariable } from 'uniwind';
34
36
  import { Text } from '../../primitives/text';
@@ -48,6 +48,7 @@ import {
48
48
  isValidElement,
49
49
  useContext,
50
50
  useEffect,
51
+ useId,
51
52
  useImperativeHandle,
52
53
  useMemo,
53
54
  useRef,
@@ -235,7 +236,7 @@ const LineChartRoot = forwardRef<LineChartHandle, LineChartProps>(function LineC
235
236
  const [size, setSize] = useState({ width: 0, height: 0 });
236
237
  const [series, setSeries] = useState<[string, string][]>([]);
237
238
  const [activeIndexJS, setActiveIndexJS] = useState(-1);
238
- const clipId = useRef(`panelui-clip-${Math.random().toString(36).slice(2, 9)}`).current;
239
+ const clipId = `panelui-clip-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
239
240
 
240
241
  const reveal = useSharedValue(0);
241
242
  const domainMin = useSharedValue(0);
@@ -657,7 +658,7 @@ export interface LineChartAreaProps {
657
658
  function LineChartArea({ dataKey, color, colorIndex = 1, opacity = 0.18 }: LineChartAreaProps) {
658
659
  const { data, plot, domainMin, domainMax, curve, status, clipId } = useChart('LineChart.Area');
659
660
  const fill = useSeriesColor(color, colorIndex);
660
- const gradientId = useRef(`panelui-area-${Math.random().toString(36).slice(2, 9)}`).current;
661
+ const gradientId = `panelui-area-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
661
662
 
662
663
  const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
663
664
  const loading = status === 'loading';
@@ -36,13 +36,14 @@ import {
36
36
  isValidElement,
37
37
  useCallback,
38
38
  useContext,
39
+ useEffect,
39
40
  useImperativeHandle,
40
41
  useMemo,
41
42
  useRef,
42
43
  useState,
43
44
  type ReactNode,
44
45
  } from 'react';
45
- import { Pressable, View, type ViewProps } from 'react-native';
46
+ import { Pressable, View, type PressableProps, type ViewProps } from 'react-native';
46
47
  import { useCSSVariable } from 'uniwind';
47
48
  import { tv } from 'tailwind-variants';
48
49
  import { CompassIcon, CrosshairIcon, MinusIcon, PlusIcon } from '../../icons';
@@ -64,9 +65,15 @@ import {
64
65
  type StyleSpecification,
65
66
  type ViewState,
66
67
  } from './maplibre';
68
+ import {
69
+ describeMapFeatures,
70
+ type MapFeatureAccessibility,
71
+ type MapFeatureAccessibilityDescription,
72
+ } from './map-accessibility';
67
73
 
68
74
  export { hasMapLibre, CARTO_SOURCE };
69
75
  export type { BasemapSource, BasemapTokens, LngLat, LngLatBounds, ViewState };
76
+ export type { MapFeatureAccessibility } from './map-accessibility';
70
77
 
71
78
  const mapVariants = tv({
72
79
  slots: {
@@ -172,7 +179,16 @@ interface MapContextValue {
172
179
  ready: boolean;
173
180
  }
174
181
 
175
- const MapContext = createContext<MapContextValue | null>(null);
182
+ interface AccessibleFeatureGroup {
183
+ features: MapFeatureAccessibilityDescription[];
184
+ onPress?: (feature: unknown) => void;
185
+ }
186
+
187
+ interface InternalMapContextValue extends MapContextValue {
188
+ registerAccessibleFeatures(id: string, group: AccessibleFeatureGroup | null): void;
189
+ }
190
+
191
+ const MapContext = createContext<InternalMapContextValue | null>(null);
176
192
 
177
193
  /**
178
194
  * The map's camera and view state, from inside it.
@@ -293,6 +309,21 @@ const MapRoot = forwardRef<MapHandle, MapProps>(function MapRoot(
293
309
  const mapRef = useRef<MapRef | null>(null);
294
310
  const cameraRef = useRef<CameraRef | null>(null);
295
311
  const [ready, setReady] = useState(false);
312
+ const [accessibleFeatureGroups, setAccessibleFeatureGroups] = useState(
313
+ () => new globalThis.Map<string, AccessibleFeatureGroup>()
314
+ );
315
+
316
+ const registerAccessibleFeatures = useCallback(
317
+ (id: string, group: AccessibleFeatureGroup | null) => {
318
+ setAccessibleFeatureGroups((current) => {
319
+ const next = new globalThis.Map(current);
320
+ if (group?.features.length) next.set(id, group);
321
+ else next.delete(id);
322
+ return next;
323
+ });
324
+ },
325
+ []
326
+ );
296
327
 
297
328
  // Every colour the style needs, resolved from the active theme. Hooks run
298
329
  // unconditionally, so they sit above the renderer check rather than inside it.
@@ -344,7 +375,10 @@ const MapRoot = forwardRef<MapHandle, MapProps>(function MapRoot(
344
375
  []
345
376
  );
346
377
 
347
- const context = useMemo(() => ({ mapRef, cameraRef, ready }), [ready]);
378
+ const context = useMemo(
379
+ () => ({ mapRef, cameraRef, ready, registerAccessibleFeatures }),
380
+ [ready, registerAccessibleFeatures]
381
+ );
348
382
 
349
383
  if (!hasMapLibre || !MapLibre) {
350
384
  return <MapUnavailable className={className} {...props} />;
@@ -370,6 +404,8 @@ const MapRoot = forwardRef<MapHandle, MapProps>(function MapRoot(
370
404
  touchZoom={interactive}
371
405
  touchRotate={rotatable}
372
406
  touchPitch={rotatable}
407
+ accessible={false}
408
+ importantForAccessibility="no"
373
409
  onDidFinishLoadingMap={() => {
374
410
  setReady(true);
375
411
  onReady?.();
@@ -400,12 +436,41 @@ const MapRoot = forwardRef<MapHandle, MapProps>(function MapRoot(
400
436
  {textChildren(children)}
401
437
  </MapLibreMap>
402
438
  </MapContext.Provider>
439
+ <View
440
+ accessibilityRole="list"
441
+ style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden' }}
442
+ >
443
+ {[...accessibleFeatureGroups].flatMap(([groupId, group]) =>
444
+ group.features.map(({ feature, label, hint, state }, index) =>
445
+ group.onPress ? (
446
+ <Pressable
447
+ key={`${groupId}-${index}`}
448
+ accessibilityRole="button"
449
+ accessibilityLabel={label}
450
+ accessibilityHint={hint}
451
+ accessibilityState={state}
452
+ onPress={() => group.onPress?.(feature)}
453
+ />
454
+ ) : (
455
+ <View
456
+ key={`${groupId}-${index}`}
457
+ accessible
458
+ accessibilityRole="text"
459
+ accessibilityLabel={label}
460
+ accessibilityHint={hint}
461
+ accessibilityState={state}
462
+ />
463
+ )
464
+ )
465
+ )}
466
+ </View>
403
467
  </View>
404
468
  );
405
469
  });
406
470
  MapRoot.displayName = 'Map';
407
471
 
408
- export interface MapMarkerProps {
472
+ export interface MapMarkerProps
473
+ extends Omit<PressableProps, 'children' | 'onPress' | 'style'> {
409
474
  /** Where the marker sits, `[longitude, latitude]`. */
410
475
  lngLat: LngLat;
411
476
  /**
@@ -415,6 +480,8 @@ export interface MapMarkerProps {
415
480
  anchor?: 'center' | 'top' | 'bottom' | 'left' | 'right';
416
481
  /** Pressing the marker. Adds a button role when given. */
417
482
  onPress?: () => void;
483
+ /** Explicit spoken name. Other React Native accessibility props pass through too. */
484
+ accessibilityLabel?: string;
418
485
  className?: string;
419
486
  /** Defaults to a dot. Anything else replaces it. */
420
487
  children?: ReactNode;
@@ -432,6 +499,8 @@ function MapMarker({
432
499
  onPress,
433
500
  className,
434
501
  children,
502
+ accessibilityState,
503
+ ...props
435
504
  }: MapMarkerProps) {
436
505
  const slots = mapVariants();
437
506
  const [open, setOpen] = useState(false);
@@ -471,21 +540,28 @@ function MapMarker({
471
540
  * the default pin keeps the box real, which is also the right drawing: a
472
541
  * label with nothing to label is a caption floating on its own.
473
542
  */
543
+ const pressable = onPress || popups.length > 0;
474
544
  const body = (
475
- <View className={cn('items-center', className)}>
545
+ <View
546
+ {...(!pressable ? props : {})}
547
+ accessibilityState={!pressable ? accessibilityState : undefined}
548
+ className={cn('items-center', className)}
549
+ >
476
550
  {content.length > 0 ? content : <View className={slots.pin()} />}
477
551
  {labels}
478
552
  </View>
479
553
  );
480
554
 
481
- const pressable = onPress || popups.length > 0;
482
-
483
555
  return (
484
556
  <MarkerContext.Provider value={lngLat}>
485
557
  <Marker lngLat={lngLat} anchor={anchor}>
486
558
  {pressable ? (
487
559
  <Pressable
560
+ {...props}
488
561
  accessibilityRole="button"
562
+ accessibilityState={
563
+ popups.length > 0 ? { ...accessibilityState, expanded: open } : accessibilityState
564
+ }
489
565
  onPress={() => {
490
566
  if (popups.length > 0) setOpen((was) => !was);
491
567
  onPress?.();
@@ -906,6 +982,8 @@ export interface MapGeoJSONProps {
906
982
  fillOpacity?: number;
907
983
  /** Fires with the pressed feature. */
908
984
  onPress?: (feature: unknown) => void;
985
+ /** Describes each inline GeoJSON feature for the synchronized nonvisual list. */
986
+ accessibility?: (feature: unknown, index: number) => MapFeatureAccessibility;
909
987
  id?: string;
910
988
  }
911
989
 
@@ -924,10 +1002,12 @@ function MapGeoJSON({
924
1002
  strokeWidth = 1,
925
1003
  fillOpacity = 0.7,
926
1004
  onPress,
1005
+ accessibility,
927
1006
  id = 'geojson',
928
1007
  }: MapGeoJSONProps) {
929
1008
  const defaultFill = useToken('--color-primary', '#262626');
930
1009
  const defaultStroke = useToken('--color-border', 'rgba(0,0,0,0.1)');
1010
+ useAccessibleMapFeatures(id, data, accessibility, onPress);
931
1011
 
932
1012
  if (!MapLibre) return null;
933
1013
  const { GeoJSONSource, Layer } = MapLibre;
@@ -976,6 +1056,8 @@ export interface MapClusterProps {
976
1056
  maxZoom?: number;
977
1057
  /** Fires with the pressed cluster or point. */
978
1058
  onPress?: (feature: unknown) => void;
1059
+ /** Describes each source point for the synchronized nonvisual list. */
1060
+ accessibility?: (feature: unknown, index: number) => MapFeatureAccessibility;
979
1061
  id?: string;
980
1062
  }
981
1063
 
@@ -993,12 +1075,14 @@ function MapCluster({
993
1075
  radius = 50,
994
1076
  maxZoom = 14,
995
1077
  onPress,
1078
+ accessibility,
996
1079
  id = 'cluster',
997
1080
  }: MapClusterProps) {
998
1081
  const fill = useToken('--color-primary', '#262626');
999
1082
  const onFill = useToken('--color-primary-foreground', '#fafafa');
1000
1083
  const bubble = color ?? fill;
1001
1084
  const ink = textColor ?? onFill;
1085
+ useAccessibleMapFeatures(id, data, accessibility, onPress);
1002
1086
 
1003
1087
  if (!MapLibre) return null;
1004
1088
  const { GeoJSONSource, Layer } = MapLibre;
@@ -1057,6 +1141,25 @@ function MapCluster({
1057
1141
  }
1058
1142
  MapCluster.displayName = 'Map.Cluster';
1059
1143
 
1144
+ function useAccessibleMapFeatures(
1145
+ id: string,
1146
+ data: unknown,
1147
+ accessibility: MapGeoJSONProps['accessibility'],
1148
+ onPress?: (feature: unknown) => void
1149
+ ) {
1150
+ const registerAccessibleFeatures = useContext(MapContext)?.registerAccessibleFeatures;
1151
+ const features = useMemo(
1152
+ () => (accessibility ? describeMapFeatures(data, accessibility) : []),
1153
+ [accessibility, data]
1154
+ );
1155
+
1156
+ useEffect(() => {
1157
+ if (!accessibility || !registerAccessibleFeatures) return;
1158
+ registerAccessibleFeatures(id, { features, onPress });
1159
+ return () => registerAccessibleFeatures(id, null);
1160
+ }, [accessibility, features, id, onPress, registerAccessibleFeatures]);
1161
+ }
1162
+
1060
1163
  export interface MapHeatmapProps {
1061
1164
  /** Point features to spread. */
1062
1165
  data: unknown;
@@ -0,0 +1,41 @@
1
+ import type { AccessibilityState } from 'react-native';
2
+
3
+ /** The nonvisual description of one feature in a GeoJSON-backed layer. */
4
+ export interface MapFeatureAccessibility {
5
+ /** The feature's spoken name. */
6
+ label: string;
7
+ /** What activating the feature does, when the layer has `onPress`. */
8
+ hint?: string;
9
+ /** Selection, disabled, expanded, checked, or busy state. */
10
+ state?: AccessibilityState;
11
+ }
12
+
13
+ export type MapFeatureAccessibilityDescription = MapFeatureAccessibility & {
14
+ feature: unknown;
15
+ };
16
+
17
+ /**
18
+ * Describes the features in inline GeoJSON without maintaining a second list.
19
+ * URL sources cannot be read synchronously and remain decorative.
20
+ */
21
+ export function describeMapFeatures(
22
+ data: unknown,
23
+ describe: (feature: unknown, index: number) => MapFeatureAccessibility
24
+ ): MapFeatureAccessibilityDescription[] {
25
+ const record = isRecord(data) ? data : null;
26
+ const features =
27
+ record?.type === 'FeatureCollection' && Array.isArray(record.features)
28
+ ? record.features
29
+ : record?.type === 'Feature'
30
+ ? [data]
31
+ : [];
32
+
33
+ return features.map((feature, index) => ({
34
+ feature,
35
+ ...describe(feature, index),
36
+ }));
37
+ }
38
+
39
+ function isRecord(value: unknown): value is Record<string, unknown> {
40
+ return typeof value === 'object' && value !== null;
41
+ }
@@ -119,6 +119,8 @@ interface MapLibreModule {
119
119
  touchZoom?: boolean;
120
120
  touchRotate?: boolean;
121
121
  touchPitch?: boolean;
122
+ accessible?: boolean;
123
+ importantForAccessibility?: 'auto' | 'yes' | 'no' | 'no-hide-descendants';
122
124
  onPress?: (event: NativeEvent<{ coordinates: LngLat }>) => void;
123
125
  onDidFinishLoadingMap?: () => void;
124
126
  onRegionDidChange?: (event: NativeEvent<ViewState>) => void;
@@ -57,7 +57,10 @@ import Animated, {
57
57
  } from 'react-native-reanimated';
58
58
  import { tv } from 'tailwind-variants';
59
59
  import { useCSSVariable } from 'uniwind';
60
- import { Check, ChevronLeft, ChevronRight, Circle } from 'lucide-react-native';
60
+ import Check from 'lucide-react-native/icons/check';
61
+ import ChevronLeft from 'lucide-react-native/icons/chevron-left';
62
+ import ChevronRight from 'lucide-react-native/icons/chevron-right';
63
+ import Circle from 'lucide-react-native/icons/circle';
61
64
  import { Text, textChildren } from '../../primitives/text';
62
65
  import { useDirection, useDirectionSign } from '../../hooks/use-direction';
63
66
  import { selectionTick } from '../../utils/haptics';
@@ -34,6 +34,7 @@ import { AnimatedPressable } from '../../primitives/animated-pressable';
34
34
  import { Text } from '../../primitives/text';
35
35
  import { selectionTick } from '../../utils/haptics';
36
36
  import { Label } from '../label';
37
+ import { normalize, precisionOf } from './number-input-math';
37
38
 
38
39
  /** Wait before a held button starts repeating — a tap must not trip it. */
39
40
  const REPEAT_DELAY = 400;
@@ -156,32 +157,6 @@ export interface NumberInputProps
156
157
  haptics?: boolean;
157
158
  }
158
159
 
159
- /** Decimals implied by `step`, so display and math round to the same place. */
160
- function precisionOf(step: number): number {
161
- if (!isFinite(step)) return 0;
162
- const s = String(step);
163
- const dot = s.indexOf('.');
164
- return dot === -1 ? 0 : s.length - dot - 1;
165
- }
166
-
167
- function clamp(value: number, min: number, max: number): number {
168
- return Math.min(Math.max(value, min), max);
169
- }
170
-
171
- /**
172
- * Snaps to the nearest step from a finite base (or 0 when `min` is open), then
173
- * rounds to the step's precision so 0.1 + 0.2 does not drift to 0.30000004.
174
- */
175
- function normalize(value: number, min: number, max: number, step: number): number {
176
- const base = isFinite(min) ? min : 0;
177
- const snapped = isFinite(step) && step > 0
178
- ? Math.round((value - base) / step) * step + base
179
- : value;
180
- const p = precisionOf(step);
181
- const factor = 10 ** p;
182
- return clamp(Math.round(snapped * factor) / factor, min, max);
183
- }
184
-
185
160
  /**
186
161
  * A number stepped by buttons or typed by hand, reconciled to one clamped,
187
162
  * step-snapped value. See the file header for why it keeps both affordances.
@@ -0,0 +1,50 @@
1
+ /** JavaScript numbers carry about fifteen reliable decimal digits. */
2
+ const MAX_DECIMAL_PRECISION = 15;
3
+
4
+ /** Decimals `step` implies, before the reliable-digits ceiling is applied. */
5
+ function requestedPrecision(step: number): number {
6
+ if (!Number.isFinite(step)) return 0;
7
+
8
+ const [coefficient = '', exponentText] = Math.abs(step).toString().toLowerCase().split('e');
9
+ const decimals = coefficient.split('.')[1]?.length ?? 0;
10
+ const exponent = exponentText === undefined ? 0 : Number(exponentText);
11
+
12
+ return Math.max(0, decimals - exponent);
13
+ }
14
+
15
+ /** Decimals implied by `step`, including numbers written with an exponent. */
16
+ export function precisionOf(step: number): number {
17
+ return Math.min(MAX_DECIMAL_PRECISION, requestedPrecision(step));
18
+ }
19
+
20
+ export function clamp(value: number, min: number, max: number): number {
21
+ return Math.min(Math.max(value, min), max);
22
+ }
23
+
24
+ /**
25
+ * Snaps to the nearest step from a finite base, then clears binary drift at
26
+ * the step's decimal precision.
27
+ */
28
+ export function normalize(value: number, min: number, max: number, step: number): number {
29
+ const base = Number.isFinite(min) ? min : 0;
30
+ const offset = (value - base) / step;
31
+ const snapped =
32
+ Number.isFinite(step) && step > 0 && Number.isFinite(offset)
33
+ ? Math.round(offset) * step + base
34
+ : value;
35
+
36
+ // A step finer than the reliable-digit ceiling cannot be tidied at its own
37
+ // precision: rounding at the ceiling instead lands on zero and takes the
38
+ // value with it. The snap already placed it on the step, so leave it there.
39
+ if (requestedPrecision(step) > MAX_DECIMAL_PRECISION) return clamp(snapped, min, max);
40
+
41
+ const precision = precisionOf(step);
42
+ const factor = 10 ** precision;
43
+ const scaled = snapped * factor;
44
+ // Keep the established rounding path while it is safe. toFixed avoids
45
+ // turning a finite large value into Infinity when scaling would overflow.
46
+ const rounded = Number.isFinite(scaled)
47
+ ? Math.round(scaled) / factor
48
+ : Number(snapped.toFixed(precision));
49
+ return clamp(rounded, min, max);
50
+ }
@@ -68,6 +68,7 @@ import {
68
68
  isValidElement,
69
69
  useContext,
70
70
  useEffect,
71
+ useId,
71
72
  useImperativeHandle,
72
73
  useMemo,
73
74
  useRef,
@@ -328,7 +329,7 @@ const PlotRoot = forwardRef<PlotHandle, PlotProps>(function PlotRoot(
328
329
  const [size, setSize] = useState({ width: 0, height: 0 });
329
330
  const [series, setSeries] = useState<[string, string][]>([]);
330
331
  const [activeIndexJS, setActiveIndexJS] = useState(-1);
331
- const clipId = useRef(`panelui-plot-${Math.random().toString(36).slice(2, 9)}`).current;
332
+ const clipId = `panelui-plot-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
332
333
 
333
334
  const reveal = useSharedValue(0);
334
335
  const domainMin = useSharedValue(0);
@@ -45,7 +45,7 @@ const itemVariants = tv({
45
45
  // `self-start` matters: the group is a column, so without it Yoga stretches
46
46
  // the row to the full width and the dead space to the right of the label
47
47
  // becomes part of the target. Tapping nothing would select the option.
48
- row: 'flex-row items-center gap-2.5 self-start',
48
+ row: 'min-h-12 min-w-12 flex-row items-center gap-2.5 self-start',
49
49
  indicator:
50
50
  'h-5 w-5 items-center justify-center rounded-full border border-input bg-background',
51
51
  label: 'text-sm text-foreground',
@@ -248,7 +248,6 @@ const RadioGroupItem = forwardRef<View, RadioGroupItemProps>(
248
248
  accessibilityLabel={label}
249
249
  disabled={disabled}
250
250
  onPress={() => context.onValueChange(value)}
251
- hitSlop={variant === 'card' ? undefined : 8}
252
251
  className={slots.row({ className })}
253
252
  >
254
253
  {body}
@@ -89,6 +89,7 @@ import { useCSSVariable } from 'uniwind';
89
89
  import { GripVerticalIcon, IconColorProvider } from '../../icons';
90
90
  import { cn } from '../../utils/cn';
91
91
  import { impactKnock, selectionTick } from '../../utils/haptics';
92
+ import { moveWithPinned, stepWithPinned } from './reorder';
92
93
 
93
94
  /**
94
95
  * Rows getting out of the way of the one being carried. Quick, because they
@@ -228,66 +229,6 @@ function slotCenter(
228
229
  return offset + (self === undefined ? 0 : (heights[self] ?? 0)) / 2;
229
230
  }
230
231
 
231
- /**
232
- * The order after a row has moved, with pinned rows left where they were.
233
- *
234
- * The move is applied first, to the whole list, so a carried row can be dragged
235
- * *past* a pinned one — refusing the move instead would make a pinned row a
236
- * wall, and a row that holds its place is not the same as a row nothing may
237
- * cross. The pinned ids are then put back at the indices they occupy in the
238
- * laid-out order, and everything else falls into the slots that are left, in
239
- * the order the move produced.
240
- *
241
- * `laid` rather than `list` is what the fixed indices are read from, because
242
- * that is the one order a pinned row is guaranteed to be correctly placed in:
243
- * it is where it was rendered, and holding its slot is the whole point.
244
- */
245
- function moveWithPinned(
246
- list: readonly string[],
247
- laid: readonly string[],
248
- pinned: Record<string, boolean>,
249
- id: string,
250
- from: number,
251
- to: number
252
- ): string[] {
253
- 'worklet';
254
- const moved = [...list];
255
- moved.splice(from, 1);
256
- moved.splice(to, 0, id);
257
-
258
- const next: (string | undefined)[] = [];
259
- let anyPinned = false;
260
- for (let i = 0; i < moved.length; i += 1) next.push(undefined);
261
- for (let i = 0; i < laid.length && i < next.length; i += 1) {
262
- const at = laid[i];
263
- if (at !== undefined && pinned[at]) {
264
- next[i] = at;
265
- anyPinned = true;
266
- }
267
- }
268
- if (!anyPinned) return moved;
269
-
270
- const free: string[] = [];
271
- for (let i = 0; i < moved.length; i += 1) {
272
- const at = moved[i];
273
- if (at !== undefined && !pinned[at]) free.push(at);
274
- }
275
-
276
- const result: string[] = [];
277
- let f = 0;
278
- for (let i = 0; i < next.length; i += 1) {
279
- const held = next[i];
280
- if (held !== undefined) {
281
- result.push(held);
282
- continue;
283
- }
284
- const take = free[f];
285
- f += 1;
286
- if (take !== undefined) result.push(take);
287
- }
288
- return result;
289
- }
290
-
291
232
  /**
292
233
  * Where the dragged row belongs now, given where its edges have reached.
293
234
  *
@@ -760,15 +701,22 @@ function SortableRoot({
760
701
  const current = rendered.value.indexOf(id);
761
702
  if (current < 0) return;
762
703
 
763
- const to = current + delta;
764
- if (to < 0 || to >= rendered.value.length) return;
704
+ const next = stepWithPinned(
705
+ rendered.value,
706
+ rendered.value,
707
+ pinned.value,
708
+ id,
709
+ current,
710
+ delta
711
+ );
712
+ const to = next.indexOf(id);
713
+ if (to === current) return;
765
714
 
766
- const next = reorderItems(rendered.value, current, to);
767
715
  order.value = next;
768
716
  if (haptics) selectionTick();
769
717
  onReorder?.(next, { id, from: current, to });
770
718
  },
771
- [haptics, onReorder, order, rendered]
719
+ [haptics, onReorder, order, pinned, rendered]
772
720
  );
773
721
 
774
722
  const context = useMemo<SortableContextValue>(