@eifi1/ui-kit 0.16.0 → 0.17.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 (89) hide show
  1. package/README.md +7 -7
  2. package/dist/chart.d.ts +1 -1
  3. package/dist/components/amount-input.d.ts +9 -1
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/button-group.d.ts +1 -1
  6. package/dist/components/calculator.d.ts +1 -1
  7. package/dist/components/copy-button.d.ts +1 -1
  8. package/dist/components/data-table-cells.d.ts +1 -1
  9. package/dist/components/data-table-filter-popover.d.ts +1 -1
  10. package/dist/components/data-table-filters.d.ts +1 -1
  11. package/dist/components/data-table.d.ts +1 -1
  12. package/dist/components/data-table.js +91 -3
  13. package/dist/components/data-table.js.map +1 -1
  14. package/dist/components/facing-pair.d.ts +1 -1
  15. package/dist/components/file-button.d.ts +1 -1
  16. package/dist/components/file-button.js +7 -4
  17. package/dist/components/file-button.js.map +1 -1
  18. package/dist/components/file-dropzone.d.ts +1 -1
  19. package/dist/components/form-actions.d.ts +1 -1
  20. package/dist/components/form-actions.js +32 -1
  21. package/dist/components/form-actions.js.map +1 -1
  22. package/dist/components/line-items.d.ts +30 -6
  23. package/dist/components/line-items.js +77 -33
  24. package/dist/components/line-items.js.map +1 -1
  25. package/dist/components/list.d.ts +13 -1
  26. package/dist/components/list.js.map +1 -1
  27. package/dist/components/money-field.d.ts +1 -1
  28. package/dist/components/number-field.d.ts +1 -1
  29. package/dist/components/number-input.d.ts +1 -1
  30. package/dist/components/numpad-sheet.d.ts +1 -1
  31. package/dist/components/progress-bar.d.ts +11 -1
  32. package/dist/components/progress-bar.js +14 -4
  33. package/dist/components/progress-bar.js.map +1 -1
  34. package/dist/components/series-chart.d.ts +1 -1
  35. package/dist/components/settings-fields.d.ts +1 -1
  36. package/dist/components/signed-amount.d.ts +12 -3
  37. package/dist/components/signed-amount.js +6 -4
  38. package/dist/components/signed-amount.js.map +1 -1
  39. package/dist/components/text-link.d.ts +1 -1
  40. package/dist/components/time-input.d.ts +1 -1
  41. package/dist/components/ui.d.ts +1 -1
  42. package/dist/components/ui.js +25 -3
  43. package/dist/components/ui.js.map +1 -1
  44. package/dist/components/use-table-state.d.ts +1 -1
  45. package/dist/{data-table-CfVXtCeG.d.ts → data-table-CpkWUJBY.d.ts} +41 -1
  46. package/dist/data-table.d.ts +1 -1
  47. package/dist/hooks/use-file-drop.d.ts +1 -1
  48. package/dist/i18n/defaults.d.ts +1 -1
  49. package/dist/i18n/kit-labels.d.ts +1 -1
  50. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  51. package/dist/i18n/locales/de-CH.d.ts +1 -1
  52. package/dist/i18n/locales/de-informal.d.ts +1 -1
  53. package/dist/i18n/locales/de.d.ts +1 -1
  54. package/dist/i18n/locales/en.d.ts +71 -0
  55. package/dist/i18n/locales/en.js +95 -0
  56. package/dist/i18n/locales/en.js.map +1 -0
  57. package/dist/i18n/locales/es.d.ts +1 -1
  58. package/dist/i18n/locales/fr.d.ts +1 -1
  59. package/dist/i18n/locales/hu.d.ts +1 -1
  60. package/dist/i18n/locales/it.d.ts +1 -1
  61. package/dist/i18n/locales/zh.d.ts +1 -1
  62. package/dist/index.d.ts +4 -4
  63. package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-C4uB_zmg.d.ts} +57 -5
  64. package/dist/rhf/fields.d.ts +1 -1
  65. package/dist/rhf/form.d.ts +1 -1
  66. package/dist/rhf/line-items.js.map +1 -1
  67. package/dist/rhf.d.ts +1 -1
  68. package/dist/search/global-search.d.ts +22 -4
  69. package/dist/search/global-search.js +31 -3
  70. package/dist/search/global-search.js.map +1 -1
  71. package/dist/search.d.ts +1 -1
  72. package/dist/shell/app-shell.d.ts +1 -1
  73. package/dist/shell/top-bar-brand.d.ts +1 -1
  74. package/dist/shell.d.ts +1 -1
  75. package/dist/wizard/stepper-nav.d.ts +1 -1
  76. package/dist/wizard.d.ts +1 -1
  77. package/package.json +1 -1
  78. package/src/components/amount-input.tsx +8 -0
  79. package/src/components/data-table.tsx +165 -3
  80. package/src/components/file-button.tsx +12 -4
  81. package/src/components/form-actions.tsx +73 -3
  82. package/src/components/line-items.tsx +116 -40
  83. package/src/components/list.tsx +13 -1
  84. package/src/components/progress-bar.tsx +20 -4
  85. package/src/components/signed-amount.tsx +16 -5
  86. package/src/components/ui.tsx +57 -3
  87. package/src/i18n/locales/en.ts +116 -0
  88. package/src/rhf/line-items.tsx +2 -1
  89. package/src/search/global-search.tsx +57 -6
