@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.
Files changed (186) hide show
  1. package/README.md +34 -9
  2. package/dist/chart.d.ts +10 -1
  3. package/dist/components/account-chips.d.ts +153 -0
  4. package/dist/components/account-chips.js +135 -0
  5. package/dist/components/account-chips.js.map +1 -0
  6. package/dist/components/account-settings-labels.d.ts +10 -2
  7. package/dist/components/account-settings-labels.js +3 -0
  8. package/dist/components/account-settings-labels.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +18 -1
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/button-group.d.ts +10 -1
  12. package/dist/components/calculator.d.ts +10 -1
  13. package/dist/components/chip.d.ts +25 -1
  14. package/dist/components/chip.js +143 -6
  15. package/dist/components/chip.js.map +1 -1
  16. package/dist/components/confirm-dialog.d.ts +42 -1
  17. package/dist/components/confirm-dialog.js +36 -5
  18. package/dist/components/confirm-dialog.js.map +1 -1
  19. package/dist/components/copy-button.d.ts +10 -1
  20. package/dist/components/danger-confirm.d.ts +55 -4
  21. package/dist/components/danger-confirm.js +67 -30
  22. package/dist/components/danger-confirm.js.map +1 -1
  23. package/dist/components/data-table-cells.d.ts +1 -1
  24. package/dist/components/data-table-filter-popover.d.ts +1 -1
  25. package/dist/components/data-table-filters.d.ts +1 -1
  26. package/dist/components/data-table.d.ts +1 -1
  27. package/dist/components/data-table.js +93 -3
  28. package/dist/components/data-table.js.map +1 -1
  29. package/dist/components/facing-pair.d.ts +10 -1
  30. package/dist/components/file-button.d.ts +10 -1
  31. package/dist/components/file-dropzone.d.ts +10 -1
  32. package/dist/components/form-actions.d.ts +10 -1
  33. package/dist/components/form-actions.js +35 -1
  34. package/dist/components/form-actions.js.map +1 -1
  35. package/dist/components/line-items.d.ts +30 -6
  36. package/dist/components/line-items.js +77 -33
  37. package/dist/components/line-items.js.map +1 -1
  38. package/dist/components/list.d.ts +13 -1
  39. package/dist/components/list.js.map +1 -1
  40. package/dist/components/money-field.d.ts +10 -1
  41. package/dist/components/number-field.d.ts +11 -2
  42. package/dist/components/number-field.js +23 -22
  43. package/dist/components/number-field.js.map +1 -1
  44. package/dist/components/number-input.d.ts +10 -1
  45. package/dist/components/numpad-sheet.d.ts +10 -1
  46. package/dist/components/passkeys-setting.d.ts +27 -1
  47. package/dist/components/passkeys-setting.js +31 -5
  48. package/dist/components/passkeys-setting.js.map +1 -1
  49. package/dist/components/progress-bar.d.ts +25 -1
  50. package/dist/components/progress-bar.js +21 -4
  51. package/dist/components/progress-bar.js.map +1 -1
  52. package/dist/components/reauth-dialog.d.ts +90 -0
  53. package/dist/components/reauth-dialog.js +158 -0
  54. package/dist/components/reauth-dialog.js.map +1 -0
  55. package/dist/components/series-chart.d.ts +10 -1
  56. package/dist/components/server-wake.d.ts +58 -0
  57. package/dist/components/server-wake.js +58 -0
  58. package/dist/components/server-wake.js.map +1 -0
  59. package/dist/components/settings-fields.d.ts +10 -1
  60. package/dist/components/share-card.d.ts +61 -0
  61. package/dist/components/share-card.js +350 -0
  62. package/dist/components/share-card.js.map +1 -0
  63. package/dist/components/signed-amount.d.ts +12 -3
  64. package/dist/components/signed-amount.js +6 -4
  65. package/dist/components/signed-amount.js.map +1 -1
  66. package/dist/components/status-dot.d.ts +2 -0
  67. package/dist/components/text-link.d.ts +10 -1
  68. package/dist/components/time-input.d.ts +10 -1
  69. package/dist/components/toast.d.ts +10 -1
  70. package/dist/components/toast.js +42 -2
  71. package/dist/components/toast.js.map +1 -1
  72. package/dist/components/toggle-group.js +5 -2
  73. package/dist/components/toggle-group.js.map +1 -1
  74. package/dist/components/ui.d.ts +10 -1
  75. package/dist/components/ui.js +67 -30
  76. package/dist/components/ui.js.map +1 -1
  77. package/dist/components/use-table-state.d.ts +1 -1
  78. package/dist/components/user-avatar.d.ts +2 -0
  79. package/dist/components/write-lock.d.ts +68 -0
  80. package/dist/components/write-lock.js +33 -0
  81. package/dist/components/write-lock.js.map +1 -0
  82. package/dist/{data-table-CfVXtCeG.d.ts → data-table-3mi2R3so.d.ts} +59 -1
  83. package/dist/data-table.d.ts +1 -1
  84. package/dist/hooks/use-file-drop.d.ts +10 -1
  85. package/dist/hooks/use-overlay-history.js +16 -0
  86. package/dist/hooks/use-overlay-history.js.map +1 -1
  87. package/dist/i18n/defaults.d.ts +10 -1
  88. package/dist/i18n/defaults.js +11 -1
  89. package/dist/i18n/defaults.js.map +1 -1
  90. package/dist/i18n/german.d.ts +78 -0
  91. package/dist/i18n/{locales/de.js → german.js} +59 -9
  92. package/dist/i18n/german.js.map +1 -0
  93. package/dist/i18n/kit-labels.d.ts +10 -1
  94. package/dist/i18n/kit-labels.js +5 -0
  95. package/dist/i18n/kit-labels.js.map +1 -1
  96. package/dist/i18n/locales/de-CH.d.ts +23 -8
  97. package/dist/i18n/locales/de-CH.js +7 -3
  98. package/dist/i18n/locales/de-CH.js.map +1 -1
  99. package/dist/i18n/locales/{de.d.ts → en.d.ts} +24 -10
  100. package/dist/i18n/locales/en.js +95 -0
  101. package/dist/i18n/locales/en.js.map +1 -0
  102. package/dist/i18n/locales/es.d.ts +10 -1
  103. package/dist/i18n/locales/es.js +51 -1
  104. package/dist/i18n/locales/es.js.map +1 -1
  105. package/dist/i18n/locales/fr.d.ts +10 -1
  106. package/dist/i18n/locales/fr.js +51 -1
  107. package/dist/i18n/locales/fr.js.map +1 -1
  108. package/dist/i18n/locales/hu.d.ts +10 -1
  109. package/dist/i18n/locales/hu.js +54 -1
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.d.ts +10 -1
  112. package/dist/i18n/locales/it.js +51 -1
  113. package/dist/i18n/locales/it.js.map +1 -1
  114. package/dist/i18n/locales/zh.d.ts +10 -1
  115. package/dist/i18n/locales/zh.js +50 -1
  116. package/dist/i18n/locales/zh.js.map +1 -1
  117. package/dist/i18n/swiss.d.ts +3 -3
  118. package/dist/i18n/swiss.js.map +1 -1
  119. package/dist/index.d.ts +11 -6
  120. package/dist/index.js +33 -1
  121. package/dist/index.js.map +1 -1
  122. package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-CPbsjlXr.d.ts} +282 -10
  123. package/dist/lib/server-wake.d.ts +130 -0
  124. package/dist/lib/server-wake.js +121 -0
  125. package/dist/lib/server-wake.js.map +1 -0
  126. package/dist/rhf/fields.d.ts +10 -1
  127. package/dist/rhf/form.d.ts +10 -1
  128. package/dist/rhf/line-items.js.map +1 -1
  129. package/dist/rhf.d.ts +10 -1
  130. package/dist/search/global-search.d.ts +24 -4
  131. package/dist/search/global-search.js +31 -3
  132. package/dist/search/global-search.js.map +1 -1
  133. package/dist/search.d.ts +1 -1
  134. package/dist/shell/app-shell.d.ts +10 -1
  135. package/dist/shell/top-bar-brand.d.ts +10 -1
  136. package/dist/shell/topbar-action-menu.d.ts +2 -0
  137. package/dist/shell.d.ts +10 -1
  138. package/dist/wizard/stepper-nav.d.ts +10 -1
  139. package/dist/wizard.d.ts +10 -1
  140. package/package.json +1 -1
  141. package/src/components/account-chips.tsx +302 -0
  142. package/src/components/account-settings-labels.ts +16 -2
  143. package/src/components/amount-input.tsx +8 -0
  144. package/src/components/chip.tsx +239 -6
  145. package/src/components/confirm-dialog.tsx +82 -7
  146. package/src/components/danger-confirm.tsx +118 -33
  147. package/src/components/data-table.tsx +185 -3
  148. package/src/components/form-actions.tsx +93 -4
  149. package/src/components/line-items.tsx +116 -40
  150. package/src/components/list.tsx +13 -1
  151. package/src/components/number-field.tsx +32 -26
  152. package/src/components/passkeys-setting.tsx +68 -7
  153. package/src/components/progress-bar.tsx +40 -4
  154. package/src/components/reauth-dialog.tsx +273 -0
  155. package/src/components/server-wake.tsx +124 -0
  156. package/src/components/share-card.tsx +615 -0
  157. package/src/components/signed-amount.tsx +16 -5
  158. package/src/components/toast.tsx +72 -2
  159. package/src/components/toggle-group.tsx +5 -2
  160. package/src/components/ui.tsx +145 -34
  161. package/src/components/write-lock.tsx +96 -0
  162. package/src/hooks/use-overlay-history.ts +51 -0
  163. package/src/i18n/defaults.ts +10 -0
  164. package/src/i18n/{locales/de.ts → german.ts} +68 -13
  165. package/src/i18n/kit-labels.tsx +22 -2
  166. package/src/i18n/locales/de-CH.ts +17 -8
  167. package/src/i18n/locales/en.ts +116 -0
  168. package/src/i18n/locales/es.ts +51 -0
  169. package/src/i18n/locales/fr.ts +52 -0
  170. package/src/i18n/locales/hu.ts +55 -0
  171. package/src/i18n/locales/it.ts +51 -0
  172. package/src/i18n/locales/zh.ts +50 -0
  173. package/src/i18n/swiss.ts +3 -3
  174. package/src/index.ts +23 -1
  175. package/src/lib/server-wake.ts +270 -0
  176. package/src/rhf/line-items.tsx +2 -1
  177. package/src/search/global-search.tsx +59 -6
  178. package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
  179. package/dist/i18n/locales/de-CH-informal.js +0 -8
  180. package/dist/i18n/locales/de-CH-informal.js.map +0 -1
  181. package/dist/i18n/locales/de-informal.d.ts +0 -82
  182. package/dist/i18n/locales/de-informal.js +0 -37
  183. package/dist/i18n/locales/de-informal.js.map +0 -1
  184. package/dist/i18n/locales/de.js.map +0 -1
  185. package/src/i18n/locales/de-CH-informal.ts +0 -10
  186. 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?: "trim" | "exact";
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 [pending, setPending] = useState(false);
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 || (phraseMatch === "exact" ? typed : typed.trim()) === phrase;
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
- const result = onConfirm(requirePassword ? password : undefined);
210
- if (!result || typeof (result as Promise<unknown>).then !== "function") return;
211
- setPending(true);
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
- <Input
399
+ <CurrentPasswordInput
313
400
  // The phrase field takes the ref when both are there — it comes first.
314
- ref={phrase === undefined ? firstFieldRef : undefined}
315
- type="password"
316
- autoComplete="current-password"
401
+ inputRef={phrase === undefined ? firstFieldRef : undefined}
317
402
  label={labels.password}
318
403
  value={password}
319
- readOnly={busy}
320
- onChange={(e) => setPassword(e.target.value)}
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}. Default `inline`. */
129
- placement?: FormActionsPlacement;
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 ? (