@eifi1/ui-kit 0.11.0 → 0.13.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 (397) hide show
  1. package/README.md +59 -34
  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/amount-input.d.ts +40 -4
  12. package/dist/components/amount-input.js +50 -12
  13. package/dist/components/amount-input.js.map +1 -1
  14. package/dist/components/authed-image.d.ts +68 -0
  15. package/dist/components/authed-image.js +117 -0
  16. package/dist/components/authed-image.js.map +1 -0
  17. package/dist/components/breadcrumbs.d.ts +2 -1
  18. package/dist/components/breadcrumbs.js +5 -2
  19. package/dist/components/breadcrumbs.js.map +1 -1
  20. package/dist/components/bulk-action-bar.d.ts +10 -2
  21. package/dist/components/bulk-action-bar.js.map +1 -1
  22. package/dist/components/button-group.d.ts +51 -2
  23. package/dist/components/button-group.js +5 -1
  24. package/dist/components/button-group.js.map +1 -1
  25. package/dist/components/calculator.d.ts +15 -4
  26. package/dist/components/calendar-heatmap.d.ts +41 -3
  27. package/dist/components/calendar-heatmap.js +48 -7
  28. package/dist/components/calendar-heatmap.js.map +1 -1
  29. package/dist/components/chart.js +14 -8
  30. package/dist/components/chart.js.map +1 -1
  31. package/dist/components/chip.d.ts +2 -1
  32. package/dist/components/chip.js +26 -18
  33. package/dist/components/chip.js.map +1 -1
  34. package/dist/components/choice-card.d.ts +35 -3
  35. package/dist/components/choice-card.js +83 -25
  36. package/dist/components/choice-card.js.map +1 -1
  37. package/dist/components/combobox-core.d.ts +3 -1
  38. package/dist/components/combobox-core.js +18 -5
  39. package/dist/components/combobox-core.js.map +1 -1
  40. package/dist/components/combobox.d.ts +2 -2
  41. package/dist/components/combobox.js +31 -9
  42. package/dist/components/combobox.js.map +1 -1
  43. package/dist/components/copy-button.d.ts +51 -70
  44. package/dist/components/copy-button.js +1 -1
  45. package/dist/components/copy-button.js.map +1 -1
  46. package/dist/components/data-table-cells.d.ts +63 -0
  47. package/dist/components/data-table-cells.js +64 -0
  48. package/dist/components/data-table-cells.js.map +1 -0
  49. package/dist/components/data-table-filter-popover.d.ts +1 -1
  50. package/dist/components/data-table-filter-popover.js +8 -1
  51. package/dist/components/data-table-filter-popover.js.map +1 -1
  52. package/dist/components/data-table-filters.d.ts +1 -1
  53. package/dist/components/data-table-filters.js +31 -1
  54. package/dist/components/data-table-filters.js.map +1 -1
  55. package/dist/components/data-table-labels.d.ts +13 -0
  56. package/dist/components/data-table-labels.js +8 -1
  57. package/dist/components/data-table-labels.js.map +1 -1
  58. package/dist/components/data-table.d.ts +1 -1
  59. package/dist/components/data-table.js +125 -27
  60. package/dist/components/data-table.js.map +1 -1
  61. package/dist/components/date-picker.d.ts +3 -1
  62. package/dist/components/date-picker.js +15 -2
  63. package/dist/components/date-picker.js.map +1 -1
  64. package/dist/components/description-list.d.ts +23 -3
  65. package/dist/components/description-list.js +23 -3
  66. package/dist/components/description-list.js.map +1 -1
  67. package/dist/components/dialog-frame.d.ts +1 -0
  68. package/dist/components/entity-combobox.d.ts +1 -1
  69. package/dist/components/entity-combobox.js +40 -20
  70. package/dist/components/entity-combobox.js.map +1 -1
  71. package/dist/components/error-boundary.d.ts +281 -0
  72. package/dist/components/error-boundary.js +302 -0
  73. package/dist/components/error-boundary.js.map +1 -0
  74. package/dist/components/field-sync.js +90 -83
  75. package/dist/components/field-sync.js.map +1 -1
  76. package/dist/components/field.d.ts +30 -4
  77. package/dist/components/field.js +40 -17
  78. package/dist/components/field.js.map +1 -1
  79. package/dist/components/file-button.d.ts +50 -208
  80. package/dist/components/file-dropzone.d.ts +49 -2
  81. package/dist/components/floating-panel.d.ts +13 -4
  82. package/dist/components/floating-panel.js +7 -3
  83. package/dist/components/floating-panel.js.map +1 -1
  84. package/dist/components/form-actions.d.ts +53 -0
  85. package/dist/components/form-actions.js +98 -0
  86. package/dist/components/form-actions.js.map +1 -0
  87. package/dist/components/full-bleed-dialog.js +4 -0
  88. package/dist/components/full-bleed-dialog.js.map +1 -1
  89. package/dist/components/hover-menu.js +64 -56
  90. package/dist/components/hover-menu.js.map +1 -1
  91. package/dist/components/image-grid.d.ts +67 -0
  92. package/dist/components/image-grid.js +112 -0
  93. package/dist/components/image-grid.js.map +1 -0
  94. package/dist/components/lightbox.d.ts +104 -0
  95. package/dist/components/lightbox.js +229 -0
  96. package/dist/components/lightbox.js.map +1 -0
  97. package/dist/components/line-items.d.ts +114 -0
  98. package/dist/components/line-items.js +238 -0
  99. package/dist/components/line-items.js.map +1 -0
  100. package/dist/components/list.d.ts +37 -4
  101. package/dist/components/list.js +35 -14
  102. package/dist/components/list.js.map +1 -1
  103. package/dist/components/loading-state.d.ts +24 -0
  104. package/dist/components/loading-state.js +32 -0
  105. package/dist/components/loading-state.js.map +1 -0
  106. package/dist/components/menu-item.d.ts +2 -1
  107. package/dist/components/menu-item.js +5 -1
  108. package/dist/components/menu-item.js.map +1 -1
  109. package/dist/components/mini-calendar.d.ts +13 -1
  110. package/dist/components/mini-calendar.js +46 -2
  111. package/dist/components/mini-calendar.js.map +1 -1
  112. package/dist/components/modal.d.ts +20 -1
  113. package/dist/components/modal.js +26 -2
  114. package/dist/components/modal.js.map +1 -1
  115. package/dist/components/month-picker.d.ts +21 -1
  116. package/dist/components/month-picker.js +139 -26
  117. package/dist/components/month-picker.js.map +1 -1
  118. package/dist/components/multi-entity-combobox.d.ts +1 -1
  119. package/dist/components/multi-entity-combobox.js +42 -20
  120. package/dist/components/multi-entity-combobox.js.map +1 -1
  121. package/dist/components/nav-pills.d.ts +2 -1
  122. package/dist/components/nav-pills.js +5 -1
  123. package/dist/components/nav-pills.js.map +1 -1
  124. package/dist/components/number-field.d.ts +24 -5
  125. package/dist/components/number-field.js +5 -7
  126. package/dist/components/number-field.js.map +1 -1
  127. package/dist/components/number-input.d.ts +25 -5
  128. package/dist/components/number-input.js +7 -1
  129. package/dist/components/number-input.js.map +1 -1
  130. package/dist/components/numpad-sheet.d.ts +20 -5
  131. package/dist/components/numpad-sheet.js +3 -2
  132. package/dist/components/numpad-sheet.js.map +1 -1
  133. package/dist/components/page-header.d.ts +35 -6
  134. package/dist/components/page-header.js +7 -1
  135. package/dist/components/page-header.js.map +1 -1
  136. package/dist/components/passkeys-setting.d.ts +67 -0
  137. package/dist/components/passkeys-setting.js +214 -0
  138. package/dist/components/passkeys-setting.js.map +1 -0
  139. package/dist/components/picker-sheet.js +5 -1
  140. package/dist/components/picker-sheet.js.map +1 -1
  141. package/dist/components/pie-chart-labels.d.ts +20 -0
  142. package/dist/components/pie-chart-labels.js +12 -0
  143. package/dist/components/pie-chart-labels.js.map +1 -0
  144. package/dist/components/pie-chart.d.ts +101 -0
  145. package/dist/components/pie-chart.js +283 -0
  146. package/dist/components/pie-chart.js.map +1 -0
  147. package/dist/components/popover.js +3 -0
  148. package/dist/components/popover.js.map +1 -1
  149. package/dist/components/progress-bar.d.ts +44 -3
  150. package/dist/components/progress-bar.js +58 -22
  151. package/dist/components/progress-bar.js.map +1 -1
  152. package/dist/components/qr-code.d.ts +34 -0
  153. package/dist/components/qr-code.js +57 -0
  154. package/dist/components/qr-code.js.map +1 -0
  155. package/dist/components/search-field.d.ts +15 -3
  156. package/dist/components/search-field.js +5 -1
  157. package/dist/components/search-field.js.map +1 -1
  158. package/dist/components/settings-fields.d.ts +49 -1
  159. package/dist/components/signed-amount.d.ts +122 -0
  160. package/dist/components/signed-amount.js +152 -0
  161. package/dist/components/signed-amount.js.map +1 -0
  162. package/dist/components/skeleton.d.ts +10 -3
  163. package/dist/components/skeleton.js +12 -2
  164. package/dist/components/skeleton.js.map +1 -1
  165. package/dist/components/stat-tile.d.ts +2 -1
  166. package/dist/components/stat-tile.js +6 -3
  167. package/dist/components/stat-tile.js.map +1 -1
  168. package/dist/components/swipeable-row.js +4 -3
  169. package/dist/components/swipeable-row.js.map +1 -1
  170. package/dist/components/table.d.ts +39 -5
  171. package/dist/components/table.js +45 -11
  172. package/dist/components/table.js.map +1 -1
  173. package/dist/components/text-link.d.ts +238 -0
  174. package/dist/components/text-link.js +144 -0
  175. package/dist/components/text-link.js.map +1 -0
  176. package/dist/components/text.d.ts +26 -2
  177. package/dist/components/text.js +16 -2
  178. package/dist/components/text.js.map +1 -1
  179. package/dist/components/time-input.d.ts +49 -1
  180. package/dist/components/toggle-group.d.ts +35 -0
  181. package/dist/components/toggle-group.js +43 -14
  182. package/dist/components/toggle-group.js.map +1 -1
  183. package/dist/components/toggle-legend.d.ts +53 -3
  184. package/dist/components/toggle-legend.js +54 -1
  185. package/dist/components/toggle-legend.js.map +1 -1
  186. package/dist/components/tooltip.d.ts +51 -1
  187. package/dist/components/tooltip.js +59 -35
  188. package/dist/components/tooltip.js.map +1 -1
  189. package/dist/components/ui.d.ts +51 -779
  190. package/dist/components/ui.js +364 -34
  191. package/dist/components/ui.js.map +1 -1
  192. package/dist/components/use-table-state.d.ts +3 -52
  193. package/dist/components/use-table-state.js +128 -30
  194. package/dist/components/use-table-state.js.map +1 -1
  195. package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
  196. package/dist/data-table.d.ts +2 -1
  197. package/dist/data-table.js +5 -1
  198. package/dist/data-table.js.map +1 -1
  199. package/dist/feedback/feedback-attachment.js +4 -6
  200. package/dist/feedback/feedback-attachment.js.map +1 -1
  201. package/dist/feedback/feedback-dialog.d.ts +26 -3
  202. package/dist/feedback/feedback-dialog.js +21 -5
  203. package/dist/feedback/feedback-dialog.js.map +1 -1
  204. package/dist/feedback/feedback-inbox.js +86 -75
  205. package/dist/feedback/feedback-inbox.js.map +1 -1
  206. package/dist/feedback/feedback-thread.d.ts +173 -0
  207. package/dist/feedback/feedback-thread.js +264 -0
  208. package/dist/feedback/feedback-thread.js.map +1 -0
  209. package/dist/feedback.d.ts +2 -1
  210. package/dist/feedback.js +1 -0
  211. package/dist/feedback.js.map +1 -1
  212. package/dist/hooks/use-authed-src.d.ts +71 -0
  213. package/dist/hooks/use-authed-src.js +63 -0
  214. package/dist/hooks/use-authed-src.js.map +1 -0
  215. package/dist/hooks/use-file-drop.d.ts +49 -2
  216. package/dist/hooks/use-hotkey.d.ts +59 -0
  217. package/dist/hooks/use-hotkey.js +105 -0
  218. package/dist/hooks/use-hotkey.js.map +1 -0
  219. package/dist/hooks/use-search-param-state.d.ts +83 -0
  220. package/dist/hooks/use-search-param-state.js +94 -0
  221. package/dist/hooks/use-search-param-state.js.map +1 -0
  222. package/dist/i18n/defaults.d.ts +15 -4
  223. package/dist/i18n/defaults.js +31 -1
  224. package/dist/i18n/defaults.js.map +1 -1
  225. package/dist/i18n/kit-labels.d.ts +46 -312
  226. package/dist/i18n/kit-labels.js +20 -5
  227. package/dist/i18n/kit-labels.js.map +1 -1
  228. package/dist/i18n/locales/de-CH-informal.d.ts +15 -4
  229. package/dist/i18n/locales/de-CH.d.ts +15 -4
  230. package/dist/i18n/locales/de-informal.d.ts +15 -4
  231. package/dist/i18n/locales/de.d.ts +15 -4
  232. package/dist/i18n/locales/de.js +164 -3
  233. package/dist/i18n/locales/de.js.map +1 -1
  234. package/dist/i18n/locales/es.d.ts +15 -4
  235. package/dist/i18n/locales/es.js +161 -3
  236. package/dist/i18n/locales/es.js.map +1 -1
  237. package/dist/i18n/locales/fr.d.ts +15 -4
  238. package/dist/i18n/locales/fr.js +161 -3
  239. package/dist/i18n/locales/fr.js.map +1 -1
  240. package/dist/i18n/locales/hu.d.ts +15 -4
  241. package/dist/i18n/locales/hu.js +161 -3
  242. package/dist/i18n/locales/hu.js.map +1 -1
  243. package/dist/i18n/locales/it.d.ts +15 -4
  244. package/dist/i18n/locales/it.js +161 -3
  245. package/dist/i18n/locales/it.js.map +1 -1
  246. package/dist/i18n/locales/zh.d.ts +15 -4
  247. package/dist/i18n/locales/zh.js +161 -3
  248. package/dist/i18n/locales/zh.js.map +1 -1
  249. package/dist/index.d.ts +39 -19
  250. package/dist/index.js +40 -1
  251. package/dist/index.js.map +1 -1
  252. package/dist/kit-labels-Mz8zZHIF.d.ts +1744 -0
  253. package/dist/lib/format.d.ts +162 -0
  254. package/dist/lib/format.js +151 -0
  255. package/dist/lib/format.js.map +1 -0
  256. package/dist/lib/qr-encode.d.ts +44 -0
  257. package/dist/lib/qr-encode.js +338 -0
  258. package/dist/lib/qr-encode.js.map +1 -0
  259. package/dist/rhf/fields.d.ts +283 -0
  260. package/dist/rhf/fields.js +631 -0
  261. package/dist/rhf/fields.js.map +1 -0
  262. package/dist/rhf/form.d.ts +49 -1
  263. package/dist/rhf/line-items.d.ts +31 -0
  264. package/dist/rhf/line-items.js +59 -0
  265. package/dist/rhf/line-items.js.map +1 -0
  266. package/dist/rhf.d.ts +61 -1
  267. package/dist/rhf.js +2 -0
  268. package/dist/rhf.js.map +1 -1
  269. package/dist/search/command-palette.js +20 -14
  270. package/dist/search/command-palette.js.map +1 -1
  271. package/dist/shell/app-shell.d.ts +99 -2
  272. package/dist/shell/app-shell.js +120 -57
  273. package/dist/shell/app-shell.js.map +1 -1
  274. package/dist/shell/auth-layout.d.ts +74 -0
  275. package/dist/shell/auth-layout.js +97 -0
  276. package/dist/shell/auth-layout.js.map +1 -0
  277. package/dist/shell/top-bar-brand.d.ts +81 -0
  278. package/dist/shell/top-bar-brand.js +47 -0
  279. package/dist/shell/top-bar-brand.js.map +1 -0
  280. package/dist/shell/topbar-action-menu.d.ts +21 -4
  281. package/dist/shell/topbar-action-menu.js +18 -2
  282. package/dist/shell/topbar-action-menu.js.map +1 -1
  283. package/dist/shell.d.ts +56 -1
  284. package/dist/shell.js +2 -0
  285. package/dist/shell.js.map +1 -1
  286. package/dist/tour/tour.js +10 -4
  287. package/dist/tour/tour.js.map +1 -1
  288. package/dist/wizard/stepper-nav.d.ts +48 -1
  289. package/dist/wizard/use-wizard.js +4 -2
  290. package/dist/wizard/use-wizard.js.map +1 -1
  291. package/dist/wizard/wizard-context.d.ts +24 -1
  292. package/dist/wizard/wizard-context.js +19 -2
  293. package/dist/wizard/wizard-context.js.map +1 -1
  294. package/dist/wizard/wizard-step.d.ts +30 -5
  295. package/dist/wizard/wizard-step.js +34 -3
  296. package/dist/wizard/wizard-step.js.map +1 -1
  297. package/dist/wizard.d.ts +50 -3
  298. package/package.json +6 -6
  299. package/src/chart.ts +4 -0
  300. package/src/components/account-settings-labels.ts +190 -0
  301. package/src/components/account-settings.tsx +110 -63
  302. package/src/components/amount-input.tsx +109 -10
  303. package/src/components/authed-image.tsx +190 -0
  304. package/src/components/breadcrumbs.tsx +8 -2
  305. package/src/components/bulk-action-bar.tsx +16 -3
  306. package/src/components/button-group.tsx +7 -2
  307. package/src/components/calendar-heatmap.tsx +105 -10
  308. package/src/components/chart.tsx +19 -8
  309. package/src/components/chip.tsx +14 -4
  310. package/src/components/choice-card.tsx +136 -25
  311. package/src/components/combobox-core.tsx +33 -9
  312. package/src/components/combobox.tsx +33 -9
  313. package/src/components/copy-button.tsx +3 -2
  314. package/src/components/data-table-cells.tsx +119 -0
  315. package/src/components/data-table-filter-popover.tsx +13 -5
  316. package/src/components/data-table-filters.ts +77 -0
  317. package/src/components/data-table-labels.ts +23 -0
  318. package/src/components/data-table.tsx +230 -13
  319. package/src/components/date-picker.tsx +19 -3
  320. package/src/components/description-list.tsx +52 -3
  321. package/src/components/entity-combobox.tsx +25 -7
  322. package/src/components/error-boundary.tsx +640 -0
  323. package/src/components/field-sync.tsx +48 -40
  324. package/src/components/field.tsx +56 -5
  325. package/src/components/floating-panel.tsx +20 -7
  326. package/src/components/form-actions.tsx +191 -0
  327. package/src/components/full-bleed-dialog.tsx +4 -0
  328. package/src/components/hover-menu.tsx +26 -17
  329. package/src/components/image-grid.tsx +181 -0
  330. package/src/components/lightbox.tsx +374 -0
  331. package/src/components/line-items.tsx +385 -0
  332. package/src/components/list.tsx +82 -19
  333. package/src/components/loading-state.tsx +47 -0
  334. package/src/components/menu-item.tsx +7 -2
  335. package/src/components/mini-calendar.tsx +72 -3
  336. package/src/components/modal.tsx +65 -3
  337. package/src/components/month-picker.tsx +181 -26
  338. package/src/components/multi-entity-combobox.tsx +27 -7
  339. package/src/components/nav-pills.tsx +8 -1
  340. package/src/components/number-field.tsx +15 -10
  341. package/src/components/number-input.tsx +17 -1
  342. package/src/components/numpad-sheet.tsx +6 -1
  343. package/src/components/page-header.tsx +42 -5
  344. package/src/components/passkeys-setting.tsx +331 -0
  345. package/src/components/picker-sheet.tsx +10 -1
  346. package/src/components/pie-chart-labels.ts +32 -0
  347. package/src/components/pie-chart.tsx +479 -0
  348. package/src/components/popover.tsx +3 -0
  349. package/src/components/progress-bar.tsx +129 -19
  350. package/src/components/qr-code.tsx +83 -0
  351. package/src/components/search-field.tsx +20 -4
  352. package/src/components/signed-amount.tsx +295 -0
  353. package/src/components/skeleton.tsx +21 -3
  354. package/src/components/stat-tile.tsx +11 -3
  355. package/src/components/swipeable-row.tsx +7 -7
  356. package/src/components/table.tsx +102 -13
  357. package/src/components/text-link.tsx +356 -0
  358. package/src/components/text.tsx +46 -2
  359. package/src/components/toggle-group.tsx +93 -20
  360. package/src/components/toggle-legend.tsx +108 -2
  361. package/src/components/tooltip.tsx +79 -8
  362. package/src/components/ui.tsx +707 -49
  363. package/src/components/use-table-state.ts +265 -33
  364. package/src/data-table.ts +10 -0
  365. package/src/feedback/feedback-attachment.tsx +10 -7
  366. package/src/feedback/feedback-dialog.tsx +49 -7
  367. package/src/feedback/feedback-inbox.tsx +7 -0
  368. package/src/feedback/feedback-thread.tsx +510 -0
  369. package/src/feedback.ts +1 -0
  370. package/src/hooks/use-authed-src.ts +161 -0
  371. package/src/hooks/use-hotkey.ts +172 -0
  372. package/src/hooks/use-search-param-state.ts +187 -0
  373. package/src/i18n/defaults.ts +30 -0
  374. package/src/i18n/kit-labels.tsx +118 -8
  375. package/src/i18n/locales/de.ts +161 -0
  376. package/src/i18n/locales/es.ts +158 -0
  377. package/src/i18n/locales/fr.ts +161 -0
  378. package/src/i18n/locales/hu.ts +159 -0
  379. package/src/i18n/locales/it.ts +159 -0
  380. package/src/i18n/locales/zh.ts +158 -0
  381. package/src/index.ts +59 -0
  382. package/src/lib/format.ts +348 -0
  383. package/src/lib/qr-encode.ts +420 -0
  384. package/src/rhf/fields.tsx +1135 -0
  385. package/src/rhf/line-items.tsx +130 -0
  386. package/src/rhf.ts +6 -0
  387. package/src/search/command-palette.tsx +29 -19
  388. package/src/shell/app-shell.tsx +153 -3
  389. package/src/shell/auth-layout.tsx +190 -0
  390. package/src/shell/top-bar-brand.tsx +76 -0
  391. package/src/shell/topbar-action-menu.tsx +32 -4
  392. package/src/shell.ts +2 -0
  393. package/src/tour/tour.tsx +16 -5
  394. package/src/wizard/use-wizard.ts +8 -4
  395. package/src/wizard/wizard-context.tsx +41 -1
  396. package/src/wizard/wizard-step.tsx +75 -6
  397. package/tokens.css +12 -0
