@eifi1/ui-kit 0.12.0 → 0.13.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 (197) hide show
  1. package/README.md +10 -10
  2. package/dist/components/amount-input.d.ts +17 -1
  3. package/dist/components/amount-input.js +46 -11
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/bulk-action-bar.d.ts +10 -2
  6. package/dist/components/bulk-action-bar.js.map +1 -1
  7. package/dist/components/button-group.d.ts +1 -1
  8. package/dist/components/calculator.d.ts +1 -1
  9. package/dist/components/chart.js +14 -8
  10. package/dist/components/chart.js.map +1 -1
  11. package/dist/components/chip.js +20 -15
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/combobox-core.d.ts +3 -1
  14. package/dist/components/combobox-core.js +16 -5
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.js.map +1 -1
  17. package/dist/components/copy-button.d.ts +1 -1
  18. package/dist/components/data-table-filter-popover.js +8 -1
  19. package/dist/components/data-table-filter-popover.js.map +1 -1
  20. package/dist/components/data-table.js +28 -20
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/entity-combobox.js +22 -17
  23. package/dist/components/entity-combobox.js.map +1 -1
  24. package/dist/components/error-boundary.d.ts +202 -13
  25. package/dist/components/error-boundary.js +211 -20
  26. package/dist/components/error-boundary.js.map +1 -1
  27. package/dist/components/field-sync.js +90 -83
  28. package/dist/components/field-sync.js.map +1 -1
  29. package/dist/components/file-button.d.ts +1 -1
  30. package/dist/components/file-dropzone.d.ts +1 -1
  31. package/dist/components/form-actions.d.ts +1 -1
  32. package/dist/components/full-bleed-dialog.js +4 -0
  33. package/dist/components/full-bleed-dialog.js.map +1 -1
  34. package/dist/components/hover-menu.js +64 -56
  35. package/dist/components/hover-menu.js.map +1 -1
  36. package/dist/components/list.d.ts +14 -2
  37. package/dist/components/list.js +3 -1
  38. package/dist/components/list.js.map +1 -1
  39. package/dist/components/modal.js.map +1 -1
  40. package/dist/components/multi-entity-combobox.js +23 -17
  41. package/dist/components/multi-entity-combobox.js.map +1 -1
  42. package/dist/components/number-field.d.ts +1 -1
  43. package/dist/components/number-input.d.ts +1 -1
  44. package/dist/components/number-input.js.map +1 -1
  45. package/dist/components/numpad-sheet.d.ts +6 -2
  46. package/dist/components/numpad-sheet.js +3 -2
  47. package/dist/components/numpad-sheet.js.map +1 -1
  48. package/dist/components/page-header.d.ts +35 -6
  49. package/dist/components/page-header.js +7 -1
  50. package/dist/components/page-header.js.map +1 -1
  51. package/dist/components/picker-sheet.js +3 -0
  52. package/dist/components/picker-sheet.js.map +1 -1
  53. package/dist/components/popover.js +3 -0
  54. package/dist/components/popover.js.map +1 -1
  55. package/dist/components/settings-fields.d.ts +1 -1
  56. package/dist/components/signed-amount.d.ts +2 -1
  57. package/dist/components/signed-amount.js +1 -1
  58. package/dist/components/signed-amount.js.map +1 -1
  59. package/dist/components/stat-tile.js +1 -1
  60. package/dist/components/stat-tile.js.map +1 -1
  61. package/dist/components/swipeable-row.js +4 -3
  62. package/dist/components/swipeable-row.js.map +1 -1
  63. package/dist/components/text-link.d.ts +73 -11
  64. package/dist/components/text-link.js +45 -10
  65. package/dist/components/text-link.js.map +1 -1
  66. package/dist/components/text.d.ts +26 -2
  67. package/dist/components/text.js +16 -2
  68. package/dist/components/text.js.map +1 -1
  69. package/dist/components/time-input.d.ts +1 -1
  70. package/dist/components/toggle-group.d.ts +22 -0
  71. package/dist/components/toggle-group.js +8 -8
  72. package/dist/components/toggle-group.js.map +1 -1
  73. package/dist/components/toggle-legend.d.ts +17 -0
  74. package/dist/components/toggle-legend.js +6 -2
  75. package/dist/components/toggle-legend.js.map +1 -1
  76. package/dist/components/tooltip.d.ts +51 -1
  77. package/dist/components/tooltip.js +59 -35
  78. package/dist/components/tooltip.js.map +1 -1
  79. package/dist/components/ui.d.ts +1 -1
  80. package/dist/components/ui.js +166 -28
  81. package/dist/components/ui.js.map +1 -1
  82. package/dist/feedback/feedback-attachment.js +4 -6
  83. package/dist/feedback/feedback-attachment.js.map +1 -1
  84. package/dist/feedback/feedback-dialog.js +5 -3
  85. package/dist/feedback/feedback-dialog.js.map +1 -1
  86. package/dist/feedback/feedback-inbox.js +86 -75
  87. package/dist/feedback/feedback-inbox.js.map +1 -1
  88. package/dist/feedback/feedback-thread.d.ts +43 -3
  89. package/dist/feedback/feedback-thread.js +53 -26
  90. package/dist/feedback/feedback-thread.js.map +1 -1
  91. package/dist/feedback.d.ts +1 -1
  92. package/dist/hooks/use-file-drop.d.ts +1 -1
  93. package/dist/i18n/defaults.d.ts +1 -1
  94. package/dist/i18n/kit-labels.d.ts +1 -1
  95. package/dist/i18n/kit-labels.js.map +1 -1
  96. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  97. package/dist/i18n/locales/de-CH.d.ts +1 -1
  98. package/dist/i18n/locales/de-informal.d.ts +1 -1
  99. package/dist/i18n/locales/de.d.ts +1 -1
  100. package/dist/i18n/locales/de.js +9 -1
  101. package/dist/i18n/locales/de.js.map +1 -1
  102. package/dist/i18n/locales/es.d.ts +1 -1
  103. package/dist/i18n/locales/es.js +9 -1
  104. package/dist/i18n/locales/es.js.map +1 -1
  105. package/dist/i18n/locales/fr.d.ts +1 -1
  106. package/dist/i18n/locales/fr.js +9 -1
  107. package/dist/i18n/locales/fr.js.map +1 -1
  108. package/dist/i18n/locales/hu.d.ts +1 -1
  109. package/dist/i18n/locales/hu.js +9 -1
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.d.ts +1 -1
  112. package/dist/i18n/locales/it.js +9 -1
  113. package/dist/i18n/locales/it.js.map +1 -1
  114. package/dist/i18n/locales/zh.d.ts +1 -1
  115. package/dist/i18n/locales/zh.js +9 -1
  116. package/dist/i18n/locales/zh.js.map +1 -1
  117. package/dist/index.d.ts +6 -6
  118. package/dist/{kit-labels-v3biUF1L.d.ts → kit-labels-Mz8zZHIF.d.ts} +102 -3
  119. package/dist/lib/format.d.ts +23 -5
  120. package/dist/lib/format.js +3 -1
  121. package/dist/lib/format.js.map +1 -1
  122. package/dist/rhf/fields.d.ts +57 -15
  123. package/dist/rhf/fields.js +41 -11
  124. package/dist/rhf/fields.js.map +1 -1
  125. package/dist/rhf/form.d.ts +1 -1
  126. package/dist/rhf.d.ts +2 -2
  127. package/dist/rhf.js.map +1 -1
  128. package/dist/search/command-palette.js +20 -14
  129. package/dist/search/command-palette.js.map +1 -1
  130. package/dist/shell/app-shell.d.ts +1 -1
  131. package/dist/shell/app-shell.js +62 -54
  132. package/dist/shell/app-shell.js.map +1 -1
  133. package/dist/shell/top-bar-brand.d.ts +1 -1
  134. package/dist/shell/topbar-action-menu.d.ts +9 -3
  135. package/dist/shell/topbar-action-menu.js +2 -1
  136. package/dist/shell/topbar-action-menu.js.map +1 -1
  137. package/dist/shell.d.ts +1 -1
  138. package/dist/tour/tour.js +10 -4
  139. package/dist/tour/tour.js.map +1 -1
  140. package/dist/wizard/stepper-nav.d.ts +1 -1
  141. package/dist/wizard/use-wizard.js +4 -2
  142. package/dist/wizard/use-wizard.js.map +1 -1
  143. package/dist/wizard/wizard-step.d.ts +11 -1
  144. package/dist/wizard/wizard-step.js +24 -6
  145. package/dist/wizard/wizard-step.js.map +1 -1
  146. package/dist/wizard.d.ts +1 -1
  147. package/package.json +6 -6
  148. package/src/components/amount-input.tsx +97 -10
  149. package/src/components/bulk-action-bar.tsx +16 -3
  150. package/src/components/chart.tsx +19 -8
  151. package/src/components/chip.tsx +6 -0
  152. package/src/components/combobox-core.tsx +28 -9
  153. package/src/components/combobox.tsx +2 -0
  154. package/src/components/data-table-filter-popover.tsx +13 -5
  155. package/src/components/data-table.tsx +6 -0
  156. package/src/components/entity-combobox.tsx +7 -4
  157. package/src/components/error-boundary.tsx +453 -29
  158. package/src/components/field-sync.tsx +48 -40
  159. package/src/components/full-bleed-dialog.tsx +4 -0
  160. package/src/components/hover-menu.tsx +26 -17
  161. package/src/components/list.tsx +17 -3
  162. package/src/components/modal.tsx +4 -0
  163. package/src/components/multi-entity-combobox.tsx +8 -4
  164. package/src/components/number-input.tsx +1 -0
  165. package/src/components/numpad-sheet.tsx +6 -1
  166. package/src/components/page-header.tsx +42 -5
  167. package/src/components/picker-sheet.tsx +3 -0
  168. package/src/components/popover.tsx +3 -0
  169. package/src/components/signed-amount.tsx +12 -3
  170. package/src/components/stat-tile.tsx +3 -1
  171. package/src/components/swipeable-row.tsx +7 -7
  172. package/src/components/text-link.tsx +126 -18
  173. package/src/components/text.tsx +46 -2
  174. package/src/components/toggle-group.tsx +35 -10
  175. package/src/components/toggle-legend.tsx +22 -1
  176. package/src/components/tooltip.tsx +79 -8
  177. package/src/components/ui.tsx +307 -39
  178. package/src/feedback/feedback-attachment.tsx +10 -7
  179. package/src/feedback/feedback-dialog.tsx +7 -4
  180. package/src/feedback/feedback-inbox.tsx +7 -0
  181. package/src/feedback/feedback-thread.tsx +109 -33
  182. package/src/i18n/kit-labels.tsx +16 -0
  183. package/src/i18n/locales/de.ts +8 -0
  184. package/src/i18n/locales/es.ts +8 -0
  185. package/src/i18n/locales/fr.ts +8 -0
  186. package/src/i18n/locales/hu.ts +8 -0
  187. package/src/i18n/locales/it.ts +8 -0
  188. package/src/i18n/locales/zh.ts +8 -0
  189. package/src/lib/format.ts +29 -6
  190. package/src/rhf/fields.tsx +97 -16
  191. package/src/rhf.ts +1 -1
  192. package/src/search/command-palette.tsx +29 -19
  193. package/src/shell/app-shell.tsx +6 -1
  194. package/src/shell/topbar-action-menu.tsx +11 -4
  195. package/src/tour/tour.tsx +16 -5
  196. package/src/wizard/use-wizard.ts +8 -4
  197. package/src/wizard/wizard-step.tsx +38 -6
