@eifi1/ui-kit 0.22.0 → 0.24.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 (208) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +8 -7
  3. package/dist/components/amount-input.d.ts +34 -7
  4. package/dist/components/amount-input.js +14 -10
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +26 -0
  7. package/dist/components/autocomplete.js +164 -137
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +8 -7
  10. package/dist/components/calculator.d.ts +8 -7
  11. package/dist/components/column-mapper.d.ts +235 -0
  12. package/dist/components/column-mapper.js +411 -0
  13. package/dist/components/column-mapper.js.map +1 -0
  14. package/dist/components/combobox-core.d.ts +39 -2
  15. package/dist/components/combobox-core.js +29 -6
  16. package/dist/components/combobox-core.js.map +1 -1
  17. package/dist/components/combobox.d.ts +113 -3
  18. package/dist/components/combobox.js +456 -313
  19. package/dist/components/combobox.js.map +1 -1
  20. package/dist/components/confirm-dialog.d.ts +8 -7
  21. package/dist/components/confirm-dialog.js +7 -8
  22. package/dist/components/confirm-dialog.js.map +1 -1
  23. package/dist/components/copy-button.d.ts +5 -4
  24. package/dist/components/country-select.d.ts +50 -7
  25. package/dist/components/country-select.js +65 -9
  26. package/dist/components/country-select.js.map +1 -1
  27. package/dist/components/danger-confirm.d.ts +8 -7
  28. package/dist/components/danger-confirm.js +64 -7
  29. package/dist/components/danger-confirm.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +41 -0
  31. package/dist/components/date-picker.js +269 -8
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/entity-combobox.d.ts +48 -2
  34. package/dist/components/entity-combobox.js +159 -114
  35. package/dist/components/entity-combobox.js.map +1 -1
  36. package/dist/components/facing-pair.d.ts +8 -7
  37. package/dist/components/field-parts.d.ts +116 -6
  38. package/dist/components/field-parts.js +84 -0
  39. package/dist/components/field-parts.js.map +1 -1
  40. package/dist/components/field-strip.d.ts +105 -0
  41. package/dist/components/field-strip.js +46 -0
  42. package/dist/components/field-strip.js.map +1 -0
  43. package/dist/components/file-button.d.ts +8 -7
  44. package/dist/components/file-dropzone.d.ts +8 -7
  45. package/dist/components/form-actions.d.ts +8 -7
  46. package/dist/components/form-actions.js +23 -3
  47. package/dist/components/form-actions.js.map +1 -1
  48. package/dist/components/iban-input.d.ts +8 -7
  49. package/dist/components/icon-picker.d.ts +10 -1
  50. package/dist/components/icon-picker.js +12 -4
  51. package/dist/components/icon-picker.js.map +1 -1
  52. package/dist/components/language-select.d.ts +8 -7
  53. package/dist/components/money-field.d.ts +11 -7
  54. package/dist/components/money-field.js.map +1 -1
  55. package/dist/components/month-picker.js +2 -1
  56. package/dist/components/month-picker.js.map +1 -1
  57. package/dist/components/multi-entity-combobox.d.ts +31 -2
  58. package/dist/components/multi-entity-combobox.js +152 -103
  59. package/dist/components/multi-entity-combobox.js.map +1 -1
  60. package/dist/components/number-field.d.ts +8 -7
  61. package/dist/components/number-input.d.ts +8 -7
  62. package/dist/components/numpad-sheet.d.ts +8 -7
  63. package/dist/components/phone-input.d.ts +8 -7
  64. package/dist/components/reauth-dialog.d.ts +2 -2
  65. package/dist/components/reauth-dialog.js +6 -8
  66. package/dist/components/reauth-dialog.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +8 -7
  68. package/dist/components/settings-fields.d.ts +8 -7
  69. package/dist/components/share-card.d.ts +8 -7
  70. package/dist/components/swatch-picker.d.ts +17 -1
  71. package/dist/components/swatch-picker.js +10 -4
  72. package/dist/components/swatch-picker.js.map +1 -1
  73. package/dist/components/text-link.d.ts +8 -7
  74. package/dist/components/tile-radio.d.ts +20 -0
  75. package/dist/components/tile-radio.js +25 -8
  76. package/dist/components/tile-radio.js.map +1 -1
  77. package/dist/components/time-input.d.ts +8 -7
  78. package/dist/components/toggle-group.js +2 -2
  79. package/dist/components/toggle-group.js.map +1 -1
  80. package/dist/components/trigger-aria.d.ts +3 -0
  81. package/dist/components/trigger-aria.js +3 -1
  82. package/dist/components/trigger-aria.js.map +1 -1
  83. package/dist/components/ui.d.ts +5 -4
  84. package/dist/components/ui.js +60 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/feedback/feedback-attachment.d.ts +66 -1
  87. package/dist/feedback/feedback-attachment.js +185 -65
  88. package/dist/feedback/feedback-attachment.js.map +1 -1
  89. package/dist/feedback/feedback-dialog.d.ts +66 -1
  90. package/dist/feedback/feedback-dialog.js.map +1 -1
  91. package/dist/feedback/feedback-inbox.d.ts +67 -252
  92. package/dist/feedback/feedback-inbox.js.map +1 -1
  93. package/dist/feedback/feedback-thread.d.ts +66 -287
  94. package/dist/feedback/feedback-thread.js +16 -3
  95. package/dist/feedback/feedback-thread.js.map +1 -1
  96. package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-DOwPu-Il.d.ts} +1273 -30
  97. package/dist/feedback.d.ts +65 -3
  98. package/dist/hooks/use-file-drop.d.ts +8 -7
  99. package/dist/i18n/defaults.d.ts +8 -7
  100. package/dist/i18n/defaults.js +3 -1
  101. package/dist/i18n/defaults.js.map +1 -1
  102. package/dist/i18n/german.d.ts +8 -7
  103. package/dist/i18n/german.js +36 -2
  104. package/dist/i18n/german.js.map +1 -1
  105. package/dist/i18n/kit-labels.d.ts +5 -4
  106. package/dist/i18n/kit-labels.js.map +1 -1
  107. package/dist/i18n/languages.d.ts +8 -7
  108. package/dist/i18n/locales/de-CH.d.ts +8 -7
  109. package/dist/i18n/locales/en.d.ts +8 -7
  110. package/dist/i18n/locales/en.js +7 -0
  111. package/dist/i18n/locales/en.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +8 -7
  113. package/dist/i18n/locales/es.js +37 -2
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +8 -7
  116. package/dist/i18n/locales/fr.js +36 -2
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +8 -7
  119. package/dist/i18n/locales/hu.js +38 -2
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +8 -7
  122. package/dist/i18n/locales/it.js +37 -2
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +8 -7
  125. package/dist/i18n/locales/zh.js +34 -2
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/i18n/review.d.ts +8 -7
  128. package/dist/i18n/review.js +20 -1
  129. package/dist/i18n/review.js.map +1 -1
  130. package/dist/index.d.ts +6 -4
  131. package/dist/index.js +16 -0
  132. package/dist/index.js.map +1 -1
  133. package/dist/lib/column-mapping.d.ts +81 -0
  134. package/dist/lib/column-mapping.js +108 -0
  135. package/dist/lib/column-mapping.js.map +1 -0
  136. package/dist/lib/strip-fade.d.ts +2 -2
  137. package/dist/lib/strip-fade.js.map +1 -1
  138. package/dist/lib/table-text.d.ts +109 -1
  139. package/dist/lib/table-text.js +122 -1
  140. package/dist/lib/table-text.js.map +1 -1
  141. package/dist/rhf/fields.d.ts +142 -13
  142. package/dist/rhf/fields.js +185 -3
  143. package/dist/rhf/fields.js.map +1 -1
  144. package/dist/rhf/form.d.ts +8 -7
  145. package/dist/rhf.d.ts +9 -8
  146. package/dist/rhf.js.map +1 -1
  147. package/dist/shell/app-shell.d.ts +8 -7
  148. package/dist/shell/top-bar-brand.d.ts +8 -7
  149. package/dist/shell/topbar-action-menu.d.ts +36 -3
  150. package/dist/shell/topbar-action-menu.js +74 -33
  151. package/dist/shell/topbar-action-menu.js.map +1 -1
  152. package/dist/shell.d.ts +8 -7
  153. package/dist/table-text.d.ts +1 -1
  154. package/dist/wizard/stepper-nav.d.ts +38 -9
  155. package/dist/wizard/stepper-nav.js +4 -0
  156. package/dist/wizard/stepper-nav.js.map +1 -1
  157. package/dist/wizard.d.ts +8 -7
  158. package/package.json +1 -1
  159. package/src/components/amount-input.tsx +50 -14
  160. package/src/components/autocomplete.tsx +188 -116
  161. package/src/components/column-mapper.tsx +768 -0
  162. package/src/components/combobox-core.tsx +82 -9
  163. package/src/components/combobox.tsx +655 -335
  164. package/src/components/confirm-dialog.tsx +12 -8
  165. package/src/components/country-select.tsx +135 -22
  166. package/src/components/danger-confirm.tsx +274 -24
  167. package/src/components/date-picker.tsx +423 -10
  168. package/src/components/entity-combobox.tsx +243 -129
  169. package/src/components/field-parts.tsx +224 -5
  170. package/src/components/field-strip.tsx +149 -0
  171. package/src/components/form-actions.tsx +52 -5
  172. package/src/components/icon-picker.tsx +23 -4
  173. package/src/components/money-field.tsx +3 -0
  174. package/src/components/month-picker.tsx +2 -1
  175. package/src/components/multi-entity-combobox.tsx +221 -121
  176. package/src/components/reauth-dialog.tsx +17 -18
  177. package/src/components/swatch-picker.tsx +29 -5
  178. package/src/components/tile-radio.tsx +74 -13
  179. package/src/components/toggle-group.tsx +2 -2
  180. package/src/components/trigger-aria.ts +5 -0
  181. package/src/components/ui.tsx +114 -33
  182. package/src/feedback/feedback-attachment.tsx +372 -72
  183. package/src/feedback/feedback-dialog.tsx +7 -3
  184. package/src/feedback/feedback-inbox.tsx +3 -2
  185. package/src/feedback/feedback-thread.tsx +112 -9
  186. package/src/i18n/defaults.ts +2 -0
  187. package/src/i18n/german.ts +42 -0
  188. package/src/i18n/kit-labels.tsx +4 -0
  189. package/src/i18n/locales/en.ts +27 -5
  190. package/src/i18n/locales/es.ts +41 -0
  191. package/src/i18n/locales/fr.ts +42 -0
  192. package/src/i18n/locales/hu.ts +37 -0
  193. package/src/i18n/locales/it.ts +41 -0
  194. package/src/i18n/locales/zh.ts +32 -0
  195. package/src/i18n/review.ts +19 -0
  196. package/src/index.ts +18 -0
  197. package/src/lib/column-mapping.ts +234 -0
  198. package/src/lib/strip-fade.ts +2 -2
  199. package/src/lib/table-text.ts +271 -0
  200. package/src/rhf/fields.tsx +421 -6
  201. package/src/rhf.ts +2 -1
  202. package/src/shell/topbar-action-menu.tsx +134 -38
  203. package/src/wizard/stepper-nav.tsx +35 -1
  204. package/dist/components/field-anatomy.d.ts +0 -95
  205. package/dist/components/field-anatomy.js +0 -84
  206. package/dist/components/field-anatomy.js.map +0 -1
  207. package/dist/feedback-attachment-NhUm7Zga.d.ts +0 -367
  208. package/src/components/field-anatomy.tsx +0 -190
