@eifi1/ui-kit 0.10.0 → 0.12.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 (382) hide show
  1. package/README.md +66 -37
  2. package/dist/chart.d.ts +3 -1
  3. package/dist/chart.js +2 -0
  4. package/dist/chart.js.map +1 -1
  5. package/dist/components/account-settings-labels.d.ts +105 -0
  6. package/dist/components/account-settings-labels.js +81 -0
  7. package/dist/components/account-settings-labels.js.map +1 -0
  8. package/dist/components/account-settings.d.ts +36 -52
  9. package/dist/components/account-settings.js +44 -8
  10. package/dist/components/account-settings.js.map +1 -1
  11. package/dist/components/alert-banner.d.ts +26 -1
  12. package/dist/components/alert-banner.js +10 -7
  13. package/dist/components/alert-banner.js.map +1 -1
  14. package/dist/components/amount-input.d.ts +26 -4
  15. package/dist/components/amount-input.js +5 -2
  16. package/dist/components/amount-input.js.map +1 -1
  17. package/dist/components/authed-image.d.ts +68 -0
  18. package/dist/components/authed-image.js +117 -0
  19. package/dist/components/authed-image.js.map +1 -0
  20. package/dist/components/breadcrumbs.d.ts +2 -1
  21. package/dist/components/breadcrumbs.js +5 -2
  22. package/dist/components/breadcrumbs.js.map +1 -1
  23. package/dist/components/bulk-action-bar.d.ts +51 -5
  24. package/dist/components/bulk-action-bar.js +88 -41
  25. package/dist/components/bulk-action-bar.js.map +1 -1
  26. package/dist/components/button-group.d.ts +107 -3
  27. package/dist/components/button-group.js +60 -5
  28. package/dist/components/button-group.js.map +1 -1
  29. package/dist/components/calculator.d.ts +17 -4
  30. package/dist/components/calendar-heatmap.d.ts +162 -0
  31. package/dist/components/calendar-heatmap.js +336 -0
  32. package/dist/components/calendar-heatmap.js.map +1 -0
  33. package/dist/components/chip.d.ts +2 -1
  34. package/dist/components/chip.js +6 -3
  35. package/dist/components/chip.js.map +1 -1
  36. package/dist/components/choice-card.d.ts +66 -2
  37. package/dist/components/choice-card.js +108 -1
  38. package/dist/components/choice-card.js.map +1 -1
  39. package/dist/components/combobox-core.js +2 -0
  40. package/dist/components/combobox-core.js.map +1 -1
  41. package/dist/components/combobox.d.ts +2 -2
  42. package/dist/components/combobox.js +31 -9
  43. package/dist/components/combobox.js.map +1 -1
  44. package/dist/components/copy-button.d.ts +52 -70
  45. package/dist/components/copy-button.js +1 -1
  46. package/dist/components/copy-button.js.map +1 -1
  47. package/dist/components/data-table-cells.d.ts +63 -0
  48. package/dist/components/data-table-cells.js +64 -0
  49. package/dist/components/data-table-cells.js.map +1 -0
  50. package/dist/components/data-table-filter-popover.d.ts +1 -1
  51. package/dist/components/data-table-filters.d.ts +1 -1
  52. package/dist/components/data-table-filters.js +31 -1
  53. package/dist/components/data-table-filters.js.map +1 -1
  54. package/dist/components/data-table-labels.d.ts +13 -0
  55. package/dist/components/data-table-labels.js +8 -1
  56. package/dist/components/data-table-labels.js.map +1 -1
  57. package/dist/components/data-table.d.ts +1 -1
  58. package/dist/components/data-table.js +104 -12
  59. package/dist/components/data-table.js.map +1 -1
  60. package/dist/components/date-picker.d.ts +3 -1
  61. package/dist/components/date-picker.js +15 -2
  62. package/dist/components/date-picker.js.map +1 -1
  63. package/dist/components/description-list.d.ts +23 -3
  64. package/dist/components/description-list.js +23 -3
  65. package/dist/components/description-list.js.map +1 -1
  66. package/dist/components/dialog-frame.d.ts +1 -0
  67. package/dist/components/entity-combobox.d.ts +1 -1
  68. package/dist/components/entity-combobox.js +18 -3
  69. package/dist/components/entity-combobox.js.map +1 -1
  70. package/dist/components/error-boundary.d.ts +92 -0
  71. package/dist/components/error-boundary.js +111 -0
  72. package/dist/components/error-boundary.js.map +1 -0
  73. package/dist/components/field.d.ts +69 -0
  74. package/dist/components/field.js +75 -0
  75. package/dist/components/field.js.map +1 -0
  76. package/dist/components/file-button.d.ts +51 -208
  77. package/dist/components/file-dropzone.d.ts +50 -2
  78. package/dist/components/floating-panel.d.ts +156 -4
  79. package/dist/components/floating-panel.js +193 -29
  80. package/dist/components/floating-panel.js.map +1 -1
  81. package/dist/components/form-actions.d.ts +53 -0
  82. package/dist/components/form-actions.js +98 -0
  83. package/dist/components/form-actions.js.map +1 -0
  84. package/dist/components/image-grid.d.ts +67 -0
  85. package/dist/components/image-grid.js +112 -0
  86. package/dist/components/image-grid.js.map +1 -0
  87. package/dist/components/lightbox.d.ts +104 -0
  88. package/dist/components/lightbox.js +229 -0
  89. package/dist/components/lightbox.js.map +1 -0
  90. package/dist/components/line-items.d.ts +114 -0
  91. package/dist/components/line-items.js +238 -0
  92. package/dist/components/line-items.js.map +1 -0
  93. package/dist/components/list.d.ts +37 -3
  94. package/dist/components/list.js +39 -14
  95. package/dist/components/list.js.map +1 -1
  96. package/dist/components/loading-state.d.ts +24 -0
  97. package/dist/components/loading-state.js +32 -0
  98. package/dist/components/loading-state.js.map +1 -0
  99. package/dist/components/menu-item.d.ts +13 -2
  100. package/dist/components/menu-item.js +11 -2
  101. package/dist/components/menu-item.js.map +1 -1
  102. package/dist/components/mini-calendar.d.ts +82 -5
  103. package/dist/components/mini-calendar.js +212 -64
  104. package/dist/components/mini-calendar.js.map +1 -1
  105. package/dist/components/modal.d.ts +20 -1
  106. package/dist/components/modal.js +26 -2
  107. package/dist/components/modal.js.map +1 -1
  108. package/dist/components/month-picker.d.ts +21 -1
  109. package/dist/components/month-picker.js +139 -26
  110. package/dist/components/month-picker.js.map +1 -1
  111. package/dist/components/multi-entity-combobox.d.ts +1 -1
  112. package/dist/components/multi-entity-combobox.js +19 -3
  113. package/dist/components/multi-entity-combobox.js.map +1 -1
  114. package/dist/components/nav-pills.d.ts +67 -0
  115. package/dist/components/nav-pills.js +69 -0
  116. package/dist/components/nav-pills.js.map +1 -0
  117. package/dist/components/number-field.d.ts +26 -5
  118. package/dist/components/number-field.js +5 -7
  119. package/dist/components/number-field.js.map +1 -1
  120. package/dist/components/number-input.d.ts +27 -5
  121. package/dist/components/number-input.js +7 -1
  122. package/dist/components/number-input.js.map +1 -1
  123. package/dist/components/numpad-sheet.d.ts +17 -4
  124. package/dist/components/passkeys-setting.d.ts +67 -0
  125. package/dist/components/passkeys-setting.js +214 -0
  126. package/dist/components/passkeys-setting.js.map +1 -0
  127. package/dist/components/picker-sheet.js +2 -1
  128. package/dist/components/picker-sheet.js.map +1 -1
  129. package/dist/components/pie-chart-labels.d.ts +20 -0
  130. package/dist/components/pie-chart-labels.js +12 -0
  131. package/dist/components/pie-chart-labels.js.map +1 -0
  132. package/dist/components/pie-chart.d.ts +101 -0
  133. package/dist/components/pie-chart.js +283 -0
  134. package/dist/components/pie-chart.js.map +1 -0
  135. package/dist/components/progress-bar.d.ts +73 -3
  136. package/dist/components/progress-bar.js +110 -8
  137. package/dist/components/progress-bar.js.map +1 -1
  138. package/dist/components/qr-code.d.ts +34 -0
  139. package/dist/components/qr-code.js +57 -0
  140. package/dist/components/qr-code.js.map +1 -0
  141. package/dist/components/search-field.d.ts +15 -3
  142. package/dist/components/search-field.js +5 -1
  143. package/dist/components/search-field.js.map +1 -1
  144. package/dist/components/series-chart.d.ts +16 -0
  145. package/dist/components/series-chart.js +4 -1
  146. package/dist/components/series-chart.js.map +1 -1
  147. package/dist/components/settings-fields.d.ts +50 -1
  148. package/dist/components/signed-amount.d.ts +121 -0
  149. package/dist/components/signed-amount.js +152 -0
  150. package/dist/components/signed-amount.js.map +1 -0
  151. package/dist/components/skeleton.d.ts +10 -3
  152. package/dist/components/skeleton.js +12 -2
  153. package/dist/components/skeleton.js.map +1 -1
  154. package/dist/components/stat-tile.d.ts +2 -1
  155. package/dist/components/stat-tile.js +5 -2
  156. package/dist/components/stat-tile.js.map +1 -1
  157. package/dist/components/status-dot.d.ts +6 -2
  158. package/dist/components/status-dot.js +6 -1
  159. package/dist/components/status-dot.js.map +1 -1
  160. package/dist/components/table.d.ts +77 -11
  161. package/dist/components/table.js +85 -17
  162. package/dist/components/table.js.map +1 -1
  163. package/dist/components/text-link.d.ts +176 -0
  164. package/dist/components/text-link.js +109 -0
  165. package/dist/components/text-link.js.map +1 -0
  166. package/dist/components/text.d.ts +8 -1
  167. package/dist/components/text.js +1 -0
  168. package/dist/components/text.js.map +1 -1
  169. package/dist/components/time-input.d.ts +50 -1
  170. package/dist/components/toggle-group.d.ts +28 -0
  171. package/dist/components/toggle-group.js +58 -9
  172. package/dist/components/toggle-group.js.map +1 -1
  173. package/dist/components/toggle-legend.d.ts +36 -3
  174. package/dist/components/toggle-legend.js +49 -0
  175. package/dist/components/toggle-legend.js.map +1 -1
  176. package/dist/components/tooltip.d.ts +11 -3
  177. package/dist/components/tooltip.js +2 -0
  178. package/dist/components/tooltip.js.map +1 -1
  179. package/dist/components/ui.d.ts +52 -729
  180. package/dist/components/ui.js +296 -27
  181. package/dist/components/ui.js.map +1 -1
  182. package/dist/components/use-table-state.d.ts +3 -52
  183. package/dist/components/use-table-state.js +128 -30
  184. package/dist/components/use-table-state.js.map +1 -1
  185. package/dist/components/user-avatar.d.ts +25 -3
  186. package/dist/components/user-avatar.js +32 -3
  187. package/dist/components/user-avatar.js.map +1 -1
  188. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  189. package/dist/data-table.d.ts +2 -1
  190. package/dist/data-table.js +5 -1
  191. package/dist/data-table.js.map +1 -1
  192. package/dist/feedback/feedback-dialog.d.ts +26 -3
  193. package/dist/feedback/feedback-dialog.js +16 -2
  194. package/dist/feedback/feedback-dialog.js.map +1 -1
  195. package/dist/feedback/feedback-thread.d.ts +133 -0
  196. package/dist/feedback/feedback-thread.js +237 -0
  197. package/dist/feedback/feedback-thread.js.map +1 -0
  198. package/dist/feedback.d.ts +2 -1
  199. package/dist/feedback.js +1 -0
  200. package/dist/feedback.js.map +1 -1
  201. package/dist/hooks/use-authed-src.d.ts +71 -0
  202. package/dist/hooks/use-authed-src.js +63 -0
  203. package/dist/hooks/use-authed-src.js.map +1 -0
  204. package/dist/hooks/use-file-drop.d.ts +50 -2
  205. package/dist/hooks/use-hotkey.d.ts +59 -0
  206. package/dist/hooks/use-hotkey.js +105 -0
  207. package/dist/hooks/use-hotkey.js.map +1 -0
  208. package/dist/hooks/use-search-param-state.d.ts +83 -0
  209. package/dist/hooks/use-search-param-state.js +94 -0
  210. package/dist/hooks/use-search-param-state.js.map +1 -0
  211. package/dist/i18n/defaults.d.ts +17 -4
  212. package/dist/i18n/defaults.js +33 -1
  213. package/dist/i18n/defaults.js.map +1 -1
  214. package/dist/i18n/kit-labels.d.ts +47 -310
  215. package/dist/i18n/kit-labels.js +20 -5
  216. package/dist/i18n/kit-labels.js.map +1 -1
  217. package/dist/i18n/locales/de-CH-informal.d.ts +17 -4
  218. package/dist/i18n/locales/de-CH.d.ts +17 -4
  219. package/dist/i18n/locales/de-informal.d.ts +17 -4
  220. package/dist/i18n/locales/de.d.ts +17 -4
  221. package/dist/i18n/locales/de.js +165 -4
  222. package/dist/i18n/locales/de.js.map +1 -1
  223. package/dist/i18n/locales/es.d.ts +17 -4
  224. package/dist/i18n/locales/es.js +162 -4
  225. package/dist/i18n/locales/es.js.map +1 -1
  226. package/dist/i18n/locales/fr.d.ts +17 -4
  227. package/dist/i18n/locales/fr.js +162 -4
  228. package/dist/i18n/locales/fr.js.map +1 -1
  229. package/dist/i18n/locales/hu.d.ts +17 -4
  230. package/dist/i18n/locales/hu.js +162 -4
  231. package/dist/i18n/locales/hu.js.map +1 -1
  232. package/dist/i18n/locales/it.d.ts +17 -4
  233. package/dist/i18n/locales/it.js +162 -4
  234. package/dist/i18n/locales/it.js.map +1 -1
  235. package/dist/i18n/locales/zh.d.ts +17 -4
  236. package/dist/i18n/locales/zh.js +162 -4
  237. package/dist/i18n/locales/zh.js.map +1 -1
  238. package/dist/index.d.ts +45 -21
  239. package/dist/index.js +43 -1
  240. package/dist/index.js.map +1 -1
  241. package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
  242. package/dist/lib/clipping.d.ts +21 -5
  243. package/dist/lib/clipping.js +3 -0
  244. package/dist/lib/clipping.js.map +1 -1
  245. package/dist/lib/dates.d.ts +17 -1
  246. package/dist/lib/dates.js +19 -0
  247. package/dist/lib/dates.js.map +1 -1
  248. package/dist/lib/format.d.ts +144 -0
  249. package/dist/lib/format.js +149 -0
  250. package/dist/lib/format.js.map +1 -0
  251. package/dist/lib/qr-encode.d.ts +44 -0
  252. package/dist/lib/qr-encode.js +338 -0
  253. package/dist/lib/qr-encode.js.map +1 -0
  254. package/dist/rhf/fields.d.ts +241 -0
  255. package/dist/rhf/fields.js +601 -0
  256. package/dist/rhf/fields.js.map +1 -0
  257. package/dist/rhf/form.d.ts +50 -1
  258. package/dist/rhf/line-items.d.ts +31 -0
  259. package/dist/rhf/line-items.js +59 -0
  260. package/dist/rhf/line-items.js.map +1 -0
  261. package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
  262. package/dist/rhf/use-rhf-wizard-step.js +38 -0
  263. package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
  264. package/dist/rhf.d.ts +63 -1
  265. package/dist/rhf.js +3 -0
  266. package/dist/rhf.js.map +1 -1
  267. package/dist/shell/app-shell.d.ts +99 -2
  268. package/dist/shell/app-shell.js +58 -3
  269. package/dist/shell/app-shell.js.map +1 -1
  270. package/dist/shell/auth-layout.d.ts +74 -0
  271. package/dist/shell/auth-layout.js +97 -0
  272. package/dist/shell/auth-layout.js.map +1 -0
  273. package/dist/shell/top-bar-brand.d.ts +81 -0
  274. package/dist/shell/top-bar-brand.js +47 -0
  275. package/dist/shell/top-bar-brand.js.map +1 -0
  276. package/dist/shell/topbar-action-menu.d.ts +84 -3
  277. package/dist/shell/topbar-action-menu.js +115 -27
  278. package/dist/shell/topbar-action-menu.js.map +1 -1
  279. package/dist/shell.d.ts +58 -2
  280. package/dist/shell.js +2 -0
  281. package/dist/shell.js.map +1 -1
  282. package/dist/wizard/stepper-nav.d.ts +49 -1
  283. package/dist/wizard/wizard-context.d.ts +30 -1
  284. package/dist/wizard/wizard-context.js +23 -2
  285. package/dist/wizard/wizard-context.js.map +1 -1
  286. package/dist/wizard/wizard-step.d.ts +20 -5
  287. package/dist/wizard/wizard-step.js +16 -3
  288. package/dist/wizard/wizard-step.js.map +1 -1
  289. package/dist/wizard.d.ts +51 -3
  290. package/package.json +1 -1
  291. package/src/chart.ts +4 -0
  292. package/src/components/account-settings-labels.ts +190 -0
  293. package/src/components/account-settings.tsx +110 -63
  294. package/src/components/alert-banner.tsx +36 -7
  295. package/src/components/amount-input.tsx +13 -1
  296. package/src/components/authed-image.tsx +190 -0
  297. package/src/components/breadcrumbs.tsx +8 -2
  298. package/src/components/bulk-action-bar.tsx +163 -57
  299. package/src/components/button-group.tsx +129 -4
  300. package/src/components/calendar-heatmap.tsx +599 -0
  301. package/src/components/chip.tsx +8 -4
  302. package/src/components/choice-card.tsx +208 -1
  303. package/src/components/combobox-core.tsx +5 -0
  304. package/src/components/combobox.tsx +31 -9
  305. package/src/components/copy-button.tsx +3 -2
  306. package/src/components/data-table-cells.tsx +119 -0
  307. package/src/components/data-table-filters.ts +77 -0
  308. package/src/components/data-table-labels.ts +23 -0
  309. package/src/components/data-table.tsx +231 -13
  310. package/src/components/date-picker.tsx +19 -3
  311. package/src/components/description-list.tsx +52 -3
  312. package/src/components/entity-combobox.tsx +18 -3
  313. package/src/components/error-boundary.tsx +216 -0
  314. package/src/components/field.tsx +188 -0
  315. package/src/components/floating-panel.tsx +380 -16
  316. package/src/components/form-actions.tsx +191 -0
  317. package/src/components/image-grid.tsx +181 -0
  318. package/src/components/lightbox.tsx +374 -0
  319. package/src/components/line-items.tsx +385 -0
  320. package/src/components/list.tsx +93 -18
  321. package/src/components/loading-state.tsx +47 -0
  322. package/src/components/menu-item.tsx +29 -3
  323. package/src/components/mini-calendar.tsx +317 -57
  324. package/src/components/modal.tsx +61 -3
  325. package/src/components/month-picker.tsx +181 -26
  326. package/src/components/multi-entity-combobox.tsx +19 -3
  327. package/src/components/nav-pills.tsx +152 -0
  328. package/src/components/number-field.tsx +15 -10
  329. package/src/components/number-input.tsx +16 -1
  330. package/src/components/passkeys-setting.tsx +331 -0
  331. package/src/components/picker-sheet.tsx +7 -1
  332. package/src/components/pie-chart-labels.ts +32 -0
  333. package/src/components/pie-chart.tsx +479 -0
  334. package/src/components/progress-bar.tsx +241 -13
  335. package/src/components/qr-code.tsx +83 -0
  336. package/src/components/search-field.tsx +20 -4
  337. package/src/components/series-chart.tsx +21 -0
  338. package/src/components/signed-amount.tsx +286 -0
  339. package/src/components/skeleton.tsx +21 -3
  340. package/src/components/stat-tile.tsx +8 -2
  341. package/src/components/status-dot.tsx +10 -2
  342. package/src/components/table.tsx +186 -23
  343. package/src/components/text-link.tsx +248 -0
  344. package/src/components/text.tsx +9 -1
  345. package/src/components/toggle-group.tsx +106 -13
  346. package/src/components/toggle-legend.tsx +87 -2
  347. package/src/components/tooltip.tsx +17 -3
  348. package/src/components/ui.tsx +572 -44
  349. package/src/components/use-table-state.ts +265 -33
  350. package/src/components/user-avatar.tsx +57 -3
  351. package/src/data-table.ts +10 -0
  352. package/src/feedback/feedback-dialog.tsx +42 -3
  353. package/src/feedback/feedback-thread.tsx +434 -0
  354. package/src/feedback.ts +1 -0
  355. package/src/hooks/use-authed-src.ts +161 -0
  356. package/src/hooks/use-hotkey.ts +172 -0
  357. package/src/hooks/use-search-param-state.ts +187 -0
  358. package/src/i18n/defaults.ts +32 -0
  359. package/src/i18n/kit-labels.tsx +104 -8
  360. package/src/i18n/locales/de.ts +162 -0
  361. package/src/i18n/locales/es.ts +159 -0
  362. package/src/i18n/locales/fr.ts +162 -0
  363. package/src/i18n/locales/hu.ts +160 -0
  364. package/src/i18n/locales/it.ts +160 -0
  365. package/src/i18n/locales/zh.ts +159 -0
  366. package/src/index.ts +64 -0
  367. package/src/lib/clipping.ts +22 -4
  368. package/src/lib/dates.ts +37 -0
  369. package/src/lib/format.ts +325 -0
  370. package/src/lib/qr-encode.ts +420 -0
  371. package/src/rhf/fields.tsx +1054 -0
  372. package/src/rhf/line-items.tsx +130 -0
  373. package/src/rhf/use-rhf-wizard-step.ts +113 -0
  374. package/src/rhf.ts +8 -0
  375. package/src/shell/app-shell.tsx +147 -2
  376. package/src/shell/auth-layout.tsx +190 -0
  377. package/src/shell/top-bar-brand.tsx +76 -0
  378. package/src/shell/topbar-action-menu.tsx +260 -49
  379. package/src/shell.ts +2 -0
  380. package/src/wizard/wizard-context.tsx +50 -1
  381. package/src/wizard/wizard-step.tsx +43 -6
  382. package/tokens.css +12 -0
