@eifi1/ui-kit 0.16.1 → 0.18.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 (186) hide show
  1. package/README.md +34 -9
  2. package/dist/chart.d.ts +10 -1
  3. package/dist/components/account-chips.d.ts +153 -0
  4. package/dist/components/account-chips.js +135 -0
  5. package/dist/components/account-chips.js.map +1 -0
  6. package/dist/components/account-settings-labels.d.ts +10 -2
  7. package/dist/components/account-settings-labels.js +3 -0
  8. package/dist/components/account-settings-labels.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +18 -1
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/button-group.d.ts +10 -1
  12. package/dist/components/calculator.d.ts +10 -1
  13. package/dist/components/chip.d.ts +25 -1
  14. package/dist/components/chip.js +143 -6
  15. package/dist/components/chip.js.map +1 -1
  16. package/dist/components/confirm-dialog.d.ts +42 -1
  17. package/dist/components/confirm-dialog.js +36 -5
  18. package/dist/components/confirm-dialog.js.map +1 -1
  19. package/dist/components/copy-button.d.ts +10 -1
  20. package/dist/components/danger-confirm.d.ts +55 -4
  21. package/dist/components/danger-confirm.js +67 -30
  22. package/dist/components/danger-confirm.js.map +1 -1
  23. package/dist/components/data-table-cells.d.ts +1 -1
  24. package/dist/components/data-table-filter-popover.d.ts +1 -1
  25. package/dist/components/data-table-filters.d.ts +1 -1
  26. package/dist/components/data-table.d.ts +1 -1
  27. package/dist/components/data-table.js +93 -3
  28. package/dist/components/data-table.js.map +1 -1
  29. package/dist/components/facing-pair.d.ts +10 -1
  30. package/dist/components/file-button.d.ts +10 -1
  31. package/dist/components/file-dropzone.d.ts +10 -1
  32. package/dist/components/form-actions.d.ts +10 -1
  33. package/dist/components/form-actions.js +35 -1
  34. package/dist/components/form-actions.js.map +1 -1
  35. package/dist/components/line-items.d.ts +30 -6
  36. package/dist/components/line-items.js +77 -33
  37. package/dist/components/line-items.js.map +1 -1
  38. package/dist/components/list.d.ts +13 -1
  39. package/dist/components/list.js.map +1 -1
  40. package/dist/components/money-field.d.ts +10 -1
  41. package/dist/components/number-field.d.ts +11 -2
  42. package/dist/components/number-field.js +23 -22
  43. package/dist/components/number-field.js.map +1 -1
  44. package/dist/components/number-input.d.ts +10 -1
  45. package/dist/components/numpad-sheet.d.ts +10 -1
  46. package/dist/components/passkeys-setting.d.ts +27 -1
  47. package/dist/components/passkeys-setting.js +31 -5
  48. package/dist/components/passkeys-setting.js.map +1 -1
  49. package/dist/components/progress-bar.d.ts +25 -1
  50. package/dist/components/progress-bar.js +21 -4
  51. package/dist/components/progress-bar.js.map +1 -1
  52. package/dist/components/reauth-dialog.d.ts +90 -0
  53. package/dist/components/reauth-dialog.js +158 -0
  54. package/dist/components/reauth-dialog.js.map +1 -0
  55. package/dist/components/series-chart.d.ts +10 -1
  56. package/dist/components/server-wake.d.ts +58 -0
  57. package/dist/components/server-wake.js +58 -0
  58. package/dist/components/server-wake.js.map +1 -0
  59. package/dist/components/settings-fields.d.ts +10 -1
  60. package/dist/components/share-card.d.ts +61 -0
  61. package/dist/components/share-card.js +350 -0
  62. package/dist/components/share-card.js.map +1 -0
  63. package/dist/components/signed-amount.d.ts +12 -3
  64. package/dist/components/signed-amount.js +6 -4
  65. package/dist/components/signed-amount.js.map +1 -1
  66. package/dist/components/status-dot.d.ts +2 -0
  67. package/dist/components/text-link.d.ts +10 -1
  68. package/dist/components/time-input.d.ts +10 -1
  69. package/dist/components/toast.d.ts +10 -1
  70. package/dist/components/toast.js +42 -2
  71. package/dist/components/toast.js.map +1 -1
  72. package/dist/components/toggle-group.js +5 -2
  73. package/dist/components/toggle-group.js.map +1 -1
  74. package/dist/components/ui.d.ts +10 -1
  75. package/dist/components/ui.js +67 -30
  76. package/dist/components/ui.js.map +1 -1
  77. package/dist/components/use-table-state.d.ts +1 -1
  78. package/dist/components/user-avatar.d.ts +2 -0
  79. package/dist/components/write-lock.d.ts +68 -0
  80. package/dist/components/write-lock.js +33 -0
  81. package/dist/components/write-lock.js.map +1 -0
  82. package/dist/{data-table-CfVXtCeG.d.ts → data-table-3mi2R3so.d.ts} +59 -1
  83. package/dist/data-table.d.ts +1 -1
  84. package/dist/hooks/use-file-drop.d.ts +10 -1
  85. package/dist/hooks/use-overlay-history.js +16 -0
  86. package/dist/hooks/use-overlay-history.js.map +1 -1
  87. package/dist/i18n/defaults.d.ts +10 -1
  88. package/dist/i18n/defaults.js +11 -1
  89. package/dist/i18n/defaults.js.map +1 -1
  90. package/dist/i18n/german.d.ts +78 -0
  91. package/dist/i18n/{locales/de.js → german.js} +59 -9
  92. package/dist/i18n/german.js.map +1 -0
  93. package/dist/i18n/kit-labels.d.ts +10 -1
  94. package/dist/i18n/kit-labels.js +5 -0
  95. package/dist/i18n/kit-labels.js.map +1 -1
  96. package/dist/i18n/locales/de-CH.d.ts +23 -8
  97. package/dist/i18n/locales/de-CH.js +7 -3
  98. package/dist/i18n/locales/de-CH.js.map +1 -1
  99. package/dist/i18n/locales/{de.d.ts → en.d.ts} +24 -10
  100. package/dist/i18n/locales/en.js +95 -0
  101. package/dist/i18n/locales/en.js.map +1 -0
  102. package/dist/i18n/locales/es.d.ts +10 -1
  103. package/dist/i18n/locales/es.js +51 -1
  104. package/dist/i18n/locales/es.js.map +1 -1
  105. package/dist/i18n/locales/fr.d.ts +10 -1
  106. package/dist/i18n/locales/fr.js +51 -1
  107. package/dist/i18n/locales/fr.js.map +1 -1
  108. package/dist/i18n/locales/hu.d.ts +10 -1
  109. package/dist/i18n/locales/hu.js +54 -1
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.d.ts +10 -1
  112. package/dist/i18n/locales/it.js +51 -1
  113. package/dist/i18n/locales/it.js.map +1 -1
  114. package/dist/i18n/locales/zh.d.ts +10 -1
  115. package/dist/i18n/locales/zh.js +50 -1
  116. package/dist/i18n/locales/zh.js.map +1 -1
  117. package/dist/i18n/swiss.d.ts +3 -3
  118. package/dist/i18n/swiss.js.map +1 -1
  119. package/dist/index.d.ts +11 -6
  120. package/dist/index.js +33 -1
  121. package/dist/index.js.map +1 -1
  122. package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-CPbsjlXr.d.ts} +282 -10
  123. package/dist/lib/server-wake.d.ts +130 -0
  124. package/dist/lib/server-wake.js +121 -0
  125. package/dist/lib/server-wake.js.map +1 -0
  126. package/dist/rhf/fields.d.ts +10 -1
  127. package/dist/rhf/form.d.ts +10 -1
  128. package/dist/rhf/line-items.js.map +1 -1
  129. package/dist/rhf.d.ts +10 -1
  130. package/dist/search/global-search.d.ts +24 -4
  131. package/dist/search/global-search.js +31 -3
  132. package/dist/search/global-search.js.map +1 -1
  133. package/dist/search.d.ts +1 -1
  134. package/dist/shell/app-shell.d.ts +10 -1
  135. package/dist/shell/top-bar-brand.d.ts +10 -1
  136. package/dist/shell/topbar-action-menu.d.ts +2 -0
  137. package/dist/shell.d.ts +10 -1
  138. package/dist/wizard/stepper-nav.d.ts +10 -1
  139. package/dist/wizard.d.ts +10 -1
  140. package/package.json +1 -1
  141. package/src/components/account-chips.tsx +302 -0
  142. package/src/components/account-settings-labels.ts +16 -2
  143. package/src/components/amount-input.tsx +8 -0
  144. package/src/components/chip.tsx +239 -6
  145. package/src/components/confirm-dialog.tsx +82 -7
  146. package/src/components/danger-confirm.tsx +118 -33
  147. package/src/components/data-table.tsx +185 -3
  148. package/src/components/form-actions.tsx +93 -4
  149. package/src/components/line-items.tsx +116 -40
  150. package/src/components/list.tsx +13 -1
  151. package/src/components/number-field.tsx +32 -26
  152. package/src/components/passkeys-setting.tsx +68 -7
  153. package/src/components/progress-bar.tsx +40 -4
  154. package/src/components/reauth-dialog.tsx +273 -0
  155. package/src/components/server-wake.tsx +124 -0
  156. package/src/components/share-card.tsx +615 -0
  157. package/src/components/signed-amount.tsx +16 -5
  158. package/src/components/toast.tsx +72 -2
  159. package/src/components/toggle-group.tsx +5 -2
  160. package/src/components/ui.tsx +145 -34
  161. package/src/components/write-lock.tsx +96 -0
  162. package/src/hooks/use-overlay-history.ts +51 -0
  163. package/src/i18n/defaults.ts +10 -0
  164. package/src/i18n/{locales/de.ts → german.ts} +68 -13
  165. package/src/i18n/kit-labels.tsx +22 -2
  166. package/src/i18n/locales/de-CH.ts +17 -8
  167. package/src/i18n/locales/en.ts +116 -0
  168. package/src/i18n/locales/es.ts +51 -0
  169. package/src/i18n/locales/fr.ts +52 -0
  170. package/src/i18n/locales/hu.ts +55 -0
  171. package/src/i18n/locales/it.ts +51 -0
  172. package/src/i18n/locales/zh.ts +50 -0
  173. package/src/i18n/swiss.ts +3 -3
  174. package/src/index.ts +23 -1
  175. package/src/lib/server-wake.ts +270 -0
  176. package/src/rhf/line-items.tsx +2 -1
  177. package/src/search/global-search.tsx +59 -6
  178. package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
  179. package/dist/i18n/locales/de-CH-informal.js +0 -8
  180. package/dist/i18n/locales/de-CH-informal.js.map +0 -1
  181. package/dist/i18n/locales/de-informal.d.ts +0 -82
  182. package/dist/i18n/locales/de-informal.js +0 -37
  183. package/dist/i18n/locales/de-informal.js.map +0 -1
  184. package/dist/i18n/locales/de.js.map +0 -1
  185. package/src/i18n/locales/de-CH-informal.ts +0 -10
  186. package/src/i18n/locales/de-informal.ts +0 -61