@@ -1,8 +1,9 @@
1
1
  import { Component } from "react";
2
2
  import type { ErrorInfo, ReactNode } from "react";
3
- import { AlertTriangle } from "lucide-react";
3
+ import { AlertTriangle, RefreshCw, WifiOff } from "lucide-react";
4
4
  import { useKitLabels } from "../i18n/kit-labels";
5
5
  import { Button, EmptyState } from "./ui";
6
+ import { CopyButton } from "./copy-button";
6
7
  import { Disclosure } from "./disclosure";
7
8
 
8
9
  /** The words of the default fallback. */
@@ -13,8 +14,24 @@ export interface ErrorBoundaryLabels {
13
14
  message: string;
14
15
  /** The button that clears the error and renders the children again. */
15
16
  retry: string;
16
- /** The disclosure that opens the error's own text (`showDetails`). */
17
+ /** The disclosure that opens the full error report (`showDetails`). */
17
18
  details: string;
19
+ /** The button that reloads the whole page (0.13). */
20
+ reload: string;
21
+ /** Heading when a lazy chunk failed to load while online — a deploy replaced the
22
+ * files this page was built from (0.13). */
23
+ updateTitle: string;
24
+ updateMessage: string;
25
+ /** Heading when the browser says it is offline (0.13). */
26
+ offlineTitle: string;
27
+ offlineMessage: string;
28
+ /** The copy button's words: what it copies is the plain-text report (0.13). */
29
+ copyReport: string;
30
+ /** Shown once `onReport` has filed the crash and returned no reference (0.13). */
31
+ reported: string;
32
+ /** Shown once `onReport` has filed the crash under a reference — rendered as the
33
+ * app returned it, so return `"#123"` if the hash belongs to it (0.13). */
34
+ reportedAs: (reference: string) => string;
18
35
  }
