@eifi1/ui-kit 0.16.1 → 0.18.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 +34 -9
- package/dist/chart.d.ts +10 -1
- package/dist/components/account-chips.d.ts +153 -0
- package/dist/components/account-chips.js +135 -0
- package/dist/components/account-chips.js.map +1 -0
- package/dist/components/account-settings-labels.d.ts +10 -2
- package/dist/components/account-settings-labels.js +3 -0
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/amount-input.d.ts +18 -1
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/button-group.d.ts +10 -1
- package/dist/components/calculator.d.ts +10 -1
- package/dist/components/chip.d.ts +25 -1
- package/dist/components/chip.js +143 -6
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +42 -1
- package/dist/components/confirm-dialog.js +36 -5
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +10 -1
- package/dist/components/danger-confirm.d.ts +55 -4
- package/dist/components/danger-confirm.js +67 -30
- package/dist/components/danger-confirm.js.map +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 +93 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +10 -1
- package/dist/components/file-button.d.ts +10 -1
- package/dist/components/file-dropzone.d.ts +10 -1
- package/dist/components/form-actions.d.ts +10 -1
- package/dist/components/form-actions.js +35 -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 +10 -1
- package/dist/components/number-field.d.ts +11 -2
- package/dist/components/number-field.js +23 -22
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +10 -1
- package/dist/components/numpad-sheet.d.ts +10 -1
- package/dist/components/passkeys-setting.d.ts +27 -1
- package/dist/components/passkeys-setting.js +31 -5
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +25 -1
- package/dist/components/progress-bar.js +21 -4
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +90 -0
- package/dist/components/reauth-dialog.js +158 -0
- package/dist/components/reauth-dialog.js.map +1 -0
- package/dist/components/series-chart.d.ts +10 -1
- package/dist/components/server-wake.d.ts +58 -0
- package/dist/components/server-wake.js +58 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +10 -1
- package/dist/components/share-card.d.ts +61 -0
- package/dist/components/share-card.js +350 -0
- package/dist/components/share-card.js.map +1 -0
- 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/status-dot.d.ts +2 -0
- package/dist/components/text-link.d.ts +10 -1
- package/dist/components/time-input.d.ts +10 -1
- package/dist/components/toast.d.ts +10 -1
- package/dist/components/toast.js +42 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +5 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/ui.d.ts +10 -1
- package/dist/components/ui.js +67 -30
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/user-avatar.d.ts +2 -0
- package/dist/components/write-lock.d.ts +68 -0
- package/dist/components/write-lock.js +33 -0
- package/dist/components/write-lock.js.map +1 -0
- package/dist/{data-table-CfVXtCeG.d.ts → data-table-3mi2R3so.d.ts} +59 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +10 -1
- package/dist/hooks/use-overlay-history.js +16 -0
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +10 -1
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +78 -0
- package/dist/i18n/{locales/de.js → german.js} +59 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +10 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH.d.ts +23 -8
- package/dist/i18n/locales/de-CH.js +7 -3
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/{de.d.ts → en.d.ts} +24 -10
- package/dist/i18n/locales/en.js +95 -0
- package/dist/i18n/locales/en.js.map +1 -0
- package/dist/i18n/locales/es.d.ts +10 -1
- package/dist/i18n/locales/es.js +51 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +10 -1
- package/dist/i18n/locales/fr.js +51 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +10 -1
- package/dist/i18n/locales/hu.js +54 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +10 -1
- package/dist/i18n/locales/it.js +51 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +10 -1
- package/dist/i18n/locales/zh.js +50 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.js +33 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-CPbsjlXr.d.ts} +282 -10
- package/dist/lib/server-wake.d.ts +130 -0
- package/dist/lib/server-wake.js +121 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/rhf/fields.d.ts +10 -1
- package/dist/rhf/form.d.ts +10 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +10 -1
- package/dist/search/global-search.d.ts +24 -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 +10 -1
- package/dist/shell/top-bar-brand.d.ts +10 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +10 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard.d.ts +10 -1
- package/package.json +1 -1
- package/src/components/account-chips.tsx +302 -0
- package/src/components/account-settings-labels.ts +16 -2
- package/src/components/amount-input.tsx +8 -0
- package/src/components/chip.tsx +239 -6
- package/src/components/confirm-dialog.tsx +82 -7
- package/src/components/danger-confirm.tsx +118 -33
- package/src/components/data-table.tsx +185 -3
- package/src/components/form-actions.tsx +93 -4
- package/src/components/line-items.tsx +116 -40
- package/src/components/list.tsx +13 -1
- package/src/components/number-field.tsx +32 -26
- package/src/components/passkeys-setting.tsx +68 -7
- package/src/components/progress-bar.tsx +40 -4
- package/src/components/reauth-dialog.tsx +273 -0
- package/src/components/server-wake.tsx +124 -0
- package/src/components/share-card.tsx +615 -0
- package/src/components/signed-amount.tsx +16 -5
- package/src/components/toast.tsx +72 -2
- package/src/components/toggle-group.tsx +5 -2
- package/src/components/ui.tsx +145 -34
- package/src/components/write-lock.tsx +96 -0
- package/src/hooks/use-overlay-history.ts +51 -0
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/{locales/de.ts → german.ts} +68 -13
- package/src/i18n/kit-labels.tsx +22 -2
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/en.ts +116 -0
- package/src/i18n/locales/es.ts +51 -0
- package/src/i18n/locales/fr.ts +52 -0
- package/src/i18n/locales/hu.ts +55 -0
- package/src/i18n/locales/it.ts +51 -0
- package/src/i18n/locales/zh.ts +50 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +23 -1
- package/src/lib/server-wake.ts +270 -0
- package/src/rhf/line-items.tsx +2 -1
- package/src/search/global-search.tsx +59 -6
- package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
- package/dist/i18n/locales/de-CH-informal.js +0 -8
- package/dist/i18n/locales/de-CH-informal.js.map +0 -1
- package/dist/i18n/locales/de-informal.d.ts +0 -82
- package/dist/i18n/locales/de-informal.js +0 -37
- package/dist/i18n/locales/de-informal.js.map +0 -1
- package/dist/i18n/locales/de.js.map +0 -1
- package/src/i18n/locales/de-CH-informal.ts +0 -10
- package/src/i18n/locales/de-informal.ts +0 -61
|
@@ -1,10 +1,113 @@
|
|
|
1
|
-
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
-
import type { ChangeEvent, ComponentPropsWithoutRef, FormEvent, ReactNode } from "react";
|
|
1
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import type { ChangeEvent, ComponentPropsWithoutRef, FormEvent, ReactNode, Ref } from "react";
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
4
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
5
|
import { Button, Input, Label, Spinner } from "./ui";
|
|
6
6
|
import { Tooltip } from "./tooltip";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* How typed text is compared with the text it has to repeat — shared by
|
|
10
|
+
* `DangerConfirm`'s `phraseMatch` and `useConfirm`'s `typedMatch`.
|
|
11
|
+
*
|
|
12
|
+
* - `"trim"` ignores spaces around the typed text — a phone keyboard's autocomplete
|
|
13
|
+
* likes to add one. Case counts: "delete" is not "DELETE".
|
|
14
|
+
* - `"exact"` compares character for character, spaces included.
|
|
15
|
+
* - `"caseless"` trims AND ignores case: for an e-mail address, whose domain is
|
|
16
|
+
* case-insensitive and whose local part every real mail server treats so. Both apps'
|
|
17
|
+
* "type the account's address" rules (keksdose's `TYPE_EMAIL`, Kurvenschmiede's
|
|
18
|
+
* `TypedConfirm`) compared `trim().toLowerCase()`, and a capital the user's keyboard
|
|
19
|
+
* put at the start must not make the right row look like the wrong one.
|
|
20
|
+
*/
|
|
21
|
+
export type TypedMatch = "trim" | "exact" | "caseless";
|
|
22
|
+
|
|
23
|
+
/** Whether `typed` repeats `target` under `mode` — see {@link TypedMatch}. Exported for
|
|
24
|
+
* a caller that draws its own field (a dialog that also picks a recipient) and wants
|
|
25
|
+
* the same rule as the kit's. */
|
|
26
|
+
export function typedMatches(typed: string, target: string, mode: TypedMatch = "trim"): boolean {
|
|
27
|
+
if (mode === "exact") return typed === target;
|
|
28
|
+
if (mode === "trim") return typed.trim() === target;
|
|
29
|
+
// `toLowerCase`, not `toLocaleLowerCase`: the comparison must not change with the
|
|
30
|
+
// reader's locale (a Turkish dotless i would otherwise fail an ASCII address).
|
|
31
|
+
return typed.trim().toLowerCase() === target.trim().toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Busy-while-a-promise-runs, for an action that MAY return one: `run(result, onResolved)`
|
|
36
|
+
* marks the caller pending until `result` settles, calls `onResolved` if it fulfilled,
|
|
37
|
+
* and only clears the flag if it rejected — the caller shows why and the user retries.
|
|
38
|
+
* Nothing runs after unmount. Shared by `DangerConfirm` and `ReauthDialog`, whose
|
|
39
|
+
* contract is the same: resolve = done, reject = stay put.
|
|
40
|
+
*
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export function usePromisePending() {
|
|
44
|
+
const [pending, setPending] = useState(false);
|
|
45
|
+
const mounted = useRef(true);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
mounted.current = true;
|
|
48
|
+
return () => {
|
|
49
|
+
mounted.current = false;
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
const run = useCallback((result: unknown, onResolved: () => void): boolean => {
|
|
53
|
+
if (!result || typeof (result as Promise<unknown>).then !== "function") return false;
|
|
54
|
+
setPending(true);
|
|
55
|
+
(result as Promise<unknown>).then(
|
|
56
|
+
() => {
|
|
57
|
+
if (!mounted.current) return;
|
|
58
|
+
setPending(false);
|
|
59
|
+
onResolved();
|
|
60
|
+
},
|
|
61
|
+
() => {
|
|
62
|
+
if (mounted.current) setPending(false);
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
return true;
|
|
66
|
+
}, []);
|
|
67
|
+
return { pending, run };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The "prove it is you" field: a password input the browser's password manager fills
|
|
72
|
+
* with the CURRENT password (`autocomplete="current-password"`), never offers to
|
|
73
|
+
* generate a new one for. `readOnly` rather than `disabled` while busy: disabling the
|
|
74
|
+
* field that has focus (Enter was pressed in it) drops focus to <body>.
|
|
75
|
+
*
|
|
76
|
+
* @internal DangerConfirm's field. ReauthDialog spells it out with the field's own
|
|
77
|
+
* `error`, which keeps the `<input>` mounted while its message comes and goes.
|
|
78
|
+
*/
|
|
79
|
+
export function CurrentPasswordInput({
|
|
80
|
+
inputRef,
|
|
81
|
+
label,
|
|
82
|
+
value,
|
|
83
|
+
onValueChange,
|
|
84
|
+
busy,
|
|
85
|
+
invalid,
|
|
86
|
+
describedBy,
|
|
87
|
+
}: {
|
|
88
|
+
inputRef?: Ref<HTMLInputElement>;
|
|
89
|
+
label: string;
|
|
90
|
+
value: string;
|
|
91
|
+
onValueChange: (value: string) => void;
|
|
92
|
+
busy: boolean;
|
|
93
|
+
invalid?: boolean;
|
|
94
|
+
describedBy?: string;
|
|
95
|
+
}) {
|
|
96
|
+
return (
|
|
97
|
+
<Input
|
|
98
|
+
ref={inputRef}
|
|
99
|
+
type="password"
|
|
100
|
+
autoComplete="current-password"
|
|
101
|
+
label={label}
|
|
102
|
+
value={value}
|
|
103
|
+
readOnly={busy}
|
|
104
|
+
invalid={invalid}
|
|
105
|
+
aria-describedby={describedBy}
|
|
106
|
+
onChange={(e) => onValueChange(e.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
8
111
|
/**
|
|
9
112
|
* Every string the tile renders — the `dangerConfirm` namespace of
|
|
10
113
|
* `<UiKitProvider labels>`, overridable per instance through `labels`. The arm and
|
|
@@ -65,9 +168,11 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
|
|
|
65
168
|
* How the typed text is compared with `phrase`. `"trim"` (default) ignores spaces
|
|
66
169
|
* around it — a phone keyboard's autocomplete likes to add one. `"exact"` compares
|
|
67
170
|
* character for character, spaces included, for an app whose contract is "type
|
|
68
|
-
* exactly this".
|
|
171
|
+
* exactly this". `"caseless"` (0.18) also ignores case — for a phrase that is an
|
|
172
|
+
* e-mail address (keksdose's "type the user's address" admin rule). See
|
|
173
|
+
* {@link TypedMatch}.
|
|
69
174
|
*/
|
|
70
|
-
phraseMatch?:
|
|
175
|
+
phraseMatch?: TypedMatch;
|
|
71
176
|
/** The warning above the fields. Defaults to `labels.prompt`. */
|
|
72
177
|
prompt?: ReactNode;
|
|
73
178
|
/** `"danger"` (default) for what cannot be undone; `"warning"` for what can, at a
|
|
@@ -140,7 +245,7 @@ export function DangerConfirm({
|
|
|
140
245
|
const armed = armedProp ?? armedState;
|
|
141
246
|
const [password, setPassword] = useState("");
|
|
142
247
|
const [typed, setTyped] = useState("");
|
|
143
|
-
const
|
|
248
|
+
const { pending, run } = usePromisePending();
|
|
144
249
|
const busy = Boolean(busyProp) || pending;
|
|
145
250
|
// `readOnly` rather than `disabled` on the fields while busy: disabling the field
|
|
146
251
|
// that has focus (Enter was pressed in it) drops focus to <body>.
|
|
@@ -155,13 +260,6 @@ export function DangerConfirm({
|
|
|
155
260
|
// Focus moves only after a transition — never on mount, so a tile that renders armed
|
|
156
261
|
// (controlled) does not take the page's focus merely by existing.
|
|
157
262
|
const moveFocus = useRef(false);
|
|
158
|
-
const mounted = useRef(true);
|
|
159
|
-
useEffect(() => {
|
|
160
|
-
mounted.current = true;
|
|
161
|
-
return () => {
|
|
162
|
-
mounted.current = false;
|
|
163
|
-
};
|
|
164
|
-
}, []);
|
|
165
263
|
|
|
166
264
|
// A disarm from anywhere (cancel, a resolved confirm, the parent) wipes the fields.
|
|
167
265
|
// During render, like NumberField's draft, so no frame shows a collapsed tile that
|
|
@@ -200,26 +298,15 @@ export function DangerConfirm({
|
|
|
200
298
|
|
|
201
299
|
const locked = lockedReason !== undefined && lockedReason !== null && lockedReason !== false && lockedReason !== "";
|
|
202
300
|
const passwordOk = !requirePassword || password !== "";
|
|
203
|
-
const phraseOk = phrase === undefined || (
|
|
301
|
+
const phraseOk = phrase === undefined || typedMatches(typed, phrase, phraseMatch);
|
|
204
302
|
const canConfirm = passwordOk && phraseOk && !busy;
|
|
205
303
|
|
|
206
304
|
const submit = (e: FormEvent) => {
|
|
207
305
|
e.preventDefault();
|
|
208
306
|
if (!canConfirm) return;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
(result as Promise<unknown>).then(
|
|
213
|
-
() => {
|
|
214
|
-
if (!mounted.current) return;
|
|
215
|
-
setPending(false);
|
|
216
|
-
setArmed(false);
|
|
217
|
-
},
|
|
218
|
-
() => {
|
|
219
|
-
// Stays armed, fields kept: the caller shows why, the user retries.
|
|
220
|
-
if (mounted.current) setPending(false);
|
|
221
|
-
},
|
|
222
|
-
);
|
|
307
|
+
// Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
|
|
308
|
+
// shows why, the user retries.
|
|
309
|
+
run(onConfirm(requirePassword ? password : undefined), () => setArmed(false));
|
|
223
310
|
};
|
|
224
311
|
|
|
225
312
|
const phrasePlaceholder =
|
|
@@ -309,15 +396,13 @@ export function DangerConfirm({
|
|
|
309
396
|
<Input label={phraseLabel} {...phraseFieldProps} />
|
|
310
397
|
))}
|
|
311
398
|
{requirePassword && (
|
|
312
|
-
<
|
|
399
|
+
<CurrentPasswordInput
|
|
313
400
|
// The phrase field takes the ref when both are there — it comes first.
|
|
314
|
-
|
|
315
|
-
type="password"
|
|
316
|
-
autoComplete="current-password"
|
|
401
|
+
inputRef={phrase === undefined ? firstFieldRef : undefined}
|
|
317
402
|
label={labels.password}
|
|
318
403
|
value={password}
|
|
319
|
-
|
|
320
|
-
|
|
404
|
+
busy={busy}
|
|
405
|
+
onValueChange={setPassword}
|
|
321
406
|
/>
|
|
322
407
|
)}
|
|
323
408
|
<div className="flex flex-wrap gap-2">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
1
|
+
import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { CSSProperties, ReactNode, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
4
|
import {
|
|
@@ -101,6 +101,26 @@ export interface DataTableColumn<T> {
|
|
|
101
101
|
* width a resize pins stay the table's, and the class list goes before
|
|
102
102
|
* `headClassName` for the same reason as in {@link cellProps}. */
|
|
103
103
|
headProps?: DataTableHeadProps;
|
|
104
|
+
/**
|
|
105
|
+
* This column's cell in the table's totals row — a `<tfoot>` on a wide screen, a
|
|
106
|
+
* labelled summary card under the list on a phone. kastlan's trial balance is why
|
|
107
|
+
* it exists: it hand-built a debit/credit/balance strip in a `div` UNDER the table,
|
|
108
|
+
* which lined up with no column, dropped out of the table's semantics, and stayed
|
|
109
|
+
* put when the user hid or resized the columns it was summing.
|
|
110
|
+
*
|
|
111
|
+
* `rows` is every row that passes the current filters (in the current sort order),
|
|
112
|
+
* NOT just the page on screen: a total that changes when the user pages is not a
|
|
113
|
+
* total, and the trial balance has to balance on page 1 as much as on page 7. The
|
|
114
|
+
* page is the second argument for the rare "subtotal of this page" footer.
|
|
115
|
+
*
|
|
116
|
+
* Under `serverPagination` the table only ever holds one page, so both arguments are
|
|
117
|
+
* that page — a server-side table computes its totals on the server and returns
|
|
118
|
+
* them here (`footer: () => fmt(response.totalDebit)`), ignoring the arguments.
|
|
119
|
+
*
|
|
120
|
+
* The cell takes the column's `className` (so a `text-end` amount column totals
|
|
121
|
+
* end-aligned), its resized width, and drops out with the column when it is hidden.
|
|
122
|
+
*/
|
|
123
|
+
footer?: (rows: T[], page: T[]) => ReactNode;
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
/** A `data-*` attribute, typed so `{ "data-private": "" }` needs no cast. */
|
|
@@ -335,6 +355,24 @@ export interface DataTableProps<T> {
|
|
|
335
355
|
* of unfolding it inline (feedback #204). Desktop always uses inline expansion.
|
|
336
356
|
*/
|
|
337
357
|
mobileExpandAsDialog?: boolean;
|
|
358
|
+
/**
|
|
359
|
+
* The phone row dialog's `backCloses` ({@link FullBleedDialog}): whether it pushes
|
|
360
|
+
* its own history entry so Back dismisses it. On by default — right for a row whose
|
|
361
|
+
* expansion is component state, where nothing else would answer Back.
|
|
362
|
+
*
|
|
363
|
+
* **Set it `false` when the open row is in the URL** and opening it PUSHES (a
|
|
364
|
+
* `?row=` written with `useSearchParamState(…, { history: "replace-on-clear" })` or
|
|
365
|
+
* `"push"`): Back already closes the row through the router, and a second entry on
|
|
366
|
+
* top of the page's is two owners of one Back press (keksdose dev #584). Keep it on
|
|
367
|
+
* when opening the row REPLACES the URL — then nothing was pushed, and the dialog's
|
|
368
|
+
* own entry is the only thing Back can pop.
|
|
369
|
+
*
|
|
370
|
+
* The table cannot decide this for you: `isExpanded` is a predicate, and neither it
|
|
371
|
+
* nor `rowHref` says whether opening pushed or replaced (keksdose's feedback page
|
|
372
|
+
* has `rowHref` with a REPLACING `?row=`, and needs the entry). Ignored unless
|
|
373
|
+
* `mobileExpandAsDialog`.
|
|
374
|
+
*/
|
|
375
|
+
mobileDialogBackCloses?: boolean;
|
|
338
376
|
/**
|
|
339
377
|
* Group the mobile card list into sections, each with a sticky header — the
|
|
340
378
|
* "assistance"-style list (like the transactions list grouped by date, but by
|
|
@@ -466,6 +504,26 @@ export interface DataTableProps<T> {
|
|
|
466
504
|
* pages set a search box in a `div` above the card, outside the frame it filters).
|
|
467
505
|
*/
|
|
468
506
|
toolbar?: ReactNode;
|
|
507
|
+
/**
|
|
508
|
+
* The totals row's name — "Total", "Summe", "Totals as of 30 June". It is the row's
|
|
509
|
+
* header: rendered as a `<th scope="row">` in the first visible column that has no
|
|
510
|
+
* `footer` of its own (falling back to the first visible column if every one has a
|
|
511
|
+
* value), so a screen reader reads "Total, Debit, 12 400.00" for the cell beside it.
|
|
512
|
+
* On a phone it heads the summary card, which is also named by it.
|
|
513
|
+
*
|
|
514
|
+
* There is no built-in default word: a new kit label key is a silent English string
|
|
515
|
+
* in every translated app (see `missingDataTableLabels`), and the caller has the
|
|
516
|
+
* word in its own catalogue already. Without it the row is unlabelled on screen —
|
|
517
|
+
* pass one.
|
|
518
|
+
*/
|
|
519
|
+
footerLabel?: ReactNode;
|
|
520
|
+
/**
|
|
521
|
+
* Show the totals row. Default: shown when any column declares a `footer`. Set
|
|
522
|
+
* false to drop it without touching the column definitions (a "show totals"
|
|
523
|
+
* switch). The row is also left out while there are no rows to total — a row of
|
|
524
|
+
* zeroes under "No results" says nothing the empty state has not.
|
|
525
|
+
*/
|
|
526
|
+
footer?: boolean;
|
|
469
527
|
}
|
|
470
528
|
|
|
471
529
|
/** One entry of {@link DataTableProps.rowActions}. */
|
|
@@ -590,6 +648,15 @@ function logicalAlign(className: string | undefined): string | undefined {
|
|
|
590
648
|
);
|
|
591
649
|
}
|
|
592
650
|
|
|
651
|
+
/**
|
|
652
|
+
* A totals-row cell: pinned to the scroller's bottom edge, on the header's surface.
|
|
653
|
+
* The rule above it is an inset shadow rather than a `border-t`: the table collapses
|
|
654
|
+
* its borders, and a collapsed border belongs to the TABLE, not the cell, so it stays
|
|
655
|
+
* behind at the bottom of the rows while the sticky cell rides over them.
|
|
656
|
+
*/
|
|
657
|
+
const FOOTER_CELL =
|
|
658
|
+
"sticky bottom-0 z-10 bg-[var(--bg-surface-2)] shadow-[inset_0_2px_0_var(--border-strong)]";
|
|
659
|
+
|
|
593
660
|
/** Whether a column is end-aligned — the header then puts its filter button first. */
|
|
594
661
|
const END_ALIGN = /(^|\s)text-end(?=\s|$)/;
|
|
595
662
|
|
|
@@ -786,6 +853,7 @@ export function DataTable<T>({
|
|
|
786
853
|
locale: localeProp,
|
|
787
854
|
storageKeyPrefix = DEFAULT_PERSIST_PREFIX,
|
|
788
855
|
mobileExpandAsDialog = false,
|
|
856
|
+
mobileDialogBackCloses = true,
|
|
789
857
|
mobileGroupBy,
|
|
790
858
|
mobileGroupLabel,
|
|
791
859
|
mobileCard,
|
|
@@ -801,6 +869,8 @@ export function DataTable<T>({
|
|
|
801
869
|
className,
|
|
802
870
|
rowActions,
|
|
803
871
|
toolbar,
|
|
872
|
+
footerLabel,
|
|
873
|
+
footer: footerProp,
|
|
804
874
|
}: DataTableProps<T>) {
|
|
805
875
|
const compact = density === "compact";
|
|
806
876
|
const minimal = chrome === "minimal";
|
|
@@ -1192,7 +1262,7 @@ export function DataTable<T>({
|
|
|
1192
1262
|
// `setWidths` is called once, when the drag ends.
|
|
1193
1263
|
const resizing = useRef<Resize | null>(null);
|
|
1194
1264
|
|
|
1195
|
-
/** Every cell in one column, header first. `data-col` rather than a cell index:
|
|
1265
|
+
/** Every cell in one column, header first, totals row included. `data-col` rather than a cell index:
|
|
1196
1266
|
* an expanded row holds a single `colSpan` cell, so the nth `<td>` of a row is
|
|
1197
1267
|
* not reliably the nth column. */
|
|
1198
1268
|
const columnCells = (key: string): HTMLElement[] => {
|
|
@@ -1202,7 +1272,7 @@ export function DataTable<T>({
|
|
|
1202
1272
|
if (!table) return [th];
|
|
1203
1273
|
return [
|
|
1204
1274
|
th,
|
|
1205
|
-
...Array.from(table.querySelectorAll<HTMLElement>("tbody [data-col]")).filter(
|
|
1275
|
+
...Array.from(table.querySelectorAll<HTMLElement>("tbody [data-col], tfoot [data-col]")).filter(
|
|
1206
1276
|
(el) => el.dataset.col === key,
|
|
1207
1277
|
),
|
|
1208
1278
|
];
|
|
@@ -1309,6 +1379,21 @@ export function DataTable<T>({
|
|
|
1309
1379
|
const totalCount = columns.length;
|
|
1310
1380
|
// Spans the full row width including the optional leading selection column.
|
|
1311
1381
|
const totalColSpan = visibleCount + (selection ? 1 : 0);
|
|
1382
|
+
|
|
1383
|
+
// ---- Totals row ----
|
|
1384
|
+
// `sorted`, not `slice`: the totals are of everything the filters let through, so
|
|
1385
|
+
// paging does not move them — see `DataTableColumn.footer`. In server mode `sorted`
|
|
1386
|
+
// IS the page (the table holds nothing else), which the prop's note spells out.
|
|
1387
|
+
const hasFooterColumns = columns.some((c) => c.footer);
|
|
1388
|
+
const showFooter = footerProp !== false && hasFooterColumns && sorted.length > 0;
|
|
1389
|
+
// The row header sits in the first visible column that has nothing of its own to
|
|
1390
|
+
// show — so hiding the "Account" column moves "Total" into "Name" rather than
|
|
1391
|
+
// dropping it — and on the first visible column when every one carries a value.
|
|
1392
|
+
const footerLabelCol = showFooter
|
|
1393
|
+
? (visibleColumns.find((c) => !c.footer && c.key !== ROW_ACTIONS_KEY) ?? visibleColumns[0])
|
|
1394
|
+
: undefined;
|
|
1395
|
+
const footerValue = (col: DataTableColumn<T>) => col.footer?.(sorted, slice);
|
|
1396
|
+
const footerHeadingId = useId();
|
|
1312
1397
|
// The actions column is the table's own, not one the user can hide: out of the count.
|
|
1313
1398
|
const columnsCountLabel = rowActions
|
|
1314
1399
|
? labels.columnsCount(visibleCount - 1, totalCount - 1)
|
|
@@ -1725,6 +1810,49 @@ export function DataTable<T>({
|
|
|
1725
1810
|
</li>
|
|
1726
1811
|
)}
|
|
1727
1812
|
</ul>
|
|
1813
|
+
{/* The phone's totals: a summary card after the list rather than one more
|
|
1814
|
+
`<li>` in it. The list reveals rows in chunks as it is scrolled, so a last
|
|
1815
|
+
item would sit mid-list until the end was reached; outside it, and under
|
|
1816
|
+
`fillHeight` outside the scroller too, the totals stay in view the way the
|
|
1817
|
+
sticky `<tfoot>` keeps them in view on a wide screen. Named by
|
|
1818
|
+
`footerLabel` so it is announced as what it is, not as a stray list of
|
|
1819
|
+
numbers; `mobileHidden` columns stay hidden here as on the cards. */}
|
|
1820
|
+
{showFooter && mobileColumns.some((c) => c.footer) && (
|
|
1821
|
+
<section
|
|
1822
|
+
data-table-footer=""
|
|
1823
|
+
aria-labelledby={footerLabel != null ? footerHeadingId : undefined}
|
|
1824
|
+
className={cn(
|
|
1825
|
+
"border-t-2 border-[var(--border-strong)] bg-[var(--bg-surface-2)]",
|
|
1826
|
+
compact ? "px-3 py-2" : "px-4 py-3",
|
|
1827
|
+
isLoading && "opacity-60 transition-opacity",
|
|
1828
|
+
)}
|
|
1829
|
+
>
|
|
1830
|
+
{footerLabel != null && (
|
|
1831
|
+
<div id={footerHeadingId} className={cn("font-semibold", compact ? "mb-1 text-sm" : "mb-2")}>
|
|
1832
|
+
{footerLabel}
|
|
1833
|
+
</div>
|
|
1834
|
+
)}
|
|
1835
|
+
<dl
|
|
1836
|
+
className={cn(
|
|
1837
|
+
"grid grid-cols-[auto_1fr] gap-x-3",
|
|
1838
|
+
compact ? "gap-y-0.5 text-xs" : "gap-y-1 text-sm",
|
|
1839
|
+
)}
|
|
1840
|
+
>
|
|
1841
|
+
{mobileColumns
|
|
1842
|
+
.filter((c) => c.footer)
|
|
1843
|
+
.map((col) => (
|
|
1844
|
+
<Fragment key={col.key}>
|
|
1845
|
+
<dt className="self-center text-xs uppercase tracking-wide text-[var(--text-muted)]">
|
|
1846
|
+
{col.header}
|
|
1847
|
+
</dt>
|
|
1848
|
+
<dd className="min-w-0 self-center font-semibold text-[var(--text-primary)]">
|
|
1849
|
+
{footerValue(col)}
|
|
1850
|
+
</dd>
|
|
1851
|
+
</Fragment>
|
|
1852
|
+
))}
|
|
1853
|
+
</dl>
|
|
1854
|
+
</section>
|
|
1855
|
+
)}
|
|
1728
1856
|
{/* Client-side mobile lists scroll endlessly (sentinel above); only
|
|
1729
1857
|
server-paginated tables keep the pager here. */}
|
|
1730
1858
|
{isServer && (
|
|
@@ -1747,6 +1875,7 @@ export function DataTable<T>({
|
|
|
1747
1875
|
onClose={closeDialog}
|
|
1748
1876
|
closeLabel={labels.close}
|
|
1749
1877
|
header={mobileDialogRow ? mobilePrimaryCol?.cell(mobileDialogRow) : null}
|
|
1878
|
+
backCloses={mobileDialogBackCloses}
|
|
1750
1879
|
>
|
|
1751
1880
|
{mobileDialogContent}
|
|
1752
1881
|
</FullBleedDialog>
|
|
@@ -2166,6 +2295,59 @@ export function DataTable<T>({
|
|
|
2166
2295
|
</tr>
|
|
2167
2296
|
)}
|
|
2168
2297
|
</tbody>
|
|
2298
|
+
{showFooter && (
|
|
2299
|
+
// Sticky to the scroller's bottom edge for the same reason the header is
|
|
2300
|
+
// sticky to its top: the body scrolls inside a bounded wrapper, and a
|
|
2301
|
+
// total that scrolls away with the last row is a total nobody sees on a
|
|
2302
|
+
// 25-row page. Each cell carries its own background and its own `sticky`,
|
|
2303
|
+
// as the header's do — `position: sticky` on a `<tfoot>` is not reliable.
|
|
2304
|
+
<tfoot
|
|
2305
|
+
data-table-footer=""
|
|
2306
|
+
className={cn(
|
|
2307
|
+
"font-semibold text-[var(--text-primary)]",
|
|
2308
|
+
isLoading && "opacity-60 transition-opacity",
|
|
2309
|
+
)}
|
|
2310
|
+
>
|
|
2311
|
+
<tr>
|
|
2312
|
+
{/* The selection column has nothing to total: an empty cell keeps the
|
|
2313
|
+
values under their own headers. */}
|
|
2314
|
+
{selection && (
|
|
2315
|
+
<td
|
|
2316
|
+
className={cn(
|
|
2317
|
+
FOOTER_CELL,
|
|
2318
|
+
compact ? "w-8 px-2 py-1" : "w-10 px-3 py-2",
|
|
2319
|
+
)}
|
|
2320
|
+
/>
|
|
2321
|
+
)}
|
|
2322
|
+
{visibleColumns.map((col) => {
|
|
2323
|
+
const width = widths[col.key];
|
|
2324
|
+
const pinned = width ? { width, minWidth: width, maxWidth: width } : undefined;
|
|
2325
|
+
const isLabel = col === footerLabelCol;
|
|
2326
|
+
const Cell = isLabel ? "th" : "td";
|
|
2327
|
+
return (
|
|
2328
|
+
<Cell
|
|
2329
|
+
key={col.key}
|
|
2330
|
+
data-col={col.key}
|
|
2331
|
+
scope={isLabel ? "row" : undefined}
|
|
2332
|
+
style={pinned}
|
|
2333
|
+
className={cn(
|
|
2334
|
+
FOOTER_CELL,
|
|
2335
|
+
"align-top",
|
|
2336
|
+
compact ? "px-2 py-1" : "px-3 py-2",
|
|
2337
|
+
width && "overflow-hidden text-ellipsis",
|
|
2338
|
+
// A `<th>` centres by default; the label reads from the start
|
|
2339
|
+
// like the cells above it, unless the column says otherwise.
|
|
2340
|
+
isLabel && "text-start",
|
|
2341
|
+
logicalAlign(col.className),
|
|
2342
|
+
)}
|
|
2343
|
+
>
|
|
2344
|
+
{col.footer ? footerValue(col) : isLabel ? footerLabel : null}
|
|
2345
|
+
</Cell>
|
|
2346
|
+
);
|
|
2347
|
+
})}
|
|
2348
|
+
</tr>
|
|
2349
|
+
</tfoot>
|
|
2350
|
+
)}
|
|
2169
2351
|
</table>
|
|
2170
2352
|
</div>
|
|
2171
2353
|
{!unpaged && (
|
|
@@ -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,57 @@ 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. The breakpoints are the VIEWPORT's, not the
|
|
41
|
+
* container's: a form in a narrow pane on a wide screen resolves to `md`.
|
|
42
|
+
*/
|
|
43
|
+
export interface ResponsiveFormActionsPlacement {
|
|
44
|
+
base: FormActionsPlacement;
|
|
45
|
+
sm?: FormActionsPlacement;
|
|
46
|
+
md?: FormActionsPlacement;
|
|
47
|
+
lg?: FormActionsPlacement;
|
|
48
|
+
xl?: FormActionsPlacement;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const BREAKPOINTS = [
|
|
52
|
+
["xl", "(min-width: 1280px)"],
|
|
53
|
+
["lg", "(min-width: 1024px)"],
|
|
54
|
+
["md", "(min-width: 768px)"],
|
|
55
|
+
["sm", "(min-width: 640px)"],
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
/** The placement in force: the widest breakpoint that matches AND names one, else
|
|
59
|
+
* `base`. The queries are subscribed unconditionally (hooks cannot be skipped); a
|
|
60
|
+
* plain string ignores them. Without `matchMedia` (SSR, tests) none match, so the row
|
|
61
|
+
* renders its `base`, the phone's. The same resolution as BulkActionBar's variant. */
|
|
62
|
+
function useResolvedPlacement(
|
|
63
|
+
placement: FormActionsPlacement | ResponsiveFormActionsPlacement,
|
|
64
|
+
): FormActionsPlacement {
|
|
65
|
+
const matches = {
|
|
66
|
+
xl: useMediaQuery(BREAKPOINTS[0][1], false),
|
|
67
|
+
lg: useMediaQuery(BREAKPOINTS[1][1], false),
|
|
68
|
+
md: useMediaQuery(BREAKPOINTS[2][1], false),
|
|
69
|
+
sm: useMediaQuery(BREAKPOINTS[3][1], false),
|
|
70
|
+
};
|
|
71
|
+
if (typeof placement === "string") return placement;
|
|
72
|
+
for (const [key] of BREAKPOINTS) {
|
|
73
|
+
const p = placement[key];
|
|
74
|
+
if (matches[key] && p !== undefined) return p;
|
|
75
|
+
}
|
|
76
|
+
return placement.base;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A CSS length: a number is pixels. */
|
|
80
|
+
function cssLength(value: string | number): string {
|
|
81
|
+
return typeof value === "number" ? `${value}px` : value;
|
|
82
|
+
}
|
|
83
|
+
|
|
32
84
|
/**
|
|
33
85
|
* What a `sticky` row sticks to — see {@link FormActionsProps.stickyWithin}.
|
|
34
86
|
* - `viewport` (default): the page's (or AppShell pane's) bottom edge, lifted onto
|
|
@@ -45,7 +97,7 @@ export type FormActionsStickyWithin = "viewport" | "container";
|
|
|
45
97
|
*/
|
|
46
98
|
export type FormActionsSubmitProps = Omit<
|
|
47
99
|
ButtonProps,
|
|
48
|
-
"type" | "onClick" | "disabled" | "disabledReason" | "variant" | "form" | "children" | "aria-busy"
|
|
100
|
+
"type" | "onClick" | "disabled" | "disabledReason" | "variant" | "form" | "children" | "aria-busy" | "commit"
|
|
49
101
|
> & { [key: `data-${string}`]: string | number | boolean | undefined };
|
|
50
102
|
|
|
51
103
|
/** The start-side action of {@link FormActionsProps.destructive}, as data. */
|
|
@@ -106,6 +158,18 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
106
158
|
submitDisabled?: boolean;
|
|
107
159
|
/** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */
|
|
108
160
|
submitDisabledReason?: ReactNode;
|
|
161
|
+
/**
|
|
162
|
+
* Save is a commit: under a locked {@link WriteLockProvider} it is disabled with the
|
|
163
|
+
* lock's reason (over `submitDisabledReason`), focusable, the reason in its tooltip —
|
|
164
|
+
* Button's `commit`. The `{ label, onClick }` form of {@link destructive} is a
|
|
165
|
+
* commit too and is locked with it; Cancel and `children` are not (closing a form
|
|
166
|
+
* writes nothing). No provider, or an unlocked one: no effect. Default `false`.
|
|
167
|
+
*
|
|
168
|
+
* keksdose wrote `submitDisabled={lock.locked || …}` and
|
|
169
|
+
* `submitDisabledReason={lock.locked ? lock.reason : undefined}` at each form; this is
|
|
170
|
+
* that pair, read from the provider.
|
|
171
|
+
*/
|
|
172
|
+
commit?: boolean;
|
|
109
173
|
/** The save button's variant. Default `brand`; `danger` for a save that destroys
|
|
110
174
|
* (kastlan's `destructive` flag on its own FormActions). */
|
|
111
175
|
submitVariant?: ButtonVariant;
|
|
@@ -125,8 +189,21 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
125
189
|
start?: ReactNode;
|
|
126
190
|
/** Horizontal alignment. Default `end` (`between` with a `destructive` action). */
|
|
127
191
|
align?: FormActionsAlign;
|
|
128
|
-
/** See {@link FormActionsPlacement}
|
|
129
|
-
|
|
192
|
+
/** See {@link FormActionsPlacement}, or one per breakpoint — see
|
|
193
|
+
* {@link ResponsiveFormActionsPlacement}. Default `inline`. */
|
|
194
|
+
placement?: FormActionsPlacement | ResponsiveFormActionsPlacement;
|
|
195
|
+
/**
|
|
196
|
+
* While the row is `sticky`: pull it out over its container's inline padding by this
|
|
197
|
+
* much (a CSS length, a number in px) and give the same back as padding — so the
|
|
198
|
+
* rule above it and its surface run edge to edge, while the buttons stay on the
|
|
199
|
+
* content's line. Without it a sticky row in a padded card or pane is a strip inset
|
|
200
|
+
* from both sides, with the content scrolling past it in the gutters (keksdose wrote
|
|
201
|
+
* `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`"0.75rem"`,
|
|
202
|
+
* `12`, `"var(--pane-px)"`). Applied only while the resolved placement is sticky, so
|
|
203
|
+
* `placement={{ base: "sticky", md: "inline" }}` bleeds on the phone alone. The margin
|
|
204
|
+
* is `calc(-1 * X)`; jsdom folds that to `calc(-X)`, so match it loosely in tests.
|
|
205
|
+
*/
|
|
206
|
+
bleed?: string | number;
|
|
130
207
|
/**
|
|
131
208
|
* With `placement="sticky"`: what the row sticks to. Default `viewport`.
|
|
132
209
|
*
|
|
@@ -186,12 +263,14 @@ export function FormActions({
|
|
|
186
263
|
pending = false,
|
|
187
264
|
submitDisabled = false,
|
|
188
265
|
submitDisabledReason,
|
|
266
|
+
commit = false,
|
|
189
267
|
submitVariant = "brand",
|
|
190
268
|
destructive,
|
|
191
269
|
start: startSlot,
|
|
192
270
|
align,
|
|
193
|
-
placement = "inline",
|
|
271
|
+
placement: placementProp = "inline",
|
|
194
272
|
stickyWithin = "viewport",
|
|
273
|
+
bleed,
|
|
195
274
|
form,
|
|
196
275
|
children,
|
|
197
276
|
className,
|
|
@@ -199,6 +278,8 @@ export function FormActions({
|
|
|
199
278
|
...rest
|
|
200
279
|
}: FormActionsProps) {
|
|
201
280
|
const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
|
|
281
|
+
const placement = useResolvedPlacement(placementProp);
|
|
282
|
+
const bleedLength = bleed === undefined || bleed === "" ? undefined : cssLength(bleed);
|
|
202
283
|
const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
|
|
203
284
|
const hasStart = startSlot !== undefined && startSlot !== null && startSlot !== false;
|
|
204
285
|
const justify = align ?? (hasDestructive || hasStart ? "between" : "end");
|
|
@@ -210,6 +291,7 @@ export function FormActions({
|
|
|
210
291
|
onClick={destructive.onClick}
|
|
211
292
|
disabled={destructive.disabled}
|
|
212
293
|
disabledReason={destructive.disabledReason}
|
|
294
|
+
commit={commit}
|
|
213
295
|
>
|
|
214
296
|
{destructive.label}
|
|
215
297
|
</Button>
|
|
@@ -235,6 +317,10 @@ export function FormActions({
|
|
|
235
317
|
// container the nav is not underneath: `--app-nav-h` is the document's,
|
|
236
318
|
// and would hold the row that far above the container's own edge.
|
|
237
319
|
bottom: stickyWithin === "container" ? 0 : "max(0px, calc(var(--app-nav-h, 0px) - 1px))",
|
|
320
|
+
...(bleedLength !== undefined && {
|
|
321
|
+
marginInline: `calc(-1 * ${bleedLength})`,
|
|
322
|
+
paddingInline: bleedLength,
|
|
323
|
+
}),
|
|
238
324
|
...style,
|
|
239
325
|
}
|
|
240
326
|
: style
|
|
@@ -262,6 +348,9 @@ export function FormActions({
|
|
|
262
348
|
variant={submitVariant}
|
|
263
349
|
disabled={pending || submitDisabled}
|
|
264
350
|
disabledReason={pending ? undefined : submitDisabledReason}
|
|
351
|
+
// Not while pending: the save already left, and the lock's look over the
|
|
352
|
+
// spinner would say it had not.
|
|
353
|
+
commit={commit && !pending}
|
|
265
354
|
aria-busy={pending || undefined}
|
|
266
355
|
>
|
|
267
356
|
{pending ? (
|