@eifi1/ui-kit 0.15.4 → 0.16.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 (160) hide show
  1. package/README.md +20 -15
  2. package/dist/chart.d.ts +3 -4
  3. package/dist/components/alert-banner.js +1 -1
  4. package/dist/components/alert-banner.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +13 -4
  6. package/dist/components/amount-input.js +166 -157
  7. package/dist/components/amount-input.js.map +1 -1
  8. package/dist/components/authed-image.d.ts +14 -3
  9. package/dist/components/authed-image.js +32 -23
  10. package/dist/components/authed-image.js.map +1 -1
  11. package/dist/components/button-group.d.ts +3 -4
  12. package/dist/components/calculator.d.ts +3 -4
  13. package/dist/components/checkbox.js +1 -1
  14. package/dist/components/checkbox.js.map +1 -1
  15. package/dist/components/choice-card.js +1 -1
  16. package/dist/components/choice-card.js.map +1 -1
  17. package/dist/components/copy-button.d.ts +3 -4
  18. package/dist/components/error-boundary.js +1 -1
  19. package/dist/components/error-boundary.js.map +1 -1
  20. package/dist/components/facing-pair.d.ts +3 -4
  21. package/dist/components/file-button.d.ts +3 -4
  22. package/dist/components/file-button.js +64 -31
  23. package/dist/components/file-button.js.map +1 -1
  24. package/dist/components/file-dropzone.d.ts +3 -4
  25. package/dist/components/file-dropzone.js +1 -1
  26. package/dist/components/file-dropzone.js.map +1 -1
  27. package/dist/components/form-actions.d.ts +4 -5
  28. package/dist/components/form-actions.js +18 -6
  29. package/dist/components/form-actions.js.map +1 -1
  30. package/dist/components/line-items.d.ts +53 -2
  31. package/dist/components/line-items.js +73 -7
  32. package/dist/components/line-items.js.map +1 -1
  33. package/dist/components/list.d.ts +99 -5
  34. package/dist/components/list.js +103 -38
  35. package/dist/components/list.js.map +1 -1
  36. package/dist/components/loading-state.d.ts +19 -3
  37. package/dist/components/loading-state.js +14 -3
  38. package/dist/components/loading-state.js.map +1 -1
  39. package/dist/components/money-field.d.ts +3 -4
  40. package/dist/components/number-field.d.ts +3 -4
  41. package/dist/components/number-input.d.ts +3 -4
  42. package/dist/components/number-input.js +4 -2
  43. package/dist/components/number-input.js.map +1 -1
  44. package/dist/components/numpad-sheet.d.ts +3 -4
  45. package/dist/components/passkeys-setting.d.ts +30 -10
  46. package/dist/components/passkeys-setting.js +19 -11
  47. package/dist/components/passkeys-setting.js.map +1 -1
  48. package/dist/components/progress-bar.d.ts +12 -3
  49. package/dist/components/progress-bar.js +21 -11
  50. package/dist/components/progress-bar.js.map +1 -1
  51. package/dist/components/series-chart.d.ts +3 -4
  52. package/dist/components/settings-fields.d.ts +3 -4
  53. package/dist/components/signed-amount.d.ts +28 -4
  54. package/dist/components/signed-amount.js +17 -8
  55. package/dist/components/signed-amount.js.map +1 -1
  56. package/dist/components/stat-tile.d.ts +31 -2
  57. package/dist/components/stat-tile.js +37 -6
  58. package/dist/components/stat-tile.js.map +1 -1
  59. package/dist/components/status-dot.d.ts +4 -1
  60. package/dist/components/status-dot.js +20 -1
  61. package/dist/components/status-dot.js.map +1 -1
  62. package/dist/components/swatch-picker.d.ts +3 -1
  63. package/dist/components/swatch-picker.js.map +1 -1
  64. package/dist/components/table.d.ts +18 -13
  65. package/dist/components/table.js +20 -5
  66. package/dist/components/table.js.map +1 -1
  67. package/dist/components/text-link.d.ts +3 -4
  68. package/dist/components/time-input.d.ts +3 -4
  69. package/dist/components/toggle-legend.d.ts +12 -1
  70. package/dist/components/toggle-legend.js +6 -2
  71. package/dist/components/toggle-legend.js.map +1 -1
  72. package/dist/components/ui.d.ts +3 -4
  73. package/dist/components/ui.js +156 -52
  74. package/dist/components/ui.js.map +1 -1
  75. package/dist/feedback/feedback-attachment.d.ts +2 -82
  76. package/dist/feedback/feedback-attachment.js +285 -55
  77. package/dist/feedback/feedback-attachment.js.map +1 -1
  78. package/dist/feedback/feedback-dialog.d.ts +2 -105
  79. package/dist/feedback/feedback-dialog.js +57 -25
  80. package/dist/feedback/feedback-dialog.js.map +1 -1
  81. package/dist/feedback/feedback-inbox.d.ts +1 -1
  82. package/dist/feedback/feedback-thread.d.ts +2 -2
  83. package/dist/feedback-attachment-WFiXJ8We.d.ts +302 -0
  84. package/dist/feedback.d.ts +1 -2
  85. package/dist/hooks/use-file-drop.d.ts +3 -4
  86. package/dist/i18n/defaults.d.ts +3 -4
  87. package/dist/i18n/kit-labels.d.ts +2 -3
  88. package/dist/i18n/locales/de-CH-informal.d.ts +3 -4
  89. package/dist/i18n/locales/de-CH.d.ts +3 -4
  90. package/dist/i18n/locales/de-informal.d.ts +3 -4
  91. package/dist/i18n/locales/de.d.ts +3 -4
  92. package/dist/i18n/locales/de.js +5 -1
  93. package/dist/i18n/locales/de.js.map +1 -1
  94. package/dist/i18n/locales/es.d.ts +3 -4
  95. package/dist/i18n/locales/es.js +5 -1
  96. package/dist/i18n/locales/es.js.map +1 -1
  97. package/dist/i18n/locales/fr.d.ts +3 -4
  98. package/dist/i18n/locales/fr.js +5 -1
  99. package/dist/i18n/locales/fr.js.map +1 -1
  100. package/dist/i18n/locales/hu.d.ts +3 -4
  101. package/dist/i18n/locales/hu.js +5 -1
  102. package/dist/i18n/locales/hu.js.map +1 -1
  103. package/dist/i18n/locales/it.d.ts +3 -4
  104. package/dist/i18n/locales/it.js +5 -1
  105. package/dist/i18n/locales/it.js.map +1 -1
  106. package/dist/i18n/locales/zh.d.ts +3 -4
  107. package/dist/i18n/locales/zh.js +5 -1
  108. package/dist/i18n/locales/zh.js.map +1 -1
  109. package/dist/index.d.ts +10 -11
  110. package/dist/{kit-labels-CnnBd-2N.d.ts → kit-labels-DE--LmCk.d.ts} +154 -14
  111. package/dist/lib/format.d.ts +18 -3
  112. package/dist/lib/format.js +2 -1
  113. package/dist/lib/format.js.map +1 -1
  114. package/dist/rhf/fields.d.ts +3 -4
  115. package/dist/rhf/form.d.ts +3 -4
  116. package/dist/rhf/line-items.js.map +1 -1
  117. package/dist/rhf.d.ts +3 -4
  118. package/dist/shell/app-shell.d.ts +3 -4
  119. package/dist/shell/top-bar-brand.d.ts +3 -4
  120. package/dist/shell.d.ts +3 -4
  121. package/dist/theme/palette-derive.d.ts +3 -0
  122. package/dist/theme/palette-derive.js +7 -0
  123. package/dist/theme/palette-derive.js.map +1 -1
  124. package/dist/wizard/stepper-nav.d.ts +3 -4
  125. package/dist/wizard.d.ts +3 -4
  126. package/package.json +4 -2
  127. package/src/components/alert-banner.tsx +1 -1
  128. package/src/components/amount-input.tsx +47 -16
  129. package/src/components/authed-image.tsx +20 -2
  130. package/src/components/checkbox.tsx +1 -1
  131. package/src/components/choice-card.tsx +1 -1
  132. package/src/components/error-boundary.tsx +1 -1
  133. package/src/components/file-button.tsx +77 -27
  134. package/src/components/file-dropzone.tsx +1 -1
  135. package/src/components/form-actions.tsx +86 -8
  136. package/src/components/line-items.tsx +131 -14
  137. package/src/components/list.tsx +240 -50
  138. package/src/components/loading-state.tsx +35 -4
  139. package/src/components/number-input.tsx +18 -2
  140. package/src/components/passkeys-setting.tsx +45 -18
  141. package/src/components/progress-bar.tsx +46 -21
  142. package/src/components/signed-amount.tsx +47 -8
  143. package/src/components/stat-tile.tsx +76 -6
  144. package/src/components/status-dot.tsx +31 -0
  145. package/src/components/swatch-picker.tsx +3 -1
  146. package/src/components/table.tsx +42 -15
  147. package/src/components/toggle-legend.tsx +21 -4
  148. package/src/components/ui.tsx +280 -70
  149. package/src/feedback/feedback-attachment.tsx +472 -96
  150. package/src/feedback/feedback-dialog.tsx +162 -50
  151. package/src/i18n/locales/de.ts +4 -0
  152. package/src/i18n/locales/es.ts +4 -0
  153. package/src/i18n/locales/fr.ts +4 -0
  154. package/src/i18n/locales/hu.ts +4 -0
  155. package/src/i18n/locales/it.ts +4 -0
  156. package/src/i18n/locales/zh.ts +4 -0
  157. package/src/lib/format.ts +24 -3
  158. package/src/rhf/line-items.tsx +6 -0
  159. package/src/theme/palette-derive.ts +10 -0
  160. package/tokens.css +9 -1