@@ -1,18 +1,61 @@
1
1
  import { createContext, useId } from "react";
2
- import type { ReactNode } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { Tooltip } from "./tooltip";
5
5
  import { useCommitReason } from "./write-lock";
6
6
  import { hasMessage, mergeDescribedBy } from "./choice-parts";
7
7
 
8
8
  /**
9
- * Small pieces the fields share for the 0.22.0 anatomy round (keksdose K3/K4, kastlan
10
- * 8). Private to the package — not re-exported from the barrel — like `choice-parts`.
9
+ * The pieces of a field's anatomy the kit's fields share — the static label, the hint
10
+ * (caption or "?"), the caption and the error under a field, the label strip over a
11
+ * bare group, the write lock on a control that saves itself. Private to the package —
12
+ * not re-exported from the barrel, except `FieldLabel` and `FLOATING_LABEL_STATIC`,
13
+ * which ui.tsx re-exports as it always has.
11
14
  *
12
- * Nothing here imports `ui.tsx`: `ui.tsx` imports this, and so do the combobox family
13
- * and the native choice controls, which is the point of keeping the rules in one place.
15
+ * One file since 0.23: until then the pickers' half (`field-anatomy.tsx`: the date and
16
+ * month pickers, whose trigger is a button in a wrapper of their own, and the groups
17
+ * with no field box) sat beside this one with its own copies of `isTextHint` and the
18
+ * caption class (keksdose K4: "uniform field anatomy … today it is a patchwork").
19
+ *
20
+ * Nothing here imports `ui.tsx`: `ui.tsx` imports this, and so do the combobox family,
21
+ * the pickers and the native choice controls — the lint's no-cycle rule holds it.
14
22
  */