@@ -135,9 +135,33 @@ type Kind = "default" | "success" | "error" | "warning" | "info" | "loading";
135
135
 
136
136
  const privately = (node: ReactNode) => <span data-private="">{node}</span>;
137
137
 
138
+ /**
139
+ * The toast's id, written onto its `<li>` as a class — the one per-toast attribute
140
+ * sonner passes through untouched besides `testId`, which is the app's (sonner keeps the
141
+ * id in React state only). `<Toaster dismissOnMiddleClick>` reads it back to dismiss
142
+ * the toast under the pointer, close button or not. The type is kept (`s`/`n`) because
143
+ * sonner matches ids with `===`, and the value is URI-encoded so any string id is one
144
+ * whitespace-free class token.
145
+ */
146
+ const ID_CLASS = "kit-toast-id-";
147
+ const idClass = (id: ToastId) =>
148
+ `${ID_CLASS}${typeof id === "number" ? "n" : "s"}-${encodeURIComponent(String(id))}`;
149
+
150
+ /** The id {@link idClass} stamped on a toast element, or null for a toast the kit did
151
+ * not make (an app still calling sonner directly). */
152
+ function readToastId(el: Element): ToastId | null {
153
+ for (const token of el.classList) {
154
+ if (!token.startsWith(ID_CLASS)) continue;
155
+ const kind = token.charAt(ID_CLASS.length);
156
+ const raw = decodeURIComponent(token.slice(ID_CLASS.length + 2));
157
+ return kind === "n" ? Number(raw) : raw;
158
+ }
159
+ return null;
160
+ }
161
+
138
162
  function toSonnerData(opts: ToastOptions | undefined, id: ToastId): SonnerData {
139
- const { redact, description, duration, action, ...rest } = opts ?? {};
140
- const data: SonnerData = { ...rest, id };
163
+ const { redact, description, duration, action, className, ...rest } = opts ?? {};
164
+ const data: SonnerData = { ...rest, id, className: className ? `${idClass(id)} ${className}` : idClass(id) };
141
165
  if (description !== undefined) data.description = redact ? privately(description) : description;
142
166
  if (action) data.action = action;
143
167
  const resolved = duration ?? (action ? TOAST_ACTION_DURATION : undefined);
@@ -312,6 +336,15 @@ export interface ToasterProps {
312
336
  hotkey?: string[];
313
337
  gap?: number;
314
338
  dir?: "ltr" | "rtl" | "auto";
339
+ /**
340
+ * A middle click on a toast dismisses it (0.18.0) — the gesture that closes a tab,
341
+ * which is what keksdose's users reached for (dev #583). By id, so it works on a
342
+ * toast with `closeButton: false` too; it counts as the user closing it, so
343
+ * `onDismiss` runs. Not on a link or a button inside the toast — a middle click on a
344
+ * link still opens it in a new tab — and not on a `dismissible: false` toast.
345
+ * Default true.
346
+ */
347
+ dismissOnMiddleClick?: boolean;
315
348
  className?: string;
316
349
  style?: CSSProperties;
317
350
  }
@@ -385,6 +418,7 @@ export function Toaster({
385
418
  swipeDirections = ALL_DIRECTIONS,
386
419
  offset,
387
420
  mobileOffset,
421
+ dismissOnMiddleClick = true,
388
422
  className,
389
423
  style,
390
424
  ...rest
@@ -409,6 +443,42 @@ export function Toaster({
409
443
  };
410
444
  }, [labels]);
411
445
 
446
+ useEffect(() => {
447
+ if (!dismissOnMiddleClick || !mod) return;
448
+ // On the document rather than the toaster: sonner owns the `<ol>`, and a listener
449
+ // there would have to be re-attached whenever it remounts it.
450
+ const toastUnder = (e: globalThis.MouseEvent) => {
451
+ if (e.button !== 1 || !(e.target instanceof Element)) return null;
452
+ const li = e.target.closest<HTMLElement>("[data-sonner-toast]");
453
+ if (!li || li.dataset.dismissible === "false") return null;
454
+ // A link keeps its middle click (open in a new tab); a button or a field inside
455
+ // the toast is the app's, not a background to click.
456
+ const control = e.target.closest("a[href], button, input, select, textarea, [role='button'], [role='link']");
457
+ if (control && li.contains(control)) return null;
458
+ return li;
459
+ };
460
+ // `mousedown`: the middle button starts autoscroll (Windows) before any click.
461
+ const onDown = (e: globalThis.MouseEvent) => {
462
+ if (toastUnder(e)) e.preventDefault();
463
+ };
464
+ const onAux = (e: globalThis.MouseEvent) => {
465
+ const li = toastUnder(e);
466
+ if (!li) return;
467
+ // No paste of the primary selection on Linux, no other handler's idea of it.
468
+ e.preventDefault();
469
+ const id = readToastId(li);
470
+ if (id !== null) mod.toast.dismiss(id);
471
+ // A toast made by sonner directly carries no id: its close button, if any.
472
+ else li.querySelector<HTMLButtonElement>("[data-close-button]")?.click();
473
+ };
474
+ document.addEventListener("mousedown", onDown);
475
+ document.addEventListener("auxclick", onAux);
476
+ return () => {
477
+ document.removeEventListener("mousedown", onDown);
478
+ document.removeEventListener("auxclick", onAux);
479
+ };
480
+ }, [dismissOnMiddleClick, mod]);
481
+
412
482
  const documentTheme = useSyncExternalStore(subscribeDocumentTheme, readDocumentTheme, () => "light" as const);
413
483
  const phone = useMediaQuery(PHONE_QUERY, false);
414
484
  const resolved: ToastPosition = position ?? (phone ? "bottom-center" : "top-center");
@@ -340,8 +340,11 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
340
340
  ? "bg-[var(--bg-inverse)] text-[var(--text-inverse)]"
341
341
  : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
342
342
  // No hover fill on a group that cannot be changed — a segment that
343
- // lights up under the pointer is an offer, and there is none here.
344
- disabled && "cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent",
343
+ // lights up under the pointer is an offer, and there is none here. Not on
344
+ // the chosen segment: its fill IS its state, and its inverse text would
345
+ // vanish on a transparent hover.
346
+ disabled && "cursor-not-allowed",
347
+ disabled && !active && "hover:bg-transparent dark:hover:bg-transparent",
345
348
  optionClassName,
346
349
  opt.className,
347
350
  )}
