@eifi1/ui-kit 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +19 -14
  3. package/dist/components/account-chips.d.ts +10 -3
  4. package/dist/components/account-chips.js +5 -2
  5. package/dist/components/account-chips.js.map +1 -1
  6. package/dist/components/account-settings.d.ts +33 -2
  7. package/dist/components/account-settings.js +43 -21
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +53 -14
  10. package/dist/components/amount-input.js +17 -28
  11. package/dist/components/amount-input.js.map +1 -1
  12. package/dist/components/autocomplete.d.ts +29 -0
  13. package/dist/components/autocomplete.js +134 -104
  14. package/dist/components/autocomplete.js.map +1 -1
  15. package/dist/components/button-group.d.ts +19 -14
  16. package/dist/components/calculator.d.ts +19 -14
  17. package/dist/components/checkbox-group.d.ts +104 -0
  18. package/dist/components/checkbox-group.js +111 -0
  19. package/dist/components/checkbox-group.js.map +1 -0
  20. package/dist/components/checkbox.d.ts +19 -0
  21. package/dist/components/checkbox.js +30 -7
  22. package/dist/components/checkbox.js.map +1 -1
  23. package/dist/components/chip.d.ts +18 -0
  24. package/dist/components/chip.js +47 -10
  25. package/dist/components/chip.js.map +1 -1
  26. package/dist/components/column-mapper.d.ts +182 -0
  27. package/dist/components/column-mapper.js +375 -0
  28. package/dist/components/column-mapper.js.map +1 -0
  29. package/dist/components/combobox-core.d.ts +39 -2
  30. package/dist/components/combobox-core.js +29 -6
  31. package/dist/components/combobox-core.js.map +1 -1
  32. package/dist/components/combobox.d.ts +91 -2
  33. package/dist/components/combobox.js +454 -308
  34. package/dist/components/combobox.js.map +1 -1
  35. package/dist/components/confirm-dialog.d.ts +69 -161
  36. package/dist/components/confirm-dialog.js +7 -8
  37. package/dist/components/confirm-dialog.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +16 -11
  39. package/dist/components/country-select.d.ts +167 -0
  40. package/dist/components/country-select.js +293 -0
  41. package/dist/components/country-select.js.map +1 -0
  42. package/dist/components/currency-select.d.ts +17 -1
  43. package/dist/components/currency-select.js +56 -23
  44. package/dist/components/currency-select.js.map +1 -1
  45. package/dist/components/danger-confirm.d.ts +69 -166
  46. package/dist/components/danger-confirm.js +144 -50
  47. package/dist/components/danger-confirm.js.map +1 -1
  48. package/dist/components/data-table-cells.d.ts +1 -2
  49. package/dist/components/data-table-filter-popover.d.ts +1 -2
  50. package/dist/components/data-table-filters.d.ts +1 -2
  51. package/dist/components/data-table-labels.d.ts +4 -97
  52. package/dist/components/data-table-labels.js +9 -1
  53. package/dist/components/data-table-labels.js.map +1 -1
  54. package/dist/components/data-table-pagination.d.ts +3 -1
  55. package/dist/components/data-table.d.ts +1 -2
  56. package/dist/components/data-table.js +98 -3
  57. package/dist/components/data-table.js.map +1 -1
  58. package/dist/components/date-picker.d.ts +66 -5
  59. package/dist/components/date-picker.js +378 -66
  60. package/dist/components/date-picker.js.map +1 -1
  61. package/dist/components/entity-combobox.d.ts +42 -1
  62. package/dist/components/entity-combobox.js +154 -113
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/facing-pair.d.ts +19 -14
  65. package/dist/components/field-parts.d.ts +224 -0
  66. package/dist/components/field-parts.js +156 -0
  67. package/dist/components/field-parts.js.map +1 -0
  68. package/dist/components/field-strip.d.ts +105 -0
  69. package/dist/components/field-strip.js +46 -0
  70. package/dist/components/field-strip.js.map +1 -0
  71. package/dist/components/file-button.d.ts +19 -14
  72. package/dist/components/file-button.js +13 -3
  73. package/dist/components/file-button.js.map +1 -1
  74. package/dist/components/file-dropzone.d.ts +41 -15
  75. package/dist/components/file-dropzone.js +57 -34
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/form-actions.d.ts +18 -13
  78. package/dist/components/form-actions.js +69 -5
  79. package/dist/components/form-actions.js.map +1 -1
  80. package/dist/components/iban-input.d.ts +69 -0
  81. package/dist/components/iban-input.js +125 -0
  82. package/dist/components/iban-input.js.map +1 -0
  83. package/dist/components/icon-picker.d.ts +21 -2
  84. package/dist/components/icon-picker.js +19 -4
  85. package/dist/components/icon-picker.js.map +1 -1
  86. package/dist/components/inline-edit-field.d.ts +167 -0
  87. package/dist/components/inline-edit-field.js +293 -0
  88. package/dist/components/inline-edit-field.js.map +1 -0
  89. package/dist/components/language-select.d.ts +130 -0
  90. package/dist/components/language-select.js +18 -0
  91. package/dist/components/language-select.js.map +1 -0
  92. package/dist/components/line-items.d.ts +25 -3
  93. package/dist/components/line-items.js +55 -46
  94. package/dist/components/line-items.js.map +1 -1
  95. package/dist/components/list.d.ts +25 -0
  96. package/dist/components/list.js +16 -5
  97. package/dist/components/list.js.map +1 -1
  98. package/dist/components/money-field.d.ts +32 -14
  99. package/dist/components/money-field.js.map +1 -1
  100. package/dist/components/month-picker.d.ts +52 -4
  101. package/dist/components/month-picker.js +291 -86
  102. package/dist/components/month-picker.js.map +1 -1
  103. package/dist/components/multi-entity-combobox.d.ts +26 -1
  104. package/dist/components/multi-entity-combobox.js +143 -102
  105. package/dist/components/multi-entity-combobox.js.map +1 -1
  106. package/dist/components/multi-select.d.ts +8 -1
  107. package/dist/components/multi-select.js +9 -4
  108. package/dist/components/multi-select.js.map +1 -1
  109. package/dist/components/number-field.d.ts +34 -19
  110. package/dist/components/number-field.js +6 -20
  111. package/dist/components/number-field.js.map +1 -1
  112. package/dist/components/number-input.d.ts +79 -19
  113. package/dist/components/number-input.js +90 -38
  114. package/dist/components/number-input.js.map +1 -1
  115. package/dist/components/numpad-sheet.d.ts +19 -14
  116. package/dist/components/one-time-code-input.d.ts +100 -0
  117. package/dist/components/one-time-code-input.js +137 -0
  118. package/dist/components/one-time-code-input.js.map +1 -0
  119. package/dist/components/phone-input.d.ts +69 -0
  120. package/dist/components/phone-input.js +171 -0
  121. package/dist/components/phone-input.js.map +1 -0
  122. package/dist/components/reauth-dialog.d.ts +2 -2
  123. package/dist/components/reauth-dialog.js +6 -8
  124. package/dist/components/reauth-dialog.js.map +1 -1
  125. package/dist/components/series-chart.d.ts +19 -14
  126. package/dist/components/settings-fields.d.ts +19 -14
  127. package/dist/components/share-card.d.ts +16 -11
  128. package/dist/components/sign-chip.d.ts +66 -0
  129. package/dist/components/sign-chip.js +39 -0
  130. package/dist/components/sign-chip.js.map +1 -0
  131. package/dist/components/swatch-picker.d.ts +43 -2
  132. package/dist/components/swatch-picker.js +80 -32
  133. package/dist/components/swatch-picker.js.map +1 -1
  134. package/dist/components/switch.d.ts +19 -0
  135. package/dist/components/switch.js +24 -6
  136. package/dist/components/switch.js.map +1 -1
  137. package/dist/components/text-link.d.ts +18 -13
  138. package/dist/components/tile-radio.d.ts +88 -13
  139. package/dist/components/tile-radio.js +28 -11
  140. package/dist/components/tile-radio.js.map +1 -1
  141. package/dist/components/time-input.d.ts +24 -15
  142. package/dist/components/time-input.js.map +1 -1
  143. package/dist/components/toggle-group.d.ts +47 -1
  144. package/dist/components/toggle-group.js +110 -69
  145. package/dist/components/toggle-group.js.map +1 -1
  146. package/dist/components/trigger-aria.d.ts +3 -0
  147. package/dist/components/trigger-aria.js +3 -1
  148. package/dist/components/trigger-aria.js.map +1 -1
  149. package/dist/components/ui.d.ts +16 -11
  150. package/dist/components/ui.js +284 -88
  151. package/dist/components/ui.js.map +1 -1
  152. package/dist/components/use-table-state.d.ts +1 -2
  153. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  154. package/dist/data-table.d.ts +1 -2
  155. package/dist/data-table.js +2 -1
  156. package/dist/data-table.js.map +1 -1
  157. package/dist/feedback/feedback-attachment.d.ts +1 -1
  158. package/dist/feedback/feedback-attachment.js +348 -41
  159. package/dist/feedback/feedback-attachment.js.map +1 -1
  160. package/dist/feedback/feedback-dialog.d.ts +1 -1
  161. package/dist/feedback/feedback-dialog.js.map +1 -1
  162. package/dist/feedback/feedback-inbox.d.ts +3 -2
  163. package/dist/feedback/feedback-inbox.js.map +1 -1
  164. package/dist/feedback/feedback-thread.d.ts +68 -253
  165. package/dist/feedback/feedback-thread.js +46 -10
  166. package/dist/feedback/feedback-thread.js.map +1 -1
  167. package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
  168. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
  169. package/dist/feedback.d.ts +66 -2
  170. package/dist/hooks/use-file-drop.d.ts +19 -14
  171. package/dist/i18n/defaults.d.ts +19 -14
  172. package/dist/i18n/defaults.js +19 -2
  173. package/dist/i18n/defaults.js.map +1 -1
  174. package/dist/i18n/german.d.ts +19 -14
  175. package/dist/i18n/german.js +84 -5
  176. package/dist/i18n/german.js.map +1 -1
  177. package/dist/i18n/kit-labels.d.ts +15 -10
  178. package/dist/i18n/kit-labels.js +8 -2
  179. package/dist/i18n/kit-labels.js.map +1 -1
  180. package/dist/i18n/languages.d.ts +19 -14
  181. package/dist/i18n/locales/de-CH.d.ts +19 -14
  182. package/dist/i18n/locales/en.d.ts +19 -14
  183. package/dist/i18n/locales/en.js +16 -0
  184. package/dist/i18n/locales/en.js.map +1 -1
  185. package/dist/i18n/locales/es.d.ts +19 -14
  186. package/dist/i18n/locales/es.js +83 -5
  187. package/dist/i18n/locales/es.js.map +1 -1
  188. package/dist/i18n/locales/fr.d.ts +19 -14
  189. package/dist/i18n/locales/fr.js +82 -5
  190. package/dist/i18n/locales/fr.js.map +1 -1
  191. package/dist/i18n/locales/hu.d.ts +19 -14
  192. package/dist/i18n/locales/hu.js +85 -5
  193. package/dist/i18n/locales/hu.js.map +1 -1
  194. package/dist/i18n/locales/it.d.ts +19 -14
  195. package/dist/i18n/locales/it.js +84 -5
  196. package/dist/i18n/locales/it.js.map +1 -1
  197. package/dist/i18n/locales/zh.d.ts +19 -14
  198. package/dist/i18n/locales/zh.js +80 -5
  199. package/dist/i18n/locales/zh.js.map +1 -1
  200. package/dist/i18n/review.d.ts +19 -14
  201. package/dist/i18n/review.js +27 -1
  202. package/dist/i18n/review.js.map +1 -1
  203. package/dist/index.d.ts +21 -10
  204. package/dist/index.js +73 -2
  205. package/dist/index.js.map +1 -1
  206. package/dist/lib/column-mapping.d.ts +81 -0
  207. package/dist/lib/column-mapping.js +108 -0
  208. package/dist/lib/column-mapping.js.map +1 -0
  209. package/dist/lib/countries.d.ts +43 -0
  210. package/dist/lib/countries.js +39 -0
  211. package/dist/lib/countries.js.map +1 -0
  212. package/dist/lib/decimal-marks.d.ts +46 -0
  213. package/dist/lib/decimal-marks.js +40 -0
  214. package/dist/lib/decimal-marks.js.map +1 -0
  215. package/dist/lib/iban.d.ts +98 -0
  216. package/dist/lib/iban.js +154 -0
  217. package/dist/lib/iban.js.map +1 -0
  218. package/dist/lib/isin.d.ts +30 -0
  219. package/dist/lib/isin.js +36 -0
  220. package/dist/lib/isin.js.map +1 -0
  221. package/dist/lib/phone.d.ts +69 -0
  222. package/dist/lib/phone.js +121 -0
  223. package/dist/lib/phone.js.map +1 -0
  224. package/dist/lib/table-text.d.ts +109 -1
  225. package/dist/lib/table-text.js +122 -1
  226. package/dist/lib/table-text.js.map +1 -1
  227. package/dist/rhf/fields.d.ts +233 -18
  228. package/dist/rhf/fields.js +456 -3
  229. package/dist/rhf/fields.js.map +1 -1
  230. package/dist/rhf/form.d.ts +19 -14
  231. package/dist/rhf.d.ts +22 -15
  232. package/dist/rhf.js.map +1 -1
  233. package/dist/shell/app-shell.d.ts +18 -13
  234. package/dist/shell/top-bar-brand.d.ts +19 -14
  235. package/dist/shell/topbar-action-menu.d.ts +36 -3
  236. package/dist/shell/topbar-action-menu.js +74 -33
  237. package/dist/shell/topbar-action-menu.js.map +1 -1
  238. package/dist/shell.d.ts +18 -13
  239. package/dist/table-text.d.ts +1 -1
  240. package/dist/tour/tour.js +20 -24
  241. package/dist/tour/tour.js.map +1 -1
  242. package/dist/wizard/stepper-nav.d.ts +49 -16
  243. package/dist/wizard/stepper-nav.js +4 -0
  244. package/dist/wizard/stepper-nav.js.map +1 -1
  245. package/dist/wizard.d.ts +19 -14
  246. package/package.json +1 -1
  247. package/src/components/account-chips.tsx +17 -3
  248. package/src/components/account-settings.tsx +92 -26
  249. package/src/components/amount-input.tsx +68 -56
  250. package/src/components/autocomplete.tsx +198 -114
  251. package/src/components/checkbox-group.tsx +229 -0
  252. package/src/components/checkbox.tsx +72 -8
  253. package/src/components/chip.tsx +80 -10
  254. package/src/components/column-mapper.tsx +666 -0
  255. package/src/components/combobox-core.tsx +82 -9
  256. package/src/components/combobox.tsx +661 -340
  257. package/src/components/confirm-dialog.tsx +12 -8
  258. package/src/components/country-select.tsx +556 -0
  259. package/src/components/currency-select.tsx +109 -33
  260. package/src/components/danger-confirm.tsx +429 -82
  261. package/src/components/data-table-labels.ts +16 -1
  262. package/src/components/data-table.tsx +175 -6
  263. package/src/components/date-picker.tsx +595 -71
  264. package/src/components/entity-combobox.tsx +235 -124
  265. package/src/components/field-parts.tsx +403 -0
  266. package/src/components/field-strip.tsx +149 -0
  267. package/src/components/file-button.tsx +36 -3
  268. package/src/components/file-dropzone.tsx +106 -38
  269. package/src/components/form-actions.tsx +147 -6
  270. package/src/components/iban-input.tsx +231 -0
  271. package/src/components/icon-picker.tsx +47 -6
  272. package/src/components/inline-edit-field.tsx +533 -0
  273. package/src/components/language-select.tsx +78 -0
  274. package/src/components/line-items.tsx +35 -2
  275. package/src/components/list.tsx +55 -6
  276. package/src/components/money-field.tsx +13 -0
  277. package/src/components/month-picker.tsx +400 -63
  278. package/src/components/multi-entity-combobox.tsx +207 -120
  279. package/src/components/multi-select.tsx +80 -58
  280. package/src/components/number-field.tsx +28 -46
  281. package/src/components/number-input.tsx +210 -43
  282. package/src/components/one-time-code-input.tsx +288 -0
  283. package/src/components/phone-input.tsx +299 -0
  284. package/src/components/reauth-dialog.tsx +17 -18
  285. package/src/components/sign-chip.tsx +103 -0
  286. package/src/components/swatch-picker.tsx +143 -31
  287. package/src/components/switch.tsx +58 -7
  288. package/src/components/tile-radio.tsx +145 -29
  289. package/src/components/time-input.tsx +8 -1
  290. package/src/components/toggle-group.tsx +206 -73
  291. package/src/components/trigger-aria.ts +5 -0
  292. package/src/components/ui.tsx +479 -138
  293. package/src/data-table.ts +2 -1
  294. package/src/feedback/feedback-attachment.tsx +597 -46
  295. package/src/feedback/feedback-dialog.tsx +9 -3
  296. package/src/feedback/feedback-inbox.tsx +3 -2
  297. package/src/feedback/feedback-thread.tsx +113 -9
  298. package/src/i18n/defaults.ts +18 -1
  299. package/src/i18n/german.ts +89 -0
  300. package/src/i18n/kit-labels.tsx +82 -2
  301. package/src/i18n/locales/en.ts +36 -4
  302. package/src/i18n/locales/es.ts +87 -0
  303. package/src/i18n/locales/fr.ts +87 -0
  304. package/src/i18n/locales/hu.ts +83 -0
  305. package/src/i18n/locales/it.ts +88 -0
  306. package/src/i18n/locales/zh.ts +76 -0
  307. package/src/i18n/review.ts +26 -0
  308. package/src/index.ts +71 -3
  309. package/src/lib/column-mapping.ts +234 -0
  310. package/src/lib/countries.ts +91 -0
  311. package/src/lib/decimal-marks.ts +99 -0
  312. package/src/lib/iban.ts +171 -0
  313. package/src/lib/isin.ts +62 -0
  314. package/src/lib/phone.ts +211 -0
  315. package/src/lib/table-text.ts +271 -0
  316. package/src/rhf/fields.tsx +860 -9
  317. package/src/rhf.ts +3 -1
  318. package/src/shell/topbar-action-menu.tsx +134 -38
  319. package/src/tour/tour.tsx +66 -28
  320. package/src/wizard/stepper-nav.tsx +35 -1
