@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
|
@@ -14,6 +14,12 @@ const DEFAULT_LINE_ITEMS_LABELS = {
|
|
|
14
14
|
totals: "Total"
|
|
15
15
|
};
|
|
16
16
|
const REMOVE_TRACK = "2.25rem";
|
|
17
|
+
const SUMMARY_TONE = {
|
|
18
|
+
neutral: "text-[var(--text-primary)]",
|
|
19
|
+
success: "text-[var(--success)]",
|
|
20
|
+
danger: "text-[var(--danger)]",
|
|
21
|
+
muted: "text-[var(--text-muted)]"
|
|
22
|
+
};
|
|
17
23
|
const FOCUSABLE = "input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])";
|
|
18
24
|
function defaultKey(item, index) {
|
|
19
25
|
if (typeof item === "object" && item !== null && "id" in item) {
|
|
@@ -40,6 +46,9 @@ function LineItems({
|
|
|
40
46
|
totalsLabel,
|
|
41
47
|
empty,
|
|
42
48
|
error,
|
|
49
|
+
summary,
|
|
50
|
+
fieldLabels = "aria",
|
|
51
|
+
narrowColumns = 1,
|
|
43
52
|
disabled = false,
|
|
44
53
|
labels: labelsProp,
|
|
45
54
|
className,
|
|
@@ -96,6 +105,9 @@ function LineItems({
|
|
|
96
105
|
}
|
|
97
106
|
};
|
|
98
107
|
const columnName = (column) => column.label ?? (typeof column.header === "string" ? column.header : column.key);
|
|
108
|
+
const floats = (column) => (column.fieldLabels ?? fieldLabels) === "floating";
|
|
109
|
+
const showHeader = columns.some((c) => !floats(c));
|
|
110
|
+
const pairs = narrowColumns === 2;
|
|
99
111
|
const headerCell = (column) => /* @__PURE__ */ jsx(
|
|
100
112
|
"span",
|
|
101
113
|
{
|
|
@@ -103,7 +115,7 @@ function LineItems({
|
|
|
103
115
|
"min-w-0 truncate text-xs font-medium text-[var(--text-muted)]",
|
|
104
116
|
column.align === "end" && "text-end"
|
|
105
117
|
),
|
|
106
|
-
children: column.header
|
|
118
|
+
children: floats(column) ? null : column.header
|
|
107
119
|
},
|
|
108
120
|
column.key
|
|
109
121
|
);
|
|
@@ -117,7 +129,7 @@ function LineItems({
|
|
|
117
129
|
style: { ...gridStyle, ...style },
|
|
118
130
|
className: cn("@container space-y-2", className),
|
|
119
131
|
children: [
|
|
120
|
-
/* @__PURE__ */ jsxs("div", { "aria-hidden": true, className: "hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]", children: [
|
|
132
|
+
showHeader && /* @__PURE__ */ jsxs("div", { "aria-hidden": true, className: "hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]", children: [
|
|
121
133
|
columns.map(headerCell),
|
|
122
134
|
onRemove && /* @__PURE__ */ jsx("span", {})
|
|
123
135
|
] }),
|
|
@@ -141,15 +153,39 @@ function LineItems({
|
|
|
141
153
|
onKeyDown: onRowKeyDown,
|
|
142
154
|
className: cn(
|
|
143
155
|
"grid grid-cols-1 gap-2 @lg:grid-cols-[var(--line-items-cols)] @lg:items-start",
|
|
156
|
+
// Two short fields to a row of the card — from 18rem, where two fields
|
|
157
|
+
// still fit a figure each; on anything narrower they stack after all.
|
|
158
|
+
pairs && "@2xs:grid-cols-2",
|
|
144
159
|
// Stacked, each row is a card of its own so the rows stay apart.
|
|
145
160
|
"@max-lg:rounded-md @max-lg:border @max-lg:border-[var(--border)] @max-lg:p-3"
|
|
146
161
|
),
|
|
147
162
|
children: [
|
|
148
|
-
columns.map((column) =>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
163
|
+
columns.map((column) => {
|
|
164
|
+
const floating = floats(column);
|
|
165
|
+
return /* @__PURE__ */ jsxs(
|
|
166
|
+
"div",
|
|
167
|
+
{
|
|
168
|
+
className: cn(
|
|
169
|
+
"min-w-0 space-y-1 @lg:space-y-0",
|
|
170
|
+
// The wide grid places every cell in its own track again.
|
|
171
|
+
pairs && column.narrowSpan === 2 && "@2xs:col-span-2 @lg:col-span-1"
|
|
172
|
+
),
|
|
173
|
+
children: [
|
|
174
|
+
!floating && /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "block text-xs font-medium text-[var(--text-muted)] @lg:hidden", children: column.header }),
|
|
175
|
+
column.render({
|
|
176
|
+
item,
|
|
177
|
+
index,
|
|
178
|
+
label: labels.cell(columnName(column), row),
|
|
179
|
+
fieldLabel: floating ? columnName(column) : void 0
|
|
180
|
+
})
|
|
181
|
+
]
|
|
182
|
+
},
|
|
183
|
+
column.key
|
|
184
|
+
);
|
|
185
|
+
}),
|
|
186
|
+
onRemove && // Paired, the remove button keeps a row of its own at the card's end
|
|
187
|
+
// rather than squeezing in beside the last field.
|
|
188
|
+
/* @__PURE__ */ jsx("div", { className: cn("flex justify-end @lg:justify-center", pairs && "@2xs:col-span-2 @lg:col-span-1"), children: /* @__PURE__ */ jsx(
|
|
153
189
|
IconButton,
|
|
154
190
|
{
|
|
155
191
|
type: "button",
|
|
@@ -211,6 +247,36 @@ function LineItems({
|
|
|
211
247
|
]
|
|
212
248
|
}
|
|
213
249
|
),
|
|
250
|
+
summary && /* @__PURE__ */ jsxs(
|
|
251
|
+
"div",
|
|
252
|
+
{
|
|
253
|
+
"data-line-items-summary": "",
|
|
254
|
+
className: "flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--text-primary)]",
|
|
255
|
+
children: [
|
|
256
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0", children: summary.label }),
|
|
257
|
+
hasContent(summary.value) && /* @__PURE__ */ jsx(
|
|
258
|
+
"span",
|
|
259
|
+
{
|
|
260
|
+
"data-tone": summary.tone ?? "neutral",
|
|
261
|
+
className: cn("font-semibold tabular-nums", SUMMARY_TONE[summary.tone ?? "neutral"]),
|
|
262
|
+
children: summary.value
|
|
263
|
+
}
|
|
264
|
+
),
|
|
265
|
+
summary.action && /* @__PURE__ */ jsx(
|
|
266
|
+
Button,
|
|
267
|
+
{
|
|
268
|
+
type: "button",
|
|
269
|
+
variant: "secondary",
|
|
270
|
+
size: "sm",
|
|
271
|
+
className: "ms-auto",
|
|
272
|
+
disabled: disabled || summary.action.disabled,
|
|
273
|
+
onClick: summary.action.onClick,
|
|
274
|
+
children: summary.action.label
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
),
|
|
214
280
|
hasError && /* @__PURE__ */ jsx("p", { className: "text-[11px] leading-tight text-[var(--danger)]", children: error }),
|
|
215
281
|
onAdd && /* @__PURE__ */ jsxs(
|
|
216
282
|
Button,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/line-items.tsx"],"sourcesContent":["import { useCallback, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, Key, ReactNode } from \"react\";\nimport { Plus, Trash2 } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, IconButton } from \"./ui\";\n\n/** The words {@link LineItems} renders on its own behalf. Row numbers are 1-based. */\nexport interface LineItemsLabels {\n /** The dashed add button. */\n add: string;\n /** The remove button's name for one row. */\n remove: (row: number) => string;\n /** Its name while it waits for the second press (`confirmRemove`). */\n confirmRemove: (row: number) => string;\n /** A row's group name, when the caller gives no `rowLabel`. */\n row: (row: number) => string;\n /** The name a cell's field should carry: the column, and which row. */\n cell: (column: string, row: number) => string;\n /** The totals row's label, and its group name. */\n totals: string;\n}\n\nexport const DEFAULT_LINE_ITEMS_LABELS: LineItemsLabels = {\n add: \"Add row\",\n remove: (row) => `Remove row ${row}`,\n confirmRemove: (row) => `Remove row ${row}? Press again to confirm`,\n row: (row) => `Row ${row}`,\n cell: (column, row) => `${column}, row ${row}`,\n totals: \"Total\",\n};\n\n/** What a column's `render` receives for one row. */\nexport interface LineItemCellContext<T> {\n item: T;\n /** 0-based. */\n index: number;\n /** The accessible name for the cell's field — \"Debit, row 2\". The header is\n * hidden from assistive tech (each row repeats it), so the field has to carry it:\n * `<Input aria-label={label} … />`. */\n label: string;\n}\n\nexport interface LineItemsColumn<T> {\n /** Stable id: the React key, and the key into `totals`. */\n key: string;\n /** The column header (and the small label over the cell once the rows stack). */\n header: ReactNode;\n /** The column's name for {@link LineItemCellContext.label}, when `header` is not a\n * string. */\n label?: string;\n /** The grid track once the rows are wide enough to be a table. Default\n * `minmax(0,1fr)`; `\"7rem\"` for an amount. */\n width?: string;\n /** `end` for figures: the header and the totals cell end-align with the field. */\n align?: \"start\" | \"end\";\n render: (ctx: LineItemCellContext<T>) => ReactNode;\n}\n\nexport interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n items: readonly T[];\n columns: readonly LineItemsColumn<T>[];\n /** The row's React key. Default: the item's `id` (react-hook-form's `useFieldArray`\n * `fields` carry one), else its index. */\n getKey?: (item: T, index: number) => Key;\n /** Append a row. Omitted: no add button. The first field of the new row is focused. */\n onAdd?: () => void;\n /** Remove the row at `index`. Omitted: no remove buttons. */\n onRemove?: (index: number) => void;\n /** Remove is disabled at or below this many rows (a journal entry needs two).\n * Default 0. */\n minItems?: number;\n /** Add is disabled at this many rows. */\n maxItems?: number;\n /**\n * Ask before a row goes:\n * - `true`: the remove button asks for a second press — it fills red and its name\n * becomes `lineItems.confirmRemove`; Escape, or leaving it, calls it off.\n * - a function: called with the row, and the row goes only if it answers `true`\n * (`(item, i) => confirm({ title: … , tone: \"danger\" })` with `useConfirm`).\n */\n confirmRemove?: boolean | ((item: T, index: number) => boolean | Promise<boolean>);\n /** The add button's text. Default `lineItems.add`. */\n addLabel?: ReactNode;\n /** A row's group name (\"Line 2\"). Default `lineItems.row`. */\n rowLabel?: (index: number, item: T) => string;\n /** A totals row under the lines, one entry per column `key`. The first column\n * without an entry carries {@link totalsLabel}. Omitted: no totals row. */\n totals?: Partial<Record<string, ReactNode>>;\n /** Default `lineItems.totals`, \"Total\". */\n totalsLabel?: ReactNode;\n /** Shown in place of the rows when there are none. */\n empty?: ReactNode;\n /** A message about the list as a whole (\"The entry does not balance\"). */\n error?: ReactNode;\n /** Disables add and remove (the fields are the caller's to disable). */\n disabled?: boolean;\n labels?: Partial<LineItemsLabels>;\n}\n\nconst REMOVE_TRACK = \"2.25rem\";\nconst FOCUSABLE =\n \"input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])\";\n\nfunction defaultKey(item: unknown, index: number): Key {\n if (typeof item === \"object\" && item !== null && \"id\" in item) {\n const id = (item as { id: unknown }).id;\n if (typeof id === \"string\" || typeof id === \"number\") return id;\n }\n return index;\n}\n\nfunction hasContent(node: ReactNode): boolean {\n return node !== undefined && node !== null && node !== false && node !== \"\";\n}\n\ntype PendingFocus = { kind: \"added\" } | { kind: \"removed\"; index: number };\n\n/**\n * An editable list of lines — journal entry lines, lease components, invoice\n * positions, budget rows: column headers, a row of fields per item, a remove button\n * per row, a dashed \"add\" button under them, and an optional totals row.\n *\n * Presentation only: the rows are the caller's `items` and the fields are the\n * caller's, rendered per column. So it works over plain state —\n *\n * ```tsx\n * <LineItems items={lines} columns={cols} onAdd={() => setLines([...lines, blank()])}\n * onRemove={(i) => setLines(lines.filter((_, j) => j !== i))} minItems={2} />\n * ```\n *\n * — and over react-hook-form's `useFieldArray` alike (`items={fields}`, `onAdd={() =>\n * append(blank())}`, `onRemove={remove}`), for which `@eifi1/ui-kit/rhf` has\n * `RhfLineItems`.\n *\n * On a wide pane the rows are a grid under the headers; on a narrow one (a container\n * query, so a dialog stacks where it is cramped) each row stacks its fields, each\n * under a small copy of its header. The headers are `aria-hidden`: every row is a\n * named group and every field is named by `render`'s `label` (\"Debit, row 2\"), which\n * is what a screen reader needs — a header row it would read once, out of context.\n *\n * Keyboard: after Add, focus lands in the new row's first field; after a remove, on\n * the row that took its place (else the add button). Ctrl/⌘+Enter in any row adds a\n * row.\n */\nexport function LineItems<T>({\n items,\n columns,\n getKey = defaultKey,\n onAdd,\n onRemove,\n minItems = 0,\n maxItems,\n confirmRemove,\n addLabel,\n rowLabel,\n totals,\n totalsLabel,\n empty,\n error,\n disabled = false,\n labels: labelsProp,\n className,\n style,\n ...rest\n}: LineItemsProps<T>) {\n const labels = useKitLabels(\"lineItems\", DEFAULT_LINE_ITEMS_LABELS, labelsProp);\n const rowRefs = useRef<Array<HTMLDivElement | null>>([]);\n const addRef = useRef<HTMLButtonElement>(null);\n const pendingFocus = useRef<PendingFocus | null>(null);\n const [confirmingAt, setConfirming] = useState<number | null>(null);\n // A confirm waiting on a row that has since gone is void.\n const confirming = confirmingAt !== null && confirmingAt < items.length ? confirmingAt : null;\n\n const tracks = [...columns.map((c) => c.width ?? \"minmax(0,1fr)\"), ...(onRemove ? [REMOVE_TRACK] : [])];\n const gridStyle = { \"--line-items-cols\": tracks.join(\" \") } as CSSProperties;\n const canAdd = Boolean(onAdd) && !disabled && (maxItems === undefined || items.length < maxItems);\n const canRemove = Boolean(onRemove) && !disabled && items.length > minItems;\n const hasError = hasContent(error);\n\n\n // Focus after the list has re-rendered with the new rows — a layout effect, so the\n // caret is where it belongs before paint.\n useLayoutEffect(() => {\n const pending = pendingFocus.current;\n if (!pending) return;\n pendingFocus.current = null;\n if (pending.kind === \"added\") {\n const row = rowRefs.current[items.length - 1];\n row?.querySelector<HTMLElement>(FOCUSABLE)?.focus();\n return;\n }\n const row = rowRefs.current[Math.min(pending.index, items.length - 1)];\n const target =\n row?.querySelector<HTMLElement>(\"[data-line-items-remove]:not([disabled])\") ??\n row?.querySelector<HTMLElement>(FOCUSABLE) ??\n addRef.current;\n target?.focus();\n }, [items.length]);\n\n const add = useCallback(() => {\n if (!canAdd || !onAdd) return;\n pendingFocus.current = { kind: \"added\" };\n onAdd();\n }, [canAdd, onAdd]);\n\n const removeAt = async (index: number) => {\n if (!canRemove || !onRemove) return;\n if (confirmRemove === true && confirming !== index) {\n setConfirming(index);\n return;\n }\n if (typeof confirmRemove === \"function\") {\n const ok = await confirmRemove(items[index], index);\n if (!ok) return;\n }\n setConfirming(null);\n pendingFocus.current = { kind: \"removed\", index };\n onRemove(index);\n };\n\n const onRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key === \"Enter\" && (e.ctrlKey || e.metaKey) && canAdd) {\n e.preventDefault();\n add();\n }\n };\n\n const columnName = (column: LineItemsColumn<T>) =>\n column.label ?? (typeof column.header === \"string\" ? column.header : column.key);\n\n const headerCell = (column: LineItemsColumn<T>) => (\n <span\n key={column.key}\n className={cn(\n \"min-w-0 truncate text-xs font-medium text-[var(--text-muted)]\",\n column.align === \"end\" && \"text-end\",\n )}\n >\n {column.header}\n </span>\n );\n\n const showTotals = totals !== undefined;\n const labelColumn = showTotals ? columns.find((c) => !hasContent(totals[c.key]))?.key : undefined;\n\n return (\n <div\n data-slot=\"line-items\"\n {...rest}\n style={{ ...gridStyle, ...style }}\n className={cn(\"@container space-y-2\", className)}\n >\n <div aria-hidden className=\"hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]\">\n {columns.map(headerCell)}\n {onRemove && <span />}\n </div>\n\n {items.length === 0 && hasContent(empty) && (\n <div className=\"rounded-md border border-dashed border-[var(--border)] px-3 py-4 text-center text-sm text-[var(--text-muted)]\">\n {empty}\n </div>\n )}\n\n {items.map((item, index) => {\n const row = index + 1;\n const isConfirming = confirming === index;\n return (\n // The row's Ctrl/⌘+Enter is a shortcut for the add button, which is itself\n // reachable by Tab; the group only listens for it bubbling from its fields.\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n key={getKey(item, index)}\n ref={(el) => {\n rowRefs.current[index] = el;\n }}\n role=\"group\"\n aria-label={rowLabel ? rowLabel(index, item) : labels.row(row)}\n data-line-items-row=\"\"\n onKeyDown={onRowKeyDown}\n className={cn(\n \"grid grid-cols-1 gap-2 @lg:grid-cols-[var(--line-items-cols)] @lg:items-start\",\n // Stacked, each row is a card of its own so the rows stay apart.\n \"@max-lg:rounded-md @max-lg:border @max-lg:border-[var(--border)] @max-lg:p-3\",\n )}\n >\n {columns.map((column) => (\n <div key={column.key} className=\"min-w-0 space-y-1 @lg:space-y-0\">\n <span aria-hidden className=\"block text-xs font-medium text-[var(--text-muted)] @lg:hidden\">\n {column.header}\n </span>\n {column.render({ item, index, label: labels.cell(columnName(column), row) })}\n </div>\n ))}\n {onRemove && (\n <div className=\"flex justify-end @lg:justify-center\">\n <IconButton\n type=\"button\"\n data-line-items-remove=\"\"\n variant={isConfirming ? \"danger\" : \"ghost\"}\n tone={isConfirming ? undefined : \"danger\"}\n label={isConfirming ? labels.confirmRemove(row) : labels.remove(row)}\n disabled={!canRemove}\n onClick={() => void removeAt(index)}\n onBlur={() => {\n if (isConfirming) setConfirming(null);\n }}\n onKeyDown={(e) => {\n if (e.key === \"Escape\" && isConfirming) {\n e.stopPropagation();\n setConfirming(null);\n }\n }}\n >\n <Trash2 />\n </IconButton>\n </div>\n )}\n </div>\n );\n })}\n\n {showTotals && (\n <div\n role=\"group\"\n aria-label={typeof totalsLabel === \"string\" ? totalsLabel : labels.totals}\n data-line-items-totals=\"\"\n className=\"grid grid-cols-1 gap-2 border-t border-[var(--border)] pt-2 text-sm font-semibold tabular-nums text-[var(--text-primary)] @lg:grid-cols-[var(--line-items-cols)]\"\n >\n {columns.map((column) => {\n const value = totals[column.key];\n if (column.key === labelColumn) {\n return (\n <span key={column.key} className=\"min-w-0\">\n {totalsLabel ?? labels.totals}\n </span>\n );\n }\n return (\n <span\n key={column.key}\n className={cn(\n \"min-w-0\",\n column.align === \"end\" && \"@lg:text-end\",\n // Stacked, a column with no total takes no line.\n !hasContent(value) && \"@max-lg:hidden\",\n )}\n >\n {hasContent(value) && (\n <span className=\"flex justify-between gap-2 @lg:block\">\n <span aria-hidden className=\"font-normal text-[var(--text-muted)] @lg:hidden\">\n {column.header}\n </span>\n {value}\n </span>\n )}\n </span>\n );\n })}\n {onRemove && <span className=\"@max-lg:hidden\" />}\n </div>\n )}\n\n {hasError && (\n <p className=\"text-[11px] leading-tight text-[var(--danger)]\">\n {error}\n </p>\n )}\n\n {onAdd && (\n <Button\n ref={addRef}\n type=\"button\"\n variant=\"secondary\"\n className=\"w-full border-dashed\"\n disabled={!canAdd}\n onClick={add}\n >\n <Plus aria-hidden className=\"size-4\" />\n {addLabel ?? labels.add}\n </Button>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["row"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/line-items.tsx"],"sourcesContent":["import { useCallback, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, KeyboardEvent, Key, ReactNode } from \"react\";\nimport { Plus, Trash2 } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, IconButton } from \"./ui\";\n\n/** The words {@link LineItems} renders on its own behalf. Row numbers are 1-based. */\nexport interface LineItemsLabels {\n /** The dashed add button. */\n add: string;\n /** The remove button's name for one row. */\n remove: (row: number) => string;\n /** Its name while it waits for the second press (`confirmRemove`). */\n confirmRemove: (row: number) => string;\n /** A row's group name, when the caller gives no `rowLabel`. */\n row: (row: number) => string;\n /** The name a cell's field should carry: the column, and which row. */\n cell: (column: string, row: number) => string;\n /** The totals row's label, and its group name. */\n totals: string;\n}\n\nexport const DEFAULT_LINE_ITEMS_LABELS: LineItemsLabels = {\n add: \"Add row\",\n remove: (row) => `Remove row ${row}`,\n confirmRemove: (row) => `Remove row ${row}? Press again to confirm`,\n row: (row) => `Row ${row}`,\n cell: (column, row) => `${column}, row ${row}`,\n totals: \"Total\",\n};\n\n/** What a column's `render` receives for one row. */\nexport interface LineItemCellContext<T> {\n item: T;\n /** 0-based. */\n index: number;\n /** The accessible name for the cell's field — \"Debit, row 2\". The header is\n * hidden from assistive tech (each row repeats it), so the field has to carry it:\n * `<Input aria-label={label} … />`. */\n label: string;\n /** The column's own name (\"Debit\") for the field's VISIBLE label — set only when the\n * cell's labels are `\"floating\"` (see {@link LineItemsProps.fieldLabels}), so one\n * render serves both modes: `<Input label={fieldLabel} aria-label={label} … />`.\n * Undefined there, the Input stays a bare compact control; set, it floats \"Debit\"\n * while `aria-label` still says which row. */\n fieldLabel?: string;\n}\n\n/** How a cell's field is labelled. `aria`: the field is a bare control named by\n * `aria-label`, under a header row (stacked: a small copy of the header over it).\n * `floating`: the field shows its own floating label ({@link LineItemCellContext.fieldLabel}),\n * so the list draws no header for it. */\nexport type LineItemsFieldLabels = \"aria\" | \"floating\";\n\n/** The colour of a {@link LineItemsSummary}'s value. */\nexport type LineItemsSummaryTone = \"neutral\" | \"success\" | \"danger\" | \"muted\";\n\n/**\n * A line under the lines that says where they stand — a split's remainder (\"Left to\n * assign CHF 0.00\", green at zero, red otherwise, muted while the total is not known\n * yet), with an optional action that settles it (\"Set amount to total\").\n */\nexport interface LineItemsSummary {\n label: ReactNode;\n value?: ReactNode;\n /** The value's colour. Default `neutral`. The label stays neutral: the colour is a\n * second signal, never the only one — the value and label carry the meaning. */\n tone?: LineItemsSummaryTone;\n /** A small button at the end of the line. Disabled along with the list's\n * `disabled`. */\n action?: { label: ReactNode; onClick: () => void; disabled?: boolean };\n}\n\nexport interface LineItemsColumn<T> {\n /** Stable id: the React key, and the key into `totals`. */\n key: string;\n /** The column header (and the small label over the cell once the rows stack). */\n header: ReactNode;\n /** The column's name for {@link LineItemCellContext.label}, when `header` is not a\n * string. */\n label?: string;\n /** The grid track once the rows are wide enough to be a table. Default\n * `minmax(0,1fr)`; `\"7rem\"` for an amount. */\n width?: string;\n /** `end` for figures: the header and the totals cell end-align with the field. */\n align?: \"start\" | \"end\";\n /** This column's labelling, over the list's {@link LineItemsProps.fieldLabels} — a\n * read-only \"Sum\" cell in a floating list keeps its header with `\"aria\"`. */\n fieldLabels?: LineItemsFieldLabels;\n /** With {@link LineItemsProps.narrowColumns} `2`: `2` gives the cell a whole row of\n * the stacked card (a description), where short fields pair up two to a row.\n * Default `1`. No effect on the wide grid. */\n narrowSpan?: 1 | 2;\n render: (ctx: LineItemCellContext<T>) => ReactNode;\n}\n\nexport interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n items: readonly T[];\n columns: readonly LineItemsColumn<T>[];\n /** The row's React key. Default: the item's `id` (react-hook-form's `useFieldArray`\n * `fields` carry one), else its index. */\n getKey?: (item: T, index: number) => Key;\n /** Append a row. Omitted: no add button. The first field of the new row is focused. */\n onAdd?: () => void;\n /** Remove the row at `index`. Omitted: no remove buttons. */\n onRemove?: (index: number) => void;\n /** Remove is disabled at or below this many rows (a journal entry needs two).\n * Default 0. */\n minItems?: number;\n /** Add is disabled at this many rows. */\n maxItems?: number;\n /**\n * Ask before a row goes:\n * - `true`: the remove button asks for a second press — it fills red and its name\n * becomes `lineItems.confirmRemove`; Escape, or leaving it, calls it off.\n * - a function: called with the row, and the row goes only if it answers `true`\n * (`(item, i) => confirm({ title: … , tone: \"danger\" })` with `useConfirm`).\n */\n confirmRemove?: boolean | ((item: T, index: number) => boolean | Promise<boolean>);\n /** The add button's text. Default `lineItems.add`. */\n addLabel?: ReactNode;\n /** A row's group name (\"Line 2\"). Default `lineItems.row`. */\n rowLabel?: (index: number, item: T) => string;\n /** A totals row under the lines, one entry per column `key`. The first column\n * without an entry carries {@link totalsLabel}. Omitted: no totals row. */\n totals?: Partial<Record<string, ReactNode>>;\n /** Default `lineItems.totals`, \"Total\". */\n totalsLabel?: ReactNode;\n /** Shown in place of the rows when there are none. */\n empty?: ReactNode;\n /** A message about the list as a whole (\"The entry does not balance\"). */\n error?: ReactNode;\n /** A line under the lines and totals — the remainder of a split, say. See\n * {@link LineItemsSummary}. */\n summary?: LineItemsSummary;\n /** How the cells' fields are labelled. Default `aria` (compact bare controls under a\n * header row); `floating` hands each cell its column's name as\n * {@link LineItemCellContext.fieldLabel} for a visible floating label, and drops the\n * header for those columns. A column's own `fieldLabels` wins. */\n fieldLabels?: LineItemsFieldLabels;\n /** Stacked (a narrow pane), how many fields share a row of a line's card: `2` pairs\n * them up (a column's `narrowSpan={2}` takes a whole row), once the pane is wide\n * enough for two fields (18rem); narrower still, one. Default `1`. */\n narrowColumns?: 1 | 2;\n /** Disables add and remove (the fields are the caller's to disable). */\n disabled?: boolean;\n labels?: Partial<LineItemsLabels>;\n}\n\nconst REMOVE_TRACK = \"2.25rem\";\nconst SUMMARY_TONE: Record<LineItemsSummaryTone, string> = {\n neutral: \"text-[var(--text-primary)]\",\n success: \"text-[var(--success)]\",\n danger: \"text-[var(--danger)]\",\n muted: \"text-[var(--text-muted)]\",\n};\nconst FOCUSABLE =\n \"input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])\";\n\nfunction defaultKey(item: unknown, index: number): Key {\n if (typeof item === \"object\" && item !== null && \"id\" in item) {\n const id = (item as { id: unknown }).id;\n if (typeof id === \"string\" || typeof id === \"number\") return id;\n }\n return index;\n}\n\nfunction hasContent(node: ReactNode): boolean {\n return node !== undefined && node !== null && node !== false && node !== \"\";\n}\n\ntype PendingFocus = { kind: \"added\" } | { kind: \"removed\"; index: number };\n\n/**\n * An editable list of lines — journal entry lines, lease components, invoice\n * positions, budget rows: column headers, a row of fields per item, a remove button\n * per row, a dashed \"add\" button under them, and an optional totals row.\n *\n * Presentation only: the rows are the caller's `items` and the fields are the\n * caller's, rendered per column. So it works over plain state —\n *\n * ```tsx\n * <LineItems items={lines} columns={cols} onAdd={() => setLines([...lines, blank()])}\n * onRemove={(i) => setLines(lines.filter((_, j) => j !== i))} minItems={2} />\n * ```\n *\n * — and over react-hook-form's `useFieldArray` alike (`items={fields}`, `onAdd={() =>\n * append(blank())}`, `onRemove={remove}`), for which `@eifi1/ui-kit/rhf` has\n * `RhfLineItems`.\n *\n * On a wide pane the rows are a grid under the headers; on a narrow one (a container\n * query, so a dialog stacks where it is cramped) each row stacks its fields, each\n * under a small copy of its header. The headers are `aria-hidden`: every row is a\n * named group and every field is named by `render`'s `label` (\"Debit, row 2\"), which\n * is what a screen reader needs — a header row it would read once, out of context.\n *\n * Keyboard: after Add, focus lands in the new row's first field; after a remove, on\n * the row that took its place (else the add button). Ctrl/⌘+Enter in any row adds a\n * row.\n */\nexport function LineItems<T>({\n items,\n columns,\n getKey = defaultKey,\n onAdd,\n onRemove,\n minItems = 0,\n maxItems,\n confirmRemove,\n addLabel,\n rowLabel,\n totals,\n totalsLabel,\n empty,\n error,\n summary,\n fieldLabels = \"aria\",\n narrowColumns = 1,\n disabled = false,\n labels: labelsProp,\n className,\n style,\n ...rest\n}: LineItemsProps<T>) {\n const labels = useKitLabels(\"lineItems\", DEFAULT_LINE_ITEMS_LABELS, labelsProp);\n const rowRefs = useRef<Array<HTMLDivElement | null>>([]);\n const addRef = useRef<HTMLButtonElement>(null);\n const pendingFocus = useRef<PendingFocus | null>(null);\n const [confirmingAt, setConfirming] = useState<number | null>(null);\n // A confirm waiting on a row that has since gone is void.\n const confirming = confirmingAt !== null && confirmingAt < items.length ? confirmingAt : null;\n\n const tracks = [...columns.map((c) => c.width ?? \"minmax(0,1fr)\"), ...(onRemove ? [REMOVE_TRACK] : [])];\n const gridStyle = { \"--line-items-cols\": tracks.join(\" \") } as CSSProperties;\n const canAdd = Boolean(onAdd) && !disabled && (maxItems === undefined || items.length < maxItems);\n const canRemove = Boolean(onRemove) && !disabled && items.length > minItems;\n const hasError = hasContent(error);\n\n\n // Focus after the list has re-rendered with the new rows — a layout effect, so the\n // caret is where it belongs before paint.\n useLayoutEffect(() => {\n const pending = pendingFocus.current;\n if (!pending) return;\n pendingFocus.current = null;\n if (pending.kind === \"added\") {\n const row = rowRefs.current[items.length - 1];\n row?.querySelector<HTMLElement>(FOCUSABLE)?.focus();\n return;\n }\n const row = rowRefs.current[Math.min(pending.index, items.length - 1)];\n const target =\n row?.querySelector<HTMLElement>(\"[data-line-items-remove]:not([disabled])\") ??\n row?.querySelector<HTMLElement>(FOCUSABLE) ??\n addRef.current;\n target?.focus();\n }, [items.length]);\n\n const add = useCallback(() => {\n if (!canAdd || !onAdd) return;\n pendingFocus.current = { kind: \"added\" };\n onAdd();\n }, [canAdd, onAdd]);\n\n const removeAt = async (index: number) => {\n if (!canRemove || !onRemove) return;\n if (confirmRemove === true && confirming !== index) {\n setConfirming(index);\n return;\n }\n if (typeof confirmRemove === \"function\") {\n const ok = await confirmRemove(items[index], index);\n if (!ok) return;\n }\n setConfirming(null);\n pendingFocus.current = { kind: \"removed\", index };\n onRemove(index);\n };\n\n const onRowKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n if (e.key === \"Enter\" && (e.ctrlKey || e.metaKey) && canAdd) {\n e.preventDefault();\n add();\n }\n };\n\n const columnName = (column: LineItemsColumn<T>) =>\n column.label ?? (typeof column.header === \"string\" ? column.header : column.key);\n const floats = (column: LineItemsColumn<T>) => (column.fieldLabels ?? fieldLabels) === \"floating\";\n // A header row only for the columns that still need one: with every field floating\n // its own label, a row of headers would say each name twice.\n const showHeader = columns.some((c) => !floats(c));\n const pairs = narrowColumns === 2;\n\n const headerCell = (column: LineItemsColumn<T>) => (\n <span\n key={column.key}\n className={cn(\n \"min-w-0 truncate text-xs font-medium text-[var(--text-muted)]\",\n column.align === \"end\" && \"text-end\",\n )}\n >\n {floats(column) ? null : column.header}\n </span>\n );\n\n const showTotals = totals !== undefined;\n const labelColumn = showTotals ? columns.find((c) => !hasContent(totals[c.key]))?.key : undefined;\n\n return (\n <div\n data-slot=\"line-items\"\n {...rest}\n style={{ ...gridStyle, ...style }}\n className={cn(\"@container space-y-2\", className)}\n >\n {showHeader && (\n <div aria-hidden className=\"hidden gap-2 @lg:grid @lg:grid-cols-[var(--line-items-cols)]\">\n {columns.map(headerCell)}\n {onRemove && <span />}\n </div>\n )}\n\n {items.length === 0 && hasContent(empty) && (\n <div className=\"rounded-md border border-dashed border-[var(--border)] px-3 py-4 text-center text-sm text-[var(--text-muted)]\">\n {empty}\n </div>\n )}\n\n {items.map((item, index) => {\n const row = index + 1;\n const isConfirming = confirming === index;\n return (\n // The row's Ctrl/⌘+Enter is a shortcut for the add button, which is itself\n // reachable by Tab; the group only listens for it bubbling from its fields.\n // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions\n <div\n key={getKey(item, index)}\n ref={(el) => {\n rowRefs.current[index] = el;\n }}\n role=\"group\"\n aria-label={rowLabel ? rowLabel(index, item) : labels.row(row)}\n data-line-items-row=\"\"\n onKeyDown={onRowKeyDown}\n className={cn(\n \"grid grid-cols-1 gap-2 @lg:grid-cols-[var(--line-items-cols)] @lg:items-start\",\n // Two short fields to a row of the card — from 18rem, where two fields\n // still fit a figure each; on anything narrower they stack after all.\n pairs && \"@2xs:grid-cols-2\",\n // Stacked, each row is a card of its own so the rows stay apart.\n \"@max-lg:rounded-md @max-lg:border @max-lg:border-[var(--border)] @max-lg:p-3\",\n )}\n >\n {columns.map((column) => {\n const floating = floats(column);\n return (\n <div\n key={column.key}\n className={cn(\n \"min-w-0 space-y-1 @lg:space-y-0\",\n // The wide grid places every cell in its own track again.\n pairs && column.narrowSpan === 2 && \"@2xs:col-span-2 @lg:col-span-1\",\n )}\n >\n {!floating && (\n <span aria-hidden className=\"block text-xs font-medium text-[var(--text-muted)] @lg:hidden\">\n {column.header}\n </span>\n )}\n {column.render({\n item,\n index,\n label: labels.cell(columnName(column), row),\n fieldLabel: floating ? columnName(column) : undefined,\n })}\n </div>\n );\n })}\n {onRemove && (\n // Paired, the remove button keeps a row of its own at the card's end\n // rather than squeezing in beside the last field.\n <div className={cn(\"flex justify-end @lg:justify-center\", pairs && \"@2xs:col-span-2 @lg:col-span-1\")}>\n <IconButton\n type=\"button\"\n data-line-items-remove=\"\"\n variant={isConfirming ? \"danger\" : \"ghost\"}\n tone={isConfirming ? undefined : \"danger\"}\n label={isConfirming ? labels.confirmRemove(row) : labels.remove(row)}\n disabled={!canRemove}\n onClick={() => void removeAt(index)}\n onBlur={() => {\n if (isConfirming) setConfirming(null);\n }}\n onKeyDown={(e) => {\n if (e.key === \"Escape\" && isConfirming) {\n e.stopPropagation();\n setConfirming(null);\n }\n }}\n >\n <Trash2 />\n </IconButton>\n </div>\n )}\n </div>\n );\n })}\n\n {showTotals && (\n <div\n role=\"group\"\n aria-label={typeof totalsLabel === \"string\" ? totalsLabel : labels.totals}\n data-line-items-totals=\"\"\n className=\"grid grid-cols-1 gap-2 border-t border-[var(--border)] pt-2 text-sm font-semibold tabular-nums text-[var(--text-primary)] @lg:grid-cols-[var(--line-items-cols)]\"\n >\n {columns.map((column) => {\n const value = totals[column.key];\n if (column.key === labelColumn) {\n return (\n <span key={column.key} className=\"min-w-0\">\n {totalsLabel ?? labels.totals}\n </span>\n );\n }\n return (\n <span\n key={column.key}\n className={cn(\n \"min-w-0\",\n column.align === \"end\" && \"@lg:text-end\",\n // Stacked, a column with no total takes no line.\n !hasContent(value) && \"@max-lg:hidden\",\n )}\n >\n {hasContent(value) && (\n <span className=\"flex justify-between gap-2 @lg:block\">\n <span aria-hidden className=\"font-normal text-[var(--text-muted)] @lg:hidden\">\n {column.header}\n </span>\n {value}\n </span>\n )}\n </span>\n );\n })}\n {onRemove && <span className=\"@max-lg:hidden\" />}\n </div>\n )}\n\n {summary && (\n <div\n data-line-items-summary=\"\"\n className=\"flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--text-primary)]\"\n >\n <span className=\"min-w-0\">{summary.label}</span>\n {hasContent(summary.value) && (\n <span\n data-tone={summary.tone ?? \"neutral\"}\n className={cn(\"font-semibold tabular-nums\", SUMMARY_TONE[summary.tone ?? \"neutral\"])}\n >\n {summary.value}\n </span>\n )}\n {summary.action && (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n className=\"ms-auto\"\n disabled={disabled || summary.action.disabled}\n onClick={summary.action.onClick}\n >\n {summary.action.label}\n </Button>\n )}\n </div>\n )}\n\n {hasError && (\n <p className=\"text-[11px] leading-tight text-[var(--danger)]\">\n {error}\n </p>\n )}\n\n {onAdd && (\n <Button\n ref={addRef}\n type=\"button\"\n variant=\"secondary\"\n className=\"w-full border-dashed\"\n disabled={!canAdd}\n onClick={add}\n >\n <Plus aria-hidden className=\"size-4\" />\n {addLabel ?? labels.add}\n </Button>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["row"]}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ComponentPropsWithoutRef, ReactNode, HTMLAttributes, MouseEvent, Ref
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode, HTMLAttributes, ReactElement, MouseEvent, Ref } from 'react';
|
|
3
3
|
import { LucideIcon } from 'lucide-react';
|
|
4
|
+
import { TextTone } from './signed-amount.js';
|
|
5
|
+
import './stat-tile.js';
|
|
4
6
|
|
|
5
7
|
interface ListLabels {
|
|
6
8
|
/** Read after an unread row's title — the dot alone says nothing to a screen reader. */
|
|
@@ -49,6 +51,9 @@ interface ListItemLinkProps {
|
|
|
49
51
|
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
50
52
|
[key: `data-${string}`]: unknown;
|
|
51
53
|
}
|
|
54
|
+
/** {@link ListItemBaseProps.trailingTone}: a {@link TextTone}, or `inherit` — no
|
|
55
|
+
* colour of the slot's own, so an element's colour (or the row's) stands. */
|
|
56
|
+
type ListItemTrailingTone = TextTone | "inherit";
|
|
52
57
|
interface ListItemBaseProps {
|
|
53
58
|
/** The row's name: one line, truncated — unless {@link titleLines} says otherwise. */
|
|
54
59
|
title: ReactNode;
|
|
@@ -68,8 +73,37 @@ interface ListItemBaseProps {
|
|
|
68
73
|
subtitle?: ReactNode;
|
|
69
74
|
/** `2` for a line that is a body rather than a label (keksdose's notification text). */
|
|
70
75
|
subtitleLines?: 1 | 2;
|
|
76
|
+
/**
|
|
77
|
+
* A small line ABOVE the title, in the caption type: the group a row belongs to, a
|
|
78
|
+
* date heading, a category ("Groceries · weekly"). Part of the target and of its
|
|
79
|
+
* name, read before the title. One line, truncated.
|
|
80
|
+
*/
|
|
81
|
+
overline?: ReactNode;
|
|
71
82
|
/** A third line in the caption type: a timestamp, "Updated …" (lenkbank's projects). */
|
|
72
83
|
meta?: ReactNode;
|
|
84
|
+
/**
|
|
85
|
+
* Let {@link meta} wrap: it becomes a wrapping row (`flex-wrap`, a small gap)
|
|
86
|
+
* instead of one truncated line — for a run of chips (keksdose's payee-backfill
|
|
87
|
+
* tags), which otherwise had nowhere to go but the body, outside the target. Still
|
|
88
|
+
* inside the target, so still no controls: chips that only show, not ones that
|
|
89
|
+
* remove themselves.
|
|
90
|
+
*/
|
|
91
|
+
metaWrap?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Content INSIDE the main target, under the title, subtitle and meta: a progress or
|
|
94
|
+
* budget bar, a sparkline, a strip of figures (keksdose's food-group bars). Unlike
|
|
95
|
+
* {@link children}, which sits outside the target, a click on it is a click on the
|
|
96
|
+
* row, and its hover fill covers it.
|
|
97
|
+
*
|
|
98
|
+
* Because it is inside the button or link it obeys the {@link trailing} rule: shown,
|
|
99
|
+
* never operated — no buttons, links or inputs (invalid HTML in a button, and folded
|
|
100
|
+
* into its name). Its text joins the row's accessible name, so give a bar that only
|
|
101
|
+
* repeats a figure already in the row `aria-hidden`. Rendered in a block `<span>`,
|
|
102
|
+
* full width of the text column. Strictly a button holds phrasing content only; the
|
|
103
|
+
* kit's `ProgressBar` draws `<div>`s, which every browser lays out and reads fine
|
|
104
|
+
* there but an HTML validator flags.
|
|
105
|
+
*/
|
|
106
|
+
content?: ReactNode;
|
|
73
107
|
/** A Lucide icon before the text, muted, at 16px. */
|
|
74
108
|
icon?: LucideIcon;
|
|
75
109
|
/** Anything else before the text — an avatar, a file-type badge, a drag handle. */
|
|
@@ -80,6 +114,13 @@ interface ListItemBaseProps {
|
|
|
80
114
|
* Controls go in {@link actions}.
|
|
81
115
|
*/
|
|
82
116
|
trailing?: ReactNode;
|
|
117
|
+
/**
|
|
118
|
+
* The colour of the {@link trailing} slot. Default `muted` — today's look, right for
|
|
119
|
+
* a size or a count in plain text. `inherit` sets none, so an element's own colour
|
|
120
|
+
* (an amount coloured by its sign) stands without a second override; any
|
|
121
|
+
* {@link TextTone} colours a plain-text figure (`"danger"` for an overdue count).
|
|
122
|
+
*/
|
|
123
|
+
trailingTone?: ListItemTrailingTone;
|
|
83
124
|
/**
|
|
84
125
|
* Controls BESIDE the main target, never inside it: copy, delete, "Open".
|
|
85
126
|
*
|
|
@@ -91,6 +132,15 @@ interface ListItemBaseProps {
|
|
|
91
132
|
* on "Copy" from selecting the row.
|
|
92
133
|
*/
|
|
93
134
|
actions?: ReactNode;
|
|
135
|
+
/**
|
|
136
|
+
* Controls BEFORE the main target, never inside it: a selection checkbox, a drag
|
|
137
|
+
* handle that is a button. The mirror of {@link actions} at the start edge — a
|
|
138
|
+
* sibling of the target, so checking the box does not activate (or expand) the row
|
|
139
|
+
* and the checkbox keeps its own name. Give the control an `aria-label` naming the
|
|
140
|
+
* row ("Select Groceries"), since it is not inside the row's name. Anything that only
|
|
141
|
+
* shows goes in {@link leading}, inside the target.
|
|
142
|
+
*/
|
|
143
|
+
leadingActions?: ReactNode;
|
|
94
144
|
/**
|
|
95
145
|
* Rich content UNDER the title row and outside its target: photos, per-row buttons,
|
|
96
146
|
* form fields — kastlan's handover room inspector (room-inspector.tsx), the handover
|
|
@@ -157,6 +207,20 @@ interface ListItemBaseProps {
|
|
|
157
207
|
bordered?: boolean;
|
|
158
208
|
/** `div` for a single row outside a {@link List}; an `<li>` must sit in a list. */
|
|
159
209
|
as?: "li" | "div";
|
|
210
|
+
/**
|
|
211
|
+
* Wraps the visible row (the bordered box: target, actions and body) in an element
|
|
212
|
+
* of your own, INSIDE the `<li>` — a `<Tooltip>` explaining why the row is disabled,
|
|
213
|
+
* an app's save guard: `renderRow={(row) => <Tooltip label="Locked">{row}</Tooltip>}`.
|
|
214
|
+
*
|
|
215
|
+
* The wrapper otherwise had to go round the whole `ListItem`, which put a `<span>`
|
|
216
|
+
* between the `<ul>` and its `<li>` (invalid; the list stops counting items), so
|
|
217
|
+
* callers wrote `<li><Wrapper><ListItem as="div" /></Wrapper></li>` — and lost the
|
|
218
|
+
* divider and its spacing, which sit on the row's own `<li>`. Here the `<li>` stays
|
|
219
|
+
* the list's child and keeps both. The `<li>` becomes a column and the row takes the
|
|
220
|
+
* full width, so an inline wrapper (the tooltip's `inline-flex` span) still stretches
|
|
221
|
+
* the row across the list.
|
|
222
|
+
*/
|
|
223
|
+
renderRow?: (row: ReactElement) => ReactNode;
|
|
160
224
|
/** Reaches the main target — the element with the row's name and action. */
|
|
161
225
|
id?: string;
|
|
162
226
|
[key: `aria-${string}`]: string | boolean | number | undefined;
|
|
@@ -166,14 +230,14 @@ interface ListItemBaseProps {
|
|
|
166
230
|
* The row is ONE thing to activate, so the types allow one: a button, an in-app link,
|
|
167
231
|
* an external link, or nothing (a static row, like kastlan's passkey and user lists).
|
|
168
232
|
*/
|
|
169
|
-
type ListItemProps = ListItemBaseProps & ({
|
|
233
|
+
type ListItemProps = ListItemBaseProps & (({
|
|
170
234
|
/** Makes the row a button — select, open, mark read. */
|
|
171
235
|
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
172
236
|
href?: never;
|
|
173
237
|
renderLink?: never;
|
|
174
238
|
external?: never;
|
|
175
239
|
onAuxClick?: never;
|
|
176
|
-
} | {
|
|
240
|
+
} & NotExpandable) | ({
|
|
177
241
|
/**
|
|
178
242
|
* Makes the row a link. A real `<a href>`, so a middle click opens it in a
|
|
179
243
|
* background tab (keksdose feedback #451) — which a button calling `navigate`
|
|
@@ -196,13 +260,43 @@ type ListItemProps = ListItemBaseProps & ({
|
|
|
196
260
|
* notification read here too, so a row opened into a background tab does not
|
|
197
261
|
* stay unread. */
|
|
198
262
|
onAuxClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
199
|
-
} | {
|
|
263
|
+
} & NotExpandable) | ({
|
|
264
|
+
onClick?: never;
|
|
265
|
+
href?: never;
|
|
266
|
+
renderLink?: never;
|
|
267
|
+
external?: never;
|
|
268
|
+
onAuxClick?: never;
|
|
269
|
+
} & NotExpandable) | {
|
|
270
|
+
/**
|
|
271
|
+
* Makes the row a disclosure: the target is a button with `aria-expanded` and a
|
|
272
|
+
* turning chevron at its end, and this opens under the row, outside the target
|
|
273
|
+
* (so it may hold controls), animated by {@link Collapse}. keksdose's collapsible
|
|
274
|
+
* groups — a group row with a select-all checkbox before it
|
|
275
|
+
* ({@link ListItemBaseProps.leadingActions}) and its members inside.
|
|
276
|
+
*
|
|
277
|
+
* The row's ONE action is the toggle, so it takes no `onClick` or `href`. The
|
|
278
|
+
* content is a `role="group"` named by the title, unmounted while shut (as a
|
|
279
|
+
* `Disclosure`'s body is).
|
|
280
|
+
*/
|
|
281
|
+
expandedContent: ReactNode;
|
|
282
|
+
/** Controlled open state. Leave it out for the row to keep its own. */
|
|
283
|
+
expanded?: boolean;
|
|
284
|
+
/** The uncontrolled row's first state. Default `false`. */
|
|
285
|
+
defaultExpanded?: boolean;
|
|
286
|
+
/** Runs on every toggle with the new state. */
|
|
287
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
200
288
|
onClick?: never;
|
|
201
289
|
href?: never;
|
|
202
290
|
renderLink?: never;
|
|
203
291
|
external?: never;
|
|
204
292
|
onAuxClick?: never;
|
|
205
293
|
});
|
|
294
|
+
interface NotExpandable {
|
|
295
|
+
expandedContent?: never;
|
|
296
|
+
expanded?: never;
|
|
297
|
+
defaultExpanded?: never;
|
|
298
|
+
onExpandedChange?: never;
|
|
299
|
+
}
|
|
206
300
|
/**
|
|
207
301
|
* One row of a {@link List}: leading icon or avatar, title and subtitle (both
|
|
208
302
|
* truncating by default), a trailing slot, and — beside the row, not inside it — its actions.
|
|
@@ -217,4 +311,4 @@ type ListItemProps = ListItemBaseProps & ({
|
|
|
217
311
|
*/
|
|
218
312
|
declare const ListItem: react.ForwardRefExoticComponent<ListItemProps & react.RefAttributes<HTMLElement>>;
|
|
219
313
|
|
|
220
|
-
export { DEFAULT_LIST_LABELS, List, type ListDensity, ListItem, type ListItemLinkProps, type ListItemProps, type ListLabels, type ListProps, type ListSeparator };
|
|
314
|
+
export { DEFAULT_LIST_LABELS, List, type ListDensity, ListItem, type ListItemLinkProps, type ListItemProps, type ListItemTrailingTone, type ListLabels, type ListProps, type ListSeparator };
|