@eifi1/ui-kit 0.15.5 → 0.16.1

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 (140) hide show
  1. package/README.md +17 -12
  2. package/dist/chart.d.ts +3 -3
  3. package/dist/components/amount-input.d.ts +10 -3
  4. package/dist/components/amount-input.js +165 -156
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/authed-image.d.ts +14 -3
  7. package/dist/components/authed-image.js +32 -23
  8. package/dist/components/authed-image.js.map +1 -1
  9. package/dist/components/button-group.d.ts +3 -3
  10. package/dist/components/calculator.d.ts +3 -3
  11. package/dist/components/copy-button.d.ts +3 -3
  12. package/dist/components/facing-pair.d.ts +3 -3
  13. package/dist/components/file-button.d.ts +3 -3
  14. package/dist/components/file-button.js +67 -31
  15. package/dist/components/file-button.js.map +1 -1
  16. package/dist/components/file-dropzone.d.ts +3 -3
  17. package/dist/components/form-actions.d.ts +4 -4
  18. package/dist/components/form-actions.js +18 -6
  19. package/dist/components/form-actions.js.map +1 -1
  20. package/dist/components/line-items.d.ts +53 -2
  21. package/dist/components/line-items.js +73 -7
  22. package/dist/components/line-items.js.map +1 -1
  23. package/dist/components/list.d.ts +99 -5
  24. package/dist/components/list.js +103 -38
  25. package/dist/components/list.js.map +1 -1
  26. package/dist/components/loading-state.d.ts +19 -3
  27. package/dist/components/loading-state.js +14 -3
  28. package/dist/components/loading-state.js.map +1 -1
  29. package/dist/components/money-field.d.ts +3 -3
  30. package/dist/components/number-field.d.ts +3 -3
  31. package/dist/components/number-input.d.ts +3 -3
  32. package/dist/components/number-input.js +4 -2
  33. package/dist/components/number-input.js.map +1 -1
  34. package/dist/components/numpad-sheet.d.ts +3 -3
  35. package/dist/components/passkeys-setting.d.ts +30 -10
  36. package/dist/components/passkeys-setting.js +19 -11
  37. package/dist/components/passkeys-setting.js.map +1 -1
  38. package/dist/components/progress-bar.d.ts +12 -3
  39. package/dist/components/progress-bar.js +21 -11
  40. package/dist/components/progress-bar.js.map +1 -1
  41. package/dist/components/series-chart.d.ts +3 -3
  42. package/dist/components/settings-fields.d.ts +3 -3
  43. package/dist/components/signed-amount.d.ts +28 -4
  44. package/dist/components/signed-amount.js +17 -8
  45. package/dist/components/signed-amount.js.map +1 -1
  46. package/dist/components/stat-tile.d.ts +31 -2
  47. package/dist/components/stat-tile.js +37 -6
  48. package/dist/components/stat-tile.js.map +1 -1
  49. package/dist/components/status-dot.d.ts +4 -1
  50. package/dist/components/status-dot.js +20 -1
  51. package/dist/components/status-dot.js.map +1 -1
  52. package/dist/components/swatch-picker.d.ts +3 -1
  53. package/dist/components/swatch-picker.js.map +1 -1
  54. package/dist/components/table.d.ts +18 -13
  55. package/dist/components/table.js +20 -5
  56. package/dist/components/table.js.map +1 -1
  57. package/dist/components/text-link.d.ts +3 -3
  58. package/dist/components/time-input.d.ts +3 -3
  59. package/dist/components/toggle-legend.d.ts +12 -1
  60. package/dist/components/toggle-legend.js +6 -2
  61. package/dist/components/toggle-legend.js.map +1 -1
  62. package/dist/components/ui.d.ts +3 -3
  63. package/dist/components/ui.js +149 -45
  64. package/dist/components/ui.js.map +1 -1
  65. package/dist/feedback/feedback-attachment.d.ts +1 -1
  66. package/dist/feedback/feedback-attachment.js +24 -11
  67. package/dist/feedback/feedback-attachment.js.map +1 -1
  68. package/dist/feedback/feedback-dialog.d.ts +1 -1
  69. package/dist/feedback/feedback-dialog.js +6 -5
  70. package/dist/feedback/feedback-dialog.js.map +1 -1
  71. package/dist/feedback/feedback-inbox.d.ts +1 -1
  72. package/dist/feedback/feedback-thread.d.ts +1 -1
  73. package/dist/{feedback-attachment-BYAHJMOr.d.ts → feedback-attachment-WFiXJ8We.d.ts} +21 -8
  74. package/dist/feedback.d.ts +1 -1
  75. package/dist/hooks/use-file-drop.d.ts +3 -3
  76. package/dist/i18n/defaults.d.ts +3 -3
  77. package/dist/i18n/kit-labels.d.ts +2 -2
  78. package/dist/i18n/locales/de-CH-informal.d.ts +3 -3
  79. package/dist/i18n/locales/de-CH.d.ts +3 -3
  80. package/dist/i18n/locales/de-informal.d.ts +3 -3
  81. package/dist/i18n/locales/de.d.ts +3 -3
  82. package/dist/i18n/locales/de.js +1 -1
  83. package/dist/i18n/locales/de.js.map +1 -1
  84. package/dist/i18n/locales/es.d.ts +3 -3
  85. package/dist/i18n/locales/es.js +1 -1
  86. package/dist/i18n/locales/es.js.map +1 -1
  87. package/dist/i18n/locales/fr.d.ts +3 -3
  88. package/dist/i18n/locales/fr.js +1 -1
  89. package/dist/i18n/locales/fr.js.map +1 -1
  90. package/dist/i18n/locales/hu.d.ts +3 -3
  91. package/dist/i18n/locales/hu.js +1 -1
  92. package/dist/i18n/locales/hu.js.map +1 -1
  93. package/dist/i18n/locales/it.d.ts +3 -3
  94. package/dist/i18n/locales/it.js +1 -1
  95. package/dist/i18n/locales/it.js.map +1 -1
  96. package/dist/i18n/locales/zh.d.ts +3 -3
  97. package/dist/i18n/locales/zh.js +1 -1
  98. package/dist/i18n/locales/zh.js.map +1 -1
  99. package/dist/index.d.ts +10 -10
  100. package/dist/{kit-labels-BD2F7sOz.d.ts → kit-labels-DE--LmCk.d.ts} +153 -12
  101. package/dist/lib/format.d.ts +18 -3
  102. package/dist/lib/format.js +2 -1
  103. package/dist/lib/format.js.map +1 -1
  104. package/dist/rhf/fields.d.ts +3 -3
  105. package/dist/rhf/form.d.ts +3 -3
  106. package/dist/rhf/line-items.js.map +1 -1
  107. package/dist/rhf.d.ts +3 -3
  108. package/dist/shell/app-shell.d.ts +3 -3
  109. package/dist/shell/top-bar-brand.d.ts +3 -3
  110. package/dist/shell.d.ts +3 -3
  111. package/dist/wizard/stepper-nav.d.ts +3 -3
  112. package/dist/wizard.d.ts +3 -3
  113. package/package.json +1 -1
  114. package/src/components/amount-input.tsx +39 -15
  115. package/src/components/authed-image.tsx +20 -2
  116. package/src/components/file-button.tsx +85 -27
  117. package/src/components/form-actions.tsx +86 -8
  118. package/src/components/line-items.tsx +131 -14
  119. package/src/components/list.tsx +240 -50
  120. package/src/components/loading-state.tsx +35 -4
  121. package/src/components/number-input.tsx +18 -2
  122. package/src/components/passkeys-setting.tsx +45 -18
  123. package/src/components/progress-bar.tsx +46 -21
  124. package/src/components/signed-amount.tsx +47 -8
  125. package/src/components/stat-tile.tsx +76 -6
  126. package/src/components/status-dot.tsx +31 -0
  127. package/src/components/swatch-picker.tsx +3 -1
  128. package/src/components/table.tsx +42 -15
  129. package/src/components/toggle-legend.tsx +21 -4
  130. package/src/components/ui.tsx +273 -63
  131. package/src/feedback/feedback-attachment.tsx +45 -14
  132. package/src/feedback/feedback-dialog.tsx +31 -16
  133. package/src/i18n/locales/de.ts +1 -1
  134. package/src/i18n/locales/es.ts +1 -1
  135. package/src/i18n/locales/fr.ts +1 -1
  136. package/src/i18n/locales/hu.ts +1 -1
  137. package/src/i18n/locales/it.ts +1 -1
  138. package/src/i18n/locales/zh.ts +1 -1
  139. package/src/lib/format.ts +24 -3
  140. package/src/rhf/line-items.tsx +6 -0
