@eifi1/ui-kit 0.20.1 → 0.22.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 (286) hide show
  1. package/dist/chart.d.ts +10 -8
  2. package/dist/components/account-chips.d.ts +10 -3
  3. package/dist/components/account-chips.js +5 -2
  4. package/dist/components/account-chips.js.map +1 -1
  5. package/dist/components/account-settings.d.ts +33 -2
  6. package/dist/components/account-settings.js +43 -21
  7. package/dist/components/account-settings.js.map +1 -1
  8. package/dist/components/amount-input.d.ts +27 -8
  9. package/dist/components/amount-input.js +15 -28
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/autocomplete.d.ts +8 -0
  12. package/dist/components/autocomplete.js +9 -4
  13. package/dist/components/autocomplete.js.map +1 -1
  14. package/dist/components/button-group.d.ts +10 -8
  15. package/dist/components/calculator.d.ts +10 -8
  16. package/dist/components/checkbox-group.d.ts +104 -0
  17. package/dist/components/checkbox-group.js +111 -0
  18. package/dist/components/checkbox-group.js.map +1 -0
  19. package/dist/components/checkbox.d.ts +19 -0
  20. package/dist/components/checkbox.js +30 -7
  21. package/dist/components/checkbox.js.map +1 -1
  22. package/dist/components/chip.d.ts +18 -0
  23. package/dist/components/chip.js +47 -10
  24. package/dist/components/chip.js.map +1 -1
  25. package/dist/components/combobox.d.ts +13 -2
  26. package/dist/components/combobox.js +17 -8
  27. package/dist/components/combobox.js.map +1 -1
  28. package/dist/components/confirm-dialog.d.ts +66 -161
  29. package/dist/components/copy-button.d.ts +10 -8
  30. package/dist/components/country-select.d.ts +124 -0
  31. package/dist/components/country-select.js +237 -0
  32. package/dist/components/country-select.js.map +1 -0
  33. package/dist/components/currency-select.d.ts +17 -1
  34. package/dist/components/currency-select.js +56 -23
  35. package/dist/components/currency-select.js.map +1 -1
  36. package/dist/components/danger-confirm.d.ts +66 -166
  37. package/dist/components/danger-confirm.js +133 -49
  38. package/dist/components/danger-confirm.js.map +1 -1
  39. package/dist/components/data-table-cells.d.ts +1 -2
  40. package/dist/components/data-table-filter-popover.d.ts +1 -2
  41. package/dist/components/data-table-filters.d.ts +1 -2
  42. package/dist/components/data-table-labels.d.ts +4 -97
  43. package/dist/components/data-table-labels.js +9 -1
  44. package/dist/components/data-table-labels.js.map +1 -1
  45. package/dist/components/data-table-pagination.d.ts +3 -1
  46. package/dist/components/data-table.d.ts +1 -2
  47. package/dist/components/data-table.js +98 -3
  48. package/dist/components/data-table.js.map +1 -1
  49. package/dist/components/date-picker.d.ts +25 -5
  50. package/dist/components/date-picker.js +110 -59
  51. package/dist/components/date-picker.js.map +1 -1
  52. package/dist/components/entity-combobox.d.ts +8 -1
  53. package/dist/components/entity-combobox.js +9 -4
  54. package/dist/components/entity-combobox.js.map +1 -1
  55. package/dist/components/facing-pair.d.ts +10 -8
  56. package/dist/components/field-anatomy.d.ts +95 -0
  57. package/dist/components/field-anatomy.js +84 -0
  58. package/dist/components/field-anatomy.js.map +1 -0
  59. package/dist/components/field-parts.d.ts +114 -0
  60. package/dist/components/field-parts.js +72 -0
  61. package/dist/components/field-parts.js.map +1 -0
  62. package/dist/components/file-button.d.ts +10 -8
  63. package/dist/components/file-button.js +13 -3
  64. package/dist/components/file-button.js.map +1 -1
  65. package/dist/components/file-dropzone.d.ts +32 -9
  66. package/dist/components/file-dropzone.js +57 -34
  67. package/dist/components/file-dropzone.js.map +1 -1
  68. package/dist/components/form-actions.d.ts +10 -8
  69. package/dist/components/form-actions.js +61 -3
  70. package/dist/components/form-actions.js.map +1 -1
  71. package/dist/components/hover-menu.d.ts +3 -0
  72. package/dist/components/hover-menu.js +2 -2
  73. package/dist/components/hover-menu.js.map +1 -1
  74. package/dist/components/iban-input.d.ts +66 -0
  75. package/dist/components/iban-input.js +125 -0
  76. package/dist/components/iban-input.js.map +1 -0
  77. package/dist/components/icon-picker.d.ts +12 -2
  78. package/dist/components/icon-picker.js +9 -2
  79. package/dist/components/icon-picker.js.map +1 -1
  80. package/dist/components/inline-edit-field.d.ts +167 -0
  81. package/dist/components/inline-edit-field.js +293 -0
  82. package/dist/components/inline-edit-field.js.map +1 -0
  83. package/dist/components/language-select.d.ts +127 -0
  84. package/dist/components/language-select.js +18 -0
  85. package/dist/components/language-select.js.map +1 -0
  86. package/dist/components/line-items.d.ts +25 -3
  87. package/dist/components/line-items.js +55 -46
  88. package/dist/components/line-items.js.map +1 -1
  89. package/dist/components/list.d.ts +25 -0
  90. package/dist/components/list.js +16 -5
  91. package/dist/components/list.js.map +1 -1
  92. package/dist/components/money-field.d.ts +20 -8
  93. package/dist/components/money-field.js.map +1 -1
  94. package/dist/components/month-picker.d.ts +52 -4
  95. package/dist/components/month-picker.js +290 -86
  96. package/dist/components/month-picker.js.map +1 -1
  97. package/dist/components/multi-select.d.ts +8 -1
  98. package/dist/components/multi-select.js +9 -4
  99. package/dist/components/multi-select.js.map +1 -1
  100. package/dist/components/number-field.d.ts +25 -13
  101. package/dist/components/number-field.js +6 -20
  102. package/dist/components/number-field.js.map +1 -1
  103. package/dist/components/number-input.d.ts +70 -13
  104. package/dist/components/number-input.js +90 -38
  105. package/dist/components/number-input.js.map +1 -1
  106. package/dist/components/numpad-sheet.d.ts +10 -8
  107. package/dist/components/one-time-code-input.d.ts +100 -0
  108. package/dist/components/one-time-code-input.js +137 -0
  109. package/dist/components/one-time-code-input.js.map +1 -0
  110. package/dist/components/phone-input.d.ts +66 -0
  111. package/dist/components/phone-input.js +171 -0
  112. package/dist/components/phone-input.js.map +1 -0
  113. package/dist/components/series-chart.d.ts +10 -8
  114. package/dist/components/settings-fields.d.ts +10 -8
  115. package/dist/components/share-card.d.ts +8 -6
  116. package/dist/components/sign-chip.d.ts +66 -0
  117. package/dist/components/sign-chip.js +39 -0
  118. package/dist/components/sign-chip.js.map +1 -0
  119. package/dist/components/swatch-picker.d.ts +27 -2
  120. package/dist/components/swatch-picker.js +73 -31
  121. package/dist/components/swatch-picker.js.map +1 -1
  122. package/dist/components/switch.d.ts +19 -0
  123. package/dist/components/switch.js +24 -6
  124. package/dist/components/switch.js.map +1 -1
  125. package/dist/components/text-link.d.ts +10 -8
  126. package/dist/components/tile-radio.d.ts +68 -13
  127. package/dist/components/tile-radio.js +3 -3
  128. package/dist/components/tile-radio.js.map +1 -1
  129. package/dist/components/time-input.d.ts +15 -9
  130. package/dist/components/time-input.js.map +1 -1
  131. package/dist/components/toggle-group.d.ts +47 -1
  132. package/dist/components/toggle-group.js +110 -69
  133. package/dist/components/toggle-group.js.map +1 -1
  134. package/dist/components/ui.d.ts +10 -8
  135. package/dist/components/ui.js +280 -78
  136. package/dist/components/ui.js.map +1 -1
  137. package/dist/components/use-table-state.d.ts +1 -2
  138. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  139. package/dist/data-table.d.ts +1 -2
  140. package/dist/data-table.js +2 -1
  141. package/dist/data-table.js.map +1 -1
  142. package/dist/feedback/feedback-attachment.d.ts +1 -1
  143. package/dist/feedback/feedback-attachment.js +210 -3
  144. package/dist/feedback/feedback-attachment.js.map +1 -1
  145. package/dist/feedback/feedback-dialog.d.ts +1 -1
  146. package/dist/feedback/feedback-dialog.js.map +1 -1
  147. package/dist/feedback/feedback-inbox.d.ts +1 -1
  148. package/dist/feedback/feedback-thread.d.ts +37 -3
  149. package/dist/feedback/feedback-thread.js +38 -9
  150. package/dist/feedback/feedback-thread.js.map +1 -1
  151. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-NhUm7Zga.d.ts} +70 -5
  152. package/dist/feedback.d.ts +2 -2
  153. package/dist/hooks/use-file-drop.d.ts +10 -8
  154. package/dist/i18n/defaults.d.ts +10 -8
  155. package/dist/i18n/defaults.js +17 -2
  156. package/dist/i18n/defaults.js.map +1 -1
  157. package/dist/i18n/german.d.ts +10 -8
  158. package/dist/i18n/german.js +50 -5
  159. package/dist/i18n/german.js.map +1 -1
  160. package/dist/i18n/kit-labels.d.ts +10 -8
  161. package/dist/i18n/kit-labels.js +8 -2
  162. package/dist/i18n/kit-labels.js.map +1 -1
  163. package/dist/i18n/languages.d.ts +10 -8
  164. package/dist/i18n/locales/de-CH.d.ts +10 -8
  165. package/dist/i18n/locales/en.d.ts +10 -8
  166. package/dist/i18n/locales/en.js +9 -0
  167. package/dist/i18n/locales/en.js.map +1 -1
  168. package/dist/i18n/locales/es.d.ts +10 -8
  169. package/dist/i18n/locales/es.js +48 -5
  170. package/dist/i18n/locales/es.js.map +1 -1
  171. package/dist/i18n/locales/fr.d.ts +10 -8
  172. package/dist/i18n/locales/fr.js +48 -5
  173. package/dist/i18n/locales/fr.js.map +1 -1
  174. package/dist/i18n/locales/hu.d.ts +10 -8
  175. package/dist/i18n/locales/hu.js +49 -5
  176. package/dist/i18n/locales/hu.js.map +1 -1
  177. package/dist/i18n/locales/it.d.ts +10 -8
  178. package/dist/i18n/locales/it.js +49 -5
  179. package/dist/i18n/locales/it.js.map +1 -1
  180. package/dist/i18n/locales/zh.d.ts +10 -8
  181. package/dist/i18n/locales/zh.js +48 -5
  182. package/dist/i18n/locales/zh.js.map +1 -1
  183. package/dist/i18n/review.d.ts +10 -8
  184. package/dist/i18n/review.js +8 -1
  185. package/dist/i18n/review.js.map +1 -1
  186. package/dist/index.d.ts +17 -10
  187. package/dist/index.js +57 -2
  188. package/dist/index.js.map +1 -1
  189. package/dist/{kit-labels-D1J2_jyD.d.ts → kit-labels-R8mIAc9N.d.ts} +811 -8
  190. package/dist/lib/countries.d.ts +43 -0
  191. package/dist/lib/countries.js +39 -0
  192. package/dist/lib/countries.js.map +1 -0
  193. package/dist/lib/decimal-marks.d.ts +46 -0
  194. package/dist/lib/decimal-marks.js +40 -0
  195. package/dist/lib/decimal-marks.js.map +1 -0
  196. package/dist/lib/iban.d.ts +98 -0
  197. package/dist/lib/iban.js +154 -0
  198. package/dist/lib/iban.js.map +1 -0
  199. package/dist/lib/isin.d.ts +30 -0
  200. package/dist/lib/isin.js +36 -0
  201. package/dist/lib/isin.js.map +1 -0
  202. package/dist/lib/phone.d.ts +69 -0
  203. package/dist/lib/phone.js +121 -0
  204. package/dist/lib/phone.js.map +1 -0
  205. package/dist/rhf/fields.d.ts +148 -11
  206. package/dist/rhf/fields.js +332 -3
  207. package/dist/rhf/fields.js.map +1 -1
  208. package/dist/rhf/form.d.ts +10 -8
  209. package/dist/rhf.d.ts +13 -9
  210. package/dist/rhf.js.map +1 -1
  211. package/dist/shell/app-shell.d.ts +10 -8
  212. package/dist/shell/top-bar-brand.d.ts +10 -8
  213. package/dist/shell/topbar-action-menu.d.ts +13 -1
  214. package/dist/shell/topbar-action-menu.js +31 -28
  215. package/dist/shell/topbar-action-menu.js.map +1 -1
  216. package/dist/shell.d.ts +10 -8
  217. package/dist/tour/tour.js +20 -24
  218. package/dist/tour/tour.js.map +1 -1
  219. package/dist/wizard/stepper-nav.d.ts +10 -8
  220. package/dist/wizard.d.ts +10 -8
  221. package/package.json +1 -1
  222. package/src/components/account-chips.tsx +17 -3
  223. package/src/components/account-settings.tsx +92 -26
  224. package/src/components/amount-input.tsx +49 -56
  225. package/src/components/autocomplete.tsx +133 -110
  226. package/src/components/checkbox-group.tsx +229 -0
  227. package/src/components/checkbox.tsx +72 -8
  228. package/src/components/chip.tsx +80 -10
  229. package/src/components/combobox.tsx +292 -253
  230. package/src/components/country-select.tsx +443 -0
  231. package/src/components/currency-select.tsx +109 -33
  232. package/src/components/danger-confirm.tsx +345 -77
  233. package/src/components/data-table-labels.ts +16 -1
  234. package/src/components/data-table.tsx +175 -6
  235. package/src/components/date-picker.tsx +173 -62
  236. package/src/components/entity-combobox.tsx +107 -87
  237. package/src/components/field-anatomy.tsx +190 -0
  238. package/src/components/field-parts.tsx +184 -0
  239. package/src/components/file-button.tsx +36 -3
  240. package/src/components/file-dropzone.tsx +106 -38
  241. package/src/components/form-actions.tsx +115 -2
  242. package/src/components/hover-menu.tsx +7 -4
  243. package/src/components/iban-input.tsx +231 -0
  244. package/src/components/icon-picker.tsx +26 -4
  245. package/src/components/inline-edit-field.tsx +533 -0
  246. package/src/components/language-select.tsx +78 -0
  247. package/src/components/line-items.tsx +35 -2
  248. package/src/components/list.tsx +55 -6
  249. package/src/components/money-field.tsx +10 -0
  250. package/src/components/month-picker.tsx +399 -63
  251. package/src/components/multi-select.tsx +80 -58
  252. package/src/components/number-field.tsx +28 -46
  253. package/src/components/number-input.tsx +210 -43
  254. package/src/components/one-time-code-input.tsx +288 -0
  255. package/src/components/phone-input.tsx +299 -0
  256. package/src/components/sign-chip.tsx +103 -0
  257. package/src/components/swatch-picker.tsx +118 -30
  258. package/src/components/switch.tsx +58 -7
  259. package/src/components/tile-radio.tsx +71 -16
  260. package/src/components/time-input.tsx +8 -1
  261. package/src/components/toggle-group.tsx +206 -73
  262. package/src/components/ui.tsx +472 -105
  263. package/src/data-table.ts +2 -1
  264. package/src/feedback/feedback-attachment.tsx +313 -9
  265. package/src/feedback/feedback-dialog.tsx +2 -0
  266. package/src/feedback/feedback-thread.tsx +66 -5
  267. package/src/i18n/defaults.ts +16 -1
  268. package/src/i18n/german.ts +47 -0
  269. package/src/i18n/kit-labels.tsx +78 -2
  270. package/src/i18n/locales/en.ts +10 -0
  271. package/src/i18n/locales/es.ts +46 -0
  272. package/src/i18n/locales/fr.ts +45 -0
  273. package/src/i18n/locales/hu.ts +46 -0
  274. package/src/i18n/locales/it.ts +47 -0
  275. package/src/i18n/locales/zh.ts +44 -0
  276. package/src/i18n/review.ts +7 -0
  277. package/src/index.ts +53 -3
  278. package/src/lib/countries.ts +91 -0
  279. package/src/lib/decimal-marks.ts +99 -0
  280. package/src/lib/iban.ts +171 -0
  281. package/src/lib/isin.ts +62 -0
  282. package/src/lib/phone.ts +211 -0
  283. package/src/rhf/fields.tsx +606 -9
  284. package/src/rhf.ts +2 -1
  285. package/src/shell/topbar-action-menu.tsx +53 -29
  286. package/src/tour/tour.tsx +66 -28
