@eifi1/ui-kit 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/dist/chart.d.ts +10 -8
  2. package/dist/components/account-chips.d.ts +10 -3
  3. package/dist/components/account-chips.js +5 -2
  4. package/dist/components/account-chips.js.map +1 -1
  5. package/dist/components/account-settings.d.ts +33 -2
  6. package/dist/components/account-settings.js +43 -21
  7. package/dist/components/account-settings.js.map +1 -1
  8. package/dist/components/amount-input.d.ts +27 -8
  9. package/dist/components/amount-input.js +15 -28
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/autocomplete.d.ts +8 -0
  12. package/dist/components/autocomplete.js +9 -4
  13. package/dist/components/autocomplete.js.map +1 -1
  14. package/dist/components/button-group.d.ts +10 -8
  15. package/dist/components/calculator.d.ts +10 -8
  16. package/dist/components/checkbox-group.d.ts +104 -0
  17. package/dist/components/checkbox-group.js +111 -0
  18. package/dist/components/checkbox-group.js.map +1 -0
  19. package/dist/components/checkbox.d.ts +19 -0
  20. package/dist/components/checkbox.js +30 -7
  21. package/dist/components/checkbox.js.map +1 -1
  22. package/dist/components/chip.d.ts +18 -0
  23. package/dist/components/chip.js +47 -10
  24. package/dist/components/chip.js.map +1 -1
  25. package/dist/components/combobox.d.ts +13 -2
  26. package/dist/components/combobox.js +17 -8
  27. package/dist/components/combobox.js.map +1 -1
  28. package/dist/components/confirm-dialog.d.ts +66 -161
  29. package/dist/components/copy-button.d.ts +10 -8
  30. package/dist/components/country-select.d.ts +124 -0
  31. package/dist/components/country-select.js +237 -0
  32. package/dist/components/country-select.js.map +1 -0
  33. package/dist/components/currency-select.d.ts +17 -1
  34. package/dist/components/currency-select.js +56 -23
  35. package/dist/components/currency-select.js.map +1 -1
  36. package/dist/components/danger-confirm.d.ts +66 -166
  37. package/dist/components/danger-confirm.js +133 -49
  38. package/dist/components/danger-confirm.js.map +1 -1
  39. package/dist/components/data-table-cells.d.ts +1 -2
  40. package/dist/components/data-table-filter-popover.d.ts +1 -2
  41. package/dist/components/data-table-filters.d.ts +1 -2
  42. package/dist/components/data-table-labels.d.ts +4 -97
  43. package/dist/components/data-table-labels.js +9 -1
  44. package/dist/components/data-table-labels.js.map +1 -1
  45. package/dist/components/data-table-pagination.d.ts +3 -1
  46. package/dist/components/data-table.d.ts +1 -2
  47. package/dist/components/data-table.js +98 -3
  48. package/dist/components/data-table.js.map +1 -1
  49. package/dist/components/date-picker.d.ts +25 -5
  50. package/dist/components/date-picker.js +110 -59
  51. package/dist/components/date-picker.js.map +1 -1
  52. package/dist/components/entity-combobox.d.ts +8 -1
  53. package/dist/components/entity-combobox.js +9 -4
  54. package/dist/components/entity-combobox.js.map +1 -1
  55. package/dist/components/facing-pair.d.ts +10 -8
  56. package/dist/components/field-anatomy.d.ts +95 -0
  57. package/dist/components/field-anatomy.js +84 -0
  58. package/dist/components/field-anatomy.js.map +1 -0
  59. package/dist/components/field-parts.d.ts +114 -0
  60. package/dist/components/field-parts.js +72 -0
  61. package/dist/components/field-parts.js.map +1 -0
  62. package/dist/components/file-button.d.ts +10 -8
  63. package/dist/components/file-button.js +13 -3
  64. package/dist/components/file-button.js.map +1 -1
  65. package/dist/components/file-dropzone.d.ts +32 -9
  66. package/dist/components/file-dropzone.js +57 -34
  67. package/dist/components/file-dropzone.js.map +1 -1
  68. package/dist/components/form-actions.d.ts +10 -8
  69. package/dist/components/form-actions.js +61 -3
  70. package/dist/components/form-actions.js.map +1 -1
  71. package/dist/components/iban-input.d.ts +66 -0
  72. package/dist/components/iban-input.js +125 -0
  73. package/dist/components/iban-input.js.map +1 -0
  74. package/dist/components/icon-picker.d.ts +12 -2
  75. package/dist/components/icon-picker.js +9 -2
  76. package/dist/components/icon-picker.js.map +1 -1
  77. package/dist/components/inline-edit-field.d.ts +167 -0
  78. package/dist/components/inline-edit-field.js +293 -0
  79. package/dist/components/inline-edit-field.js.map +1 -0
  80. package/dist/components/language-select.d.ts +127 -0
  81. package/dist/components/language-select.js +18 -0
  82. package/dist/components/language-select.js.map +1 -0
  83. package/dist/components/line-items.d.ts +25 -3
  84. package/dist/components/line-items.js +55 -46
  85. package/dist/components/line-items.js.map +1 -1
  86. package/dist/components/list.d.ts +25 -0
  87. package/dist/components/list.js +16 -5
  88. package/dist/components/list.js.map +1 -1
  89. package/dist/components/money-field.d.ts +20 -8
  90. package/dist/components/money-field.js.map +1 -1
  91. package/dist/components/month-picker.d.ts +52 -4
  92. package/dist/components/month-picker.js +290 -86
  93. package/dist/components/month-picker.js.map +1 -1
  94. package/dist/components/multi-select.d.ts +8 -1
  95. package/dist/components/multi-select.js +9 -4
  96. package/dist/components/multi-select.js.map +1 -1
  97. package/dist/components/number-field.d.ts +25 -13
  98. package/dist/components/number-field.js +6 -20
  99. package/dist/components/number-field.js.map +1 -1
  100. package/dist/components/number-input.d.ts +70 -13
  101. package/dist/components/number-input.js +90 -38
  102. package/dist/components/number-input.js.map +1 -1
  103. package/dist/components/numpad-sheet.d.ts +10 -8
  104. package/dist/components/one-time-code-input.d.ts +100 -0
  105. package/dist/components/one-time-code-input.js +137 -0
  106. package/dist/components/one-time-code-input.js.map +1 -0
  107. package/dist/components/phone-input.d.ts +66 -0
  108. package/dist/components/phone-input.js +171 -0
  109. package/dist/components/phone-input.js.map +1 -0
  110. package/dist/components/series-chart.d.ts +10 -8
  111. package/dist/components/settings-fields.d.ts +10 -8
  112. package/dist/components/share-card.d.ts +8 -6
  113. package/dist/components/sign-chip.d.ts +66 -0
  114. package/dist/components/sign-chip.js +39 -0
  115. package/dist/components/sign-chip.js.map +1 -0
  116. package/dist/components/swatch-picker.d.ts +27 -2
  117. package/dist/components/swatch-picker.js +73 -31
  118. package/dist/components/swatch-picker.js.map +1 -1
  119. package/dist/components/switch.d.ts +19 -0
  120. package/dist/components/switch.js +24 -6
  121. package/dist/components/switch.js.map +1 -1
  122. package/dist/components/text-link.d.ts +10 -8
  123. package/dist/components/tile-radio.d.ts +68 -13
  124. package/dist/components/tile-radio.js +3 -3
  125. package/dist/components/tile-radio.js.map +1 -1
  126. package/dist/components/time-input.d.ts +15 -9
  127. package/dist/components/time-input.js.map +1 -1
  128. package/dist/components/toggle-group.d.ts +47 -1
  129. package/dist/components/toggle-group.js +110 -69
  130. package/dist/components/toggle-group.js.map +1 -1
  131. package/dist/components/ui.d.ts +10 -8
  132. package/dist/components/ui.js +280 -78
  133. package/dist/components/ui.js.map +1 -1
  134. package/dist/components/use-table-state.d.ts +1 -2
  135. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  136. package/dist/data-table.d.ts +1 -2
  137. package/dist/data-table.js +2 -1
  138. package/dist/data-table.js.map +1 -1
  139. package/dist/feedback/feedback-attachment.d.ts +1 -1
  140. package/dist/feedback/feedback-attachment.js +210 -3
  141. package/dist/feedback/feedback-attachment.js.map +1 -1
  142. package/dist/feedback/feedback-dialog.d.ts +1 -1
  143. package/dist/feedback/feedback-dialog.js.map +1 -1
  144. package/dist/feedback/feedback-inbox.d.ts +1 -1
  145. package/dist/feedback/feedback-thread.d.ts +37 -3
  146. package/dist/feedback/feedback-thread.js +38 -9
  147. package/dist/feedback/feedback-thread.js.map +1 -1
  148. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-NhUm7Zga.d.ts} +70 -5
  149. package/dist/feedback.d.ts +2 -2
  150. package/dist/hooks/use-file-drop.d.ts +10 -8
  151. package/dist/i18n/defaults.d.ts +10 -8
  152. package/dist/i18n/defaults.js +17 -2
  153. package/dist/i18n/defaults.js.map +1 -1
  154. package/dist/i18n/german.d.ts +10 -8
  155. package/dist/i18n/german.js +50 -5
  156. package/dist/i18n/german.js.map +1 -1
  157. package/dist/i18n/kit-labels.d.ts +10 -8
  158. package/dist/i18n/kit-labels.js +8 -2
  159. package/dist/i18n/kit-labels.js.map +1 -1
  160. package/dist/i18n/languages.d.ts +10 -8
  161. package/dist/i18n/locales/de-CH.d.ts +10 -8
  162. package/dist/i18n/locales/en.d.ts +10 -8
  163. package/dist/i18n/locales/en.js +9 -0
  164. package/dist/i18n/locales/en.js.map +1 -1
  165. package/dist/i18n/locales/es.d.ts +10 -8
  166. package/dist/i18n/locales/es.js +48 -5
  167. package/dist/i18n/locales/es.js.map +1 -1
  168. package/dist/i18n/locales/fr.d.ts +10 -8
  169. package/dist/i18n/locales/fr.js +48 -5
  170. package/dist/i18n/locales/fr.js.map +1 -1
  171. package/dist/i18n/locales/hu.d.ts +10 -8
  172. package/dist/i18n/locales/hu.js +49 -5
  173. package/dist/i18n/locales/hu.js.map +1 -1
  174. package/dist/i18n/locales/it.d.ts +10 -8
  175. package/dist/i18n/locales/it.js +49 -5
  176. package/dist/i18n/locales/it.js.map +1 -1
  177. package/dist/i18n/locales/zh.d.ts +10 -8
  178. package/dist/i18n/locales/zh.js +48 -5
  179. package/dist/i18n/locales/zh.js.map +1 -1
  180. package/dist/i18n/review.d.ts +10 -8
  181. package/dist/i18n/review.js +8 -1
  182. package/dist/i18n/review.js.map +1 -1
  183. package/dist/index.d.ts +17 -10
  184. package/dist/index.js +57 -2
  185. package/dist/index.js.map +1 -1
  186. package/dist/{kit-labels-D1J2_jyD.d.ts → kit-labels-R8mIAc9N.d.ts} +811 -8
  187. package/dist/lib/countries.d.ts +43 -0
  188. package/dist/lib/countries.js +39 -0
  189. package/dist/lib/countries.js.map +1 -0
  190. package/dist/lib/decimal-marks.d.ts +46 -0
  191. package/dist/lib/decimal-marks.js +40 -0
  192. package/dist/lib/decimal-marks.js.map +1 -0
  193. package/dist/lib/iban.d.ts +98 -0
  194. package/dist/lib/iban.js +154 -0
  195. package/dist/lib/iban.js.map +1 -0
  196. package/dist/lib/isin.d.ts +30 -0
  197. package/dist/lib/isin.js +36 -0
  198. package/dist/lib/isin.js.map +1 -0
  199. package/dist/lib/phone.d.ts +69 -0
  200. package/dist/lib/phone.js +121 -0
  201. package/dist/lib/phone.js.map +1 -0
  202. package/dist/rhf/fields.d.ts +148 -11
  203. package/dist/rhf/fields.js +332 -3
  204. package/dist/rhf/fields.js.map +1 -1
  205. package/dist/rhf/form.d.ts +10 -8
  206. package/dist/rhf.d.ts +13 -9
  207. package/dist/rhf.js.map +1 -1
  208. package/dist/shell/app-shell.d.ts +10 -8
  209. package/dist/shell/top-bar-brand.d.ts +10 -8
  210. package/dist/shell.d.ts +10 -8
  211. package/dist/tour/tour.js +20 -24
  212. package/dist/tour/tour.js.map +1 -1
  213. package/dist/wizard/stepper-nav.d.ts +10 -8
  214. package/dist/wizard.d.ts +10 -8
  215. package/package.json +1 -1
  216. package/src/components/account-chips.tsx +17 -3
  217. package/src/components/account-settings.tsx +92 -26
  218. package/src/components/amount-input.tsx +49 -56
  219. package/src/components/autocomplete.tsx +133 -110
  220. package/src/components/checkbox-group.tsx +229 -0
  221. package/src/components/checkbox.tsx +72 -8
  222. package/src/components/chip.tsx +80 -10
  223. package/src/components/combobox.tsx +292 -253
  224. package/src/components/country-select.tsx +443 -0
  225. package/src/components/currency-select.tsx +109 -33
  226. package/src/components/danger-confirm.tsx +345 -77
  227. package/src/components/data-table-labels.ts +16 -1
  228. package/src/components/data-table.tsx +175 -6
  229. package/src/components/date-picker.tsx +173 -62
  230. package/src/components/entity-combobox.tsx +107 -87
  231. package/src/components/field-anatomy.tsx +190 -0
  232. package/src/components/field-parts.tsx +184 -0
  233. package/src/components/file-button.tsx +36 -3
  234. package/src/components/file-dropzone.tsx +106 -38
  235. package/src/components/form-actions.tsx +115 -2
  236. package/src/components/iban-input.tsx +231 -0
  237. package/src/components/icon-picker.tsx +26 -4
  238. package/src/components/inline-edit-field.tsx +533 -0
  239. package/src/components/language-select.tsx +78 -0
  240. package/src/components/line-items.tsx +35 -2
  241. package/src/components/list.tsx +55 -6
  242. package/src/components/money-field.tsx +10 -0
  243. package/src/components/month-picker.tsx +399 -63
  244. package/src/components/multi-select.tsx +80 -58
  245. package/src/components/number-field.tsx +28 -46
  246. package/src/components/number-input.tsx +210 -43
  247. package/src/components/one-time-code-input.tsx +288 -0
  248. package/src/components/phone-input.tsx +299 -0
  249. package/src/components/sign-chip.tsx +103 -0
  250. package/src/components/swatch-picker.tsx +118 -30
  251. package/src/components/switch.tsx +58 -7
  252. package/src/components/tile-radio.tsx +71 -16
  253. package/src/components/time-input.tsx +8 -1
  254. package/src/components/toggle-group.tsx +206 -73
  255. package/src/components/ui.tsx +472 -105
  256. package/src/data-table.ts +2 -1
  257. package/src/feedback/feedback-attachment.tsx +313 -9
  258. package/src/feedback/feedback-dialog.tsx +2 -0
  259. package/src/feedback/feedback-thread.tsx +66 -5
  260. package/src/i18n/defaults.ts +16 -1
  261. package/src/i18n/german.ts +47 -0
  262. package/src/i18n/kit-labels.tsx +78 -2
  263. package/src/i18n/locales/en.ts +10 -0
  264. package/src/i18n/locales/es.ts +46 -0
  265. package/src/i18n/locales/fr.ts +45 -0
  266. package/src/i18n/locales/hu.ts +46 -0
  267. package/src/i18n/locales/it.ts +47 -0
  268. package/src/i18n/locales/zh.ts +44 -0
  269. package/src/i18n/review.ts +7 -0
  270. package/src/index.ts +53 -3
  271. package/src/lib/countries.ts +91 -0
  272. package/src/lib/decimal-marks.ts +99 -0
  273. package/src/lib/iban.ts +171 -0
  274. package/src/lib/isin.ts +62 -0
  275. package/src/lib/phone.ts +211 -0
  276. package/src/rhf/fields.tsx +606 -9
  277. package/src/rhf.ts +2 -1
  278. package/src/tour/tour.tsx +66 -28