@@ -9,6 +9,7 @@ import { Tooltip, type TooltipSide } from "./tooltip";
9
9
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-labels";
10
10
  import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
11
11
  import { pickLinkRenderer, replacingClick, routerLinkNavigation } from "./text-link";
12
+ import { useCommitReason } from "./write-lock";
12
13
 
13
14
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
14
15
 
@@ -196,6 +197,13 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
196
197
  * DangerConfirm keeps its own.
197
198
  */
198
199
  disabledReason?: ReactNode;
200
+ /**
201
+ * This button COMMITS — it saves, creates, deletes. Under a locked
202
+ * {@link WriteLockProvider} it is disabled the `disabledReason` way, with the lock's
203
+ * reason (which wins over a `disabledReason` of its own). No provider, or an
204
+ * unlocked one: no effect. See {@link WriteLockProvider}.
205
+ */
206
+ commit?: boolean;
199
207
  /**
200
208
  * Busy — the save is in flight: a spinner, `aria-busy`, and no second submit.
201
209
  *
@@ -271,6 +279,7 @@ export interface ButtonLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorEle
271
279
  pending?: never;
272
280
  pressed?: never;
273
281
  disabledReason?: never;
282
+ commit?: never;
274
283
  }
275
284
 
276
285
  function hasContent(node: ReactNode): boolean {
@@ -396,7 +405,8 @@ function ButtonElement({
396
405
  stretch,
397
406
  tone = "default",
398
407
  pressed,
399
- disabledReason,
408
+ disabledReason: ownDisabledReason,
409
+ commit,
400
410
  pending,
401
411
  className,
402
412
  onClick,
@@ -409,6 +419,7 @@ function ButtonElement({
409
419
  ...rest
410
420
  }: ButtonProps) {
411
421
  const reasonId = useId();
422
+ const disabledReason = useCommitReason(commit, ownDisabledReason);
412
423
  const locked = hasContent(disabledReason);
413
424
  const inert = locked || Boolean(pending);
414
425
  const ownDescribedBy = rest["aria-describedby"];
@@ -874,6 +885,10 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
874
885
  * reason nobody can see is not one. Wins over `disabled`, as on Button.
875
886
  */
876
887
  disabledReason?: ReactNode;
888
+ /** This action COMMITS — {@link Button}'s `commit`: under a locked
889
+ * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's
890
+ * reason. No effect without a lock. */
891
+ commit?: boolean;
877
892
  /** The `<button>` element — a prop in React 19, as on {@link Button}. */
878
893
  ref?: Ref<HTMLButtonElement>;
879
894
  /** Only on the link form — see {@link IconButtonLinkProps}. */
@@ -937,6 +952,7 @@ export interface IconButtonLinkProps
937
952
  form?: never;
938
953
  pressed?: never;
939
954
  disabledReason?: never;
955
+ commit?: never;
940
956
  disabledStyle?: never;
941
957
  pending?: never;
942
958
  }
@@ -1164,7 +1180,8 @@ function IconButtonElement(
1164
1180
  tooltipSide,
1165
1181
  tooltipPortal,
1166
1182
  tooltipLazy = true,
1167
- disabledReason,
1183
+ disabledReason: ownDisabledReason,
1184
+ commit,
1168
1185
  pending,
1169
1186
  glyphSize,
1170
1187
  badge,
@@ -1186,6 +1203,7 @@ function IconButtonElement(
1186
1203
  const tone = toneProp ?? (toneColor !== undefined ? "custom" : "default");
1187
1204
  const keep = disabledStyle === "keep";
1188
1205
  const reasonId = useId();
1206
+ const disabledReason = useCommitReason(commit, ownDisabledReason);
1189
1207
  const locked = hasContent(disabledReason);
1190
1208
  // Pending is inert like a locked button — focusable, every activation swallowed —
1191
1209
  // without the locked look: the spinner is the state.
@@ -1750,15 +1768,48 @@ function useFieldError(
1750
1768
  * down out of the field, between the value and the text. So the field keeps its box
1751
1769
  * and this wraps the pair.
1752
1770
  *
1753
- * It renders nothing of its own when there is no message, which is what keeps `error`
1754
- * additive: a field without one is exactly the DOM it was before the prop existed,
1755
- * down to the bare `<input>` an unlabelled {@link Input} drops straight into a
1756
- * caller's flex row. For the same reason `className` is NOT moved out here — it goes
1757
- * on the field, as it always has, so adding a message cannot silently change what
1758
- * that prop styles.
1771
+ * WHEN IT WRAPS. A field that never takes `error` renders nothing of its own here,
1772
+ * which is what keeps the prop additive: it is exactly the DOM it was before the prop
1773
+ * existed, down to the bare `<input>` an unlabelled {@link Input} drops straight into a
1774
+ * caller's flex row. A field that DOES take `error` — the key is passed, whatever its
1775
+ * value — is wrapped for as long as it is mounted, message or not (`reserve`).
1776
+ *
1777
+ * It used to wrap only while a message showed, and that cost the control its
1778
+ * identity: a fragment and a `<div>` are different elements at the same spot in the
1779
+ * tree, so React threw the `<input>` away and mounted a new one every time the message
1780
+ * came or went. Focus fell to `<body>`, the caret went with it, and an uncontrolled
1781
+ * field lost what had been typed — Kurvenschmiede's confirm-password dialog clears its
1782
+ * "Wrong password" on change, so the first keystroke of the retry dropped the user out
1783
+ * of the field. Keeping the box stops that; the field is never reparented.
1784
+ *
1785
+ * The other ways to keep it stable were worse. A box on EVERY field changes what every
1786
+ * flex row and grid in three apps lays out (`flex-1`, `w-56`, `col-span-2` on the
1787
+ * field would land on a child of the item instead of the item). A `display: contents`
1788
+ * box would keep the flex/grid item, but a contents box takes no margin, so a parent's
1789
+ * `space-y-*` stops spacing the field — the shape of Kurvenschmiede's reset-password
1790
+ * form and gear wizard, both `space-y-*` stacks of fields with `error` — and it moves
1791
+ * `first:`/`last:`/`> *` matches. A field passing `error` already took this plain box
1792
+ * whenever its message showed, so what changes is only that it keeps it while none
1793
+ * does; every such call site in the three apps sits in a block or a `space-y`/grid
1794
+ * stack where a full-width block box is the field's own size. The case it does change
1795
+ * is a field passing `error` whose `className` sizes it as a flex item (`flex-1` in a
1796
+ * row): that caller already lost the sizing while a message showed, and now sees the
1797
+ * same box without one. Pass `error` only where a message can actually appear.
1798
+ *
1799
+ * `className` is NOT moved out here, for the same reason — it goes on the field, as it
1800
+ * always has, so the box cannot silently change what that prop styles.
1759
1801
  */
1760
- function FieldGroup({ errorEl, children }: { errorEl: ReactNode; children: ReactNode }) {
1761
- if (errorEl === null) return <>{children}</>;
1802
+ function FieldGroup({
1803
+ errorEl,
1804
+ reserve = false,
1805
+ children,
1806
+ }: {
1807
+ errorEl: ReactNode;
1808
+ /** The caller passed `error`: keep the box even while there is no message. */
1809
+ reserve?: boolean;
1810
+ children: ReactNode;
1811
+ }) {
1812
+ if (errorEl === null && !reserve) return <>{children}</>;
1762
1813
  return (
1763
1814
  <div>
1764
1815
  {children}
@@ -1829,8 +1880,8 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
1829
1880
  passwordLabels?: Partial<PasswordRevealLabels>;
1830
1881
  }
1831
1882
 
1832
- export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
1833
- {
1883
+ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
1884
+ const {
1834
1885
  className,
1835
1886
  inputClassName,
1836
1887
  label,
@@ -1842,9 +1893,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
1842
1893
  error,
1843
1894
  passwordLabels,
1844
1895
  ...rest
1845
- },
1846
- ref,
1847
- ) {
1896
+ } = props;
1897
+ // Passed at all — even as `undefined` — the field keeps its box; see FieldGroup.
1898
+ const reserve = "error" in props;
1848
1899
  const generated = useId();
1849
1900
  const fieldId = id ?? generated;
1850
1901
  const { isInvalid, describedBy, errorEl } = useFieldError(
@@ -1894,7 +1945,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
1894
1945
  if (label === undefined) {
1895
1946
  if (!isPassword) {
1896
1947
  return (
1897
- <FieldGroup errorEl={errorEl}>
1948
+ <FieldGroup errorEl={errorEl} reserve={reserve}>
1898
1949
  <input
1899
1950
  ref={ref}
1900
1951
  id={id}
@@ -1915,7 +1966,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
1915
1966
  );
1916
1967
  }
1917
1968
  return (
1918
- <FieldGroup errorEl={errorEl}>
1969
+ <FieldGroup errorEl={errorEl} reserve={reserve}>
1919
1970
  <div className={cn("relative", className)}>
1920
1971
  <input
1921
1972
  ref={ref}
@@ -1933,7 +1984,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
1933
1984
  );
1934
1985
  }
1935
1986
  return (
1936
- <FieldGroup errorEl={errorEl}>
1987
+ <FieldGroup errorEl={errorEl} reserve={reserve}>
1937
1988
  <FloatingField className={className} htmlFor={fieldId} label={label} srOnlyLabel={asDisplay}>
1938
1989
  <input
1939
1990
  ref={ref}
@@ -2001,10 +2052,11 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
2001
2052
  // height the caller's type brings with it.
2002
2053
  const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
2003
2054
 
2004
- export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
2005
- { className, label, id, children, invalid, error, hint, size, selectClassName, ...rest },
2006
- ref,
2007
- ) {
2055
+ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(props, ref) {
2056
+ const { className, label, id, children, invalid, error, hint, size, selectClassName, ...rest } =
2057
+ props;
2058
+ // See Input's `reserve`.
2059
+ const reserve = "error" in props;
2008
2060
  const generated = useId();
2009
2061
  // Only a number reaches the DOM; the two words are this component's own.
2010
2062
  const nativeSize = typeof size === "number" ? size : undefined;
@@ -2100,7 +2152,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2100
2152
  // box, as the label line's own hint goes at the end of the label.
2101
2153
  if (hasContent(hint) && !textHint) {
2102
2154
  return (
2103
- <FieldGroup errorEl={below}>
2155
+ <FieldGroup errorEl={below} reserve={reserve}>
2104
2156
  <div className={cn("flex items-center gap-1.5", className)}>
2105
2157
  <div className="relative min-w-0 flex-1">
2106
2158
  {select}
@@ -2112,7 +2164,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2112
2164
  );
2113
2165
  }
2114
2166
  return (
2115
- <FieldGroup errorEl={below}>
2167
+ <FieldGroup errorEl={below} reserve={reserve}>
2116
2168
  <div className={cn("relative", className)}>
2117
2169
  {select}
2118
2170
  {chevron}
@@ -2121,7 +2173,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2121
2173
  );
2122
2174
  }
2123
2175
  return (
2124
- <FieldGroup errorEl={below}>
2176
+ <FieldGroup errorEl={below} reserve={reserve}>
2125
2177
  <FloatingField className={className} htmlFor={fieldId} label={label} staticLabel hint={textHint ? undefined : hint}>
2126
2178
  <select
2127
2179
  ref={ref}
@@ -2159,10 +2211,10 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
2159
2211
  error?: ReactNode;
2160
2212
  }
2161
2213
 
2162
- export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
2163
- { className, label, id, placeholder, invalid, error, ...rest },
2164
- ref,
2165
- ) {
2214
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(props, ref) {
2215
+ const { className, label, id, placeholder, invalid, error, ...rest } = props;
2216
+ // See Input's `reserve`.
2217
+ const reserve = "error" in props;
2166
2218
  const generated = useId();
2167
2219
  const fieldId = id ?? generated;
2168
2220
  const { isInvalid, describedBy, errorEl } = useFieldError(
@@ -2173,7 +2225,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
2173
2225
  );
2174
2226
  if (label === undefined) {
2175
2227
  return (
2176
- <FieldGroup errorEl={errorEl}>
2228
+ <FieldGroup errorEl={errorEl} reserve={reserve}>
2177
2229
  <textarea
2178
2230
  ref={ref}
2179
2231
  id={id}
@@ -2187,7 +2239,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
2187
2239
  );
2188
2240
  }
2189
2241
  return (
2190
- <FieldGroup errorEl={errorEl}>
2242
+ <FieldGroup errorEl={errorEl} reserve={reserve}>
2191
2243
  <FloatingField className={className} htmlFor={fieldId} label={label}>
2192
2244
  <textarea
2193
2245
  ref={ref}
@@ -2269,8 +2321,33 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
2269
2321
  * nor on `inset`, which has no border to make louder (its tone is a fill).
2270
2322
  */
2271
2323
  toneStrength?: "soft" | "strong";
2324
+ /**
2325
+ * Wash the card in the tone's quiet `-bg` surface as well as framing it. A toned
2326
+ * card is a border and a title colour, at either strength — the wash belongs to
2327
+ * `AlertBanner`, the message, not to a card that holds a form — so it stays
2328
+ * off by default. Some tiles want both: keksdose's password / delete-account tile
2329
+ * was `toneFrameClass(tone)` (border AND wash) before 0.16, and after moving to
2330
+ * `toneStrength="strong"` it re-added `bg-[var(--danger-bg)]` by hand. The wash is
2331
+ * layered OVER the card's own `--bg-surface` (as AlertBanner's `elevated` does), so
2332
+ * the dark theme's translucent tints stay a card, not a hole to the page below.
2333
+ *
2334
+ * No effect without `tone`, nor on `inset`, whose tone is already the fill.
2335
+ */
2336
+ toneFill?: boolean;
2337
+ /**
2338
+ * The parts' type scale. `"compact"`: CardTitle `text-sm font-medium`,
2339
+ * CardDescription `text-xs` and the CardHeader's title-to-description gap
2340
+ * `gap-0.5` — the small settings / admin card. keksdose wrote those three classes by
2341
+ * hand on ~25 cards. Set here, they reach the parts through context; a class on a
2342
+ * part still wins, and a Card nested inside starts again from its own density.
2343
+ *
2344
+ * Default `"comfortable"`: the parts as they have always been.
2345
+ */
2346
+ density?: CardDensity;
2272
2347
  }
2273
2348
 
2349
+ export type CardDensity = "comfortable" | "compact";
2350
+
2274
2351
  export type CardTone = "warning" | "danger" | "info" | "success";
2275
2352
 
2276
2353
  // The title is reached through its `data-slot`, so the tone needs no context and a
@@ -2312,6 +2389,16 @@ const CARD_PADDING_STRONG: Record<NonNullable<CardProps["padding"]>, string> = {
2312
2389
  md: "p-[15px]",
2313
2390
  };
2314
2391
 
2392
+ // The wash as a background IMAGE over the card's opaque `--bg-surface` — see
2393
+ // `toneFill`. A plain `bg-[var(--danger-bg)]` would REPLACE the surface, and the dark
2394
+ // theme's washes are a translucent tint.
2395
+ const CARD_TONE_WASH: Record<CardTone, string> = {
2396
+ warning: "bg-[image:linear-gradient(var(--warning-bg),var(--warning-bg))]",
2397
+ danger: "bg-[image:linear-gradient(var(--danger-bg),var(--danger-bg))]",
2398
+ info: "bg-[image:linear-gradient(var(--info-bg),var(--info-bg))]",
2399
+ success: "bg-[image:linear-gradient(var(--success-bg),var(--success-bg))]",
2400
+ };
2401
+
2315
2402
  const CARD_TONES_STRONG: Record<CardTone, string> = {
2316
2403
  warning: "border-2 border-[var(--warning)]",
2317
2404
  danger: "border-2 border-[var(--danger-border-strong)]",
@@ -2326,6 +2413,9 @@ const CARD_TONES_STRONG: Record<CardTone, string> = {
2326
2413
  */
2327
2414
  const CardPaddedContext = createContext(false);
2328
2415
 
2416
+ /** The {@link CardProps.density} of the nearest Card — the parts' type scale. */
2417
+ const CardDensityContext = createContext<CardDensity>("comfortable");
2418
+
2329
2419
  export function Card({
2330
2420
  className,
2331
2421
  children,
@@ -2333,6 +2423,8 @@ export function Card({
2333
2423
  variant = "default",
2334
2424
  tone,
2335
2425
  toneStrength = "soft",
2426
+ toneFill = false,
2427
+ density = "comfortable",
2336
2428
  padding,
2337
2429
  ...rest
2338
2430
  }: CardProps) {
@@ -2347,6 +2439,7 @@ export function Card({
2347
2439
  <div
2348
2440
  data-tone={tone}
2349
2441
  data-tone-strength={strong ? "strong" : undefined}
2442
+ data-density={density === "compact" ? "compact" : undefined}
2350
2443
  {...rest}
2351
2444
  className={cn(
2352
2445
  variant === "inset"
@@ -2365,12 +2458,15 @@ export function Card({
2365
2458
  ),
2366
2459
  toned && (bordered ? toned.border : toned.fill),
2367
2460
  strong && tone && CARD_TONES_STRONG[tone],
2461
+ toneFill && bordered && tone && CARD_TONE_WASH[tone],
2368
2462
  toned?.title,
2369
2463
  ownPadding && (strong ? CARD_PADDING_STRONG : CARD_PADDING)[ownPadding],
2370
2464
  className,
2371
2465
  )}
2372
2466
  >
2373
- <CardPaddedContext.Provider value={ownPadding !== undefined}>{children}</CardPaddedContext.Provider>
2467
+ <CardPaddedContext.Provider value={ownPadding !== undefined}>
2468
+ <CardDensityContext.Provider value={density}>{children}</CardDensityContext.Provider>
2469
+ </CardPaddedContext.Provider>
2374
2470
  </div>
2375
2471
  );
2376
2472
  }
@@ -2415,6 +2511,7 @@ export type CardFooterProps = ComponentPropsWithoutRef<"div">;
2415
2511
 
2416
2512
  export function CardHeader({ className, stackAction = false, ...props }: CardHeaderProps) {
2417
2513
  const padded = useContext(CardPaddedContext);
2514
+ const compact = useContext(CardDensityContext) === "compact";
2418
2515
  return (
2419
2516
  <div
2420
2517
  data-slot="card-header"
@@ -2433,6 +2530,7 @@ export function CardHeader({ className, stackAction = false, ...props }: CardHea
2433
2530
  // never inherits an ancestor header's two columns.
2434
2531
  "grid auto-rows-min items-start gap-1.5 [--card-header-cols:1fr] has-data-[slot=card-action]:[--card-header-cols:1fr_auto] grid-cols-[var(--card-header-cols,1fr)]",
2435
2532
  padded ? "px-0 pt-0" : "px-6 pt-6",
2533
+ compact && "gap-0.5",
2436
2534
  // One column under `sm`, and the action back into the flow (DOM order, so
2437
2535
  // after the description when it is written after it), at the start edge.
2438
2536
  // The descendant selector outranks CardAction's own placement classes.
@@ -2446,10 +2544,23 @@ export function CardHeader({ className, stackAction = false, ...props }: CardHea
2446
2544
  }
2447
2545
 
2448
2546
  export function CardTitle({ className, as: Tag = "div", ...props }: CardTitleProps) {
2449
- return <Tag data-slot="card-title" className={cn("font-semibold leading-none", className)} {...props} />;
2547
+ const compact = useContext(CardDensityContext) === "compact";
2548
+ return (
2549
+ <Tag
2550
+ data-slot="card-title"
2551
+ // Compact is `leading-snug`, not `leading-none`: there the title sits on a
2552
+ // 12px description `gap-0.5` below, and a line box exactly the cap height left
2553
+ // the hint hugging the title's descenders (every keksdose admin card added
2554
+ // `leading-normal` by hand). Comfortable keeps `leading-none` — its 14px
2555
+ // description is `gap-1.5` away and has always read right.
2556
+ className={cn(compact ? "text-sm font-medium leading-snug" : "font-semibold leading-none", className)}
2557
+ {...props}
2558
+ />
2559
+ );
2450
2560
  }
2451
2561
 
2452
2562
  export function CardDescription({ className, ...props }: CardDescriptionProps) {
2563
+ const compact = useContext(CardDensityContext) === "compact";
2453
2564
  return (
2454
2565
  <div
2455
2566
  data-slot="card-description"
@@ -2457,7 +2568,7 @@ export function CardDescription({ className, ...props }: CardDescriptionProps) {
2457
2568
  // field captions). It was `--money-neutral`, a MONEY token that happened to be
2458
2569
  // a similar grey — and a palette that re-tunes the money colours moved every
2459
2570
  // card's description with them.
2460
- className={cn("text-sm text-[var(--text-muted)]", className)}
2571
+ className={cn(compact ? "text-xs" : "text-sm", "text-[var(--text-muted)]", className)}
2461
2572
  {...props}
2462
2573
  />
2463
2574
  );
@@ -0,0 +1,96 @@
1
+ import { createContext, useContext, useMemo } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { useKitLabels } from "../i18n/kit-labels";
4
+
5
+ /** The words {@link WriteLockProvider} renders on its own behalf. */
6
+ export interface WriteLockLabels {
7
+ /** The tooltip of a locked commit when the provider gives no `reason`. */
8
+ reason: string;
9
+ }
10
+
11
+ export const DEFAULT_WRITE_LOCK_LABELS: WriteLockLabels = {
12
+ reason: "You can view this but not change it.",
13
+ };
14
+
15
+ /** What {@link useWriteLock} returns: whether commits are locked here, and why. */
16
+ export interface WriteLock {
17
+ locked: boolean;
18
+ /** The one sentence a locked commit shows in its tooltip. Always set while
19
+ * `locked`; `undefined` when not. */
20
+ reason: ReactNode | undefined;
21
+ }
22
+
23
+ const UNLOCKED: WriteLock = { locked: false, reason: undefined };
24
+
25
+ const WriteLockContext = createContext<WriteLock>(UNLOCKED);
26
+
27
+ export interface WriteLockProviderProps {
28
+ /** Commits below are locked. `false` unlocks a subtree under an outer lock. */
29
+ locked: boolean;
30
+ /**
31
+ * The one sentence that says why — "Shared with you to read", "Read-only demo —
32
+ * saving is disabled", "A customer account cannot create curves". The app's policy,
33
+ * already translated. Left out: `labels.reason`.
34
+ */
35
+ reason?: ReactNode;
36
+ labels?: Partial<WriteLockLabels>;
37
+ children?: ReactNode;
38
+ }
39
+
40
+ /**
41
+ * The read-only lock — **everything renders; only the commit is locked.**
42
+ *
43
+ * keksdose (features/budgets/write-lock.tsx, the read-only demo budget) and
44
+ * Kurvenschmiede (features/sharing/write-lock.tsx, a curve shared to view, a customer
45
+ * account that owns nothing) each built the same `SaveGuard`: the page, its tabs,
46
+ * forms and dialogs render exactly as for an owner, because a viewer looking at half
47
+ * a page is not looking at what they were sent; what changes is that the control
48
+ * that SAVES is off and says why on hover or focus. Both wrapped each Save in a
49
+ * Tooltip and forced a native `disabled` on it — out of the tab order, so the reason
50
+ * never reached a keyboard — and both had to remember it at thirty call sites.
51
+ *
52
+ * Here the page mounts one provider and each commit control opts in with `commit`
53
+ * ({@link Button}, {@link IconButton}, {@link FormActions}): under a lock it takes the
54
+ * kit's `disabledReason` path with this reason — `aria-disabled` and focusable,
55
+ * every activation swallowed, the reason in the kit Tooltip and its description.
56
+ *
57
+ * Who is locked is the app's: the provider takes a boolean, never a role. Fields stay
58
+ * editable — nothing in them reaches the server until the commit is pressed, and a
59
+ * form you cannot fill in is not a demo of the form (keksdose accounts-page). A
60
+ * control that IS its own commit (a toggle that saves on change) reads
61
+ * {@link useWriteLock} and disables itself. The lock is a courtesy, not a guard: the
62
+ * server still refuses the write.
63
+ *
64
+ * Nested providers: the nearest wins, so an inner `locked={false}` reopens a section
65
+ * the caller may write (a viewer's own notes on a shared curve), and an inner lock
66
+ * with its own reason (Kurvenschmiede's owner lock beside the viewer lock) overrides
67
+ * the outer one's sentence.
68
+ */
69
+ export function WriteLockProvider({ locked, reason, labels, children }: WriteLockProviderProps) {
70
+ // The `writeLock` namespace: prop > `<UiKitProvider labels>` > English.
71
+ const fallback = useKitLabels("writeLock", DEFAULT_WRITE_LOCK_LABELS, labels).reason;
72
+ // `null`/`""`/`false` count as "no reason given": a lock whose tooltip is empty
73
+ // would be a disabled button that cannot say why, the thing this replaces.
74
+ const given = reason !== undefined && reason !== null && reason !== false && reason !== "";
75
+ const resolved = locked ? (given ? reason : fallback) : undefined;
76
+ const value = useMemo<WriteLock>(
77
+ () => (locked ? { locked: true, reason: resolved } : UNLOCKED),
78
+ [locked, resolved],
79
+ );
80
+ return <WriteLockContext.Provider value={value}>{children}</WriteLockContext.Provider>;
81
+ }
82
+
83
+ /** The nearest {@link WriteLockProvider}'s lock; unlocked with no provider. */
84
+ export function useWriteLock(): WriteLock {
85
+ return useContext(WriteLockContext);
86
+ }
87
+
88
+ /**
89
+ * The `disabledReason` a control with `commit` should use: the lock's reason while
90
+ * locked — it wins over the control's own, since it is the answer to the question the
91
+ * hover is asking — else the control's own. Internal to the kit's commit controls.
92
+ */
93
+ export function useCommitReason(commit: boolean | undefined, own: ReactNode): ReactNode {
94
+ const lock = useWriteLock();
95
+ return commit && lock.locked ? lock.reason : own;
96
+ }