@@ -5,6 +5,7 @@ import { useAnnounce } from "../hooks/use-announce";
5
5
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
6
6
  import { Button, Spinner } from "./ui";
7
7
  import { Tooltip } from "./tooltip";
8
+ import { useCommitReason } from "./write-lock";
8
9
 
9
10
  /**
10
11
  * A button that opens the file picker — the shape all three apps kept writing by hand
@@ -428,6 +429,27 @@ export interface FileButtonProps
428
429
  * file leaves it as it was. Off by default, so the button's layout is unchanged.
429
430
  */
430
431
  showFileName?: boolean;
432
+ /**
433
+ * Why picking is not available — {@link Button}'s `disabledReason`, with the picker's
434
+ * half added (keksdose K3): the button is `aria-disabled` but stays focusable, the
435
+ * reason is in the kit Tooltip and its description, a click opens no picker, and a
436
+ * `droppable` button takes no drop (the drag is still cancelled, so the browser does
437
+ * not navigate to the file). Wins over `disabled`, as on Button.
438
+ */
439
+ disabledReason?: ReactNode;
440
+ /**
441
+ * This pick COMMITS — an upload that starts on pick, an attachment saved at once.
442
+ * Under a locked {@link WriteLockProvider} it takes the `disabledReason` path with
443
+ * the lock's reason (which wins over one of its own). No provider, or an unlocked
444
+ * one: no effect. A picker that only fills a form the user saves later is not a
445
+ * commit, and is left live — the form's Save carries the lock.
446
+ */
447
+ commit?: boolean;
448
+ }
449
+
450
+ /** A reason that is really there — `null`, `false` and `""` are no reason. */
451
+ function hasReason(reason: ReactNode): boolean {
452
+ return reason !== undefined && reason !== null && reason !== false && reason !== "";
431
453
  }