@@ -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-anatomy";
7
10
 
8
11
  export interface SwatchPickerLabels {
9
12
  /** The "no colour" tile's name, when `allowNone` is set. */
@@ -72,6 +75,31 @@ export interface SwatchPickerProps<T extends string>
72
75
  tileClassName?: string;
73
76
  disabled?: boolean;
74
77
  labels?: Partial<SwatchPickerLabels>;
78
+ /**
79
+ * An 11px static label in a strip over the swatches, named by it (keksdose K2): the
80
+ * transaction row's flag picker stands between labelled fields and hand-builds this
81
+ * (`FieldLabel` + `pt-5`, transaction-flags.tsx:155) because the picker had no label.
82
+ * The label sits on the same line as a field's own (`top-1`, `inset-x-3`); the
83
+ * swatches start 20px down, below its whole line box — see `LABEL_STRIP_PAD`. With a
84
+ * label, `className` styles the wrapper, as on a labelled {@link ToggleGroup}; an
85
+ * explicit `aria-label` / `aria-labelledby` still names the group instead.
86
+ */
87
+ label?: ReactNode;
88
+ /** A {@link FieldHint} beside the label. Only with `label`. */
89
+ hint?: ReactNode;
90
+ /**
91
+ * Why the colour cannot be changed right now — {@link Button}'s `disabledReason`, for
92
+ * a picker that saves itself on change (keksdose K3, the flag picker in a row a
93
+ * read-only viewer may see). The swatches stay reachable and the arrow keys still
94
+ * walk them, but nothing reaches `onChange`; the reason is shown in the kit
95
+ * {@link Tooltip} under the row (each swatch's own name bubble sits above it) and
96
+ * describes the group. Wins over `disabled`.
97
+ */
98
+ disabledReason?: ReactNode;
99
+ /** This picker COMMITS — choosing a colour saves it. Under a locked
100
+ * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's
101
+ * reason, as {@link Button}'s `commit` does. */
102
+ commit?: boolean;
75
103
  }
