@eifi1/ui-kit 0.21.0 → 0.23.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 (320) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +19 -14
  3. package/dist/components/account-chips.d.ts +10 -3
  4. package/dist/components/account-chips.js +5 -2
  5. package/dist/components/account-chips.js.map +1 -1
  6. package/dist/components/account-settings.d.ts +33 -2
  7. package/dist/components/account-settings.js +43 -21
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +53 -14
  10. package/dist/components/amount-input.js +17 -28
  11. package/dist/components/amount-input.js.map +1 -1
  12. package/dist/components/autocomplete.d.ts +29 -0
  13. package/dist/components/autocomplete.js +134 -104
  14. package/dist/components/autocomplete.js.map +1 -1
  15. package/dist/components/button-group.d.ts +19 -14
  16. package/dist/components/calculator.d.ts +19 -14
  17. package/dist/components/checkbox-group.d.ts +104 -0
  18. package/dist/components/checkbox-group.js +111 -0
  19. package/dist/components/checkbox-group.js.map +1 -0
  20. package/dist/components/checkbox.d.ts +19 -0
  21. package/dist/components/checkbox.js +30 -7
  22. package/dist/components/checkbox.js.map +1 -1
  23. package/dist/components/chip.d.ts +18 -0
  24. package/dist/components/chip.js +47 -10
  25. package/dist/components/chip.js.map +1 -1
  26. package/dist/components/column-mapper.d.ts +182 -0
  27. package/dist/components/column-mapper.js +375 -0
  28. package/dist/components/column-mapper.js.map +1 -0
  29. package/dist/components/combobox-core.d.ts +39 -2
  30. package/dist/components/combobox-core.js +29 -6
  31. package/dist/components/combobox-core.js.map +1 -1
  32. package/dist/components/combobox.d.ts +91 -2
  33. package/dist/components/combobox.js +454 -308
  34. package/dist/components/combobox.js.map +1 -1
  35. package/dist/components/confirm-dialog.d.ts +69 -161
  36. package/dist/components/confirm-dialog.js +7 -8
  37. package/dist/components/confirm-dialog.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +16 -11
  39. package/dist/components/country-select.d.ts +167 -0
  40. package/dist/components/country-select.js +293 -0
  41. package/dist/components/country-select.js.map +1 -0
  42. package/dist/components/currency-select.d.ts +17 -1
  43. package/dist/components/currency-select.js +56 -23
  44. package/dist/components/currency-select.js.map +1 -1
  45. package/dist/components/danger-confirm.d.ts +69 -166
  46. package/dist/components/danger-confirm.js +144 -50
  47. package/dist/components/danger-confirm.js.map +1 -1
  48. package/dist/components/data-table-cells.d.ts +1 -2
  49. package/dist/components/data-table-filter-popover.d.ts +1 -2
  50. package/dist/components/data-table-filters.d.ts +1 -2
  51. package/dist/components/data-table-labels.d.ts +4 -97
  52. package/dist/components/data-table-labels.js +9 -1
  53. package/dist/components/data-table-labels.js.map +1 -1
  54. package/dist/components/data-table-pagination.d.ts +3 -1
  55. package/dist/components/data-table.d.ts +1 -2
  56. package/dist/components/data-table.js +98 -3
  57. package/dist/components/data-table.js.map +1 -1
  58. package/dist/components/date-picker.d.ts +66 -5
  59. package/dist/components/date-picker.js +378 -66
  60. package/dist/components/date-picker.js.map +1 -1
  61. package/dist/components/entity-combobox.d.ts +42 -1
  62. package/dist/components/entity-combobox.js +154 -113
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/facing-pair.d.ts +19 -14
  65. package/dist/components/field-parts.d.ts +224 -0
  66. package/dist/components/field-parts.js +156 -0
  67. package/dist/components/field-parts.js.map +1 -0
  68. package/dist/components/field-strip.d.ts +105 -0
  69. package/dist/components/field-strip.js +46 -0
  70. package/dist/components/field-strip.js.map +1 -0
  71. package/dist/components/file-button.d.ts +19 -14
  72. package/dist/components/file-button.js +13 -3
  73. package/dist/components/file-button.js.map +1 -1
  74. package/dist/components/file-dropzone.d.ts +41 -15
  75. package/dist/components/file-dropzone.js +57 -34
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/form-actions.d.ts +18 -13
  78. package/dist/components/form-actions.js +69 -5
  79. package/dist/components/form-actions.js.map +1 -1
  80. package/dist/components/iban-input.d.ts +69 -0
  81. package/dist/components/iban-input.js +125 -0
  82. package/dist/components/iban-input.js.map +1 -0
  83. package/dist/components/icon-picker.d.ts +21 -2
  84. package/dist/components/icon-picker.js +19 -4
  85. package/dist/components/icon-picker.js.map +1 -1
  86. package/dist/components/inline-edit-field.d.ts +167 -0
  87. package/dist/components/inline-edit-field.js +293 -0
  88. package/dist/components/inline-edit-field.js.map +1 -0
  89. package/dist/components/language-select.d.ts +130 -0
  90. package/dist/components/language-select.js +18 -0
  91. package/dist/components/language-select.js.map +1 -0
  92. package/dist/components/line-items.d.ts +25 -3
  93. package/dist/components/line-items.js +55 -46
  94. package/dist/components/line-items.js.map +1 -1
  95. package/dist/components/list.d.ts +25 -0
  96. package/dist/components/list.js +16 -5
  97. package/dist/components/list.js.map +1 -1
  98. package/dist/components/money-field.d.ts +32 -14
  99. package/dist/components/money-field.js.map +1 -1
  100. package/dist/components/month-picker.d.ts +52 -4
  101. package/dist/components/month-picker.js +291 -86
  102. package/dist/components/month-picker.js.map +1 -1
  103. package/dist/components/multi-entity-combobox.d.ts +26 -1
  104. package/dist/components/multi-entity-combobox.js +143 -102
  105. package/dist/components/multi-entity-combobox.js.map +1 -1
  106. package/dist/components/multi-select.d.ts +8 -1
  107. package/dist/components/multi-select.js +9 -4
  108. package/dist/components/multi-select.js.map +1 -1
  109. package/dist/components/number-field.d.ts +34 -19
  110. package/dist/components/number-field.js +6 -20
  111. package/dist/components/number-field.js.map +1 -1
  112. package/dist/components/number-input.d.ts +79 -19
  113. package/dist/components/number-input.js +90 -38
  114. package/dist/components/number-input.js.map +1 -1
  115. package/dist/components/numpad-sheet.d.ts +19 -14
  116. package/dist/components/one-time-code-input.d.ts +100 -0
  117. package/dist/components/one-time-code-input.js +137 -0
  118. package/dist/components/one-time-code-input.js.map +1 -0
  119. package/dist/components/phone-input.d.ts +69 -0
  120. package/dist/components/phone-input.js +171 -0
  121. package/dist/components/phone-input.js.map +1 -0
  122. package/dist/components/reauth-dialog.d.ts +2 -2
  123. package/dist/components/reauth-dialog.js +6 -8
  124. package/dist/components/reauth-dialog.js.map +1 -1
  125. package/dist/components/series-chart.d.ts +19 -14
  126. package/dist/components/settings-fields.d.ts +19 -14
  127. package/dist/components/share-card.d.ts +16 -11
  128. package/dist/components/sign-chip.d.ts +66 -0
  129. package/dist/components/sign-chip.js +39 -0
  130. package/dist/components/sign-chip.js.map +1 -0
  131. package/dist/components/swatch-picker.d.ts +43 -2
  132. package/dist/components/swatch-picker.js +80 -32
  133. package/dist/components/swatch-picker.js.map +1 -1
  134. package/dist/components/switch.d.ts +19 -0
  135. package/dist/components/switch.js +24 -6
  136. package/dist/components/switch.js.map +1 -1
  137. package/dist/components/text-link.d.ts +18 -13
  138. package/dist/components/tile-radio.d.ts +88 -13
  139. package/dist/components/tile-radio.js +28 -11
  140. package/dist/components/tile-radio.js.map +1 -1
  141. package/dist/components/time-input.d.ts +24 -15
  142. package/dist/components/time-input.js.map +1 -1
  143. package/dist/components/toggle-group.d.ts +47 -1
  144. package/dist/components/toggle-group.js +110 -69
  145. package/dist/components/toggle-group.js.map +1 -1
  146. package/dist/components/trigger-aria.d.ts +3 -0
  147. package/dist/components/trigger-aria.js +3 -1
  148. package/dist/components/trigger-aria.js.map +1 -1
  149. package/dist/components/ui.d.ts +16 -11
  150. package/dist/components/ui.js +284 -88
  151. package/dist/components/ui.js.map +1 -1
  152. package/dist/components/use-table-state.d.ts +1 -2
  153. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  154. package/dist/data-table.d.ts +1 -2
  155. package/dist/data-table.js +2 -1
  156. package/dist/data-table.js.map +1 -1
  157. package/dist/feedback/feedback-attachment.d.ts +1 -1
  158. package/dist/feedback/feedback-attachment.js +348 -41
  159. package/dist/feedback/feedback-attachment.js.map +1 -1
  160. package/dist/feedback/feedback-dialog.d.ts +1 -1
  161. package/dist/feedback/feedback-dialog.js.map +1 -1
  162. package/dist/feedback/feedback-inbox.d.ts +3 -2
  163. package/dist/feedback/feedback-inbox.js.map +1 -1
  164. package/dist/feedback/feedback-thread.d.ts +68 -253
  165. package/dist/feedback/feedback-thread.js +46 -10
  166. package/dist/feedback/feedback-thread.js.map +1 -1
  167. package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
  168. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
  169. package/dist/feedback.d.ts +66 -2
  170. package/dist/hooks/use-file-drop.d.ts +19 -14
  171. package/dist/i18n/defaults.d.ts +19 -14
  172. package/dist/i18n/defaults.js +19 -2
  173. package/dist/i18n/defaults.js.map +1 -1
  174. package/dist/i18n/german.d.ts +19 -14
  175. package/dist/i18n/german.js +84 -5
  176. package/dist/i18n/german.js.map +1 -1
  177. package/dist/i18n/kit-labels.d.ts +15 -10
  178. package/dist/i18n/kit-labels.js +8 -2
  179. package/dist/i18n/kit-labels.js.map +1 -1
  180. package/dist/i18n/languages.d.ts +19 -14
  181. package/dist/i18n/locales/de-CH.d.ts +19 -14
  182. package/dist/i18n/locales/en.d.ts +19 -14
  183. package/dist/i18n/locales/en.js +16 -0
  184. package/dist/i18n/locales/en.js.map +1 -1
  185. package/dist/i18n/locales/es.d.ts +19 -14
  186. package/dist/i18n/locales/es.js +83 -5
  187. package/dist/i18n/locales/es.js.map +1 -1
  188. package/dist/i18n/locales/fr.d.ts +19 -14
  189. package/dist/i18n/locales/fr.js +82 -5
  190. package/dist/i18n/locales/fr.js.map +1 -1
  191. package/dist/i18n/locales/hu.d.ts +19 -14
  192. package/dist/i18n/locales/hu.js +85 -5
  193. package/dist/i18n/locales/hu.js.map +1 -1
  194. package/dist/i18n/locales/it.d.ts +19 -14
  195. package/dist/i18n/locales/it.js +84 -5
  196. package/dist/i18n/locales/it.js.map +1 -1
  197. package/dist/i18n/locales/zh.d.ts +19 -14
  198. package/dist/i18n/locales/zh.js +80 -5
  199. package/dist/i18n/locales/zh.js.map +1 -1
  200. package/dist/i18n/review.d.ts +19 -14
  201. package/dist/i18n/review.js +27 -1
  202. package/dist/i18n/review.js.map +1 -1
  203. package/dist/index.d.ts +21 -10
  204. package/dist/index.js +73 -2
  205. package/dist/index.js.map +1 -1
  206. package/dist/lib/column-mapping.d.ts +81 -0
  207. package/dist/lib/column-mapping.js +108 -0
  208. package/dist/lib/column-mapping.js.map +1 -0
  209. package/dist/lib/countries.d.ts +43 -0
  210. package/dist/lib/countries.js +39 -0
  211. package/dist/lib/countries.js.map +1 -0
  212. package/dist/lib/decimal-marks.d.ts +46 -0
  213. package/dist/lib/decimal-marks.js +40 -0
  214. package/dist/lib/decimal-marks.js.map +1 -0
  215. package/dist/lib/iban.d.ts +98 -0
  216. package/dist/lib/iban.js +154 -0
  217. package/dist/lib/iban.js.map +1 -0
  218. package/dist/lib/isin.d.ts +30 -0
  219. package/dist/lib/isin.js +36 -0
  220. package/dist/lib/isin.js.map +1 -0
  221. package/dist/lib/phone.d.ts +69 -0
  222. package/dist/lib/phone.js +121 -0
  223. package/dist/lib/phone.js.map +1 -0
  224. package/dist/lib/table-text.d.ts +109 -1
  225. package/dist/lib/table-text.js +122 -1
  226. package/dist/lib/table-text.js.map +1 -1
  227. package/dist/rhf/fields.d.ts +233 -18
  228. package/dist/rhf/fields.js +456 -3
  229. package/dist/rhf/fields.js.map +1 -1
  230. package/dist/rhf/form.d.ts +19 -14
  231. package/dist/rhf.d.ts +22 -15
  232. package/dist/rhf.js.map +1 -1
  233. package/dist/shell/app-shell.d.ts +18 -13
  234. package/dist/shell/top-bar-brand.d.ts +19 -14
  235. package/dist/shell/topbar-action-menu.d.ts +36 -3
  236. package/dist/shell/topbar-action-menu.js +74 -33
  237. package/dist/shell/topbar-action-menu.js.map +1 -1
  238. package/dist/shell.d.ts +18 -13
  239. package/dist/table-text.d.ts +1 -1
  240. package/dist/tour/tour.js +20 -24
  241. package/dist/tour/tour.js.map +1 -1
  242. package/dist/wizard/stepper-nav.d.ts +49 -16
  243. package/dist/wizard/stepper-nav.js +4 -0
  244. package/dist/wizard/stepper-nav.js.map +1 -1
  245. package/dist/wizard.d.ts +19 -14
  246. package/package.json +1 -1
  247. package/src/components/account-chips.tsx +17 -3
  248. package/src/components/account-settings.tsx +92 -26
  249. package/src/components/amount-input.tsx +68 -56
  250. package/src/components/autocomplete.tsx +198 -114
  251. package/src/components/checkbox-group.tsx +229 -0
  252. package/src/components/checkbox.tsx +72 -8
  253. package/src/components/chip.tsx +80 -10
  254. package/src/components/column-mapper.tsx +666 -0
  255. package/src/components/combobox-core.tsx +82 -9
  256. package/src/components/combobox.tsx +661 -340
  257. package/src/components/confirm-dialog.tsx +12 -8
  258. package/src/components/country-select.tsx +556 -0
  259. package/src/components/currency-select.tsx +109 -33
  260. package/src/components/danger-confirm.tsx +429 -82
  261. package/src/components/data-table-labels.ts +16 -1
  262. package/src/components/data-table.tsx +175 -6
  263. package/src/components/date-picker.tsx +595 -71
  264. package/src/components/entity-combobox.tsx +235 -124
  265. package/src/components/field-parts.tsx +403 -0
  266. package/src/components/field-strip.tsx +149 -0
  267. package/src/components/file-button.tsx +36 -3
  268. package/src/components/file-dropzone.tsx +106 -38
  269. package/src/components/form-actions.tsx +147 -6
  270. package/src/components/iban-input.tsx +231 -0
  271. package/src/components/icon-picker.tsx +47 -6
  272. package/src/components/inline-edit-field.tsx +533 -0
  273. package/src/components/language-select.tsx +78 -0
  274. package/src/components/line-items.tsx +35 -2
  275. package/src/components/list.tsx +55 -6
  276. package/src/components/money-field.tsx +13 -0
  277. package/src/components/month-picker.tsx +400 -63
  278. package/src/components/multi-entity-combobox.tsx +207 -120
  279. package/src/components/multi-select.tsx +80 -58
  280. package/src/components/number-field.tsx +28 -46
  281. package/src/components/number-input.tsx +210 -43
  282. package/src/components/one-time-code-input.tsx +288 -0
  283. package/src/components/phone-input.tsx +299 -0
  284. package/src/components/reauth-dialog.tsx +17 -18
  285. package/src/components/sign-chip.tsx +103 -0
  286. package/src/components/swatch-picker.tsx +143 -31
  287. package/src/components/switch.tsx +58 -7
  288. package/src/components/tile-radio.tsx +145 -29
  289. package/src/components/time-input.tsx +8 -1
  290. package/src/components/toggle-group.tsx +206 -73
  291. package/src/components/trigger-aria.ts +5 -0
  292. package/src/components/ui.tsx +479 -138
  293. package/src/data-table.ts +2 -1
  294. package/src/feedback/feedback-attachment.tsx +597 -46
  295. package/src/feedback/feedback-dialog.tsx +9 -3
  296. package/src/feedback/feedback-inbox.tsx +3 -2
  297. package/src/feedback/feedback-thread.tsx +113 -9
  298. package/src/i18n/defaults.ts +18 -1
  299. package/src/i18n/german.ts +89 -0
  300. package/src/i18n/kit-labels.tsx +82 -2
  301. package/src/i18n/locales/en.ts +36 -4
  302. package/src/i18n/locales/es.ts +87 -0
  303. package/src/i18n/locales/fr.ts +87 -0
  304. package/src/i18n/locales/hu.ts +83 -0
  305. package/src/i18n/locales/it.ts +88 -0
  306. package/src/i18n/locales/zh.ts +76 -0
  307. package/src/i18n/review.ts +26 -0
  308. package/src/index.ts +71 -3
  309. package/src/lib/column-mapping.ts +234 -0
  310. package/src/lib/countries.ts +91 -0
  311. package/src/lib/decimal-marks.ts +99 -0
  312. package/src/lib/iban.ts +171 -0
  313. package/src/lib/isin.ts +62 -0
  314. package/src/lib/phone.ts +211 -0
  315. package/src/lib/table-text.ts +271 -0
  316. package/src/rhf/fields.tsx +860 -9
  317. package/src/rhf.ts +3 -1
  318. package/src/shell/topbar-action-menu.tsx +134 -38
  319. package/src/tour/tour.tsx +66 -28
  320. package/src/wizard/stepper-nav.tsx +35 -1