432
454
 
433
455
  /**
@@ -459,6 +481,8 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
459
481
  droppable,
460
482
  showFileName = false,
461
483
  disabled,
484
+ disabledReason: ownDisabledReason,
485
+ commit,
462
486
  children,
463
487
  className,
464
488
  onClick,
@@ -470,7 +494,13 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
470
494
  },
471
495
  ref,
472
496
  ) {
497
+ const disabledReason = useCommitReason(commit, ownDisabledReason);
498
+ const locked = hasReason(disabledReason);
473
499
  const inert = Boolean(disabled || pending);
500
+ // What takes no files: busy, disabled, or locked with a reason. Kept apart from
501
+ // `inert`, which is what reaches the button's NATIVE `disabled` — a locked button
502
+ // stays focusable (Button's `disabledReason` path), so `locked` must not get there.
503
+ const refusing = inert || locked;
474
504
  const [picked, setPicked] = useState<readonly File[]>([]);
475
505
  const pickedId = useId();
476
506
  const pickerLabels = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS, labels);
@@ -491,7 +521,7 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
491
521
  onPick,
492
522
  onReject,
493
523
  labels,
494
- disabled: inert,
524
+ disabled: refusing,
495
525
  });
496
526
  const pickedText =
497
527
  picked.length === 0 ? "" : picked.length === 1 ? picked[0].name : pickerLabels.selected(picked.length, picked[0].name);
@@ -505,7 +535,7 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
505
535
  onDragEnter?.(e);
506
536
  if (!hasFiles(e)) return;
507
537
  e.preventDefault();
508
- if (!inert) setDragOver(true);
538
+ if (!refusing) setDragOver(true);
509
539
  },
510
540
  onDragOver: (e: DragEvent<HTMLButtonElement>) => {
511
541
  onDragOver?.(e);
@@ -513,7 +543,7 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
513
543
  // Always cancel for a file drag, busy or not: an uncancelled drop makes the
514
544
  // browser NAVIGATE to the file, which loses the page.
515
545
  e.preventDefault();
516
- e.dataTransfer.dropEffect = inert ? "none" : "copy";
546
+ e.dataTransfer.dropEffect = refusing ? "none" : "copy";
517
547
  },
518
548
  onDragLeave: (e: DragEvent<HTMLButtonElement>) => {
519
549
  onDragLeave?.(e);
@@ -542,6 +572,9 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
542
572
  {...dropHandlers}
543
573
  type="button"
544
574
  disabled={inert}
575
+ // Resolved here rather than passed on as `commit`: the picker above has to know
576
+ // it is locked too, and one resolution keeps the two from disagreeing.
577
+ disabledReason={locked ? disabledReason : undefined}
545
578
  aria-busy={pending || undefined}
546
579
  aria-describedby={
547
580
  showFileName && pickedText
@@ -15,6 +15,7 @@ import {
15
15
  import type { FilePickHandler, FilePickerLabels, FileRejection } from "./file-button";
16
16
  import { useDragTarget } from "../hooks/use-file-drop";
17
17
  import { toast } from "./toast";
18
+ import { useCommitReason } from "./write-lock";
18
19
 
19
20
  /**
20
21
  * Where a refused file's message goes.
@@ -35,6 +36,11 @@ export interface FileDropzoneState {
35
36
  dragOver: boolean;
36
37
  disabled: boolean;
37
38
  busy: boolean;
39
+ /** 0.22: locked with a reason (`disabledReason`, or `commit` under a locked
40
+ * {@link WriteLockProvider}) — takes no files, like `disabled`. */
41
+ locked: boolean;
42
+ /** The reason while `locked`, for a custom body to show; `undefined` otherwise. */
43
+ disabledReason: ReactNode | undefined;
38
44
  /** What is chosen: `files` in multiple mode, `[file]` or `[]` in single mode. */
