@eifi1/ui-kit 0.16.1 → 0.17.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 +7 -7
- package/dist/chart.d.ts +1 -1
- package/dist/components/amount-input.d.ts +9 -1
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/copy-button.d.ts +1 -1
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +91 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/form-actions.js +32 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/line-items.d.ts +30 -6
- package/dist/components/line-items.js +77 -33
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +13 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/money-field.d.ts +1 -1
- package/dist/components/number-field.d.ts +1 -1
- package/dist/components/number-input.d.ts +1 -1
- package/dist/components/numpad-sheet.d.ts +1 -1
- package/dist/components/progress-bar.d.ts +11 -1
- package/dist/components/progress-bar.js +14 -4
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart.d.ts +1 -1
- package/dist/components/settings-fields.d.ts +1 -1
- package/dist/components/signed-amount.d.ts +12 -3
- package/dist/components/signed-amount.js +6 -4
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/text-link.d.ts +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/components/ui.js +25 -3
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-CfVXtCeG.d.ts → data-table-CpkWUJBY.d.ts} +41 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/de-informal.d.ts +1 -1
- package/dist/i18n/locales/de.d.ts +1 -1
- package/dist/i18n/locales/en.d.ts +71 -0
- package/dist/i18n/locales/en.js +95 -0
- package/dist/i18n/locales/en.js.map +1 -0
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-C4uB_zmg.d.ts} +57 -5
- package/dist/rhf/fields.d.ts +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +1 -1
- package/dist/search/global-search.d.ts +22 -4
- package/dist/search/global-search.js +31 -3
- package/dist/search/global-search.js.map +1 -1
- package/dist/search.d.ts +1 -1
- package/dist/shell/app-shell.d.ts +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/amount-input.tsx +8 -0
- package/src/components/data-table.tsx +165 -3
- package/src/components/form-actions.tsx +73 -3
- package/src/components/line-items.tsx +116 -40
- package/src/components/list.tsx +13 -1
- package/src/components/progress-bar.tsx +20 -4
- package/src/components/signed-amount.tsx +16 -5
- package/src/components/ui.tsx +57 -3
- package/src/i18n/locales/en.ts +116 -0
- package/src/rhf/line-items.tsx +2 -1
- package/src/search/global-search.tsx +57 -6
|
@@ -3,6 +3,7 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
|
3
3
|
import type { LucideIcon } from "lucide-react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
5
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
6
|
+
import { useMediaQuery } from "../hooks/use-media-query";
|
|
6
7
|
import { Button, Spinner, type ButtonProps, type ButtonVariant } from "./ui";
|
|
7
8
|
|
|
8
9
|
/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */
|
|
@@ -29,6 +30,56 @@ export type FormActionsAlign = "start" | "center" | "end" | "between";
|
|
|
29
30
|
*/
|
|
30
31
|
export type FormActionsPlacement = "inline" | "sticky" | "dialog";
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A placement per breakpoint, mobile first — `{ base: "sticky", md: "inline" }` is a
|
|
35
|
+
* Save row stuck to the bottom of a phone's long form and in the flow under the last
|
|
36
|
+
* field from 768px up, where the form fits. The breakpoints are Tailwind's (sm 640,
|
|
37
|
+
* md 768, lg 1024, xl 1280px), as `BulkActionBar`'s `variant` breakpoints are, and
|
|
38
|
+
* resolved the same way, in JS: the sticky row is positioned by inline style, which a
|
|
39
|
+
* `md:` class cannot reach. keksdose switched `placement` on its own media query at
|
|
40
|
+
* every long form for want of this.
|
|
41
|
+
*/
|
|
42
|
+
export interface ResponsiveFormActionsPlacement {
|
|
43
|
+
base: FormActionsPlacement;
|
|
44
|
+
sm?: FormActionsPlacement;
|
|
45
|
+
md?: FormActionsPlacement;
|
|
46
|
+
lg?: FormActionsPlacement;
|
|
47
|
+
xl?: FormActionsPlacement;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const BREAKPOINTS = [
|
|
51
|
+
["xl", "(min-width: 1280px)"],
|
|
52
|
+
["lg", "(min-width: 1024px)"],
|
|
53
|
+
["md", "(min-width: 768px)"],
|
|
54
|
+
["sm", "(min-width: 640px)"],
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
/** The placement in force: the widest breakpoint that matches AND names one, else
|
|
58
|
+
* `base`. The queries are subscribed unconditionally (hooks cannot be skipped); a
|
|
59
|
+
* plain string ignores them. Without `matchMedia` (SSR, tests) none match, so the row
|
|
60
|
+
* renders its `base`, the phone's. The same resolution as BulkActionBar's variant. */
|
|
61
|
+
function useResolvedPlacement(
|
|
62
|
+
placement: FormActionsPlacement | ResponsiveFormActionsPlacement,
|
|
63
|
+
): FormActionsPlacement {
|
|
64
|
+
const matches = {
|
|
65
|
+
xl: useMediaQuery(BREAKPOINTS[0][1], false),
|
|
66
|
+
lg: useMediaQuery(BREAKPOINTS[1][1], false),
|
|
67
|
+
md: useMediaQuery(BREAKPOINTS[2][1], false),
|
|
68
|
+
sm: useMediaQuery(BREAKPOINTS[3][1], false),
|
|
69
|
+
};
|
|
70
|
+
if (typeof placement === "string") return placement;
|
|
71
|
+
for (const [key] of BREAKPOINTS) {
|
|
72
|
+
const p = placement[key];
|
|
73
|
+
if (matches[key] && p !== undefined) return p;
|
|
74
|
+
}
|
|
75
|
+
return placement.base;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A CSS length: a number is pixels. */
|
|
79
|
+
function cssLength(value: string | number): string {
|
|
80
|
+
return typeof value === "number" ? `${value}px` : value;
|
|
81
|
+
}
|
|
82
|
+
|
|
32
83
|
/**
|
|
33
84
|
* What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.
|
|
34
85
|
* - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto
|
|
@@ -125,8 +176,20 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
125
176
|
start?: ReactNode;
|
|
126
177
|
/** Horizontal alignment. Default `end` (`between` with a `destructive` action). */
|
|
127
178
|
align?: FormActionsAlign;
|
|
128
|
-
/** See {@link FormActionsPlacement}
|
|
129
|
-
|
|
179
|
+
/** See {@link FormActionsPlacement}, or one per breakpoint — see
|
|
180
|
+
* {@link ResponsiveFormActionsPlacement}. Default `inline`. */
|
|
181
|
+
placement?: FormActionsPlacement | ResponsiveFormActionsPlacement;
|
|
182
|
+
/**
|
|
183
|
+
* While the row is `sticky`: pull it out over its container's inline padding by this
|
|
184
|
+
* much (a CSS length, a number in px) and give the same back as padding — so the
|
|
185
|
+
* rule above it and its surface run edge to edge, while the buttons stay on the
|
|
186
|
+
* content's line. Without it a sticky row in a padded card or pane is a strip inset
|
|
187
|
+
* from both sides, with the content scrolling past it in the gutters (keksdose wrote
|
|
188
|
+
* `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`"0.75rem"`,
|
|
189
|
+
* `12`, `"var(--pane-px)"`). Applied only while the resolved placement is sticky, so
|
|
190
|
+
* `placement={{ base: "sticky", md: "inline" }}` bleeds on the phone alone.
|
|
191
|
+
*/
|
|
192
|
+
bleed?: string | number;
|
|
130
193
|
/**
|
|
131
194
|
* With `placement="sticky"`: what the row sticks to. Default `viewport`.
|
|
132
195
|
*
|
|
@@ -190,8 +253,9 @@ export function FormActions({
|
|
|
190
253
|
destructive,
|
|
191
254
|
start: startSlot,
|
|
192
255
|
align,
|
|
193
|
-
placement = "inline",
|
|
256
|
+
placement: placementProp = "inline",
|
|
194
257
|
stickyWithin = "viewport",
|
|
258
|
+
bleed,
|
|
195
259
|
form,
|
|
196
260
|
children,
|
|
197
261
|
className,
|
|
@@ -199,6 +263,8 @@ export function FormActions({
|
|
|
199
263
|
...rest
|
|
200
264
|
}: FormActionsProps) {
|
|
201
265
|
const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
|
|
266
|
+
const placement = useResolvedPlacement(placementProp);
|
|
267
|
+
const bleedLength = bleed === undefined || bleed === "" ? undefined : cssLength(bleed);
|
|
202
268
|
const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
|
|
203
269
|
const hasStart = startSlot !== undefined && startSlot !== null && startSlot !== false;
|
|
204
270
|
const justify = align ?? (hasDestructive || hasStart ? "between" : "end");
|
|
@@ -235,6 +301,10 @@ export function FormActions({
|
|
|
235
301
|
// container the nav is not underneath: `--app-nav-h` is the document's,
|
|
236
302
|
// and would hold the row that far above the container's own edge.
|
|
237
303
|
bottom: stickyWithin === "container" ? 0 : "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
|
|
304
|
+
...(bleedLength !== undefined && {
|
|
305
|
+
marginInline: `calc(-1 * ${bleedLength})`,
|
|
306
|
+
paddingInline: bleedLength,
|
|
307
|
+
}),
|
|
238
308
|
...style,
|
|
239
309
|
}
|
|
240
310
|
: style
|
|
@@ -53,8 +53,10 @@ export interface LineItemCellContext<T> {
|
|
|
53
53
|
* so the list draws no header for it. */
|
|
54
54
|
export type LineItemsFieldLabels = "aria" | "floating";
|
|
55
55
|
|
|
56
|
-
/** The colour of a {@link LineItemsSummary}'s value
|
|
57
|
-
|
|
56
|
+
/** The colour of a {@link LineItemsSummary}'s value — a verdict (`success` /
|
|
57
|
+
* `danger`), quiet (`muted`), or the money pair (`income` / `expense`, the colours
|
|
58
|
+
* `SignedAmount` draws a figure in) for a remainder that reads as the row it settles. */
|
|
59
|
+
export type LineItemsSummaryTone = "neutral" | "success" | "danger" | "muted" | "income" | "expense";
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
62
|
* A line under the lines that says where they stand — a split's remainder ("Left to
|
|
@@ -65,13 +67,28 @@ export interface LineItemsSummary {
|
|
|
65
67
|
label: ReactNode;
|
|
66
68
|
value?: ReactNode;
|
|
67
69
|
/** 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.
|
|
70
|
+
* second signal, never the only one — the value and label carry the meaning.
|
|
71
|
+
* With no `value` the line is a state on its own ("Waiting for the total"), and
|
|
72
|
+
* the tone colours the label instead — `muted` quiets the whole line. */
|
|
69
73
|
tone?: LineItemsSummaryTone;
|
|
74
|
+
/** Beside the value: a chip or badge that names the state ("Balanced" next to a
|
|
75
|
+
* journal entry's zero difference). */
|
|
76
|
+
status?: ReactNode;
|
|
70
77
|
/** A small button at the end of the line. Disabled along with the list's
|
|
71
|
-
* `disabled`.
|
|
72
|
-
action
|
|
78
|
+
* `disabled`. `variant`: `secondary` (default) is a boxed button; `link` a muted
|
|
79
|
+
* text link, for an action that is an aside rather than the way to settle it. */
|
|
80
|
+
action?: { label: ReactNode; onClick: () => void; disabled?: boolean; variant?: "secondary" | "link" };
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
/** Where a row's remove button sits. `row` (default): stacked, a row of the card to
|
|
84
|
+
* itself (paired, the whole width); wide, its own track. `inline`: stacked, beside
|
|
85
|
+
* the row's last field ("category + amount" over "memo + delete"); wide, unchanged. */
|
|
86
|
+
export type LineItemsRemovePlacement = "row" | "inline";
|
|
87
|
+
|
|
88
|
+
/** How the remove button sits in the height of a wide row: at its top, centred, or at
|
|
89
|
+
* its foot. */
|
|
90
|
+
export type LineItemsRemoveAlign = "start" | "center" | "end";
|
|
91
|
+
|
|
75
92
|
export interface LineItemsColumn<T> {
|
|
76
93
|
/** Stable id: the React key, and the key into `totals`. */
|
|
77
94
|
key: string;
|
|
@@ -143,6 +160,14 @@ export interface LineItemsProps<T> extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
143
160
|
* them up (a column's `narrowSpan={2}` takes a whole row), once the pane is wide
|
|
144
161
|
* enough for two fields (18rem); narrower still, one. Default `1`. */
|
|
145
162
|
narrowColumns?: 1 | 2;
|
|
163
|
+
/** Stacked, where the remove button goes: a row of its own (`row`, default) or
|
|
164
|
+
* beside the last field (`inline`). See {@link LineItemsRemovePlacement}. */
|
|
165
|
+
removePlacement?: LineItemsRemovePlacement;
|
|
166
|
+
/** On the wide grid, the remove button's place in the row's height. Default `start`
|
|
167
|
+
* — or `center` when a column floats its label (`fieldLabels="floating"`): a
|
|
168
|
+
* floating field is taller than the button, and centred is level with its control.
|
|
169
|
+
* A field that shows a message under itself grows the row; pin `start` then. */
|
|
170
|
+
removeAlign?: LineItemsRemoveAlign;
|
|
146
171
|
/** Disables add and remove (the fields are the caller's to disable). */
|
|
147
172
|
disabled?: boolean;
|
|
148
173
|
labels?: Partial<LineItemsLabels>;
|
|
@@ -154,6 +179,14 @@ const SUMMARY_TONE: Record<LineItemsSummaryTone, string> = {
|
|
|
154
179
|
success: "text-[var(--success)]",
|
|
155
180
|
danger: "text-[var(--danger)]",
|
|
156
181
|
muted: "text-[var(--text-muted)]",
|
|
182
|
+
// SignedAmount's money palette (toneTextClass "income" / "expense").
|
|
183
|
+
income: "text-[var(--money-income)]",
|
|
184
|
+
expense: "text-[var(--money-expense)]",
|
|
185
|
+
};
|
|
186
|
+
const REMOVE_ALIGN: Record<LineItemsRemoveAlign, string> = {
|
|
187
|
+
start: "@lg:self-start",
|
|
188
|
+
center: "@lg:self-center",
|
|
189
|
+
end: "@lg:self-end",
|
|
157
190
|
};
|
|
158
191
|
const FOCUSABLE =
|
|
159
192
|
"input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex='-1'])";
|
|
@@ -217,6 +250,8 @@ export function LineItems<T>({
|
|
|
217
250
|
summary,
|
|
218
251
|
fieldLabels = "aria",
|
|
219
252
|
narrowColumns = 1,
|
|
253
|
+
removePlacement = "row",
|
|
254
|
+
removeAlign: removeAlignProp,
|
|
220
255
|
disabled = false,
|
|
221
256
|
labels: labelsProp,
|
|
222
257
|
className,
|
|
@@ -292,6 +327,9 @@ export function LineItems<T>({
|
|
|
292
327
|
// its own label, a row of headers would say each name twice.
|
|
293
328
|
const showHeader = columns.some((c) => !floats(c));
|
|
294
329
|
const pairs = narrowColumns === 2;
|
|
330
|
+
const removeAlign = removeAlignProp ?? (columns.some(floats) ? "center" : "start");
|
|
331
|
+
const inline = removePlacement === "inline" && Boolean(onRemove) && columns.length > 0;
|
|
332
|
+
const lastColumn = columns[columns.length - 1];
|
|
295
333
|
|
|
296
334
|
const headerCell = (column: LineItemsColumn<T>) => (
|
|
297
335
|
<span
|
|
@@ -305,7 +343,43 @@ export function LineItems<T>({
|
|
|
305
343
|
</span>
|
|
306
344
|
);
|
|
307
345
|
|
|
346
|
+
const removeCell = (index: number, row: number, isConfirming: boolean) => (
|
|
347
|
+
<div
|
|
348
|
+
data-line-items-remove-cell=""
|
|
349
|
+
className={cn(
|
|
350
|
+
"flex justify-end @lg:justify-center",
|
|
351
|
+
REMOVE_ALIGN[removeAlign],
|
|
352
|
+
// Paired, the remove button keeps a row of its own at the card's end rather
|
|
353
|
+
// than squeezing in beside the last field — unless asked to go `inline`.
|
|
354
|
+
inline ? "shrink-0" : pairs && "@2xs:col-span-2 @lg:col-span-1",
|
|
355
|
+
)}
|
|
356
|
+
>
|
|
357
|
+
<IconButton
|
|
358
|
+
type="button"
|
|
359
|
+
data-line-items-remove=""
|
|
360
|
+
variant={isConfirming ? "danger" : "ghost"}
|
|
361
|
+
tone={isConfirming ? undefined : "danger"}
|
|
362
|
+
label={isConfirming ? labels.confirmRemove(row) : labels.remove(row)}
|
|
363
|
+
disabled={!canRemove}
|
|
364
|
+
onClick={() => void removeAt(index)}
|
|
365
|
+
onBlur={() => {
|
|
366
|
+
if (isConfirming) setConfirming(null);
|
|
367
|
+
}}
|
|
368
|
+
onKeyDown={(e) => {
|
|
369
|
+
if (e.key === "Escape" && isConfirming) {
|
|
370
|
+
e.stopPropagation();
|
|
371
|
+
setConfirming(null);
|
|
372
|
+
}
|
|
373
|
+
}}
|
|
374
|
+
>
|
|
375
|
+
<Trash2 />
|
|
376
|
+
</IconButton>
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
|
|
308
380
|
const showTotals = totals !== undefined;
|
|
381
|
+
const summaryTone = summary?.tone ?? "neutral";
|
|
382
|
+
const summaryHasValue = hasContent(summary?.value);
|
|
309
383
|
const labelColumn = showTotals ? columns.find((c) => !hasContent(totals[c.key]))?.key : undefined;
|
|
310
384
|
|
|
311
385
|
return (
|
|
@@ -355,13 +429,15 @@ export function LineItems<T>({
|
|
|
355
429
|
>
|
|
356
430
|
{columns.map((column) => {
|
|
357
431
|
const floating = floats(column);
|
|
358
|
-
|
|
432
|
+
const isLast = inline && column === lastColumn;
|
|
433
|
+
const span = pairs && column.narrowSpan === 2 && "@2xs:col-span-2 @lg:col-span-1";
|
|
434
|
+
const cell = (
|
|
359
435
|
<div
|
|
360
436
|
key={column.key}
|
|
361
437
|
className={cn(
|
|
362
438
|
"min-w-0 space-y-1 @lg:space-y-0",
|
|
363
439
|
// The wide grid places every cell in its own track again.
|
|
364
|
-
|
|
440
|
+
isLast ? "flex-1" : span,
|
|
365
441
|
)}
|
|
366
442
|
>
|
|
367
443
|
{!floating && (
|
|
@@ -377,33 +453,23 @@ export function LineItems<T>({
|
|
|
377
453
|
})}
|
|
378
454
|
</div>
|
|
379
455
|
);
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
label={isConfirming ? labels.confirmRemove(row) : labels.remove(row)}
|
|
391
|
-
disabled={!canRemove}
|
|
392
|
-
onClick={() => void removeAt(index)}
|
|
393
|
-
onBlur={() => {
|
|
394
|
-
if (isConfirming) setConfirming(null);
|
|
395
|
-
}}
|
|
396
|
-
onKeyDown={(e) => {
|
|
397
|
-
if (e.key === "Escape" && isConfirming) {
|
|
398
|
-
e.stopPropagation();
|
|
399
|
-
setConfirming(null);
|
|
400
|
-
}
|
|
401
|
-
}}
|
|
456
|
+
if (!isLast) return cell;
|
|
457
|
+
// Inline, the last field and the remove button share one cell of the
|
|
458
|
+
// card; on the wide grid the wrapper dissolves (`contents`) and each is a
|
|
459
|
+
// track of its own again. Level with the field's control: its foot under
|
|
460
|
+
// a header copy, its middle when it floats its own label.
|
|
461
|
+
return (
|
|
462
|
+
<div
|
|
463
|
+
key={column.key}
|
|
464
|
+
data-line-items-inline=""
|
|
465
|
+
className={cn("flex min-w-0 gap-2 @lg:contents", floating ? "items-center" : "items-end", span)}
|
|
402
466
|
>
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
467
|
+
{cell}
|
|
468
|
+
{removeCell(index, row, isConfirming)}
|
|
469
|
+
</div>
|
|
470
|
+
);
|
|
471
|
+
})}
|
|
472
|
+
{onRemove && !inline && removeCell(index, row, isConfirming)}
|
|
407
473
|
</div>
|
|
408
474
|
);
|
|
409
475
|
})}
|
|
@@ -454,19 +520,29 @@ export function LineItems<T>({
|
|
|
454
520
|
data-line-items-summary=""
|
|
455
521
|
className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--text-primary)]"
|
|
456
522
|
>
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
523
|
+
{/* With no value the line is a state of its own, and the label takes the
|
|
524
|
+
tone — the only place left to carry it. */}
|
|
525
|
+
<span
|
|
526
|
+
data-tone={summaryHasValue ? undefined : summaryTone}
|
|
527
|
+
className={cn("min-w-0", !summaryHasValue && SUMMARY_TONE[summaryTone])}
|
|
528
|
+
>
|
|
529
|
+
{summary.label}
|
|
530
|
+
</span>
|
|
531
|
+
{summaryHasValue && (
|
|
532
|
+
<span data-tone={summaryTone} className={cn("font-semibold tabular-nums", SUMMARY_TONE[summaryTone])}>
|
|
463
533
|
{summary.value}
|
|
464
534
|
</span>
|
|
465
535
|
)}
|
|
536
|
+
{hasContent(summary.status) && (
|
|
537
|
+
<span data-line-items-summary-status="" className="inline-flex">
|
|
538
|
+
{summary.status}
|
|
539
|
+
</span>
|
|
540
|
+
)}
|
|
466
541
|
{summary.action && (
|
|
467
542
|
<Button
|
|
468
543
|
type="button"
|
|
469
|
-
variant="secondary"
|
|
544
|
+
variant={summary.action.variant === "link" ? "link" : "secondary"}
|
|
545
|
+
tone={summary.action.variant === "link" ? "muted" : undefined}
|
|
470
546
|
size="sm"
|
|
471
547
|
className="ms-auto"
|
|
472
548
|
disabled={disabled || summary.action.disabled}
|
package/src/components/list.tsx
CHANGED
|
@@ -160,7 +160,12 @@ interface ListItemBaseProps {
|
|
|
160
160
|
* never operated — no buttons, links or inputs (invalid HTML in a button, and folded
|
|
161
161
|
* into its name). Its text joins the row's accessible name, so give a bar that only
|
|
162
162
|
* repeats a figure already in the row `aria-hidden`. Rendered in a block `<span>`,
|
|
163
|
-
* full width of the
|
|
163
|
+
* full width of the TEXT COLUMN only — the column the title and subtitle are in, not
|
|
164
|
+
* the whole row: it starts after the {@link icon} / {@link leading} and stops where
|
|
165
|
+
* {@link trailing} begins, so a meter under a row with a trailing figure is as wide as
|
|
166
|
+
* the title, not run on under the figure (keksdose's budget rows). For a bar across
|
|
167
|
+
* the full row, put it in {@link children} (outside the target) or leave `trailing`
|
|
168
|
+
* off and show the figure in `meta`. Strictly a button holds phrasing content only; the
|
|
164
169
|
* kit's `ProgressBar` draws `<div>`s, which every browser lays out and reads fine
|
|
165
170
|
* there but an HTML validator flags.
|
|
166
171
|
*/
|
|
@@ -280,6 +285,13 @@ interface ListItemBaseProps {
|
|
|
280
285
|
* the list's child and keeps both. The `<li>` becomes a column and the row takes the
|
|
281
286
|
* full width, so an inline wrapper (the tooltip's `inline-flex` span) still stretches
|
|
282
287
|
* the row across the list.
|
|
288
|
+
*
|
|
289
|
+
* `row` is the row BOX (a `<div>`), not the target: a wrapper that forces a prop onto
|
|
290
|
+
* its child with `cloneElement` — a save guard that injects `disabled` while a form is
|
|
291
|
+
* dirty (keksdose's SaveGuard) — sets it on that div, where it does nothing, and the
|
|
292
|
+
* button inside stays live. Pass `disabled` (or `loading`) to the ListItem yourself,
|
|
293
|
+
* from the same state the guard reads, and let the wrapper only explain it:
|
|
294
|
+
* `<ListItem disabled={dirty} renderRow={(row) => <Tooltip label="Save first">{row}</Tooltip>} />`.
|
|
283
295
|
*/
|
|
284
296
|
renderRow?: (row: ReactElement) => ReactNode;
|
|
285
297
|
/** Reaches the main target — the element with the row's name and action. */
|
|
@@ -180,6 +180,16 @@ export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
180
180
|
* `tone="danger"` if it should read as over. Ignored for `segments` and `max={null}`.
|
|
181
181
|
*/
|
|
182
182
|
overage?: boolean | ((over: number) => ReactNode);
|
|
183
|
+
/**
|
|
184
|
+
* Tag every figure the bar PRINTS `data-private`, so the host's demo-mode rule blurs
|
|
185
|
+
* it — the attribute `StatTile sensitive` and `Tooltip redact` carry: the `showValue`
|
|
186
|
+
* text, each `legend` row's value (yours from `legendValue` too), the unlimited
|
|
187
|
+
* figure and the `overage` line. The track, the swatches and the part names stay
|
|
188
|
+
* readable — a blurred bar is no picture at all. Before this an app with a demo-mode
|
|
189
|
+
* blur had to pass `legendValue` only to wrap the figure in a tagged span (keksdose).
|
|
190
|
+
* The `aria-valuetext` is not touched: a screen reader is not a screen-share.
|
|
191
|
+
*/
|
|
192
|
+
sensitive?: boolean;
|
|
183
193
|
labels?: Partial<ProgressBarLabels>;
|
|
184
194
|
}
|
|
185
195
|
|
|
@@ -216,6 +226,7 @@ export function ProgressBar({
|
|
|
216
226
|
legendValue,
|
|
217
227
|
hint,
|
|
218
228
|
overage,
|
|
229
|
+
sensitive = false,
|
|
219
230
|
labels,
|
|
220
231
|
className,
|
|
221
232
|
as = "div",
|
|
@@ -233,6 +244,7 @@ export function ProgressBar({
|
|
|
233
244
|
const text = useKitLabels("progressBar", DEFAULT_PROGRESS_BAR_LABELS, labels);
|
|
234
245
|
const kitLocale = useKitLocale(locale);
|
|
235
246
|
const labelId = useId();
|
|
247
|
+
const priv = sensitive ? "" : undefined;
|
|
236
248
|
const hintId = useId();
|
|
237
249
|
const hintNode =
|
|
238
250
|
hint != null ? (
|
|
@@ -258,7 +270,7 @@ export function ProgressBar({
|
|
|
258
270
|
{label}
|
|
259
271
|
</span>
|
|
260
272
|
)}
|
|
261
|
-
<span data-part="unlimited" className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
|
|
273
|
+
<span data-part="unlimited" data-private={priv} className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
|
|
262
274
|
{shown !== undefined && `${shown} · `}
|
|
263
275
|
{text.unlimited}
|
|
264
276
|
</span>
|
|
@@ -320,7 +332,7 @@ export function ProgressBar({
|
|
|
320
332
|
overage && !stacked && value !== undefined && Number.isFinite(value) && value > max ? value - max : 0;
|
|
321
333
|
const overageNode =
|
|
322
334
|
over > 0 ? (
|
|
323
|
-
<p id={`${hintId}-over`} data-part="overage" className="mt-1 text-xs text-[var(--danger)]">
|
|
335
|
+
<p id={`${hintId}-over`} data-part="overage" data-private={priv} className="mt-1 text-xs text-[var(--danger)]">
|
|
324
336
|
{typeof overage === "function"
|
|
325
337
|
? overage(over)
|
|
326
338
|
: text.overLimit(new Intl.NumberFormat(kitLocale).format(over))}
|
|
@@ -344,7 +356,7 @@ export function ProgressBar({
|
|
|
344
356
|
{showValue && valueText !== undefined && (
|
|
345
357
|
// Hidden: the same words are the bar's aria-valuetext, and reading them
|
|
346
358
|
// twice in a row is noise.
|
|
347
|
-
<span aria-hidden className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
|
|
359
|
+
<span aria-hidden data-part="value" data-private={priv} className="ms-auto shrink-0 tabular-nums text-[var(--text-muted)]">
|
|
348
360
|
{valueText}
|
|
349
361
|
</span>
|
|
350
362
|
)}
|
|
@@ -426,7 +438,11 @@ export function ProgressBar({
|
|
|
426
438
|
{seg.label != null && (
|
|
427
439
|
<span className="min-w-0 flex-1 truncate text-[var(--text-secondary)]">{seg.label}</span>
|
|
428
440
|
)}
|
|
429
|
-
<span
|
|
441
|
+
<span
|
|
442
|
+
data-part="legend-value"
|
|
443
|
+
data-private={priv}
|
|
444
|
+
className="ms-auto shrink-0 text-xs tabular-nums text-[var(--text-muted)]"
|
|
445
|
+
>
|
|
430
446
|
{legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)}
|
|
431
447
|
</span>
|
|
432
448
|
</li>
|
|
@@ -106,8 +106,17 @@ export const DEFAULT_SIGNED_AMOUNT_LABELS: SignedAmountLabels = {
|
|
|
106
106
|
interface SignedFormatProps {
|
|
107
107
|
/** ISO 4217 code: format as money. */
|
|
108
108
|
currency?: string;
|
|
109
|
-
/** `"percent"` reads `value` as a RATIO (`0.12` → 12 %), as `StatTile`'s delta does
|
|
109
|
+
/** `"percent"` reads `value` as a RATIO (`0.12` → 12 %), as `StatTile`'s delta does
|
|
110
|
+
* — or as percent POINTS with `ratio={false}`. */
|
|
110
111
|
unit?: "value" | "percent";
|
|
112
|
+
/** With `unit="percent"`: `true` (default) — `value` is a ratio, `0.12` is 12 %.
|
|
113
|
+
* `false` — `value` is already in percent POINTS, `12` is 12 %, as `formatPercent`'s
|
|
114
|
+
* `ratio` option reads it. Backends send points (keksdose's price changes, kastlan's
|
|
115
|
+
* `change_pct`), and without this every site divided by 100 — and wrote its
|
|
116
|
+
* `flatWithin` as a ratio (`0.005`) beside a value it had to convert. With
|
|
117
|
+
* `ratio={false}` the value AND `flatWithin` are in points (`flatWithin={0.5}` is
|
|
118
|
+
* half a point). Ignored for any other unit. */
|
|
119
|
+
ratio?: boolean;
|
|
111
120
|
/** Compact notation: 12 400 → "12K". */
|
|
112
121
|
compact?: boolean;
|
|
113
122
|
/** Fraction digits, as `formatNumber`'s `digits`. */
|
|
@@ -118,11 +127,11 @@ interface SignedFormatProps {
|
|
|
118
127
|
locale?: string;
|
|
119
128
|
}
|
|
120
129
|
|
|
121
|
-
function useMagnitudeFormatter({ currency, unit, compact, digits, format, locale }: SignedFormatProps) {
|
|
130
|
+
function useMagnitudeFormatter({ currency, unit, ratio, compact, digits, format, locale }: SignedFormatProps) {
|
|
122
131
|
const kitLocale = useKitLocale(locale);
|
|
123
132
|
return (magnitude: number): string => {
|
|
124
133
|
if (format) return format(magnitude);
|
|
125
|
-
if (unit === "percent") return formatPercent(magnitude, { locale: kitLocale, compact, digits });
|
|
134
|
+
if (unit === "percent") return formatPercent(magnitude, { locale: kitLocale, compact, digits, ratio });
|
|
126
135
|
if (currency) return formatMoney(magnitude, currency, { locale: kitLocale, compact, digits });
|
|
127
136
|
return formatNumber(magnitude, { locale: kitLocale, compact, digits });
|
|
128
137
|
};
|
|
@@ -200,6 +209,7 @@ export function SignedAmount({
|
|
|
200
209
|
value,
|
|
201
210
|
currency,
|
|
202
211
|
unit,
|
|
212
|
+
ratio,
|
|
203
213
|
compact,
|
|
204
214
|
digits,
|
|
205
215
|
format,
|
|
@@ -216,7 +226,7 @@ export function SignedAmount({
|
|
|
216
226
|
...rest
|
|
217
227
|
}: SignedAmountProps) {
|
|
218
228
|
const text = useKitLabels("signedAmount", DEFAULT_SIGNED_AMOUNT_LABELS, labels);
|
|
219
|
-
const fmt = useMagnitudeFormatter({ currency, unit, compact, digits, format, locale });
|
|
229
|
+
const fmt = useMagnitudeFormatter({ currency, unit, ratio, compact, digits, format, locale });
|
|
220
230
|
const finite = Number.isFinite(value);
|
|
221
231
|
const dir = finite ? directionOf(value, flatWithin) : "flat";
|
|
222
232
|
const amount = finite ? fmt(Math.abs(value)) : "—";
|
|
@@ -281,6 +291,7 @@ export function Delta({
|
|
|
281
291
|
value,
|
|
282
292
|
currency,
|
|
283
293
|
unit,
|
|
294
|
+
ratio,
|
|
284
295
|
compact,
|
|
285
296
|
digits,
|
|
286
297
|
format,
|
|
@@ -296,7 +307,7 @@ export function Delta({
|
|
|
296
307
|
...rest
|
|
297
308
|
}: DeltaProps) {
|
|
298
309
|
const text = useKitLabels("statTile", DEFAULT_STAT_TILE_LABELS, labels);
|
|
299
|
-
const fmt = useMagnitudeFormatter({ currency, unit, compact, digits, format, locale });
|
|
310
|
+
const fmt = useMagnitudeFormatter({ currency, unit, ratio, compact, digits, format, locale });
|
|
300
311
|
if (!Number.isFinite(value)) return null;
|
|
301
312
|
const amount = fmt(Math.abs(value));
|
|
302
313
|
const dir = directionOf(value, flatWithin);
|
package/src/components/ui.tsx
CHANGED
|
@@ -2269,8 +2269,33 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
2269
2269
|
* nor on `inset`, which has no border to make louder (its tone is a fill).
|
|
2270
2270
|
*/
|
|
2271
2271
|
toneStrength?: "soft" | "strong";
|
|
2272
|
+
/**
|
|
2273
|
+
* Wash the card in the tone's quiet `-bg` surface as well as framing it. A toned
|
|
2274
|
+
* card is a border and a title colour, at either strength — the wash belongs to
|
|
2275
|
+
* `AlertBanner`, the message, not to a card that holds a form — so it stays
|
|
2276
|
+
* off by default. Some tiles want both: keksdose's password / delete-account tile
|
|
2277
|
+
* was `toneFrameClass(tone)` (border AND wash) before 0.16, and after moving to
|
|
2278
|
+
* `toneStrength="strong"` it re-added `bg-[var(--danger-bg)]` by hand. The wash is
|
|
2279
|
+
* layered OVER the card's own `--bg-surface` (as AlertBanner's `elevated` does), so
|
|
2280
|
+
* the dark theme's translucent tints stay a card, not a hole to the page below.
|
|
2281
|
+
*
|
|
2282
|
+
* No effect without `tone`, nor on `inset`, whose tone is already the fill.
|
|
2283
|
+
*/
|
|
2284
|
+
toneFill?: boolean;
|
|
2285
|
+
/**
|
|
2286
|
+
* The parts' type scale. `"compact"`: CardTitle `text-sm font-medium`,
|
|
2287
|
+
* CardDescription `text-xs` and the CardHeader's title-to-description gap
|
|
2288
|
+
* `gap-0.5` — the small settings / admin card. keksdose wrote those three classes by
|
|
2289
|
+
* hand on ~25 cards. Set here, they reach the parts through context; a class on a
|
|
2290
|
+
* part still wins, and a Card nested inside starts again from its own density.
|
|
2291
|
+
*
|
|
2292
|
+
* Default `"comfortable"`: the parts as they have always been.
|
|
2293
|
+
*/
|
|
2294
|
+
density?: CardDensity;
|
|
2272
2295
|
}
|
|
2273
2296
|
|
|
2297
|
+
export type CardDensity = "comfortable" | "compact";
|
|
2298
|
+
|
|
2274
2299
|
export type CardTone = "warning" | "danger" | "info" | "success";
|
|
2275
2300
|
|
|
2276
2301
|
// The title is reached through its `data-slot`, so the tone needs no context and a
|
|
@@ -2312,6 +2337,16 @@ const CARD_PADDING_STRONG: Record<NonNullable<CardProps["padding"]>, string> = {
|
|
|
2312
2337
|
md: "p-[15px]",
|
|
2313
2338
|
};
|
|
2314
2339
|
|
|
2340
|
+
// The wash as a background IMAGE over the card's opaque `--bg-surface` — see
|
|
2341
|
+
// `toneFill`. A plain `bg-[var(--danger-bg)]` would REPLACE the surface, and the dark
|
|
2342
|
+
// theme's washes are a translucent tint.
|
|
2343
|
+
const CARD_TONE_WASH: Record<CardTone, string> = {
|
|
2344
|
+
warning: "bg-[image:linear-gradient(var(--warning-bg),var(--warning-bg))]",
|
|
2345
|
+
danger: "bg-[image:linear-gradient(var(--danger-bg),var(--danger-bg))]",
|
|
2346
|
+
info: "bg-[image:linear-gradient(var(--info-bg),var(--info-bg))]",
|
|
2347
|
+
success: "bg-[image:linear-gradient(var(--success-bg),var(--success-bg))]",
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2315
2350
|
const CARD_TONES_STRONG: Record<CardTone, string> = {
|
|
2316
2351
|
warning: "border-2 border-[var(--warning)]",
|
|
2317
2352
|
danger: "border-2 border-[var(--danger-border-strong)]",
|
|
@@ -2326,6 +2361,9 @@ const CARD_TONES_STRONG: Record<CardTone, string> = {
|
|
|
2326
2361
|
*/
|
|
2327
2362
|
const CardPaddedContext = createContext(false);
|
|
2328
2363
|
|
|
2364
|
+
/** The {@link CardProps.density} of the nearest Card — the parts' type scale. */
|
|
2365
|
+
const CardDensityContext = createContext<CardDensity>("comfortable");
|
|
2366
|
+
|
|
2329
2367
|
export function Card({
|
|
2330
2368
|
className,
|
|
2331
2369
|
children,
|
|
@@ -2333,6 +2371,8 @@ export function Card({
|
|
|
2333
2371
|
variant = "default",
|
|
2334
2372
|
tone,
|
|
2335
2373
|
toneStrength = "soft",
|
|
2374
|
+
toneFill = false,
|
|
2375
|
+
density = "comfortable",
|
|
2336
2376
|
padding,
|
|
2337
2377
|
...rest
|
|
2338
2378
|
}: CardProps) {
|
|
@@ -2347,6 +2387,7 @@ export function Card({
|
|
|
2347
2387
|
<div
|
|
2348
2388
|
data-tone={tone}
|
|
2349
2389
|
data-tone-strength={strong ? "strong" : undefined}
|
|
2390
|
+
data-density={density === "compact" ? "compact" : undefined}
|
|
2350
2391
|
{...rest}
|
|
2351
2392
|
className={cn(
|
|
2352
2393
|
variant === "inset"
|
|
@@ -2365,12 +2406,15 @@ export function Card({
|
|
|
2365
2406
|
),
|
|
2366
2407
|
toned && (bordered ? toned.border : toned.fill),
|
|
2367
2408
|
strong && tone && CARD_TONES_STRONG[tone],
|
|
2409
|
+
toneFill && bordered && tone && CARD_TONE_WASH[tone],
|
|
2368
2410
|
toned?.title,
|
|
2369
2411
|
ownPadding && (strong ? CARD_PADDING_STRONG : CARD_PADDING)[ownPadding],
|
|
2370
2412
|
className,
|
|
2371
2413
|
)}
|
|
2372
2414
|
>
|
|
2373
|
-
<CardPaddedContext.Provider value={ownPadding !== undefined}>
|
|
2415
|
+
<CardPaddedContext.Provider value={ownPadding !== undefined}>
|
|
2416
|
+
<CardDensityContext.Provider value={density}>{children}</CardDensityContext.Provider>
|
|
2417
|
+
</CardPaddedContext.Provider>
|
|
2374
2418
|
</div>
|
|
2375
2419
|
);
|
|
2376
2420
|
}
|
|
@@ -2415,6 +2459,7 @@ export type CardFooterProps = ComponentPropsWithoutRef<"div">;
|
|
|
2415
2459
|
|
|
2416
2460
|
export function CardHeader({ className, stackAction = false, ...props }: CardHeaderProps) {
|
|
2417
2461
|
const padded = useContext(CardPaddedContext);
|
|
2462
|
+
const compact = useContext(CardDensityContext) === "compact";
|
|
2418
2463
|
return (
|
|
2419
2464
|
<div
|
|
2420
2465
|
data-slot="card-header"
|
|
@@ -2433,6 +2478,7 @@ export function CardHeader({ className, stackAction = false, ...props }: CardHea
|
|
|
2433
2478
|
// never inherits an ancestor header's two columns.
|
|
2434
2479
|
"grid auto-rows-min items-start gap-1.5 [--card-header-cols:1fr] has-data-[slot=card-action]:[--card-header-cols:1fr_auto] grid-cols-[var(--card-header-cols,1fr)]",
|
|
2435
2480
|
padded ? "px-0 pt-0" : "px-6 pt-6",
|
|
2481
|
+
compact && "gap-0.5",
|
|
2436
2482
|
// One column under `sm`, and the action back into the flow (DOM order, so
|
|
2437
2483
|
// after the description when it is written after it), at the start edge.
|
|
2438
2484
|
// The descendant selector outranks CardAction's own placement classes.
|
|
@@ -2446,10 +2492,18 @@ export function CardHeader({ className, stackAction = false, ...props }: CardHea
|
|
|
2446
2492
|
}
|
|
2447
2493
|
|
|
2448
2494
|
export function CardTitle({ className, as: Tag = "div", ...props }: CardTitleProps) {
|
|
2449
|
-
|
|
2495
|
+
const compact = useContext(CardDensityContext) === "compact";
|
|
2496
|
+
return (
|
|
2497
|
+
<Tag
|
|
2498
|
+
data-slot="card-title"
|
|
2499
|
+
className={cn(compact ? "text-sm font-medium" : "font-semibold", "leading-none", className)}
|
|
2500
|
+
{...props}
|
|
2501
|
+
/>
|
|
2502
|
+
);
|
|
2450
2503
|
}
|
|
2451
2504
|
|
|
2452
2505
|
export function CardDescription({ className, ...props }: CardDescriptionProps) {
|
|
2506
|
+
const compact = useContext(CardDensityContext) === "compact";
|
|
2453
2507
|
return (
|
|
2454
2508
|
<div
|
|
2455
2509
|
data-slot="card-description"
|
|
@@ -2457,7 +2511,7 @@ export function CardDescription({ className, ...props }: CardDescriptionProps) {
|
|
|
2457
2511
|
// field captions). It was `--money-neutral`, a MONEY token that happened to be
|
|
2458
2512
|
// a similar grey — and a palette that re-tunes the money colours moved every
|
|
2459
2513
|
// card's description with them.
|
|
2460
|
-
className={cn("text-sm text-[var(--text-muted)]", className)}
|
|
2514
|
+
className={cn(compact ? "text-xs" : "text-sm", "text-[var(--text-muted)]", className)}
|
|
2461
2515
|
{...props}
|
|
2462
2516
|
/>
|
|
2463
2517
|
);
|