panelui-native 0.62.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 (198) hide show
  1. package/README.md +5 -0
  2. package/lib/module/components/area-chart/index.js +14 -4
  3. package/lib/module/components/area-chart/index.js.map +1 -1
  4. package/lib/module/components/badge/index.js +1 -1
  5. package/lib/module/components/badge/index.js.map +1 -1
  6. package/lib/module/components/bar-chart/index.js +53 -6
  7. package/lib/module/components/bar-chart/index.js.map +1 -1
  8. package/lib/module/components/bottom-sheet/index.js +2 -2
  9. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  10. package/lib/module/components/button/index.js +19 -9
  11. package/lib/module/components/button/index.js.map +1 -1
  12. package/lib/module/components/candlestick-chart/index.js +14 -4
  13. package/lib/module/components/candlestick-chart/index.js.map +1 -1
  14. package/lib/module/components/card/index.js +26 -1
  15. package/lib/module/components/card/index.js.map +1 -1
  16. package/lib/module/components/carousel/index.js +28 -14
  17. package/lib/module/components/carousel/index.js.map +1 -1
  18. package/lib/module/components/checkbox/index.js +2 -3
  19. package/lib/module/components/checkbox/index.js.map +1 -1
  20. package/lib/module/components/dialog/index.js +51 -5
  21. package/lib/module/components/dialog/index.js.map +1 -1
  22. package/lib/module/components/drawer/index.js +2 -2
  23. package/lib/module/components/drawer/index.js.map +1 -1
  24. package/lib/module/components/fab/index.js +4 -3
  25. package/lib/module/components/fab/index.js.map +1 -1
  26. package/lib/module/components/field/index.js +41 -6
  27. package/lib/module/components/field/index.js.map +1 -1
  28. package/lib/module/components/flow/flow-accessibility.js +84 -0
  29. package/lib/module/components/flow/flow-accessibility.js.map +1 -0
  30. package/lib/module/components/flow/index.js +100 -39
  31. package/lib/module/components/flow/index.js.map +1 -1
  32. package/lib/module/components/form/use-form.js +93 -66
  33. package/lib/module/components/form/use-form.js.map +1 -1
  34. package/lib/module/components/heatmap-chart/index.js +2 -2
  35. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  36. package/lib/module/components/hex-chart/index.js +4 -4
  37. package/lib/module/components/hex-chart/index.js.map +1 -1
  38. package/lib/module/components/kpi/index.js +3 -1
  39. package/lib/module/components/kpi/index.js.map +1 -1
  40. package/lib/module/components/line-chart/index.js +17 -7
  41. package/lib/module/components/line-chart/index.js.map +1 -1
  42. package/lib/module/components/map/index.js +71 -8
  43. package/lib/module/components/map/index.js.map +1 -1
  44. package/lib/module/components/map/map-accessibility.js +20 -0
  45. package/lib/module/components/map/map-accessibility.js.map +1 -0
  46. package/lib/module/components/map/maplibre.js.map +1 -1
  47. package/lib/module/components/menu/index.js +4 -1
  48. package/lib/module/components/menu/index.js.map +1 -1
  49. package/lib/module/components/number-input/index.js +1 -23
  50. package/lib/module/components/number-input/index.js.map +1 -1
  51. package/lib/module/components/number-input/number-input-math.js +44 -0
  52. package/lib/module/components/number-input/number-input-math.js.map +1 -0
  53. package/lib/module/components/pie-chart/index.js +2 -2
  54. package/lib/module/components/pie-chart/index.js.map +1 -1
  55. package/lib/module/components/plot/index.js +1329 -0
  56. package/lib/module/components/plot/index.js.map +1 -0
  57. package/lib/module/components/polar-area-chart/index.js +2 -2
  58. package/lib/module/components/polar-area-chart/index.js.map +1 -1
  59. package/lib/module/components/radar-chart/index.js +14 -4
  60. package/lib/module/components/radar-chart/index.js.map +1 -1
  61. package/lib/module/components/radio-group/index.js +1 -2
  62. package/lib/module/components/radio-group/index.js.map +1 -1
  63. package/lib/module/components/ring-chart/index.js +2 -2
  64. package/lib/module/components/ring-chart/index.js.map +1 -1
  65. package/lib/module/components/section-rail/index.js +120 -15
  66. package/lib/module/components/section-rail/index.js.map +1 -1
  67. package/lib/module/components/selection-mode/index.js +129 -18
  68. package/lib/module/components/selection-mode/index.js.map +1 -1
  69. package/lib/module/components/sortable/index.js +20 -60
  70. package/lib/module/components/sortable/index.js.map +1 -1
  71. package/lib/module/components/sortable/reorder.js +63 -0
  72. package/lib/module/components/sortable/reorder.js.map +1 -0
  73. package/lib/module/components/swipe/index.js +4 -4
  74. package/lib/module/components/swipe/index.js.map +1 -1
  75. package/lib/module/components/switch/index.js +36 -3
  76. package/lib/module/components/switch/index.js.map +1 -1
  77. package/lib/module/components/time-picker/accessibility.js +21 -0
  78. package/lib/module/components/time-picker/accessibility.js.map +1 -0
  79. package/lib/module/components/time-picker/index.js +34 -8
  80. package/lib/module/components/time-picker/index.js.map +1 -1
  81. package/lib/module/components/waterfall-chart/index.js +1325 -0
  82. package/lib/module/components/waterfall-chart/index.js.map +1 -0
  83. package/lib/module/index.js +8 -0
  84. package/lib/module/index.js.map +1 -1
  85. package/lib/module/primitives/modal-isolation-store.js +29 -0
  86. package/lib/module/primitives/modal-isolation-store.js.map +1 -0
  87. package/lib/module/primitives/portal.js +36 -4
  88. package/lib/module/primitives/portal.js.map +1 -1
  89. package/lib/module/providers/panel-ui-provider.js +27 -6
  90. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  91. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  92. package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  94. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/candlestick-chart/index.d.ts +6 -3
  96. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -1
  97. package/lib/typescript/src/components/card/index.d.ts +48 -1
  98. package/lib/typescript/src/components/card/index.d.ts.map +1 -1
  99. package/lib/typescript/src/components/carousel/index.d.ts +1 -1
  100. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/dialog/index.d.ts +47 -2
  103. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  104. package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
  105. package/lib/typescript/src/components/field/index.d.ts +8 -1
  106. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  107. package/lib/typescript/src/components/flow/flow-accessibility.d.ts +49 -0
  108. package/lib/typescript/src/components/flow/flow-accessibility.d.ts.map +1 -0
  109. package/lib/typescript/src/components/flow/index.d.ts +14 -2
  110. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  111. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
  112. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  114. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  115. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  116. package/lib/typescript/src/components/map/index.d.ts +13 -5
  117. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  118. package/lib/typescript/src/components/map/map-accessibility.d.ts +19 -0
  119. package/lib/typescript/src/components/map/map-accessibility.d.ts.map +1 -0
  120. package/lib/typescript/src/components/map/maplibre.d.ts +2 -0
  121. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
  122. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  123. package/lib/typescript/src/components/number-input/index.d.ts.map +1 -1
  124. package/lib/typescript/src/components/number-input/number-input-math.d.ts +9 -0
  125. package/lib/typescript/src/components/number-input/number-input-math.d.ts.map +1 -0
  126. package/lib/typescript/src/components/plot/index.d.ts +471 -0
  127. package/lib/typescript/src/components/plot/index.d.ts.map +1 -0
  128. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -1
  129. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  130. package/lib/typescript/src/components/section-rail/index.d.ts +8 -1
  131. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  132. package/lib/typescript/src/components/selection-mode/index.d.ts +82 -6
  133. package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -1
  134. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  135. package/lib/typescript/src/components/sortable/reorder.d.ts +14 -0
  136. package/lib/typescript/src/components/sortable/reorder.d.ts.map +1 -0
  137. package/lib/typescript/src/components/swipe/index.d.ts.map +1 -1
  138. package/lib/typescript/src/components/switch/index.d.ts +3 -3
  139. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  140. package/lib/typescript/src/components/time-picker/accessibility.d.ts +10 -0
  141. package/lib/typescript/src/components/time-picker/accessibility.d.ts.map +1 -0
  142. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  143. package/lib/typescript/src/components/waterfall-chart/index.d.ts +379 -0
  144. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -0
  145. package/lib/typescript/src/index.d.ts +4 -1
  146. package/lib/typescript/src/index.d.ts.map +1 -1
  147. package/lib/typescript/src/primitives/modal-isolation-store.d.ts +10 -0
  148. package/lib/typescript/src/primitives/modal-isolation-store.d.ts.map +1 -0
  149. package/lib/typescript/src/primitives/portal.d.ts +6 -0
  150. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  151. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  152. package/package.json +5 -2
  153. package/src/components/area-chart/index.tsx +14 -4
  154. package/src/components/badge/index.tsx +4 -1
  155. package/src/components/bar-chart/index.tsx +54 -7
  156. package/src/components/bottom-sheet/index.tsx +3 -3
  157. package/src/components/button/index.tsx +15 -21
  158. package/src/components/candlestick-chart/index.tsx +20 -7
  159. package/src/components/card/index.tsx +32 -7
  160. package/src/components/carousel/index.tsx +42 -20
  161. package/src/components/checkbox/index.tsx +1 -2
  162. package/src/components/dialog/index.tsx +58 -5
  163. package/src/components/drawer/index.tsx +3 -3
  164. package/src/components/fab/index.tsx +8 -12
  165. package/src/components/field/index.tsx +52 -10
  166. package/src/components/flow/flow-accessibility.ts +109 -0
  167. package/src/components/flow/index.tsx +162 -50
  168. package/src/components/form/use-form.ts +101 -65
  169. package/src/components/heatmap-chart/index.tsx +2 -3
  170. package/src/components/hex-chart/index.tsx +4 -5
  171. package/src/components/kpi/index.tsx +3 -1
  172. package/src/components/line-chart/index.tsx +17 -6
  173. package/src/components/map/index.tsx +110 -7
  174. package/src/components/map/map-accessibility.ts +41 -0
  175. package/src/components/map/maplibre.ts +2 -0
  176. package/src/components/menu/index.tsx +4 -1
  177. package/src/components/number-input/index.tsx +1 -26
  178. package/src/components/number-input/number-input-math.ts +50 -0
  179. package/src/components/pie-chart/index.tsx +2 -2
  180. package/src/components/plot/index.tsx +1565 -0
  181. package/src/components/polar-area-chart/index.tsx +2 -2
  182. package/src/components/radar-chart/index.tsx +14 -4
  183. package/src/components/radio-group/index.tsx +1 -2
  184. package/src/components/ring-chart/index.tsx +2 -2
  185. package/src/components/section-rail/index.tsx +133 -14
  186. package/src/components/selection-mode/index.tsx +163 -26
  187. package/src/components/sortable/index.tsx +27 -69
  188. package/src/components/sortable/reorder.ts +88 -0
  189. package/src/components/swipe/index.tsx +7 -4
  190. package/src/components/switch/index.tsx +42 -6
  191. package/src/components/time-picker/accessibility.ts +24 -0
  192. package/src/components/time-picker/index.tsx +48 -2
  193. package/src/components/waterfall-chart/index.tsx +1589 -0
  194. package/src/index.ts +69 -0
  195. package/src/primitives/modal-isolation-store.ts +31 -0
  196. package/src/primitives/portal.tsx +34 -4
  197. package/src/providers/panel-ui-provider.tsx +41 -9
  198. package/theme.css +54 -3
