@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,103 @@
1
+ import { ArrowUpDown, Minus, Plus } from "lucide-react";
2
+ import { Chip } from "./chip";
3
+ import { Tooltip } from "./tooltip";
4
+ import { cn } from "../lib/cn";
5
+ import { useKitLabels } from "../i18n/kit-labels";
6
+
7
+ // ── Labels ────────────────────────────────────────────────────────────────────
8
+
9
+ /** The words a {@link SignChip} says — the `signChip` namespace of
10
+ * `<UiKitProvider labels>`, and per chip through its `labels` prop. */
11
+ export interface SignChipLabels {
12
+ /** The chip's text while the figure is negative — money going out. */
13
+ outflow: string;
14
+ /** The chip's text while the figure is positive — money coming in. */
15
+ inflow: string;
16
+ /** The chip's accessible name and its tooltip: the state it is in, and what a press
17
+ * turns it into — `current` and `next` are the two words above. The other option is
18
+ * nowhere on screen to be read, so the name has to say both. */
19
+ direction: (current: string, next: string) => string;
20
+ }
21
+
22
+ export const DEFAULT_SIGN_CHIP_LABELS: SignChipLabels = {
23
+ outflow: "Outflow",
24
+ inflow: "Inflow",
25
+ direction: (current, next) => `Direction: ${current} — switch to ${next}`,
26
+ };
27
+
28
+ export interface SignChipProps {
29
+ /** The figure is negative (an outflow) — the same flag {@link AmountInput}'s
30
+ * `negative` is, so one state feeds both: `<AmountInput negative={neg}
31
+ * onNegativeChange={setNeg} />` and `<SignChip negative={neg}
32
+ * onNegativeChange={setNeg} />`. */
33
+ negative: boolean;
34
+ /** A press: the other direction. */
35
+ onNegativeChange: (negative: boolean) => void;
36
+ disabled?: boolean;
37
+ /** Per-chip overrides of the `signChip` namespace. */
38
+ labels?: Partial<SignChipLabels>;
39
+ /** On the chip. Place it in its row from here — keksdose's form passes `self-end`
40
+ * to sit it on the display figure's baseline strip. */
41
+ className?: string;
42
+ }
43
+
44
+ /**
45
+ * The SIGN of an amount, as one chip that shows only the state it is in — keksdose
46
+ * K15, its `DirectionToggle` (feedback #417, #430, #431, dev#434) made a kit piece,
47
+ * so the figure and its direction come from one package and wear one palette.
48
+ *
49
+ * WHY A CHIP, NOT A SEGMENTED CONTROL. A two-option toggle spends half its width
50
+ * telling you what the figure is NOT, and at a phone's width it was the widest thing on
51
+ * the form after the figure itself (#417). This sits beside the amount and reads as one
52
+ * phrase with it — "−42,50 Outflow" — and a press flips it. Hiding the alternative costs
53
+ * discoverability, so two things pay it back: the ± glyph and the swap arrows say "this
54
+ * is a control", not a badge; and the name and tooltip spell out both the current state
55
+ * and what a press will do.
56
+ *
57
+ * NOT A PRESSED/UNPRESSED TOGGLE. A {@link Chip} toggle has a FIXED label naming its
58
+ * on-state, with `aria-pressed` carrying the state. Here the word IS the state, so it
59
+ * is an action button with no `aria-pressed`: "Direction: Inflow, not pressed" would
60
+ * name no on-state for "not pressed" to be the absence of. The name changing under
61
+ * focus is the echo.
62
+ *
63
+ * THE COLOUR is the money pairing — the chip's `expense` / `income` tones, the same
64
+ * `--money-expense` / `--money-income` that {@link AmountInput}'s `tone` paints the
65
+ * figure with (dev#434): only the text and the border carry the tint, on a neutral
66
+ * surface, because a filled pill next to a tinted figure reads as one smear.
67
+ *
68
+ * SIZE. `lg` on a phone — the 44px touch target beside a display figure that has no
69
+ * field height to match — and from `md` up exactly a labelled field's 42px, so it never
70
+ * stands taller than the fields in its grid row (#431).
71
+ *
72
+ * An exported chip rather than an `AmountInput signControl` slot: the slot would have
73
+ * to own the row the two share, and every form lays that row out differently (beside
74
+ * the figure on a phone, in a grid column on a desktop). The chip is the part that was
75
+ * being re-written; the row stays the form's.
76
+ */
77
+ export function SignChip({ negative, onNegativeChange, disabled, labels: labelsProp, className }: SignChipProps) {
78
+ const labels = useKitLabels("signChip", DEFAULT_SIGN_CHIP_LABELS, labelsProp);
79
+ const current = negative ? labels.outflow : labels.inflow;
80
+ const next = negative ? labels.inflow : labels.outflow;
81
+ const name = labels.direction(current, next);
82
+ return (
83
+ <Tooltip label={name} lazy>
84
+ <Chip
85
+ size="lg"
86
+ tone={negative ? "expense" : "income"}
87
+ icon={negative ? Minus : Plus}
88
+ onClick={() => onNegativeChange(!negative)}
89
+ disabled={disabled}
90
+ aria-label={name}
91
+ // `gap-1.5 px-3`: the chip's narrower body, because on a 375px screen every
92
+ // pixel of it is taken from the figure beside it (#430). `md:py-2.5` with the
93
+ // hairline border is the 42px a labelled field measures (#431).
94
+ className={cn("shrink-0 gap-1.5 whitespace-nowrap px-3 font-medium md:min-h-0 md:py-2.5", className)}
95
+ >
96
+ {current}
97
+ {/* The swap arrows after the word, in the label's own row: decoration (the
98
+ name already says what a press does), set a little apart from the word. */}
99
+ <ArrowUpDown className="ms-1.5 inline size-3.5 align-[-0.2em] opacity-60" aria-hidden />
100
+ </Chip>
101
+ </Tooltip>
102
+ );
103
+ }
@@ -1,9 +1,12 @@
1
1
  import { useId, useMemo } from "react";