19
36
 
20
37
  export const DEFAULT_ERROR_BOUNDARY_LABELS: ErrorBoundaryLabels = {
@@ -22,6 +39,14 @@ export const DEFAULT_ERROR_BOUNDARY_LABELS: ErrorBoundaryLabels = {
22
39
  message: "This part of the page could not be shown. Try again, or reload the page.",
23
40
  retry: "Try again",
24
41
  details: "Error details",
42
+ reload: "Reload",
43
+ updateTitle: "A new version is available",
44
+ updateMessage: "Part of the app has changed since this page was loaded. Reload to get the new version.",
45
+ offlineTitle: "You’re offline",
46
+ offlineMessage: "This page could not be loaded without a connection. Reconnect, then reload the page.",
47
+ copyReport: "Copy error report",
48
+ reported: "This error has been reported automatically.",
49
+ reportedAs: (reference) => `Reported as ${reference}`,
25
50
  };
26
51
 
27
52
  /**
@@ -61,6 +86,178 @@ export function describeThrown(error: unknown): ErrorBoundaryDetails {
61
86
  return { name: "Error", message: read(() => error) ?? "" };
62
87
  }
63
88
 
89
+ /**
90
+ * Is this a failed lazy import — a route chunk that did not arrive?
91
+ *
92
+ * Two causes, and neither is a bug in the page: the device is offline and the chunk was
93
+ * never cached, or a deploy replaced the hashed files the running page still points at.
94
+ * Both are cured by a reload and by nothing else — `React.lazy` caches the rejected
95
+ * import, so Try again re-throws the same error without fetching. The patterns are
96
+ * keksdose's (crash-report.ts), which covers Vite ("dynamically imported module",
97
+ * "module script failed"), webpack ("ChunkLoadError", "Loading chunk") and the bare
98
+ * `fetch` failure a service worker hands back.
99
+ *
100
+ * Never throws: it runs inside the fallback's own render, where a throw is a white
101
+ * screen. An error it cannot read is not a chunk failure.
102
+ */
103
+ export function isChunkLoadError(
104
+ error: { name?: string | null; message?: string | null } | null | undefined,
105
+ ): boolean {
106
+ try {
107
+ const s = `${error?.name ?? ""} ${error?.message ?? ""}`;
108
+ return /ChunkLoadError|dynamically imported module|module script failed|Loading chunk|Failed to fetch/i.test(s);
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Everything a crash report carries, read once when the boundary catches.
116
+ *
117
+ * Plain strings only, so it survives `JSON.stringify` into an app's crash endpoint as is
118
+ * and {@link formatCrashReport} can print it without a single guard. The wording of the
119
+ * printed report is English on purpose: it is read by whoever fixes the bug, not by the
120
+ * user who copies it, and a report in nine languages cannot be searched.
121
+ */
122
+ export interface CrashReport {
123
+ /** The thrown value's `name` ("TypeError"), "Error" for a non-object. */
124
+ name: string;
125
+ message: string;
126
+ stack?: string;
127
+ /** React's component stack from `componentDidCatch` — the part that names the
128
+ * component, which a minified JS stack no longer does. */
129
+ componentStack?: string;
130
+ /** `pathname + search` at the moment of the crash. The search may carry what the
131
+ * user typed — see `redact`. */
132
+ page: string;
133
+ /** ISO 8601, UTC. */
134
+ time: string;
135
+ userAgent: string;
136
+ /** The boundary's `appVersion` prop. */
137
+ appVersion?: string;
138
+ /** `navigator.onLine` at the moment of the crash; left out where there is none. */
139
+ online?: boolean;
140
+ /** {@link isChunkLoadError} of the thrown value. Such a crash is never handed to
141
+ * `onReport` — it is a deploy or a dropped connection, not a defect — but it is
142
+ * still in the copied report. */
143
+ chunkLoad: boolean;
144
+ /** The boundary's `reportExtras()`, stringified. */
145
+ extras: Record<string, string>;
146
+ }
147
+
148
+ /**
149
+ * The report as one plain-text block — what the copy button puts on the clipboard and
150
+ * the details disclosure shows.
151
+ *
152
+ * lenkbank's layout, because that is the one that was already being pasted into
153
+ * feedback messages: the error line first (it is what a search for the bug matches),
154
+ * then where / when / what, then the two stacks.
155
+ */
156
+ export function formatCrashReport(report: CrashReport): string {
157
+ const lines = [
158
+ report.message ? `${report.name}: ${report.message}` : report.name,
159
+ `Page: ${report.page}`,
160
+ `Time: ${report.time}`,
161
+ `User agent: ${report.userAgent}`,
162
+ ];
163
+ if (report.appVersion) lines.push(`App version: ${report.appVersion}`);
164
+ if (report.online === false) lines.push("Online: no");
165
+ for (const [key, value] of Object.entries(report.extras)) lines.push(`${key}: ${value}`);
166
+ if (report.stack) lines.push("", "Stack:", report.stack.trim());
167
+ if (report.componentStack) lines.push("", "Component stack:", report.componentStack.trim());
168
+ return lines.join("\n");
169
+ }
170
+
171
+ /**
172
+ * A key for "the same crash again": the error's name and message and the first frames
173
+ * of its stack.
174
+ *
175
+ * Page-load local, not a server-side grouping key (keksdose's crash-report.ts draws the
176
+ * same line): its only job is to stop one page load filing one bug twice — React's dev
177
+ * StrictMode, a Try again that crashes again, a render loop that trips the boundary many
178
+ * times a second. The page and the time are left out on purpose: the same bug on two
179
+ * routes is still one bug.
180
+ */
181
+ export function crashFingerprint(report: Pick<CrashReport, "name" | "message" | "stack">): string {
182
+ const frames = (report.stack ?? "").split("\n").slice(0, 4).join("|");
183
+ return `${report.name}::${report.message}::${frames}`;
184
+ }
185
+
186
+ /**
187
+ * What `onReport` may resolve with. `{ reference }` is shown ("Reported as …");
188
+ * nothing (or `{}`) means filed without one. `{ filed: false }` says the app decided NOT
189
+ * to file (a demo session, a hot-reload artefact, a report buffered for later): the
190
+ * screen then claims nothing and keeps the copy button — claiming a report that was
191
+ * never sent is the same false statement as hiding one that was.
192
+ */
193
+ export type CrashReportResult = { reference?: string; filed?: boolean } | void;
194
+
195
+ interface Filing {
196
+ filed: boolean;
197
+ reference?: string;
198
+ }
199
+
200
+ const NOT_FILED: Filing = { filed: false };
201
+
202
+ /** A render loop can trip a boundary dozens of times a second with a different message
203
+ * each time (a counter in it); without a ceiling the first bad deploy files hundreds of
204
+ * rows. keksdose's number. */
205
+ const MAX_REPORTS_PER_PAGE_LOAD = 5;
206
+
207
+ /** Fingerprint → the one filing for it in this page load. Module scope IS the page
208
+ * load: a reload starts it empty. A duplicate gets the first filing's promise, so the
209
+ * second screen shows the same reference instead of claiming nothing. */
210
+ const filings = new Map<string, Promise<Filing>>();
211
+
212
+ function settle(out: unknown): Filing {
213
+ try {
214
+ if (out && typeof out === "object") {
215
+ const o = out as { filed?: unknown; reference?: unknown };
216
+ if (o.filed === false) return NOT_FILED;
217
+ const reference = typeof o.reference === "string" && o.reference ? o.reference : undefined;
218
+ return { filed: true, reference };
219
+ }
220
+ return { filed: true };
221
+ } catch {
222
+ return NOT_FILED;
223
+ }
224
+ }
225
+
226
+ /** Hand the report to the app, once per fingerprint per page load, and never throw —
227
+ * not for a synchronous throw, a rejection, or a hostile thenable. */
228
+ function fileOnce(report: CrashReport, onReport: (report: CrashReport) => unknown): Promise<Filing> {
229
+ const key = crashFingerprint(report);
230
+ const known = filings.get(key);
231
+ if (known) return known;
232
+ if (filings.size >= MAX_REPORTS_PER_PAGE_LOAD) return Promise.resolve(NOT_FILED);
233
+ let filing: Promise<Filing>;
234
+ try {
235
+ filing = Promise.resolve(onReport(report)).then(settle, () => NOT_FILED);
236
+ } catch {
237
+ filing = Promise.resolve(NOT_FILED);
238
+ }
239
+ // Claimed before the first await: two catches in the same tick (StrictMode) must not
240
+ // both get past the lookup above.
241
+ filings.set(key, filing);
242
+ return filing;
243
+ }
244
+
245
+ function isOffline(): boolean {
246
+ try {
247
+ return typeof navigator !== "undefined" && navigator.onLine === false;
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+
253
+ function reloadPage(): void {
254
+ try {
255
+ window.location.reload();
256
+ } catch {
257
+ /* nothing left to do from here */
258
+ }
259
+ }
260
+
64
261
  /** What a render-prop `fallback` is handed. */
65
262
  export interface ErrorBoundaryFallbackProps {
66
263
  /** The thrown value, as it was thrown. Read it with care — see {@link describeThrown}. */
@@ -68,16 +265,21 @@ export interface ErrorBoundaryFallbackProps {
68
265
  details: ErrorBoundaryDetails;
69
266
  /** Clear the error and render the children again. */
70
267
  reset: () => void;
268
+ /** The crash report, once `componentDidCatch` has built it (null on the very first
269
+ * render of the fallback, which happens before it). Already `redact`ed. */
270
+ report: CrashReport | null;
71
271
  }
72
272
 
73
273
  export interface ErrorBoundaryProps {
74
274
  children?: ReactNode;
75
275
  /** Replaces the default fallback: a node, or a render function handed the error and
76
- * a `reset`. */
276
+ * a `reset`. The same rule applies to it as to the default: see the component docs. */
77
277
  fallback?: ReactNode | ((props: ErrorBoundaryFallbackProps) => ReactNode);
78
- /** Called once per caught error — log it, file a crash report. */
278
+ /** Called once per caught error with the raw value — log it. A throw from it is
279
+ * swallowed: the boundary is the last thing standing. For filing a report, prefer
280
+ * `onReport`, which gets the finished report, dedupe and the reference on screen. */
79
281
  onError?: (error: unknown, info: ErrorInfo) => void;
80
- /** Called when the error is cleared, by Retry, `reset` or a `resetKeys` change —
282
+ /** Called when the error is cleared, by Try again, `reset` or a `resetKeys` change —
81
283
  * drop a cache, refetch. */
82
284
  onReset?: () => void;
83
285
  /**
@@ -87,9 +289,59 @@ export interface ErrorBoundaryProps {
87
289
  * carry its fallback to the next one (keksdose's `resetKey`).
88
290
  */
89
291
  resetKeys?: ReadonlyArray<unknown>;
90
- /** A disclosure under the message with the error's name, message and stack — for an
91
- * internal tool, or while developing. Off by default: a stack means nothing to a user. */
292
+ /** Quote the error's `name: message` in a monospace box under the hint. Default on
293
+ * (0.13): three sentences true of every crash identify none of them, and the one
294
+ * line that does is what a user can read out or screenshot (lenkbank P7). Not shown
295
+ * for a chunk-load / offline screen, where the message is a URL that explains
296
+ * nothing the title does not. */
297
+ showMessage?: boolean;
298
+ /** A collapsed disclosure holding the full plain-text report. Default on since 0.13
299
+ * (it was an opt-in showing name, message and stack): closed, it costs one line, and
300
+ * it is where a support conversation asks the user to look. */
92
301
  showDetails?: boolean;
302
+ /** The copy button for the plain-text report (the kit's CopyButton, which says when
303
+ * the clipboard refused). Default on. Hidden once `onReport` has filed the crash —
304
+ * there is nothing left to paste anywhere — and shown again whenever it did not. */
305
+ copyReport?: boolean;
306
+ /** The app's version, printed in the report — the first question on every crash. */
307
+ appVersion?: string;
308
+ /** More `key: value` lines for the report, read when the boundary catches (a tenant
309
+ * id, the feature flags). A throw from it is swallowed and the lines are left out. */
310
+ reportExtras?: () => Record<string, string>;
311
+ /**
312
+ * Rewrite the report before anything sees it: the screen, the clipboard and
313
+ * `onReport` all get the returned one.
314
+ *
315
+ * THE REPORT IS STRUCTURE, NEVER CONTENT — and only the app knows which of its query
316
+ * params hold content. keksdose's register put the free-text search in `?q=` and payee
317
+ * names in the `f.*` filters, and its crash channel sent them in the clear to a column
318
+ * every admin reads (crash-report.ts `safeUrl`). The cure there is an ALLOW-list, so a
319
+ * param added later is dropped until somebody decides otherwise — rebuild `page`
320
+ * from `new URL(r.page, location.origin)`, keeping only the listed `searchParams`.
321
+ * `extras` are the app's own and need no second pass. A throw from `redact` falls
322
+ * back to the pathname alone and no extras — failing closed.
323
+ *
324
+ * The same holds inside `onReport`, which is the other place to do it: clamp fields
325
+ * to what the endpoint accepts, drop what it must not store. The kit cannot know.
326
+ */
327
+ redact?: (report: CrashReport) => CrashReport;
328
+ /**
329
+ * File the report automatically. Called from `componentDidCatch`, at most once per
330
+ * {@link crashFingerprint} per page load (and at most five times in all), never for a
331
+ * chunk-load error. It can NOT throw into the boundary: a synchronous throw and a
332
+ * rejection both read as "not filed", and the copy button stays.
333
+ *
334
+ * Resolve with `{ reference }` and the screen says "Reported as …" instead of the copy
335
+ * button; see {@link CrashReportResult} for "filed without a reference" and "decided not
336
+ * to file". Whatever the app's reporter does, it must do without the router, the query
337
+ * client or the auth store's hooks — a bare `fetch` with `keepalive`, so it survives
338
+ * the reload the user is about to press (keksdose crash-report.ts `post`).
339
+ */
340
+ onReport?: (report: CrashReport) => Promise<CrashReportResult> | CrashReportResult;
341
+ /** The app's own escape hatches, beside Reload / Try again — keksdose's "Clear local
342
+ * data" for a poisoned persisted cache that no in-app action could otherwise break.
343
+ * A full-width hint under it: wrap it in `<div className="basis-full">`. */
344
+ actions?: ReactNode;
93
345
  /** The default fallback's heading level. Default `h2`: the fallback often IS the page. */
94
346
  headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
95
347
  /** Classes for the default fallback's box (`m-8 min-h-[400px]`, kastlan). */
@@ -97,14 +349,26 @@ export interface ErrorBoundaryProps {
97
349
  labels?: Partial<ErrorBoundaryLabels>;
98
350
  }
99
351
 
352
+ type FilingState = "none" | "pending" | "filed" | "failed";
353
+
100
354
  interface ErrorBoundaryState {
101
355
  /** A flag of its own, so `throw undefined` is still an error shown. */
102
356
  failed: boolean;
103
357
  error: unknown;
104
358
  details: ErrorBoundaryDetails | null;
359
+ report: CrashReport | null;
360
+ filing: FilingState;
361
+ reference: string | undefined;
105
362
  }
106
363
 
107
- const CLEAR: ErrorBoundaryState = { failed: false, error: null, details: null };
364
+ const CLEAR: ErrorBoundaryState = {
365
+ failed: false,
366
+ error: null,
367
+ details: null,
368
+ report: null,
369
+ filing: "none",
370
+ reference: undefined,
371
+ };
108
372
 
109
373
  function keysChanged(a: ReadonlyArray<unknown> | undefined, b: ReadonlyArray<unknown> | undefined): boolean {
110
374
  if (a === b) return false;
@@ -112,14 +376,82 @@ function keysChanged(a: ReadonlyArray<unknown> | undefined, b: ReadonlyArray<unk
112
376
  return a.some((v, i) => !Object.is(v, b[i]));
113
377
  }
114
378
 
379
+ function currentPage(): { page: string; path: string } {
380
+ try {
381
+ const { pathname, search } = window.location;
382
+ return { page: `${pathname}${search}`, path: pathname };
383
+ } catch {
384
+ return { page: "", path: "" };
385
+ }
386
+ }
387
+
388
+ function buildReport(details: ErrorBoundaryDetails, componentStack: string | undefined, props: ErrorBoundaryProps): CrashReport {
389
+ const { page, path } = currentPage();
390
+ const extras: Record<string, string> = {};
391
+ try {
392
+ const more = props.reportExtras?.();
393
+ if (more) for (const [k, v] of Object.entries(more)) extras[k] = String(v);
394
+ } catch {
395
+ /* documented: the lines are left out */
396
+ }
397
+ let userAgent = "";
398
+ let online: boolean | undefined;
399
+ try {
400
+ userAgent = navigator.userAgent;
401
+ online = typeof navigator.onLine === "boolean" ? navigator.onLine : undefined;
402
+ } catch {
403
+ /* no navigator (SSR, a worker test) */
404
+ }
405
+ const report: CrashReport = {
406
+ name: details.name,
407
+ message: details.message,
408
+ stack: details.stack,
409
+ componentStack: componentStack ?? undefined,
410
+ page,
411
+ time: new Date().toISOString(),
412
+ userAgent,
413
+ appVersion: props.appVersion,
414
+ online,
415
+ chunkLoad: isChunkLoadError(details),
416
+ extras,
417
+ };
418
+ if (!props.redact) return report;
419
+ try {
420
+ return props.redact(report);
421
+ } catch {
422
+ // Fail closed: an app that asked for redaction must not get the unredacted page.
423
+ return { ...report, page: path, extras: {} };
424
+ }
425
+ }
426
+
115
427
  /**
116
- * Catch a render error below it and show a fallback instead of an empty page.
428
+ * Catch a render error below it and show ONE crash screen instead of an empty page.
429
+ *
430
+ * The three apps each built one. kastlan's and keksdose's sat on `EmptyState
431
+ * tone="danger"`; lenkbank's quoted the error and offered a plain-text report to copy;
432
+ * keksdose's filed the crash itself, told a lazy-chunk failure apart as "offline", and
433
+ * offered "clear local data" when a poisoned cache made Retry useless (feedback #160).
434
+ * This is all of that, so the apps reduce to
435
+ * `<ErrorBoundary resetKeys={[pathname]} appVersion={__VERSION__} />` plus, for an app
436
+ * with a crash endpoint, `onReport` and `actions`:
117
437
  *
118
- * kastlan (shared/components/error-boundary.tsx) and keksdose (app/error-boundary.tsx)
119
- * each built one on `EmptyState tone="danger"`: a warning glyph, a title, a line of
120
- * explanation and a Retry. This is that fallback, translated through the provider
121
- * (`errorBoundary.*`), with the hooks an app hangs its own behaviour on — `onError`
122
- * for the crash report, `resetKeys` for the route change, `fallback` for anything else.
438
+ * - the error's `name: message`, quoted (`showMessage`);
439
+ * - Try again (reset the boundary) AND Reload (the page) — Try again alone re-renders
440
+ * the same subtree from the same state, which a deterministic crash survives;
441
+ * - a chunk-load error or an offline browser gets its own title and Reload first
442
+ * ("A new version is available" / "You're offline");
443
+ * - the copy button and the report in a disclosure; `onReport` for filing it, deduped
444
+ * per page load, with its reference on screen.
445
+ *
446
+ * THE RULE for this fallback — and for a `fallback` or `actions` an app passes in: it
447
+ * must not depend on the router, the query client or the auth store. A crash is exactly
448
+ * when those may be what broke, and a fallback that throws escalates to the next
449
+ * boundary up — at the app level there is none, and the user gets the white screen this
450
+ * exists to prevent. So no `useNavigate`, no `useQuery`, no `useAuth()` in it; reload
451
+ * with `window.location`, not the router. The kit's own label hooks are the one context
452
+ * it does read (`errorBoundary.*` from `UiKitProvider`), and they degrade to the English
453
+ * defaults when there is no provider above — which is why the app-level boundary may
454
+ * sit OUTSIDE the provider and still render.
123
455
  *
124
456
  * The fallback is `role="alert"`: a page replaced by an error is news, and a reader
125
457
  * that was on it is told.
@@ -127,12 +459,45 @@ function keysChanged(a: ReadonlyArray<unknown> | undefined, b: ReadonlyArray<unk
127
459
  export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
128
460
  state: ErrorBoundaryState = CLEAR;
129
461
 
462
+ /** Bumped by every catch and reset, so a filing that settles after the screen moved
463
+ * on (Try again, a route change, a second crash) does not write into it. */
464
+ private generation = 0;
465
+
466
+ /**
467
+ * Forget which crashes this page load has filed, and the count towards the cap.
468
+ *
469
+ * For tests: the dedupe lives in module scope because a page load is what it spans,
470
+ * and a test file is one page load — so without this, the sixth test that files a
471
+ * report finds the cap reached and `onReport` never called. Call it in `beforeEach`.
472
+ * Never needed in an app: a reload is the reset.
473
+ */
474
+ static forgetReports(): void {
475
+ filings.clear();
476
+ }
477
+
130
478
  static getDerivedStateFromError(error: unknown): Partial<ErrorBoundaryState> {
131
- return { failed: true, error, details: describeThrown(error) };
479
+ return { failed: true, error, details: describeThrown(error), report: null, filing: "none", reference: undefined };
132
480
  }
133
481
 
134
482
  componentDidCatch(error: unknown, info: ErrorInfo): void {
135
- this.props.onError?.(error, info);
483
+ try {
484
+ this.props.onError?.(error, info);
485
+ } catch {
486
+ /* documented: a logger must not take the fallback down */
487
+ }
488
+ const details = this.state.details ?? describeThrown(error);
489
+ const report = buildReport(details, info.componentStack ?? undefined, this.props);
490
+ const { onReport } = this.props;
491
+ const willFile = onReport !== undefined && !report.chunkLoad;
492
+ const generation = ++this.generation;
493
+ this.setState({ report, filing: willFile ? "pending" : "none", reference: undefined });
494
+ if (!willFile) return;
495
+ void fileOnce(report, onReport).then((filing) => {
496
+ if (generation !== this.generation) return;
497
+ this.setState(
498
+ filing.filed ? { filing: "filed", reference: filing.reference } : { filing: "failed", reference: undefined },
499
+ );
500
+ });
136
501
  }
137
502
 
138
503
  componentDidUpdate(prev: ErrorBoundaryProps, prevState: ErrorBoundaryState): void {
@@ -148,21 +513,28 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
148
513
  }
149
514
 
150
515
  reset = (): void => {
516
+ this.generation++;
151
517
  this.props.onReset?.();
152
518
  this.setState(CLEAR);
153
519
  };
154
520
 
155
521
  render(): ReactNode {
156
- const { failed, error, details } = this.state;
522
+ const { failed, error, details, report, filing, reference } = this.state;
157
523
  if (!failed || !details) return this.props.children;
158
524
  const { fallback } = this.props;
159
- if (typeof fallback === "function") return fallback({ error, details, reset: this.reset });
525
+ if (typeof fallback === "function") return fallback({ error, details, reset: this.reset, report });
160
526
  if (fallback !== undefined) return fallback;
161
527
  return (
162
528
  <ErrorFallback
163
529
  details={details}
530
+ report={report}
531
+ filing={filing}
532
+ reference={reference}
164
533
  reset={this.reset}
165
- showDetails={this.props.showDetails ?? false}
534
+ showMessage={this.props.showMessage ?? true}
535
+ showDetails={this.props.showDetails ?? true}
536
+ copyReport={this.props.copyReport ?? true}
537
+ actions={this.props.actions}
166
538
  headingAs={this.props.headingAs ?? "h2"}
167
539
  className={this.props.className}
168
540
  labels={this.props.labels}
@@ -173,39 +545,91 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
173
545
 
174
546
  function ErrorFallback({
175
547
  details,
548
+ report,
549
+ filing,
550
+ reference,
176
551
  reset,
552
+ showMessage,
177
553
  showDetails,
554
+ copyReport,
555
+ actions,
178
556
  headingAs,
179
557
  className,
180
558
  labels,
181
559
  }: {
182
560
  details: ErrorBoundaryDetails;
561
+ report: CrashReport | null;
562
+ filing: FilingState;
563
+ reference: string | undefined;
183
564
  reset: () => void;
565
+ showMessage: boolean;
184
566
  showDetails: boolean;
567
+ copyReport: boolean;
568
+ actions?: ReactNode;
185
569
  headingAs: NonNullable<ErrorBoundaryProps["headingAs"]>;
186
570
  className?: string;
187
571
  labels?: Partial<ErrorBoundaryLabels>;
188
572
  }) {
189
573
  const text = useKitLabels("errorBoundary", DEFAULT_ERROR_BOUNDARY_LABELS, labels);
574
+ const chunk = isChunkLoadError(details);
575
+ // Offline first, as keksdose has it: offline, a chunk failure is the connection, and
576
+ // any other crash most likely a request that could not be made — either way "reload
577
+ // once you are back" is the advice, and "a new version" would be a false one.
578
+ const mode = isOffline() ? "offline" : chunk ? "update" : "error";
579
+ const title = mode === "offline" ? text.offlineTitle : mode === "update" ? text.updateTitle : text.title;
580
+ const hint = mode === "offline" ? text.offlineMessage : mode === "update" ? text.updateMessage : text.message;
581
+ const Icon = mode === "offline" ? WifiOff : mode === "update" ? RefreshCw : AlertTriangle;
582
+ const reportText = report ? formatCrashReport(report) : null;
583
+ const errorLine = details.message ? `${details.name}: ${details.message}` : details.name;
584
+
585
+ const reloadButton = (
586
+ <Button key="reload" variant={mode === "error" ? "secondary" : "primary"} onClick={reloadPage}>
587
+ {text.reload}
588
+ </Button>
589
+ );
590
+ // Not after a chunk failure: `React.lazy` keeps the rejected import, so Try again
591
+ // could only throw the same error again.
592
+ const retryButton = chunk ? null : (
593
+ <Button key="retry" variant={mode === "error" ? "primary" : "secondary"} onClick={reset}>
594
+ {text.retry}
595
+ </Button>
596
+ );
597
+
190
598
  return (
191
599
  <EmptyState
192
600
  role="alert"
193
- tone="danger"
601
+ tone={mode === "error" ? "danger" : undefined}
194
602
  headingAs={headingAs}
195
603
  className={className}
196
- icon={<AlertTriangle />}
197
- title={text.title}
198
- hint={text.message}
604
+ icon={<Icon />}
605
+ title={title}
606
+ hint={hint}
199
607
  action={
200
608
  <>
201
- <Button variant="secondary" onClick={reset}>
202
- {text.retry}
203
- </Button>
204
- {showDetails && (
609
+ {showMessage && mode === "error" && (
610
+ // A quotation, not prose: left-aligned and monospaced, so it reads as the
611
+ // program's words and can be read out or screenshotted verbatim.
612
+ <p
613
+ data-error-message=""
614
+ className="-mt-1 mb-1 basis-full whitespace-pre-wrap break-words rounded-md border border-[var(--danger-border)] bg-[var(--danger-bg)] px-3 py-2 text-start font-mono text-xs text-[var(--text-primary)]"
615
+ >
616
+ {errorLine}
617
+ </p>
618
+ )}
619
+ {filing === "filed" && (
620
+ <p className="-mt-1 mb-1 basis-full text-xs">
621
+ {reference ? text.reportedAs(reference) : text.reported}
622
+ </p>
623
+ )}
624
+ {mode === "error" ? [retryButton, reloadButton] : [reloadButton, retryButton]}
625
+ {copyReport && reportText !== null && filing !== "filed" && (
626
+ <CopyButton variant="label" buttonVariant="ghost" label={text.copyReport} text={reportText} />
627
+ )}
628
+ {actions}
629
+ {showDetails && reportText !== null && (
205
630
  <Disclosure variant="bare" title={text.details} className="basis-full text-start">
206
631
  <pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words text-xs text-[var(--text-secondary)]">
207
- {details.message ? `${details.name}: ${details.message}` : details.name}
208
- {details.stack ? `\n\n${details.stack}` : null}
632
+ {reportText}
209
633
  </pre>
210
634
  </Disclosure>
211
635
  )}