15
23
 
24
+ /* ── the static label ─────────────────────────────────────────────────────── */
25
+
26
+ // The TYPE of the small static label, without any placement. Split out so the
27
+ // label and anything sharing its line (see `hint` on {@link FloatingField}) are
28
+ // laid out by one flex row instead of by two absolute offsets guessing at the
29
+ // same baseline — which is what put dev#468's "?" three pixels above the word it
30
+ // belongs to.
31
+ export const STATIC_LABEL_TYPE =
32
+ "text-[11px] leading-tight text-[var(--text-muted)] peer-disabled:opacity-50";
33
+
34
+ // A field that always has a value (select / dropdown trigger) keeps the label
35
+ // permanently in the floated position — small, in the top strip, value below.
36
+ export const FLOATING_LABEL_STATIC = cn(
37
+ "pointer-events-none absolute start-3 top-1",
38
+ STATIC_LABEL_TYPE,
39
+ "max-w-[calc(100%-1.5rem)] truncate",
40
+ );
41
+
42
+ /**
43
+ * The floating label for a custom-dropdown trigger (a `<span>`, since the trigger is a
44
+ * button not a labelable input). Same placement as {@link FloatingField}'s static label,
45
+ * so every labelled field lines up. Render inside a `relative` wrapper, before the trigger.
46
+ */
47
+ export interface FieldLabelProps extends ComponentPropsWithoutRef<"span"> {
48
+ children: ReactNode;
49
+ }
50
+
51
+ export function FieldLabel({ children, className, ...rest }: FieldLabelProps) {
52
+ return (
53
+ <span {...rest} className={cn(FLOATING_LABEL_STATIC, "z-10", className)}>
54
+ {children}
55
+ </span>
56
+ );
57
+ }
58
+
16
59
  /* ── hint ─────────────────────────────────────────────────────────────────── */
17
60
 
18
61
  /**
@@ -182,3 +225,179 @@ export function LockedReason({
182
225
  * ChoiceCardGroup's `InGroupContext`, for the plain checkbox.
183
226
  */
184
227
  export const RequiredStarOnLegend = createContext(false);