package/src/rhf.ts CHANGED
@@ -10,7 +10,9 @@ export * from "./rhf/form";
10
10
  // The react-hook-form bridge to the wizard's Next gate (useWizard lives in /wizard).
11
11
  export * from "./rhf/use-rhf-wizard-step";
12
12
  // Bound fields — one line per field (RhfTextField, RhfNumberField, RhfIntegerField, RhfMoneyField,
13
- // RhfDateField, RhfTextarea, RhfSelect, RhfCheckbox, RhfCombobox, RhfTextCombobox)
13
+ // RhfDateField, RhfTextarea, RhfSelect, RhfCheckbox, RhfCombobox, RhfTextCombobox; 0.22: RhfTimeInput,
14
+ // RhfDateRangePicker, RhfToggleGroup, RhfIbanInput, RhfPhoneInput; 0.23: RhfCountrySelect,
15
+ // RhfMonthPicker)
14
16
  // and RhfField, the shell they are built on.
15
17
  export * from "./rhf/fields";
16
18
  // LineItems over useFieldArray.
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useRef } from "react";
1
+ import { useId, useLayoutEffect, useRef } from "react";
2
2
  import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
3
3
  import { Link } from "react-router";
4
4
  import { cn } from "../lib/cn";
@@ -11,9 +11,9 @@ import { TOPBAR_TRIGGER_CLASS } from "./topbar-controls";
11
11
 