2
- import type { ComponentPropsWithoutRef } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { useKitLabels } from "../i18n/kit-labels";
5
5
  import { TILE_SIZE, TileRadioGroup } from "./tile-radio";
6
6
  import type { TileItem, TileSize } from "./tile-radio";
7
+ import { Tooltip } from "./tooltip";
8
+ import { useCommitReason } from "./write-lock";
9
+ import { hasContent, LabelStrip } from "./field-parts";
7
10
 
8
11
  export interface SwatchPickerLabels {
9
12
  /** The "no colour" tile's name, when `allowNone` is set. */
@@ -35,7 +38,23 @@ export interface SwatchOption<T extends string> {
35
38
  /** A remark read after the name and shown under it in the bubble ("used by
36
39
  * Groceries"). The tile wears a dot while it has one. */
37
40
  note?: string;
41
+ /** Out of the choice and out of reach — skipped by the arrow keys and Tab. With a
42
+ * `disabledReason` the tile stays reachable instead and says why. */
38
43
  disabled?: boolean;
44
+ /**
45
+ * Why THIS colour cannot be chosen right now — the whole picker's `disabledReason`,
46
+ * one tile at a time (the kit's later list after keksdose K3: a colour another
47
+ * category already wears, a flag only an owner may set). {@link Button}'s rule: a tile
48
+ * that is merely `disabled` is a dead square a keyboard never lands on, so nobody
49
+ * learns why it is dimmed.
50
+ *
51
+ * The tile looks disabled but is `aria-disabled`, not `disabled`: Tab and the arrow
52
+ * keys still reach it, its bubble shows the reason under its name, and the reason
53
+ * describes it. Choosing it — a click, Space or Enter, or an arrow under automatic
54
+ * activation — does nothing: the focus moves onto it and the checked colour stays
55
+ * where it was, as under the whole-picker lock. Wins over `disabled`.
56
+ */
57
+ disabledReason?: ReactNode;
39
58
  }
40
59
 
41
60
  /**
@@ -72,6 +91,31 @@ export interface SwatchPickerProps<T extends string>
72
91
  tileClassName?: string;
73
92
  disabled?: boolean;
74
93
  labels?: Partial<SwatchPickerLabels>;
94
+ /**
95
+ * An 11px static label in a strip over the swatches, named by it (keksdose K2): the
96
+ * transaction row's flag picker stands between labelled fields and hand-builds this
97
+ * (`FieldLabel` + `pt-5`, transaction-flags.tsx:155) because the picker had no label.
98
+ * The label sits on the same line as a field's own (`top-1`, `inset-x-3`); the
99
+ * swatches start 20px down, below its whole line box — see `LABEL_STRIP_PAD`. With a
100
+ * label, `className` styles the wrapper, as on a labelled {@link ToggleGroup}; an
101
+ * explicit `aria-label` / `aria-labelledby` still names the group instead.
102
+ */
103
+ label?: ReactNode;
104
+ /** A {@link FieldHint} beside the label. Only with `label`. */
105
+ hint?: ReactNode;
106
+ /**
107
+ * Why the colour cannot be changed right now — {@link Button}'s `disabledReason`, for
108
+ * a picker that saves itself on change (keksdose K3, the flag picker in a row a
109
+ * read-only viewer may see). The swatches stay reachable and the arrow keys still
110
+ * walk them, but nothing reaches `onChange`; the reason is shown in the kit
111
+ * {@link Tooltip} under the row (each swatch's own name bubble sits above it) and
112
+ * describes the group. Wins over `disabled`.
113
+ */
114
+ disabledReason?: ReactNode;
115
+ /** This picker COMMITS — choosing a colour saves it. Under a locked
116
+ * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's
117
+ * reason, as {@link Button}'s `commit` does. */
118
+ commit?: boolean;
75
119
  }
