@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
@@ -0,0 +1,533 @@
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+ import type { FocusEvent, InputHTMLAttributes, KeyboardEvent, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { useAnnounce } from "../hooks/use-announce";
5
+ import { useKitLabels } from "../i18n/kit-labels";
6
+ import { Input, Spinner } from "./ui";
7
+ import { Tooltip } from "./tooltip";
8
+ import { useCommitReason } from "./write-lock";
9
+
10
+ /**
11
+ * Every string {@link InlineEditField} says on its own behalf — the `inlineEdit`
12
+ * namespace of `<UiKitProvider labels>`, overridable per instance through `labels`.
13
+ */
14
+ export interface InlineEditLabels {
15
+ /** The display button's tooltip and description — what pressing it does. Receives
16
+ * the field's `label` ("Budget name" → "Edit Budget name"). */
17
+ edit: (label: string) => string;
18
+ /** Shown (and spoken) when `onCommit` rejects and no `formatError` says better. */
19
+ failed: string;
20
+ /** What the display shows for an empty value, when no `placeholder` is given. */
21
+ empty: string;
22
+ }
23
+
24
+ export const DEFAULT_INLINE_EDIT_LABELS: InlineEditLabels = {
25
+ edit: (label) => `Edit ${label}`,
26
+ failed: "The change could not be saved.",
27
+ empty: "Empty",
28
+ };
29
+
30
+ /** What an `editor` render prop is handed: the draft and the means to end the edit. */
31
+ export interface InlineEditorProps<T> {
32
+ /** The draft — seeded from `value` each time the editor opens. */
33
+ value: T;
34
+ /** Replace the draft (every keystroke). */
35
+ onChange: (next: T) => void;
36
+ /**
37
+ * End the edit and save. Without an argument it saves the draft; WITH one it saves
38
+ * that — for an editor whose own commit hands over a resolved value (`NumberInput`'s
39
+ * `onCommit` gives "1050" for a typed "1100-50"). Called twice (the editor's own
40
+ * Enter, then the field's), the second call does nothing.
41
+ */
42
+ commit: (next?: T) => void;
43
+ /** End the edit and drop the draft — what Escape does. */
44
+ cancel: () => void;
45
+ /** The save is in flight: make the control read-only (not disabled — it has focus). */
46
+ pending: boolean;
47
+ /** The field's `label`, for the control's accessible name. */
48
+ label: string;
49
+ /** The last save failed and the draft has not been touched since. */
50
+ invalid: boolean;
51
+ /** The id of the failure message while there is one, for `aria-describedby`. */
52
+ describedBy: string | undefined;
53
+ /** The four above as attributes — spread them on a kit field that takes the DOM
54
+ * spelling: `<AmountInput {...p.inputProps} … />`. */
55
+ inputProps: {
56
+ "aria-label": string;
57
+ "aria-invalid": true | undefined;
58
+ "aria-describedby": string | undefined;
59
+ readOnly: boolean;
60
+ };
61
+ }
62
+
63
+ interface InlineEditFieldBaseProps<T> {
64
+ /** The saved value — what the display shows, and what the editor opens on. */
65
+ value: T;
66
+ /**
67
+ * Save `next`. Return a promise and the field waits: the editor stays open and
68
+ * read-only with a spinner until it settles, closes when it resolves, and STAYS OPEN
69
+ * with the failure under it when it rejects (or throws) — the typed value is kept for
70
+ * a retry. Not called when the draft equals `value` (see `isEqual`): opening the
71
+ * editor and leaving it unchanged is not a write.
72
+ */
73
+ onCommit: (next: T) => void | Promise<unknown>;
74
+ /** What the value is ("Budget name", "Assigned") — the editor's accessible name and
75
+ * the subject of the display's "Edit …" description. Not drawn: inline, the row or
76
+ * the column header is the visible label. */
77
+ label: string;
78
+ /** The value as the display shows it — a formatted amount, a date. Default: the
79
+ * value as text. */
80
+ display?: (value: T) => ReactNode;
81
+ /** Whether the draft is unchanged, so leaving the editor is not a write. Default
82
+ * `Object.is`. keksdose compares assigned amounts as NUMBERS — "1100" is the
83
+ * server's "1100.0000". */
84
+ isEqual?: (a: T, b: T) => boolean;
85
+ /** What the display shows for an empty value (`""`, `null`, an empty `display`).
86
+ * Default `labels.empty`. */
87
+ placeholder?: ReactNode;
88
+ /** `"end"` for a figure in a right-aligned money column. Default `"start"`. */
89
+ align?: "start" | "end";
90
+ /** Look, do not touch — a guest's view: the value as plain text, no button. */
91
+ readOnly?: boolean;
92
+ /**
93
+ * Why it cannot be edited now — the write lock's sentence, a closed period. The
94
+ * value stays a BUTTON (focusable, `aria-disabled`) with the reason in the kit
95
+ * Tooltip and its description; it opens no editor. An editor that is open when the
96
+ * reason arrives closes, its draft dropped: a field you can type into must be one
97
+ * whose value can be saved (keksdose dev#496).
98
+ */
99
+ disabledReason?: ReactNode;
100
+ /** This field COMMITS — under a locked {@link WriteLockProvider} it takes the
101
+ * `disabledReason` path with the lock's reason (which wins over its own). No
102
+ * provider, or an unlocked one: no effect. */
103
+ commit?: boolean;
104
+ /** The value is the user's own data: `data-private` on the display and the editor,
105
+ * so the app's demo-mode blur covers both. */
106
+ redact?: boolean;
107
+ /** Turns a rejection into words. Default `labels.failed`. */
108
+ formatError?: (error: unknown) => ReactNode;
109
+ /** Controlled open state — a row's swipe action that opens the editor from outside
110
+ * (keksdose's assigned cell). Left out, the field keeps its own. */
111
+ editing?: boolean;
112
+ /** Called with the next open state: on open, on a save that resolved, on cancel. */
113
+ onEditingChange?: (editing: boolean) => void;
114
+ /** Attributes for the DEFAULT text editor's `<input>` — `maxLength`, `inputMode`,
115
+ * `autoComplete`. Ignored with `editor`. */
116
+ inputProps?: Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "type">;
117
+ /** Classes for the root. */
118
+ className?: string;
119
+ labels?: Partial<InlineEditLabels>;
120
+ }
121
+
122
+ type EditorRender<T> = (props: InlineEditorProps<T>) => ReactNode;
123
+
124
+ /**
125
+ * The editor. Optional for a string value — the kit's `Input`, compact — and
126
+ * REQUIRED for anything else, since only the caller knows how to edit a number or an
127
+ * amount. Render a kit field from the props it is handed:
128
+ *
129
+ * ```tsx
130
+ * editor={(p) => (
131
+ * <NumberInput calculator={false} ariaLabel={p.label} value={p.value} onChange={p.onChange}
132
+ * onCommit={p.commit} aria-invalid={p.invalid || undefined} aria-describedby={p.describedBy} />
133
+ * )}
134
+ * ```
135
+ *
136
+ * The field itself handles Enter (save), Escape (cancel) and focus leaving the editor
137
+ * (save) around whatever is rendered — a key the editor has already handled
138
+ * (`preventDefault`, as a listbox does for its Enter) is left to it. Focus counts as
139
+ * "inside" across a portal, so a picker or a phone keypad the editor opens does not
140
+ * end the edit.
141
+ */
142
+ export type InlineEditFieldProps<T = string> = InlineEditFieldBaseProps<T> &
143
+ ([T] extends [string] ? { editor?: EditorRender<T> } : { editor: EditorRender<T> });
144
+
145
+ function hasContent(node: ReactNode): boolean {
146
+ return node !== undefined && node !== null && node !== false && node !== "";
147
+ }
148
+
149
+ /** Calls the `editor` render prop as a component of its own: the props it is handed
150
+ * close over the field's refs, and a render prop called in the field's own render
151
+ * would read like a ref read during render. Module-level, so the type is stable and
152
+ * the editor never remounts. */
153
+ function RenderEditor<T>({ render, ...props }: InlineEditorProps<T> & { render: EditorRender<T> }) {
154
+ return render(props);
155
+ }
156
+
157
+ /** `onCommit`'s result, if it is a promise. */
158
+ function asPromise(result: unknown): Promise<unknown> | null {
159
+ return result && typeof (result as Promise<unknown>).then === "function" ? (result as Promise<unknown>) : null;
160
+ }
161
+
162
+ /**
163
+ * A value that edits in place: a quiet button showing the value; click it (or Enter
164
+ * on it) and it becomes a field; Enter or leaving the field saves, Escape cancels
165
+ * (keksdose K9).
166
+ *
167
+ * keksdose has three of them by hand — the budget's assigned cell (budget-cells.tsx),
168
+ * the invoice line's value cells (invoice-lines-table.tsx `EditableCell`) and the
169
+ * budget rename (budgets-page.tsx, an `Input` + Save + Cancel with no Escape) — and
170
+ * each had to learn the same rules:
171
+ *
172
+ * - **The display's accessible name is the VALUE.** A cell named "Edit" reads a table
173
+ * of "edit, edit, edit"; the action is the description instead ("Edit Assigned"),
174
+ * in the kit Tooltip on hover and focus.
175
+ * - **An unchanged draft is not a write.** Opening the editor and leaving it was a PUT
176
+ * in keksdose until `isEqual` compared numbers, and on a sealed budget an outbox row
177
+ * for a change nobody made.
178
+ * - **A failed save keeps the editor.** The typed value is the user's work; the
179
+ * failure is shown under it (attached with `aria-describedby`, and spoken), and the
180
+ * next keystroke clears it. Escape still drops the draft.
181
+ * - **Locked means a disabled BUTTON, not a disabled field** (keksdose dev#496): at
182
+ * rest a real row shows this button, so a locked page shows the same, with the
183
+ * reason — never a field that takes keys it cannot save.
184
+ *
185
+ * Inside a {@link DataTable} cell it needs nothing extra: the table already leaves a
186
+ * click or a key on a control of the cell to that control. Focus returns to the value
187
+ * after Enter or Escape; after a save that came from focus leaving, it stays where the
188
+ * user put it.
189
+ *
190
+ * ```tsx
191
+ * <InlineEditField label={t("budgets.name")} value={budget.name}
192
+ * onCommit={(name) => rename.mutateAsync({ id: budget.id, name })} inputProps={{ maxLength: 120 }} />
193
+ * ```
194
+ */
195
+ export function InlineEditField<T = string>(props: InlineEditFieldProps<T>) {
196
+ const {
197
+ value,
198
+ onCommit,
199
+ label,
200
+ display,
201
+ isEqual = Object.is,
202
+ placeholder,
203
+ align = "start",
204
+ readOnly = false,
205
+ disabledReason: ownDisabledReason,
206
+ commit,
207
+ redact = false,
208
+ formatError,
209
+ editing: editingProp,
210
+ onEditingChange,
211
+ inputProps,
212
+ className,
213
+ labels: labelsProp,
214
+ } = props;
215
+ const editor = props.editor as EditorRender<T> | undefined;
216
+ const labels = useKitLabels("inlineEdit", DEFAULT_INLINE_EDIT_LABELS, labelsProp);
217
+ const disabledReason = useCommitReason(commit, ownDisabledReason);
218
+ const locked = hasContent(disabledReason);
219
+
220
+ const [openState, setOpenState] = useState(false);
221
+ const open = editingProp ?? openState;
222
+ // Locked (or read-only) wins over an open flag: a stale `editing` must never bring a
223
+ // live commit-on-blur field back on a page that cannot save it.
224
+ const editing = open && !locked && !readOnly;
225
+ const [draft, setDraft] = useState<T>(value);
226
+ const [pending, setPending] = useState(false);
227
+ const [failure, setFailure] = useState<ReactNode>(null);
228
+ const failed = hasContent(failure);
229
+
230
+ const errorId = useId();
231
+ const reasonId = useId();
232
+ const displayRef = useRef<HTMLButtonElement>(null);
233
+ const editorRef = useRef<HTMLDivElement>(null);
234
+ const alert = useAnnounce({ politeness: "assertive" });
235
+
236
+ // One edit ends once: Enter in a NumberInput commits through its own `onCommit` and
237
+ // then reaches the field's Enter; a save that closes the editor unmounts the focused
238
+ // control, which some browsers answer with a blur. The second ending is a no-op.
239
+ const ended = useRef(false);
240
+ const blurTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
241
+ const mounted = useRef(true);
242
+ // The latest draft for the deferred blur check, which runs after a render.
243
+ const draftRef = useRef(draft);
244
+ useEffect(() => {
245
+ draftRef.current = draft;
246
+ });
247
+ useEffect(() => {
248
+ mounted.current = true;
249
+ return () => {
250
+ mounted.current = false;
251
+ if (blurTimer.current) clearTimeout(blurTimer.current);
252
+ };
253
+ }, []);
254
+
255
+ // Seed the draft when the editor opens — from wherever the open came (the button, a
256
+ // controlled `editing`). During render, like DangerConfirm's wipe, so no frame shows
257
+ // the editor holding the previous edit's text.
258
+ const [wasEditing, setWasEditing] = useState(editing);
259
+ if (wasEditing !== editing) {
260
+ setWasEditing(editing);
261
+ if (editing) {
262
+ setDraft(value);
263
+ setFailure(null);
264
+ setPending(false);
265
+ }
266
+ }
267
+ // An open editor that a lock (or `readOnly`) took away is closed for good, not
268
+ // merely hidden until the lock lifts.
269
+ if (openState && !editing && editingProp === undefined) setOpenState(false);
270
+
271
+ // Focus follows the edit: into the editor when it opens, back to the value when it
272
+ // closes — but only if focus went down with the editor (Enter, Escape, a save that
273
+ // resolved under it). A save that came from focus LEAVING keeps focus where it went.
274
+ const prevEditing = useRef(editing);
275
+ useEffect(() => {
276
+ const changed = prevEditing.current !== editing;
277
+ prevEditing.current = editing;
278
+ if (!changed) return;
279
+ if (editing) {
280
+ // A fresh edit, which may end once. Here rather than in the seed above: a ref is
281
+ // not written during render, and no event can end the edit before this runs.
282
+ ended.current = false;
283
+ const control = editorRef.current?.querySelector<HTMLElement>(
284
+ "input:not([type=hidden]), textarea, select, [contenteditable='true'], [tabindex]:not([tabindex='-1']), button",
285
+ );
286
+ control?.focus();
287
+ if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) control.select();
288
+ return;
289
+ }
290
+ // However it closed — a save, Escape, a lock taking it away — a blur check still
291
+ // queued from the editor has nothing left to save.
292
+ ended.current = true;
293
+ if (blurTimer.current) clearTimeout(blurTimer.current);
294
+ blurTimer.current = null;
295
+ const lost = document.activeElement === null || document.activeElement === document.body;
296
+ if (lost) displayRef.current?.focus();
297
+ }, [editing]);
298
+
299
+ const setOpen = (next: boolean) => {
300
+ if (editingProp === undefined) setOpenState(next);
301
+ onEditingChange?.(next);
302
+ };
303
+
304
+ const fail = (caught: unknown) => {
305
+ ended.current = false;
306
+ const message = formatError ? formatError(caught) : labels.failed;
307
+ setFailure(hasContent(message) ? message : labels.failed);
308
+ alert.announce(typeof message === "string" && message !== "" ? message : labels.failed);
309
+ // Focus back into the editor if the failure left it nowhere (a pending field that
310
+ // the browser let go of); never pulled back from somewhere the user went.
311
+ const active = document.activeElement;
312
+ if (active === null || active === document.body) {
313
+ editorRef.current?.querySelector<HTMLElement>("input, textarea, select, [tabindex]")?.focus();
314
+ }
315
+ };
316
+
317
+ const save = (next: T = draftRef.current) => {
318
+ if (!editing || ended.current || pending) return;
319
+ ended.current = true;
320
+ if (next !== draftRef.current) setDraft(next);
321
+ if (isEqual(next, value)) {
322
+ setOpen(false);
323
+ return;
324
+ }
325
+ let result: unknown;
326
+ try {
327
+ result = onCommit(next);
328
+ } catch (caught) {
329
+ fail(caught);
330
+ return;
331
+ }
332
+ const promise = asPromise(result);
333
+ if (!promise) {
334
+ setOpen(false);
335
+ return;
336
+ }
337
+ setPending(true);
338
+ setFailure(null);
339
+ promise.then(
340
+ () => {
341
+ if (!mounted.current) return;
342
+ setPending(false);
343
+ setOpen(false);
344
+ },
345
+ (caught: unknown) => {
346
+ if (!mounted.current) return;
347
+ setPending(false);
348
+ fail(caught);
349
+ },
350
+ );
351
+ };
352
+
353
+ const cancel = () => {
354
+ // A request that has left cannot be called back; Escape waits for it.
355
+ if (!editing || pending) return;
356
+ ended.current = true;
357
+ setOpen(false);
358
+ };
359
+
360
+ const change = (next: T) => {
361
+ if (pending) return;
362
+ setDraft(next);
363
+ // Now, not after the render: a blur check scheduled before that render reads it.
364
+ draftRef.current = next;
365
+ // The failure described the draft that was sent; once edited it describes nothing
366
+ // on screen, and the next save (or blur) is a fresh attempt.
367
+ setFailure(null);
368
+ };
369
+
370
+ const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
371
+ if (e.defaultPrevented) return;
372
+ if (e.key === "Escape") {
373
+ // Ours, not the dialog's or the popover's around the field.
374
+ e.preventDefault();
375
+ e.stopPropagation();
376
+ cancel();
377
+ return;
378
+ }
379
+ // Not mid-composition: Enter there picks the IME's candidate (Japanese, Chinese).
380
+ if (e.key === "Enter" && !e.nativeEvent.isComposing && !e.shiftKey) {
381
+ e.preventDefault();
382
+ save();
383
+ }
384
+ };
385
+
386
+ // Leaving the editor saves. Deferred one task, and cancelled by any focus that lands
387
+ // back inside: React reports focus moves through PORTALS to the component tree, so a
388
+ // calculator popover or a phone keypad the editor opened counts as inside, which a
389
+ // DOM `contains` check would get wrong. A failure on screen holds the editor: the
390
+ // user is looking at why, and moving away is not a retry.
391
+ const onBlur = (e: FocusEvent<HTMLDivElement>) => {
392
+ if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
393
+ if (blurTimer.current) clearTimeout(blurTimer.current);
394
+ blurTimer.current = setTimeout(() => {
395
+ blurTimer.current = null;
396
+ if (!mounted.current || failed) return;
397
+ save();
398
+ }, 0);
399
+ };
400
+ const onFocus = () => {
401
+ if (blurTimer.current) clearTimeout(blurTimer.current);
402
+ blurTimer.current = null;
403
+ };
404
+
405
+ const private_ = redact ? { "data-private": true } : undefined;
406
+ const shown = display ? display(value) : value === null || value === undefined ? "" : String(value);
407
+ const content = hasContent(shown) ? (
408
+ shown
409
+ ) : (
410
+ <span className="text-[var(--text-muted)]">{placeholder ?? labels.empty}</span>
411
+ );
412
+ const alignClass = align === "end" ? "text-end" : "text-start";
413
+
414
+ let body: ReactNode;
415
+ if (readOnly) {
416
+ body = (
417
+ <span {...private_} className={cn("block min-w-0 truncate", alignClass)}>
418
+ {content}
419
+ </span>
420
+ );
421
+ } else if (editing) {
422
+ const describedBy = failed ? errorId : undefined;
423
+ const editorProps: InlineEditorProps<T> = {
424
+ value: draft,
425
+ onChange: change,
426
+ commit: save,
427
+ cancel,
428
+ pending,
429
+ label,
430
+ invalid: failed,
431
+ describedBy,
432
+ inputProps: {
433
+ "aria-label": label,
434
+ "aria-invalid": failed || undefined,
435
+ "aria-describedby": describedBy,
436
+ readOnly: pending,
437
+ },
438
+ };
439
+ body = (
440
+ // A wrapper for the keys and the focus, not a control: the keys belong to the
441
+ // field inside it, which is what has focus.
442
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- see above
443
+ <div
444
+ ref={editorRef}
445
+ {...private_}
446
+ onKeyDown={onKeyDown}
447
+ onBlur={onBlur}
448
+ onFocus={onFocus}
449
+ aria-busy={pending || undefined}
450
+ className="min-w-0"
451
+ >
452
+ <div className="flex min-w-0 items-center gap-1.5">
453
+ <div className="min-w-0 flex-1">
454
+ {editor ? (
455
+ <RenderEditor render={editor} {...editorProps} />
456
+ ) : (
457
+ <Input
458
+ {...inputProps}
459
+ {...editorProps.inputProps}
460
+ value={String(draft ?? "")}
461
+ onChange={(e) => change(e.target.value as unknown as T)}
462
+ className={cn("px-2 py-1", alignClass, inputProps?.className)}
463
+ />
464
+ )}
465
+ </div>
466
+ {/* Decorative: `aria-busy` on the wrapper says it. */}
467
+ {pending && <Spinner label={null} className="size-4 shrink-0" />}
468
+ </div>
469
+ {failed && (
470
+ <p id={errorId} className="mt-1 text-[11px] leading-tight text-[var(--danger)]">
471
+ {failure}
472
+ </p>
473
+ )}
474
+ </div>
475
+ );
476
+ } else if (locked) {
477
+ // Button's `disabledReason` path, spelled out for a button that must look like
478
+ // the value rather than like a Button: focusable, `aria-disabled`, every
479
+ // activation swallowed, the reason in the kit Tooltip and in a `hidden` copy that
480
+ // describes it. The button in a FRAGMENT so the Tooltip does not add the bubble as
481
+ // a second description of the same sentence.
482
+ body = (
483
+ <Tooltip label={disabledReason} className="flex w-full min-w-0">
484
+ <>
485
+ <button
486
+ ref={displayRef}
487
+ type="button"
488
+ {...private_}
489
+ aria-disabled
490
+ aria-describedby={reasonId}
491
+ onClick={(e) => e.preventDefault()}
492
+ className={cn(
493
+ "block w-full min-w-0 cursor-not-allowed truncate rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
494
+ alignClass,
495
+ )}
496
+ >
497
+ {content}
498
+ </button>
499
+ <span id={reasonId} hidden>
500
+ {disabledReason}
501
+ </span>
502
+ </>
503
+ </Tooltip>
504
+ );
505
+ } else {
506
+ body = (
507
+ // Lazy: a table of these mounts no bubble until one is hovered or focused, and
508
+ // the closed bubble's text is in no ancestor's accessible name.
509
+ <Tooltip label={labels.edit(label)} lazy className="flex w-full min-w-0">
510
+ <button
511
+ ref={displayRef}
512
+ type="button"
513
+ {...private_}
514
+ onClick={() => setOpen(true)}
515
+ className={cn(
516
+ "block w-full min-w-0 truncate rounded-sm underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]",
517
+ alignClass,
518
+ )}
519
+ >
520
+ {content}
521
+ </button>
522
+ </Tooltip>
523
+ );
524
+ }
525
+
526
+ return (
527
+ <div className={cn("min-w-0", className)} data-editing={editing || undefined}>
528
+ {body}
529
+ {/* Always mounted: a live region that mounts with its message is usually missed. */}
530
+ <span {...alert.regionProps} />
531
+ </div>
532
+ );
533
+ }
@@ -0,0 +1,78 @@
1
+ import { forwardRef } from "react";
2
+ import { KIT_LANGUAGES, languageOptions, resolveLanguage } from "../i18n/languages";
3
+ import type { KitLanguage, KitLanguageCode } from "../i18n/languages";
4
+ import { Select } from "./ui";
5
+ import type { SelectProps } from "./ui";
6
+
7
+ /**
8
+ * A language as a FORM FIELD: one of the codes an app offers, picked in a form beside
9
+ * other fields — label, hint and error like every kit field, controlled, submitted with
10
+ * the rest.
11
+ *
12
+ * Kurvenschmiede's admin invites people by mail, and the invitation goes out in a
13
+ * language the admin picks, because the invitee has no account yet to say theirs
14
+ * (allowlist-panel.tsx: a `Select` with an `<option>` per language, spelled out by
15
+ * hand). Neither language control the kit had fits there: `LanguageMenu` is the top
16
+ * bar's switcher for the reader's OWN language, a flag in a hover menu that changes the
17
+ * page the moment it is picked; `LanguageSetting` is the settings row for the same
18
+ * thing and takes ready-made `{ code, label }` rows. This one is for a language that
19
+ * belongs to something else — an invitation, a mail template, a customer's
20
+ * correspondence — and takes only the CODES: the names come from the kit's registry
21
+ * ({@link languageOptions}), the same "Deutsch", "Français", "简体中文" the switcher shows.
22
+ *
23
+ * Every {@link Select} prop passes through (`label`, `hint`, `error`, `required`,
24
+ * `disabled`, `size`, `name`, `id`, `data-*`); only `value`, `onChange` and the options
25
+ * are this component's.
26
+ *
27
+ * WHAT IT SHOWS FOR `value`. The offered code that answers it, by
28
+ * {@link resolveLanguage}'s rule: so i18next's `resolvedLanguage` can be handed in as it
29
+ * comes, and a stored `de` or `de-AT` shows as the one German, `de-CH`. A value that
30
+ * answers to none of the codes shows the fallback `resolveLanguage` would pick (`de-CH`
31
+ * when offered, else the first code) — where a bare native select would show its first
32
+ * option and say nothing. Either way the field does not write the resolved code back:
33
+ * `onChange` fires when the person picks, as on any field. Keep `value` one of `codes`
34
+ * (resolve it once, where the state starts) and what is shown is what is submitted.
35
+ *
36
+ * Each name is marked with its own language (`lang`), so a screen reader says
37
+ * "Français" in French rather than spelling it in the page's voice. A caller's
38
+ * `optionLabel` (English names for a team-facing list) is in the page's language, and
39
+ * goes unmarked.
40
+ */
41
+ export interface LanguageSelectProps extends Omit<SelectProps, "value" | "onChange" | "children" | "multiple"> {
42
+ /** The chosen language: one of `codes` — or any tag, shown as the code it resolves to. */
43
+ value: string;
44
+ /** The picked code — always one of `codes`. */
45
+ onChange: (code: KitLanguageCode) => void;
46
+ /** The languages to offer, in the order to list them. Default: all seven
47
+ * ({@link KIT_LANGUAGES}' order, alphabetical by native name). */
48
+ codes?: readonly KitLanguageCode[];
49
+ /**
50
+ * The text of each option. Default: the language's name in itself — the right name
51
+ * for a reader looking for theirs, and for an admin choosing what an invitee reads.
52
+ * `(language) => language.englishName` for a list read by a team rather than by the
53
+ * language's readers (a reviewer grant), or the app's own words for a code.
54
+ */
55
+ optionLabel?: (language: KitLanguage) => string;
56
+ }
57
+
58
+ const ALL_CODES: readonly KitLanguageCode[] = KIT_LANGUAGES.map((language) => language.code);
59
+
60
+ export const LanguageSelect = forwardRef<HTMLSelectElement, LanguageSelectProps>(function LanguageSelect(
61
+ { value, onChange, codes = ALL_CODES, optionLabel, ...rest },
62
+ ref,
63
+ ) {
64
+ const shown = resolveLanguage([value], codes);
65
+ return (
66
+ <Select {...rest} ref={ref} value={shown} onChange={(e) => onChange(e.target.value as KitLanguageCode)}>
67
+ {languageOptions(codes).map((option) => {
68
+ const language = KIT_LANGUAGES.find((entry) => entry.code === option.code)!;
69
+ return (
70
+ <option key={option.code} value={option.code} lang={optionLabel ? undefined : language.formatLocale}>
71
+ {optionLabel ? optionLabel(language) : option.label}
72
+ </option>
73
+ );
74
+ })}
75
+ </Select>
76
+ );
77
+ });
78
+ LanguageSelect.displayName = "LanguageSelect";