@@ -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 { Button, Spinner, type ButtonProps, type ButtonVariant } from "./ui";
7
+ import { isApplePlatform } from "../hooks/use-hotkey";
8
+ import { Button, Spinner, type ButtonProps, type ButtonSize, 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
  /**
@@ -173,6 +186,27 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
173
186
  /** The save button's variant. Default `brand`; `danger` for a save that destroys
174
187
  * (kastlan's `destructive` flag on its own FormActions). */
175
188
  submitVariant?: ButtonVariant;
189
+ /**
190
+ * The Cancel button's variant. Default `secondary` (the outlined button, as before).
191
+ * `ghost` for a row where Cancel should step back further than an outline — keksdose
192
+ * G3: a quiet inline row (a confirm under a table row, a card's own small editor)
193
+ * whose Cancel was a ghost button before it moved onto FormActions, which drew every
194
+ * Cancel outlined.
195
+ */
196
+ cancelVariant?: ButtonVariant;
197
+ /**
198
+ * The size of every button the row draws itself — Save, Cancel and the
199
+ * `{ label, onClick }` form of {@link destructive}: Button's `size`. Default `md`.
200
+ * `sm` (12px text, `px-2 py-1`) for a dense row: a confirm inside a table's expansion
201
+ * row, a toolbar card. keksdose G3: two such rows had small hand-built buttons and lost
202
+ * them on adopting FormActions, which had no size. At `sm` the spinner and
203
+ * {@link submitIcon} shrink with the text.
204
+ *
205
+ * Your own elements (`children`, an element passed as `destructive`, `start`) are
206
+ * yours to size — pass them `size="sm"` too. A `size` in {@link submitProps} still
207
+ * wins for Save, as it always has.
208
+ */
209
+ size?: ButtonSize;
176
210
  /**
177
211
  * A destructive action at the START of the row, apart from Save and Cancel — the
178
212
  * "Delete" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger
@@ -229,10 +263,60 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
229
263
  /** The `id` of the `<form>` the save button submits, when the row is rendered
230
264
  * outside it (a dialog's footer slot). */
231
265
  form?: string;
266
+ /**
267
+ * A keyboard shortcut that presses Save from anywhere in the form. `"mod-enter"`:
268
+ * Ctrl+Enter, or Cmd+Enter on a Mac — both are accepted everywhere, as keksdose's
269
+ * editors always have. keksdose K14 (inputs audit 2026-10-02, S5): its transaction
270
+ * editor, category editor and create card each wrote this as an `onKeyDown` on a
271
+ * container, re-stating `!pending && !saveDisabled && !lock.locked` every time, and
272
+ * the one that forgot the lock let the demo's refused save through from the keyboard.
273
+ *
274
+ * What it does is what pressing Save does — the same button is clicked, so a form
275
+ * submits with its validation and its submitter, and `onSubmit` is called in the
276
+ * `type="button"` mode — and it does NOTHING while Save could not be pressed:
277
+ * `pending`, `submitDisabled`, or a `commit` under a locked {@link WriteLockProvider}.
278
+ *
279
+ * Scoped to the form Save submits (the `form` prop's, else the enclosing `<form>`):
280
+ * the chord counts only when the focus is inside it, so two editors on one page each
281
+ * answer for their own fields. Without a form (an `onSubmit` row in a plain container)
282
+ * the scope is the nearest `role="dialog"`, else the row's parent element — the
283
+ * container the row ends.
284
+ *
285
+ * Listened for on the document, so it runs AFTER every React handler: a field that
286
+ * takes Ctrl+Enter for itself calls `preventDefault()` or `stopPropagation()` and
287
+ * the row stays out of it (keksdose live #202 — the create card's Ctrl+Shift+Enter
288
+ * must create ONE row; the chord here is exactly Ctrl/Cmd+Enter, never with Shift or
289
+ * Alt). Not during IME composition, and not on a held key's repeats.
290
+ *
291
+ * The save button announces it with `aria-keyshortcuts`; {@link submitShortcutHint}
292
+ * also shows it.
293
+ */
294
+ submitShortcut?: FormActionsSubmitShortcut;
295
+ /** Show the {@link submitShortcut} beside Save's text ("Ctrl+Enter", "⌘ Enter"; the
296
+ * `form.submitShortcut` label), dimmed. Hidden on a touch-only device, which has no
297
+ * keyboard to press it on. Default `false`. */
298
+ submitShortcutHint?: boolean;
232
299
  /** Extra actions, placed before Cancel. */
233
300
  children?: ReactNode;
234
301
  }