76
120
 
77
121
  /**
@@ -97,51 +141,113 @@ export function SwatchPicker<T extends string>({
97
141
  tileClassName,
98
142
  disabled = false,
99
143
  labels,
144
+ label,
145
+ hint,
146
+ disabledReason: ownDisabledReason,
147
+ commit,
100
148
  className,
101
149
  ...rest
102
150
  }: SwatchPickerProps<T>) {
103
151
  const text = useKitLabels("swatchPicker", DEFAULT_SWATCH_PICKER_LABELS, labels);
104
152
  const mixedId = useId();
153
+ const labelId = useId();
154
+ const reasonId = useId();
155
+ const disabledReason = useCommitReason(commit, ownDisabledReason);
156
+ const locked = hasContent(disabledReason);
157
+ const labelled = hasContent(label);
105
158
  const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
106
159
  const items: TileItem<T>[] = [
107
160
  ...(allowNone ? [{ key: null, label: text.none }] : []),
108
- ...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),
161
+ ...options.map((o) => ({
162
+ key: o.value,
163
+ label: o.label,
164
+ note: o.note,
165
+ disabled: o.disabled,
166
+ disabledReason: o.disabledReason,
167
+ })),
109
168
  ];
110
- return (
169
+ const tiles = (
170
+ <TileRadioGroup
171
+ items={items}
172
+ checked={mixed ? undefined : value}
173
+ // A locked picker saves on change, so the change is swallowed — the arrow keys
174
+ // still move the focus (and, automatic, would choose), and nothing is chosen. A
175
+ // locked TILE is refused by the tiles themselves; asked again here so a reason
176
+ // can never become a change, whatever the tiles do.
177
+ onSelect={(next) => {
178
+ if (!locked && !hasContent(next === null ? undefined : byValue.get(next)?.disabledReason)) onChange(next);
179
+ }}
180
+ activation={activation}
181
+ // A reason wins over `disabled`: the tiles stay in the tab order.
182
+ disabled={disabled && !locked}
183
+ size={size}
184
+ tileClassName={
185
+ tileClassName || locked
186
+ ? (_item, selected) =>
187
+ cn(
188
+ // The disabled tile's look, without its `disabled`: dimmed, a
189
+ // not-allowed cursor, and no hover offer on a tile that cannot be
190
+ // chosen. Not on the selected tile, whose frame IS its state.
191
+ locked && "cursor-not-allowed opacity-50",
192
+ locked && !selected && "hover:border-[var(--border)] hover:bg-[var(--bg-surface)]",
193
+ tileClassName,
194
+ )
195
+ : undefined
196
+ }
197
+ renderTile={(item) => {
198
+ const opt = item.key === null ? undefined : byValue.get(item.key);
199
+ return (
200
+ <span
201
+ aria-hidden
202
+ // A hairline round the dot, drawn over whatever colour it is: a pale
203
+ // yellow swatch on a white surface is otherwise a hole in the row.
204
+ className={cn(
205
+ "rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15",
206
+ TILE_SIZE[size].swatch,
207
+ opt?.swatchClassName,
208
+ )}
209
+ style={{ background: opt?.color }}
210
+ />
211
+ );
212
+ }}
213
+ />
214
+ );
215
+ const group = (
111
216
  <div
112
217
  {...rest}
113
218
  role="radiogroup"
114
- aria-disabled={disabled || undefined}
219
+ aria-labelledby={
220
+ labelled && rest["aria-label"] === undefined && rest["aria-labelledby"] === undefined
221
+ ? labelId
222
+ : rest["aria-labelledby"]
223
+ }
224
+ aria-disabled={disabled || locked || undefined}
115
225
  aria-describedby={
116
- [rest["aria-describedby"], mixed && mixedId].filter(Boolean).join(" ") || undefined
226
+ [rest["aria-describedby"], mixed && mixedId, locked && reasonId].filter(Boolean).join(" ") ||
227
+ undefined
117
228
  }
118
- className={cn("flex flex-wrap items-center gap-1.5", className)}
229
+ className={cn("flex flex-wrap items-center gap-1.5", !labelled && className)}
119
230
  >
120
- <TileRadioGroup
121
- items={items}
122
- checked={mixed ? undefined : value}
123
- onSelect={onChange}
124
- activation={activation}
125
- disabled={disabled}
126
- size={size}
127
- tileClassName={tileClassName ? () => tileClassName : undefined}
128
- renderTile={(item) => {
129
- const opt = item.key === null ? undefined : byValue.get(item.key);
130
- return (
131
- <span
132
- aria-hidden
133
- // A hairline round the dot, drawn over whatever colour it is: a pale
134
- // yellow swatch on a white surface is otherwise a hole in the row.
135
- className={cn(
136
- "rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15",
137
- TILE_SIZE[size].swatch,
138
- opt?.swatchClassName,
139
- )}
140
- style={{ background: opt?.color }}
141
- />
142
- );
143
- }}
144
- />
231
+ {locked ? (
232
+ // Inside the group, round the tiles — not round the group, whose box is the
233
+ // caller's row item. This row inherits the group's gap, wrap and alignment, so
234
+ // the tiles sit exactly where they sit unlocked. Below the row: each tile's own
235
+ // bubble (its colour's name) is above it.
236
+ <Tooltip
237
+ label={disabledReason}
238
+ side="bottom"
239
+ className="flex min-w-0 flex-1 gap-[inherit] [align-items:inherit] [flex-wrap:inherit] [justify-content:inherit]"
240
+ >
241
+ <>
242
+ {tiles}
243
+ <span id={reasonId} hidden>
244
+ {disabledReason}
245
+ </span>
246
+ </>
247
+ </Tooltip>
248
+ ) : (
249
+ tiles
250
+ )}
145
251
  {/* `hidden`, not `sr-only`: a description is read from hidden text, and this
146
252
  way it takes no room in the row. */}