76
104
 
77
105
  /**
@@ -97,51 +125,105 @@ export function SwatchPicker<T extends string>({
97
125
  tileClassName,
98
126
  disabled = false,
99
127
  labels,
128
+ label,
129
+ hint,
130
+ disabledReason: ownDisabledReason,
131
+ commit,
100
132
  className,
101
133
  ...rest
102
134
  }: SwatchPickerProps<T>) {
103
135
  const text = useKitLabels("swatchPicker", DEFAULT_SWATCH_PICKER_LABELS, labels);
104
136
  const mixedId = useId();
137
+ const labelId = useId();
138
+ const reasonId = useId();
139
+ const disabledReason = useCommitReason(commit, ownDisabledReason);
140
+ const locked = hasContent(disabledReason);
141
+ const labelled = hasContent(label);
105
142
  const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
106
143
  const items: TileItem<T>[] = [
107
144
  ...(allowNone ? [{ key: null, label: text.none }] : []),
108
145
  ...options.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),
109
146
  ];
110
- return (
147
+ const tiles = (
148
+ <TileRadioGroup
149
+ items={items}
150
+ checked={mixed ? undefined : value}
151
+ // A locked picker saves on change, so the change is swallowed — the arrow keys
152
+ // still move the focus (and, automatic, would choose), and nothing is chosen.
153
+ onSelect={(next) => {
154
+ if (!locked) onChange(next);
155
+ }}
156
+ activation={activation}
157
+ // A reason wins over `disabled`: the tiles stay in the tab order.
158
+ disabled={disabled && !locked}
159
+ size={size}
160
+ tileClassName={
161
+ tileClassName || locked
162
+ ? (_item, selected) =>
163
+ cn(
164
+ // The disabled tile's look, without its `disabled`: dimmed, a
165
+ // not-allowed cursor, and no hover offer on a tile that cannot be
166
+ // chosen. Not on the selected tile, whose frame IS its state.
167
+ locked && "cursor-not-allowed opacity-50",
168
+ locked && !selected && "hover:border-[var(--border)] hover:bg-[var(--bg-surface)]",
169
+ tileClassName,
170
+ )
171
+ : undefined
172
+ }
173
+ renderTile={(item) => {
174
+ const opt = item.key === null ? undefined : byValue.get(item.key);
175
+ return (
176
+ <span
177
+ aria-hidden
178
+ // A hairline round the dot, drawn over whatever colour it is: a pale
179
+ // yellow swatch on a white surface is otherwise a hole in the row.
180
+ className={cn(
181
+ "rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15",
182
+ TILE_SIZE[size].swatch,
183
+ opt?.swatchClassName,
184
+ )}
185
+ style={{ background: opt?.color }}
186
+ />
187
+ );
188
+ }}
189
+ />
190
+ );
191
+ const group = (
111
192
  <div
112
193
  {...rest}
113
194
  role="radiogroup"
114
- aria-disabled={disabled || undefined}
195
+ aria-labelledby={
196
+ labelled && rest["aria-label"] === undefined && rest["aria-labelledby"] === undefined
197
+ ? labelId
198
+ : rest["aria-labelledby"]
199
+ }
200
+ aria-disabled={disabled || locked || undefined}
115
201
  aria-describedby={
116
- [rest["aria-describedby"], mixed && mixedId].filter(Boolean).join(" ") || undefined
202
+ [rest["aria-describedby"], mixed && mixedId, locked && reasonId].filter(Boolean).join(" ") ||
203
+ undefined
117
204
  }
118
- className={cn("flex flex-wrap items-center gap-1.5", className)}
205
+ className={cn("flex flex-wrap items-center gap-1.5", !labelled && className)}
119
206
  >
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
- />
207
+ {locked ? (
208
+ // Inside the group, round the tiles — not round the group, whose box is the
209
+ // caller's row item. This row inherits the group's gap, wrap and alignment, so
210
+ // the tiles sit exactly where they sit unlocked. Below the row: each tile's own
211
+ // bubble (its colour's name) is above it.
212
+ <Tooltip
213
+ label={disabledReason}
214
+ side="bottom"
215
+ className="flex min-w-0 flex-1 gap-[inherit] [align-items:inherit] [flex-wrap:inherit] [justify-content:inherit]"
216
+ >
217
+ <>
218
+ {tiles}
219
+ <span id={reasonId} hidden>
220
+ {disabledReason}
221
+ </span>
222
+ </>
223
+ </Tooltip>
224
+ ) : (
225
+ tiles
226
+ )}
145
227
  {/* `hidden`, not `sr-only`: a description is read from hidden text, and this
146
228
  way it takes no room in the row. */}