@@ -4,6 +4,7 @@ import { cn } from "../lib/cn";
4
4
  import { useAnnounce } from "../hooks/use-announce";
5
5
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
6
6
  import { Button, Spinner } from "./ui";
7
+ import { Tooltip } from "./tooltip";
7
8
 
8
9
  /**
9
10
  * A button that opens the file picker — the shape all three apps kept writing by hand
@@ -578,17 +579,24 @@ export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(functio
578
579
  }
579
580
  return (
580
581
  // One inline row, so the name sits beside the button wherever the button sits; the
581
- // name truncates (full list in its title) rather than pushing the row wider.
582
+ // name truncates rather than pushing the row wider, and the full list is in the
583
+ // kit's own Tooltip — not a native `title` (0.16.0 shipped one: the one-tooltip rule
584
+ // since keksdose dev#523, and a title never opens on touch). Lazy, so the closed
585
+ // bubble adds nothing to the read-out's text.
582
586
  <span className="inline-flex max-w-full min-w-0 items-center gap-2">
583
587
  {button}
588
+ {/* The live region is the OUTER span and never remounts: a Tooltip with an empty
589
+ label renders no wrapper, so wrapping the region itself would swap its element
590
+ the moment the first file arrives — and a fresh live region may not announce. */}
584
591
  <span
585
592
  id={pickedId}
586
593
  aria-live="polite"
587
594
  data-slot="file-button-name"
588
- title={picked.map((f) => f.name).join(", ") || undefined}
589
- className="min-w-0 truncate text-sm text-[var(--text-secondary)]"
595
+ className="flex min-w-0 text-sm text-[var(--text-secondary)]"
590
596
  >
591
- {pickedText}
597
+ <Tooltip label={picked.map((f) => f.name).join(", ")} lazy className="min-w-0">
598
+ <span className="block truncate">{pickedText}</span>
599
+ </Tooltip>
592
600
  </span>
593
601
  {picker.element}
594
602
  </span>
@@ -3,6 +3,7 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import type { LucideIcon } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { useKitLabels } from "../i18n/kit-labels";
6
+ import { useMediaQuery } from "../hooks/use-media-query";
6
7
  import { Button, Spinner, type ButtonProps, type ButtonVariant } from "./ui";
7
8
 
8
9
  /** The words {@link FormActions} renders on its own behalf — the `form` namespace. */
@@ -29,6 +30,56 @@ export type FormActionsAlign = "start" | "center" | "end" | "between";
29
30
  */
30
31
  export type FormActionsPlacement = "inline" | "sticky" | "dialog";
31
32
 