@@ -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();
@@ -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,
@@ -269,7 +270,7 @@ const HexChartRoot = forwardRef<HexChartHandle, HexChartProps>(function HexChart
269
270
  density = 0.55,
270
271
  shape = 'blob',
271
272
  cellGap = 0.14,
272
- animationDuration = 900,
273
+ animationDuration = 620,
273
274
  status = 'ready',
274
275
  activeIndex: activeIndexProp,
275
276
  onActiveIndexChange,
@@ -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;
@@ -413,7 +412,7 @@ const HexChartRoot = forwardRef<HexChartHandle, HexChartProps>(function HexChart
413
412
  reveal.value = 0;
414
413
  reveal.value = withTiming(1, {
415
414
  duration: animationDuration,
416
- easing: Easing.bezier(0.85, 0, 0.15, 1),
415
+ easing: Easing.out(Easing.cubic),
417
416
  });
418
417
  },
419
418
  [reducedMotion, animationDuration, reveal]
@@ -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,
@@ -221,7 +222,7 @@ const LineChartRoot = forwardRef<LineChartHandle, LineChartProps>(function LineC
221
222
  xDataKey = 'date',
222
223
  status = 'ready',
223
224
  aspectRatio = 2,
224
- animationDuration = 1100,
225
+ animationDuration = 700,
225
226
  domainDuration = 500,
226
227
  yDomain,
227
228
  curve = 'monotone',
@@ -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);
@@ -341,17 +342,27 @@ const LineChartRoot = forwardRef<LineChartHandle, LineChartProps>(function LineC
341
342
  reveal.value = 0;
342
343
  reveal.value = withTiming(1, {
343
344
  duration: animationDuration,
344
- easing: Easing.bezier(0.85, 0, 0.15, 1),
345
+ easing: Easing.out(Easing.cubic),
345
346
  });
346
347
  },
347
348
  [reducedMotion, animationDuration, reveal]
348
349
  );
