@eifi1/ui-kit 0.15.5 → 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 (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 +64 -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 +77 -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
@@ -39,6 +39,37 @@ export interface LineItemCellContext<T> {
39
39
  * hidden from assistive tech (each row repeats it), so the field has to carry it:
40
40
  * `<Input aria-label={label} … />`. */
41
41
  label: string;
42
+ /** The column's own name ("Debit") for the field's VISIBLE label — set only when the
43
+ * cell's labels are `"floating"` (see {@link LineItemsProps.fieldLabels}), so one
44
+ * render serves both modes: `<Input label={fieldLabel} aria-label={label} … />`.
45
+ * Undefined there, the Input stays a bare compact control; set, it floats "Debit"
46
+ * while `aria-label` still says which row. */
47
+ fieldLabel?: string;
48
+ }
49
+
50
+ /** How a cell's field is labelled. `aria`: the field is a bare control named by
51
+ * `aria-label`, under a header row (stacked: a small copy of the header over it).
52
+ * `floating`: the field shows its own floating label ({@link LineItemCellContext.fieldLabel}),
53
+ * so the list draws no header for it. */
54
+ export type LineItemsFieldLabels = "aria" | "floating";
55
+
56
+ /** The colour of a {@link LineItemsSummary}'s value. */
57
+ export type LineItemsSummaryTone = "neutral" | "success" | "danger" | "muted";
58
+
59
+ /**
60
+ * A line under the lines that says where they stand — a split's remainder ("Left to
61
+ * assign CHF 0.00", green at zero, red otherwise, muted while the total is not known
62
+ * yet), with an optional action that settles it ("Set amount to total").
63
+ */
64
+ export interface LineItemsSummary {
65
+ label: ReactNode;
66
+ value?: ReactNode;
67
+ /** 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. */
69
+ tone?: LineItemsSummaryTone;
70
+ /** 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 };
42
73
  }
43
74
 
44
75
  export interface LineItemsColumn<T> {
@@ -54,6 +85,13 @@ export interface LineItemsColumn<T> {
54
85
  width?: string;
55
86
  /** `end` for figures: the header and the totals cell end-align with the field. */
56
87
  align?: "start" | "end";
88
+ /** This column's labelling, over the list's {@link LineItemsProps.fieldLabels} — a
89
+ * read-only "Sum" cell in a floating list keeps its header with `"aria"`. */
90
+ fieldLabels?: LineItemsFieldLabels;
91
+ /** With {@link LineItemsProps.narrowColumns} `2`: `2` gives the cell a whole row of
92
+ * the stacked card (a description), where short fields pair up two to a row.
93
+ * Default `1`. No effect on the wide grid. */
94
+ narrowSpan?: 1 | 2;
57
95
  render: (ctx: LineItemCellContext<T>) => ReactNode;
58
96
  }
59
97
 
@@ -93,12 +131,30 @@ export interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<"div">,
93
131
  empty?: ReactNode;
94
132
  /** A message about the list as a whole ("The entry does not balance"). */
95
133
  error?: ReactNode;
134
+ /** A line under the lines and totals — the remainder of a split, say. See
135
+ * {@link LineItemsSummary}. */
136
+ summary?: LineItemsSummary;
137
+ /** How the cells' fields are labelled. Default `aria` (compact bare controls under a
138
+ * header row); `floating` hands each cell its column's name as
139
+ * {@link LineItemCellContext.fieldLabel} for a visible floating label, and drops the
140
+ * header for those columns. A column's own `fieldLabels` wins. */
141
+ fieldLabels?: LineItemsFieldLabels;
142
+ /** Stacked (a narrow pane), how many fields share a row of a line's card: `2` pairs
143
+ * them up (a column's `narrowSpan={2}` takes a whole row), once the pane is wide
144
+ * enough for two fields (18rem); narrower still, one. Default `1`. */
145
+ narrowColumns?: 1 | 2;
96
146
  /** Disables add and remove (the fields are the caller's to disable). */
97
147
  disabled?: boolean;
98
148
  labels?: Partial<LineItemsLabels>;
99
149
  }
100
150
 
101
151
  const REMOVE_TRACK = "2.25rem";
152
+ const SUMMARY_TONE: Record<LineItemsSummaryTone, string> = {
153
+ neutral: "text-[var(--text-primary)]",
154
+ success: "text-[var(--success)]",
155
+ danger: "text-[var(--danger)]",
156
+ muted: "text-[var(--text-muted)]",
157
+ };
102
158
  const FOCUSABLE =
103
159
  "input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])";
104
160
 