12
12
  /**
13
13
  * One row of a {@link TopBarActionMenu}. An `action` (default) runs `onSelect`, a
14
- * `link` navigates via react-router, and a `divider` draws a rule. Non-divider
15
- * rows close the menu automatically when chosen. `kind` may be omitted for
16
- * actions, keeping the common case terse.
14
+ * `link` navigates via react-router, a `divider` draws a rule, and a `heading` names
15
+ * the rows after it. Action and link rows close the menu automatically when chosen.
16
+ * `kind` may be omitted for actions, keeping the common case terse.
17
17
  *
18
18
  * The rows are {@link MenuItem}s, so they take its options: `tone: "danger"` for the
19
19
  * log-out row, `checked` (+ `checkable`) for a choice, `current` for the link to the
@@ -46,7 +46,67 @@ export type TopBarMenuEntry =
46
46
  current?: boolean;
47
47
  disabled?: boolean;
48
48
  }
49
- | { kind: "divider"; key: string };
49
+ | { kind: "divider"; key: string }
50
+ | {
51
+ /**
52
+ * A group heading (0.23, keksdose's guided-tours menu): the rows after it, up to
53
+ * the next heading, the next `divider` or the end of `entries`, are ONE group
54
+ * named by `label`. keksdose files its tours by topic and separated the blocks
55
+ * with bare dividers — "the kit's menu has no heading row to name them with"
56
+ * (tour-menu.tsx) — so a sighted reader had to guess what each block was and a
57
+ * screen reader was told nothing at all.
58
+ *
59
+ * Not a row: it takes no focus, the arrow keys (and Home/End) pass over it, and it
60
+ * never closes the menu. It is announced the way the kit's other grouped lists
61
+ * announce theirs (`CommandPalette`): the group's rows sit in a `role="group"`
62
+ * whose `aria-labelledby` is this heading, so a reader says "On this page, group"
63
+ * when the focus enters the first row, and nothing between rows.
64
+ *
65
+ * A divider ENDS the group, so a closing row such as "All tours…" behind a rule
66
+ * belongs to no topic. A heading with no rows under it is not drawn — an empty
67
+ * group names nothing; build the list from data and it may well be empty.
68
+ *
69
+ * The menu-wide `heading` prop is the panel's title, drawn once above everything;
70
+ * with group headings it is usually redundant (two stacked caps lines read as one),
71
+ * and the trigger's `ariaLabel` already names the menu.
72
+ */
73
+ kind: "heading";
74
+ key: string;
75
+ /** The group's name, shown above its rows and announced as the group's name. Keep
76
+ * it short: it is set in small caps and wraps on a narrow panel. */
77
+ label: ReactNode;
78
+ };
79
+
80
+ type HeadingEntry = Extract<TopBarMenuEntry, { kind: "heading" }>;
81
+ type DividerEntry = Extract<TopBarMenuEntry, { kind: "divider" }>;
82
+ type RowEntry = Exclude<TopBarMenuEntry, HeadingEntry | DividerEntry>;
83
+
84
+ /** What the panel draws from `entries`: loose rows and rules, and headed groups. */
85
+ type Block = { kind: "loose"; entry: RowEntry | DividerEntry } | { kind: "group"; heading: HeadingEntry; rows: RowEntry[] };
86
+
87
+ /**
88
+ * Cut `entries` into blocks: a heading opens a group that takes the rows after it until
89
+ * the next heading or divider; everything else stands alone, as it always has.
90
+ */
91
+ function toBlocks(entries: readonly TopBarMenuEntry[]): Block[] {
92
+ const blocks: Block[] = [];
93
+ let group: Extract<Block, { kind: "group" }> | null = null;
94
+ for (const entry of entries) {
95
+ if (entry.kind === "heading") {
96
+ group = { kind: "group", heading: entry, rows: [] };
97
+ blocks.push(group);
98
+ } else if (entry.kind === "divider") {
99
+ group = null;
100
+ blocks.push({ kind: "loose", entry });
101
+ } else if (group) {
102
+ group.rows.push(entry);
103
+ } else {
104
+ blocks.push({ kind: "loose", entry });
105
+ }
106
+ }
107
+ // A heading with nothing under it names nothing: not drawn (see the `heading` kind).
108
+ return blocks.filter((block) => block.kind === "loose" || block.rows.length > 0);
109
+ }
50
110
 