@@ -5,12 +5,29 @@ import { Spinner } from "./ui";
5
5
 
6
6
  export type LoadingStateSize = "sm" | "md" | "lg";
7
7
 
8
+ /** Whether {@link LoadingStateProps.label} shows, or is only read out. */
9
+ export type LoadingStateLabelVisibility = "visible" | "sr-only";
10
+
8
11
  export interface LoadingStateProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
9
12
  /** The visible words under the spinner, and what the status region announces.
10
- * Default: `common.loading` ("Loading…"). */
13
+ * Default: `common.loading` ("Loading…"). `null` (or `""`) hides the words — the
14
+ * same as `labelVisibility="sr-only"` with the default text, so the region still
15
+ * announces "Loading…" rather than nothing. */
11
16
  label?: ReactNode;
17
+ /**
18
+ * `sr-only`: the spinner alone shows, the label is still the status region's words
19
+ * for a screen reader — a busy row or a bubble where "Loading…" in print is noise.
20
+ * Default `visible`.
21
+ */
22
+ labelVisibility?: LoadingStateLabelVisibility;
12
23
  /** `sm` for a panel or a table body, `md` (default) for a section, `lg` for a page. */
13
24
  size?: LoadingStateSize;
25
+ /**
26
+ * Less room above and below, the size's spinner and text unchanged — `md` at `py-8`
27
+ * instead of `py-12` (`sm` `py-2`, `lg` `py-12`): the step between a panel and a
28
+ * section, for a card body that `md` makes look empty rather than loading.
29
+ */
30
+ compact?: boolean;
14
31
  /** Spinner beside the text on one line, rather than above it. */