33
+ /**
34
+ * A placement per breakpoint, mobile first — `{ base: "sticky", md: "inline" }` is a
35
+ * Save row stuck to the bottom of a phone's long form and in the flow under the last
36
+ * field from 768px up, where the form fits. The breakpoints are Tailwind's (sm 640,
37
+ * md 768, lg 1024, xl 1280px), as `BulkActionBar`'s `variant` breakpoints are, and
38
+ * resolved the same way, in JS: the sticky row is positioned by inline style, which a
39
+ * `md:` class cannot reach. keksdose switched `placement` on its own media query at
40
+ * every long form for want of this.
41
+ */
42
+ export interface ResponsiveFormActionsPlacement {
43
+ base: FormActionsPlacement;
44
+ sm?: FormActionsPlacement;
45
+ md?: FormActionsPlacement;
46
+ lg?: FormActionsPlacement;
47
+ xl?: FormActionsPlacement;
48
+ }
49
+
50
+ const BREAKPOINTS = [
51
+ ["xl", "(min-width: 1280px)"],
52
+ ["lg", "(min-width: 1024px)"],
53
+ ["md", "(min-width: 768px)"],
54
+ ["sm", "(min-width: 640px)"],
55
+ ] as const;
56
+
57
+ /** The placement in force: the widest breakpoint that matches AND names one, else
58
+ * `base`. The queries are subscribed unconditionally (hooks cannot be skipped); a
59
+ * plain string ignores them. Without `matchMedia` (SSR, tests) none match, so the row
60
+ * renders its `base`, the phone's. The same resolution as BulkActionBar's variant. */
61
+ function useResolvedPlacement(
62
+ placement: FormActionsPlacement | ResponsiveFormActionsPlacement,
63
+ ): FormActionsPlacement {
64
+ const matches = {
65
+ xl: useMediaQuery(BREAKPOINTS[0][1], false),
66
+ lg: useMediaQuery(BREAKPOINTS[1][1], false),
67
+ md: useMediaQuery(BREAKPOINTS[2][1], false),
68
+ sm: useMediaQuery(BREAKPOINTS[3][1], false),
69
+ };
70
+ if (typeof placement === "string") return placement;
71
+ for (const [key] of BREAKPOINTS) {
72
+ const p = placement[key];
73
+ if (matches[key] && p !== undefined) return p;
74
+ }
75
+ return placement.base;
76
+ }
77
+
78
+ /** A CSS length: a number is pixels. */
79
+ function cssLength(value: string | number): string {
80
+ return typeof value === "number" ? `${value}px` : value;
81
+ }
82
+
32
83
  /**
33
84
  * What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.
34
85
  * - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto
@@ -125,8 +176,20 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
125
176
  start?: ReactNode;
126
177
  /** Horizontal alignment. Default `end` (`between` with a `destructive` action). */
127
178
  align?: FormActionsAlign;
128
- /** See {@link FormActionsPlacement}. Default `inline`. */
129
- placement?: FormActionsPlacement;
179
+ /** See {@link FormActionsPlacement}, or one per breakpoint — see
180
+ * {@link ResponsiveFormActionsPlacement}. Default `inline`. */
181
+ placement?: FormActionsPlacement | ResponsiveFormActionsPlacement;
182
+ /**
183
+ * While the row is `sticky`: pull it out over its container's inline padding by this
184
+ * much (a CSS length, a number in px) and give the same back as padding — so the
185
+ * rule above it and its surface run edge to edge, while the buttons stay on the
186
+ * content's line. Without it a sticky row in a padded card or pane is a strip inset
187
+ * from both sides, with the content scrolling past it in the gutters (keksdose wrote
188
+ * `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`"0.75rem"`,
189
+ * `12`, `"var(--pane-px)"`). Applied only while the resolved placement is sticky, so
190
+ * `placement={{ base: "sticky", md: "inline" }}` bleeds on the phone alone.
191
+ */
192
+ bleed?: string | number;
130
193
  /**
131
194
  * With `placement="sticky"`: what the row sticks to. Default `viewport`.
132
195
  *
@@ -190,8 +253,9 @@ export function FormActions({
190
253
  destructive,
191
254
  start: startSlot,
192
255
  align,
193
- placement = "inline",
256
+ placement: placementProp = "inline",
194
257
  stickyWithin = "viewport",
258
+ bleed,
195
259
  form,
196
260
  children,
197
261
  className,
@@ -199,6 +263,8 @@ export function FormActions({
199
263
  ...rest
200
264
  }: FormActionsProps) {
201
265
  const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
266
+ const placement = useResolvedPlacement(placementProp);
267
+ const bleedLength = bleed === undefined || bleed === "" ? undefined : cssLength(bleed);
202
268
  const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
203
269
  const hasStart = startSlot !== undefined && startSlot !== null && startSlot !== false;
204
270
  const justify = align ?? (hasDestructive || hasStart ? "between" : "end");
@@ -235,6 +301,10 @@ export function FormActions({
235
301
  // container the nav is not underneath: `--app-nav-h` is the document's,
236
302
  // and would hold the row that far above the container's own edge.
237
303
  bottom: stickyWithin === "container" ? 0 : "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
304
+ ...(bleedLength !== undefined && {
305
+ marginInline: `calc(-1 * ${bleedLength})`,
306
+ paddingInline: bleedLength,
307
+ }),
238
308
  ...style,
239
309
  }
240
310
  : style
@@ -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. */
@@ -180,6 +180,16 @@ export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">,
180
180
  * `tone="danger"` if it should read as over. Ignored for `segments` and `max={null}`.
