@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.
- package/README.md +17 -12
- package/dist/chart.d.ts +3 -3
- package/dist/components/amount-input.d.ts +10 -3
- package/dist/components/amount-input.js +165 -156
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.d.ts +14 -3
- package/dist/components/authed-image.js +32 -23
- package/dist/components/authed-image.js.map +1 -1
- package/dist/components/button-group.d.ts +3 -3
- package/dist/components/calculator.d.ts +3 -3
- package/dist/components/copy-button.d.ts +3 -3
- package/dist/components/facing-pair.d.ts +3 -3
- package/dist/components/file-button.d.ts +3 -3
- package/dist/components/file-button.js +67 -31
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +3 -3
- package/dist/components/form-actions.d.ts +4 -4
- package/dist/components/form-actions.js +18 -6
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/line-items.d.ts +53 -2
- package/dist/components/line-items.js +73 -7
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +99 -5
- package/dist/components/list.js +103 -38
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-state.d.ts +19 -3
- package/dist/components/loading-state.js +14 -3
- package/dist/components/loading-state.js.map +1 -1
- package/dist/components/money-field.d.ts +3 -3
- package/dist/components/number-field.d.ts +3 -3
- package/dist/components/number-input.d.ts +3 -3
- package/dist/components/number-input.js +4 -2
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +3 -3
- package/dist/components/passkeys-setting.d.ts +30 -10
- package/dist/components/passkeys-setting.js +19 -11
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +12 -3
- package/dist/components/progress-bar.js +21 -11
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart.d.ts +3 -3
- package/dist/components/settings-fields.d.ts +3 -3
- package/dist/components/signed-amount.d.ts +28 -4
- package/dist/components/signed-amount.js +17 -8
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/stat-tile.d.ts +31 -2
- package/dist/components/stat-tile.js +37 -6
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/status-dot.d.ts +4 -1
- package/dist/components/status-dot.js +20 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +3 -1
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/table.d.ts +18 -13
- package/dist/components/table.js +20 -5
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +3 -3
- package/dist/components/time-input.d.ts +3 -3
- package/dist/components/toggle-legend.d.ts +12 -1
- package/dist/components/toggle-legend.js +6 -2
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/ui.d.ts +3 -3
- package/dist/components/ui.js +149 -45
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +1 -1
- package/dist/feedback/feedback-attachment.js +24 -11
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +1 -1
- package/dist/feedback/feedback-dialog.js +6 -5
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +1 -1
- package/dist/feedback/feedback-thread.d.ts +1 -1
- package/dist/{feedback-attachment-BYAHJMOr.d.ts → feedback-attachment-WFiXJ8We.d.ts} +21 -8
- package/dist/feedback.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +3 -3
- package/dist/i18n/defaults.d.ts +3 -3
- package/dist/i18n/kit-labels.d.ts +2 -2
- package/dist/i18n/locales/de-CH-informal.d.ts +3 -3
- package/dist/i18n/locales/de-CH.d.ts +3 -3
- package/dist/i18n/locales/de-informal.d.ts +3 -3
- package/dist/i18n/locales/de.d.ts +3 -3
- package/dist/i18n/locales/de.js +1 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -3
- package/dist/i18n/locales/es.js +1 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -3
- package/dist/i18n/locales/fr.js +1 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -3
- package/dist/i18n/locales/hu.js +1 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -3
- package/dist/i18n/locales/it.js +1 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -3
- package/dist/i18n/locales/zh.js +1 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/{kit-labels-BD2F7sOz.d.ts → kit-labels-DE--LmCk.d.ts} +153 -12
- package/dist/lib/format.d.ts +18 -3
- package/dist/lib/format.js +2 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/rhf/fields.d.ts +3 -3
- package/dist/rhf/form.d.ts +3 -3
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +3 -3
- package/dist/shell/app-shell.d.ts +3 -3
- package/dist/shell/top-bar-brand.d.ts +3 -3
- package/dist/shell.d.ts +3 -3
- package/dist/wizard/stepper-nav.d.ts +3 -3
- package/dist/wizard.d.ts +3 -3
- package/package.json +1 -1
- package/src/components/amount-input.tsx +39 -15
- package/src/components/authed-image.tsx +20 -2
- package/src/components/file-button.tsx +85 -27
- package/src/components/form-actions.tsx +86 -8
- package/src/components/line-items.tsx +131 -14
- package/src/components/list.tsx +240 -50
- package/src/components/loading-state.tsx +35 -4
- package/src/components/number-input.tsx +18 -2
- package/src/components/passkeys-setting.tsx +45 -18
- package/src/components/progress-bar.tsx +46 -21
- package/src/components/signed-amount.tsx +47 -8
- package/src/components/stat-tile.tsx +76 -6
- package/src/components/status-dot.tsx +31 -0
- package/src/components/swatch-picker.tsx +3 -1
- package/src/components/table.tsx +42 -15
- package/src/components/toggle-legend.tsx +21 -4
- package/src/components/ui.tsx +273 -63
- package/src/feedback/feedback-attachment.tsx +45 -14
- package/src/feedback/feedback-dialog.tsx +31 -16
- package/src/i18n/locales/de.ts +1 -1
- package/src/i18n/locales/es.ts +1 -1
- package/src/i18n/locales/fr.ts +1 -1
- package/src/i18n/locales/hu.ts +1 -1
- package/src/i18n/locales/it.ts +1 -1
- package/src/i18n/locales/zh.ts +1 -1
- package/src/lib/format.ts +24 -3
- 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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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}
|
package/src/components/list.tsx
CHANGED
|
@@ -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<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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={
|
|
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 &&
|
|
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 &&
|
|
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
|
-
//
|
|
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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|