15
32
  inline?: boolean;
16
33
  }
@@ -18,6 +35,7 @@ export interface LoadingStateProps extends Omit<ComponentPropsWithoutRef<"div">,
18
35
  const SPINNER: Record<LoadingStateSize, string> = { sm: "h-4 w-4", md: "h-8 w-8", lg: "h-10 w-10" };
19
36
  const TEXT: Record<LoadingStateSize, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
20
37
  const PAD: Record<LoadingStateSize, string> = { sm: "py-4", md: "py-12", lg: "py-20" };
38
+ const PAD_COMPACT: Record<LoadingStateSize, string> = { sm: "py-2", md: "py-8", lg: "py-12" };
21
39
 
22
40
  /**
23
41
  * A spinner and the words "Loading…", centred where the content will be.
@@ -27,8 +45,21 @@ const PAD: Record<LoadingStateSize, string> = { sm: "py-4", md: "py-12", lg: "py
27
45
  * visible text inside it, so the spinner is decorative (`label={null}`) — otherwise
28
46
  * the reader hears "Loading… Loading…", one from each.
29
47
  */
30
- export function LoadingState({ label, size = "md", inline = false, className, ...rest }: LoadingStateProps) {
48
+ export function LoadingState({
49
+ label,
50
+ labelVisibility = "visible",
51
+ size = "md",
52
+ compact = false,
53
+ inline = false,
54
+ className,
55
+ ...rest
56
+ }: LoadingStateProps) {
31
57
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
58
+ // `null` / `""` used to fall through `??` to a printed "Loading…" (null) or leave a
59
+ // silent region (""): both now mean "no words on screen", and the region keeps the
60
+ // default words to announce — a status region with nothing in it says nothing.
61
+ const hidden = label === null || label === "" || labelVisibility === "sr-only";
62
+ const words = label === null || label === undefined || label === "" ? common.loading : label;
32
63
  return (
33
64
  <div
34
65
  {...rest}
@@ -36,12 +67,12 @@ export function LoadingState({ label, size = "md", inline = false, className, ..
36
67
  className={cn(
37
68
  "flex items-center justify-center text-center text-[var(--text-muted)]",
38
69
  inline ? "gap-2" : "flex-col gap-2",
39
- PAD[size],
70
+ (compact ? PAD_COMPACT : PAD)[size],
40
71
  className,
41
72
  )}
42
73
  >
43
74
  <Spinner label={null} className={SPINNER[size]} />
44
- <span className={TEXT[size]}>{label ?? common.loading}</span>
75
+ <span className={hidden ? "sr-only" : TEXT[size]}>{words}</span>
45
76
  </div>
46
77
  );
47
78
  }
@@ -195,6 +195,14 @@ export function NumberInput({
195
195
  const endRef = useRef<HTMLDivElement>(null);
196
196
  const [endWidth, setEndWidth] = useState<number | null>(null);
197
197
  const hasEnd = showCalc || suffix !== undefined;
198
+ // Before the first measurement (and wherever there is no layout), a TEXT unit
199
+ // reserves an estimate from its length — one `ch` a character, in the input's own
200
+ // (larger) font, so it errs wide — plus the span's `pe-2` and the 4px gap, and the
201
+ // calculator's 36px. The class fallback's flat pe-8 gave a "%" 32px for a frame and
202
+ // "km/h" too little.
203
+ const suffixChars = typeof suffix === "string" || typeof suffix === "number" ? String(suffix).length : null;
204
+ const estimatedEnd =
205
+ suffixChars !== null ? `calc(${suffixChars}ch + 0.75rem${showCalc ? " + 2.25rem" : ""})` : undefined;
198
206
  useLayoutEffect(() => {
199
207
  const el = endRef.current;
200
208
  if (!hasEnd || !el || typeof ResizeObserver === "undefined") return;
@@ -291,7 +299,13 @@ export function NumberInput({
291
299
  // field reserves exactly their width (+4px). A fixed pe-8 for any unit left a
292
300
  // "%" in a ~78px table cell 32px of padding and the digits ~32px of room
293
301
  // (keksdose live #367); a long unit ("km/h") got too little.
294
- style={hasEnd && endWidth != null ? { paddingInlineEnd: endWidth + 4 } : undefined}
302
+ style={
303
+ hasEnd && endWidth != null
304
+ ? { paddingInlineEnd: endWidth + 4 }
305
+ : estimatedEnd !== undefined
306
+ ? { paddingInlineEnd: estimatedEnd }
307
+ : undefined
308
+ }
295
309
  />
296
310
  {(showCalc || suffix !== undefined) && (
297
311
  // One flex track for both, so the unit and the calculator sit side by side
@@ -310,7 +324,9 @@ export function NumberInput({
310
324
  {suffix !== undefined && (
311
325
  <span
312
326
  aria-hidden
313
- className="pointer-events-none pe-3 text-xs font-medium text-[var(--text-muted)]"
327
+ // pe-2, not the input's px-3: the unit is chrome at the box's edge, and
328
+ // with pe-3 plus the 4px gap it took ~26px of a narrow table cell.
329
+ className="pointer-events-none pe-2 text-xs font-medium text-[var(--text-muted)]"
314
330
  >
315
331
  {suffix}
316
332
  </span>
@@ -8,9 +8,18 @@ import { Skeleton } from "./skeleton";
8
8
  import { useAccountSettingsLabels } from "./account-settings-labels";
9
9
  import type { PasskeysSettingLabels } from "./account-settings-labels";
10
10
 
11
- /** One registered passkey, as the app's API lists it. */
12
- export interface PasskeyItem {
13
- id: string | number;
11
+ /** What a passkey's `id` may be: the app's own key type, string or numeric. */
12
+ export type PasskeyId = string | number;
13
+
14
+ /**
15
+ * One registered passkey, as the app's API lists it.
16
+ *
17
+ * Generic in its `id` (0.16.0): `PasskeysSetting` infers `Id` from `passkeys`, so an
18
+ * app whose ids are numbers gets numbers back in `onRename` / `onDelete` — keksdose
19
+ * cast `id as number` in both. Left unparameterised it is the old `string | number`.
20
+ */
21
+ export interface PasskeyItem<Id extends PasskeyId = PasskeyId> {
22
+ id: Id;
14
23
  name: string;
15
24
  /** A `Date`, an ISO string or epoch ms. */
16
25
  createdAt?: Date | string | number | null;
@@ -18,9 +27,12 @@ export interface PasskeyItem {
18
27
  lastUsedAt?: Date | string | number | null;
19
28
  }
20
29
 
21
- export interface PasskeysSettingProps {
30
+ /** `data-*` attributes, passed through to the element they are given for. */
31
+ export type PasskeyDataAttributes = { [key: `data-${string}`]: string | number | boolean | undefined };
32
+
33
+ export interface PasskeysSettingProps<Id extends PasskeyId = PasskeyId> extends PasskeyDataAttributes {
22
34
  /** `undefined` while the first load is in flight (or pass `loading`). */
23
- passkeys: readonly PasskeyItem[] | undefined;
35
+ passkeys: readonly PasskeyItem<Id>[] | undefined;
24
36
  loading?: boolean;
25
37
  /**
26
38
  * Run the WebAuthn ceremony and register the passkey — app-side, since the options
@@ -33,11 +45,11 @@ export interface PasskeysSettingProps {
33
45
  /** The ceremony is running. The add button says so and does not start a second one. */
34
46
  adding?: boolean;
35
47
  /** Offer "Rename" on each row. Return a promise to leave edit mode only on success. */
36
- onRename?: (id: PasskeyItem["id"], name: string) => void | Promise<unknown>;
48
+ onRename?: (id: Id, name: string) => void | Promise<unknown>;
37
49
  /** Offer "Delete" on each row — always behind an inline confirmation. */
38
- onDelete?: (id: PasskeyItem["id"]) => void | Promise<unknown>;
50
+ onDelete?: (id: Id) => void | Promise<unknown>;
39
51
  /** A row whose rename or delete is in flight; its buttons are disabled. */
40
- busyId?: PasskeyItem["id"] | null;
52
+ busyId?: Id | null;
41
53
  /** Show the name field above the add button. Default true. */
42
54
  nameField?: boolean;
43
55
  /**
@@ -53,6 +65,12 @@ export interface PasskeysSettingProps {
53
65
  /** Prop > `<UiKitProvider labels={{ accountSettings: { passkeys } }}>` > English. */
54
66
  labels?: Partial<PasskeysSettingLabels>;
55
67
  className?: string;
68
+ /** On the card (0.16.0), for an in-page link (`#passkeys`) or a test hook. `data-*`
69
+ * attributes go there too. */
70
+ id?: string;
71
+ /** Extra attributes for one row's `<li>` (0.16.0) — an `id` to scroll a just-added
72
+ * key into view, `data-*` for a test. Every row also carries `data-passkey-id`. */
73
+ rowProps?: (item: PasskeyItem<Id>) => ({ id?: string } & PasskeyDataAttributes) | undefined;
56
74
  }
57
75
 
58
76
  /**
@@ -70,7 +88,7 @@ export interface PasskeysSettingProps {
70
88
  * the passkey it names. Focus moves to Cancel, as in every destructive confirm in the
71
89
  * kit, so the Enter that pressed Delete cannot also answer it.
72
90
  */
73
- export function PasskeysSetting({
91
+ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
74
92
  passkeys,
75
93
  loading,
76
94
  onAdd,
@@ -84,7 +102,9 @@ export function PasskeysSetting({
84
102
  locale: localeProp,
85
103
  labels: labelsProp,
86
104
  className,
87
- }: PasskeysSettingProps) {
105
+ rowProps,
106
+ ...rest
107
+ }: PasskeysSettingProps<Id>) {
88
108
  const labels = useAccountSettingsLabels("passkeys", labelsProp);
89
109
  const locale = useKitLocale(localeProp);
90
110
  const [name, setName] = useState("");
@@ -126,6 +146,7 @@ export function PasskeysSetting({
126
146
  item={item}
127
147
  created={date(item.createdAt)}
128
148
  lastUsed={date(item.lastUsedAt)}
149
+ attributes={rowProps?.(item)}
129
150
  busy={busyId !== undefined && busyId !== null && busyId === item.id}
130
151
  onRename={onRename}
131
152
  onDelete={onDelete}
@@ -160,7 +181,8 @@ export function PasskeysSetting({
160
181
  })();
161
182
 
162
183
  return (
163
- <Card className={cn("p-4 space-y-3", className)}>
184
+ // `rest` is only `id` and `data-*`: everything else the props name is destructured.
185
+ <Card {...rest} className={cn("p-4 space-y-3", className)}>
164
186
  <div>
165
187
  <div className="text-sm font-medium">{labels.title}</div>
166
188
  <div className="text-xs text-[var(--text-muted)]">
@@ -174,21 +196,23 @@ export function PasskeysSetting({
174
196
 
175
197
  type RowMode = "view" | "rename" | "delete";
176
198
 
177
- function PasskeyRow({
199
+ function PasskeyRow<Id extends PasskeyId>({
178
200
  item,
179
201
  created,
180
202
  lastUsed,
203
+ attributes,
181
204
  busy,
182
205
  onRename,
183
206
  onDelete,
184
207
  labels,
185
208
  }: {
186
- item: PasskeyItem;
209
+ item: PasskeyItem<Id>;
187
210
  created: string | null;
188
211
  lastUsed: string | null;
212
+ attributes?: { id?: string } & PasskeyDataAttributes;
189
213
  busy: boolean;
190
- onRename?: PasskeysSettingProps["onRename"];
191
- onDelete?: PasskeysSettingProps["onDelete"];
214
+ onRename?: PasskeysSettingProps<Id>["onRename"];
215
+ onDelete?: PasskeysSettingProps<Id>["onDelete"];
192
216
  labels: PasskeysSettingLabels;
193
217
  }) {
194
218
  const [mode, setMode] = useState<RowMode>("view");
@@ -241,13 +265,16 @@ function PasskeyRow({
241
265
  .catch(() => {});
242
266
  };
243
267
 
268
+ // On every mode's <li>, so an id the app scrolls to survives a rename or a confirm.
269
+ const li = { ...attributes, "data-passkey-id": String(item.id) };
270
+
244
271
  const meta = [created && labels.created(created), lastUsed ? labels.lastUsed(lastUsed) : labels.neverUsed]
245
272
  .filter(Boolean)
246
273
  .join(" · ");
247
274
 
248
275
  if (mode === "rename") {
249
276
  return (
250
- <li className="flex flex-wrap items-center gap-2 py-2">
277
+ <li {...li} className="flex flex-wrap items-center gap-2 py-2">
251
278
  <Input
252
279
  ref={renameRef}
253
280
  aria-label={labels.renameField(item.name)}
@@ -278,7 +305,7 @@ function PasskeyRow({
278
305
 
279
306
  if (mode === "delete") {
280
307
  return (
281
- <li className="space-y-2 py-2">
308
+ <li {...li} className="space-y-2 py-2">
282
309
  <p role="alert" className="text-sm">
283
310
  {labels.deleteConfirm(item.name)}
284
311
  </p>
@@ -295,7 +322,7 @@ function PasskeyRow({
295
322
  }
296
323
 
297
324
  return (
298
- <li className="flex items-center gap-3 py-2">
325
+ <li {...li} className="flex items-center gap-3 py-2">
299
326
  <KeyRound className="size-4 shrink-0 text-[var(--text-muted)]" aria-hidden />
300
327
  <div className="min-w-0 flex-1">
301
328
  <div className="truncate text-sm font-medium">{item.name}</div>
@@ -105,6 +105,12 @@ export interface ProgressBarSegment {
105
105
  }
106
106
 
107
107
  export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "role"> {
108
+ /**
109
+ * `"span"` builds the bar from spans, for a place only phrasing content may go: inside
110
+ * a button or a link (ListItem's `content`). The `legend` stays a list, so leave it off
111
+ * there. Default `"div"`.
112
+ */
113
+ as?: "div" | "span";
108
114
  /** Where it stands. Leave it undefined for an INDETERMINATE bar — work is under way
109
115
  * and nobody knows how much is left (an import that reports no count). */
110
116
  value?: number;
@@ -155,8 +161,11 @@ export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">,
155
161
  legend?: boolean;
156
162
  /**
157
163
  * What a legend row shows at its end, in place of the part's formatted value — which
158
- * it is handed, with the segment and its index. keksdose's cut card shows the share
159
- * AND a private outflow amount per bucket (cut-card:150), which one figure could not:
164
+ * it is handed, with the segment and its index. That value is the part's OWN, signed
165
+ * and unclamped (`formatValue`, or a percentage of the scale): a part of −40 on a
166
+ * 0–100 scale says "-40%" though the bar draws nothing for it. keksdose's cut card
167
+ * shows the share AND a private outflow amount per bucket (cut-card:150), which one
168
+ * figure could not:
160
169
  * `legendValue={(seg, share) => <>{share} <Amount … /></>}`.
161
170
  */
162
171
  legendValue?: (segment: ProgressBarSegment, formatted: string, index: number) => ReactNode;
@@ -209,11 +218,17 @@ export function ProgressBar({
209
218
  overage,
210
219
  labels,
211
220
  className,
221
+ as = "div",
212
222
  "aria-label": ariaLabel,
213
223
  "aria-labelledby": ariaLabelledBy,
214
224
  "aria-describedby": ariaDescribedBy,
215
225
  ...rest
216
226
  }: ProgressBarProps) {
227
+ // `as="span"`: the same bar from phrasing elements only, so it is valid inside a
228
+ // <button> or <a> — ListItem's `content` puts a budget bar inside the row's target
229
+ // (keksdose P2). Every span is made a block except those already laid out as flex.
230
+ const Tag = as;
231
+ const inlineClass = as === "span" ? "block [&_span:not(.flex):not(.inline-flex)]:block" : undefined;
217
232
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
218
233
  const text = useKitLabels("progressBar", DEFAULT_PROGRESS_BAR_LABELS, labels);
219
234
  const kitLocale = useKitLocale(locale);
@@ -236,8 +251,8 @@ export function ProgressBar({
236
251
  ? formatValue(value, Infinity, min)
237
252
  : new Intl.NumberFormat(kitLocale).format(value);
238
253
  return (
239
- <div {...rest} className={cn("min-w-0", className)} data-state="unlimited">
240
- <div className="flex items-baseline gap-2 text-sm">
254
+ <Tag {...rest} className={cn("min-w-0", inlineClass, className)} data-state="unlimited">
255
+ <Tag className="flex items-baseline gap-2 text-sm">
241
256
  {label != null && (
242
257
  <span id={labelId} className="min-w-0 text-[var(--text-secondary)]">
243
258
  {label}
@@ -247,9 +262,9 @@ export function ProgressBar({
247
262
  {shown !== undefined && `${shown} · `}
248
263
  {text.unlimited}
249
264
  </span>
250
- </div>
265
+ </Tag>
251
266
  {hintNode}
252
- </div>
267
+ </Tag>
253
268
  );
254
269
  }
255
270
  const max = maxProp;
@@ -269,9 +284,21 @@ export function ProgressBar({
269
284
  : new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(
270
285
  span <= 0 ? 0 : (Math.min(max, Math.max(min, v)) - min) / span,
271
286
  );
287
+ // A PART is stated as its own value, sign and all — not as the slice of track it
288
+ // draws. The bar cannot draw a negative part (a refund inside a month's spending) or
289
+ // one past `max`, but the legend and the reader can still say it: before 0.16 a
290
+ // −CHF 40 part read "0 %" in both, and keksdose had to pass `legendValue` just to get
291
+ // the minus back. For a part inside `min`–`max` this is the same text as before.
292
+ const formatPart = (v: number) => {
293
+ if (!Number.isFinite(v)) return format(min);
294
+ if (formatValue) return formatValue(v, max, min);
295
+ return new Intl.NumberFormat(kitLocale, { style: "percent", maximumFractionDigits: 0 }).format(
296
+ span <= 0 ? 0 : (v - min) / span,
297
+ );
298
+ };
272
299
  const partTexts = stacked
273
300
  ? barParts.map((seg) => {
274
- const text = format(min + partValue(seg.value));
301
+ const text = formatPart(seg.value);
275
302
  return typeof seg.label === "string" || typeof seg.label === "number"
276
303
  ? common.fieldValue(String(seg.label), text)
277
304
  : text;
@@ -306,9 +333,9 @@ export function ProgressBar({
306
333
  const name = labelledBy ? undefined : (ariaLabel ?? (indeterminate ? common.loading : undefined));
307
334
 
308
335
  return (
309
- <div className={cn("min-w-0", className)} data-state={indeterminate ? "indeterminate" : "determinate"}>
336
+ <Tag className={cn("min-w-0", inlineClass, className)} data-state={indeterminate ? "indeterminate" : "determinate"}>
310
337
  {(label != null || (showValue && valueText !== undefined)) && (
311
- <div className="mb-1 flex items-baseline gap-2 text-sm">
338
+ <Tag className="mb-1 flex items-baseline gap-2 text-sm">
312
339
  {label != null && (
313
340
  <span id={labelId} className="min-w-0 text-[var(--text-secondary)]">
314
341
  {label}
@@ -321,9 +348,9 @@ export function ProgressBar({
321
348
  {valueText}
322
349
  </span>
323
350
  )}
324
- </div>
351
+ </Tag>
325
352
  )}
326
- <div
353
+ <Tag
327
354
  {...rest}
328
355
  role={meter ? "meter" : "progressbar"}
329
356
  aria-label={name}
@@ -341,10 +368,10 @@ export function ProgressBar({
341
368
  {stacked ? (
342
369
  // The parts in a flex row with a 2px gap, so two neighbouring steps of one
343
370
  // hue are parted by the track rather than by their difference in lightness.
344
- <div className="flex h-full w-full gap-0.5">
371
+ <Tag className="flex h-full w-full gap-0.5">
345
372
  {segments.map((seg, i) =>
346
373
  seg.legendOnly ? null : (
347
- <div
374
+ <Tag
348
375
  key={seg.key ?? i}
349
376
  data-part="segment"
350
377
  className={cn(
@@ -358,9 +385,9 @@ export function ProgressBar({
358
385
  />
359
386
  ),
360
387
  )}
361
- </div>
388
+ </Tag>
362
389
  ) : indeterminate ? (
363
- <div
390
+ <Tag
364
391
  data-part="fill"
365
392
  className={cn(
366
393
  // `left-0`, deliberately physical: the keyframes (tokens.css
@@ -375,7 +402,7 @@ export function ProgressBar({
375
402
  )}
376
403
  />
377
404
  ) : (
378
- <div
405
+ <Tag
379
406
  data-part="fill"
380
407
  // Block flow starts at the inline start, so a plain width fills from the
381
408
  // right under `dir="rtl"` with nothing extra.
@@ -383,7 +410,7 @@ export function ProgressBar({
383
410
  style={{ width: `${fraction * 100}%` }}
384
411
  />
385
412
  )}
386
- </div>
413
+ </Tag>
387
414
  {stacked && legend && segments.length > 0 && (
388
415
  <ul className="mt-2 space-y-1 text-sm" data-part="legend">
389
416
  {segments.map((seg, i) => (
@@ -400,9 +427,7 @@ export function ProgressBar({
400
427
  <span className="min-w-0 flex-1 truncate text-[var(--text-secondary)]">{seg.label}</span>
401
428
  )}
402
429
  <span className="ms-auto shrink-0 text-xs tabular-nums text-[var(--text-muted)]">
403
- {legendValue
404
- ? legendValue(seg, format(min + partValue(seg.value)), i)
405
- : format(min + partValue(seg.value))}
430
+ {legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)}
406
431
  </span>
407
432
  </li>
408
433
  ))}
@@ -410,6 +435,6 @@ export function ProgressBar({
410
435
  )}
411
436
  {overageNode}
412
437
  {hintNode}
413
- </div>
438
+ </Tag>
414
439
  );
415
440
  }
@@ -78,6 +78,17 @@ export type GoodDirection = "up" | "down";
78
78
  */
79
79
  export type SignedAmountTone = "signed" | "income" | "expense" | "neutral" | "verdict";
80
80
 
81
+ /**
82
+ * The colours a VERDICT is painted in (`goodDirection` set, or `tone="verdict"`):
83
+ * - `status` (default) — `success` / `danger`: good news, bad news.
84
+ * - `money` — `income` / `expense` (the `.text-money-pos` / `.text-money-neg` pair):
85
+ * the app's ONE money palette (keksdose dev#434). A grocery price going UP is money
86
+ * going out, so with `goodDirection="down"` it wears the expense colour every other
87
+ * outflow in the app wears — not a red that says "error". keksdose coloured its
88
+ * three price-change sites by hand for want of this.
89
+ */
90
+ export type VerdictPalette = "status" | "money";
91
+
81
92
  /** What a signed figure says to a screen reader, whose readers disagree about a
82
93
  * leading "+" (most skip it) and a "−" (some read "dash", some nothing). */
83
94
  export interface SignedAmountLabels {
@@ -117,18 +128,28 @@ function useMagnitudeFormatter({ currency, unit, compact, digits, format, locale
117
128
  };
118
129
  }
119
130
 
120
- function directionOf(value: number): "up" | "down" | "flat" {
121
- return value > 0 ? "up" : value < 0 ? "down" : "flat";
131
+ /** `flatWithin` widens "no change" from exactly zero to a band round it: a price that
132
+ * moved by a fraction of a cent, a ratio that rounds to "0.0 %", is not a rise, and an
133
+ * arrow and a colour for it claim one. Inclusive — `|value| ≤ flatWithin` is flat. */
134
+ function directionOf(value: number, flatWithin = 0): "up" | "down" | "flat" {
135
+ if (Math.abs(value) <= Math.max(0, flatWithin)) return "flat";
136
+ return value > 0 ? "up" : "down";
137
+ }
138
+
139
+ function verdictClass(good: boolean, palette: VerdictPalette): string {
140
+ if (palette === "money") return good ? TEXT_TONE.income : TEXT_TONE.expense;
141
+ return good ? TEXT_TONE.success : TEXT_TONE.danger;
122
142
  }
123
143
 
124
144
  function signedToneClass(
125
145
  tone: SignedAmountTone,
126
146
  dir: "up" | "down" | "flat",
127
147
  goodDirection: GoodDirection | undefined,
148
+ palette: VerdictPalette,
128
149
  ): string {
129
150
  if (tone === "verdict" || (goodDirection && tone === "signed")) {
130
151
  if (dir === "flat" || !goodDirection) return TEXT_TONE.muted;
131
- return dir === goodDirection ? TEXT_TONE.success : TEXT_TONE.danger;
152
+ return verdictClass(dir === goodDirection, palette);
132
153
  }
133
154
  if (tone === "signed") return dir === "flat" ? TEXT_TONE.muted : dir === "up" ? TEXT_TONE.income : TEXT_TONE.expense;
134
155
  if (tone === "neutral") return "";
@@ -143,8 +164,16 @@ export interface SignedAmountProps
143
164
  value: number;
144
165
  /** See {@link SignedAmountTone}. Default `signed`, or `verdict` with `goodDirection`. */
145
166
  tone?: SignedAmountTone;
146
- /** Which way is good news — colours by verdict (`success` / `danger`). */
167
+ /** Which way is good news — colours by verdict (`success` / `danger`, or the money
168
+ * pair with `palette="money"`). */
147
169
  goodDirection?: GoodDirection;
170
+ /** See {@link VerdictPalette}. Default `status`. Only a verdict reads it. */
171
+ palette?: VerdictPalette;
172
+ /** A magnitude up to which the figure counts as NO change: no sign, no arrow but the
173
+ * flat dash, muted, spoken without "plus"/"minus" (inclusive; default 0). The digits
174
+ * still print `|value|` — use a band below the display precision, where they round
175
+ * to zero anyway (`flatWithin={0.005}` at two digits). */
176
+ flatWithin?: number;
148
177
  /** Print "+" before a positive figure (default `true`). A negative always shows its
149
178
  * minus; zero shows neither. */
150
179
  showPlus?: boolean;
@@ -177,6 +206,8 @@ export function SignedAmount({
177
206
  locale,
178
207
  tone = "signed",
179
208
  goodDirection,
209
+ palette = "status",
210
+ flatWithin,
180
211
  showPlus = true,
181
212
  arrow = false,
182
213
  sensitive = false,
@@ -187,7 +218,7 @@ export function SignedAmount({
187
218
  const text = useKitLabels("signedAmount", DEFAULT_SIGNED_AMOUNT_LABELS, labels);
188
219
  const fmt = useMagnitudeFormatter({ currency, unit, compact, digits, format, locale });
189
220
  const finite = Number.isFinite(value);
190
- const dir = finite ? directionOf(value) : "flat";
221
+ const dir = finite ? directionOf(value, flatWithin) : "flat";
191
222
  const amount = finite ? fmt(Math.abs(value)) : "—";
192
223
  // U+2212, the typographic minus: the same width as "+", so a column of signed figures
193
224
  // lines up under `tabular-nums`.
@@ -201,7 +232,7 @@ export function SignedAmount({
201
232
  data-direction={dir}
202
233
  className={cn(
203
234
  "relative inline-flex items-center gap-0.5 whitespace-nowrap tabular-nums",
204
- signedToneClass(tone, dir, goodDirection),
235
+ signedToneClass(tone, dir, goodDirection, palette),
205
236
  className,
206
237
  )}
207
238
  >
@@ -221,6 +252,12 @@ export interface DeltaProps extends SignedFormatProps, Omit<ComponentPropsWithou
221
252
  /** Which way is good news. Unset, the change is stated and not judged (muted); set,
222
253
  * it is coloured `success` / `danger` and its sentence says "(better)" / "(worse)". */
223
254
  goodDirection?: GoodDirection;
255
+ /** See {@link VerdictPalette}: `money` paints good news `income` and bad news
256
+ * `expense` instead of `success` / `danger`. Default `status`. */
257
+ palette?: VerdictPalette;
258
+ /** See {@link SignedAmountProps.flatWithin}: up to this magnitude the change is
259
+ * "unchanged" — flat dash, muted, no verdict. Default 0. */
260
+ flatWithin?: number;
224
261
  /** What it is measured against, after it: "vs last month". */
225
262
  label?: ReactNode;
226
263
  /** Hide the arrow (shown by default, as on a StatTile). At zero the arrow is a flat
@@ -249,6 +286,8 @@ export function Delta({
249
286
  format,
250
287
  locale,
251
288
  goodDirection,
289
+ palette = "status",
290
+ flatWithin,
252
291
  label,
253
292
  arrow = true,
254
293
  sensitive = false,
@@ -260,14 +299,14 @@ export function Delta({
260
299
  const fmt = useMagnitudeFormatter({ currency, unit, compact, digits, format, locale });
261
300
  if (!Number.isFinite(value)) return null;
262
301
  const amount = fmt(Math.abs(value));
263
- const dir = directionOf(value);
302
+ const dir = directionOf(value, flatWithin);
264
303
  const Icon = ARROW[dir];
265
304
  let spoken = dir === "up" ? text.increase(amount) : dir === "down" ? text.decrease(amount) : text.unchanged;
266
305
  let tone = TEXT_TONE.muted;
267
306
  if (goodDirection && dir !== "flat") {
268
307
  const good = dir === goodDirection;
269
308
  spoken = good ? text.better(spoken) : text.worse(spoken);
270
- tone = good ? TEXT_TONE.success : TEXT_TONE.danger;
309
+ tone = verdictClass(good, palette);
271
310
  }
272
311
  return (
273
312
  <span