147
229
  {mixed && (
@@ -151,4 +233,10 @@ export function SwatchPicker<T extends string>({
151
233
  )}
152
234
  </div>
153
235
  );
236
+ if (!labelled) return group;
237
+ return (
238
+ <LabelStrip labelId={labelId} label={label} hint={hint} disabled={disabled || locked} pad="tiles" className={className}>
239
+ {group}
240
+ </LabelStrip>
241
+ );
154
242
  }
@@ -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}
@@ -6,27 +6,67 @@ import { horizontalStep } from "../lib/direction";
6
6
  import { Tooltip } from "./tooltip";
7
7
 
8
8
  /**
9
- * The machinery {@link SwatchPicker} and {@link IconPicker} share: a row of square
10
- * tiles that behaves as ONE radio group. Private to the package — not re-exported
11
- * from the barrel.
9
+ * A row of square tiles that behaves as ONE radio group — the machinery
10
+ * {@link SwatchPicker} and {@link IconPicker} are built on, public since 0.22.0 for the
11
+ * one-of-n pickers those two do not cover (kastlan's input audit: a choice whose tiles
12
+ * show something of the app's own — a mark, a pattern, a symbol set the kit does not
13
+ * ship).
12
14
  *
13
15
  * Keksdose had three of these (the category colours, the category symbols, the
14
16
  * transaction flags) and all three were rows of `aria-pressed` buttons: a separate
15
17
  * tab stop per tile, and "pressed" on a choice that is really one-of-n. The APG radio
16
18
  * group is the shape a screen reader expects for that ("Red, radio, 2 of 7, checked")
17
19
  * and the one a keyboard expects too — a single tab stop, arrows to move.
20
+ *
21
+ * ## The container is yours
22
+ *
23
+ * It renders the TILES only — no wrapper — so it can sit in a layout the caller owns
24
+ * (a wrapping row, a grid, a row beside a search box). That element must carry the
25
+ * group's semantics, as SwatchPicker's does:
26
+ *
27
+ * ```tsx
28
+ * <div role="radiogroup" aria-labelledby={headingId} className="flex flex-wrap gap-1.5">
29
+ * <TileRadioGroup
30
+ * items={[{ key: "dots", label: "Dots" }, { key: "lines", label: "Lines" }]}
31
+ * checked={pattern}
32
+ * onSelect={(key) => key && setPattern(key)}
33
+ * renderTile={(item) => <PatternGlyph pattern={item.key} className={TILE_SIZE.md.glyph} />}
34
+ * />
35
+ * </div>
36
+ * ```
37
+ *
38
+ * `role="radiogroup"` and a name (`aria-label` or `aria-labelledby`) — without them a
39
+ * screen reader hears radios that belong to nothing, and no "2 of 7". Set
40
+ * `aria-disabled` on it as well when the whole group is `disabled`.
41
+ *
42
+ * ## What a tile is
43
+ *
44
+ * A square (28 / 32 / 44px, {@link TILE_SIZE}) holding `renderTile`'s content, NAMED by
45
+ * the item's `label` (`aria-label`, so what is drawn inside is decoration) and showing
46
+ * that label in a bubble on hover and focus — a row of unlabelled squares is the
47
+ * complaint that put the bubble there. It is for choices that are SEEN: a colour, a
48
+ * glyph, a pattern. A choice that has to be READ — a plan with a price and three lines
49
+ * of features, an option with a sentence of explanation — is a {@link ChoiceCardGroup}
50
+ * (`type="radio"`): a card names its option by its title and describes it by its text,
51
+ * where a tile would hide the text behind a bubble and repeat it.
52
+ *
53
+ * No strings of its own: every name is an item's `label`, the "none" tile's included.
18
54
  */