349
350
 
350
351
  useEffect(() => {
351
- if (revealed.current || loading || plot.width <= 0 || !data.length) return;
352
+ /*
353
+ * Going back to `loading` arms the reveal again. Without this a chart that
354
+ * is refetched comes back fully drawn on the frame the data lands, which
355
+ * reads as the loading state having been for nothing.
356
+ */
357
+ if (loading) {
358
+ revealed.current = false;
359
+ reveal.value = 0;
360
+ return;
361
+ }
362
+ if (revealed.current || plot.width <= 0 || !data.length) return;
352
363
  revealed.current = true;
353
364
  playReveal();
354
- }, [loading, plot.width, data.length, playReveal]);
365
+ }, [loading, plot.width, data.length, playReveal, reveal]);
355
366
 
356
367
  // `replay()` re-runs the reveal — for a control the caller wires up.
357
368
  useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
@@ -647,7 +658,7 @@ export interface LineChartAreaProps {
647
658
  function LineChartArea({ dataKey, color, colorIndex = 1, opacity = 0.18 }: LineChartAreaProps) {
648
659
  const { data, plot, domainMin, domainMax, curve, status, clipId } = useChart('LineChart.Area');
649
660
  const fill = useSeriesColor(color, colorIndex);
650
- 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, '')}`;
651
662
 
652
663
  const values = useMemo(() => columnValues(data, dataKey), [data, dataKey]);
653
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';