@eifi1/ui-kit 0.6.0 → 0.7.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 (271) hide show
  1. package/README.md +69 -33
  2. package/dist/chart.d.ts +1 -0
  3. package/dist/components/account-settings.js +3 -2
  4. package/dist/components/account-settings.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +3 -0
  6. package/dist/components/amount-input.js +6 -4
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/autocomplete.d.ts +36 -7
  9. package/dist/components/autocomplete.js +68 -22
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/calculator.d.ts +3 -0
  12. package/dist/components/calculator.js +2 -1
  13. package/dist/components/calculator.js.map +1 -1
  14. package/dist/components/chart-zoom.js +2 -1
  15. package/dist/components/chart-zoom.js.map +1 -1
  16. package/dist/components/chart.d.ts +33 -2
  17. package/dist/components/chart.js +20 -8
  18. package/dist/components/chart.js.map +1 -1
  19. package/dist/components/checkbox.d.ts +9 -0
  20. package/dist/components/checkbox.js +7 -2
  21. package/dist/components/checkbox.js.map +1 -1
  22. package/dist/components/chip.d.ts +34 -14
  23. package/dist/components/chip.js +63 -28
  24. package/dist/components/chip.js.map +1 -1
  25. package/dist/components/choice-card.d.ts +18 -1
  26. package/dist/components/choice-card.js +21 -8
  27. package/dist/components/choice-card.js.map +1 -1
  28. package/dist/components/combobox-core.d.ts +61 -2
  29. package/dist/components/combobox-core.js +68 -17
  30. package/dist/components/combobox-core.js.map +1 -1
  31. package/dist/components/combobox.d.ts +11 -7
  32. package/dist/components/combobox.js +51 -28
  33. package/dist/components/combobox.js.map +1 -1
  34. package/dist/components/currency-select.js +3 -3
  35. package/dist/components/currency-select.js.map +1 -1
  36. package/dist/components/danger-confirm.d.ts +30 -6
  37. package/dist/components/danger-confirm.js +51 -32
  38. package/dist/components/danger-confirm.js.map +1 -1
  39. package/dist/components/data-table-filter-popover.d.ts +1 -1
  40. package/dist/components/data-table-filters.d.ts +1 -1
  41. package/dist/components/data-table-pagination.d.ts +6 -3
  42. package/dist/components/data-table-pagination.js +6 -5
  43. package/dist/components/data-table-pagination.js.map +1 -1
  44. package/dist/components/data-table.d.ts +1 -1
  45. package/dist/components/data-table.js +332 -248
  46. package/dist/components/data-table.js.map +1 -1
  47. package/dist/components/date-picker.js +17 -7
  48. package/dist/components/date-picker.js.map +1 -1
  49. package/dist/components/dialog-frame.d.ts +28 -3
  50. package/dist/components/dialog-frame.js +47 -11
  51. package/dist/components/dialog-frame.js.map +1 -1
  52. package/dist/components/disclosure.d.ts +22 -2
  53. package/dist/components/disclosure.js +41 -8
  54. package/dist/components/disclosure.js.map +1 -1
  55. package/dist/components/dropdown.d.ts +7 -3
  56. package/dist/components/dropdown.js +6 -2
  57. package/dist/components/dropdown.js.map +1 -1
  58. package/dist/components/entity-combobox.d.ts +17 -8
  59. package/dist/components/entity-combobox.js +7 -5
  60. package/dist/components/entity-combobox.js.map +1 -1
  61. package/dist/components/file-button.d.ts +36 -5
  62. package/dist/components/file-button.js +42 -3
  63. package/dist/components/file-button.js.map +1 -1
  64. package/dist/components/file-dropzone.d.ts +9 -5
  65. package/dist/components/file-dropzone.js +14 -19
  66. package/dist/components/file-dropzone.js.map +1 -1
  67. package/dist/components/grouped-picker.js +2 -2
  68. package/dist/components/grouped-picker.js.map +1 -1
  69. package/dist/components/hover-menu.d.ts +8 -3
  70. package/dist/components/hover-menu.js +88 -4
  71. package/dist/components/hover-menu.js.map +1 -1
  72. package/dist/components/icon-picker.d.ts +10 -1
  73. package/dist/components/icon-picker.js +5 -1
  74. package/dist/components/icon-picker.js.map +1 -1
  75. package/dist/components/measured-grid.d.ts +165 -0
  76. package/dist/components/measured-grid.js +559 -0
  77. package/dist/components/measured-grid.js.map +1 -0
  78. package/dist/components/mini-calendar.js +7 -8
  79. package/dist/components/mini-calendar.js.map +1 -1
  80. package/dist/components/month-picker.js +13 -9
  81. package/dist/components/month-picker.js.map +1 -1
  82. package/dist/components/multi-entity-combobox.js +2 -2
  83. package/dist/components/multi-entity-combobox.js.map +1 -1
  84. package/dist/components/multi-select.d.ts +9 -1
  85. package/dist/components/multi-select.js +22 -8
  86. package/dist/components/multi-select.js.map +1 -1
  87. package/dist/components/number-field.d.ts +3 -0
  88. package/dist/components/number-input.d.ts +3 -0
  89. package/dist/components/number-input.js +3 -3
  90. package/dist/components/number-input.js.map +1 -1
  91. package/dist/components/numpad-sheet.d.ts +3 -0
  92. package/dist/components/numpad-sheet.js +3 -3
  93. package/dist/components/numpad-sheet.js.map +1 -1
  94. package/dist/components/picker-sheet.js +1 -1
  95. package/dist/components/picker-sheet.js.map +1 -1
  96. package/dist/components/popover.js +11 -2
  97. package/dist/components/popover.js.map +1 -1
  98. package/dist/components/series-chart-ticks.d.ts +13 -0
  99. package/dist/components/series-chart-ticks.js +29 -0
  100. package/dist/components/series-chart-ticks.js.map +1 -0
  101. package/dist/components/series-chart.js +33 -27
  102. package/dist/components/series-chart.js.map +1 -1
  103. package/dist/components/swatch-picker.d.ts +10 -1
  104. package/dist/components/swatch-picker.js +2 -0
  105. package/dist/components/swatch-picker.js.map +1 -1
  106. package/dist/components/swipeable-row.d.ts +11 -2
  107. package/dist/components/swipeable-row.js +5 -3
  108. package/dist/components/swipeable-row.js.map +1 -1
  109. package/dist/components/switch.d.ts +9 -0
  110. package/dist/components/switch.js +7 -2
  111. package/dist/components/switch.js.map +1 -1
  112. package/dist/components/tile-radio.js +5 -7
  113. package/dist/components/tile-radio.js.map +1 -1
  114. package/dist/components/toggle-group.d.ts +13 -3
  115. package/dist/components/toggle-group.js +21 -1
  116. package/dist/components/toggle-group.js.map +1 -1
  117. package/dist/components/toggle-legend.js +1 -2
  118. package/dist/components/toggle-legend.js.map +1 -1
  119. package/dist/components/treemap.d.ts +6 -1
  120. package/dist/components/treemap.js +16 -6
  121. package/dist/components/treemap.js.map +1 -1
  122. package/dist/components/ui.d.ts +31 -7
  123. package/dist/components/ui.js +26 -18
  124. package/dist/components/ui.js.map +1 -1
  125. package/dist/components/use-anchor-dir.d.ts +15 -0
  126. package/dist/components/use-anchor-dir.js +14 -0
  127. package/dist/components/use-anchor-dir.js.map +1 -0
  128. package/dist/components/use-mobile-reveal.d.ts +8 -1
  129. package/dist/components/use-mobile-reveal.js +3 -2
  130. package/dist/components/use-mobile-reveal.js.map +1 -1
  131. package/dist/components/use-table-state.d.ts +4 -4
  132. package/dist/components/use-table-state.js +12 -5
  133. package/dist/components/use-table-state.js.map +1 -1
  134. package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
  135. package/dist/data-table.d.ts +1 -1
  136. package/dist/data-table.js.map +1 -1
  137. package/dist/feedback/feedback-attachment.d.ts +22 -3
  138. package/dist/feedback/feedback-attachment.js +15 -7
  139. package/dist/feedback/feedback-attachment.js.map +1 -1
  140. package/dist/feedback/feedback-dialog.d.ts +3 -2
  141. package/dist/feedback/feedback-dialog.js.map +1 -1
  142. package/dist/feedback.d.ts +1 -1
  143. package/dist/hooks/use-windowed-rows.d.ts +29 -0
  144. package/dist/hooks/use-windowed-rows.js +31 -0
  145. package/dist/hooks/use-windowed-rows.js.map +1 -0
  146. package/dist/i18n/defaults.d.ts +3 -0
  147. package/dist/i18n/defaults.js +5 -1
  148. package/dist/i18n/defaults.js.map +1 -1
  149. package/dist/i18n/kit-labels.d.ts +5 -0
  150. package/dist/i18n/kit-labels.js.map +1 -1
  151. package/dist/i18n/locales/de-CH.d.ts +43 -0
  152. package/dist/i18n/locales/de-CH.js +21 -0
  153. package/dist/i18n/locales/de-CH.js.map +1 -0
  154. package/dist/i18n/locales/de.d.ts +43 -0
  155. package/dist/i18n/locales/de.js +344 -0
  156. package/dist/i18n/locales/de.js.map +1 -0
  157. package/dist/i18n/locales/es.d.ts +43 -0
  158. package/dist/i18n/locales/es.js +339 -0
  159. package/dist/i18n/locales/es.js.map +1 -0
  160. package/dist/i18n/locales/fr.d.ts +43 -0
  161. package/dist/i18n/locales/fr.js +344 -0
  162. package/dist/i18n/locales/fr.js.map +1 -0
  163. package/dist/i18n/locales/hu.d.ts +43 -0
  164. package/dist/i18n/locales/hu.js +343 -0
  165. package/dist/i18n/locales/hu.js.map +1 -0
  166. package/dist/i18n/locales/it.d.ts +43 -0
  167. package/dist/i18n/locales/it.js +342 -0
  168. package/dist/i18n/locales/it.js.map +1 -0
  169. package/dist/i18n/locales/zh.d.ts +43 -0
  170. package/dist/i18n/locales/zh.js +338 -0
  171. package/dist/i18n/locales/zh.js.map +1 -0
  172. package/dist/index.d.ts +6 -3
  173. package/dist/index.js +4 -1
  174. package/dist/index.js.map +1 -1
  175. package/dist/lib/direction.d.ts +24 -0
  176. package/dist/lib/direction.js +19 -0
  177. package/dist/lib/direction.js.map +1 -0
  178. package/dist/rhf/form.js.map +1 -1
  179. package/dist/search/command-palette.d.ts +5 -0
  180. package/dist/search/command-palette.js +80 -50
  181. package/dist/search/command-palette.js.map +1 -1
  182. package/dist/shell/app-shell.d.ts +13 -1
  183. package/dist/shell/app-shell.js +133 -84
  184. package/dist/shell/app-shell.js.map +1 -1
  185. package/dist/shell/topbar-action-menu.js +1 -1
  186. package/dist/shell/topbar-action-menu.js.map +1 -1
  187. package/dist/shell/topbar-controls.d.ts +7 -3
  188. package/dist/shell/topbar-controls.js +38 -31
  189. package/dist/shell/topbar-controls.js.map +1 -1
  190. package/dist/theme/chart-palette.d.ts +7 -1
  191. package/dist/theme/chart-palette.js +2 -1
  192. package/dist/theme/chart-palette.js.map +1 -1
  193. package/dist/tour/tour.d.ts +11 -5
  194. package/dist/tour/tour.js +124 -58
  195. package/dist/tour/tour.js.map +1 -1
  196. package/dist/wizard/use-wizard.js +30 -13
  197. package/dist/wizard/use-wizard.js.map +1 -1
  198. package/dist/wizard/wizard-summary.js +1 -1
  199. package/dist/wizard/wizard-summary.js.map +1 -1
  200. package/package.json +12 -2
  201. package/src/components/account-settings.tsx +6 -2
  202. package/src/components/amount-input.tsx +10 -8
  203. package/src/components/autocomplete.tsx +123 -33
  204. package/src/components/calculator.tsx +5 -1
  205. package/src/components/chart-zoom.tsx +8 -1
  206. package/src/components/chart.tsx +68 -7
  207. package/src/components/checkbox.tsx +16 -0
  208. package/src/components/chip.tsx +131 -49
  209. package/src/components/choice-card.tsx +77 -36
  210. package/src/components/combobox-core.tsx +147 -20
  211. package/src/components/combobox.tsx +100 -44
  212. package/src/components/currency-select.tsx +6 -6
  213. package/src/components/danger-confirm.tsx +90 -36
  214. package/src/components/data-table-pagination.tsx +32 -19
  215. package/src/components/data-table.tsx +198 -36
  216. package/src/components/date-picker.tsx +24 -7
  217. package/src/components/dialog-frame.tsx +97 -22
  218. package/src/components/disclosure.tsx +75 -13
  219. package/src/components/dropdown.tsx +17 -5
  220. package/src/components/entity-combobox.tsx +26 -13
  221. package/src/components/file-button.tsx +102 -6
  222. package/src/components/file-dropzone.tsx +44 -35
  223. package/src/components/grouped-picker.tsx +2 -2
  224. package/src/components/hover-menu.tsx +139 -10
  225. package/src/components/icon-picker.tsx +14 -1
  226. package/src/components/measured-grid.tsx +852 -0
  227. package/src/components/mini-calendar.tsx +11 -8
  228. package/src/components/month-picker.tsx +18 -11
  229. package/src/components/multi-entity-combobox.tsx +2 -2
  230. package/src/components/multi-select.tsx +34 -7
  231. package/src/components/number-input.tsx +4 -4
  232. package/src/components/numpad-sheet.tsx +5 -3
  233. package/src/components/picker-sheet.tsx +1 -1
  234. package/src/components/popover.tsx +18 -3
  235. package/src/components/series-chart-ticks.ts +52 -0
  236. package/src/components/series-chart.tsx +39 -31
  237. package/src/components/swatch-picker.tsx +11 -0
  238. package/src/components/swipeable-row.tsx +21 -6
  239. package/src/components/switch.tsx +16 -0
  240. package/src/components/tile-radio.tsx +5 -7
  241. package/src/components/toggle-group.tsx +39 -3
  242. package/src/components/toggle-legend.tsx +5 -2
  243. package/src/components/treemap.tsx +28 -5
  244. package/src/components/ui.tsx +74 -30
  245. package/src/components/use-anchor-dir.ts +20 -0
  246. package/src/components/use-mobile-reveal.ts +10 -2
  247. package/src/components/use-table-state.ts +44 -7
  248. package/src/data-table.ts +1 -0
  249. package/src/feedback/feedback-attachment.tsx +36 -9
  250. package/src/feedback/feedback-dialog.tsx +3 -2
  251. package/src/hooks/use-windowed-rows.ts +69 -0
  252. package/src/i18n/defaults.ts +4 -0
  253. package/src/i18n/kit-labels.tsx +4 -0
  254. package/src/i18n/locales/de-CH.ts +30 -0
  255. package/src/i18n/locales/de.ts +365 -0
  256. package/src/i18n/locales/es.ts +358 -0
  257. package/src/i18n/locales/fr.ts +371 -0
  258. package/src/i18n/locales/hu.ts +359 -0
  259. package/src/i18n/locales/it.ts +360 -0
  260. package/src/i18n/locales/zh.ts +348 -0
  261. package/src/index.ts +10 -1
  262. package/src/lib/direction.ts +37 -0
  263. package/src/rhf/form.tsx +21 -0
  264. package/src/search/command-palette.tsx +77 -19
  265. package/src/shell/app-shell.tsx +200 -129
  266. package/src/shell/topbar-action-menu.tsx +1 -1
  267. package/src/shell/topbar-controls.tsx +19 -8
  268. package/src/theme/chart-palette.ts +9 -2
  269. package/src/tour/tour.tsx +119 -18
  270. package/src/wizard/use-wizard.ts +55 -17
  271. package/src/wizard/wizard-summary.tsx +1 -1