@@ -158,6 +214,9 @@ export function LineItems<T>({
158
214
  totalsLabel,
159
215
  empty,
160
216
  error,
217
+ summary,
218
+ fieldLabels = "aria",
219
+ narrowColumns = 1,
161
220
  disabled = false,
162
221
  labels: labelsProp,
163
222
  className,
@@ -228,6 +287,11 @@ export function LineItems<T>({
228
287
 
229
288
  const columnName = (column: LineItemsColumn<T>) =>
230
289
  column.label ?? (typeof column.header === "string" ? column.header : column.key);
290
+ const floats = (column: LineItemsColumn<T>) => (column.fieldLabels ?? fieldLabels) === "floating";
291
+ // A header row only for the columns that still need one: with every field floating
292
+ // its own label, a row of headers would say each name twice.
293
+ const showHeader = columns.some((c) => !floats(c));
294
+ const pairs = narrowColumns === 2;
231
295
 
232
296
  const headerCell = (column: LineItemsColumn<T>) => (
233
297
  <span
@@ -237,7 +301,7 @@ export function LineItems<T>({
237
301
  column.align === "end" && "text-end",
238
302
  )}
239
303
  >
240
- {column.header}
304
+ {floats(column) ? null : column.header}
241
305
  </span>
242
306
  );
243
307
 
@@ -251,10 +315,12 @@ export function LineItems<T>({
251
315
  style={{ ...gridStyle, ...style }}
252
316
  className={cn("@container space-y-2", className)}
253
317
  >
254
- <div aria-hidden className="hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]">
255
- {columns.map(headerCell)}
256
- {onRemove && <span />}
257
- </div>
318
+ {showHeader && (
319
+ <div aria-hidden className="hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]">
320
+ {columns.map(headerCell)}
321
+ {onRemove && <span />}
322
+ </div>
323
+ )}
258
324
 
259
325
  {items.length === 0 && hasContent(empty) && (
260
326
  <div className="rounded-md border border-dashed border-[var(--border)] px-3 py-4 text-center text-sm text-[var(--text-muted)]">
@@ -280,20 +346,42 @@ export function LineItems<T>({
280
346
  onKeyDown={onRowKeyDown}
281
347
  className={cn(
282
348
  "grid grid-cols-1 gap-2 @lg:grid-cols-[var(--line-items-cols)] @lg:items-start",
349
+ // Two short fields to a row of the card — from 18rem, where two fields
350
+ // still fit a figure each; on anything narrower they stack after all.
351
+ pairs && "@2xs:grid-cols-2",
283
352
  // Stacked, each row is a card of its own so the rows stay apart.
284
353
  "@max-lg:rounded-md @max-lg:border @max-lg:border-[var(--border)] @max-lg:p-3",
285
354
  )}
286
355
  >
287
- {columns.map((column) => (
288
- <div key={column.key} className="min-w-0 space-y-1 @lg:space-y-0">
289
- <span aria-hidden className="block text-xs font-medium text-[var(--text-muted)] @lg:hidden">
290
- {column.header}
291
- </span>
292
- {column.render({ item, index, label: labels.cell(columnName(column), row) })}
293
- </div>
294
- ))}
356
+ {columns.map((column) => {
357
+ const floating = floats(column);
358
+ return (
359
+ <div
360
+ key={column.key}
361
+ className={cn(
362
+ "min-w-0 space-y-1 @lg:space-y-0",
363
+ // The wide grid places every cell in its own track again.
364
+ pairs && column.narrowSpan === 2 && "@2xs:col-span-2 @lg:col-span-1",
365
+ )}
366
+ >
367
+ {!floating && (
368
+ <span aria-hidden className="block text-xs font-medium text-[var(--text-muted)] @lg:hidden">
369
+ {column.header}
370
+ </span>
371
+ )}
372
+ {column.render({
373
+ item,
374
+ index,
375
+ label: labels.cell(columnName(column), row),
376
+ fieldLabel: floating ? columnName(column) : undefined,
377
+ })}
378
+ </div>
379
+ );
380
+ })}
295
381
  {onRemove && (
296
- <div className="flex justify-end @lg:justify-center">
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")}>
297
385
  <IconButton
298
386
  type="button"
299
387
  data-line-items-remove=""
@@ -361,6 +449,35 @@ export function LineItems<T>({
361
449
  </div>
362
450
  )}
363
451
 
452
+ {summary && (
453
+ <div
454
+ data-line-items-summary=""
455
+ className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--text-primary)]"
456
+ >
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
+ >
463
+ {summary.value}
464
+ </span>
465
+ )}
466
+ {summary.action && (
467
+ <Button
468
+ type="button"
469
+ variant="secondary"
470
+ size="sm"
471
+ className="ms-auto"
472
+ disabled={disabled || summary.action.disabled}
473
+ onClick={summary.action.onClick}
474
+ >
475
+ {summary.action.label}
476
+ </Button>
477
+ )}
478
+ </div>
479
+ )}
480
+
364
481
  {hasError && (
365
482
  <p className="text-[11px] leading-tight text-[var(--danger)]">
366
483
  {error}
@@ -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
  });