@@ -1,10 +1,13 @@
1
- import { createContext, forwardRef, useContext, useId } from "react";
1
+ import { createContext, forwardRef, useContext, useId, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef, HTMLAttributes, MouseEvent, ReactElement, ReactNode, Ref } from "react";
3
- import { ExternalLink } from "lucide-react";
3
+ import { ChevronDown, ExternalLink } from "lucide-react";
4
4
  import type { LucideIcon } from "lucide-react";
5
5
  import { cn } from "../lib/cn";
6
6
  import { Spinner } from "./ui";
7
7
  import { StatusDot } from "./status-dot";
8
+ import { Collapse } from "./disclosure";
9
+ import { toneTextClass } from "./signed-amount";
10
+ import type { TextTone } from "./signed-amount";
8
11
  import { useKitLabels, useKitLink } from "../i18n/kit-labels";
9
12
  import { pickLinkRenderer } from "./text-link";
10
13
 
@@ -96,12 +99,19 @@ export interface ListItemLinkProps {
96
99
  [key: `data-${string}`]: unknown;
97
100
  }
98
101
 
99
- const PAD: Record<ListDensity, { target: string; actions: string; gap: string; body: string }> = {
100
- compact: { target: "gap-2 px-2 py-1.5", actions: "pe-1.5", gap: "gap-0.5", body: "px-2 pb-1.5" },
101
- default: { target: "gap-3 px-3 py-2.5", actions: "pe-2", gap: "gap-1", body: "px-3 pb-2.5" },
102
- comfortable: { target: "gap-3 px-4 py-3", actions: "pe-3", gap: "gap-1", body: "px-4 pb-3" },
102
+ const PAD: Record<
103
+ ListDensity,
104
+ { target: string; actions: string; leadingActions: string; gap: string; body: string }
105
+ > = {
106
+ compact: { target: "gap-2 px-2 py-1.5", actions: "pe-1.5", leadingActions: "ps-1.5", gap: "gap-0.5", body: "px-2 pb-1.5" },
107
+ default: { target: "gap-3 px-3 py-2.5", actions: "pe-2", leadingActions: "ps-2", gap: "gap-1", body: "px-3 pb-2.5" },
108
+ comfortable: { target: "gap-3 px-4 py-3", actions: "pe-3", leadingActions: "ps-3", gap: "gap-1", body: "px-4 pb-3" },
103
109
  };
104
110
 
111
+ /** {@link ListItemBaseProps.trailingTone}: a {@link TextTone}, or `inherit` — no
112
+ * colour of the slot's own, so an element's colour (or the row's) stands. */
113
+ export type ListItemTrailingTone = TextTone | "inherit";
114
+
105
115
  const TARGET_RING =
106
116
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--brand)]";
107
117
 
