@eifi1/ui-kit 0.15.4 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -15
- package/dist/chart.d.ts +3 -4
- package/dist/components/alert-banner.js +1 -1
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +13 -4
- package/dist/components/amount-input.js +166 -157
- 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 -4
- package/dist/components/calculator.d.ts +3 -4
- package/dist/components/checkbox.js +1 -1
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/choice-card.js +1 -1
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/copy-button.d.ts +3 -4
- package/dist/components/error-boundary.js +1 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/facing-pair.d.ts +3 -4
- package/dist/components/file-button.d.ts +3 -4
- package/dist/components/file-button.js +64 -31
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +3 -4
- package/dist/components/file-dropzone.js +1 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/form-actions.d.ts +4 -5
- 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 -4
- package/dist/components/number-field.d.ts +3 -4
- package/dist/components/number-input.d.ts +3 -4
- 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 -4
- 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 -4
- package/dist/components/settings-fields.d.ts +3 -4
- 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 -4
- package/dist/components/time-input.d.ts +3 -4
- 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 -4
- package/dist/components/ui.js +156 -52
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +2 -82
- package/dist/feedback/feedback-attachment.js +285 -55
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +2 -105
- package/dist/feedback/feedback-dialog.js +57 -25
- 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 +2 -2
- package/dist/feedback-attachment-WFiXJ8We.d.ts +302 -0
- package/dist/feedback.d.ts +1 -2
- package/dist/hooks/use-file-drop.d.ts +3 -4
- package/dist/i18n/defaults.d.ts +3 -4
- package/dist/i18n/kit-labels.d.ts +2 -3
- package/dist/i18n/locales/de-CH-informal.d.ts +3 -4
- package/dist/i18n/locales/de-CH.d.ts +3 -4
- package/dist/i18n/locales/de-informal.d.ts +3 -4
- package/dist/i18n/locales/de.d.ts +3 -4
- package/dist/i18n/locales/de.js +5 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -4
- package/dist/i18n/locales/es.js +5 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -4
- package/dist/i18n/locales/fr.js +5 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -4
- package/dist/i18n/locales/hu.js +5 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -4
- package/dist/i18n/locales/it.js +5 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -4
- package/dist/i18n/locales/zh.js +5 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +10 -11
- package/dist/{kit-labels-CnnBd-2N.d.ts → kit-labels-DE--LmCk.d.ts} +154 -14
- 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 -4
- package/dist/rhf/form.d.ts +3 -4
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +3 -4
- package/dist/shell/app-shell.d.ts +3 -4
- package/dist/shell/top-bar-brand.d.ts +3 -4
- package/dist/shell.d.ts +3 -4
- package/dist/theme/palette-derive.d.ts +3 -0
- package/dist/theme/palette-derive.js +7 -0
- package/dist/theme/palette-derive.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +3 -4
- package/dist/wizard.d.ts +3 -4
- package/package.json +4 -2
- package/src/components/alert-banner.tsx +1 -1
- package/src/components/amount-input.tsx +47 -16
- package/src/components/authed-image.tsx +20 -2
- package/src/components/checkbox.tsx +1 -1
- package/src/components/choice-card.tsx +1 -1
- package/src/components/error-boundary.tsx +1 -1
- package/src/components/file-button.tsx +77 -27
- package/src/components/file-dropzone.tsx +1 -1
- 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 +280 -70
- package/src/feedback/feedback-attachment.tsx +472 -96
- package/src/feedback/feedback-dialog.tsx +162 -50
- package/src/i18n/locales/de.ts +4 -0
- package/src/i18n/locales/es.ts +4 -0
- package/src/i18n/locales/fr.ts +4 -0
- package/src/i18n/locales/hu.ts +4 -0
- package/src/i18n/locales/it.ts +4 -0
- package/src/i18n/locales/zh.ts +4 -0
- package/src/lib/format.ts +24 -3
- package/src/rhf/line-items.tsx +6 -0
- package/src/theme/palette-derive.ts +10 -0
- package/tokens.css +9 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { isValidElement } from "react";
|
|
2
2
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
3
|
+
import type { LucideIcon } from "lucide-react";
|
|
3
4
|
import { cn } from "../lib/cn";
|
|
4
5
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
|
-
import { Button, Spinner, type ButtonVariant } from "./ui";
|
|
6
|
+
import { Button, Spinner, type ButtonProps, type ButtonVariant } from "./ui";
|
|
6
7
|
|
|
7
8
|
/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */
|
|
8
9
|
export interface FormActionsLabels {
|
|
@@ -28,6 +29,25 @@ export type FormActionsAlign = "start" | "center" | "end" | "between";
|
|
|
28
29
|
*/
|
|
29
30
|
export type FormActionsPlacement = "inline" | "sticky" | "dialog";
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.
|
|
34
|
+
* - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto
|
|
35
|
+
* the phone's bottom nav by `--app-nav-h`.
|
|
36
|
+
* - `container`: the bottom of whatever scroll container it is in — a dialog body, a
|
|
37
|
+
* side pane, a panel with `overflow-y-auto` — at `bottom: 0`, with no nav offset.
|
|
38
|
+
*/
|
|
39
|
+
export type FormActionsStickyWithin = "viewport" | "container";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extra attributes for the save button — an `id`, `data-*` hooks for tests or
|
|
43
|
+
* analytics, an `aria-describedby`. The props FormActions drives itself (type, click,
|
|
44
|
+
* disabled, variant, form, children, the busy state) are not among them.
|
|
45
|
+
*/
|
|
46
|
+
export type FormActionsSubmitProps = Omit<
|
|
47
|
+
ButtonProps,
|
|
48
|
+
"type" | "onClick" | "disabled" | "disabledReason" | "variant" | "form" | "children" | "aria-busy"
|
|
49
|
+
> & { [key: `data-${string}`]: string | number | boolean | undefined };
|
|
50
|
+
|
|
31
51
|
/** The start-side action of {@link FormActionsProps.destructive}, as data. */
|
|
32
52
|
export interface FormActionsDestructive {
|
|
33
53
|
label: ReactNode;
|
|
@@ -65,10 +85,21 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
65
85
|
cancelLabel?: ReactNode;
|
|
66
86
|
/** The save button's text while `pending` ("Saving…"). Default: `submitLabel`. */
|
|
67
87
|
pendingLabel?: ReactNode;
|
|
88
|
+
/**
|
|
89
|
+
* A Lucide icon before the save button's text. While `pending` the spinner takes
|
|
90
|
+
* ITS place rather than being added in front of it — an icon written into
|
|
91
|
+
* `submitLabel` itself stays, so the button would show the spinner AND the glyph,
|
|
92
|
+
* two statuses side by side (keksdose's crop Apply needed a `pendingLabel` copy of
|
|
93
|
+
* its text only to drop the check mark).
|
|
94
|
+
*/
|
|
95
|
+
submitIcon?: LucideIcon;
|
|
96
|
+
/** Attributes passed through to the save button: `id`, `data-*`, `aria-*`, a
|
|
97
|
+
* `className`. See {@link FormActionsSubmitProps}. */
|
|
98
|
+
submitProps?: FormActionsSubmitProps;
|
|
68
99
|
/**
|
|
69
100
|
* The save is running: a spinner in the save button, `aria-busy` on it, and the
|
|
70
101
|
* button disabled, so a second click cannot send the form twice. Cancel stays
|
|
71
|
-
* usable.
|
|
102
|
+
* usable. The spinner goes in front of the text, or in place of {@link submitIcon}.
|
|
72
103
|
*/
|
|
73
104
|
pending?: boolean;
|
|
74
105
|
/** Disable save for a reason of the form's own (an unbalanced entry). */
|
|
@@ -85,10 +116,39 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
85
116
|
* is `between`-aligned unless `align` says otherwise.
|
|
86
117
|
*/
|
|
87
118
|
destructive?: ReactNode | FormActionsDestructive;
|
|
119
|
+
/**
|
|
120
|
+
* Something neutral at the START of the row — a caption ("Last saved 2 min ago",
|
|
121
|
+
* "Drag the corners to crop"), a "View activity" link. After {@link destructive}
|
|
122
|
+
* when both are given; like it, makes the row `between`-aligned unless `align` says
|
|
123
|
+
* otherwise. `children` are for further ACTIONS, beside Cancel.
|
|
124
|
+
*/
|
|
125
|
+
start?: ReactNode;
|
|
88
126
|
/** Horizontal alignment. Default `end` (`between` with a `destructive` action). */
|
|
89
127
|
align?: FormActionsAlign;
|
|
90
128
|
/** See {@link FormActionsPlacement}. Default `inline`. */
|
|
91
129
|
placement?: FormActionsPlacement;
|
|
130
|
+
/**
|
|
131
|
+
* With `placement="sticky"`: what the row sticks to. Default `viewport`.
|
|
132
|
+
*
|
|
133
|
+
* CSS sticky always sticks to the NEAREST scroll container, so the default row
|
|
134
|
+
* already holds inside a scrolling pane — but it offsets itself by `--app-nav-h`,
|
|
135
|
+
* which AppShell publishes on the document: inside a pane or a portalled dialog
|
|
136
|
+
* body on a phone it then floats a nav's height above that container's bottom,
|
|
137
|
+
* with the content scrolling through the gap. `container` drops that offset.
|
|
138
|
+
*
|
|
139
|
+
* What neither value can fix, because it is the layout, not the row:
|
|
140
|
+
* - sticky moves only within its PARENT box. Inside a DataTable expansion row the
|
|
141
|
+
* parent is the expansion cell, so the row pins to the table's own scroller
|
|
142
|
+
* (`overflow-auto`, bounded by `maxBodyHeight`) and only while the cell spans
|
|
143
|
+
* that scroller's bottom edge — not to the page.
|
|
144
|
+
* - any `overflow` other than `visible`/`clip` between the row and the scroller
|
|
145
|
+
* you mean (an `overflow-x-auto` wrapper, an `overflow-hidden` card) becomes the
|
|
146
|
+
* scroll container; if THAT box does not scroll, the row never sticks.
|
|
147
|
+
* - the offset is resolved against the scroller's content box, so a scroller with
|
|
148
|
+
* bottom padding leaves that strip uncovered; give the row a negative `bottom`
|
|
149
|
+
* through `style` and the same amount back as padding.
|
|
150
|
+
*/
|
|
151
|
+
stickyWithin?: FormActionsStickyWithin;
|
|
92
152
|
/** The `id` of the `<form>` the save button submits, when the row is rendered
|
|
93
153
|
* outside it (a dialog's footer slot). */
|
|
94
154
|
form?: string;
|
|
@@ -121,13 +181,17 @@ export function FormActions({
|
|
|
121
181
|
submitLabel,
|
|
122
182
|
cancelLabel,
|
|
123
183
|
pendingLabel,
|
|
184
|
+
submitIcon: SubmitIcon,
|
|
185
|
+
submitProps,
|
|
124
186
|
pending = false,
|
|
125
187
|
submitDisabled = false,
|
|
126
188
|
submitDisabledReason,
|
|
127
189
|
submitVariant = "brand",
|
|
128
190
|
destructive,
|
|
191
|
+
start: startSlot,
|
|
129
192
|
align,
|
|
130
193
|
placement = "inline",
|
|
194
|
+
stickyWithin = "viewport",
|
|
131
195
|
form,
|
|
132
196
|
children,
|
|
133
197
|
className,
|
|
@@ -136,8 +200,9 @@ export function FormActions({
|
|
|
136
200
|
}: FormActionsProps) {
|
|
137
201
|
const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
|
|
138
202
|
const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
|
|
139
|
-
const
|
|
140
|
-
const
|
|
203
|
+
const hasStart = startSlot !== undefined && startSlot !== null && startSlot !== false;
|
|
204
|
+
const justify = align ?? (hasDestructive || hasStart ? "between" : "end");
|
|
205
|
+
const destructiveNode = !hasDestructive ? null : isDestructiveData(destructive) ? (
|
|
141
206
|
<Button
|
|
142
207
|
type="button"
|
|
143
208
|
variant="ghost"
|
|
@@ -157,6 +222,7 @@ export function FormActions({
|
|
|
157
222
|
<div
|
|
158
223
|
data-slot="form-actions"
|
|
159
224
|
data-placement={placement}
|
|
225
|
+
data-sticky-within={placement === "sticky" ? stickyWithin : undefined}
|
|
160
226
|
{...rest}
|
|
161
227
|
style={
|
|
162
228
|
placement === "sticky"
|
|
@@ -165,15 +231,22 @@ export function FormActions({
|
|
|
165
231
|
// On the phone's bottom nav, not behind it (keksdose live #361): `bottom-0`
|
|
166
232
|
// pinned the row under AppShell's nav, which covers the page's last ~56px.
|
|
167
233
|
// `--app-nav-h` is the nav's measured height (0px from md up, and outside an
|
|
168
|
-
// AppShell); the 1px overlaps the two top borders into one line.
|
|
169
|
-
|
|
234
|
+
// AppShell); the 1px overlaps the two top borders into one line. In a
|
|
235
|
+
// container the nav is not underneath: `--app-nav-h` is the document's,
|
|
236
|
+
// and would hold the row that far above the container's own edge.
|
|
237
|
+
bottom: stickyWithin === "container" ? 0 : "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
|
|
170
238
|
...style,
|
|
171
239
|
}
|
|
172
240
|
: style
|
|
173
241
|
}
|
|
174
242
|
className={cn("flex flex-wrap items-center gap-2", ALIGN_CLASS[justify], PLACEMENT_CLASS[placement], className)}
|
|
175
243
|
>
|
|
176
|
-
{
|
|
244
|
+
{(hasDestructive || hasStart) && (
|
|
245
|
+
<div className="me-auto flex min-w-0 flex-wrap items-center gap-2">
|
|
246
|
+
{destructiveNode}
|
|
247
|
+
{hasStart && startSlot}
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
177
250
|
<div className="flex flex-wrap items-center gap-2">
|
|
178
251
|
{children}
|
|
179
252
|
{onCancel && (
|
|
@@ -182,6 +255,7 @@ export function FormActions({
|
|
|
182
255
|
</Button>
|
|
183
256
|
)}
|
|
184
257
|
<Button
|
|
258
|
+
{...submitProps}
|
|
185
259
|
type={onSubmit ? "button" : "submit"}
|
|
186
260
|
form={form}
|
|
187
261
|
onClick={onSubmit}
|
|
@@ -190,7 +264,11 @@ export function FormActions({
|
|
|
190
264
|
disabledReason={pending ? undefined : submitDisabledReason}
|
|
191
265
|
aria-busy={pending || undefined}
|
|
192
266
|
>
|
|
193
|
-
{pending
|
|
267
|
+
{pending ? (
|
|
268
|
+
<Spinner label={null} className="size-4" />
|
|
269
|
+
) : (
|
|
270
|
+
SubmitIcon && <SubmitIcon aria-hidden className="size-4 shrink-0" />
|
|
271
|
+
)}
|
|
194
272
|
{pending && pendingLabel !== undefined ? pendingLabel : saveLabel}
|
|
195
273
|
</Button>
|
|
196
274
|
</div>
|
|
@@ -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}
|