147
253
  {mixed && (
@@ -151,4 +257,10 @@ export function SwatchPicker<T extends string>({
151
257
  )}
152
258
  </div>
153
259
  );
260
+ if (!labelled) return group;
261
+ return (
262
+ <LabelStrip labelId={labelId} label={label} hint={hint} disabled={disabled || locked} pad="clear" className={className}>
263
+ {group}
264
+ </LabelStrip>
265
+ );
154
266
  }
@@ -2,6 +2,7 @@ import { forwardRef, useId } from "react";
2
2
  import type { ChangeEvent, InputHTMLAttributes, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { hasMessage, mergeDescribedBy } from "./choice-parts";
5
+ import { LockedReason, useLockReason } from "./field-parts";
5
6
 
6
7
  /**
7
8
  * An on/off switch that is still `<input type="checkbox">`, with `role="switch"`.
@@ -79,6 +80,25 @@ export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
79
80
  * label WITHOUT a literal "*". No mark without a label.
80
81
  */
81
82
  required?: boolean;
83
+ /**
84
+ * Why the switch cannot be flipped — {@link Button}'s `disabledReason`. A switch is
85
+ * the control that "takes effect the moment it is flipped", so it is the first one
86
+ * keksdose K3 names: its settings rows sat behind a `SaveGuard` that forced a native
87
+ * `disabled`, out of the tab order, so the reason never reached a keyboard.
88
+ *
89
+ * With a reason the switch is `aria-disabled` instead: still focusable, a click or
90
+ * Space flips nothing and fires no `onChange` / `onCheckedChange`, the reason is in
91
+ * the kit {@link Tooltip} on the track and on its `aria-describedby`, and the row
92
+ * fades as a disabled one does. It wins over `disabled`. Changing the lock mounts the
93
+ * switch anew (it moves in or out of the Tooltip), as a locked Button is.
94
+ */
95
+ disabledReason?: ReactNode;
96
+ /**
97
+ * This switch COMMITS — flipping it saves. Under a locked {@link WriteLockProvider} it
98
+ * is locked the `disabledReason` way with the lock's reason (which wins over its
99
+ * own). No provider, or an unlocked one: no effect. Button's `commit`, for K3.
100
+ */
101
+ commit?: boolean;
82
102
  }