@@ -124,8 +134,37 @@ interface ListItemBaseProps {
124
134
  subtitle?: ReactNode;
125
135
  /** `2` for a line that is a body rather than a label (keksdose's notification text). */
126
136
  subtitleLines?: 1 | 2;
137
+ /**
138
+ * A small line ABOVE the title, in the caption type: the group a row belongs to, a
139
+ * date heading, a category ("Groceries · weekly"). Part of the target and of its
140
+ * name, read before the title. One line, truncated.
141
+ */
142
+ overline?: ReactNode;
127
143
  /** A third line in the caption type: a timestamp, "Updated …" (lenkbank's projects). */
128
144
  meta?: ReactNode;
145
+ /**
146
+ * Let {@link meta} wrap: it becomes a wrapping row (`flex-wrap`, a small gap)
147
+ * instead of one truncated line — for a run of chips (keksdose's payee-backfill
148
+ * tags), which otherwise had nowhere to go but the body, outside the target. Still
149
+ * inside the target, so still no controls: chips that only show, not ones that
150
+ * remove themselves.
151
+ */
152
+ metaWrap?: boolean;
153
+ /**
154
+ * Content INSIDE the main target, under the title, subtitle and meta: a progress or
155
+ * budget bar, a sparkline, a strip of figures (keksdose's food-group bars). Unlike
156
+ * {@link children}, which sits outside the target, a click on it is a click on the
157
+ * row, and its hover fill covers it.
158
+ *
159
+ * Because it is inside the button or link it obeys the {@link trailing} rule: shown,
160
+ * never operated — no buttons, links or inputs (invalid HTML in a button, and folded
161
+ * into its name). Its text joins the row's accessible name, so give a bar that only
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
164
+ * kit's `ProgressBar` draws `<div>`s, which every browser lays out and reads fine
165
+ * there but an HTML validator flags.
166
+ */
167
+ content?: ReactNode;
129
168
  /** A Lucide icon before the text, muted, at 16px. */
130
169
  icon?: LucideIcon;
131
170
  /** Anything else before the text — an avatar, a file-type badge, a drag handle. */
@@ -136,6 +175,13 @@ interface ListItemBaseProps {
136
175
  * Controls go in {@link actions}.
137
176
  */
138
177
  trailing?: ReactNode;
178
+ /**
179
+ * The colour of the {@link trailing} slot. Default `muted` — today's look, right for
180
+ * a size or a count in plain text. `inherit` sets none, so an element's own colour
181
+ * (an amount coloured by its sign) stands without a second override; any
182
+ * {@link TextTone} colours a plain-text figure (`"danger"` for an overdue count).
183
+ */
184
+ trailingTone?: ListItemTrailingTone;
139
185
  /**
140
186
  * Controls BESIDE the main target, never inside it: copy, delete, "Open".
141
187
  *
@@ -147,6 +193,15 @@ interface ListItemBaseProps {
147
193
  * on "Copy" from selecting the row.
148
194
  */
149
195
  actions?: ReactNode;
196
+ /**
197
+ * Controls BEFORE the main target, never inside it: a selection checkbox, a drag
198
+ * handle that is a button. The mirror of {@link actions} at the start edge — a
199
+ * sibling of the target, so checking the box does not activate (or expand) the row
200
+ * and the checkbox keeps its own name. Give the control an `aria-label` naming the
201
+ * row ("Select Groceries"), since it is not inside the row's name. Anything that only
202
+ * shows goes in {@link leading}, inside the target.
203
+ */
204
+ leadingActions?: ReactNode;
150
205
  /**
151
206
  * Rich content UNDER the title row and outside its target: photos, per-row buttons,
152
207
  * form fields — kastlan's handover room inspector (room-inspector.tsx), the handover
@@ -213,6 +268,20 @@ interface ListItemBaseProps {
213
268
  bordered?: boolean;
214
269
  /** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */
215
270
  as?: "li" | "div";
271
+ /**
272
+ * Wraps the visible row (the bordered box: target, actions and body) in an element
273
+ * of your own, INSIDE the `<li>` — a `<Tooltip>` explaining why the row is disabled,
274
+ * an app's save guard: `renderRow={(row) => <Tooltip label="Locked">{row}</Tooltip>}`.
275
+ *
276
+ * The wrapper otherwise had to go round the whole `ListItem`, which put a `<span>`
277
+ * between the `<ul>` and its `<li>` (invalid; the list stops counting items), so
278
+ * callers wrote `<li><Wrapper><ListItem as="div" /></Wrapper></li>` — and lost the
279
+ * divider and its spacing, which sit on the row's own `<li>`. Here the `<li>` stays
280
+ * the list's child and keeps both. The `<li>` becomes a column and the row takes the
281
+ * full width, so an inline wrapper (the tooltip's `inline-flex` span) still stretches
282
+ * the row across the list.
283
+ */
284
+ renderRow?: (row: ReactElement) => ReactNode;
216
285
  /** Reaches the main target — the element with the row's name and action. */
217
286
  id?: string;
218
287
  [key: `aria-${string}`]: string | boolean | number | undefined;
@@ -225,15 +294,15 @@ interface ListItemBaseProps {
225
294
  */
226
295
  export type ListItemProps = ListItemBaseProps &
227
296
  (
228
- | {
297
+ | ({
229
298
  /** Makes the row a button — select, open, mark read. */
230
299
  onClick: (event: MouseEvent<HTMLButtonElement>) => void;
231
300
  href?: never;
232
301
  renderLink?: never;
233
302
  external?: never;
234
303
  onAuxClick?: never;
235
- }
236
- | {
304
+ } & NotExpandable)
305
+ | ({
237
306
  /**
238
307
  * Makes the row a link. A real `<a href>`, so a middle click opens it in a
239
308
  * background tab (keksdose feedback #451) — which a button calling `navigate`
@@ -256,8 +325,33 @@ export type ListItemProps = ListItemBaseProps &
256
325
  * notification read here too, so a row opened into a background tab does not
257
326
  * stay unread. */
258
327
  onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
259
- }
328
+ } & NotExpandable)
329
+ | ({
330
+ onClick?: never;
331
+ href?: never;
332
+ renderLink?: never;
333
+ external?: never;
334
+ onAuxClick?: never;
335
+ } & NotExpandable)
260
336
  | {
337
+ /**
338
+ * Makes the row a disclosure: the target is a button with `aria-expanded` and a
339
+ * turning chevron at its end, and this opens under the row, outside the target
340
+ * (so it may hold controls), animated by {@link Collapse}. keksdose's collapsible
341
+ * groups — a group row with a select-all checkbox before it
342
+ * ({@link ListItemBaseProps.leadingActions}) and its members inside.
343
+ *
344
+ * The row's ONE action is the toggle, so it takes no `onClick` or `href`. The
345
+ * content is a `role="group"` named by the title, unmounted while shut (as a
346
+ * `Disclosure`'s body is).
347
+ */
348
+ expandedContent: ReactNode;
349
+ /** Controlled open state. Leave it out for the row to keep its own. */
350
+ expanded?: boolean;
351
+ /** The uncontrolled row's first state. Default `false`. */
352
+ defaultExpanded?: boolean;
353
+ /** Runs on every toggle with the new state. */
354
+ onExpandedChange?: (expanded: boolean) => void;
261
355
  onClick?: never;
262
356
  href?: never;
263
357
  renderLink?: never;
@@ -266,6 +360,13 @@ export type ListItemProps = ListItemBaseProps &
266
360
  }
267
361
  );
268
362
 
363
+ interface NotExpandable {
364
+ expandedContent?: never;
365
+ expanded?: never;
366
+ defaultExpanded?: never;
367
+ onExpandedChange?: never;
368
+ }
369
+
269
370
  /**
270
371
  * One row of a {@link List}: leading icon or avatar, title and subtitle (both
271
372
  * truncating by default), a trailing slot, and — beside the row, not inside it — its actions.
@@ -282,13 +383,18 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
282
383
  {
283
384
  title,
284
385
  titleLines = 1,
386
+ overline,
285
387
  subtitle,
286
388
  subtitleLines = 1,
287
389
  meta,
390
+ metaWrap = false,
391
+ content,
288
392
  icon: Icon,
289
393
  leading,
290
394
  trailing,
395
+ trailingTone = "muted",
291
396
  actions,
397
+ leadingActions,
292
398
  unread = false,
293
399
  status,
294
400
  selected = false,
@@ -301,6 +407,7 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
301
407
  targetProps,
302
408
  bordered = false,
303
409
  as = "li",
410
+ renderRow,
304
411
  children,
305
412
  bodyClassName,
306
413
  href,
@@ -308,6 +415,10 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
308
415
  external = false,
309
416
  onClick,
310
417
  onAuxClick,
418
+ expandedContent,
419
+ expanded: expandedProp,
420
+ defaultExpanded = false,
421
+ onExpandedChange,
311
422
  ...rest
312
423
  },
313
424
  ref,
@@ -315,14 +426,27 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
315
426
  const list = useContext(ListContext);
316
427
  const labels = useKitLabels("list", DEFAULT_LIST_LABELS);
317
428
  const kitLink = useKitLink();
429
+ const [ownExpanded, setOwnExpanded] = useState(defaultExpanded);
318
430
  const density = densityProp ?? list.density;
319
431
  const pad = PAD[density];
432
+ const expandable = expandedContent !== undefined;
433
+ const expanded = expandable && (expandedProp ?? ownExpanded);
320
434
  const isLink = href !== undefined && !disabled;
321
- const isButton = !isLink && onClick !== undefined && href === undefined;
435
+ const isButton = !isLink && (onClick !== undefined || expandable) && href === undefined;
322
436
  const interactive = isLink || isButton;
323
437
  const inert = disabled || loading;
324
438
  const hasBody = children !== undefined && children !== null && children !== false;
439
+ // Anything under the row's own line: the box turns into a column, and the target
440
+ // takes its own corners and hover fill (see `children`).
441
+ const stacked = hasBody || expandable;
325
442
  const titleId = useId();
443
+ const expandedId = useId();
444
+
445
+ const toggle = () => {
446
+ const next = !expanded;
447
+ if (expandedProp === undefined) setOwnExpanded(next);
448
+ onExpandedChange?.(next);
449
+ };
326
450
 
327
451
  const mark =
328
452
  status ??
@@ -338,9 +462,14 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
338
462
  )}
339
463
  {leading}
340
464
  <span className="flex min-w-0 flex-1 flex-col">
465
+ {overline != null && (
466
+ <span className="block truncate text-[11px] font-medium leading-snug text-[var(--text-muted)]">
467
+ {overline}
468
+ </span>
469
+ )}
341
470
  <span
342
- // Names the body's group; harmless without one.
343
- id={hasBody ? titleId : undefined}
471
+ // Names the body's and the expanded content's group; harmless without one.
472
+ id={stacked ? titleId : undefined}
344
473
  className={cn(
345
474
  "block text-sm text-[var(--text-primary)]",
346
475
  titleLines === 1 ? "truncate" : titleLines === 2 ? "line-clamp-2 break-words" : "break-words",
@@ -361,9 +490,28 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
361
490
  {subtitle}
362
491
  </span>
363
492
  )}
364
- {meta != null && <span className="mt-0.5 block truncate text-[11px] leading-snug text-[var(--text-muted)]">{meta}</span>}
493
+ {meta != null && (
494
+ <span
495
+ className={cn(
496
+ "mt-0.5 text-[11px] leading-snug text-[var(--text-muted)]",
497
+ metaWrap ? "flex flex-wrap items-center gap-1" : "block truncate",
498
+ )}
499
+ >
500
+ {meta}
501
+ </span>
502
+ )}
503
+ {content != null && <span className="mt-1.5 block min-w-0">{content}</span>}
365
504
  </span>
366
- {trailing != null && <span className="flex shrink-0 items-center gap-2 text-xs text-[var(--text-muted)]">{trailing}</span>}
505
+ {trailing != null && (
506
+ <span
507
+ className={cn(
508
+ "flex shrink-0 items-center gap-2 text-xs",
509
+ trailingTone !== "inherit" && toneTextClass(trailingTone),
510
+ )}
511
+ >
512
+ {trailing}
513
+ </span>
514
+ )}
367
515
  {loading ? (
368
516
  <Spinner label={null} className="size-3.5 shrink-0" />
369
517
  ) : (
@@ -372,6 +520,18 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
372
520
  )
373
521
  )}
374
522
  {mark}
523
+ {expandable && (
524
+ // Turned, not swapped — the Disclosure card's convention: down while shut, up
525
+ // while open. A rotation is its own mirror, so RTL needs nothing.
526
+ <ChevronDown
527
+ aria-hidden
528
+ className={cn(
529
+ "size-4 shrink-0 text-[var(--text-muted)] transition-transform duration-200 ease-out motion-reduce:transition-none",
530
+ align === "start" && "mt-0.5",
531
+ expanded && "rotate-180",
532
+ )}
533
+ />
534
+ )}
375
535
  </>
376
536
  );
377
537
 
@@ -379,10 +539,10 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
379
539
  "flex min-w-0 flex-1 text-start",
380
540
  align === "start" ? "items-start" : "items-center",
381
541
  pad.target,
382
- // With a body the target is the top of the box, not all of it, so it takes its
542
+ // When stacked the target is the top of the box, not all of it, so it takes its
383
543
  // own corners and its own hover fill (see `children`).
384
- hasBody ? "rounded-md" : "rounded-[inherit]",
385
- hasBody && interactive && !inert && !selected && "transition-colors hover:bg-[var(--bg-hover)]",
544
+ stacked ? "rounded-md" : "rounded-[inherit]",
545
+ stacked && interactive && !inert && !selected && "transition-colors hover:bg-[var(--bg-hover)]",
386
546
  interactive && TARGET_RING,
387
547
  interactive && !inert && "cursor-pointer",
388
548
  loading && "cursor-progress",
@@ -431,7 +591,15 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
431
591
  aria-disabled={loading || undefined}
432
592
  aria-busy={loading || undefined}
433
593
  aria-current={selected ? "true" : undefined}
434
- onClick={loading ? undefined : (onClick as (event: MouseEvent<HTMLButtonElement>) => void)}
594
+ aria-expanded={expandable ? expanded : undefined}
595
+ aria-controls={expandable ? expandedId : undefined}
596
+ onClick={
597
+ loading
598
+ ? undefined
599
+ : expandable
600
+ ? toggle
601
+ : (onClick as (event: MouseEvent<HTMLButtonElement>) => void)
602
+ }
435
603
  className={target}
436
604
  >
437
605
  {body}
@@ -452,48 +620,70 @@ export const ListItem = forwardRef<HTMLElement, ListItemProps>(function ListItem
452
620
  );
453
621
  }
454
622
 
623
+ const leadingActionsEl =
624
+ leadingActions != null ? (
625
+ <div className={cn("flex shrink-0 items-center", pad.gap, pad.leadingActions)}>{leadingActions}</div>
626
+ ) : null;
455
627
  const actionsEl =
456
628
  actions != null ? <div className={cn("flex shrink-0 items-center", pad.gap, pad.actions)}>{actions}</div> : null;
457
629
 
630
+ const row = (
631
+ <div
632
+ className={cn(
633
+ "flex min-w-0 rounded-md border transition-colors",
634
+ stacked ? "flex-col" : "items-center",
635
+ // See `renderRow`: the wrapper may be inline, the row is not.
636
+ renderRow && "w-full",
637
+ selected
638
+ ? "border-[var(--brand)] bg-[var(--bg-surface-2)]"
639
+ : bordered
640
+ ? "border-[var(--border)]"
641
+ : "border-transparent",
642
+ !stacked && interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
643
+ disabled && "opacity-50",
644
+ className,
645
+ )}
646
+ >
647
+ {stacked ? (
648
+ <>
649
+ <div className="flex min-w-0 items-center">
650
+ {leadingActionsEl}
651
+ {main}
652
+ {actionsEl}
653
+ </div>
654
+ {expandable && (
655
+ <Collapse open={expanded}>
656
+ <div id={expandedId} role="group" aria-labelledby={titleId} className={cn("min-w-0", pad.body)}>
657
+ {expandedContent}
658
+ </div>
659
+ </Collapse>
660
+ )}
661
+ {hasBody && (
662
+ <div role="group" aria-labelledby={titleId} className={cn("min-w-0", pad.body, bodyClassName)}>
663
+ {children}
664
+ </div>
665
+ )}
666
+ </>
667
+ ) : (
668
+ <>
669
+ {leadingActionsEl}
670
+ {main}
671
+ {actionsEl}
672
+ </>
673
+ )}
674
+ </div>
675
+ );
676
+
458
677
  const Tag = as;
459
678
  return (
460
679
  <Tag
461
680
  className={cn(
462
681
  list.separator === "divider" && "border-b border-[var(--border)] last:border-b-0",
463
682
  list.separator === "divider" && as === "li" && "py-0.5",
683
+ renderRow && "flex flex-col",
464
684
  )}
465
685
  >
466
- <div
467
- className={cn(
468
- "flex min-w-0 rounded-md border transition-colors",
469
- hasBody ? "flex-col" : "items-center",
470
- selected
471
- ? "border-[var(--brand)] bg-[var(--bg-surface-2)]"
472
- : bordered
473
- ? "border-[var(--border)]"
474
- : "border-transparent",
475
- !hasBody && interactive && !inert && !selected && "hover:bg-[var(--bg-hover)]",
476
- disabled && "opacity-50",
477
- className,
478
- )}
479
- >
480
- {hasBody ? (
481
- <>
482
- <div className="flex min-w-0 items-center">
483
- {main}
484
- {actionsEl}
485
- </div>
486
- <div role="group" aria-labelledby={titleId} className={cn("min-w-0", pad.body, bodyClassName)}>
487
- {children}
488
- </div>
489
- </>
490
- ) : (
491
- <>
492
- {main}
493
- {actionsEl}
494
- </>
495
- )}
496
- </div>
686
+ {renderRow ? renderRow(row) : row}
497
687
  </Tag>
498
688
  );
499
689
  });
@@ -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>