235
302
 
303
+ /** Hand `el` to a caller's ref, whichever kind it is. */
304
+ function assignRef<E>(ref: Ref<E> | undefined, el: E | null): void {
305
+ if (typeof ref === "function") ref(el);
306
+ else if (ref) (ref as { current: E | null }).current = el;
307
+ }
308
+
309
+ /**
310
+ * Where {@link FormActionsProps.submitShortcut} listens: the form Save submits (named
311
+ * by `formId`, else the enclosing one), else the nearest dialog, else the row's parent.
312
+ */
313
+ function shortcutScope(row: HTMLElement | null, formId: string | undefined): Element | null {
314
+ if (!row) return null;
315
+ const named = formId ? row.ownerDocument.getElementById(formId) : null;
316
+ if (named instanceof HTMLFormElement) return named;
317
+ return row.closest("form") ?? row.closest('[role="dialog"]') ?? row.parentElement;
318
+ }
319
+
236
320
  function isDestructiveData(value: unknown): value is FormActionsDestructive {
237
321
  return typeof value === "object" && value !== null && !isValidElement(value) && "onClick" in value;
238
322
  }
@@ -265,6 +349,8 @@ export function FormActions({
265
349
  submitDisabledReason,
266
350
  commit = false,
267
351
  submitVariant = "brand",
352
+ cancelVariant = "secondary",
353
+ size = "md",
268
354
  destructive,
269
355
  start: startSlot,
270
356
  align,
@@ -272,12 +358,47 @@ export function FormActions({
272
358
  stickyWithin = "viewport",
273
359
  bleed,
274
360
  form,
361
+ submitShortcut,
362
+ submitShortcutHint = false,
275
363
  children,
276
364
  className,
277
365
  style,
278
366
  ...rest
279
367
  }: FormActionsProps) {
280
368
  const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
369
+ const lock = useWriteLock();
370
+ const rootRef = useRef<HTMLDivElement>(null);
371
+ const saveRef = useRef<HTMLButtonElement | null>(null);
372
+ // The caller's own `submitProps.ref` still gets the element.
373
+ const callerSaveRef = submitProps?.ref;
374
+ const setSaveRef = useCallback(
375
+ (el: HTMLButtonElement | null) => {
376
+ saveRef.current = el;
377
+ assignRef(callerSaveRef, el);
378
+ },
379
+ [callerSaveRef],
380
+ );
381
+ // Read by the shortcut's listener at the moment of the key press, so the listener
382
+ // itself is attached once and never sees a stale `pending`.
383
+ const canSave = !pending && !submitDisabled && !(commit && lock.locked);
384
+ const canSaveRef = useRef(canSave);
385
+ useEffect(() => {
386
+ canSaveRef.current = canSave;
387
+ });
388
+ useEffect(() => {
389
+ if (submitShortcut !== "mod-enter") return;
390
+ const onKeyDown = (e: KeyboardEvent) => {
391
+ if (e.key !== "Enter" || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
392
+ if (e.repeat || e.isComposing || e.defaultPrevented || !canSaveRef.current) return;
393
+ const scope = shortcutScope(rootRef.current, form);
394
+ if (!scope || !(e.target instanceof Node) || !scope.contains(e.target)) return;
395
+ e.preventDefault();
396
+ saveRef.current?.click();
397
+ };
398
+ document.addEventListener("keydown", onKeyDown);
399
+ return () => document.removeEventListener("keydown", onKeyDown);
400
+ }, [submitShortcut, form]);
401
+ const apple = submitShortcut ? isApplePlatform() : false;
281
402
  const placement = useResolvedPlacement(placementProp);
282
403
  const bleedLength = bleed === undefined || bleed === "" ? undefined : cssLength(bleed);
283
404
  const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
@@ -288,6 +409,7 @@ export function FormActions({
288
409
  type="button"
289
410
  variant="ghost"
290
411
  tone="danger"
412
+ size={size}
291
413
  onClick={destructive.onClick}
292
414
  disabled={destructive.disabled}
293
415
  disabledReason={destructive.disabledReason}
@@ -299,9 +421,13 @@ export function FormActions({
299
421
  destructive
300
422
  );
301
423
  const saveLabel = submitLabel ?? labels.save;
424
+ // The glyphs follow the text: 16px beside `md`'s 14px text, 14px beside `sm`'s 12px.
425
+ const saveSize = submitProps?.size ?? size;
426
+ const glyphClass = saveSize === "sm" ? "size-3.5" : "size-4";
302
427
 
303
428
  return (
304
429
  <div
430
+ ref={rootRef}
305
431
  data-slot="form-actions"
306
432
  data-placement={placement}
307
433
  data-sticky-within={placement === "sticky" ? stickyWithin : undefined}
@@ -336,16 +462,21 @@ export function FormActions({
336
462
  <div className="flex flex-wrap items-center gap-2">
337
463
  {children}
338
464
  {onCancel && (
339
- <Button type="button" variant="secondary" onClick={onCancel}>
465
+ <Button type="button" variant={cancelVariant} size={size} onClick={onCancel}>
340
466
  {cancelLabel ?? labels.cancel}
341
467
  </Button>
342
468
  )}
343
469
  <Button
344
470
  {...submitProps}
471
+ ref={setSaveRef}
472
+ aria-keyshortcuts={
473
+ submitShortcut === "mod-enter" ? "Control+Enter Meta+Enter" : submitProps?.["aria-keyshortcuts"]
474
+ }
345
475
  type={onSubmit ? "button" : "submit"}
346
476
  form={form}
347
477
  onClick={onSubmit}
348
478
  variant={submitVariant}
479
+ size={saveSize}
349
480
  disabled={pending || submitDisabled}
350
481
  disabledReason={pending ? undefined : submitDisabledReason}
351
482
  // Not while pending: the save already left, and the lock's look over the
@@ -354,11 +485,21 @@ export function FormActions({
354
485
  aria-busy={pending || undefined}
355
486
  >
356
487
  {pending ? (
357
- <Spinner label={null} className="size-4" />
488
+ <Spinner label={null} className={glyphClass} />
358
489
  ) : (
359
- SubmitIcon && <SubmitIcon aria-hidden className="size-4 shrink-0" />
490
+ SubmitIcon && <SubmitIcon aria-hidden className={cn(glyphClass, "shrink-0")} />
360
491
  )}
361
492
  {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}
493
+ {submitShortcut && submitShortcutHint && (
494
+ // Hidden from the accessibility tree: `aria-keyshortcuts` already says it,
495
+ // and in the name it would make the button "Save Ctrl+Enter".
496
+ <kbd
497
+ aria-hidden
498
+ className="ms-1 font-sans text-[0.85em] font-normal whitespace-nowrap opacity-70 pointer-coarse:hidden"
499
+ >
500
+ {labels.submitShortcut(apple)}
501
+ </kbd>
502
+ )}
362
503
  </Button>
363
504
  </div>
364
505
  </div>