83
103
 
84
104
  /**
@@ -101,39 +121,70 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(function Switch(
101
121
  id,
102
122
  disabled,
103
123
  required,
124
+ disabledReason,
125
+ commit,
126
+ onClick,
104
127
  ...rest
105
128
  },
106
129
  ref,
107
130
  ) {
108
131
  const generated = useId();
109
132
  const inputId = id ?? generated;
133
+ const lock = useLockReason(commit, disabledReason);
134
+ const locked = lock.locked;
135
+ // Locked is disabled the focusable way — the row fades and the words stop inviting a
136
+ // click, as for `disabled`.
137
+ const looksDisabled = Boolean(disabled) || locked;
110
138
  const descriptionId = `${inputId}-description`;
111
139
  const showDescription = hasMessage(description);
112
140
  const bare = label === undefined && !showDescription;
113
141
  const geometry = SIZES[size];
114
142
 
115
- const control = (
116
- <span className={cn("relative inline-flex shrink-0", bare && className)}>
143
+ const track = (
144
+ <span className={cn("relative inline-flex shrink-0", bare && !locked && className)}>
117
145
  <input
118
146
  ref={ref}
119
147
  id={bare ? id : inputId}
120
- disabled={disabled}
148
+ // A reason wins over `disabled`, as on Button: the switch stays reachable.
149
+ disabled={locked ? undefined : disabled}
121
150
  required={required}
122
151
  {...rest}
123
152
  // After the spread: a caller's props object must not be able to turn this
124
153
  // back into a plain checkbox, or into a text field.
125
154
  type="checkbox"
126
155
  role="switch"
127
- aria-describedby={mergeDescribedBy(rest["aria-describedby"], showDescription && descriptionId)}
156
+ aria-disabled={locked || rest["aria-disabled"] || undefined}
157
+ aria-describedby={mergeDescribedBy(
158
+ rest["aria-describedby"],
159
+ showDescription && descriptionId,
160
+ locked && lock.reasonId,
161
+ )}
162
+ // A locked switch's click is swallowed, as a locked Button's is.
163
+ onClick={locked ? undefined : onClick}
128
164
  onChange={(e: ChangeEvent<HTMLInputElement>) => {
165
+ if (locked) {
166
+ // Put the thumb back and report nothing — see Checkbox for why here and not
167
+ // by cancelling the click. Label clicks and Space arrive as clicks too.
168
+ e.currentTarget.checked = !e.currentTarget.checked;
169
+ return;
170
+ }
129
171
  onChange?.(e);
130
172
  onCheckedChange?.(e.target.checked);
131
173
  }}
132
- className={cn(TRACK, geometry.track, inputClassName)}
174
+ className={cn(TRACK, geometry.track, locked && "cursor-not-allowed", inputClassName)}
133
175
  />
134
176
  <span aria-hidden className={cn(THUMB, geometry.thumb)} />
135
177
  </span>
136
178
  );
179
+ // The Tooltip goes round the track, not the row — the row is the caller's layout. A
180
+ // bare switch hands its `className` to the wrapper, then the outermost element.
181
+ const control = locked ? (
182
+ <LockedReason lock={lock} className={cn("shrink-0", bare && className)}>
183
+ {track}
184
+ </LockedReason>
185
+ ) : (
186
+ track
187
+ );
137
188
 
138
189
  if (bare) return control;
139
190
 
@@ -148,7 +199,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(function Switch(
148
199
  htmlFor={inputId}
149
200
  className={cn(
150
201
  "block text-sm leading-5 text-[var(--text-primary)]",
151
- disabled ? "cursor-not-allowed" : "cursor-pointer select-none",
202
+ looksDisabled ? "cursor-not-allowed" : "cursor-pointer select-none",
152
203
  )}
153
204
  >
154
205
  {label}
@@ -168,7 +219,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(function Switch(
168
219
  );
169
220
 
170
221
  return (
171
- <div className={cn("flex items-start gap-3", disabled && "opacity-60", className)}>
222
+ <div className={cn("flex items-start gap-3", looksDisabled && "opacity-60", className)}>
172
223
  {switchPosition === "end" ? (
173
224
  <>
174
225
  {words}