181
181
  */
182
182
  overage?: boolean | ((over: number) => ReactNode);
183
+ /**
184
+ * Tag every figure the bar PRINTS `data-private`, so the host's demo-mode rule blurs
185
+ * it — the attribute `StatTile sensitive` and `Tooltip redact` carry: the `showValue`
186
+ * text, each `legend` row's value (yours from `legendValue` too), the unlimited
187
+ * figure and the `overage` line. The track, the swatches and the part names stay
188
+ * readable — a blurred bar is no picture at all. Before this an app with a demo-mode
189
+ * blur had to pass `legendValue` only to wrap the figure in a tagged span (keksdose).
190
+ * The `aria-valuetext` is not touched: a screen reader is not a screen-share.
191
+ */
192
+ sensitive?: boolean;
183
193
  labels?: Partial<ProgressBarLabels>;
184
194
  }
185
195
 
@@ -216,6 +226,7 @@ export function ProgressBar({
216
226
  legendValue,
217
227
  hint,
218
228
  overage,
229
+ sensitive = false,
219
230
  labels,
220
231
  className,
221
232
  as = "div",
@@ -233,6 +244,7 @@ export function ProgressBar({
233
244
  const text = useKitLabels("progressBar", DEFAULT_PROGRESS_BAR_LABELS, labels);
234
245
  const kitLocale = useKitLocale(locale);
235
246
  const labelId = useId();
247
+ const priv = sensitive ? "" : undefined;
236
248
  const hintId = useId();
237
249
  const hintNode =
238
250
  hint != null ? (
@@ -258,7 +270,7 @@ export function ProgressBar({
258
270
  {label}
259
271
  </span>
260
272
  )}
261
- <span data-part="unlimited" className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
273
+ <span data-part="unlimited" data-private={priv} className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
262
274
  {shown !== undefined && `${shown} · `}
263
275
  {text.unlimited}
264
276
  </span>
@@ -320,7 +332,7 @@ export function ProgressBar({
320
332
  overage && !stacked && value !== undefined && Number.isFinite(value) && value > max ? value - max : 0;
321
333
  const overageNode =
322
334
  over > 0 ? (
323
- <p id={`${hintId}-over`} data-part="overage" className="mt-1 text-xs text-[var(--danger)]">
335
+ <p id={`${hintId}-over`} data-part="overage" data-private={priv} className="mt-1 text-xs text-[var(--danger)]">
324
336
  {typeof overage === "function"
325
337
  ? overage(over)
326
338
  : text.overLimit(new Intl.NumberFormat(kitLocale).format(over))}
@@ -344,7 +356,7 @@ export function ProgressBar({
344
356
  {showValue && valueText !== undefined && (
345
357
  // Hidden: the same words are the bar's aria-valuetext, and reading them
346
358
  // twice in a row is noise.
347
- <span aria-hidden className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
359
+ <span aria-hidden data-part="value" data-private={priv} className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
348
360
  {valueText}
349
361
  </span>
350
362
  )}
@@ -426,7 +438,11 @@ export function ProgressBar({
426
438
  {seg.label != null && (
427
439
  <span className="min-w-0 flex-1 truncate text-[var(--text-secondary)]">{seg.label}</span>
428
440
  )}
429
- <span className="ms-auto shrink-0 text-xs tabular-nums text-[var(--text-muted)]">
441
+ <span
442
+ data-part="legend-value"
443
+ data-private={priv}
444
+ className="ms-auto shrink-0 text-xs tabular-nums text-[var(--text-muted)]"
445
+ >
430
446
  {legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)}
431
447
  </span>
432
448
  </li>
@@ -106,8 +106,17 @@ export const DEFAULT_SIGNED_AMOUNT_LABELS: SignedAmountLabels = {
106
106
  interface SignedFormatProps {
107
107
  /** ISO 4217 code: format as money. */
108
108
  currency?: string;
109
- /** `"percent"` reads `value` as a RATIO (`0.12` → 12 %), as `StatTile`'s delta does. */
109
+ /** `"percent"` reads `value` as a RATIO (`0.12` → 12 %), as `StatTile`'s delta does
110
+ * — or as percent POINTS with `ratio={false}`. */
110
111
  unit?: "value" | "percent";
112
+ /** With `unit="percent"`: `true` (default) — `value` is a ratio, `0.12` is 12 %.
113
+ * `false` — `value` is already in percent POINTS, `12` is 12 %, as `formatPercent`'s
114
+ * `ratio` option reads it. Backends send points (keksdose's price changes, kastlan's
115
+ * `change_pct`), and without this every site divided by 100 — and wrote its
116
+ * `flatWithin` as a ratio (`0.005`) beside a value it had to convert. With
117
+ * `ratio={false}` the value AND `flatWithin` are in points (`flatWithin={0.5}` is
118
+ * half a point). Ignored for any other unit. */
119
+ ratio?: boolean;
111
120
  /** Compact notation: 12 400 → "12K". */
112
121
  compact?: boolean;
113
122
  /** Fraction digits, as `formatNumber`'s `digits`. */
@@ -118,11 +127,11 @@ interface SignedFormatProps {
118
127
  locale?: string;
119
128
  }
120
129
 
121
- function useMagnitudeFormatter({ currency, unit, compact, digits, format, locale }: SignedFormatProps) {
130
+ function useMagnitudeFormatter({ currency, unit, ratio, compact, digits, format, locale }: SignedFormatProps) {
122
131
  const kitLocale = useKitLocale(locale);
123
132
  return (magnitude: number): string => {
124
133
  if (format) return format(magnitude);
125
- if (unit === "percent") return formatPercent(magnitude, { locale: kitLocale, compact, digits });
134
+ if (unit === "percent") return formatPercent(magnitude, { locale: kitLocale, compact, digits, ratio });
126
135
  if (currency) return formatMoney(magnitude, currency, { locale: kitLocale, compact, digits });
127
136
  return formatNumber(magnitude, { locale: kitLocale, compact, digits });
128
137
  };
@@ -200,6 +209,7 @@ export function SignedAmount({
200
209
  value,
201
210
  currency,
202
211
  unit,
212
+ ratio,
203
213
  compact,
204
214
  digits,
205
215
  format,
@@ -216,7 +226,7 @@ export function SignedAmount({
216
226
  ...rest
217
227
  }: SignedAmountProps) {
218
228
  const text = useKitLabels("signedAmount", DEFAULT_SIGNED_AMOUNT_LABELS, labels);
219
- const fmt = useMagnitudeFormatter({ currency, unit, compact, digits, format, locale });
229
+ const fmt = useMagnitudeFormatter({ currency, unit, ratio, compact, digits, format, locale });
220
230
  const finite = Number.isFinite(value);
221
231
  const dir = finite ? directionOf(value, flatWithin) : "flat";
222
232
  const amount = finite ? fmt(Math.abs(value)) : "—";
@@ -281,6 +291,7 @@ export function Delta({
281
291
  value,
282
292
  currency,
283
293
  unit,
294
+ ratio,
284
295
  compact,
285
296
  digits,
286
297
  format,
@@ -296,7 +307,7 @@ export function Delta({
296
307
  ...rest
297
308
  }: DeltaProps) {
298
309
  const text = useKitLabels("statTile", DEFAULT_STAT_TILE_LABELS, labels);
299
- const fmt = useMagnitudeFormatter({ currency, unit, compact, digits, format, locale });
310
+ const fmt = useMagnitudeFormatter({ currency, unit, ratio, compact, digits, format, locale });
300
311
  if (!Number.isFinite(value)) return null;
301
312
  const amount = fmt(Math.abs(value));
302
313
  const dir = directionOf(value, flatWithin);