39
45
  files: readonly File[];
40
46
  /** The inline refusal, when `rejectionFeedback="inline"` has one to show. */
@@ -139,6 +145,22 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
139
145
  * text. The other half of why keksdose's invoice zone was hand-rolled.
140
146
  */
141
147
  busy?: boolean;
148
+ /**
149
+ * Why the zone is not taking files — the write lock's sentence, a quota, a closed
150
+ * period (keksdose K3). Takes no drop, no picker and no removal, like `disabled`, and
151
+ * SAYS so: the Browse button (and every remove button) stays focusable,
152
+ * `aria-disabled`, with the reason in the kit Tooltip and its description, the zone's
153
+ * group is described by it, and the default body shows it where the hint was. Not
154
+ * dimmed: what is chosen stays readable. Wins over `disabled`'s silent look.
155
+ */
156
+ disabledReason?: ReactNode;
157
+ /**
158
+ * This zone COMMITS — an upload that starts on drop. Under a locked
159
+ * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's
160
+ * reason (which wins over one of its own). No provider, or an unlocked one: no
161
+ * effect. A zone that only stages a file for a form saved later is not a commit.
162
+ */
163
+ commit?: boolean;
142
164
  /**
143
165
  * Replace the zone's body — the icon, the text and the chosen-file list — with your
144
166
  * own, given the zone's state: keksdose's spinner-plus-"uploading", its multi-shot
@@ -182,6 +204,8 @@ export function FileDropzone({
182
204
  hint: hintProp,
183
205
  disabled,
184
206
  busy,
207
+ disabledReason: ownDisabledReason,
208
+ commit,
185
209
  renderBody,
186
210
  className,
187
211
  "aria-label": ariaLabel,
@@ -189,7 +213,12 @@ export function FileDropzone({
189
213
  ...rest
190
214
  }: FileDropzoneProps) {
191
215
  const [error, setError] = useState<string | null>(null);
192
- const inert = Boolean(disabled || busy);
216
+ const disabledReason = useCommitReason(commit, ownDisabledReason);
217
+ const locked = hasReason(disabledReason);
218
+ // Locked counts as inert for every file path (drop, click, Browse, remove); only the
219
+ // LOOK and the native `disabled` keep it apart, so its controls stay focusable.
220
+ const inert = Boolean(disabled || busy) || locked;
221
+ const reasonId = useId();
193
222
  // State rather than a ref: `open` is handed to `renderBody` during render, and a
194
223
  // function that reads a ref there is exactly what the refs rule cannot prove safe.
195
224
  const [fileInput, setFileInput] = useState<HTMLInputElement | null>(null);
@@ -278,7 +307,16 @@ export function FileDropzone({
278
307
 
279
308
  const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];
280
309
  const showError = feedback === "inline" && error !== null;
281
- const describedBy = cn(describedByProp, showError && errorId) || undefined;
310
+ const describedBy = cn(describedByProp, showError && errorId, locked && reasonId) || undefined;
311
+ // The controls inside: natively disabled while disabled or busy; locked, they take
312
+ // the reason instead and stay reachable (Button's `disabledReason` path).
313
+ const controlDisabled = Boolean(disabled || busy);
314
+ const controlReason = locked ? disabledReason : undefined;
315
+ const reasonLine = locked && (
316
+ <div data-slot="file-dropzone-reason" className="text-xs text-[var(--text-muted)]">
317
+ {disabledReason}
318
+ </div>
319
+ );
282
320
 
283
321
  return (
284
322
  <>
@@ -311,7 +349,7 @@ export function FileDropzone({
311
349
  // reached through the description instead, and spoken when it appears.
312
350
  aria-describedby={describedBy}
313
351
  data-invalid={showError || undefined}
314
- aria-disabled={disabled || undefined}
352
+ aria-disabled={disabled || locked || undefined}
315
353
  aria-busy={busy || undefined}
316
354
  data-drag-over={dragOver || undefined}
317
355
  className={cn(
@@ -336,7 +374,9 @@ export function FileDropzone({
336
374
  ? "cursor-not-allowed border-[var(--border)] opacity-60"
337
375
  : busy
338
376
  ? "cursor-progress border-[var(--border)]"
339
- : dragOver
377
+ : locked
378
+ ? "cursor-not-allowed border-[var(--border)]"
379
+ : dragOver
340
380
  ? "cursor-pointer border-[var(--border-strong)] bg-[var(--bg-active)]"
341
381
  : showError
342
382
  ? "cursor-pointer border-[var(--danger-border-strong)]"
@@ -349,6 +389,8 @@ export function FileDropzone({
349
389
  dragOver,
350
390
  disabled: Boolean(disabled),
351
391
  busy: Boolean(busy),
392
+ locked,
393
+ disabledReason: locked ? disabledReason : undefined,
352
394
  files: chosen,
353
395
  error: showError ? error : null,
354
396
  open,
@@ -365,34 +407,38 @@ export function FileDropzone({
365
407
  <>
366
408
  <Upload aria-hidden className="size-5 text-[var(--text-muted)]" />
367
409
  {multiple && chosen.length > 0 ? (
368
- <ul className="flex w-full max-w-sm flex-col gap-1 text-start">
369
- {chosen.map((f, i) => (
370
- <li
371
- // Name + size + position: two files may share a name, and the index
372
- // alone would re-key every row below a removal.
373
- key={`${f.name}-${f.size}-${i}`}
374
- className="flex items-center gap-2 text-sm text-[var(--text-secondary)]"
375
- >
376
- <span className="min-w-0 flex-1 truncate font-medium">{f.name}</span>
377
- <span className="shrink-0 text-xs text-[var(--text-muted)]">{fileText.size(f.size)}</span>
378
- {onRemove && (
379
- <IconButton
380
- type="button"
381
- size="sm"
382
- disabled={inert}
383
- aria-label={labels.remove(f.name)}
384
- onClick={(e) => {
385
- e.stopPropagation();
386
- onRemove(f, i);
387
- afterRemoval(labels.removed(f.name));
388
- }}
389
- >
390
- <X aria-hidden />
391
- </IconButton>
392
- )}
393
- </li>
394
- ))}
395
- </ul>
410
+ <>
411
+ <ul className="flex w-full max-w-sm flex-col gap-1 text-start">
412
+ {chosen.map((f, i) => (
413
+ <li
414
+ // Name + size + position: two files may share a name, and the index
415
+ // alone would re-key every row below a removal.
416
+ key={`${f.name}-${f.size}-${i}`}
417
+ className="flex items-center gap-2 text-sm text-[var(--text-secondary)]"
418
+ >
419
+ <span className="min-w-0 flex-1 truncate font-medium">{f.name}</span>
420
+ <span className="shrink-0 text-xs text-[var(--text-muted)]">{fileText.size(f.size)}</span>
421
+ {onRemove && (
422
+ <IconButton
423
+ type="button"
424
+ size="sm"
425
+ disabled={controlDisabled}
426
+ disabledReason={controlReason}
427
+ aria-label={labels.remove(f.name)}
428
+ onClick={(e) => {
429
+ e.stopPropagation();
430
+ onRemove(f, i);
431
+ afterRemoval(labels.removed(f.name));
432
+ }}
433
+ >
434
+ <X aria-hidden />
435
+ </IconButton>
436
+ )}
437
+ </li>
438
+ ))}
439
+ </ul>
440
+ {reasonLine}
441
+ </>
396
442
  ) : (
397
443
  <>
398
444
  <div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
@@ -403,7 +449,8 @@ export function FileDropzone({
403
449
  <IconButton
404
450
  type="button"
405
451
  size="sm"
406
- disabled={inert}
452
+ disabled={controlDisabled}
453
+ disabledReason={controlReason}
407
454
  aria-label={labels.remove(file.name)}
408
455
  onClick={(e) => {
409
456
  e.stopPropagation();
@@ -415,9 +462,16 @@ export function FileDropzone({
415
462
  </IconButton>
416
463
  )}
417
464
  </div>
418
- <div className="text-xs text-[var(--text-muted)]">
419
- {file && !multiple ? fileText.size(file.size) : hint}
420
- </div>
465
+ {/* Locked, the reason takes the hint's place: what may be dropped is moot
466
+ while nothing may. A chosen file keeps its size, the reason below. */}
467
+ {locked && !(file && !multiple) ? (
468
+ reasonLine
469
+ ) : (
470
+ <div className="text-xs text-[var(--text-muted)]">
471
+ {file && !multiple ? fileText.size(file.size) : hint}
472
+ </div>
473
+ )}
474
+ {locked && file && !multiple && reasonLine}
421
475
  </>