@@ -11,6 +11,7 @@ import { cn } from "../lib/cn";
11
11
  import { useFocusTrap } from "../hooks/use-focus-trap";
12
12
  import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
13
13
  import { useOverlayHistory } from "../hooks/use-overlay-history";
14
+ import { useDialogParam, type DialogParam } from "../hooks/use-search-param-state";
14
15
  import { OVERLAY_EXIT_MS, prefersReducedMotion, useCloseTransition } from "../hooks/use-close-transition";
15
16
 
16
17
  /**
@@ -196,6 +197,50 @@ export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"
196
197
  * suppressed there — there is nothing beside it to see.
197
198
  */
198
199
  draggable?: boolean;
200
+ /**
201
+ * Bind the open state to a URL search param (0.12.0): `"edit"` is open while
202
+ * `?edit` is in the query, `{ key: "dialog", value: "create-lease" }` while
203
+ * `?dialog=create-lease` is. The URL is then the only state — `open` is ignored —
204
+ * and the dialog opens from anywhere that sets the param: {@link useDialogParam}'s
205
+ * setter, a `<Link>`, a shared link, a reload.
206
+ *
207
+ * kastlan's `useDialogParam` did this beside the dialog, so its open state could be
208
+ * deep-linked and captured in a feedback report. Here it also owns the history:
209
+ * opening through `useDialogParam` pushes an entry, so the Back gesture closes the
210
+ * dialog by leaving it, and a close (Escape, the backdrop, the X) goes back over
211
+ * that entry rather than stacking a second one. The dialog's own Back handling
212
+ * ({@link useOverlayHistory}'s throwaway entry) stays off in this mode — the param's
213
+ * entry already is one, and two would take two Back presses to close one dialog.
214
+ *
215
+ * `onClose` still runs, after the param is cleared. Requires a react-router Router.
216
+ */
217
+ urlParam?: DialogParam;
218
+ }
219
+
220
+ /**
221
+ * The URL-bound dialog: the param is the open state, and closing clears it. A
222
+ * component of its own so the router hook is only called by a `Modal` that asked for
223
+ * it — a plain `Modal` must keep working with no Router above it.
224
+ */
225
+ function UrlParamModal({ urlParam, onClose, ...rest }: ModalProps & { urlParam: DialogParam }) {
226
+ const [open, setOpen] = useDialogParam(urlParam);
227
+ // Closed from here the moment the dismissal is decided. Going back is a traversal
228
+ // the browser lands a task later, and between the two the param still says "open":
229
+ // without this the panel, already lowered, would pop back up for that task.
230
+ const [leaving, setLeaving] = useState(false);
231
+ if (leaving && !open) setLeaving(false);
232
+ return (
233
+ <ModalPanel
234
+ {...rest}
235
+ open={open && !leaving}
236
+ ownHistoryEntry={false}
237
+ onClose={() => {
238
+ setLeaving(true);
239
+ setOpen(false);
240
+ onClose();
241
+ }}
242
+ />
243
+ );
199
244
  }