package/src/tour/tour.tsx CHANGED
@@ -13,8 +13,15 @@ import { cn } from "../lib/cn";
13
13
  import { useKitLabels } from "../i18n/kit-labels";
14
14
  import { Button } from "../components/ui";
15
15
  import { useFocusTrap } from "../hooks/use-focus-trap";
16
+ import { dirOf, horizontalStep } from "../lib/direction";
17
+ import type { Direction } from "../lib/direction";
16
18
 
17
- export type TourPlacement = "top" | "right" | "bottom" | "left" | "center";
19
+ /**
20
+ * `start`/`end` follow the reading direction (`end` is the right side in LTR and the
21
+ * left side in RTL) and are what a translated app wants. `left`/`right` stay physical
22
+ * for the steps that genuinely mean a screen side.
23
+ */
24
+ export type TourPlacement = "top" | "right" | "bottom" | "left" | "start" | "end" | "center";
18
25
 
19
26
  export interface TourStep {
20
27
  /** CSS selector for the element to spotlight. Omit for a centered step
@@ -28,13 +35,14 @@ export interface TourStep {
28
35
  padding?: number;
29
36
  /** Where the card sits relative to the target. Omit for auto placement
30
37
  * (below → above → centered). "center" ignores the target and centers on
31
- * screen. An explicit side flips to the opposite side if it would overflow,
32
- * then clamps into the viewport. */
38
+ * screen. "start"/"end" are the logical sides and flip in RTL; "left"/"right"
39
+ * are physical. An explicit side flips to the opposite side if it would
40
+ * overflow, then clamps into the viewport. */
33
41
  placement?: TourPlacement;
34
42
  /** Advance only when the user clicks the spotlighted target itself (not the
35
43
  * card's Next button). Requires `target`: the real control's own handler runs
36
- * first, then the tour steps forward on a microtask. The overlay stays
37
- * click-through to the target. */
44
+ * first, then the tour steps forward on a microtask. The spotlight hole stays
45
+ * click-through to the target; the rest of the page does not. */
38
46
  awaitClick?: boolean;
39
47
  /** Runs before the step is shown — e.g. navigate to a route, open a panel.
40
48
  * May be async; the step waits for it, then locates the target. */
@@ -269,6 +277,11 @@ function TourOverlay({
269
277
  const isLast = index === steps.length - 1;
270
278
  const [rect, setRect] = useState<Rect | null>(null);
271
279
  const [ready, setReady] = useState(false);
280
+ // The overlay is portalled to <body>, out of whatever `dir` subtree the app set, so it
281
+ // takes the direction of the element it spotlights (the document's for a centered
282
+ // step). Arrow keys and start/end placement read it.
283
+ const [dir, setDir] = useState<Direction>(() => dirOf(null));
284
+ const rootRef = useRef<HTMLDivElement>(null);
272
285
 
273
286
  // Run beforeStep, then locate the target (retrying across route/render lag).
274
287
  useEffect(() => {
@@ -280,6 +293,7 @@ function TourOverlay({
280
293
  await step.beforeStep?.();
281
294
  if (cancelled) return;
282
295
  if (!step.target) {
296
+ setDir(dirOf(null));
283
297
  setReady(true);
284
298
  return;
285
299
  }
@@ -291,11 +305,15 @@ function TourOverlay({
291
305
  el.scrollIntoView({ block: "center", behavior: "smooth" });
292
306
  const r = el.getBoundingClientRect();
293
307
  setRect({ top: r.top, left: r.left, width: r.width, height: r.height });
308
+ setDir(dirOf(el));
294
309
  setReady(true);
295
310
  return;
296
311
  }
297
312
  if (tries++ < 45) raf = requestAnimationFrame(find);
298
- else setReady(true); // give up → render centered
313
+ else {
314
+ setDir(dirOf(null));
315
+ setReady(true); // give up → render centered
316
+ }
299
317
  };
300
318
  find();
301
319
  })();
@@ -326,8 +344,8 @@ function TourOverlay({
326
344
  };
327
345
  }, [index, step]);
328
346
 
329
- // awaitClick: advance when the *real* target is clicked. The overlay is
330
- // click-through (pointer-events-none except the card) so the element's own
347
+ // awaitClick: advance when the *real* target is clicked. The spotlight hole is
348
+ // click-through (see the shields in the render below) so the element's own
331
349
  // handler runs first; we step forward on the next macrotask.
332
350
  useEffect(() => {
333
351
  if (!ready || !step.awaitClick || !step.target) return;
@@ -338,21 +356,34 @@ function TourOverlay({
338
356
  return () => el.removeEventListener("click", onClick);
339
357
  }, [ready, index, step, onNext]);
340
358
 
341
- // Keyboard: Esc skips, →/Enter advance, ← goes back. On awaitClick steps Enter
342
- // is ignored (it would fire the target's click AND advance) — → stays as an
343
- // escape hatch.
359
+ // Keyboard: Esc skips, Enter and the reading-direction arrow advance (→ in LTR,
360
+ // ← in RTL), the other arrow goes back. On awaitClick steps Enter is ignored (it
361
+ // would fire the target's click AND advance) — the forward arrow stays as an escape
362
+ // hatch.
363
+ //
364
+ // Enter on a focused control belongs to that control. This used to advance from
365
+ // anywhere, so Enter on a focused Skip or Back ran the button's own click AND stepped
366
+ // forward: Back moved nowhere, Skip skipped and then called `next` on a dead tour.
367
+ // The same goes for arrows in a text field a step's `action` might contain.
344
368
  useEffect(() => {
345
369
  const onKey = (e: KeyboardEvent) => {
346
370
  if (e.key === "Escape") {
347
371
  onSkip();
348
- } else if (e.key === "ArrowRight") {
372
+ return;
373
+ }
374
+ if (e.key === "Enter") {
375
+ if (step.awaitClick || ownsKey(e.target, "Enter")) return;
349
376
  e.preventDefault();
350
377
  onNext();
351
- } else if (e.key === "Enter") {
352
- if (step.awaitClick) return;
378
+ return;
379
+ }
380
+ if (ownsKey(e.target, e.key)) return;
381
+ // The overlay root carries `dir`; before it paints, the document's answers.
382
+ const delta = horizontalStep(e.key, rootRef.current);
383
+ if (delta === 1) {
353
384
  e.preventDefault();
354
385
  onNext();
355
- } else if (e.key === "ArrowLeft") {
386
+ } else if (delta === -1) {
356
387
  if (!isFirst) onPrev();
357
388
  }
358
389
  };
@@ -367,8 +398,24 @@ function TourOverlay({
367
398
  ? { top: rect.top - pad, left: rect.left - pad, width: rect.width + pad * 2, height: rect.height + pad * 2 }
368
399
  : null;
369
400
 
401
+ // `aria-modal` promises the page behind is out of reach, and the card's focus trap
402
+ // keeps that promise for the keyboard. The pointer used to get no such treatment: the
403
+ // root is `pointer-events-none` so that the spotlight hole can stay live for
404
+ // `awaitClick`, and the scrim is a box-shadow, which takes no clicks — so every
405
+ // control on the dimmed page stayed clickable. Four shields now tile the viewport
406
+ // around the hole: the page is blocked, the card and the spotlighted target are not.
407
+ // Keeping `aria-modal` (rather than dropping it) is the coherent half: the tour
408
+ // really is modal — focus is trapped and Esc/Skip is the way out.
370
409
  return createPortal(
371
- <div className="pointer-events-none fixed inset-0 z-[60]" role="dialog" aria-modal="true" aria-label={step.title}>
410
+ <div
411
+ ref={rootRef}
412
+ className="pointer-events-none fixed inset-0 z-[60]"
413
+ role="dialog"
414
+ aria-modal="true"
415
+ aria-label={step.title}
416
+ dir={dir}
417
+ >
418
+ {spot && <SpotlightShields spot={spot} />}
372
419
  {spot ? (
373
420
  <div
374
421
  className="pointer-events-none fixed rounded-lg transition-all duration-200 ease-out"
@@ -410,6 +457,7 @@ function TourOverlay({
410
457
  isFirst={isFirst}
411
458
  isLast={isLast}
412
459
  rect={spot}
460
+ dir={dir}
413
461
  onBack={onPrev}
414
462
  onNext={onNext}
415
463
  onSkip={onSkip}
@@ -419,6 +467,53 @@ function TourOverlay({
419
467
  );
420
468
  }
421
469
 
470
+ /** Does the focused element handle `key` itself? Enter on a button or link, and any
471
+ * editing key in a text field or select, is the control's — not the tour's. */
472
+ function ownsKey(target: EventTarget | null, key: string): boolean {
473
+ if (!(target instanceof HTMLElement)) return false;
474
+ if (target.isContentEditable) return true;
475
+ const tag = target.tagName;
476
+ if (tag === "TEXTAREA" || tag === "SELECT") return true;
477
+ if (tag === "INPUT") {
478
+ const type = (target as HTMLInputElement).type;
479
+ // Enter on a checkbox does nothing natively, but ←/→ on a radio moves it.
480
+ return key !== "Enter" || type !== "checkbox";
481
+ }
482
+ if (key !== "Enter") return false;
483
+ return (
484
+ tag === "BUTTON" ||
485
+ (tag === "A" && target.hasAttribute("href")) ||
486
+ target.getAttribute("role") === "button" ||
487
+ target.getAttribute("role") === "link"
488
+ );
489
+ }
490
+
491
+ /**
492
+ * Transparent pointer blockers tiling the viewport around the spotlight hole: one
493
+ * full-width band above and below, one on each side at the hole's own height. The hole
494
+ * itself stays uncovered, so the target keeps its clicks.
495
+ */
496
+ function SpotlightShields({ spot }: { spot: Rect }) {
497
+ const shield = "pointer-events-auto fixed";
498
+ const bottom = spot.top + spot.height;
499
+ return (
500
+ <>
501
+ <div data-tour-shield="" className={shield} style={{ top: 0, left: 0, right: 0, height: Math.max(0, spot.top) }} />
502
+ <div data-tour-shield="" className={shield} style={{ top: bottom, left: 0, right: 0, bottom: 0 }} />
503
+ <div
504
+ data-tour-shield=""
505
+ className={shield}
506
+ style={{ top: spot.top, left: 0, width: Math.max(0, spot.left), height: spot.height }}
507
+ />
508
+ <div
509
+ data-tour-shield=""
510
+ className={shield}
511
+ style={{ top: spot.top, left: spot.left + spot.width, right: 0, height: spot.height }}
512
+ />
513
+ </>
514
+ );
515
+ }
516
+
422
517
  /**
423
518
  * Card position (viewport px) for the given placement relative to the padded
424
519
  * spotlight `rect`. Omitted placement keeps the original auto behavior
@@ -434,7 +529,11 @@ function placeCard(
434
529
  vh: number,
435
530
  m: number,
436
531
  placement?: TourPlacement,
532
+ dir: Direction = "ltr",
437
533
  ): { top: number; left: number } {
534
+ // The logical sides resolve to a physical one here, and only here.
535
+ if (placement === "start") placement = dir === "rtl" ? "right" : "left";
536
+ else if (placement === "end") placement = dir === "rtl" ? "left" : "right";
438
537
  if (!rect || placement === "center") {
439
538
  return { top: Math.max(m, (vh - ch) / 2), left: Math.max(m, (vw - cw) / 2) };
440
539
  }
@@ -475,6 +574,7 @@ function TourCard({
475
574
  isFirst,
476
575
  isLast,
477
576
  rect,
577
+ dir,
478
578
  onBack,
479
579
  onNext,
480
580
  onSkip,
@@ -486,6 +586,7 @@ function TourCard({
486
586
  isFirst: boolean;
487
587
  isLast: boolean;
488
588
  rect: Rect | null;
589
+ dir: Direction;
489
590
  onBack: () => void;
490
591
  onNext: () => void;
491
592
  onSkip: () => void;
@@ -524,9 +625,9 @@ function TourCard({
524
625
  const card = ref.current;
525
626
  if (!card) return;
526
627
  setStyle(
527
- placeCard(rect, card.offsetWidth, card.offsetHeight, window.innerWidth, window.innerHeight, 12, step.placement),
628
+ placeCard(rect, card.offsetWidth, card.offsetHeight, window.innerWidth, window.innerHeight, 12, step.placement, dir),
528
629
  );
529
- }, [rect, index, step.placement]);
630
+ }, [rect, index, step.placement, dir]);
530
631
 
531
632
  return (
532
633
  <div
@@ -33,10 +33,13 @@ interface WizardState<TData> {
33
33
  }
34
34
 
35
35
  type WizardAction<TData> =
36
- | { type: "GO_NEXT" }
36
+ // `from` is the step the move was decided ON. A move whose step is no longer the
37
+ // current one is stale — see `goNext` — and is dropped rather than applied relative
38
+ // to wherever the wizard has got to since.
39
+ | { type: "GO_NEXT"; from: number }
37
40
  | { type: "GO_BACK" }
38
41
  | { type: "GO_TO_STEP"; index: number }
39
- | { type: "SKIP" }
42
+ | { type: "SKIP"; from: number }
40
43
  | { type: "UPDATE_DATA"; partial: Partial<TData> }
41
44
  | { type: "SET_SUBMITTING"; value: boolean }
42
45
  | { type: "SET_ERROR"; error: string | null }
@@ -50,6 +53,7 @@ function createReducer<TData>(stepCount: number) {
50
53
  ): WizardState<TData> {
51
54
  switch (action.type) {
52
55
  case "GO_NEXT": {
56
+ if (action.from !== state.currentStepIndex) return state;
53
57
  const completed = new Set(state.completedSteps);
54
58
  completed.add(state.currentStepIndex);
55
59
  const nextIndex = Math.min(state.currentStepIndex + 1, stepCount - 1);
@@ -67,6 +71,7 @@ function createReducer<TData>(stepCount: number) {
67
71
  return { ...state, currentStepIndex: action.index };
68
72
  }
69
73
  case "SKIP": {
74
+ if (action.from !== state.currentStepIndex) return state;
70
75
  const skipped = new Set(state.skippedSteps);
71
76
  skipped.add(state.currentStepIndex);
72
77
  const nextIndex = Math.min(state.currentStepIndex + 1, stepCount - 1);
@@ -243,11 +248,32 @@ export function useWizard<TData extends Record<string, unknown>>(
243
248
  return true;
244
249
  }, [state.currentStepIndex, missingRequired, onValidationFailed]);
245
250
 
251
+ /**
252
+ * One forward move at a time. A double-click on Next used to validate the CURRENT
253
+ * step twice and then advance twice — the second GO_NEXT landing on a step whose
254
+ * own validators never ran, so a required field two steps in could be walked past.
255
+ * Two layers, because either alone leaves a gap:
256
+ *
257
+ * - this ref refuses a second `goNext`/`skip`/`finish` while one is in flight,
258
+ * which covers an async validator (a server check) of any length;
259
+ * - the `from` on each action drops a move decided on a step that is no longer
260
+ * current, which covers the click that arrives after the first move resolved
261
+ * but before React re-rendered — its closure still holds the old step index.
262
+ */
263
+ const advancingRef = useRef(false);
264
+
246
265
  const goNext = useCallback(async () => {
247
- if (await runStepValidators()) {
248
- dispatch({ type: "GO_NEXT" });
266
+ if (advancingRef.current) return;
267
+ advancingRef.current = true;
268
+ const from = state.currentStepIndex;
269
+ try {
270
+ if (await runStepValidators()) {
271
+ dispatch({ type: "GO_NEXT", from });
272
+ }
273
+ } finally {
274
+ advancingRef.current = false;
249
275
  }
250
- }, [runStepValidators]);
276
+ }, [runStepValidators, state.currentStepIndex]);
251
277
 
252
278
  const goBack = useCallback(() => {
253
279
  dispatch({ type: "GO_BACK" });
@@ -258,8 +284,11 @@ export function useWizard<TData extends Record<string, unknown>>(
258
284
  }, []);
259
285
 
260
286
  const skip = useCallback(() => {
261
- dispatch({ type: "SKIP" });
262
- }, []);
287
+ // Not while Next is validating: the skip would land first and the pending GO_NEXT
288
+ // would then be stale anyway — but the user asked for one move, not a race.
289
+ if (advancingRef.current) return;
290
+ dispatch({ type: "SKIP", from: state.currentStepIndex });
291
+ }, [state.currentStepIndex]);
263
292
 
264
293
  const cancel = useCallback(() => {
265
294
  dispatch({ type: "SHOW_CANCEL_DIALOG", show: true });
@@ -288,18 +317,27 @@ export function useWizard<TData extends Record<string, unknown>>(
288
317
 
289
318
  const finish = useCallback(async () => {
290
319
  if (!onComplete || !canFinish) return;
291
- // Validate the (possibly form-bearing) last step before submitting, matching
292
- // the Next gate — review-only steps register no validators and pass through.
293
- if (!(await runStepValidators())) return;
294
- dispatch({ type: "SET_SUBMITTING", value: true });
295
- dispatch({ type: "SET_ERROR", error: null });
320
+ // The same re-entry guard as `goNext`: a double-click on Finish would otherwise
321
+ // validate twice and call `onComplete` twice — `isSubmitting` is only set once
322
+ // validation has passed, too late to stop the second click.
323
+ if (advancingRef.current) return;
324
+ advancingRef.current = true;
296
325
  try {
297
- await onComplete(state.data);
298
- } catch (err) {
299
- const message = err instanceof Error && err.message ? err.message : genericError;
300
- dispatch({ type: "SET_ERROR", error: message });
326
+ // Validate the (possibly form-bearing) last step before submitting, matching
327
+ // the Next gate — review-only steps register no validators and pass through.
328
+ if (!(await runStepValidators())) return;
329
+ dispatch({ type: "SET_SUBMITTING", value: true });
330
+ dispatch({ type: "SET_ERROR", error: null });
331
+ try {
332
+ await onComplete(state.data);
333
+ } catch (err) {
334
+ const message = err instanceof Error && err.message ? err.message : genericError;
335
+ dispatch({ type: "SET_ERROR", error: message });
336
+ } finally {
337
+ dispatch({ type: "SET_SUBMITTING", value: false });
338
+ }
301
339
  } finally {
302
- dispatch({ type: "SET_SUBMITTING", value: false });
340
+ advancingRef.current = false;
303
341
  }
304
342
  }, [onComplete, canFinish, state.data, runStepValidators, genericError]);
305
343
 
@@ -54,7 +54,7 @@ export function WizardSummary({
54
54
  )}
55
55
  <div className="flex items-start justify-between gap-4">
56
56
  <dt className="text-[var(--text-muted)]">{item.label}</dt>
57
- <dd className="text-right font-medium text-[var(--text-primary)]">
57
+ <dd className="text-end font-medium text-[var(--text-primary)]">
58
58
  {item.value}
59
59
  </dd>
60
60
  </div>