51
111
  /**
52
112
  * The non-interactive identity block at the top of an account menu — kastlan's name +
@@ -130,6 +190,11 @@ function keepTabInsidePanel(e: ReactKeyboardEvent<HTMLUListElement>) {
130
190
  * It is also the ACCOUNT menu kastlan and keksdose each hand-build on `HoverMenu`: a
131
191
  * custom `trigger` (the avatar), a `header` (name + role / email), the rows, and a
132
192
  * free-content `footer` (the legal links).
193
+ *
194
+ * A long menu is grouped by topic with `{ kind: "heading" }` entries (0.23) — see
195
+ * {@link TopBarMenuEntry}. There is no separate phone layout: the panel is the same
196
+ * hover panel at 390px, kept inside the viewport by `HoverMenu`, and a heading wraps
197
+ * rather than widening it.
133
198
  */
134
199
  export function TopBarActionMenu({
135
200
  icon,
@@ -206,6 +271,10 @@ export function TopBarActionMenu({
206
271
  align?: "start" | "end" | "left" | "right";
207
272
  panelClassName?: string;
208
273
  }) {
274
+ // Names the groups' headings for `aria-labelledby`. Positional rather than from the
275
+ // entry's `key`, which is the caller's text and may hold a space — and a space in
276
+ // `aria-labelledby` is a list separator.
277
+ const uid = useId();
209
278
  return (
210
279
  <HoverMenu
211
280
  ariaLabel={ariaLabel}
@@ -258,6 +327,40 @@ export function TopBarActionMenu({
258
327
  const headerExtra = typeof header?.extra === "function" ? header.extra(close) : header?.extra;
259
328
  const footerClass =
260
329
  "flex flex-wrap gap-x-3 gap-y-1 text-xs text-[var(--text-muted)] [&_a:hover]:text-[var(--text-primary)] [&_a]:rounded-sm [&_a:focus-visible]:outline-none [&_a:focus-visible]:ring-2 [&_a:focus-visible]:ring-[var(--brand)]";
330
+ /** One action or link row — loose, or inside a heading's group. */
331
+ const row = (entry: RowEntry) => (
332
+ <li key={entry.key}>
333
+ {entry.kind === "link" ? (
334
+ <MenuItem
335
+ href={entry.to}
336
+ renderLink={({ href, ...p }) => <Link to={href} {...p} />}
337
+ onClick={close}
338
+ leading={entry.icon}
339
+ trailing={entry.trailing}
340
+ tone={entry.tone}
341
+ current={entry.current}
342
+ disabled={entry.disabled}
343
+ >
344
+ {entry.label}
345
+ </MenuItem>
346
+ ) : (
347
+ <MenuItem
348
+ onClick={() => {
349
+ entry.onSelect();
350
+ close();
351
+ }}
352
+ leading={entry.icon}
353
+ trailing={entry.trailing}
354
+ tone={entry.tone}
355
+ checked={entry.checked}
356
+ checkable={entry.checkable}
357
+ disabled={entry.disabled}
358
+ >
359
+ {entry.label}
360
+ </MenuItem>
361
+ )}
362
+ </li>
363
+ );
261
364
  return (
262
365
  // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a Tab bubbling up from a row or footer link, see keepTabInsidePanel
263
366
  <ul className={cn("py-1", panelClassName)} onKeyDown={keepTabInsidePanel}>
@@ -275,41 +378,34 @@ export function TopBarActionMenu({
275
378
  {heading}
276
379
  </li>
277
380
  )}
278
- {entries.map((entry) => {
279
- if (entry.kind === "divider") {
280
- return <li key={entry.key} className="my-1 border-t border-[var(--border)]" />;
381
+ {toBlocks(entries).map((block, i, blocks) => {
382
+ if (block.kind === "loose") {
383
+ const entry = block.entry;
384
+ if (entry.kind === "divider") {
385
+ return <li key={entry.key} className="my-1 border-t border-[var(--border)]" />;
386
+ }
387
+ return row(entry);
281
388
  }
389
+ const headingId = `${uid}-group-${i}`;
390
+ const before = blocks[i - 1];
391
+ // Under a rule (or at the top of the rows) the rule's margin is the gap;
392
+ // straight under another group's last row the heading makes its own.
393
+ const tight = !before || (before.kind === "loose" && before.entry.kind === "divider");
282
394
  return (
283
- <li key={entry.key}>
284
- {entry.kind === "link" ? (
285
- <MenuItem
286
- href={entry.to}
287
- renderLink={({ href, ...p }) => <Link to={href} {...p} />}
288
- onClick={close}
289
- leading={entry.icon}
290
- trailing={entry.trailing}
291
- tone={entry.tone}
292
- current={entry.current}
293
- disabled={entry.disabled}
294
- >
295
- {entry.label}
296
- </MenuItem>
297
- ) : (
298
- <MenuItem
299
- onClick={() => {
300
- entry.onSelect();
301
- close();
302
- }}
303
- leading={entry.icon}
304
- trailing={entry.trailing}
305
- tone={entry.tone}
306
- checked={entry.checked}
307
- checkable={entry.checkable}
308
- disabled={entry.disabled}
309
- >
310
- {entry.label}
311
- </MenuItem>
312
- )}
395
+ <li key={block.heading.key} role="none">
396
+ <div
397
+ id={headingId}
398
+ role="presentation"
399
+ className={cn(
400
+ "px-3 pb-0.5 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)] [overflow-wrap:anywhere]",
401
+ tight ? "pt-1" : "pt-2.5",
402
+ )}
403
+ >
404
+ {block.heading.label}
405
+ </div>
406
+ <ul role="group" aria-labelledby={headingId}>
407
+ {block.rows.map(row)}
408
+ </ul>
313
409
  </li>
314
410
  );
315
411
  })}
package/src/tour/tour.tsx CHANGED
@@ -261,6 +261,43 @@ function sameRect(a: Rect | null, b: Rect | null): boolean {
261
261
  return a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height;
262
262
  }
263
263
 
264
+ function rectOf(el: Element): Rect {
265
+ const r = el.getBoundingClientRect();
266
+ return { top: r.top, left: r.left, width: r.width, height: r.height };
267
+ }
268
+
269
+ /**
270
+ * What the finder established for ONE step: where its target is (`null` = no target,
271
+ * or not on the page), and the reading direction the card takes from it.
272
+ *
273
+ * It carries the step it was found for, and the overlay only believes it for that
274
+ * step — which is the whole fix for keksdose's tours audit 2026-10-02, §1.0 A ("stale
275
+ * spotlight on the step after a spotlighted one", 15 steps in 16 tours, desktop and
276
+ * phone, mouse and keyboard). When step N spotlit a target and step N+1 had none (or
277
+ * its anchor was missing), N+1 kept N's hole and parked its card beside it, so a
278
+ * centred "You're all set" read as pointing at the Import & Export link; Back→Next kept
279
+ * it for as long as anyone watched.
280
+ *
281
+ * The overlay used to hold `rect` and `ready` as separate states and reset them
282
+ * DURING RENDER when the step changed. React 19 does not keep a render-phase update
283
+ * while an earlier, lower-priority update to the same hook is still queued — it only
284
+ * writes the result to the base state "unless the queue is empty" — and the follow
285
+ * interval below queues exactly such an update every 250 ms (eagerly bailed out because
286
+ * the rect had not moved, but left in the queue for rebasing). So the click's render
287
+ * showed the reset, and the very next render (the finder saying "ready") re-based on
288
+ * the old rect. A deep link was fine because nothing had queued anything yet.
289
+ *
290
+ * Keying the result to its step does not depend on any of that ordering: a rect found
291
+ * for another step is not this step's rect, whatever the queue does with it, and a
292
+ * missing anchor reads as "no target" instead of as the last thing that was lit.
293
+ */
294
+ interface Located {
295
+ index: number;
296
+ step: TourStep;
297
+ rect: Rect | null;
298
+ dir: Direction;
299
+ }
300
+
264
301
  function TourOverlay({
265
302
  steps,
266
303
  index,
@@ -279,33 +316,31 @@ function TourOverlay({
279
316
  const step = steps[index];
280
317
  const isFirst = index === 0;
281
318
  const isLast = index === steps.length - 1;
282
- const [rect, setRect] = useState<Rect | null>(null);
283
- const [ready, setReady] = useState(false);
284
- // A new step starts unlocated: reset during render when the step changes, so the
285
- // previous step's spotlight never paints over the new one. The effect below then
286
- // locates it.
287
- const [locating, setLocating] = useState({ index, step });
288
- if (locating.index !== index || locating.step !== step) {
289
- setLocating({ index, step });
290
- setReady(false);
291
- setRect(null);
292
- }
319
+ // A new step starts unlocated: whatever was found for another step (see `Located`)
320
+ // is ignored, so the overlay renders nothing until the effect below has located THIS
321
+ // step, and never the previous step's spotlight.
322
+ const [located, setLocated] = useState<Located | null>(null);
323
+ const current = located !== null && located.index === index && located.step === step ? located : null;
324
+ const ready = current !== null;
325
+ const rect = current?.rect ?? null;
293
326
  // The overlay is portalled to <body>, out of whatever `dir` subtree the app set, so it
294
327
  // takes the direction of the element it spotlights (the document's for a centered
295
328
  // step). Arrow keys and start/end placement read it.
296
- const [dir, setDir] = useState<Direction>(() => dirOf(null));
329
+ const dir: Direction = current?.dir ?? "ltr";
297
330
  const rootRef = useRef<HTMLDivElement>(null);
298
331
 
299
332
  // Run beforeStep, then locate the target (retrying across route/render lag).
300
333
  useEffect(() => {
301
334
  let cancelled = false;
302
335
  let raf = 0;
336
+ // `null` = untargeted, or the finder gave up → the card centres, with no hole.
337
+ const settle = (el: Element | null) =>
338
+ setLocated({ index, step, rect: el ? rectOf(el) : null, dir: dirOf(el) });
303
339
  void (async () => {
304
340
  await step.beforeStep?.();
305
341
  if (cancelled) return;
306
342
  if (!step.target) {
307
- setDir(dirOf(null));
308
- setReady(true);
343
+ settle(null);
309
344
  return;
310
345
  }
311
346
  let tries = 0;
@@ -314,17 +349,11 @@ function TourOverlay({
314
349
  const el = queryVisibleTarget(step.target!);
315
350
  if (el) {
316
351
  el.scrollIntoView({ block: "center", behavior: "smooth" });
317
- const r = el.getBoundingClientRect();
318
- setRect({ top: r.top, left: r.left, width: r.width, height: r.height });
319
- setDir(dirOf(el));
320
- setReady(true);
352
+ settle(el);
321
353
  return;
322
354
  }
323
355
  if (tries++ < 45) raf = requestAnimationFrame(find);
324
- else {
325
- setDir(dirOf(null));
326
- setReady(true); // give up → render centered
327
- }
356
+ else settle(null); // give up → render centered
328
357
  };
329
358
  find();
330
359
  })();
@@ -334,15 +363,19 @@ function TourOverlay({
334
363
  };
335
364
  }, [index, step]);
336
365
 
337
- // Follow the target as the page scrolls / reflows.
366
+ // Follow the target as the page scrolls / reflows — only once THIS step is located,
367
+ // and only into this step's result.
338
368
  useEffect(() => {
339
369
  if (!step.target) return;
340
370
  const update = () => {
341
371
  const el = queryVisibleTarget(step.target!);
342
372
  if (el) {
343
- const r = el.getBoundingClientRect();
344
- const next = { top: r.top, left: r.left, width: r.width, height: r.height };
345
- setRect((prev) => (sameRect(prev, next) ? prev : next));
373
+ const next = rectOf(el);
374
+ setLocated((prev) =>
375
+ prev && prev.index === index && prev.step === step && !sameRect(prev.rect, next)
376
+ ? { ...prev, rect: next }
377
+ : prev,
378
+ );
346
379
  }
347
380
  };
348
381
  window.addEventListener("scroll", update, true);
@@ -358,14 +391,19 @@ function TourOverlay({
358
391
  // awaitClick: advance when the *real* target is clicked. The spotlight hole is
359
392
  // click-through (see the shields in the render below) so the element's own
360
393
  // handler runs first; we step forward on the next macrotask.
394
+ //
395
+ // Re-armed when the target turns up after the finder gave up (the follow effect
396
+ // lights it late): the card swaps Next for the "click the highlighted element" hint
397
+ // as soon as there is a rect, so the click must work from that moment too.
398
+ const spotted = rect !== null;
361
399
  useEffect(() => {
362
- if (!ready || !step.awaitClick || !step.target) return;
400
+ if (!ready || !spotted || !step.awaitClick || !step.target) return;
363
401
  const el = queryVisibleTarget(step.target);
364
402
  if (!el) return;
365
403
  const onClick = () => window.setTimeout(() => onNext(), 0);
366
404
  el.addEventListener("click", onClick, { once: true });
367
405
  return () => el.removeEventListener("click", onClick);
368
- }, [ready, index, step, onNext]);
406
+ }, [ready, spotted, index, step, onNext]);
369
407
 
370
408
  // Keyboard: Esc skips, Enter and the reading-direction arrow advance (→ in LTR,
371
409
  // ← in RTL), the other arrow goes back. On awaitClick steps Enter is ignored (it
@@ -31,7 +31,7 @@ import type { UseWizardReturn, WizardLabels } from "./types";
31
31
  * `isCommitStep`), so the policy options — `cancellable`, `onExit`, `commits`,
32
32
  * `onDone` — are all set on `useWizard`. What is set HERE is how the Finish button
33
33
  * looks and what wraps it, because that is presentation and usually depends on
34
- * state the app holds (a "replace everything" toggle, a write lock).
34
+ * state the app holds (a "replace everything" toggle, a write lock — `finishCommit`).
35
35
  */
36
36
  export function StepperNav<TData extends Record<string, unknown>>({
37
37
  wizard,
@@ -42,6 +42,8 @@ export function StepperNav<TData extends Record<string, unknown>>({
42
42
  labels,
43
43
  finishVariant = "brand",
44
44
  finishDisabled = false,
45
+ finishCommit,
46
+ finishDisabledReason,
45
47
  doneDisabled = false,
46
48
  renderFinish,
47
49
  }: {
@@ -57,6 +59,30 @@ export function StepperNav<TData extends Record<string, unknown>>({
57
59
  /** Disable Finish for a reason of the app's own, on top of the kit's gates
58
60
  * (`canFinish`, submitting, validating). */
59
61
  finishDisabled?: boolean;
62
+ /**
63
+ * Finish COMMITS — {@link Button}'s `commit`, handed to the Finish button. Under a
64
+ * locked {@link WriteLockProvider} it is `aria-disabled` but focusable, with the lock's
65
+ * reason in the kit Tooltip, and a click (or Enter) does not run `wizard.finish`.
66
+ * Every other button of the bar stays live: the steps before the commit write nothing,
67
+ * so the whole wizard can still be walked through on a read-only page.
68
+ *
69
+ * keksdose G1: its YNAB import runs the mapping wizard on the read-only demo (dev#497)
70
+ * and locks only the confirm step's Finish — by cloning `commit` into the element
71
+ * `renderFinish` hands it, because StepperNav took no `commit` of its own. That clone
72
+ * still works; this is the same thing without one. Named `finishCommit` beside
73
+ * `finishVariant` / `finishDisabled` rather than a bare `commit`, which on a component
74
+ * with five buttons would not say which one saves — and would read like `useWizard`'s
75
+ * `commits`, which is a step's policy, not a lock.
76
+ */
77
+ finishCommit?: boolean;
78
+ /**
79
+ * Why Finish is not available — {@link Button}'s `disabledReason` on the Finish button:
80
+ * `aria-disabled` and focusable, the reason in the kit Tooltip and on the button's
81
+ * `aria-describedby`, `wizard.finish` not run. The explained form of `finishDisabled`
82
+ * (a native `disabled` takes the button out of the tab order, so its reason never
83
+ * reaches a keyboard). Under a lock with `finishCommit`, the lock's reason wins.
84
+ */
85
+ finishDisabledReason?: ReactNode;
60
86
  /**
61
87
  * Disable Done — the button of the last step after a commit — while the step still
62
88
  * has work of its own running. keksdose's YNAB import ends on a recurring step whose
@@ -82,6 +108,10 @@ export function StepperNav<TData extends Record<string, unknown>>({
82
108
  * re-derive (the problem this component exists to end); `onBeforeFinish` can veto
83
109
  * a click but cannot explain a disabled button. The element is a plain `<Button>`,
84
110
  * so a wrapper that clones it with `disabled` works as expected.
111
+ *
112
+ * For the write lock itself there is no need to wrap any more: `finishCommit` (and
113
+ * `finishDisabledReason`) put the kit Button's own Tooltip on Finish. An element
114
+ * cloned with `commit` here keeps working the same way.
85
115
  */
86
116
  renderFinish?: (button: ReactElement) => ReactNode;
87
117
  }) {
@@ -134,6 +164,10 @@ export function StepperNav<TData extends Record<string, unknown>>({
134
164
  variant={finishVariant}
135
165
  data-tour="wizard-finish"
136
166
  onClick={wizard.finish}
167
+ // The kit Button's lock: a reason (its own or, with `commit`, the provider's)
168
+ // wins over `disabled` and keeps the button reachable, saying why.
169
+ commit={finishCommit}
170
+ disabledReason={finishDisabledReason}
137
171
  disabled={wizard.isSubmitting || pending || !canFinish || finishDisabled}
138
172
  aria-busy={wizard.isSubmitting || pending || undefined}
139
173
  >