422
476
  )}
423
477
  </>
@@ -427,7 +481,8 @@ export function FileDropzone({
427
481
  ref={browseRef}
428
482
  type="button"
429
483
  variant="secondary"
430
- disabled={inert}
484
+ disabled={controlDisabled}
485
+ disabledReason={controlReason}
431
486
  onClick={(e) => {
432
487
  e.stopPropagation();
433
488
  open();
@@ -439,7 +494,8 @@ export function FileDropzone({
439
494
  <Button
440
495
  type="button"
441
496
  variant="ghost"
442
- disabled={inert}
497
+ disabled={controlDisabled}
498
+ disabledReason={controlReason}
443
499
  onClick={(e) => {
444
500
  e.stopPropagation();
445
501
  onClear();
@@ -455,6 +511,13 @@ export function FileDropzone({
455
511
  {error}
456
512
  </p>
457
513
  )}
514
+ {/* The group's description while locked: a copy of its own, so a custom body
515
+ that does not show the reason still has the zone say it. */}
516
+ {locked && (
517
+ <span id={reasonId} hidden>
518
+ {disabledReason}
519
+ </span>
520
+ )}
458
521
  <input
459
522
  ref={setFileInput}
460
523
  type="file"
@@ -484,6 +547,11 @@ export function FileDropzone({
484
547
  );
485
548
  }
486
549
 
550
+ /** A reason that is really there — `null`, `false` and `""` are no reason. */
551
+ function hasReason(reason: ReactNode): boolean {
552
+ return reason !== undefined && reason !== null && reason !== false && reason !== "";
553
+ }
554
+
487
555
  /** A click that landed on a control inside the zone (a custom body's own button, a
488
556
  * link in the hint) is that control's, not a request to open the picker. */
489
557
  function fromControl(e: MouseEvent<HTMLElement>): boolean {
@@ -1,22 +1,35 @@
1
- import { isValidElement } from "react";
2
- import type { ComponentPropsWithoutRef, ReactNode } from "react";
1
+ import { isValidElement, useCallback, useEffect, useRef } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
3
3
  import type { LucideIcon } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { useKitLabels } from "../i18n/kit-labels";
6
6
  import { useMediaQuery } from "../hooks/use-media-query";
7
+ import { isApplePlatform } from "../hooks/use-hotkey";
7
8
  import { Button, Spinner, type ButtonProps, type ButtonVariant } from "./ui";
9
+ import { useWriteLock } from "./write-lock";
8
10
 
9
11
  /** The words {@link FormActions} renders on its own behalf — the `form` namespace. */
10
12
  export interface FormActionsLabels {
11
13
  save: string;
12
14
  cancel: string;
15
+ /**
16
+ * The {@link FormActionsProps.submitShortcut} hint beside Save's text, when
17
+ * {@link FormActionsProps.submitShortcutHint} shows it. `apple` is true on a Mac,
18
+ * iPhone or iPad, where the key is Cmd: "⌘ Enter" there, "Ctrl+Enter" elsewhere
19
+ * (a German keyboard says "Strg").
20
+ */
21
+ submitShortcut: (apple: boolean) => string;
13
22
  }
14
23
 
15
24
  export const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {
16
25
  save: "Save",
17
26
  cancel: "Cancel",
27
+ submitShortcut: (apple) => (apple ? "⌘ Enter" : "Ctrl+Enter"),
18
28
  };
19
29
 
30
+ /** See {@link FormActionsProps.submitShortcut}. */
31
+ export type FormActionsSubmitShortcut = "mod-enter";
32
+
20
33
  export type FormActionsAlign = "start" | "center" | "end" | "between";
21
34
 
22
35
  /**
@@ -229,10 +242,60 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
229
242
  /** The `id` of the `<form>` the save button submits, when the row is rendered
230
243
  * outside it (a dialog's footer slot). */
231
244
  form?: string;
245
+ /**
246
+ * A keyboard shortcut that presses Save from anywhere in the form. `"mod-enter"`:
247
+ * Ctrl+Enter, or Cmd+Enter on a Mac — both are accepted everywhere, as keksdose's
248
+ * editors always have. keksdose K14 (inputs audit 2026-10-02, S5): its transaction
249
+ * editor, category editor and create card each wrote this as an `onKeyDown` on a
250
+ * container, re-stating `!pending && !saveDisabled && !lock.locked` every time, and
251
+ * the one that forgot the lock let the demo's refused save through from the keyboard.
252
+ *
253
+ * What it does is what pressing Save does — the same button is clicked, so a form
254
+ * submits with its validation and its submitter, and `onSubmit` is called in the
255
+ * `type="button"` mode — and it does NOTHING while Save could not be pressed:
256
+ * `pending`, `submitDisabled`, or a `commit` under a locked {@link WriteLockProvider}.
257
+ *
258
+ * Scoped to the form Save submits (the `form` prop's, else the enclosing `<form>`):
259
+ * the chord counts only when the focus is inside it, so two editors on one page each
260
+ * answer for their own fields. Without a form (an `onSubmit` row in a plain container)
261
+ * the scope is the nearest `role="dialog"`, else the row's parent element — the
262
+ * container the row ends.
263
+ *
264
+ * Listened for on the document, so it runs AFTER every React handler: a field that
265
+ * takes Ctrl+Enter for itself calls `preventDefault()` or `stopPropagation()` and
266
+ * the row stays out of it (keksdose live #202 — the create card's Ctrl+Shift+Enter
267
+ * must create ONE row; the chord here is exactly Ctrl/Cmd+Enter, never with Shift or
268
+ * Alt). Not during IME composition, and not on a held key's repeats.
269
+ *
270
+ * The save button announces it with `aria-keyshortcuts`; {@link submitShortcutHint}
271
+ * also shows it.
272
+ */
273
+ submitShortcut?: FormActionsSubmitShortcut;
274
+ /** Show the {@link submitShortcut} beside Save's text ("Ctrl+Enter", "⌘ Enter"; the
275
+ * `form.submitShortcut` label), dimmed. Hidden on a touch-only device, which has no
276
+ * keyboard to press it on. Default `false`. */
277
+ submitShortcutHint?: boolean;
232
278
  /** Extra actions, placed before Cancel. */
233
279
  children?: ReactNode;
234
280
  }
235
281
 
282
+ /** Hand `el` to a caller's ref, whichever kind it is. */
283
+ function assignRef<E>(ref: Ref<E> | undefined, el: E | null): void {
284
+ if (typeof ref === "function") ref(el);
285
+ else if (ref) (ref as { current: E | null }).current = el;
286
+ }
287
+
288
+ /**
289
+ * Where {@link FormActionsProps.submitShortcut} listens: the form Save submits (named
290
+ * by `formId`, else the enclosing one), else the nearest dialog, else the row's parent.
291
+ */
292
+ function shortcutScope(row: HTMLElement | null, formId: string | undefined): Element | null {
293
+ if (!row) return null;
294
+ const named = formId ? row.ownerDocument.getElementById(formId) : null;
295
+ if (named instanceof HTMLFormElement) return named;
296
+ return row.closest("form") ?? row.closest('[role="dialog"]') ?? row.parentElement;
297
+ }
298
+
236
299
  function isDestructiveData(value: unknown): value is FormActionsDestructive {
237
300
  return typeof value === "object" && value !== null && !isValidElement(value) && "onClick" in value;
238
301
  }
@@ -272,12 +335,47 @@ export function FormActions({
272
335
  stickyWithin = "viewport",
273
336
  bleed,
274
337
  form,
338
+ submitShortcut,
339
+ submitShortcutHint = false,
275
340
  children,
276
341
  className,
277
342
  style,
278
343
  ...rest
279
344
  }: FormActionsProps) {
280
345
  const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
346
+ const lock = useWriteLock();
347
+ const rootRef = useRef<HTMLDivElement>(null);
348
+ const saveRef = useRef<HTMLButtonElement | null>(null);
349
+ // The caller's own `submitProps.ref` still gets the element.
350
+ const callerSaveRef = submitProps?.ref;
351
+ const setSaveRef = useCallback(
352
+ (el: HTMLButtonElement | null) => {
353
+ saveRef.current = el;
354
+ assignRef(callerSaveRef, el);
355
+ },
356
+ [callerSaveRef],
357
+ );
358
+ // Read by the shortcut's listener at the moment of the key press, so the listener
359
+ // itself is attached once and never sees a stale `pending`.
360
+ const canSave = !pending && !submitDisabled && !(commit && lock.locked);
361
+ const canSaveRef = useRef(canSave);
362
+ useEffect(() => {
363
+ canSaveRef.current = canSave;
364
+ });
365
+ useEffect(() => {
366
+ if (submitShortcut !== "mod-enter") return;
367
+ const onKeyDown = (e: KeyboardEvent) => {
368
+ if (e.key !== "Enter" || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
369
+ if (e.repeat || e.isComposing || e.defaultPrevented || !canSaveRef.current) return;
370
+ const scope = shortcutScope(rootRef.current, form);
371
+ if (!scope || !(e.target instanceof Node) || !scope.contains(e.target)) return;
372
+ e.preventDefault();
373
+ saveRef.current?.click();
374
+ };
375
+ document.addEventListener("keydown", onKeyDown);
376
+ return () => document.removeEventListener("keydown", onKeyDown);
377
+ }, [submitShortcut, form]);
378
+ const apple = submitShortcut ? isApplePlatform() : false;
281
379
  const placement = useResolvedPlacement(placementProp);
282
380
  const bleedLength = bleed === undefined || bleed === "" ? undefined : cssLength(bleed);
283
381
  const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
@@ -302,6 +400,7 @@ export function FormActions({
302
400
 
303
401
  return (
304
402
  <div
403
+ ref={rootRef}
305
404
  data-slot="form-actions"
306
405
  data-placement={placement}
307
406
  data-sticky-within={placement === "sticky" ? stickyWithin : undefined}
@@ -342,6 +441,10 @@ export function FormActions({
342
441
  )}
343
442
  <Button
344
443
  {...submitProps}
444
+ ref={setSaveRef}
445
+ aria-keyshortcuts={
446
+ submitShortcut === "mod-enter" ? "Control+Enter Meta+Enter" : submitProps?.["aria-keyshortcuts"]
447
+ }
345
448
  type={onSubmit ? "button" : "submit"}
346
449
  form={form}
347
450
  onClick={onSubmit}
@@ -359,6 +462,16 @@ export function FormActions({
359
462
  SubmitIcon && <SubmitIcon aria-hidden className="size-4 shrink-0" />
360
463
  )}
361
464
  {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}
465
+ {submitShortcut && submitShortcutHint && (
466
+ // Hidden from the accessibility tree: `aria-keyshortcuts` already says it,
467
+ // and in the name it would make the button "Save Ctrl+Enter".
468
+ <kbd
469
+ aria-hidden
470
+ className="ms-1 font-sans text-[0.85em] font-normal whitespace-nowrap opacity-70 pointer-coarse:hidden"
471
+ >
472
+ {labels.submitShortcut(apple)}
473
+ </kbd>
474
+ )}
362
475
  </Button>
363
476
  </div>
364
477
  </div>
@@ -12,7 +12,9 @@ import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
12
12
  * the panel's `close`, not a `ReactNode`.
13
13
  */
14
14
  export interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
15
- trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;
15
+ /** The trigger. `toggle` for a button's click; `close` for a trigger that is a LINK —
16
+ * following it should shut a panel hover opened (TopBarActionMenu `href`). */
17
+ trigger: (state: { open: boolean; toggle: () => void; close: () => void }) => ReactNode;
16
18
  children: (close: () => void) => ReactNode;
17
19
  /**
18
20
  * Which edge of the trigger the panel lines up with. `start`/`end` follow the
@@ -209,8 +211,9 @@ export function HoverMenu({
209
211
  if (target !== triggerEl()) return;
210
212
  if (e.key === "Enter" || e.key === " ") {
211
213
  // The trigger's own click handler does the toggling; this only records that
212
- // the press came from a keyboard.
213
- if (!open) focusOnOpenRef.current = "first";
214
+ // the press came from a keyboard. Not for a link trigger: Enter follows it, and
215
+ // a mark left here would pull focus into the panel the next time hover opens it.
216
+ if (!open && !target.matches("a[href]")) focusOnOpenRef.current = "first";
214
217
  } else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
215
218
  e.preventDefault();
216
219
  const edge = e.key === "ArrowDown" ? "first" : "last";
@@ -289,7 +292,7 @@ export function HoverMenu({
289
292
  onKeyDown(e);
290
293
  }}
291
294
  >
292
- {trigger({ open, toggle })}
295
+ {trigger({ open, toggle, close })}
293
296
  {open && (
294
297
  <div
295
298
  ref={panelRef}