@eifi1/ui-kit 0.53.0 → 0.54.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 +9 -9
- package/dist/account/delete-account-setting.d.ts +3 -2
- package/dist/account/delete-account-setting.js.map +1 -1
- package/dist/auth/auth-errors.d.ts +36 -0
- package/dist/auth/auth-errors.js +20 -0
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/components/amount-input.d.ts +2 -1
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/calendar-heatmap.js +4 -2
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/currency-option-list.d.ts +2 -1
- package/dist/components/currency-option-list.js +10 -3
- package/dist/components/currency-option-list.js.map +1 -1
- package/dist/components/currency-select.d.ts +3 -1
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +22 -1
- package/dist/components/danger-confirm.js +50 -28
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +2 -1
- package/dist/components/data-table-filter-popover.js +3 -2
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +33 -3
- package/dist/components/data-table-filters.js +6 -4
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +3 -1
- package/dist/components/dialog-frame.js +6 -2
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/mini-calendar.js +3 -2
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +27 -0
- package/dist/components/modal.js +5 -3
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/stat-tile.d.ts +21 -2
- package/dist/components/stat-tile.js +4 -2
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/currencies.d.ts +16 -2
- package/dist/currencies.js +2 -1
- package/dist/currencies.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-context-labels.js +2 -1
- package/dist/feedback/feedback-context-labels.js.map +1 -1
- package/dist/feedback/feedback-context.d.ts +19 -3
- package/dist/feedback/feedback-context.js +12 -1
- package/dist/feedback/feedback-context.js.map +1 -1
- package/dist/feedback/feedback-diagnostics.d.ts +17 -0
- package/dist/feedback/feedback-diagnostics.js +27 -0
- package/dist/feedback/feedback-diagnostics.js.map +1 -0
- package/dist/feedback/feedback-dialog.d.ts +10 -0
- package/dist/feedback/feedback-dialog.js +6 -4
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-record.d.ts +5 -0
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-row-detail-labels.js +1 -0
- package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +3 -0
- package/dist/feedback/feedback-row-detail.js +3 -0
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-submit-dialog.d.ts +5 -1
- package/dist/feedback/feedback-submit-dialog.js +15 -3
- package/dist/feedback/feedback-submit-dialog.js.map +1 -1
- package/dist/feedback/feedback-submit.d.ts +30 -2
- package/dist/feedback/feedback-submit.js +5 -2
- package/dist/feedback/feedback-submit.js.map +1 -1
- package/dist/hooks/use-hotkey.js +11 -1
- package/dist/hooks/use-hotkey.js.map +1 -1
- package/dist/i18n/german.js +3 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/locales/en.js +3 -1
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +3 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +3 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +3 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +3 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +3 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/dates.d.ts +7 -1
- package/dist/lib/dates.js +8 -1
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/format.d.ts +37 -2
- package/dist/lib/format.js +25 -1
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/intl-cache.d.ts +4 -2
- package/dist/lib/intl-cache.js.map +1 -1
- package/dist/lib/translation-review.d.ts +26 -0
- package/dist/lib/translation-review.js +14 -0
- package/dist/lib/translation-review.js.map +1 -1
- package/package.json +1 -1
- package/src/account/delete-account-setting.tsx +3 -2
- package/src/auth/auth-errors.ts +69 -0
- package/src/components/amount-input.tsx +2 -1
- package/src/components/calendar-heatmap.tsx +8 -2
- package/src/components/currency-option-list.tsx +12 -4
- package/src/components/currency-select.tsx +3 -1
- package/src/components/danger-confirm.tsx +80 -27
- package/src/components/data-table-filter-popover.tsx +19 -11
- package/src/components/data-table-filters.ts +36 -8
- package/src/components/dialog-frame.tsx +12 -4
- package/src/components/mini-calendar.tsx +5 -2
- package/src/components/modal.tsx +36 -7
- package/src/components/number-input.tsx +4 -3
- package/src/components/stat-tile.tsx +32 -3
- package/src/currencies.ts +23 -3
- package/src/feedback/feedback-context-labels.ts +1 -0
- package/src/feedback/feedback-context.tsx +34 -2
- package/src/feedback/feedback-diagnostics.tsx +41 -0
- package/src/feedback/feedback-dialog.tsx +16 -4
- package/src/feedback/feedback-record.ts +5 -0
- package/src/feedback/feedback-row-detail-labels.ts +1 -0
- package/src/feedback/feedback-row-detail.tsx +13 -0
- package/src/feedback/feedback-submit-dialog.tsx +15 -2
- package/src/feedback/feedback-submit.tsx +36 -3
- package/src/hooks/use-hotkey.ts +24 -1
- package/src/i18n/german.ts +2 -0
- package/src/i18n/locales/es.ts +2 -0
- package/src/i18n/locales/fr.ts +2 -0
- package/src/i18n/locales/hu.ts +2 -0
- package/src/i18n/locales/it.ts +2 -0
- package/src/i18n/locales/zh.ts +2 -0
- package/src/index.ts +14 -2
- package/src/lib/dates.ts +24 -2
- package/src/lib/format.ts +66 -3
- package/src/lib/intl-cache.ts +4 -2
- package/src/lib/translation-review.ts +52 -0
package/src/components/modal.tsx
CHANGED
|
@@ -257,8 +257,35 @@ export interface ModalProps extends Omit<ComponentPropsWithoutRef<"div">, "role"
|
|
|
257
257
|
* the dialog asks for itself through `ModalCloseContext` while busy is refused too; one
|
|
258
258
|
* made from an effect that depends on that close (ReauthDialog's on a resolved submit)
|
|
259
259
|
* runs again once `busy` turns false, because the close changes with it.
|
|
260
|
+
*
|
|
261
|
+
* A dialog that must not be dismissed while NOTHING runs — a code shown once — wants
|
|
262
|
+
* {@link ModalProps.held} instead: the same hold without the request.
|
|
260
263
|
*/
|
|
261
264
|
busy?: boolean;
|
|
265
|
+
/**
|
|
266
|
+
* Hold the dialog open: every dismissal `busy` refuses is refused — the backdrop,
|
|
267
|
+
* Escape, Back, the frame's X and an `actions(close)` button — but no request is
|
|
268
|
+
* claimed: the panel is not `aria-busy`. For a dialog the user must not lose by
|
|
269
|
+
* accident while nothing is in flight (keksdose audit follow-up, 2026-11-01): keksdose's
|
|
270
|
+
* one-time recovery code (auth/recovery-code-pane.tsx) refused in its own `onClose`,
|
|
271
|
+
* which comes after the exit has started and cannot hold Back — the first Back spent
|
|
272
|
+
* the dialog's history entry, and the second left the page, and the code with it.
|
|
273
|
+
* Held, a refused Back puts the entry back, so every Back is held, not only the first.
|
|
274
|
+
* The refusal is silent, as under `busy` — `onClose` is not called — so what the
|
|
275
|
+
* dialog is waiting for is said up front, not after a refused press.
|
|
276
|
+
*
|
|
277
|
+
* The way out is a close the CALLER decides on, which is never held: `open={false}`
|
|
278
|
+
* (the exit still plays) or unmounting, from the dialog's own "I have saved it" button.
|
|
279
|
+
* Not the `close` that `actions` and `headerActions` receive — that is the dialog's
|
|
280
|
+
* own dismissal, and held like the rest.
|
|
281
|
+
*
|
|
282
|
+
* `DialogFrame`'s X is DISABLED while held, where under `busy` it stays enabled and its
|
|
283
|
+
* press is dropped: a request is over in a moment, a hold lasts until the user acts,
|
|
284
|
+
* and an X that does nothing for that long reads as broken. Disabled rather than
|
|
285
|
+
* hidden, so the header does not reflow the moment the hold lifts; a caller that wants
|
|
286
|
+
* it gone passes `closeButton={!held}`. Independent of `busy`: either one holds.
|
|
287
|
+
*/
|
|
288
|
+
held?: boolean;
|
|
262
289
|
/**
|
|
263
290
|
* The PANEL — the `role="dialog"` element the rest of the props go on, not the
|
|
264
291
|
* backdrop around it (0.35, round 0.35 R4, the follow-up to API-2; as on
|
|
@@ -325,6 +352,7 @@ function ModalPanel({
|
|
|
325
352
|
role = "dialog",
|
|
326
353
|
urlParam: _urlParam,
|
|
327
354
|
busy = false,
|
|
355
|
+
held = false,
|
|
328
356
|
"aria-busy": ariaBusy,
|
|
329
357
|
ownHistoryEntry = true,
|
|
330
358
|
ref,
|
|
@@ -371,13 +399,14 @@ function ModalPanel({
|
|
|
371
399
|
setDismissed(true);
|
|
372
400
|
onClose();
|
|
373
401
|
});
|
|
374
|
-
// `busy` (DUP-3)
|
|
375
|
-
// caller's `onClose` comes 220ms too late, after the panel has already gone down.
|
|
376
|
-
// A new function when
|
|
402
|
+
// `busy` (DUP-3) and `held` refuse HERE, before `lower` starts the exit: a refusal in
|
|
403
|
+
// the caller's `onClose` comes 220ms too late, after the panel has already gone down.
|
|
404
|
+
// A new function when the hold flips, so a close asked for from an effect while busy
|
|
377
405
|
// (a resolved action whose caller's `isPending` trails its promise) runs again.
|
|
406
|
+
const holding = busy || held;
|
|
378
407
|
const requestClose = useCallback(() => {
|
|
379
|
-
if (!
|
|
380
|
-
}, [
|
|
408
|
+
if (!holding) lower();
|
|
409
|
+
}, [holding, lower]);
|
|
381
410
|
// A panel on its way out after `open` turned false is already closed: a press on
|
|
382
411
|
// its fading backdrop must not report a second close.
|
|
383
412
|
const backdropClose = useBackdropClose(active ? requestClose : () => {});
|
|
@@ -385,8 +414,8 @@ function ModalPanel({
|
|
|
385
414
|
// this the only way out of a dialog is finding its close button — and Back, the
|
|
386
415
|
// gesture everyone reaches for, navigated the page underneath instead (#172).
|
|
387
416
|
// Held only while open: a closed `open={false}` dialog must not own a history entry.
|
|
388
|
-
// `held`: a Back while busy is refused, and the entry it took is put back.
|
|
389
|
-
useOverlayHistory(active && ownHistoryEntry, requestClose, { held:
|
|
417
|
+
// `held`: a Back while busy or held is refused, and the entry it took is put back.
|
|
418
|
+
useOverlayHistory(active && ownHistoryEntry, requestClose, { held: holding });
|
|
390
419
|
|
|
391
420
|
// Through the shared hook and not by hand: this component's own save/restore copy
|
|
392
421
|
// was one half of the pair that left the page permanently unscrollable when a dialog
|
|
@@ -222,9 +222,10 @@ const asTyped = (text: string) => text;
|
|
|
222
222
|
|
|
223
223
|
// ── The money guard (kastlan 5, Kurvenschmiede) ─────────────────────────────────
|
|
224
224
|
|
|
225
|
-
// The codes the kit's own currency pickers
|
|
226
|
-
// "EUR", "Kosten CHF".
|
|
227
|
-
//
|
|
225
|
+
// The codes the kit's own currency pickers know, as whole words: "Amount (CHF)",
|
|
226
|
+
// "EUR", "Kosten CHF". A `retired` one too (BGN): a label that names it is still money,
|
|
227
|
+
// whatever the pickers offer. Not every ISO 4217 code — "ALL", "TOP" and "PEN" are codes
|
|
228
|
+
// too, and an upper-case label would trip on them.
|
|
228
229
|
const CURRENCY_CODE = new RegExp(`(?:^|[^A-Za-z])(?:${CURRENCIES.map((c) => c.code).join("|")})(?![A-Za-z])`);
|
|
229
230
|
// Symbols no unit of measure is written with. Not "kr", "R" or "Fr": those are words
|
|
230
231
|
// and units as often as they are money.
|
|
@@ -229,8 +229,27 @@ export interface StatTileProps extends Omit<ComponentPropsWithRef<"div">, "child
|
|
|
229
229
|
/** Anything below the rest — a progress bar, a link row. */
|
|
230
230
|
footer?: ReactNode;
|
|
231
231
|
/** Tag every figure `data-private`, so the host's demo-mode rule blurs it — the
|
|
232
|
-
* same attribute `Tooltip redact` and the chart tooltip's value cell carry.
|
|
232
|
+
* same attribute `Tooltip redact` and the chart tooltip's value cell carry.
|
|
233
|
+
* The figures are the tile's own: value, delta, projection, sub-values and the
|
|
234
|
+
* trend. The `description` and `footer` are the app's nodes and stay readable;
|
|
235
|
+
* see {@link StatTileProps.descriptionSensitive} for the ones that carry an amount. */
|
|
233
236
|
sensitive?: boolean;
|
|
237
|
+
/**
|
|
238
|
+
* Tag the `description` line `data-private` too, as `sensitive` tags the figures.
|
|
239
|
+
* For a description that names an amount: keksdose's tax reserve tile states its
|
|
240
|
+
* basis there ("…applied to €42,000"), and its report KpiCard puts a `hintSensitive`
|
|
241
|
+
* hint there — both tag their own `<span data-private>` today
|
|
242
|
+
* (reports/tabs/tax-tab.tsx, reports/charts/kpi-card.tsx). Separate from
|
|
243
|
+
* `sensitive`, so a description of words ("Last 12 months") on a money tile stays
|
|
244
|
+
* readable in demo mode. keksdose audit follow-up, 2026-11-01.
|
|
245
|
+
*/
|
|
246
|
+
descriptionSensitive?: boolean;
|
|
247
|
+
/**
|
|
248
|
+
* Tag the `footer` `data-private`, as `sensitive` tags the figures — for a footer
|
|
249
|
+
* that carries an amount: keksdose's tax reserve tile's shortfall line ("Set aside
|
|
250
|
+
* €1,200 more…"), which it tags by hand. keksdose audit follow-up, 2026-11-01.
|
|
251
|
+
*/
|
|
252
|
+
footerSensitive?: boolean;
|
|
234
253
|
/** Skeleton in place of the value; delta, sub-values and trend are withheld. */
|
|
235
254
|
loading?: boolean;
|
|
236
255
|
/** `"sm"` for dense grids (a metrics wall); `"md"` the default KPI. */
|
|
@@ -317,6 +336,8 @@ export function StatTile({
|
|
|
317
336
|
icon,
|
|
318
337
|
footer,
|
|
319
338
|
sensitive = false,
|
|
339
|
+
descriptionSensitive = false,
|
|
340
|
+
footerSensitive = false,
|
|
320
341
|
loading = false,
|
|
321
342
|
size = "md",
|
|
322
343
|
variant = "card",
|
|
@@ -551,7 +572,11 @@ export function StatTile({
|
|
|
551
572
|
)}
|
|
552
573
|
|
|
553
574
|
{deltaNode}
|
|
554
|
-
{description != null &&
|
|
575
|
+
{description != null && (
|
|
576
|
+
<div data-private={descriptionSensitive ? "" : undefined} className="mt-1 text-xs text-muted">
|
|
577
|
+
{description}
|
|
578
|
+
</div>
|
|
579
|
+
)}
|
|
555
580
|
{!large && graphic && <div className="mt-2">{graphic}</div>}
|
|
556
581
|
|
|
557
582
|
{!loading && ((large && projections.length > 0) || (subValues && subValues.length > 0)) && (
|
|
@@ -572,7 +597,11 @@ export function StatTile({
|
|
|
572
597
|
</div>
|
|
573
598
|
)}
|
|
574
599
|
|
|
575
|
-
{footer != null &&
|
|
600
|
+
{footer != null && (
|
|
601
|
+
<div data-private={footerSensitive ? "" : undefined} className="mt-2">
|
|
602
|
+
{footer}
|
|
603
|
+
</div>
|
|
604
|
+
)}
|
|
576
605
|
</Card>
|
|
577
606
|
);
|
|
578
607
|
}
|
package/src/currencies.ts
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
// CurrencySelect, AmountInput's currency chip and NumberInput's code detection read this
|
|
13
13
|
// same array (components/currency-option-list re-exports it, and currency-select as it
|
|
14
14
|
// always has), so the list the pickers show and the flags an app ships cannot drift.
|
|
15
|
+
//
|
|
16
|
+
// A currency that was REPLACED stays in the array, marked `retired`: the pickers stop
|
|
17
|
+
// offering it, and whatever reads a value an app already holds — the trigger's flag and
|
|
18
|
+
// code, `getCurrency`, the flag subset, NumberInput's code detection — keeps knowing it
|
|
19
|
+
// (keksdose audit follow-up, 2026-11-01: the lev, after Bulgaria's euro).
|
|
15
20
|
|
|
16
21
|
export interface CurrencyOption {
|
|
17
22
|
code: string;
|
|
@@ -19,6 +24,19 @@ export interface CurrencyOption {
|
|
|
19
24
|
symbol: string;
|
|
20
25
|
/** ISO 3166-1 alpha-2 country code (lowercase) used by flag-icons. */
|
|
21
26
|
country: string;
|
|
27
|
+
/**
|
|
28
|
+
* The day ("YYYY-MM-DD") the currency was replaced as its country's money — set only on
|
|
29
|
+
* one that was. The pickers leave it out of their default list. A field that already
|
|
30
|
+
* holds it still shows it (the trigger's flag and code, and its row in the list,
|
|
31
|
+
* checked), and an explicit `options` list that names it still offers it: that list is
|
|
32
|
+
* the caller's.
|
|
33
|
+
*
|
|
34
|
+
* Marked rather than deleted, because an app may hold amounts in it from before. Gone
|
|
35
|
+
* from the array, `getCurrency` would answer `undefined` for a stored value, the field
|
|
36
|
+
* would show its placeholder over a real amount, and an app's flag subset
|
|
37
|
+
* (`CURRENCY_COUNTRIES`) would ship that value's flag as an empty box.
|
|
38
|
+
*/
|
|
39
|
+
retired?: string;
|
|
22
40
|
}
|
|
23
41
|
|
|
24
42
|
export const CURRENCIES: CurrencyOption[] = [
|
|
@@ -37,7 +55,8 @@ export const CURRENCIES: CurrencyOption[] = [
|
|
|
37
55
|
{ code: "CZK", name: "Czech Koruna", symbol: "Kč", country: "cz" },
|
|
38
56
|
{ code: "HUF", name: "Hungarian Forint", symbol: "Ft", country: "hu" },
|
|
39
57
|
{ code: "RON", name: "Romanian Leu", symbol: "lei", country: "ro" },
|
|
40
|
-
|
|
58
|
+
// Bulgaria adopted the euro on 2026-01-01 (keksdose audit follow-up, 2026-11-01).
|
|
59
|
+
{ code: "BGN", name: "Bulgarian Lev", symbol: "лв", country: "bg", retired: "2026-01-01" },
|
|
41
60
|
{ code: "TRY", name: "Turkish Lira", symbol: "₺", country: "tr" },
|
|
42
61
|
{ code: "RUB", name: "Russian Ruble", symbol: "₽", country: "ru" },
|
|
43
62
|
{ code: "CNY", name: "Chinese Yuan", symbol: "¥", country: "cn" },
|
|
@@ -56,8 +75,9 @@ export const CURRENCIES: CurrencyOption[] = [
|
|
|
56
75
|
* code flag-icons names it by (`fi-ch`, `flags/4x3/ch.svg`). `CHF` → `"ch"`, `EUR` →
|
|
57
76
|
* `"eu"` (flag-icons has the EU's flag under that code).
|
|
58
77
|
*
|
|
59
|
-
* Derived from {@link CURRENCIES}, so a currency the pickers gain is a flag here too
|
|
60
|
-
*
|
|
78
|
+
* Derived from {@link CURRENCIES}, so a currency the pickers gain is a flag here too — and
|
|
79
|
+
* a `retired` one keeps its flag, for the values an app still holds in it. An app that
|
|
80
|
+
* ships only the flags it renders builds its subset from these values:
|
|
61
81
|
*
|
|
62
82
|
* ```ts
|
|
63
83
|
* // vite.config.ts — plain Node, no DOM
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { useId } from "react";
|
|
1
2
|
import { Checkbox } from "../components/checkbox";
|
|
2
3
|
import { DEFAULT_COMMON_LABELS, useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
3
4
|
import { cn } from "../lib/cn";
|
|
4
5
|
import type { FeedbackContext, FeedbackEnvironment } from "./feedback-record";
|
|
5
6
|
import { DEFAULT_FEEDBACK_CONTEXT_LABELS } from "./feedback-context-labels";
|
|
7
|
+
import { diagnosticsEntries, FeedbackDiagnosticsList } from "./feedback-diagnostics";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* The reporter context of a feedback report: the box the submit dialog shows, and the
|
|
@@ -70,6 +72,10 @@ export interface FeedbackContextInput {
|
|
|
70
72
|
viewport?: string;
|
|
71
73
|
/** Default `navigator.userAgent`. */
|
|
72
74
|
ua?: string;
|
|
75
|
+
/** What the app attached about the thing reported — `useFeedbackSubmit().open(category,
|
|
76
|
+
* { diagnostics })` (keksdose live #481). Sent whatever `attachUrl` says, as the box
|
|
77
|
+
* shows it; left out of the object when it has no key to show. */
|
|
78
|
+
diagnostics?: Readonly<Record<string, unknown>> | null;
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
/** The pathname of a full or path-only URL; `""` for something that is neither. */
|
|
@@ -85,12 +91,13 @@ function pathOf(url: string, base: string | undefined): string {
|
|
|
85
91
|
/**
|
|
86
92
|
* The `context` object of `POST /feedback` (§3.2), keys in keksdose's order: `url`, `route`,
|
|
87
93
|
* `origin`, `environment`, `user_id`, `user_email`, `user_display_name`, `viewport`, `ua`,
|
|
88
|
-
* `version`.
|
|
94
|
+
* `version`, then the app's `diagnostics`.
|
|
89
95
|
*
|
|
90
96
|
* With `attachUrl: false`, `url` and `route` are `""` — present and empty, as keksdose
|
|
91
97
|
* sends them, so a reader can tell "withheld" from "an old row without the key".
|
|
92
98
|
* `environment` and `version` are omitted when the app passes none (kastlan had no
|
|
93
|
-
* version), rather than sent as `undefined
|
|
99
|
+
* version), rather than sent as `undefined`; `diagnostics` when the box would show no
|
|
100
|
+
* line of it (none, `{}`, not a record).
|
|
94
101
|
*
|
|
95
102
|
* Reads `window` / `navigator` only for the defaults it was not given, so it is a pure
|
|
96
103
|
* function in a test that passes everything.
|
|
@@ -111,6 +118,8 @@ export function feedbackContext(input: FeedbackContextInput): FeedbackContext {
|
|
|
111
118
|
context.viewport = input.viewport ?? (win ? `${win.innerWidth}x${win.innerHeight}` : "");
|
|
112
119
|
context.ua = input.ua ?? (typeof navigator === "undefined" ? "" : navigator.userAgent);
|
|
113
120
|
if (input.version !== undefined) context.version = input.version;
|
|
121
|
+
// The box's own test: what it draws no line of is not sent either.
|
|
122
|
+
if (input.diagnostics && diagnosticsEntries(input.diagnostics).length) context.diagnostics = input.diagnostics;
|
|
114
123
|
return context;
|
|
115
124
|
}
|
|
116
125
|
|
|
@@ -127,6 +136,9 @@ export interface FeedbackContextLabels {
|
|
|
127
136
|
user: string;
|
|
128
137
|
/** The checkbox. The URL it is about is the line beneath it. */
|
|
129
138
|
attachUrl: string;
|
|
139
|
+
/** The heading over what the app attached — `open(category, { diagnostics })`, shown
|
|
140
|
+
* read-only under the URL (keksdose live #481). de-CH: "Mitgesendete Diagnose" */
|
|
141
|
+
diagnostics: string;
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
// Defined in ./feedback-context-labels, which holds no component code (0.34, KS U8).
|
|
@@ -140,6 +152,10 @@ export interface FeedbackContextBoxProps {
|
|
|
140
152
|
/** The checkbox, on by default in the dialog (§4.2) — the same `attachUrl`. */
|
|
141
153
|
attachUrl: boolean;
|
|
142
154
|
onAttachUrlChange: (next: boolean) => void;
|
|
155
|
+
/** What the app attached — the same `diagnostics` handed to {@link feedbackContext}.
|
|
156
|
+
* Shown read-only, each key and its value as the row detail draws them; no block when
|
|
157
|
+
* there is no line to show. */
|
|
158
|
+
diagnostics?: Readonly<Record<string, unknown>> | null;
|
|
143
159
|
/** Prop > `<UiKitProvider labels={{ feedbackContext }}>` > English. */
|
|
144
160
|
labels?: LabelOverride<FeedbackContextLabels>;
|
|
145
161
|
className?: string;
|
|
@@ -157,6 +173,10 @@ export interface FeedbackContextBoxProps {
|
|
|
157
173
|
* against whatever screen happened to be open, the queue reads as if it were about that
|
|
158
174
|
* screen.
|
|
159
175
|
*
|
|
176
|
+
* Below the URL, what the app attached (`diagnostics`, keksdose live #481: a stuck receipt
|
|
177
|
+
* scan's bookkeeping), read-only and in the row detail's own lines: the reporter sees what
|
|
178
|
+
* goes with the report, and that the URL checkbox does not withhold it.
|
|
179
|
+
*
|
|
160
180
|
* Nothing in here is the app's: hand it the values handed to {@link feedbackContext}, and
|
|
161
181
|
* what the box shows is what is sent.
|
|
162
182
|
*/
|
|
@@ -165,11 +185,14 @@ export function FeedbackContextBox({
|
|
|
165
185
|
url,
|
|
166
186
|
attachUrl,
|
|
167
187
|
onAttachUrlChange,
|
|
188
|
+
diagnostics,
|
|
168
189
|
labels: labelsProp,
|
|
169
190
|
className,
|
|
170
191
|
}: FeedbackContextBoxProps) {
|
|
171
192
|
const labels = useKitLabels("feedbackContext", DEFAULT_FEEDBACK_CONTEXT_LABELS, labelsProp);
|
|
172
193
|
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
194
|
+
const diagnosticsId = useId();
|
|
195
|
+
const attached = diagnosticsEntries(diagnostics);
|
|
173
196
|
const submitter = submitterLine(user);
|
|
174
197
|
// "User: Ada …" through `common.fieldValue`, so French gets its space before the colon
|
|
175
198
|
// and Chinese its full-width one; the label and its punctuation are drawn bold, as
|
|
@@ -208,6 +231,15 @@ export function FeedbackContextBox({
|
|
|
208
231
|
) : undefined
|
|
209
232
|
}
|
|
210
233
|
/>
|
|
234
|
+
{attached.length > 0 && (
|
|
235
|
+
// A named group, so a screen reader hears what the pairs are before it reads them.
|
|
236
|
+
<div role="group" aria-labelledby={diagnosticsId} className="space-y-0.5 pt-1">
|
|
237
|
+
<div id={diagnosticsId} className="font-medium text-secondary">
|
|
238
|
+
{labels.diagnostics}
|
|
239
|
+
</div>
|
|
240
|
+
<FeedbackDiagnosticsList entries={attached} />
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
211
243
|
</div>
|
|
212
244
|
);
|
|
213
245
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context.diagnostics` (§3.2), drawn one way wherever it shows: in the submit dialog's
|
|
3
|
+
* context box, to the reporter before it goes (§4.2), and in the row detail, to the admin
|
|
4
|
+
* and the author afterwards (§4.4.2). One module for both, so the reporter is shown the
|
|
5
|
+
* very lines the admin will read (keksdose live #481).
|
|
6
|
+
*
|
|
7
|
+
* Internal: neither `@eifi1/ui-kit/feedback` nor the barrel names it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** `diagnostics` as [key, text] pairs, in the app's order: a string, number or boolean as
|
|
11
|
+
* written, an object or a list as JSON, nothing as "—". Empty for no diagnostics, and for
|
|
12
|
+
* a value that is not a plain object — the server stores whatever the client sent. */
|
|
13
|
+
export function diagnosticsEntries(diagnostics: unknown): Array<[string, string]> {
|
|
14
|
+
if (!diagnostics || typeof diagnostics !== "object" || Array.isArray(diagnostics)) return [];
|
|
15
|
+
return Object.entries(diagnostics).map(([key, v]) => [
|
|
16
|
+
key,
|
|
17
|
+
v === null || v === undefined || v === ""
|
|
18
|
+
? "—"
|
|
19
|
+
: typeof v === "object"
|
|
20
|
+
? JSON.stringify(v)
|
|
21
|
+
: String(v as string | number | boolean | bigint),
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The pairs as a definition list: each key beside its value, in monospace. Read-only in
|
|
26
|
+
* both places; the heading over it is the caller's. */
|
|
27
|
+
export function FeedbackDiagnosticsList({ entries }: { entries: ReadonlyArray<readonly [string, string]> }) {
|
|
28
|
+
return (
|
|
29
|
+
<dl
|
|
30
|
+
className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-mono text-xs"
|
|
31
|
+
data-feedback-diagnostics=""
|
|
32
|
+
>
|
|
33
|
+
{entries.map(([key, text]) => (
|
|
34
|
+
<div key={key} className="contents">
|
|
35
|
+
<dt className="text-muted">{key}</dt>
|
|
36
|
+
<dd className="min-w-0 break-words text-secondary">{text}</dd>
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</dl>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -146,6 +146,16 @@ interface FeedbackDialogBaseProps<C extends string = string> {
|
|
|
146
146
|
* English. Optional since 0.12.0; a whole `FeedbackDialogLabels` still fits. */
|
|
147
147
|
labels?: LabelOverride<FeedbackDialogLabels>;
|
|
148
148
|
submitting?: boolean;
|
|
149
|
+
/**
|
|
150
|
+
* The subject and the body each opening starts with, in place of empty fields — the
|
|
151
|
+
* reporter edits them like anything typed. `useFeedbackSubmit().open(category, { title,
|
|
152
|
+
* body })` passes them (keksdose live #481: "Report this problem" opens on words the
|
|
153
|
+
* screen already knows). Read when the dialog opens; a change while it is open leaves
|
|
154
|
+
* the draft alone.
|
|
155
|
+
*/
|
|
156
|
+
defaultTitle?: string;
|
|
157
|
+
/** The body's, as `defaultTitle` is the subject's. */
|
|
158
|
+
defaultBody?: string;
|
|
149
159
|
contextSlot?: ReactNode;
|
|
150
160
|
attachmentAccept?: string[];
|
|
151
161
|
maxAttachmentBytes?: number;
|
|
@@ -245,6 +255,8 @@ export function FeedbackDialog<C extends string = string>(props: FeedbackDialogP
|
|
|
245
255
|
onCategoryChange,
|
|
246
256
|
labels: labelsProp,
|
|
247
257
|
submitting = false,
|
|
258
|
+
defaultTitle = "",
|
|
259
|
+
defaultBody = "",
|
|
248
260
|
contextSlot,
|
|
249
261
|
attachmentAccept = DEFAULT_ATTACHMENT_ACCEPT,
|
|
250
262
|
maxAttachmentBytes = DEFAULT_MAX_ATTACHMENT_BYTES,
|
|
@@ -254,8 +266,8 @@ export function FeedbackDialog<C extends string = string>(props: FeedbackDialogP
|
|
|
254
266
|
attachmentButtonSize,
|
|
255
267
|
publicNotice,
|
|
256
268
|
} = props;
|
|
257
|
-
const [title, setTitle] = useState(
|
|
258
|
-
const [body, setBody] = useState(
|
|
269
|
+
const [title, setTitle] = useState(defaultTitle);
|
|
270
|
+
const [body, setBody] = useState(defaultBody);
|
|
259
271
|
const [attachment, setAttachment] = useState<File | null>(null);
|
|
260
272
|
// `attachments="multiple"` only: the capture, apart from the picked/pasted files.
|
|
261
273
|
const [screenshot, setScreenshot] = useState<File | null>(null);
|
|
@@ -283,8 +295,8 @@ export function FeedbackDialog<C extends string = string>(props: FeedbackDialogP
|
|
|
283
295
|
if (open !== wasOpen) {
|
|
284
296
|
setWasOpen(open);
|
|
285
297
|
if (open) {
|
|
286
|
-
setTitle(
|
|
287
|
-
setBody(
|
|
298
|
+
setTitle(defaultTitle);
|
|
299
|
+
setBody(defaultBody);
|
|
288
300
|
setAttachment(null);
|
|
289
301
|
setScreenshot(null);
|
|
290
302
|
setFiles([]);
|
|
@@ -59,6 +59,11 @@ export interface FeedbackContext {
|
|
|
59
59
|
ua?: string;
|
|
60
60
|
/** The app build (`__APP_VERSION__`). */
|
|
61
61
|
version?: string;
|
|
62
|
+
/** What the app attached about the thing reported (keksdose's stuck receipt scan, live
|
|
63
|
+
* #481): a flat record of short values, in the order the app wrote them. The row
|
|
64
|
+
* detail shows it to the admin and the author, never to anyone else; erasure drops it
|
|
65
|
+
* (it is not on server-kit's `FEEDBACK_CONTEXT_KEEP`). */
|
|
66
|
+
diagnostics?: Readonly<Record<string, unknown>>;
|
|
62
67
|
// Crash rows (server-written, keksdose `feedback_service.py:346`).
|
|
63
68
|
fingerprint?: string;
|
|
64
69
|
/** Which error boundary caught it. */
|
|
@@ -14,6 +14,7 @@ export const DEFAULT_FEEDBACK_DETAIL_LABELS: FeedbackDetailLabels = {
|
|
|
14
14
|
cancel: "Cancel",
|
|
15
15
|
url: "URL",
|
|
16
16
|
copyUrl: "Copy URL",
|
|
17
|
+
diagnostics: "Diagnostics",
|
|
17
18
|
attachment: "Attachment",
|
|
18
19
|
download: (name) => `Download ${name}`,
|
|
19
20
|
downloadFailed: "The attachment could not be downloaded.",
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
splitDescription,
|
|
36
36
|
} from "./feedback-record";
|
|
37
37
|
import type { FeedbackContext, FeedbackRecord } from "./feedback-record";
|
|
38
|
+
import { diagnosticsEntries, FeedbackDiagnosticsList } from "./feedback-diagnostics";
|
|
38
39
|
import { runMutation } from "./feedback-mutation";
|
|
39
40
|
import { feedbackPage, feedbackPageLinkTarget } from "./feedback-page-link";
|
|
40
41
|
import { useFeedbackStatusUndo } from "./feedback-status-undo";
|
|
@@ -117,6 +118,9 @@ export interface FeedbackDetailLabels {
|
|
|
117
118
|
/** The copy button beside the link — copies the FULL `context.url`.
|
|
118
119
|
* de-CH: "URL kopieren" */
|
|
119
120
|
copyUrl: string;
|
|
121
|
+
/** The heading over `context.diagnostics`, between the URL and the attachment (keksdose
|
|
122
|
+
* live #481). de-CH: "Diagnose" */
|
|
123
|
+
diagnostics: string;
|
|
120
124
|
/** Section 3's heading, and each picture's alt text. de-CH: "Anhang" */
|
|
121
125
|
attachment: string;
|
|
122
126
|
/** A PDF's / text file's download button, given the file's name.
|
|
@@ -631,6 +635,9 @@ export function FeedbackRowDetail({
|
|
|
631
635
|
const { description } = splitDescription(row.body);
|
|
632
636
|
const page = feedbackPage(row.context);
|
|
633
637
|
const pageLink = page && feedbackPageLinkTarget(page, pageLinkExclude);
|
|
638
|
+
// What the app attached is for whoever handles the report and whoever sent it, never
|
|
639
|
+
// for another viewer of a shared row (keksdose live #481).
|
|
640
|
+
const diagnostics = canEdit || access.isAuthor ? diagnosticsEntries(row.context?.diagnostics) : [];
|
|
634
641
|
// 0.47 (§12): with the entries, each entry's file is drawn inline in the Replies
|
|
635
642
|
// timeline, so the Attachment section holds the report's own files.
|
|
636
643
|
const files = feedbackAttachmentUrls(row.replies ? { ...row, replies: undefined } : row);
|
|
@@ -733,6 +740,12 @@ export function FeedbackRowDetail({
|
|
|
733
740
|
</FeedbackDetailSection>
|
|
734
741
|
)}
|
|
735
742
|
|
|
743
|
+
{diagnostics.length > 0 && (
|
|
744
|
+
<FeedbackDetailSection title={labels.diagnostics}>
|
|
745
|
+
<FeedbackDiagnosticsList entries={diagnostics} />
|
|
746
|
+
</FeedbackDetailSection>
|
|
747
|
+
)}
|
|
748
|
+
|
|
736
749
|
{files.length > 0 && (
|
|
737
750
|
<FeedbackDetailSection title={labels.attachment}>
|
|
738
751
|
<div className="flex min-w-0 flex-wrap items-start gap-2">
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type FeedbackContext,
|
|
13
13
|
type FeedbackEnvironment,
|
|
14
14
|
} from "./feedback-record";
|
|
15
|
+
import type { FeedbackSubmitOpenOptions } from "./feedback-submit";
|
|
15
16
|
import type { FeedbackCategory } from "./feedback-vocabulary";
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -49,6 +50,9 @@ export interface FeedbackSubmitDialogProps {
|
|
|
49
50
|
capture?: false | (() => Promise<File | null>);
|
|
50
51
|
/** `UseFeedbackSubmitOptions.publicNotice` (0.38): the line under the body. */
|
|
51
52
|
publicNotice?: boolean;
|
|
53
|
+
/** `open(category, options)`'s, taken when it opened (keksdose live #481): the box
|
|
54
|
+
* shows the diagnostics and the report carries them; the title and body prefill. */
|
|
55
|
+
opened?: FeedbackSubmitOpenOptions;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/** `FeedbackDialog` as `useFeedbackSubmit` settles it — see the hook for what it settles. */
|
|
@@ -66,6 +70,7 @@ export function FeedbackSubmitDialog({
|
|
|
66
70
|
version,
|
|
67
71
|
capture,
|
|
68
72
|
publicNotice = false,
|
|
73
|
+
opened: { diagnostics, title, body } = {},
|
|
69
74
|
}: FeedbackSubmitDialogProps) {
|
|
70
75
|
const toastText = useFeedbackToastLabels();
|
|
71
76
|
const dialogText = useKitLabels("feedbackDialog", DEFAULT_FEEDBACK_DIALOG_LABELS);
|
|
@@ -108,10 +113,18 @@ export function FeedbackSubmitDialog({
|
|
|
108
113
|
}
|
|
109
114
|
onCaptureScreenshot={onCaptureScreenshot}
|
|
110
115
|
publicNotice={publicNotice ? (dialogText.publicNotice ?? DEFAULT_FEEDBACK_DIALOG_LABELS.publicNotice) : undefined}
|
|
116
|
+
defaultTitle={title}
|
|
117
|
+
defaultBody={body}
|
|
111
118
|
// The context is read off the very values the box below shows (§3.2).
|
|
112
|
-
onSubmit={(data) => onSubmit(data, feedbackContext({ user, attachUrl, url, environment, version }))}
|
|
119
|
+
onSubmit={(data) => onSubmit(data, feedbackContext({ user, attachUrl, url, environment, version, diagnostics }))}
|
|
113
120
|
contextSlot={
|
|
114
|
-
<FeedbackContextBox
|
|
121
|
+
<FeedbackContextBox
|
|
122
|
+
user={user}
|
|
123
|
+
url={url}
|
|
124
|
+
attachUrl={attachUrl}
|
|
125
|
+
onAttachUrlChange={onAttachUrlChange}
|
|
126
|
+
diagnostics={diagnostics}
|
|
127
|
+
/>
|
|
115
128
|
}
|
|
116
129
|
/>
|
|
117
130
|
);
|
|
@@ -97,14 +97,42 @@ export interface UseFeedbackSubmitOptions<R = unknown> {
|
|
|
97
97
|
publicNotice?: boolean;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* `open`'s second argument (keksdose live #481: "Report this problem" on a receipt scan
|
|
102
|
+
* that never finished, filed with the scan's bookkeeping). All of it optional, and none of
|
|
103
|
+
* it outlives the opening it was handed to.
|
|
104
|
+
*/
|
|
105
|
+
export interface FeedbackSubmitOpenOptions {
|
|
106
|
+
/**
|
|
107
|
+
* What the app knows about the thing reported — keksdose's stuck scan: its id, status,
|
|
108
|
+
* attempts, last error. Taken when the dialog opens (a shallow copy, so what the box
|
|
109
|
+
* shows is what goes, even if the app's object changes while the reporter types), shown
|
|
110
|
+
* read-only in the context box, and sent as `context.diagnostics` whatever "Attach
|
|
111
|
+
* current page URL" says: that box withholds the page, not this. Flat, short values,
|
|
112
|
+
* never the reported content itself (§3.2); the row detail shows them to the admin and
|
|
113
|
+
* the author only.
|
|
114
|
+
*/
|
|
115
|
+
diagnostics?: Readonly<Record<string, unknown>>;
|
|
116
|
+
/** The subject the form starts with; the reporter can change it. Where the app's reports
|
|
117
|
+
* are public (`publicNotice`), other users read it: what only the admin should see goes
|
|
118
|
+
* in `diagnostics`. */
|
|
119
|
+
title?: string;
|
|
120
|
+
/** The body the form starts with, as `title` is the subject's. */
|
|
121
|
+
body?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
100
124
|
export interface FeedbackSubmit {
|
|
101
125
|
/**
|
|
102
126
|
* Open the dialog on `category` — a `FeedbackMenu` row's (§4.2: "default category when
|
|
103
127
|
* opened from a row: that row's"). Without one, or with `CRASH` (never pickable), it
|
|
104
128
|
* opens on `OTHER`, the server's own default (§3.4). Each open starts a fresh form,
|
|
105
129
|
* reads `location.href` for the context box and ticks "Attach current page URL" again.
|
|
130
|
+
*
|
|
131
|
+
* `options` (0.54, keksdose live #481) is a screen's own report — `open("BUG", {
|
|
132
|
+
* diagnostics })` from "Report this problem": the diagnostics shown in the box and sent,
|
|
133
|
+
* the subject and body prefilled. An open without them starts empty and sends none.
|
|
106
134
|
*/
|
|
107
|
-
open: (category?: FeedbackCategory) => void;
|
|
135
|
+
open: (category?: FeedbackCategory, options?: FeedbackSubmitOpenOptions) => void;
|
|
108
136
|
/** Close it, as Cancel does. */
|
|
109
137
|
close: () => void;
|
|
110
138
|
/**
|
|
@@ -140,7 +168,8 @@ export interface FeedbackSubmit {
|
|
|
140
168
|
* - The pickable categories — Bug, Idea, Question, Other, in the kit's words. `CRASH` is
|
|
141
169
|
* filed by the error boundary and is never offered (keksdose feedback #160).
|
|
142
170
|
* - The context box, built from the same values as the `context` it sends
|
|
143
|
-
* ({@link FeedbackContextBox} / `feedbackContext`), the URL read when the dialog OPENS
|
|
171
|
+
* ({@link FeedbackContextBox} / `feedbackContext`), the URL read when the dialog OPENS,
|
|
172
|
+
* and the diagnostics `open` was handed then (0.54, keksdose live #481).
|
|
144
173
|
* - Upload every file, then create (keksdose `use-feedback-dialog.tsx:89`): the
|
|
145
174
|
* screenshot to `screenshot_url`, the rest to `attachment_urls`, all in parallel.
|
|
146
175
|
* - The toasts, from `feedbackToast`: filed, failed (the server's `detail` through
|
|
@@ -161,18 +190,21 @@ export function useFeedbackSubmit<R = unknown>(options: UseFeedbackSubmitOptions
|
|
|
161
190
|
const [category, setCategory] = useState<FeedbackCategory>("OTHER");
|
|
162
191
|
const [url, setUrl] = useState("");
|
|
163
192
|
const [attachUrl, setAttachUrl] = useState(true);
|
|
193
|
+
const [opened, setOpened] = useState<FeedbackSubmitOpenOptions>({});
|
|
164
194
|
const [submitting, setSubmitting] = useState(false);
|
|
165
195
|
// Which opening a send belongs to — see "closed and opened again" above.
|
|
166
196
|
const opening = useRef(0);
|
|
167
197
|
const inFlight = useRef(false);
|
|
168
198
|
|
|
169
|
-
const open = (next?: FeedbackCategory) => {
|
|
199
|
+
const open = (next?: FeedbackCategory, given?: FeedbackSubmitOpenOptions) => {
|
|
170
200
|
opening.current += 1;
|
|
171
201
|
setCategory(next && FEEDBACK_PICKABLE_CATEGORIES.includes(next) ? next : "OTHER");
|
|
172
202
|
setUrl(typeof window === "undefined" ? "" : window.location.href);
|
|
173
203
|
// Fresh every time (Kurvenschmiede use-feedback-dialog.tsx:80): a reporter who left the
|
|
174
204
|
// page out of one idea is not saying the bug report after it is unrelated too.
|
|
175
205
|
setAttachUrl(true);
|
|
206
|
+
// This opening's own, nothing of the last one's; the diagnostics copied now (#481).
|
|
207
|
+
setOpened({ ...given, diagnostics: given?.diagnostics && { ...given.diagnostics } });
|
|
176
208
|
setIsOpen(true);
|
|
177
209
|
};
|
|
178
210
|
const close = () => setIsOpen(false);
|
|
@@ -229,6 +261,7 @@ export function useFeedbackSubmit<R = unknown>(options: UseFeedbackSubmitOptions
|
|
|
229
261
|
version={version}
|
|
230
262
|
capture={capture}
|
|
231
263
|
publicNotice={publicNotice}
|
|
264
|
+
opened={opened}
|
|
232
265
|
/>
|
|
233
266
|
</Suspense>
|
|
234
267
|
) : null;
|
package/src/hooks/use-hotkey.ts
CHANGED
|
@@ -119,10 +119,33 @@ export function matchesHotkey(e: KeyboardEvent, combo: ParsedHotkey): boolean {
|
|
|
119
119
|
return symbol && !combo.shift ? true : e.shiftKey === combo.shift;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Whether `el` is editable content — `isContentEditable`, read from the attributes where
|
|
124
|
+
* the engine has no such property (keksdose audit follow-up, 2026-11-01). jsdom is one:
|
|
125
|
+
* there a hotkey fired while typing in a contenteditable, so a test could not show the
|
|
126
|
+
* editor keeping its letters, and an engine without the property would do the same.
|
|
127
|
+
*
|
|
128
|
+
* The nearest element with a `contenteditable` that names a state decides: `""`,
|
|
129
|
+
* `"true"` and `"plaintext-only"` edit, `"false"` does not — a `false` island inside an
|
|
130
|
+
* editor is not typed in. Any other value is the attribute's invalid-value default,
|
|
131
|
+
* which inherits, so the search goes on to the next one up. Keywords are
|
|
132
|
+
* case-insensitive. None at all: editable only in a `designMode` document.
|
|
133
|
+
*/
|
|
134
|
+
function isEditableContent(el: HTMLElement): boolean {
|
|
135
|
+
const native = (el as { isContentEditable?: boolean }).isContentEditable;
|
|
136
|
+
if (native !== undefined) return native;
|
|
137
|
+
for (let at = el.closest("[contenteditable]"); at; at = at.parentElement?.closest("[contenteditable]") ?? null) {
|
|
138
|
+
const state = (at.getAttribute("contenteditable") ?? "").toLowerCase();
|
|
139
|
+
if (state === "" || state === "true" || state === "plaintext-only") return true;
|
|
140
|
+
if (state === "false") return false;
|
|
141
|
+
}
|
|
142
|
+
return el.ownerDocument.designMode === "on";
|
|
143
|
+
}
|
|
144
|
+
|
|
122
145
|
/** Is the focus somewhere that takes typed characters? */
|
|
123
146
|
function isTypingTarget(target: EventTarget | null): boolean {
|
|
124
147
|
if (!(target instanceof HTMLElement)) return false;
|
|
125
|
-
if (target
|
|
148
|
+
if (isEditableContent(target)) return true;
|
|
126
149
|
if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) return true;
|
|
127
150
|
if (target instanceof HTMLInputElement) {
|
|
128
151
|
return !["button", "checkbox", "radio", "submit", "reset", "range", "color", "file", "image"].includes(target.type);
|