@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
@@ -53,8 +53,10 @@ export interface LineItemCellContext<T> {
53
53
  * so the list draws no header for it. */
54
54
  export type LineItemsFieldLabels = "aria" | "floating";
55
55
 
56
- /** The colour of a {@link LineItemsSummary}'s value. */
57
- export type LineItemsSummaryTone = "neutral" | "success" | "danger" | "muted";
56
+ /** The colour of a {@link LineItemsSummary}'s value — a verdict (`success` /
57
+ * `danger`), quiet (`muted`), or the money pair (`income` / `expense`, the colours
58
+ * `SignedAmount` draws a figure in) for a remainder that reads as the row it settles. */
59
+ export type LineItemsSummaryTone = "neutral" | "success" | "danger" | "muted" | "income" | "expense";
58
60
 
59
61
  /**
60
62
  * A line under the lines that says where they stand — a split's remainder ("Left to
@@ -65,13 +67,28 @@ export interface LineItemsSummary {
65
67
  label: ReactNode;
66
68
  value?: ReactNode;
67
69
  /** The value's colour. Default `neutral`. The label stays neutral: the colour is a
68
- * second signal, never the only one — the value and label carry the meaning. */
70
+ * second signal, never the only one — the value and label carry the meaning.
71
+ * With no `value` the line is a state on its own ("Waiting for the total"), and
72
+ * the tone colours the label instead — `muted` quiets the whole line. */
69
73
  tone?: LineItemsSummaryTone;
74
+ /** Beside the value: a chip or badge that names the state ("Balanced" next to a
75
+ * journal entry's zero difference). */
76
+ status?: ReactNode;
70
77
  /** A small button at the end of the line. Disabled along with the list's
71
- * `disabled`. */
72
- action?: { label: ReactNode; onClick: () => void; disabled?: boolean };
78
+ * `disabled`. `variant`: `secondary` (default) is a boxed button; `link` a muted
79
+ * text link, for an action that is an aside rather than the way to settle it. */
80
+ action?: { label: ReactNode; onClick: () => void; disabled?: boolean; variant?: "secondary" | "link" };
73
81
  }
74
82
 
83
+ /** Where a row's remove button sits. `row` (default): stacked, a row of the card to
84
+ * itself (paired, the whole width); wide, its own track. `inline`: stacked, beside
85
+ * the row's last field ("category + amount" over "memo + delete"); wide, unchanged. */
86
+ export type LineItemsRemovePlacement = "row" | "inline";
87
+
88
+ /** How the remove button sits in the height of a wide row: at its top, centred, or at
89
+ * its foot. */
90
+ export type LineItemsRemoveAlign = "start" | "center" | "end";
91
+
75
92
  export interface LineItemsColumn<T> {
76
93
  /** Stable id: the React key, and the key into `totals`. */
77
94
  key: string;
@@ -143,6 +160,14 @@ export interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<"div">,
143
160
  * them up (a column's `narrowSpan={2}` takes a whole row), once the pane is wide
144
161
  * enough for two fields (18rem); narrower still, one. Default `1`. */
145
162
  narrowColumns?: 1 | 2;
163
+ /** Stacked, where the remove button goes: a row of its own (`row`, default) or
164
+ * beside the last field (`inline`). See {@link LineItemsRemovePlacement}. */
165
+ removePlacement?: LineItemsRemovePlacement;
166
+ /** On the wide grid, the remove button's place in the row's height. Default `start`
167
+ * — or `center` when a column floats its label (`fieldLabels="floating"`): a
168
+ * floating field is taller than the button, and centred is level with its control.
169
+ * A field that shows a message under itself grows the row; pin `start` then. */
170
+ removeAlign?: LineItemsRemoveAlign;
146
171
  /** Disables add and remove (the fields are the caller's to disable). */
147
172
  disabled?: boolean;
148
173
  labels?: Partial<LineItemsLabels>;
@@ -154,6 +179,14 @@ const SUMMARY_TONE: Record<LineItemsSummaryTone, string> = {
154
179
  success: "text-[var(--success)]",
155
180
  danger: "text-[var(--danger)]",
156
181
  muted: "text-[var(--text-muted)]",
182
+ // SignedAmount's money palette (toneTextClass "income" / "expense").
183
+ income: "text-[var(--money-income)]",
184
+ expense: "text-[var(--money-expense)]",
185
+ };
186
+ const REMOVE_ALIGN: Record<LineItemsRemoveAlign, string> = {
187
+ start: "@lg:self-start",
188
+ center: "@lg:self-center",
189
+ end: "@lg:self-end",
157
190
  };
158
191
  const FOCUSABLE =
159
192
  "input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])";
@@ -217,6 +250,8 @@ export function LineItems<T>({
217
250
  summary,
218
251
  fieldLabels = "aria",
219
252
  narrowColumns = 1,
253
+ removePlacement = "row",
254
+ removeAlign: removeAlignProp,
220
255
  disabled = false,
221
256
  labels: labelsProp,
222
257
  className,
@@ -292,6 +327,9 @@ export function LineItems<T>({
292
327
  // its own label, a row of headers would say each name twice.
293
328
  const showHeader = columns.some((c) => !floats(c));
294
329
  const pairs = narrowColumns === 2;
330
+ const removeAlign = removeAlignProp ?? (columns.some(floats) ? "center" : "start");
331
+ const inline = removePlacement === "inline" && Boolean(onRemove) && columns.length > 0;
332
+ const lastColumn = columns[columns.length - 1];
295
333
 
296
334
  const headerCell = (column: LineItemsColumn<T>) => (
297
335
  <span
@@ -305,7 +343,43 @@ export function LineItems<T>({
305
343
  </span>
306
344
  );
307
345
 
346
+ const removeCell = (index: number, row: number, isConfirming: boolean) => (
347
+ <div
348
+ data-line-items-remove-cell=""
349
+ className={cn(
350
+ "flex justify-end @lg:justify-center",
351
+ REMOVE_ALIGN[removeAlign],
352
+ // Paired, the remove button keeps a row of its own at the card's end rather
353
+ // than squeezing in beside the last field — unless asked to go `inline`.
354
+ inline ? "shrink-0" : pairs && "@2xs:col-span-2 @lg:col-span-1",
355
+ )}
356
+ >
357
+ <IconButton
358
+ type="button"
359
+ data-line-items-remove=""
360
+ variant={isConfirming ? "danger" : "ghost"}
361
+ tone={isConfirming ? undefined : "danger"}
362
+ label={isConfirming ? labels.confirmRemove(row) : labels.remove(row)}
363
+ disabled={!canRemove}
364
+ onClick={() => void removeAt(index)}
365
+ onBlur={() => {
366
+ if (isConfirming) setConfirming(null);
367
+ }}
368
+ onKeyDown={(e) => {
369
+ if (e.key === "Escape" && isConfirming) {
370
+ e.stopPropagation();
371
+ setConfirming(null);
372
+ }
373
+ }}
374
+ >
375
+ <Trash2 />
376
+ </IconButton>
377
+ </div>
378
+ );
379
+
308
380
  const showTotals = totals !== undefined;
381
+ const summaryTone = summary?.tone ?? "neutral";
382
+ const summaryHasValue = hasContent(summary?.value);
309
383
  const labelColumn = showTotals ? columns.find((c) => !hasContent(totals[c.key]))?.key : undefined;
310
384
 
311
385
  return (
@@ -355,13 +429,15 @@ export function LineItems<T>({
355
429
  >
356
430
  {columns.map((column) => {
357
431
  const floating = floats(column);
358
- return (
432
+ const isLast = inline && column === lastColumn;
433
+ const span = pairs && column.narrowSpan === 2 && "@2xs:col-span-2 @lg:col-span-1";
434
+ const cell = (
359
435
  <div
360
436
  key={column.key}
361
437
  className={cn(
362
438
  "min-w-0 space-y-1 @lg:space-y-0",
363
439
  // The wide grid places every cell in its own track again.
364
- pairs && column.narrowSpan === 2 && "@2xs:col-span-2 @lg:col-span-1",
440
+ isLast ? "flex-1" : span,
365
441
  )}
366
442
  >
367
443
  {!floating && (
@@ -377,33 +453,23 @@ export function LineItems<T>({
377
453
  })}
378
454
  </div>
379
455
  );
380
- })}
381
- {onRemove && (
382
- // Paired, the remove button keeps a row of its own at the card's end
383
- // rather than squeezing in beside the last field.
384
- <div className={cn("flex justify-end @lg:justify-center", pairs && "@2xs:col-span-2 @lg:col-span-1")}>
385
- <IconButton
386
- type="button"
387
- data-line-items-remove=""
388
- variant={isConfirming ? "danger" : "ghost"}
389
- tone={isConfirming ? undefined : "danger"}
390
- label={isConfirming ? labels.confirmRemove(row) : labels.remove(row)}
391
- disabled={!canRemove}
392
- onClick={() => void removeAt(index)}
393
- onBlur={() => {
394
- if (isConfirming) setConfirming(null);
395
- }}
396
- onKeyDown={(e) => {
397
- if (e.key === "Escape" && isConfirming) {
398
- e.stopPropagation();
399
- setConfirming(null);
400
- }
401
- }}
456
+ if (!isLast) return cell;
457
+ // Inline, the last field and the remove button share one cell of the
458
+ // card; on the wide grid the wrapper dissolves (`contents`) and each is a
459
+ // track of its own again. Level with the field's control: its foot under
460
+ // a header copy, its middle when it floats its own label.
461
+ return (
462
+ <div
463
+ key={column.key}
464
+ data-line-items-inline=""
465
+ className={cn("flex min-w-0 gap-2 @lg:contents", floating ? "items-center" : "items-end", span)}
402
466
  >
403
- <Trash2 />
404
- </IconButton>
405
- </div>
406
- )}
467
+ {cell}
468
+ {removeCell(index, row, isConfirming)}
469
+ </div>
470
+ );
471
+ })}
472
+ {onRemove && !inline && removeCell(index, row, isConfirming)}
407
473
  </div>
408
474
  );
409
475
  })}
@@ -454,19 +520,29 @@ export function LineItems<T>({
454
520
  data-line-items-summary=""
455
521
  className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--text-primary)]"
456
522
  >
457
- <span className="min-w-0">{summary.label}</span>
458
- {hasContent(summary.value) && (
459
- <span
460
- data-tone={summary.tone ?? "neutral"}
461
- className={cn("font-semibold tabular-nums", SUMMARY_TONE[summary.tone ?? "neutral"])}
462
- >
523
+ {/* With no value the line is a state of its own, and the label takes the
524
+ tone — the only place left to carry it. */}
525
+ <span
526
+ data-tone={summaryHasValue ? undefined : summaryTone}
527
+ className={cn("min-w-0", !summaryHasValue && SUMMARY_TONE[summaryTone])}
528
+ >
529
+ {summary.label}
530
+ </span>
531
+ {summaryHasValue && (
532
+ <span data-tone={summaryTone} className={cn("font-semibold tabular-nums", SUMMARY_TONE[summaryTone])}>
463
533
  {summary.value}
464
534
  </span>
465
535
  )}
536
+ {hasContent(summary.status) && (
537
+ <span data-line-items-summary-status="" className="inline-flex">
538
+ {summary.status}
539
+ </span>
540
+ )}
466
541
  {summary.action && (
467
542
  <Button
468
543
  type="button"
469
- variant="secondary"
544
+ variant={summary.action.variant === "link" ? "link" : "secondary"}
545
+ tone={summary.action.variant === "link" ? "muted" : undefined}
470
546
  size="sm"
471
547
  className="ms-auto"
472
548
  disabled={disabled || summary.action.disabled}
@@ -160,7 +160,12 @@ interface ListItemBaseProps {
160
160
  * never operated — no buttons, links or inputs (invalid HTML in a button, and folded
161
161
  * into its name). Its text joins the row's accessible name, so give a bar that only
162
162
  * repeats a figure already in the row `aria-hidden`. Rendered in a block `<span>`,
163
- * full width of the text column. Strictly a button holds phrasing content only; the
163
+ * full width of the TEXT COLUMN only — the column the title and subtitle are in, not
164
+ * the whole row: it starts after the {@link icon} / {@link leading} and stops where
165
+ * {@link trailing} begins, so a meter under a row with a trailing figure is as wide as
166
+ * the title, not run on under the figure (keksdose's budget rows). For a bar across
167
+ * the full row, put it in {@link children} (outside the target) or leave `trailing`
168
+ * off and show the figure in `meta`. Strictly a button holds phrasing content only; the
164
169
  * kit's `ProgressBar` draws `<div>`s, which every browser lays out and reads fine
165
170
  * there but an HTML validator flags.
166
171
  */
@@ -280,6 +285,13 @@ interface ListItemBaseProps {
280
285
  * the list's child and keeps both. The `<li>` becomes a column and the row takes the
281
286
  * full width, so an inline wrapper (the tooltip's `inline-flex` span) still stretches
282
287
  * the row across the list.
288
+ *
289
+ * `row` is the row BOX (a `<div>`), not the target: a wrapper that forces a prop onto
290
+ * its child with `cloneElement` — a save guard that injects `disabled` while a form is
291
+ * dirty (keksdose's SaveGuard) — sets it on that div, where it does nothing, and the
292
+ * button inside stays live. Pass `disabled` (or `loading`) to the ListItem yourself,
293
+ * from the same state the guard reads, and let the wrapper only explain it:
294
+ * `<ListItem disabled={dirty} renderRow={(row) => <Tooltip label="Save first">{row}</Tooltip>} />`.
283
295
  */
284
296
  renderRow?: (row: ReactElement) => ReactNode;
285
297
  /** Reaches the main target — the element with the row's name and action. */
@@ -154,26 +154,27 @@ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
154
154
  * digit grouping — "1.000" would read as a thousand in German and as one in English,
155
155
  * and a field that parses its own display back must not have that ambiguity.
156
156
  */
157
- export function NumberField({
158
- value,
159
- onCommit,
160
- onValueChange,
161
- digits,
162
- min,
163
- max,
164
- nullable = false,
165
- unit,
166
- unitPlacement = "suffix",
167
- locale: localeProp,
168
- label,
169
- invalid,
170
- error,
171
- id,
172
- "aria-describedby": ariaDescribedBy,
173
- "aria-invalid": ariaInvalid,
174
- "aria-required": ariaRequired,
175
- ...rest
176
- }: NumberFieldProps) {
157
+ export function NumberField(props: NumberFieldProps) {
158
+ const {
159
+ value,
160
+ onCommit,
161
+ onValueChange,
162
+ digits,
163
+ min,
164
+ max,
165
+ nullable = false,
166
+ unit,
167
+ unitPlacement = "suffix",
168
+ locale: localeProp,
169
+ label,
170
+ invalid,
171
+ error,
172
+ id,
173
+ "aria-describedby": ariaDescribedBy,
174
+ "aria-invalid": ariaInvalid,
175
+ "aria-required": ariaRequired,
176
+ ...rest
177
+ } = props;
177
178
  const locale = useKitLocale(localeProp);
178
179
  const mark = decimalMark(locale);
179
180
  const localize = (text: string) => (mark === "," ? text.replace(/\./g, ",") : text);
@@ -292,15 +293,20 @@ export function NumberField({
292
293
  invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === "true"}
293
294
  />
294
295
  );
295
- // Same shape as Input's FieldGroup: no wrapper at all without a message, so adding
296
- // `error` support changed nothing about the DOM a flex row lays out.
297
- if (!hasError) return field;
296
+ // Input's FieldGroup, restated: no wrapper for a field that never takes `error`, so
297
+ // the prop changed nothing about the DOM a flex row lays out — and a STEADY one for a
298
+ // field that does, message or not. Wrapping only while a message showed swapped the
299
+ // element above the <input>, which remounted it: focus, caret and the half-typed
300
+ // draft's keystrokes were lost the moment the message came or went.
301
+ if (!hasError && !("error" in props)) return field;
298
302
  return (
299
303
  <div>
300
304
  {field}
301
- <p id={errorId} className={FIELD_ERROR_CLASS}>
302
- {error}
303
- </p>
305
+ {hasError && (
306
+ <p id={errorId} className={FIELD_ERROR_CLASS}>
307
+ {error}
308
+ </p>
309
+ )}
304
310
  </div>
305
311
  );
306
312
  }
@@ -42,6 +42,32 @@ export interface PasskeysSettingProps<Id extends PasskeyId = PasskeyId> extends
42
42
  * the user cancelling the OS prompt) keeps what was typed.
43
43
  */
44
44
  onAdd: (name: string) => void | Promise<unknown>;
45
+ /**
46
+ * A step the app runs BEFORE `onAdd` (0.18.0) — re-asking for the current password,
47
+ * a policy notice, anything that may stop the add. Called with the same trimmed name;
48
+ * resolve `true` to go on to `onAdd`, `false` to stop quietly (nothing is said, the
49
+ * typed name stays). A rejection, or a throw, stops the same way: the app reports
50
+ * its own errors. While it runs the add button is disabled and busy, so a second
51
+ * click cannot start a second check.
52
+ *
53
+ * Kurvenschmiede asked for the password in its own dialog by handing `onAdd` a
54
+ * promise it kept open across that dialog (passkeys-card.tsx) — which still works.
55
+ * This is the documented place for it: open the dialog here, resolve with whether it
56
+ * was confirmed, and keep what it produced (the server's registration options) for
57
+ * `onAdd` in a ref. The ceremony then starts from the dialog's click rather than the
58
+ * card's, which is the user activation `navigator.credentials.create` wants.
59
+ *
60
+ * Left out, `onAdd` is called synchronously in the click, exactly as before.
61
+ */
62
+ beforeAdd?: (name: string) => boolean | Promise<boolean>;
63
+ /**
64
+ * What a passkey does to the password, which picks the line under the title (0.18.0).
65
+ * `"instead"` (default): it replaces it — `labels.description`, kastlan's promise.
66
+ * `"alongside"`: the password stays and the passkey is a second way in —
67
+ * `labels.descriptionAlongside`. Kurvenschmiede overrode `description` for exactly
68
+ * this; an app that sets `mode` needs no override.
69
+ */
70
+ mode?: "instead" | "alongside";
45
71
  /** The ceremony is running. The add button says so and does not start a second one. */
46
72
  adding?: boolean;
47
73
  /** Offer "Rename" on each row. Return a promise to leave edit mode only on success. */
@@ -92,6 +118,8 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
92
118
  passkeys,
93
119
  loading,
94
120
  onAdd,
121
+ beforeAdd,
122
+ mode = "instead",
95
123
  adding = false,
96
124
  onRename,
97
125
  onDelete,
@@ -108,6 +136,10 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
108
136
  const labels = useAccountSettingsLabels("passkeys", labelsProp);
109
137
  const locale = useKitLocale(localeProp);
110
138
  const [name, setName] = useState("");
139
+ // `beforeAdd` is running. State for the button, and a ref for the guard: two clicks
140
+ // in one frame both read the state from before either set it.
141
+ const [checking, setChecking] = useState(false);
142
+ const checkingRef = useRef(false);
111
143
 
112
144
  const date = (value: PasskeyItem["createdAt"]): string | null => {
113
145
  if (value === null || value === undefined || value === "") return null;
@@ -117,13 +149,40 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
117
149
  };
118
150
 
119
151
  const add = () => {
120
- if (adding) return;
152
+ if (adding || checkingRef.current) return;
121
153
  const typed = name;
122
- Promise.resolve(onAdd(typed.trim()))
123
- // Clear only what was sent: the field stays editable while the OS prompt is up.
124
- .then(() => setName((current) => (current === typed ? "" : current)))
125
- .catch(() => {});
154
+ const value = typed.trim();
155
+ const send = () =>
156
+ Promise.resolve(onAdd(value))
157
+ // Clear only what was sent: the field stays editable while the OS prompt is up.
158
+ .then(() => setName((current) => (current === typed ? "" : current)));
159
+ if (!beforeAdd) {
160
+ send().catch(() => {});
161
+ return;
162
+ }
163
+ const done = () => {
164
+ checkingRef.current = false;
165
+ setChecking(false);
166
+ };
167
+ checkingRef.current = true;
168
+ setChecking(true);
169
+ let gate: Promise<boolean>;
170
+ // Called in the click, not a microtask later, so a step that itself needs the
171
+ // user's activation (a popup, the clipboard) still has it; a throw is a "no".
172
+ try {
173
+ gate = Promise.resolve(beforeAdd(value));
174
+ } catch (error) {
175
+ gate = Promise.reject(error);
176
+ }
177
+ gate
178
+ .then((go) => {
179
+ done();
180
+ // Strictly `true`: an app whose step resolves `undefined` meant nothing.
181
+ if (go === true) return send();
182
+ })
183
+ .catch(done);
126
184
  };
185
+ const busy = adding || checking;
127
186
 
128
187
  const isLoading = loading || passkeys === undefined;
129
188
  const items = passkeys ?? [];
@@ -171,7 +230,7 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
171
230
  }}
172
231
  />
173
232
  )}
174
- <Button onClick={add} disabled={adding} aria-busy={adding || undefined}>
233
+ <Button onClick={add} disabled={busy} aria-busy={busy || undefined}>
175
234
  <KeyRound className="size-4" aria-hidden />
176
235
  {adding ? labels.adding : labels.add}
177
236
  </Button>
@@ -186,7 +245,9 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
186
245
  <div>
187
246
  <div className="text-sm font-medium">{labels.title}</div>
188
247
  <div className="text-xs text-[var(--text-muted)]">
189
- {labels.description}
248
+ {mode === "alongside"
249
+ ? labels.descriptionAlongside
250
+ : labels.description}
190
251
  </div>
191
252
  </div>
192
253
  {body}
@@ -2,6 +2,8 @@ import { useId } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
5
+ import { toneTextClass } from "./signed-amount";
6
+ import type { TextTone } from "./signed-amount";
5
7
 
6
8
  /** The words a progress bar adds of its own. */
7
9
  export interface ProgressBarLabels {
@@ -169,6 +171,18 @@ export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">,
169
171
  * `legendValue={(seg, share) => <>{share} <Amount … /></>}`.
170
172
  */
171
173
  legendValue?: (segment: ProgressBarSegment, formatted: string, index: number) => ReactNode;
174
+ /**
175
+ * The colour of each legend row's figure, as a {@link TextTone} — `toneTextClass`'s
176
+ * palette, the money pair included. Default `"muted"`: the legend explains the bar and
177
+ * should not outshout the headline above it. One tone for every row, or a function
178
+ * per row. A tone and not the segment's own `tone`: keksdose's tax card fills its
179
+ * three parts with one bucket's certainty ramp (`className`), while the FIGURES carry
180
+ * the bucket's money direction — `legendTone="expense"` for the tax owed — which it
181
+ * could only say before through `legendValue` and an app-side `Amount`. A function
182
+ * returning `undefined` keeps that row muted. Applies to `legendValue`'s node too
183
+ * (it inherits the colour unless it sets its own).
184
+ */
185
+ legendTone?: TextTone | ((segment: ProgressBarSegment, index: number) => TextTone | undefined);
172
186
  /** A muted line under the bar — the plan's reset date, "3 of 5 seats in use". It
173
187
  * describes the bar (`aria-describedby`). */
174
188
  hint?: ReactNode;
@@ -180,6 +194,16 @@ export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">,
180
194
  * `tone="danger"` if it should read as over. Ignored for `segments` and `max={null}`.
181
195
  */
182
196
  overage?: boolean | ((over: number) => ReactNode);
197
+ /**
198
+ * Tag every figure the bar PRINTS `data-private`, so the host's demo-mode rule blurs
199
+ * it — the attribute `StatTile sensitive` and `Tooltip redact` carry: the `showValue`
200
+ * text, each `legend` row's value (yours from `legendValue` too), the unlimited
201
+ * figure and the `overage` line. The track, the swatches and the part names stay
202
+ * readable — a blurred bar is no picture at all. Before this an app with a demo-mode
203
+ * blur had to pass `legendValue` only to wrap the figure in a tagged span (keksdose).
204
+ * The `aria-valuetext` is not touched: a screen reader is not a screen-share.
205
+ */
206
+ sensitive?: boolean;
183
207
  labels?: Partial<ProgressBarLabels>;
184
208
  }
185
209
 
@@ -214,8 +238,10 @@ export function ProgressBar({
214
238
  segments,
215
239
  legend = false,
216
240
  legendValue,
241
+ legendTone = "muted",
217
242
  hint,
218
243
  overage,
244
+ sensitive = false,
219
245
  labels,
220
246
  className,
221
247
  as = "div",
@@ -233,6 +259,7 @@ export function ProgressBar({
233
259
  const text = useKitLabels("progressBar", DEFAULT_PROGRESS_BAR_LABELS, labels);
234
260
  const kitLocale = useKitLocale(locale);
235
261
  const labelId = useId();
262
+ const priv = sensitive ? "" : undefined;
236
263
  const hintId = useId();
237
264
  const hintNode =
238
265
  hint != null ? (
@@ -258,7 +285,7 @@ export function ProgressBar({
258
285
  {label}
259
286
  </span>
260
287
  )}
261
- <span data-part="unlimited" className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
288
+ <span data-part="unlimited" data-private={priv} className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
262
289
  {shown !== undefined && `${shown} · `}
263
290
  {text.unlimited}
264
291
  </span>
@@ -320,7 +347,7 @@ export function ProgressBar({
320
347
  overage && !stacked && value !== undefined && Number.isFinite(value) && value > max ? value - max : 0;
321
348
  const overageNode =
322
349
  over > 0 ? (
323
- <p id={`${hintId}-over`} data-part="overage" className="mt-1 text-xs text-[var(--danger)]">
350
+ <p id={`${hintId}-over`} data-part="overage" data-private={priv} className="mt-1 text-xs text-[var(--danger)]">
324
351
  {typeof overage === "function"
325
352
  ? overage(over)
326
353
  : text.overLimit(new Intl.NumberFormat(kitLocale).format(over))}
@@ -344,7 +371,7 @@ export function ProgressBar({
344
371
  {showValue && valueText !== undefined && (
345
372
  // Hidden: the same words are the bar's aria-valuetext, and reading them
346
373
  // twice in a row is noise.
347
- <span aria-hidden className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
374
+ <span aria-hidden data-part="value" data-private={priv} className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
348
375
  {valueText}
349
376
  </span>
350
377
  )}
@@ -426,7 +453,16 @@ export function ProgressBar({
426
453
  {seg.label != null && (
427
454
  <span className="min-w-0 flex-1 truncate text-[var(--text-secondary)]">{seg.label}</span>
428
455
  )}
429
- <span className="ms-auto shrink-0 text-xs tabular-nums text-[var(--text-muted)]">
456
+ <span
457
+ data-part="legend-value"
458
+ data-private={priv}
459
+ className={cn(
460
+ "ms-auto shrink-0 text-xs tabular-nums",
461
+ toneTextClass(
462
+ (typeof legendTone === "function" ? legendTone(seg, i) : legendTone) ?? "muted",
463
+ ),
464
+ )}
465
+ >
430
466
  {legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)}
431
467
  </span>
432
468
  </li>