@@ -146,35 +146,40 @@ export function useFieldSync<T>({
146
146
  };
147
147
  }, []);
148
148
 
149
- const flush = useCallback(async () => {
150
- if (inFlightRef.current) return; // the settle path below picks the new draft up
151
- const next = draftRef.current;
152
- if (equalsRef.current(next, savedRef.current)) return;
153
-
154
- inFlightRef.current = true;
155
- setState("pending");
156
- setError(null);
157
- try {
158
- await onSaveRef.current(next);
159
- savedRef.current = next;
160
- inFlightRef.current = false;
161
- if (!mountedRef.current) return;
162
- // The draft may have moved while this was in the air. Compare against what is
163
- // on screen NOW, not against what was sent.
164
- if (equalsRef.current(draftRef.current, next)) {
165
- setState("synced");
166
- } else {
167
- setState("edited");
168
- void flush();
149
+ // A named inner function so the settle path can start the next round by calling it
150
+ // again; a `useCallback` cannot refer to its own binding while it is being built.
151
+ const flush = useCallback(() => {
152
+ const run = async (): Promise<void> => {
153
+ if (inFlightRef.current) return; // the settle path below picks the new draft up
154
+ const next = draftRef.current;
155
+ if (equalsRef.current(next, savedRef.current)) return;
156
+
157
+ inFlightRef.current = true;
158
+ setState("pending");
159
+ setError(null);
160
+ try {
161
+ await onSaveRef.current(next);
162
+ savedRef.current = next;
163
+ inFlightRef.current = false;
164
+ if (!mountedRef.current) return;
165
+ // The draft may have moved while this was in the air. Compare against what is
166
+ // on screen NOW, not against what was sent.
167
+ if (equalsRef.current(draftRef.current, next)) {
168
+ setState("synced");
169
+ } else {
170
+ setState("edited");
171
+ void run();
172
+ }
173
+ } catch (cause) {
174
+ inFlightRef.current = false;
175
+ const err = toError(cause);
176
+ onErrorRef.current?.(err);
177
+ if (!mountedRef.current) return;
178
+ setState("error");
179
+ setError(err);
169
180
  }
170
- } catch (cause) {
171
- inFlightRef.current = false;
172
- const err = toError(cause);
173
- onErrorRef.current?.(err);
174
- if (!mountedRef.current) return;
175
- setState("error");
176
- setError(err);
177
- }
181
+ };
182
+ return run();
178
183
  }, []);
179
184
 
180
185
  const setValue = useCallback(
@@ -396,23 +401,26 @@ export function FieldSyncRow<T>({
396
401
  const retry = onRetry ?? sync.retry;
397
402
 
398
403
  // The saved confirmation: on only for the moment after a save lands. Keyed on the
399
- // TRANSITION pending → synced, so a field that starts synced shows nothing.
400
- const previous = useRef(state);
404
+ // TRANSITION pending → synced, so a field that starts synced shows nothing. The
405
+ // transition is seen while rendering, against the previous state; the effect only
406
+ // owns the timer that turns it off again.
407
+ const [previous, setPrevious] = useState(state);
401
408
  const [justSaved, setJustSaved] = useState(false);
409
+ if (state !== previous) {
410
+ setPrevious(state);
411
+ setJustSaved(state === "synced" && previous === "pending");
412
+ }
402
413
  useEffect(() => {
403
- const was = previous.current;
404
- previous.current = state;
405
- if (state === "synced" && was === "pending") {
406
- // eslint-disable-next-line react-hooks/set-state-in-effect -- reacts to the save landing
407
- setJustSaved(true);
408
- const t = setTimeout(() => setJustSaved(false), savedMs);
409
- return () => clearTimeout(t);
410
- }
411
- if (state !== "synced") setJustSaved(false);
412
- }, [state, savedMs]);
414
+ if (!justSaved) return;
415
+ const t = setTimeout(() => setJustSaved(false), savedMs);
416
+ return () => clearTimeout(t);
417
+ }, [justSaved, savedMs]);
413
418
  const shown: FieldSyncState | null = state === "synced" ? (justSaved ? "synced" : null) : state;
414
419
 
415
420
  return (
421
+ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- not an interaction:
422
+ `onBlur` listens for focus leaving the row's own controls (which are the
423
+ interactive elements) to save; the wrapper itself is never focused. */
416
424
  <div
417
425
  data-field-sync={state}
418
426
  className={cn(
@@ -55,6 +55,40 @@ export interface FieldControlProps {
55
55
  "aria-required": true | undefined;
56
56
  }
57
57
 
58
+ /**
59
+ * The second argument of a `Field`'s render-prop children: what a GROUP needs that
60
+ * a single control does not. `htmlFor` names an `<input>`, but a `<label>` pointing
61
+ * at a `role="radiogroup"` div names nothing — so a group (a {@link ToggleGroup})
62
+ * takes `aria-labelledby={labelId}` instead. Kept out of {@link FieldControlProps}
63
+ * so the spread every existing control takes is unchanged.
64
+ */
65
+ export interface FieldRenderMeta {
66
+ /** The label element's id, or `undefined` when the field has no label. */
67
+ labelId: string | undefined;
68
+ }
69
+
70
+ /**
71
+ * Where the label is visible. `"visible"` (default); `"sr-only"`: never drawn, still
72
+ * the control's name; `"below-sm"` / `"below-md"` / `"below-lg"`: drawn only under
73
+ * that breakpoint and screen-reader-only from it up.
74
+ *
75
+ * For table-like editors — kastlan's journal entry lines
76
+ * (accounting/components/wizard/journal-entry-lines-step.tsx) — where a desktop
77
+ * row sits under column headers that already say "Account" and "Amount", but the
78
+ * phone layout stacks each line as a card and needs the labels back. The name stays
79
+ * on the control at every width; only the ink changes.
80
+ */
81
+ export type FieldLabelVisibility = "visible" | "sr-only" | "below-sm" | "below-md" | "below-lg";
82
+
83
+ // Literal class strings so Tailwind's scanner sees each one.
84
+ const LABEL_VISIBILITY: Record<FieldLabelVisibility, string | undefined> = {
85
+ visible: undefined,
86
+ "sr-only": "sr-only",
87
+ "below-sm": "sm:sr-only",
88
+ "below-md": "md:sr-only",
89
+ "below-lg": "lg:sr-only",
90
+ };
91
+
58
92
  export interface FieldProps {
59
93
  /** The label above the control. Omit it for a group whose control is labelled
60
94
  * some other way; the hint and error still render. */
@@ -74,10 +108,13 @@ export interface FieldProps {
74
108
  disabled?: boolean;
75
109
  /** The label's size: `sm` for a dense row, `md` otherwise. */
76
110
  labelSize?: "sm" | "md";
111
+ /** See {@link FieldLabelVisibility}. */
112
+ labelVisibility?: FieldLabelVisibility;
77
113
  /** Wrapper className. */
78
114
  className?: string;
79
- /** The control. A function receives {@link FieldControlProps} to spread on it. */
80
- children: ReactNode | ((control: FieldControlProps) => ReactNode);
115
+ /** The control. A function receives {@link FieldControlProps} to spread on it, and
116
+ * {@link FieldRenderMeta} (the label's id, for a group). */
117
+ children: ReactNode | ((control: FieldControlProps, meta: FieldRenderMeta) => ReactNode);
81
118
  }
82
119
 
83
120
  const isShown = (node: ReactNode) =>
@@ -91,6 +128,7 @@ export function Field({
91
128
  htmlFor,
92
129
  disabled,
93
130
  labelSize,
131
+ labelVisibility = "visible",
94
132
  className,
95
133
  children,
96
134
  }: FieldProps) {
@@ -98,6 +136,7 @@ export function Field({
98
136
  const id = htmlFor ?? `${generated}-control`;
99
137
  const hintId = `${generated}-hint`;
100
138
  const errorId = `${generated}-error`;
139
+ const labelId = `${generated}-label`;
101
140
  const hasHint = isShown(hint);
102
141
  const hasError = isShown(error);
103
142
  const describedBy = [hasHint && hintId, hasError && errorId].filter(Boolean).join(" ");
@@ -108,20 +147,32 @@ export function Field({
108
147
  "aria-required": required || undefined,
109
148
  };
110
149
  return (
111
- <div data-slot="field" className={cn("grid gap-1.5", className)}>
150
+ <div
151
+ data-slot="field"
152
+ className={cn(
153
+ "grid gap-1.5",
154
+ // A hidden label is `position: absolute`; `relative` keeps its containing
155
+ // block here rather than the page's (see sr-only-containment.test).
156
+ labelVisibility !== "visible" && "relative",
157
+ className,
158
+ )}
159
+ >
112
160
  {label !== undefined && (
113
161
  <Label
162
+ id={labelId}
114
163
  htmlFor={id}
115
164
  required={required}
116
165
  disabled={disabled}
117
166
  size={labelSize}
118
167
  data-error={hasError || undefined}
119
- className="data-[error=true]:text-[var(--danger)]"
168
+ className={cn("data-[error=true]:text-[var(--danger)]", LABEL_VISIBILITY[labelVisibility])}
120
169
  >
121
170
  {label}
122
171
  </Label>
123
172
  )}
124
- {typeof children === "function" ? children(control) : children}
173
+ {typeof children === "function"
174
+ ? children(control, { labelId: label !== undefined ? labelId : undefined })
175
+ : children}
125
176
  {hasHint && (
126
177
  <p id={hintId} className="text-[11px] leading-tight text-[var(--text-muted)]">
127
178
  {hint}
@@ -15,7 +15,8 @@ import { X } from "lucide-react";
15
15
 
16
16
  import { cn } from "../lib/cn";
17
17
  import { dirOf, type Direction } from "../lib/direction";
18
- import { useKitLabels } from "../i18n/kit-labels";
18
+ import { useKitLabels, useKitLink } from "../i18n/kit-labels";
19
+ import { pickLinkRenderer } from "./text-link";
19
20
  import { Tooltip, type TooltipSide } from "./tooltip";
20
21
 
21
22
  export interface FloatingPanelLabels {
@@ -588,8 +589,17 @@ export interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButto
588
589
  label: string;
589
590
  icon: ReactNode;
590
591
  /** Make it a toggle: `aria-pressed` and the brand "on" look (the brand glyph on the
591
- * quiet brand fill), as {@link FloatingActionButtonProps.pressed}. Ignored on a link. */
592
+ * quiet brand fill), as {@link FloatingActionButtonProps.pressed}. Ignored on a link.
593
+ * For the state without the tint, see {@link pressedStyle}. */
592
594
  pressed?: boolean;
595
+ /**
596
+ * How `pressed` looks. `tint` (default): the brand "on" look. `plain`: the member
597
+ * keeps its quiet look in both states and only `aria-pressed` changes — for a toggle
598
+ * whose icon already says which state it is in (keksdose's /transactions "Upcoming"
599
+ * swaps two icons; a tint on top said it twice). The same as passing `aria-pressed`
600
+ * without `pressed`, which also rides through untinted.
601
+ */
602
+ pressedStyle?: "tint" | "plain";
593
603
  /**
594
604
  * `primary`: the brand-filled member, for the group's main action (the /transactions
595
605
  * "+"). Default: the quiet one.
@@ -611,8 +621,8 @@ export interface FloatingActionProps extends Omit<ButtonHTMLAttributes<HTMLButto
611
621
  tooltip?: boolean;
612
622
  /** Where the tooltip opens. Default `top` — the group sits at the bottom of the screen. */
613
623
  tooltipSide?: TooltipSide;
614
- /** Make it a link — the /transactions invoices member. A plain `<a>` unless
615
- * `renderLink` hands it to your router. */
624
+ /** Make it a link — the /transactions invoices member. Drawn by `renderLink`, else
625
+ * the `<UiKitProvider linkComponent>` (an in-app `href`), else a plain `<a>`. */
616
626
  href?: string;
617
627
  /** Render the link through your router's `Link` — see {@link FloatingActionLinkProps}. */
618
628
  renderLink?: (props: FloatingActionLinkProps) => ReactElement;
@@ -637,6 +647,7 @@ export function FloatingAction({
637
647
  label,
638
648
  icon,
639
649
  pressed,
650
+ pressedStyle = "tint",
640
651
  variant = "default",
641
652
  badge,
642
653
  badgeLabel,
@@ -652,6 +663,7 @@ export function FloatingAction({
652
663
  ...rest
653
664
  }: FloatingActionProps) {
654
665
  const labels = useKitLabels("floatingPanel", DEFAULT_FLOATING_PANEL_LABELS);
666
+ const kitLink = useKitLink();
655
667
  const counted = badge !== undefined && badge > 0;
656
668
  const name = counted ? `${label}, ${badgeLabel ?? labels.badge(badge)}` : label;
657
669
  const isLink = href !== undefined;
@@ -661,7 +673,7 @@ export function FloatingAction({
661
673
  "disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-5",
662
674
  variant === "primary"
663
675
  ? "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]"
664
- : pressed && !isLink
676
+ : pressed && !isLink && pressedStyle === "tint"
665
677
  ? "bg-[var(--brand-bg)] text-[var(--brand)] hover:bg-[var(--brand-bg-hover)]"
666
678
  : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]",
667
679
  className,
@@ -697,8 +709,9 @@ export function FloatingAction({
697
709
  children: body,
698
710
  };
699
711
  const { children: content, ...anchor } = linkProps;
700
- member = renderLink ? (
701
- <RenderedFloatingLink render={renderLink} {...linkProps} />
712
+ const render = pickLinkRenderer(renderLink, kitLink, href);
713
+ member = render ? (
714
+ <RenderedFloatingLink render={render} {...linkProps} />
702
715
  ) : (
703
716
  <a {...anchor}>{content}</a>
704
717
  );
@@ -0,0 +1,191 @@
1
+ import { isValidElement } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { Button, Spinner, type ButtonVariant } from "./ui";
6
+
7
+ /** The words {@link FormActions} renders on its own behalf — the `form` namespace. */
8
+ export interface FormActionsLabels {
9
+ save: string;
10
+ cancel: string;
11
+ }
12
+
13
+ export const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {
14
+ save: "Save",
15
+ cancel: "Cancel",
16
+ };
17
+
18
+ export type FormActionsAlign = "start" | "center" | "end" | "between";
19
+
20
+ /**
21
+ * Where the row sits:
22
+ * - `inline` (default): under the form's last field, `pt-4` above it.
23
+ * - `sticky`: stuck to the bottom of the scrolling page or pane, on the surface
24
+ * colour with a rule above, clear of the phone's home indicator — for a long form
25
+ * whose Save should not be a scroll away.
26
+ * - `dialog`: no spacing of its own, for a dialog's footer slot (DialogFrame,
27
+ * FullBleedDialog's `footer`), which already pads and rules it.
28
+ */
29
+ export type FormActionsPlacement = "inline" | "sticky" | "dialog";
30
+
31
+ /** The start-side action of {@link FormActionsProps.destructive}, as data. */
32
+ export interface FormActionsDestructive {
33
+ label: ReactNode;
34
+ onClick: () => void;
35
+ disabled?: boolean;
36
+ /** See Button's `disabledReason`. */
37
+ disabledReason?: ReactNode;
38
+ }
39
+
40
+ const ALIGN_CLASS: Record<FormActionsAlign, string> = {
41
+ start: "justify-start",
42
+ center: "justify-center",
43
+ end: "justify-end",
44
+ between: "justify-between",
45
+ };
46
+
47
+ const PLACEMENT_CLASS: Record<FormActionsPlacement, string> = {
48
+ inline: "pt-4",
49
+ sticky: "sticky bottom-0 z-10 border-t border-[var(--border)] bg-[var(--bg-surface)] pt-3",
50
+ dialog: "",
51
+ };
52
+
53
+ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
54
+ /** Called by Cancel. Omitted: no Cancel button (a form that only saves). */
55
+ onCancel?: () => void;
56
+ /**
57
+ * Given: the save button is `type="button"` and calls this — for a dialog or a row
58
+ * that saves from a handler. Omitted: it is `type="submit"` and submits the
59
+ * enclosing `<form>` (or the one named by {@link form}).
60
+ */
61
+ onSubmit?: () => void;
62
+ /** The save button's text. Default `form.save` from the provider, else "Save". */
63
+ submitLabel?: ReactNode;
64
+ /** The cancel button's text. Default `form.cancel` from the provider, else "Cancel". */
65
+ cancelLabel?: ReactNode;
66
+ /** The save button's text while `pending` ("Saving…"). Default: `submitLabel`. */
67
+ pendingLabel?: ReactNode;
68
+ /**
69
+ * The save is running: a spinner in the save button, `aria-busy` on it, and the
70
+ * button disabled, so a second click cannot send the form twice. Cancel stays
71
+ * usable.
72
+ */
73
+ pending?: boolean;
74
+ /** Disable save for a reason of the form's own (an unbalanced entry). */
75
+ submitDisabled?: boolean;
76
+ /** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */
77
+ submitDisabledReason?: ReactNode;
78
+ /** The save button's variant. Default `brand`; `danger` for a save that destroys
79
+ * (kastlan's `destructive` flag on its own FormActions). */
80
+ submitVariant?: ButtonVariant;
81
+ /**
82
+ * A destructive action at the START of the row, apart from Save and Cancel — the
83
+ * "Delete" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger
84
+ * button, or an element of your own (a {@link DangerConfirm}, say). With it the row
85
+ * is `between`-aligned unless `align` says otherwise.
86
+ */
87
+ destructive?: ReactNode | FormActionsDestructive;
88
+ /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */
89
+ align?: FormActionsAlign;
90
+ /** See {@link FormActionsPlacement}. Default `inline`. */
91
+ placement?: FormActionsPlacement;
92
+ /** The `id` of the `<form>` the save button submits, when the row is rendered
93
+ * outside it (a dialog's footer slot). */
94
+ form?: string;
95
+ /** Extra actions, placed before Cancel. */
96
+ children?: ReactNode;
97
+ }
98
+
99
+ function isDestructiveData(value: unknown): value is FormActionsDestructive {
100
+ return typeof value === "object" && value !== null && !isValidElement(value) && "onClick" in value;
101
+ }
102
+
103
+ /**
104
+ * The Save / Cancel row every form, wizard-step form and dialog ends in — kastlan's
105
+ * `FormActions`, with the labels from the provider, a pending state and a place for
106
+ * the destructive action.
107
+ *
108
+ * ```tsx
109
+ * <form onSubmit={form.handleSubmit(save)}>
110
+ * …
111
+ * <FormActions onCancel={close} pending={mutation.isPending} />
112
+ * </form>
113
+ * ```
114
+ *
115
+ * The spinner is the kit's {@link Spinner}, drawn inside the button with `label={null}`
116
+ * so the button's name stays its text; `aria-busy` is what says it is working.
117
+ */
118
+ export function FormActions({
119
+ onCancel,
120
+ onSubmit,
121
+ submitLabel,
122
+ cancelLabel,
123
+ pendingLabel,
124
+ pending = false,
125
+ submitDisabled = false,
126
+ submitDisabledReason,
127
+ submitVariant = "brand",
128
+ destructive,
129
+ align,
130
+ placement = "inline",
131
+ form,
132
+ children,
133
+ className,
134
+ style,
135
+ ...rest
136
+ }: FormActionsProps) {
137
+ const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
138
+ const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
139
+ const justify = align ?? (hasDestructive ? "between" : "end");
140
+ const start = !hasDestructive ? null : isDestructiveData(destructive) ? (
141
+ <Button
142
+ type="button"
143
+ variant="ghost"
144
+ tone="danger"
145
+ onClick={destructive.onClick}
146
+ disabled={destructive.disabled}
147
+ disabledReason={destructive.disabledReason}
148
+ >
149
+ {destructive.label}
150
+ </Button>
151
+ ) : (
152
+ destructive
153
+ );
154
+ const saveLabel = submitLabel ?? labels.save;
155
+
156
+ return (
157
+ <div
158
+ data-slot="form-actions"
159
+ data-placement={placement}
160
+ {...rest}
161
+ style={
162
+ placement === "sticky"
163
+ ? { paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))", ...style }
164
+ : style
165
+ }
166
+ className={cn("flex flex-wrap items-center gap-2", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}
167
+ >
168
+ {start && <div className="me-auto flex items-center gap-2">{start}</div>}
169
+ <div className="flex flex-wrap items-center gap-2">
170
+ {children}
171
+ {onCancel && (
172
+ <Button type="button" variant="secondary" onClick={onCancel}>
173
+ {cancelLabel ?? labels.cancel}
174
+ </Button>
175
+ )}
176
+ <Button
177
+ type={onSubmit ? "button" : "submit"}
178
+ form={form}
179
+ onClick={onSubmit}
180
+ variant={submitVariant}
181
+ disabled={pending || submitDisabled}
182
+ disabledReason={pending ? undefined : submitDisabledReason}
183
+ aria-busy={pending || undefined}
184
+ >
185
+ {pending && <Spinner label={null} className="size-4" />}
186
+ {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}
187
+ </Button>
188
+ </div>
189
+ </div>
190
+ );
191
+ }
@@ -169,6 +169,10 @@ export function FullBleedDialog({
169
169
  if (!open || typeof document === "undefined") return null;
170
170
 
171
171
  return createPortal(
172
+ /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the dialog
173
+ container, per the APG dialog pattern: `onKeyDown` is Escape-to-close for whatever
174
+ inside it holds focus, and the mouse handlers are the backdrop press (Escape and the
175
+ close button are its keyboard path). */
172
176
  <div
173
177
  // `animate-overlay` on the backdrop and `animate-sheet` on the panel (live #320):
174
178
  // the backdrop fades, the panel rises from the bottom edge it is anchored to.
@@ -103,22 +103,24 @@ export function HoverMenu({
103
103
  // close what was just opened (feedback #19). Toggling closed on click stays
104
104
  // for keyboard/touch activation, which reaches "open" without going through
105
105
  // the hover path.
106
- const openedByHoverRef = useRef(false);
106
+ //
107
+ // State rather than a ref: `close` is handed to the caller's render prop, and a
108
+ // function that writes a ref must not be reachable from render.
109
+ const [openedByHover, setOpenedByHover] = useState(false);
107
110
 
108
111
  const close = useCallback(() => {
109
- openedByHoverRef.current = false;
112
+ setOpenedByHover(false);
110
113
  setPhase("closed");
111
114
  }, []);
112
115
 
113
- const toggle = useCallback(
114
- () =>
115
- setPhase((p) => {
116
- if (p === "open" || p === "closing") return openedByHoverRef.current ? p : "closed";
117
- openedByHoverRef.current = false;
118
- return "open";
119
- }),
120
- [],
121
- );
116
+ const toggle = useCallback(() => {
117
+ if (open) {
118
+ if (!openedByHover) setPhase("closed");
119
+ return;
120
+ }
121
+ setOpenedByHover(false);
122
+ setPhase("open");
123
+ }, [open, openedByHover]);
122
124
 
123
125
  const handleMouseEnter = useCallback(
124
126
  () => setPhase((p) => (p === "closing" ? "open" : p === "closed" ? "opening" : p)),
@@ -134,7 +136,7 @@ export function HoverMenu({
134
136
  useEffect(() => {
135
137
  if (phase === "opening") {
136
138
  const id = setTimeout(() => {
137
- openedByHoverRef.current = true;
139
+ setOpenedByHover(true);
138
140
  setPhase("open");
139
141
  }, OPEN_DELAY_MS);
140
142
  return () => clearTimeout(id);
@@ -217,7 +219,7 @@ export function HoverMenu({
217
219
  (edge === "last" ? list[list.length - 1] : list[0])?.focus();
218
220
  } else {
219
221
  focusOnOpenRef.current = edge;
220
- openedByHoverRef.current = false;
222
+ setOpenedByHover(false);
221
223
  setPhase("open");
222
224
  }
223
225
  }
@@ -247,11 +249,14 @@ export function HoverMenu({
247
249
  // subtracted first so the measurement is against the natural position.
248
250
  const panelRef = useRef<HTMLDivElement>(null);
249
251
  const [shiftX, setShiftX] = useState(0);
252
+ // Back to 0 on close, adjusted while rendering against the previous `open`.
253
+ const [wasOpen, setWasOpen] = useState(open);
254
+ if (open !== wasOpen) {
255
+ setWasOpen(open);
256
+ if (!open) setShiftX(0);
257
+ }
250
258
  useLayoutEffect(() => {
251
- if (!open) {
252
- setShiftX(0);
253
- return;
254
- }
259
+ if (!open) return;
255
260
  const el = panelRef.current;
256
261
  if (!el) return;
257
262
  const rect = el.getBoundingClientRect();
@@ -267,6 +272,10 @@ export function HoverMenu({
267
272
  }, [open]);
268
273
 
269
274
  return (
275
+ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- never focused
276
+ itself: the mouse handlers are the hover-to-open convenience, and `onKeyDown`
277
+ catches keys bubbling from the caller's trigger button and the menuitems, which
278
+ are the keyboard path (Enter/Space/arrows open, arrows move, Escape closes). */
270
279
  <div
271
280
  // `...rest` first: the hover handlers below are the whole component, and a caller
272
281
  // passing one of its own must not silently replace them.