19
55
 
56
+ /** A tile's size: 28 / 32 / 44px — see {@link TILE_SIZE}. */
20
57
  export type TileSize = "sm" | "md" | "lg";
21
58
 
22
- /** 28 / 32 / 44px. `lg` is the touch target a phone surface wants (`min-h-11`). */
59
+ /** 28 / 32 / 44px. `lg` is the touch target a phone surface wants (`min-h-11`). `glyph`
60
+ * and `swatch` are the content sizes the kit's own pickers draw inside each — use them
61
+ * in `renderTile` so a tile of the app's own matches theirs. */
23
62
  export const TILE_SIZE: Record<TileSize, { tile: string; glyph: string; swatch: string }> = {
24
63
  sm: { tile: "size-7", glyph: "size-3.5", swatch: "size-4" },
25
64
  md: { tile: "size-8", glyph: "size-4", swatch: "size-5" },
26
65
  lg: { tile: "size-11", glyph: "size-5", swatch: "size-6" },
27
66
  };
28
67
 
29
- /** One tile, whatever it shows. `key` is `null` for the "none" entry. */
68
+ /** One tile, whatever it shows. `key` is `null` for the "none" entry — a tile of its
69
+ * own that leads back to nothing, since clicking a checked tile does not uncheck it. */
30
70
  export interface TileItem<T extends string> {
31
71
  key: T | null;
32
72
  label: string;
@@ -37,20 +77,28 @@ export interface TileItem<T extends string> {
37
77
  }
38
78
 
39
79
  export interface TileRadioGroupProps<T extends string> {
80
+ /** The tiles, in reading order. Render them inside a `role="radiogroup"` element
81
+ * with a name — see the module note. */
40
82
  items: TileItem<T>[];
41
83
  /** The checked key, or `undefined` for "nothing is checked" (the mixed state, or
42
84
  * a value that is not among the options). */
43
85
  checked: T | null | undefined;
44
86
  onSelect: (key: T | null) => void;
45
- /** "automatic": an arrow key moves AND checks, the APG default. "manual": arrows
46
- * move focus only, Space or Enter checks — for a picker whose change is expensive
47
- * (a bulk edit that saves on every change). */
48
- activation: "automatic" | "manual";
49
- disabled: boolean;
50
- size: TileSize;
51
- /** What goes inside a tile. The frame, the tick and the note's dot are drawn here. */
87
+ /** "automatic" (default): an arrow key moves AND checks, the APG default. "manual":
88
+ * arrows move focus only, Space or Enter checks — for a picker whose change is
89
+ * expensive (a bulk edit that saves on every change). */
90
+ activation?: "automatic" | "manual";
91
+ /** Every tile disabled. Mark the container `aria-disabled` too. Default `false`. */
92
+ disabled?: boolean;
93
+ /** 28 / 32 / 44px tiles ({@link TILE_SIZE}). Default "md"; "lg" is the touch-target
94
+ * size a phone surface wants. */
95
+ size?: TileSize;
96
+ /** What goes inside a tile — `aria-hidden` decoration, since the tile is named by
97
+ * `label`. Not called for the `null` ("none") item, which shows the kit's "no"
98
+ * glyph. The frame, the tick and the note's dot are drawn around it. */
52
99
  renderTile: (item: TileItem<T>, selected: boolean) => ReactNode;
53
- /** Extra classes for a tile, per item. */
100
+ /** Extra classes for a tile, per item, merged after the kit's — so a `size-*` here
101
+ * beats `size`'s (a tile that grows in a narrow card). */
54
102
  tileClassName?: (item: TileItem<T>, selected: boolean) => string | undefined;
55
103
  }
56
104
 
@@ -72,13 +120,20 @@ const TILE_IDLE =
72
120
  const TILE_SELECTED =
73
121
  "border-[var(--text-primary)] bg-[var(--bg-surface)] ring-1 ring-[var(--text-primary)]";
74
122
 
123
+ /**
124
+ * The tiles of one radio group, without the group element — render them inside a
125
+ * `role="radiogroup"` with a name (the module note above has the example). One tab stop
126
+ * (the checked tile, else the first that can be chosen), arrows to move — left and
127
+ * right follow the page's direction — Home and End to the ends, and by default the
128
+ * arrows choose as they go.
129
+ */
75
130
  export function TileRadioGroup<T extends string>({
76
131
  items,
77
132
  checked,
78
133
  onSelect,
79
- activation,
80
- disabled,
81
- size,
134
+ activation = "automatic",
135
+ disabled = false,
136
+ size = "md",
82
137
  renderTile,
83
138
  tileClassName,
84
139
  }: TileRadioGroupProps<T>) {
@@ -31,9 +31,16 @@ import { cn } from "../lib/cn";
31
31
  * {@link FIELD_INVALID} and `aria-invalid`. A reversed window (`min="22:00"
32
32
  * max="06:00"`, a night shift, quiet hours) is read the way the HTML spec reads it:
33
33
  * as wrapping past midnight.
34
+ *
35
+ * `hint` is Input's too (keksdose K4): text is a caption under the field ("Local time
36
+ * at the site"), a {@link FieldHint} rides the label line. `showCount` is typed out — a
37
+ * time has no length worth counting.
34
38
  */
35
39
  export interface TimeInputProps
36
- extends Omit<InputProps, "type" | "value" | "defaultValue" | "min" | "max" | "step" | "variant"> {
40
+ extends Omit<
41
+ InputProps,
42
+ "type" | "value" | "defaultValue" | "min" | "max" | "step" | "variant" | "showCount" | "countLabels"
43
+ > {
37
44
  /** `"HH:mm"` (or `"HH:mm:ss"` with a sub-minute `step`); `""` is "no time". */
38
45
  value: string;
39
46
  /** The normalised value on every change the browser reports — which for a time