228
+
229
+ /* ── fields not built on FloatingField: pickers and bare groups ──────────── */
230
+
231
+ /** `undefined`, `null`, `false` and `""` are what a caller's `cond && "…"` evaluates to
232
+ * when there is nothing to say; none of them is content. A numeric `0` is. */
233
+ export function hasContent(node: ReactNode): boolean {
234
+ return node !== undefined && node !== null && node !== false && node !== "";
235
+ }
236
+
237
+ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
238
+
239
+ export interface FieldMessages {
240
+ /** `invalid`, a message, or an `aria-invalid` from outside (a form library). */
241
+ isInvalid: boolean;
242
+ /** The caller's description, then the caption, then the error — the standing
243
+ * advice before the news, as `useFieldError` in ui.tsx orders them. */
244
+ describedBy: string | undefined;
245
+ /** The caption and the error, for under the field; `null` when there is neither. */
246
+ below: ReactNode;
247
+ /** The hint for the LABEL line (a `FieldHint`), or `undefined` when the hint is a
248
+ * caption or there is none. */
249
+ labelHint: ReactNode;
250
+ }
251
+
252
+ /** `hint` / `error` / `invalid` resolved the way `Select` resolves them. */
253
+ export function useFieldMessages({
254
+ hint,
255
+ error,
256
+ invalid,
257
+ describedBy,
258
+ ariaInvalid,
259
+ }: {
260
+ hint?: ReactNode;
261
+ error?: ReactNode;
262
+ invalid?: boolean;
263
+ describedBy?: string;
264
+ ariaInvalid?: unknown;
265
+ }): FieldMessages {
266
+ const hintId = useId();
267
+ const errorId = useId();
268
+ const textHint = isTextHint(hint);
269
+ const hasError = hasContent(error);
270
+ return {
271
+ isInvalid: Boolean(invalid) || hasError || ariaInvalid === true || ariaInvalid === "true",
272
+ describedBy:
273
+ [describedBy, textHint && hintId, hasError && errorId].filter(Boolean).join(" ") || undefined,
274
+ below:
275
+ textHint || hasError ? (
276
+ <>
277
+ {textHint && (
278
+ <p id={hintId} className={FIELD_CAPTION_CLASS}>
279
+ {hint}
280
+ </p>
281
+ )}
282
+ {hasError && (
283
+ <p id={errorId} className={FIELD_ERROR_CLASS}>
284
+ {error}
285
+ </p>
286
+ )}
287
+ </>
288
+ ) : null,
289
+ labelHint: !textHint && hasContent(hint) ? hint : undefined,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * The field and what is under it, as one box — `FieldGroup` in ui.tsx, for the same
295
+ * reasons written there. Nothing of its own while there is nothing under the field and
296
+ * the caller passed no `error` key (the DOM is exactly what it was before these props);
297
+ * once `error` is passed the box is kept even with no message, so the trigger is never
298
+ * reparented — and remounted, losing focus — as the message comes and goes.
299
+ */
300
+ export function FieldBox({
301
+ below,
302
+ reserve,
303
+ children,
304
+ }: {
305
+ below: ReactNode;
306
+ reserve: boolean;
307
+ children: ReactNode;
308
+ }) {
309
+ if (below === null && !reserve) return <>{children}</>;
310
+ return (
311
+ <div>
312
+ {children}
313
+ {below}
314
+ </div>
315
+ );
316
+ }
317
+
318
+ /**
319
+ * The static label in a trigger field's top strip — a {@link FieldLabel}, or, with a
320
+ * `FieldHint`, the label and its "?" laid out on ONE flex row (dev#468: two absolute
321
+ * offsets guessing at the same baseline put the "?" three pixels above its word).
322
+ * `FloatingField`'s static-label row, except that it stops at `end-9`: the date and
323
+ * month triggers keep that column (`pe-9`) for the calendar glyph or the clear ×, and a
324
+ * long label truncates before it rather than putting the "?" on top of it.
325
+ */
326
+ export function FieldLabelLine({ label, hint }: { label: ReactNode; hint?: ReactNode }) {
327
+ if (!hasContent(hint)) return <FieldLabel>{label}</FieldLabel>;
328
+ return (
329
+ // `z-10` as FieldLabel has it: the trigger after it is `relative` and would paint
330
+ // over the row (and take the "?"'s hover) otherwise.
331
+ <div className="pointer-events-none absolute start-3 end-9 top-1 z-10 flex items-center gap-1">
332
+ <FieldLabel className="static min-w-0 max-w-full">{label}</FieldLabel>
333
+ <span className="pointer-events-auto flex shrink-0 items-center">{hint}</span>
334
+ </div>
335
+ );
336
+ }
337
+
338
+ /**
339
+ * How tall the strip over a bare group is — the values {@link FieldStrip}'s `pad` takes,
340
+ * public since 0.23 (keksdose G8), so the names say what the CONTENT needs rather than
341
+ * which kit group first needed it (until 0.23 they were `toggle` and `tiles`).
342
+ *
343
+ * - `"field"` — 16px. keksdose's `ClearedStatusPicker` (live #288 rework, #431,
344
+ * measured): *"16px of label strip plus a 26px group is exactly a labelled Input"*,
345
+ * so a status group in a row of 42px fields ends where they end. The label's line
346
+ * box runs into the group's top hairline, but the ACTIVE fill — the thing feedback
347
+ * #117 was about — starts at 19px, below the label's 17.75px. For content with a
348
+ * border and an inset of its own; ToggleGroup's `labelPlacement="strip"`.
349
+ * - `"clear"` — 20px, keksdose's `FlagPicker` and location field (`pt-5`). A tile has
350
+ * no inset fill: its selection IS its frame, a 2px dark border-and-ring from the
351
+ * tile's top edge, so the tile has to start below the whole label line — and so does
352
+ * anything else with no inset: a map, a row of buttons, a line of text. SwatchPicker,
353
+ * IconPicker, and FieldStrip's default.
354
+ */
355
+ export const LABEL_STRIP_PAD = { field: "pt-4", clear: "pt-5" } as const;
356
+
357
+ /**
358
+ * The 11px label (and its `FieldHint`) over a group that is NOT a field — keksdose K2:
359
+ * a segmented control or a row of swatches standing in a form row beside labelled
360
+ * inputs, whose label must sit on the same line as theirs (`top-1`, `inset-x-3`, the
361
+ * type of a field's static label) without the group growing a field's border round
362
+ * it. `id` goes on the label so the group can be `aria-labelledby` it.
363
+ *
364
+ * `relative` is load-bearing twice over: the row is positioned against it, and a
365
+ * caller's `sr-only` label cannot escape it (sr-only-containment).
366
+ *
367
+ * Any other attribute (`rest`) lands on the wrapper — {@link FieldStrip} makes it the
368
+ * `role="group"` its label names. The kit's own groups pass none, so their wrapper is
369
+ * the bare box it was in 0.22.
370
+ */
371
+ export function LabelStrip({
372
+ labelId,
373
+ label,
374
+ hint,
375
+ disabled,
376
+ pad,
377
+ className,
378
+ children,
379
+ ...rest
380
+ }: Omit<ComponentPropsWithoutRef<"div">, "children"> & {
381
+ labelId: string;
382
+ label: ReactNode;
383
+ hint?: ReactNode;
384
+ /** Dim the label with its group, as `peer-disabled:` dims a field's own. */
385
+ disabled?: boolean;
386
+ pad: keyof typeof LABEL_STRIP_PAD;
387
+ children: ReactNode;
388
+ }) {
389
+ return (
390
+ <div {...rest} className={cn("relative min-w-0", LABEL_STRIP_PAD[pad], className)}>
391
+ <div className="pointer-events-none absolute inset-x-3 top-1 flex items-center gap-1">
392
+ {/* `static`: FieldLabel positions itself absolutely; here the ROW is
393
+ positioned, so the label and its "?" are centred on one line by the flex
394
+ layout (dev#468). */}
395
+ <FieldLabel id={labelId} className={cn("static min-w-0 max-w-full", disabled && "opacity-50")}>
396
+ {label}
397
+ </FieldLabel>
398
+ {hasContent(hint) && <span className="pointer-events-auto flex shrink-0 items-center">{hint}</span>}
399
+ </div>
400
+ {children}
401
+ </div>
402
+ );
403
+ }
@@ -0,0 +1,149 @@
1
+ import { useId } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
+ import { LabelStrip, useFieldMessages } from "./field-parts";
4
+
5
+ /**
6
+ * How far down the content starts, under the 11px label — see {@link FieldStripProps.pad}.
7
+ *
8
+ * - `"clear"` (default) — 20px: the content starts below the label's whole line box.
9
+ * - `"field"` — 16px: the strip plus a 26px bordered control is exactly a labelled
10
+ * {@link Input}'s 42px.
11
+ */
12
+ export type FieldStripPad = "clear" | "field";
13
+
14
+ /** What a {@link FieldStrip} hands render-prop children. */
15
+ export interface FieldStripIds {
16
+ /** The label's id — for content that names ITSELF by the label (`aria-labelledby`)
17
+ * under `group={false}`, or anything else that has to point at it. */
18
+ labelId: string;
19
+ /** The ids of the caption and the error while they are on screen, else `undefined`.
20
+ * The group is described by them already; spread this on the content under
21
+ * `group={false}`, where there is no group to describe. */
22
+ describedBy: string | undefined;
23
+ }
24
+
25
+ export interface FieldStripProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
26
+ /** The 11px static label, on the line a labelled field's label sits on. */
27
+ label: ReactNode;
28
+ /**
29
+ * Field anatomy, as on {@link Input}: plain text is a caption under the content (and
30
+ * describes the group); a {@link FieldHint} "?" rides the label line.
31
+ */
32
+ hint?: ReactNode;
33
+ /** A message under the content, in `--danger`, describing the group. `null`,
34
+ * `false` and `""` are no error. The content's own controls are the caller's to
35
+ * mark `aria-invalid`; a `group` cannot be. */
36
+ error?: ReactNode;
37
+ /**
38
+ * Dims the label with the content, as a disabled field's label dims. The content is
39
+ * the caller's: disable (or hide) its controls yourself — keksdose's location cell
40
+ * turns its map into a picture and drops its buttons, which no flag here could guess.
41
+ */
42
+ disabled?: boolean;
43
+ /**
44
+ * How tall the strip is — what the content needs, measured in keksdose (`pt-5` /
45
+ * `pt-4`, the 0.22 `LABEL_STRIP_PAD`):
46
+ *
47
+ * - `"clear"` (default) — 20px. The content starts below the label's whole line box
48
+ * (4px down, 13.75px tall). For content with no inset of its own: a map, a row of
49
+ * buttons, a pill, a line of text, a row of tiles whose selection is their frame
50
+ * (SwatchPicker, IconPicker).
51
+ * - `"field"` — 16px. The strip plus a 26px bordered control (1 + 2 + 20 + 2 + 1) is
52
+ * exactly a labelled Input's 42px, so the row's bottom edges line up. The label's
53
+ * line box runs 1.75px into the control's top hairline, so only content with an
54
+ * inset of its own keeps clear of it — the segmented group's 2px moat
55
+ * (ToggleGroup's `labelPlacement="strip"`).
56
+ *
57
+ * A responsive strip is a `className`: it merges after the pad, so keksdose's status
58
+ * cell writes `className="pt-5 @3xl:pt-4"` (20px on a phone, 16px in the desktop row).
59
+ */
60
+ pad?: FieldStripPad;
61
+ /**
62
+ * The wrapper is a `role="group"` named by the label (default), so a screen reader
63
+ * entering the map or the first button hears "Location, group" — what the visible
64
+ * label tells a sighted reader the content is about.
65
+ *
66
+ * `false` for content that is a labelled group ITSELF (a `role="radiogroup"` of the
67
+ * app's own, a TileRadioGroup's container, a fieldset): a group inside a group, both
68
+ * named "Pattern", is read twice. The wrapper is then a plain box, and the content
69
+ * takes the name from the render-prop's `labelId` (and the caption and error from
70
+ * its `describedBy`).
71
+ */
72
+ group?: boolean;
73
+ /** The content. A function receives {@link FieldStripIds}. */
74
+ children: ReactNode | ((ids: FieldStripIds) => ReactNode);
75
+ }
76
+
77
+ /**
78
+ * The kit's label strip for content of your own — keksdose G8.
79
+ *
80
+ * 0.22 gave three groups a strip label (keksdose K2): ToggleGroup's
81
+ * `labelPlacement="strip"`, SwatchPicker's and IconPicker's `label`. Each draws the
82
+ * 11px label (and its `FieldHint`) on the same line as the labelled fields beside it,
83
+ * over content WITHOUT a field's border round it. keksdose's transaction row has one
84
+ * cell left that is not one of those groups: the location — a map, a "Locate me" and a
85
+ * "Search" button — still built by hand as a `relative` cell, a floated `FieldLabel`
86
+ * and a `pt-5` box (transaction-location-field.tsx), and so is the reconciled-status
87
+ * stand-in (cleared-status-toggle.tsx, `ClearedStatusLocked`). This is the strip on its
88
+ * own, the same component the three groups draw theirs with:
89
+ *
90
+ * ```tsx
91
+ * <FieldStrip label={t("transactions.location")} className={className}>
92
+ * <div className="space-y-2">
93
+ * <PinMap … />
94
+ * <div className="flex flex-wrap gap-2"><Button …>Locate me</Button>…</div>
95
+ * </div>
96
+ * </FieldStrip>
97
+ * ```
98
+ *
99
+ * The label sits where a field's static label sits (`top-1`, `inset-x-3`, 11px in a
100
+ * `leading-tight` line); the content starts `pad` down, full width. `className` styles
101
+ * the wrapper — the grid or flex item in the caller's row — and merges after the pad.
102
+ *
103
+ * Not a {@link Field}: that one sets a 14px label ABOVE its control, in flow, for a form
104
+ * that labels its fields that way. This is for a row whose fields float their labels
105
+ * inside them, where a label above would be the only one of its kind.
106
+ */
107
+ export function FieldStrip({
108
+ label,
109
+ hint,
110
+ error,
111
+ disabled = false,
112
+ pad = "clear",
113
+ group = true,
114
+ className,
115
+ children,
116
+ ...rest
117
+ }: FieldStripProps) {
118
+ const labelId = useId();
119
+ const messages = useFieldMessages({ hint, error });
120
+ const content = typeof children === "function" ? children({ labelId, describedBy: messages.describedBy }) : children;
121
+ // The caller's own description first, then the caption, then the error — the order
122
+ // every kit field reads them in.
123
+ const groupDescribedBy = [rest["aria-describedby"], messages.describedBy].filter(Boolean).join(" ") || undefined;
124
+ return (
125
+ <LabelStrip
126
+ {...rest}
127
+ {...(group
128
+ ? {
129
+ role: "group",
130
+ // An explicit name wins, as on the kit's labelled groups.
131
+ "aria-labelledby":
132
+ rest["aria-label"] === undefined && rest["aria-labelledby"] === undefined
133
+ ? labelId
134
+ : rest["aria-labelledby"],
135
+ "aria-describedby": groupDescribedBy,
136
+ }
137
+ : null)}
138
+ labelId={labelId}
139
+ label={label}
140
+ hint={messages.labelHint}
141
+ disabled={disabled}
142
+ pad={pad}
143
+ className={className}
144
+ >
145
+ {content}
146
+ {messages.below}
147
+ </LabelStrip>
148
+ );
149
+ }
@@ -5,7 +5,7 @@ import { cn } from "../lib/cn";
5
5
  import { useKitLabels } from "../i18n/kit-labels";
6
6
  import { useMediaQuery } from "../hooks/use-media-query";
7
7
  import { isApplePlatform } from "../hooks/use-hotkey";
8
- import { Button, Spinner, type ButtonProps, type ButtonVariant } from "./ui";
8
+ import { Button, Spinner, type ButtonProps, type ButtonSize, type ButtonVariant } from "./ui";
9
9
  import { useWriteLock } from "./write-lock";
10
10
 
11
11
  /** The words {@link FormActions} renders on its own behalf — the `form` namespace. */
@@ -186,6 +186,27 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
186
186
  /** The save button's variant. Default `brand`; `danger` for a save that destroys
187
187
  * (kastlan's `destructive` flag on its own FormActions). */
188
188
  submitVariant?: ButtonVariant;
189
+ /**
190
+ * The Cancel button's variant. Default `secondary` (the outlined button, as before).
191
+ * `ghost` for a row where Cancel should step back further than an outline — keksdose
192
+ * G3: a quiet inline row (a confirm under a table row, a card's own small editor)
193
+ * whose Cancel was a ghost button before it moved onto FormActions, which drew every
194
+ * Cancel outlined.
195
+ */
196
+ cancelVariant?: ButtonVariant;
197
+ /**
198
+ * The size of every button the row draws itself — Save, Cancel and the
199
+ * `{ label, onClick }` form of {@link destructive}: Button's `size`. Default `md`.
200
+ * `sm` (12px text, `px-2 py-1`) for a dense row: a confirm inside a table's expansion
201
+ * row, a toolbar card. keksdose G3: two such rows had small hand-built buttons and lost
202
+ * them on adopting FormActions, which had no size. At `sm` the spinner and
203
+ * {@link submitIcon} shrink with the text.
204
+ *
205
+ * Your own elements (`children`, an element passed as `destructive`, `start`) are
206
+ * yours to size — pass them `size="sm"` too. A `size` in {@link submitProps} still
207
+ * wins for Save, as it always has.
208
+ */
209
+ size?: ButtonSize;
189
210
  /**
190
211
  * A destructive action at the START of the row, apart from Save and Cancel — the
191
212
  * "Delete" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger
@@ -328,6 +349,8 @@ export function FormActions({
328
349
  submitDisabledReason,
329
350
  commit = false,
330
351
  submitVariant = "brand",
352
+ cancelVariant = "secondary",
353
+ size = "md",
331
354
  destructive,
332
355
  start: startSlot,
333
356
  align,
@@ -355,6 +378,22 @@ export function FormActions({
355
378
  },
356
379
  [callerSaveRef],
357
380
  );
381
+ // The save settled and the form is still here — it failed, and the user retries. The
382
+ // button was natively disabled while it ran, and a focused button that turns disabled
383
+ // drops the focus to <body> (DangerConfirm's 0.24 fix, keksdose). Only after a press
384
+ // of THIS button (`pressed`: a click or the shortcut), so a `pending` that comes and
385
+ // goes on its own never pulls the page's focus in.
386
+ const pressed = useRef(false);
387
+ const wasPending = useRef(pending);
388
+ useEffect(() => {
389
+ const settled = wasPending.current && !pending;
390
+ wasPending.current = pending;
391
+ if (!settled) return;
392
+ const ours = pressed.current;
393
+ pressed.current = false;
394
+ if (!ours) return;
395
+ if (document.activeElement === null || document.activeElement === document.body) saveRef.current?.focus();
396
+ }, [pending]);
358
397
  // Read by the shortcut's listener at the moment of the key press, so the listener
359
398
  // itself is attached once and never sees a stale `pending`.
360
399
  const canSave = !pending && !submitDisabled && !(commit && lock.locked);
@@ -386,6 +425,7 @@ export function FormActions({
386
425
  type="button"
387
426
  variant="ghost"
388
427
  tone="danger"
428
+ size={size}
389
429
  onClick={destructive.onClick}
390
430
  disabled={destructive.disabled}
391
431
  disabledReason={destructive.disabledReason}
@@ -397,6 +437,9 @@ export function FormActions({
397
437
  destructive
398
438
  );
399
439
  const saveLabel = submitLabel ?? labels.save;
440
+ // The glyphs follow the text: 16px beside `md`'s 14px text, 14px beside `sm`'s 12px.
441
+ const saveSize = submitProps?.size ?? size;
442
+ const glyphClass = saveSize === "sm" ? "size-3.5" : "size-4";
400
443
 
401
444
  return (
402
445
  <div
@@ -435,7 +478,7 @@ export function FormActions({
435
478
  <div className="flex flex-wrap items-center gap-2">
436
479
  {children}
437
480
  {onCancel && (
438
- <Button type="button" variant="secondary" onClick={onCancel}>
481
+ <Button type="button" variant={cancelVariant} size={size} onClick={onCancel}>
439
482
  {cancelLabel ?? labels.cancel}
440
483
  </Button>
441
484
  )}
@@ -447,8 +490,12 @@ export function FormActions({
447
490
  }
448
491
  type={onSubmit ? "button" : "submit"}
449
492
  form={form}
450
- onClick={onSubmit}
493
+ onClick={() => {
494
+ pressed.current = true;
495
+ onSubmit?.();
496
+ }}
451
497
  variant={submitVariant}
498
+ size={saveSize}
452
499
  disabled={pending || submitDisabled}
453
500
  disabledReason={pending ? undefined : submitDisabledReason}
454
501
  // Not while pending: the save already left, and the lock's look over the
@@ -457,9 +504,9 @@ export function FormActions({
457
504
  aria-busy={pending || undefined}
458
505
  >
459
506
  {pending ? (
460
- <Spinner label={null} className="size-4" />
507
+ <Spinner label={null} className={glyphClass} />
461
508
  ) : (
462
- SubmitIcon && <SubmitIcon aria-hidden className="size-4 shrink-0" />
509
+ SubmitIcon && <SubmitIcon aria-hidden className={cn(glyphClass, "shrink-0")} />
463
510
  )}
464
511
  {pending && pendingLabel !== undefined ? pendingLabel : saveLabel}
465
512
  {submitShortcut && submitShortcutHint && (
@@ -5,7 +5,7 @@ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-l
5
5
  import { SearchField } from "./search-field";
6
6
  import { TILE_SIZE, TileRadioGroup } from "./tile-radio";
7
7
  import type { TileItem, TileSize } from "./tile-radio";
8
- import { hasContent, LabelStrip } from "./field-anatomy";
8
+ import { hasContent, LabelStrip } from "./field-parts";
9
9
 
10
10
  export interface IconPickerLabels {
11
11
  /** The "no icon" tile's name, when `allowNone` is set. */
@@ -41,7 +41,16 @@ export interface IconOption<T extends string> {
41
41
  /** A remark read after the name and shown under it in the bubble ("used by
42
42
  * Leisure"). The tile wears a dot while it has one. */
43
43
  note?: string;
44
+ /** Out of the choice and out of reach — skipped by the arrow keys and Tab. With a
45
+ * `disabledReason` the tile stays reachable instead and says why. */
44
46
  disabled?: boolean;
47
+ /**
48
+ * Why THIS symbol cannot be chosen right now — {@link SwatchOption.disabledReason}'s
49
+ * twin: the tile is `aria-disabled` but reachable by Tab and the arrow keys, the reason
50
+ * shows in its bubble under its name and describes it, and choosing it does nothing.
51
+ * Wins over `disabled`. Kept through a search like any other tile.
52
+ */
53
+ disabledReason?: ReactNode;
45
54
  }
46
55
 
47
56
  export interface IconPickerProps<T extends string>
@@ -147,7 +156,13 @@ export function IconPicker<T extends string>({
147
156
  // "None" stays through a search: it is the way back to no icon, and it matches
148
157
  // no word anybody would type for it.
149
158
  ...(allowNone ? [{ key: null, label: text.none }] : []),
150
- ...visible.map((o) => ({ key: o.value, label: o.label, note: o.note, disabled: o.disabled })),
159
+ ...visible.map((o) => ({
160
+ key: o.value,
161
+ label: o.label,
162
+ note: o.note,
163
+ disabled: o.disabled,
164
+ disabledReason: o.disabledReason,
165
+ })),
151
166
  ];
152
167
 
153
168
  const picker = (
@@ -173,7 +188,11 @@ export function IconPicker<T extends string>({
173
188
  <TileRadioGroup
174
189
  items={items}
175
190
  checked={mixed ? undefined : value}
176
- onSelect={onChange}
191
+ // A locked tile is refused by the tiles themselves; asked again here so a
192
+ // reason can never become a change, whatever the tiles do.
193
+ onSelect={(next) => {
194
+ if (!hasContent(next === null ? undefined : byValue.get(next)?.disabledReason)) onChange(next);
195
+ }}
177
196
  activation={activation}
178
197
  disabled={disabled}
179
198
  size={size}
@@ -209,7 +228,7 @@ export function IconPicker<T extends string>({
209
228
  );
210
229
  if (!labelled) return picker;
211
230
  return (
212
- <LabelStrip labelId={labelId} label={label} hint={hint} disabled={disabled} pad="tiles" className={className}>
231
+ <LabelStrip labelId={labelId} label={label} hint={hint} disabled={disabled} pad="clear" className={className}>
213
232
  {picker}
214
233
  </LabelStrip>
215
234
  );
@@ -45,6 +45,9 @@ function parseSettled(text: string): number | null | undefined {
45
45
  * blurs the field and tears the editor down before the popover can open (typed
46
46
  * calculations still evaluate).
47
47
  *
48
+ * `inputClassName` (0.23, keksdose G7) styles the `<input>` itself — `text-end` for a
49
+ * money column — where `className` sizes the wrapper; see {@link AmountInput}'s.
50
+ *
48
51
  * This, not a `NumberField` with `digits={2}`, is the number-valued field for money:
49
52
  * it settles to the CURRENCY's minor unit (JPY 0, CHF 2) and `digits` takes a unit
50
53
  * price's finer scale (kastlan 5).
@@ -9,7 +9,7 @@ import { Popover } from "./popover";
9
9
  import { splitTriggerAria } from "./trigger-aria";
10
10
  import type { TriggerAria } from "./trigger-aria";
11
11
  import { useKitDateFormatter, useKitLabels, useKitLocale } from "../i18n/kit-labels";
12
- import { FieldBox, FieldLabelLine, hasContent, useFieldMessages } from "./field-anatomy";
12
+ import { FieldBox, FieldLabelLine, hasContent, useFieldMessages } from "./field-parts";
13
13
 
14
14
  /**
15
15
  * Every string the month picker can speak. The month and year names are NOT here —
@@ -348,6 +348,7 @@ function MonthFieldTrigger({
348
348
  aria-expanded={open}
349
349
  id={aria.id}
350
350
  aria-invalid={invalid || aria["aria-invalid"] === true || aria["aria-invalid"] === "true" || undefined}
351
+ aria-required={aria["aria-required"]}
351
352
  aria-labelledby={aria["aria-label"] && !aria["aria-labelledby"] ? undefined : labelledBy}
352
353
  aria-label={aria["aria-label"]}
353
354
  aria-describedby={aria["aria-describedby"]}