200
245
 
201
246
  /**
@@ -206,7 +251,15 @@ export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"
206
251
  * into the panel on open and back to the trigger on close, a Tab focus trap, and
207
252
  * `role="dialog"`/`aria-modal`.
208
253
  */
209
- export function Modal({
254
+ export function Modal(props: ModalProps) {
255
+ return props.urlParam !== undefined ? (
256
+ <UrlParamModal {...props} urlParam={props.urlParam} />
257
+ ) : (
258
+ <ModalPanel {...props} />
259
+ );
260
+ }
261
+
262
+ function ModalPanel({
210
263
  onClose,
211
264
  open,
212
265
  children,
@@ -218,8 +271,13 @@ export function Modal({
218
271
  draggable,
219
272
  style,
220
273
  role = "dialog",
274
+ urlParam: _urlParam,
275
+ ownHistoryEntry = true,
221
276
  ...rest
222
- }: ModalProps) {
277
+ }: ModalProps & {
278
+ /** False when the URL already holds an entry for this dialog — see `urlParam`. */
279
+ ownHistoryEntry?: boolean;
280
+ }) {
223
281
  const panelRef = useRef<HTMLDivElement>(null);
224
282
  const drag = useDragOffset(Boolean(draggable));
225
283
  // Without `open`, mounted means open — the constant `true` every hook below used to
@@ -262,7 +320,7 @@ export function Modal({
262
320
  // this the only way out of a dialog is finding its close button — and Back, the
263
321
  // gesture everyone reaches for, navigated the page underneath instead (#172).
264
322
  // Held only while open: a closed `open={false}` dialog must not own a history entry.
265
- useOverlayHistory(active, requestClose);
323
+ useOverlayHistory(active && ownHistoryEntry, requestClose);
266
324
 
267
325
  // Through the shared hook and not by hand: this component's own save/restore copy
268
326
  // was one half of the pair that left the page permanently unscrollable when a dialog
@@ -1,10 +1,10 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, RefObject } from "react";
3
- import { Calendar, ChevronLeft, ChevronRight } from "lucide-react";
3
+ import { Calendar, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { dirOf, horizontalStep, type Direction } from "../lib/direction";
6
6
  import { monthKey, pad } from "../lib/dates";
7
- import { FieldLabel, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER } from "./ui";
7
+ import { Button, FieldLabel, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER, IconButton } from "./ui";
8
8
  import { Popover } from "./popover";
9
9
  import { splitTriggerAria } from "./trigger-aria";
10
10
  import type { TriggerAria } from "./trigger-aria";
@@ -27,6 +27,11 @@ export interface MonthPickerLabels {
27
27
  * "Aug" read out on its own does not say which year the grid is on.
28
28
  */
29
29
  month: (monthYear: string) => string;
30
+ /** `variant="stepper"`: the icon-only arrows that step the value by one month. */
31
+ previousMonth: string;
32
+ nextMonth: string;
33
+ /** `variant="stepper"`: the button that jumps to the current month. Visible text. */
34
+ today: string;
30
35
  }
31
36
 
32
37
  /** The English starting point every override is merged onto. Exported (unlike the
@@ -37,6 +42,9 @@ export const DEFAULT_MONTH_PICKER_LABELS: MonthPickerLabels = {
37
42
  nextYear: "Next year",
38
43
  panel: "Choose a month",
39
44
  month: (monthYear) => monthYear,
45
+ previousMonth: "Previous month",
46
+ nextMonth: "Next month",
47
+ today: "Today",
40
48
  };
41
49
 
42
50
  /** Merge caller overrides onto the English defaults. */
@@ -95,6 +103,21 @@ export interface MonthPickerProps extends Omit<ComponentPropsWithoutRef<"div">,
95
103
  /** Extra classes for the trigger button — e.g. a compact `h-9 w-auto` trigger
96
104
  * sitting between two icon buttons in a toolbar. */
97
105
  triggerClassName?: string;
106
+ /**
107
+ * `"field"` (default): the field-shaped trigger, for a form.
108
+ *
109
+ * `"stepper"`: the header of a month PAGE — the month as a heading that opens the
110
+ * grid, then Today and ‹ › stepping the value by one month. What a calendar page
111
+ * assembled by hand around the field (overriding its trigger to look like a
112
+ * heading). `label`, `placeholder`, `invalid` and `triggerClassName` are the field's
113
+ * and are not drawn here; with no `value` the arrows step from the current month.
114
+ */
115
+ variant?: "field" | "stepper";
116
+ /** `variant="stepper"`: the heading level the month is set in (`<h2>` by default), or
117
+ * `false` for no heading element — the page already has one for the month. */
118
+ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6 | false;
119
+ /** `variant="stepper"`: draw the Today button. Default `true`. */
120
+ showToday?: boolean;
98
121
  }
99
122
 
100
123
  // ── Month keys ────────────────────────────────────────────────────────────
@@ -140,6 +163,74 @@ const MONTH_NAME_FORMAT: Intl.DateTimeFormatOptions = { month: "long", year: "nu
140
163
 
141
164
  // ── Trigger ───────────────────────────────────────────────────────────────
142
165
 
166
+ /** Hand focus back to the trigger when the panel closes with focus still inside it. */
167
+ function useFocusBackOnClose(open: boolean, triggerRef: RefObject<HTMLButtonElement | null>) {
168
+ const wasOpen = useRef(false);
169
+ useEffect(() => {
170
+ const justClosed = wasOpen.current && !open;
171
+ wasOpen.current = open;
172
+ if (!justClosed) return;
173
+ // Only when focus went down with the panel. An outside click has already put it
174
+ // somewhere real, and dragging it back here would be worse than leaving it.
175
+ const active = document.activeElement;
176
+ if (!active || active === document.body) triggerRef.current?.focus();
177
+ }, [open, triggerRef]);
178
+ }
179
+
180
+ /**
181
+ * The stepper's trigger: the month as a heading-sized button that opens the grid.
182
+ *
183
+ * Its own text is its name ("September 2026"), so it needs no label of its own, and
184
+ * it is `aria-live` for the reason `MiniCalendar`'s caption is: pressing ‹ or › leaves
185
+ * focus on the arrow, and the heading is the only thing that says where the arrow
186
+ * went. A plain button with `aria-haspopup`, not the field's `combobox` — it does not
187
+ * look or behave like a field, and a heading reading "combobox" is a surprise.
188
+ */
189
+ function MonthHeadingTrigger({
190
+ open,
191
+ toggle,
192
+ triggerRef,
193
+ text,
194
+ panelId,
195
+ disabled,
196
+ aria,
197
+ }: {
198
+ open: boolean;
199
+ toggle: () => void;
200
+ triggerRef: RefObject<HTMLButtonElement | null>;
201
+ text: string;
202
+ panelId: string;
203
+ disabled?: boolean;
204
+ aria: TriggerAria;
205
+ }) {
206
+ useFocusBackOnClose(open, triggerRef);
207
+ return (
208
+ <button
209
+ ref={triggerRef}
210
+ type="button"
211
+ disabled={disabled}
212
+ onClick={toggle}
213
+ aria-haspopup="dialog"
214
+ aria-controls={panelId}
215
+ aria-expanded={open}
216
+ id={aria.id}
217
+ aria-label={aria["aria-label"]}
218
+ aria-labelledby={aria["aria-labelledby"]}
219
+ aria-describedby={aria["aria-describedby"]}
220
+ className={cn(
221
+ "-ms-1.5 inline-flex h-9 min-w-0 items-center gap-1 rounded-md px-1.5 text-lg font-semibold text-[var(--text-primary)] transition-colors",
222
+ "hover:bg-[var(--bg-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
223
+ disabled && "cursor-not-allowed opacity-50",
224
+ )}
225
+ >
226
+ <span aria-live="polite" className="truncate">
227
+ {text || "\u00a0"}
228
+ </span>
229
+ <ChevronDown aria-hidden className="size-4 shrink-0 text-[var(--text-muted)]" />
230
+ </button>
231
+ );
232
+ }
233
+
143
234
  /**
144
235
  * The field-shaped button that opens the grid. The same shape as `DatePicker`'s
145
236
  * trigger, for the same reasons written down there: `role="combobox"` so it may carry
@@ -176,16 +267,7 @@ function MonthFieldTrigger({
176
267
  invalid?: boolean;
177
268
  className?: string;
178
269
  }) {
179
- const wasOpen = useRef(false);
180
- useEffect(() => {
181
- const justClosed = wasOpen.current && !open;
182
- wasOpen.current = open;
183
- if (!justClosed) return;
184
- // Only when focus went down with the panel. An outside click has already put it
185
- // somewhere real, and dragging it back here would be worse than leaving it.
186
- const active = document.activeElement;
187
- if (!active || active === document.body) triggerRef.current?.focus();
188
- }, [open, triggerRef]);
270
+ useFocusBackOnClose(open, triggerRef);
189
271
 
190
272
  return (
191
273
  <button
@@ -475,6 +557,9 @@ export function MonthPicker({
475
557
  labels: labelsProp,
476
558
  className,
477
559
  triggerClassName,
560
+ variant = "field",
561
+ headingLevel = 2,
562
+ showToday = true,
478
563
  ...rest
479
564
  }: MonthPickerProps) {
480
565
  const labels = useMonthPickerLabels(labelsProp);
@@ -506,6 +591,89 @@ export function MonthPicker({
506
591
  const rootRef = useRef<HTMLDivElement>(null);
507
592
  const [dir, setDir] = useState<Direction>("ltr");
508
593
 
594
+ const grid = (close: () => void) => (
595
+ <MonthGrid
596
+ value={selected}
597
+ locale={locale}
598
+ minKey={minKey}
599
+ maxKey={maxKey}
600
+ currentKey={currentKey}
601
+ labels={labels}
602
+ onPick={(key) => {
603
+ onChange(key);
604
+ close();
605
+ }}
606
+ />
607
+ );
608
+
609
+ if (variant === "stepper") {
610
+ // The arrows step from the value, or from "now" when there is none; each is dead
611
+ // once the month it leads to is out of bounds, and Today once "now" is.
612
+ const base = selected ?? parseMonth(currentKey)!;
613
+ const prevKey = toKey(shiftMonth(base, -1));
614
+ const nextKey = toKey(shiftMonth(base, 1));
615
+ const outside = (key: string) => Boolean((minKey && key < minKey) || (maxKey && key > maxKey));
616
+ const Heading = headingLevel ? (`h${headingLevel}` as const) : null;
617
+ const trigger = (
618
+ <Popover
619
+ width={256}
620
+ panelId={panelId}
621
+ labels={{ panel: labels.panel }}
622
+ dir={dir}
623
+ trigger={({ open, toggle, ref }) => (
624
+ <MonthHeadingTrigger
625
+ open={open}
626
+ toggle={() => {
627
+ setDir(dirOf(rootRef.current));
628
+ toggle();
629
+ }}
630
+ triggerRef={ref}
631
+ text={triggerText}
632
+ panelId={panelId}
633
+ disabled={disabled}
634
+ aria={aria}
635
+ />
636
+ )}
637
+ >
638
+ {grid}
639
+ </Popover>
640
+ );
641
+ return (
642
+ <div {...wrapperRest} ref={rootRef} className={cn("flex min-w-0 items-center gap-2", className)}>
643
+ {Heading ? <Heading className="m-0 min-w-0 text-lg font-semibold">{trigger}</Heading> : trigger}
644
+ <div className="ms-auto flex shrink-0 items-center gap-1">
645
+ {showToday && (
646
+ <Button
647
+ variant="secondary"
648
+ size="sm"
649
+ disabled={disabled || outside(currentKey)}
650
+ onClick={() => {
651
+ if (currentKey !== value) onChange(currentKey);
652
+ }}
653
+ >
654
+ {labels.today}
655
+ </Button>
656
+ )}
657
+ <IconButton
658
+ aria-label={labels.previousMonth}
659
+ disabled={disabled || outside(prevKey)}
660
+ onClick={() => onChange(prevKey)}
661
+ >
662
+ {/* Mirrored in RTL, where "previous" points the other way. */}
663
+ <ChevronLeft className="rtl:-scale-x-100" />
664
+ </IconButton>
665
+ <IconButton
666
+ aria-label={labels.nextMonth}
667
+ disabled={disabled || outside(nextKey)}
668
+ onClick={() => onChange(nextKey)}
669
+ >
670
+ <ChevronRight className="rtl:-scale-x-100" />
671
+ </IconButton>
672
+ </div>
673
+ </div>
674
+ );
675
+ }
676
+
509
677
  return (
510
678
  <div {...wrapperRest} ref={rootRef} className={cn("relative", className)}>
511
679
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
@@ -550,20 +718,7 @@ export function MonthPicker({
550
718
  />
551
719
  )}
552
720
  >
553
- {(close) => (
554
- <MonthGrid
555
- value={selected}
556
- locale={locale}
557
- minKey={minKey}
558
- maxKey={maxKey}
559
- currentKey={currentKey}
560
- labels={labels}
561
- onPick={(key) => {
562
- onChange(key);
563
- close();
564
- }}
565
- />
566
- )}
721
+ {grid}
567
722
  </Popover>
568
723
  <Calendar
569
724
  aria-hidden
@@ -94,6 +94,15 @@ export function MultiEntityCombobox<V extends string | number>({
94
94
  debounceMs,
95
95
  loadErrorLabel,
96
96
  "aria-label": ariaLabel,
97
+ // The control's own wiring, taken off `rest` so it lands on the TRIGGER rather than
98
+ // the wrapper: `Field`'s render-prop spreads `{ id, aria-describedby, aria-invalid,
99
+ // aria-required }`, and on the role-less wrapper div the label's `htmlFor` named
100
+ // nothing and the hint and required state described nothing — kastlan's
101
+ // communication-page To/Cc/Bcc and lease-tenants-step.tsx read as unnamed pickers.
102
+ id,
103
+ "aria-describedby": ariaDescribedBy,
104
+ "aria-invalid": ariaInvalid,
105
+ "aria-required": ariaRequired,
97
106
  ...rest
98
107
  }: MultiEntityComboboxProps<V>) {
99
108
  const core = useComboboxCore<V>({
@@ -104,7 +113,11 @@ export function MultiEntityCombobox<V extends string | number>({
104
113
  minChars,
105
114
  debounceMs,
106
115
  });
107
- const field = useComboboxFieldError(error, invalid);
116
+ const field = useComboboxFieldError(
117
+ error,
118
+ invalid || ariaInvalid === true || ariaInvalid === "true",
119
+ ariaDescribedBy,
120
+ );
108
121
  // The props are the per-instance overrides, the provider the app-wide ones; a
109
122
  // prop left `undefined` falls through to the provider rather than masking it.
110
123
  const labels = useKitLabels("combobox", DEFAULT_COMBOBOX_LABELS, {
@@ -154,13 +167,15 @@ export function MultiEntityCombobox<V extends string | number>({
154
167
  };
155
168
 
156
169
  return (
157
- // `rest` dresses the wrapper, which has no role; the accessible NAME goes on
158
- // the trigger, which has one. Spread FIRST so the trigger's ARIA and the
170
+ // `rest` dresses the wrapper, which has no role; the accessible NAME — and the
171
+ // id, description, invalid and required state above — go on the trigger, which
172
+ // has one. Spread FIRST so the trigger's ARIA and the
159
173
  // handlers that open the panel cannot be clobbered from outside.
160
174
  <div {...rest} className={cn("relative", className)}>
161
175
  {label !== undefined && <FieldLabel>{label}</FieldLabel>}
162
176
  <button
163
177
  ref={core.triggerRef}
178
+ id={id}
164
179
  type="button"
165
180
  // A combobox, not a button. The distinction is not pedantry: this control
166
181
  // carried `aria-invalid`, which `button` does not support, so a required
@@ -188,6 +203,7 @@ export function MultiEntityCombobox<V extends string | number>({
188
203
  disabled={disabled}
189
204
  aria-invalid={field.isInvalid || undefined}
190
205
  aria-describedby={field.describedBy}
206
+ aria-required={ariaRequired}
191
207
  onClick={() => !disabled && setOpen((o) => !o)}
192
208
  // Down/Up opens the list from the closed trigger, per the APG. Enter and
193
209
  // Space already do it through the button's own click.
@@ -0,0 +1,152 @@
1
+ import type { ComponentPropsWithoutRef, ComponentType, MouseEvent, ReactElement, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+ import { useKitLink } from "../i18n/kit-labels";
4
+ import { pickLinkRenderer, RenderedKitLink } from "./text-link";
5
+
6
+ export interface NavPillItem<T extends string> {
7
+ value: T;
8
+ label: ReactNode;
9
+ /** Where the pill leads. With one it is a link (see `renderLink`); without, a button
10
+ * that calls `onSelect`. */
11
+ href?: string;
12
+ /** A Lucide icon (or any component taking a `className`), before the label. */
13
+ icon?: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
14
+ /** Buttons only — a link cannot be disabled. */
15
+ disabled?: boolean;
16
+ }
17
+
18
+ /** What {@link NavPillsProps.renderLink} is handed. Spread it onto your router's link
19
+ * — `({ href, ...p }) => <Link to={href} {...p} />`. */
20
+ export interface NavPillLinkProps {
21
+ href: string;
22
+ className: string;
23
+ children: ReactNode;
24
+ "aria-current": NavPillsCurrent | undefined;
25
+ onClick: (e: MouseEvent<HTMLAnchorElement>) => void;
26
+ }
27
+
28
+ /** The `aria-current` token the current pill carries. */
29
+ export type NavPillsCurrent = "page" | "step" | "location" | "true";
30
+
31
+ export interface NavPillsProps<T extends string> extends Omit<ComponentPropsWithoutRef<"nav">, "children" | "onSelect"> {
32
+ items: NavPillItem<T>[];
33
+ /** The pill that is current. `null` or a value not in `items`: none is. */
34
+ current: T | null;
35
+ /** Called with the pill's value on a click — of a button, and of a link too (before
36
+ * the router follows it), for a caller that keeps the choice in state as well. */
37
+ onSelect?: (value: T) => void;
38
+ /** Your router's link. Default: the `<UiKitProvider linkComponent>` (for an in-app
39
+ * `href`), then `<a>`. The API `Breadcrumbs`, `Chip` and `ListItem` share. */
40
+ renderLink?: (props: NavPillLinkProps) => ReactElement;
41
+ /** What `aria-current` says on the current pill. Default `page` where the items
42
+ * are links and `true` where they are buttons (keksdose's swipe-surface picker
43
+ * switches a panel on the same page, which is not a page). */
44
+ currentType?: NavPillsCurrent;
45
+ /** `sm`: 12px pills — the compact size of a {@link ToggleGroup}. Default `md`. */
46
+ size?: "sm" | "md";
47
+ /** Render a `<nav>` landmark (default) or a plain `<div role="group">` for a picker
48
+ * that is not the page's navigation. Name either with `aria-label`. */
49
+ landmark?: boolean;
50
+ /** Classes for every pill. */
51
+ itemClassName?: string;
52
+ }
53
+
54
+ // The pill is a ToggleGroup segment's (`rounded`, `px-3 py-1.5`, the inverse fill for
55
+ // the current one), so a strip of these beside a segmented control reads as the same
56
+ // family. Unlike the segments, a pill never truncates: the strip WRAPS instead.
57
+ const PILL =
58
+ "inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border-strong)] [&_svg]:size-4 [&_svg]:shrink-0";
59
+ const PILL_SM = "gap-1 px-2 py-1 text-xs [&_svg]:size-3.5";
60
+ const PILL_CURRENT = "bg-[var(--bg-inverse)] text-[var(--text-inverse)]";
61
+ const PILL_IDLE = "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]";
62
+ const PILL_DISABLED = "cursor-not-allowed opacity-50 hover:bg-transparent hover:text-[var(--text-secondary)]";
63
+
64
+ /**
65
+ * A set of links or buttons that WRAPS onto as many rows as it needs, the current one
66
+ * filled and marked `aria-current`.
67
+ *
68
+ * keksdose's swipe-settings card (swipe-settings-card:152) hand-rolled this: four
69
+ * surface names that do not fit a 406px phone, and a sideways scroll would hide the
70
+ * ones that do not fit behind a gesture nothing announces (live #332: *"Have the tabs
71
+ * in more rows, rather than scrolling sideways"*). It is not {@link Tabs} with `wrap`
72
+ * because its items are not tabs: a `role="tab"` promises a tabpanel, a roving tab
73
+ * stop and arrow keys, and a set of links to pages (or of buttons that switch the rest
74
+ * of a card) promises none of that. So every pill is its own tab stop, the set is a
75
+ * list (a screen reader hears "list, 4 items"), and "current" is `aria-current` — the
76
+ * attribute a link to where you already are carries.
77
+ */
78
+ export function NavPills<T extends string>({
79
+ items,
80
+ current,
81
+ onSelect,
82
+ renderLink,
83
+ currentType,
84
+ size = "md",
85
+ landmark = true,
86
+ itemClassName,
87
+ className,
88
+ ...rest
89
+ }: NavPillsProps<T>) {
90
+ const kitLink = useKitLink();
91
+ const Root = landmark ? "nav" : "div";
92
+ return (
93
+ <Root {...rest} role={landmark ? undefined : "group"} className={cn("min-w-0", className)}>
94
+ <ul className="m-0 flex list-none flex-wrap gap-1 p-0">
95
+ {items.map((item) => {
96
+ const isCurrent = item.value === current;
97
+ const Icon = item.icon;
98
+ const pillClass = cn(
99
+ PILL,
100
+ size === "sm" && PILL_SM,
101
+ isCurrent ? PILL_CURRENT : PILL_IDLE,
102
+ item.disabled && item.href === undefined && PILL_DISABLED,
103
+ itemClassName,
104
+ );
105
+ const children = (
106
+ <>
107
+ {Icon && <Icon aria-hidden />}
108
+ {item.label}
109
+ </>
110
+ );
111
+ let pill: ReactNode;
112
+ if (item.href !== undefined) {
113
+ const props: NavPillLinkProps = {
114
+ href: item.href,
115
+ className: pillClass,
116
+ children,
117
+ "aria-current": isCurrent ? (currentType ?? "page") : undefined,
118
+ onClick: () => onSelect?.(item.value),
119
+ };
120
+ const kit = pickLinkRenderer<NavPillLinkProps>(undefined, kitLink, item.href);
121
+ pill = renderLink ? (
122
+ renderLink(props)
123
+ ) : kit ? (
124
+ <RenderedKitLink render={kit} props={props} />
125
+ ) : (
126
+ <a href={props.href} className={props.className} aria-current={props["aria-current"]} onClick={props.onClick}>
127
+ {props.children}
128
+ </a>
129
+ );
130
+ } else {
131
+ pill = (
132
+ <button
133
+ type="button"
134
+ disabled={item.disabled}
135
+ aria-current={isCurrent ? (currentType ?? "true") : undefined}
136
+ onClick={() => onSelect?.(item.value)}
137
+ className={pillClass}
138
+ >
139
+ {children}
140
+ </button>
141
+ );
142
+ }
143
+ return (
144
+ <li key={item.value} className="flex">
145
+ {pill}
146
+ </li>
147
+ );
148
+ })}
149
+ </ul>
150
+ </Root>
151
+ );
152
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useId, useState } from "react";
1
+ import { useId, useState } from "react";
2
2
  import type { ComponentProps, ReactNode } from "react";
3
3
  import { NumberInput } from "./number-input";
4
4
  import { evaluateExpression, formatResult } from "../lib/calc";
@@ -62,6 +62,14 @@ export interface NumberFieldProps
62
62
  * injects `id` / `aria-describedby` / `aria-invalid`) can wrap this directly. */
63
63
  "aria-describedby"?: string;
64
64
  "aria-invalid"?: boolean | "true" | "false";
65
+ /**
66
+ * Set on the `<input>`. Declared, with `id` (from NumberInput) and the two above, so
67
+ * `Field`'s render-prop spread — `{(ids) => <NumberField {...ids} … />}`, kastlan's
68
+ * wizard steps — is typed and reaches the input rather than being dropped: before
69
+ * this, `aria-required` was not in the type and a required number was announced as
70
+ * optional.
71
+ */
72
+ "aria-required"?: boolean | "true" | "false";
65
73
  }
66
74
 
67
75
  /** The locale's decimal mark, as far as this field can type it: "," or ".". A mark
@@ -163,6 +171,7 @@ export function NumberField({
163
171
  id,
164
172
  "aria-describedby": ariaDescribedBy,
165
173
  "aria-invalid": ariaInvalid,
174
+ "aria-required": ariaRequired,
166
175
  ...rest
167
176
  }: NumberFieldProps) {
168
177
  const locale = useKitLocale(localeProp);
@@ -250,15 +259,9 @@ export function NumberField({
250
259
  const hasError = error !== undefined && error !== null && error !== false && error !== "";
251
260
  const describedBy = [ariaDescribedBy, hasError ? errorId : undefined].filter(Boolean).join(" ");
252
261
 
253
- // NumberInput takes no `aria-describedby` of its own, so the reference is set on
254
- // its <input> directly, found by the id this field hands it. React never renders
255
- // that attribute there, so nothing reconciles it away again.
256
- useEffect(() => {
257
- const input = document.getElementById(fieldId);
258
- if (!input) return;
259
- if (describedBy) input.setAttribute("aria-describedby", describedBy);
260
- else input.removeAttribute("aria-describedby");
261
- }, [fieldId, describedBy]);
262
+ // Straight onto NumberInput's <input>, which declares both now. They used to be set
263
+ // on the DOM node from an effect, because NumberInput had no props for them.
264
+ const required = ariaRequired === true || ariaRequired === "true";
262
265
 
263
266
  const labelWithUnit =
264
267
  unit !== undefined && unitPlacement === "label"
@@ -277,6 +280,8 @@ export function NumberField({
277
280
  <NumberInput
278
281
  {...rest}
279
282
  id={fieldId}
283
+ aria-describedby={describedBy || undefined}
284
+ aria-required={required || undefined}
280
285
  label={labelWithUnit}
281
286
  value={draft}
282
287
  onChange={onText}
@@ -57,6 +57,15 @@ interface NumberInputProps {
57
57
  hint?: ReactNode;
58
58
  /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
59
59
  invalid?: boolean;
60
+ /**
61
+ * Set on the `<input>`, with `id` above. Declared so `Field`'s render-prop spread —
62
+ * `{(ids) => <NumberInput {...ids} … />}` — is typed and reaches the input: the hint
63
+ * and error are described, a required number is announced as required, and
64
+ * `aria-invalid` paints {@link FIELD_INVALID} the way `invalid` does.
65
+ */
66
+ "aria-describedby"?: string;
67
+ "aria-invalid"?: boolean | "true" | "false";
68
+ "aria-required"?: boolean | "true" | "false";
60
69
  /**
61
70
  * A static unit shown at the field's right edge — "%", "kg", "km/h".
62
71
  *
@@ -156,7 +165,10 @@ export function NumberInput({
156
165
  calculator = true,
157
166
  variant = "field",
158
167
  hint,
159
- invalid,
168
+ invalid: invalidProp,
169
+ "aria-describedby": ariaDescribedBy,
170
+ "aria-invalid": ariaInvalid,
171
+ "aria-required": ariaRequired,
160
172
  suffix,
161
173
  step,
162
174
  min,
@@ -164,6 +176,7 @@ export function NumberInput({
164
176
  }: NumberInputProps) {
165
177
  const generatedId = useId();
166
178
  const fieldId = id ?? generatedId;
179
+ const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
167
180
  const labelled = label !== undefined;
168
181
  // On phones we suppress the OS keyboard (inputMode="none" below) for our own
169
182
  // calculator numpad, so the desktop popover trigger is hidden (feedback #334).
@@ -234,6 +247,8 @@ export function NumberInput({
234
247
  }}
235
248
  onKeyDown={onKeyDown}
236
249
  aria-invalid={invalid || undefined}
250
+ aria-describedby={ariaDescribedBy}
251
+ aria-required={ariaRequired}
237
252
  // The end padding sits last so it always wins over an inputClassName that
238
253
  // sets its own px. Both trailing controls can be on at once, so the room
239
254
  // they need is reserved together rather than by whichever happens to render: