@eifi1/ui-kit 0.26.0 → 0.27.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 +15 -4
- package/dist/chart.d.ts +13 -9
- package/dist/components/account-chips.d.ts +1 -1
- package/dist/components/amount-input.d.ts +13 -9
- package/dist/components/button-group.d.ts +13 -9
- package/dist/components/calculator.d.ts +13 -9
- package/dist/components/confirm-dialog.d.ts +13 -9
- package/dist/components/copy-button.d.ts +10 -6
- package/dist/components/danger-confirm.d.ts +13 -9
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-labels.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +13 -9
- package/dist/components/file-button.d.ts +13 -9
- package/dist/components/file-dropzone.d.ts +13 -9
- package/dist/components/form-actions.d.ts +13 -9
- package/dist/components/iban-input.d.ts +13 -9
- package/dist/components/language-select.d.ts +13 -9
- package/dist/components/money-field.d.ts +13 -9
- package/dist/components/number-field.d.ts +13 -9
- package/dist/components/number-input.d.ts +13 -9
- package/dist/components/numpad-sheet.d.ts +13 -9
- package/dist/components/phone-input.d.ts +13 -9
- package/dist/components/series-chart.d.ts +13 -9
- package/dist/components/settings-fields.d.ts +13 -9
- package/dist/components/share-card.d.ts +13 -9
- package/dist/components/text-link.d.ts +13 -9
- package/dist/components/time-input.d.ts +13 -9
- package/dist/components/ui.d.ts +10 -6
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-labels-Cdn1QS2k.d.ts → data-table-labels-DUELeXpa.d.ts} +5 -0
- package/dist/data-table.d.ts +1 -1
- package/dist/feedback/feedback-attachment.d.ts +13 -9
- package/dist/feedback/feedback-attachment.js +3 -3
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-capture.d.ts +71 -0
- package/dist/feedback/feedback-capture.js +194 -0
- package/dist/feedback/feedback-capture.js.map +1 -0
- package/dist/feedback/feedback-context.d.ts +71 -0
- package/dist/feedback/feedback-context.js +96 -0
- package/dist/feedback/feedback-context.js.map +1 -0
- package/dist/feedback/feedback-crash.d.ts +276 -0
- package/dist/feedback/feedback-crash.js +225 -0
- package/dist/feedback/feedback-crash.js.map +1 -0
- package/dist/feedback/feedback-dialog.d.ts +13 -9
- package/dist/feedback/feedback-dialog.js +5 -3
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +13 -9
- package/dist/feedback/feedback-inbox.js +89 -49
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-labels.d.ts +71 -0
- package/dist/feedback/feedback-labels.js +48 -0
- package/dist/feedback/feedback-labels.js.map +1 -0
- package/dist/feedback/feedback-menu.d.ts +71 -0
- package/dist/feedback/feedback-menu.js +61 -0
- package/dist/feedback/feedback-menu.js.map +1 -0
- package/dist/feedback/feedback-record.d.ts +71 -0
- package/dist/feedback/feedback-record.js +104 -0
- package/dist/feedback/feedback-record.js.map +1 -0
- package/dist/feedback/feedback-row-detail.d.ts +71 -0
- package/dist/feedback/feedback-row-detail.js +437 -0
- package/dist/feedback/feedback-row-detail.js.map +1 -0
- package/dist/feedback/feedback-status-undo.d.ts +71 -0
- package/dist/feedback/feedback-status-undo.js +40 -0
- package/dist/feedback/feedback-status-undo.js.map +1 -0
- package/dist/feedback/feedback-submit.d.ts +182 -0
- package/dist/feedback/feedback-submit.js +101 -0
- package/dist/feedback/feedback-submit.js.map +1 -0
- package/dist/feedback/feedback-swipe.d.ts +152 -0
- package/dist/feedback/feedback-swipe.js +73 -0
- package/dist/feedback/feedback-swipe.js.map +1 -0
- package/dist/feedback/feedback-table.d.ts +71 -0
- package/dist/feedback/feedback-table.js +357 -0
- package/dist/feedback/feedback-table.js.map +1 -0
- package/dist/feedback/feedback-thread.d.ts +13 -9
- package/dist/feedback.d.ts +17 -9
- package/dist/feedback.js +11 -0
- package/dist/feedback.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +13 -9
- package/dist/i18n/defaults.d.ts +13 -9
- package/dist/i18n/defaults.js +16 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +13 -9
- package/dist/i18n/german.js +91 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +7 -3
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +13 -9
- package/dist/i18n/locales/de-CH.d.ts +13 -9
- package/dist/i18n/locales/en.d.ts +13 -9
- package/dist/i18n/locales/en.js +10 -1
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +13 -9
- package/dist/i18n/locales/es.js +89 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +13 -9
- package/dist/i18n/locales/fr.js +89 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +13 -9
- package/dist/i18n/locales/hu.js +90 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +13 -9
- package/dist/i18n/locales/it.js +89 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +13 -9
- package/dist/i18n/locales/zh.js +89 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +16 -11
- package/dist/i18n/review.js +11 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/swiss.js +3 -1
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/{feedback-BXKH-ToU.d.ts → kit-labels-DQg82eHB.d.ts} +1275 -25
- package/dist/rhf/fields.d.ts +13 -9
- package/dist/rhf/form.d.ts +13 -9
- package/dist/rhf.d.ts +13 -9
- package/dist/shell/app-shell.d.ts +13 -9
- package/dist/shell/top-bar-brand.d.ts +13 -9
- package/dist/shell.d.ts +11 -11
- package/dist/wizard/stepper-nav.d.ts +13 -9
- package/dist/wizard.d.ts +13 -9
- package/package.json +6 -1
- package/src/components/data-table.tsx +5 -0
- package/src/feedback/feedback-attachment.tsx +19 -3
- package/src/feedback/feedback-capture.ts +285 -0
- package/src/feedback/feedback-context.tsx +217 -0
- package/src/feedback/feedback-crash.ts +547 -0
- package/src/feedback/feedback-dialog.tsx +29 -6
- package/src/feedback/feedback-inbox.tsx +131 -27
- package/src/feedback/feedback-labels.ts +127 -0
- package/src/feedback/feedback-menu.tsx +132 -0
- package/src/feedback/feedback-record.ts +345 -0
- package/src/feedback/feedback-row-detail.tsx +825 -0
- package/src/feedback/feedback-status-undo.ts +111 -0
- package/src/feedback/feedback-submit.tsx +224 -0
- package/src/feedback/feedback-swipe.tsx +170 -0
- package/src/feedback/feedback-table.tsx +757 -0
- package/src/feedback.ts +13 -0
- package/src/i18n/defaults.ts +16 -0
- package/src/i18n/german.ts +98 -5
- package/src/i18n/kit-labels.tsx +20 -0
- package/src/i18n/locales/en.ts +15 -1
- package/src/i18n/locales/es.ts +96 -5
- package/src/i18n/locales/fr.ts +95 -5
- package/src/i18n/locales/hu.ts +94 -5
- package/src/i18n/locales/it.ts +97 -5
- package/src/i18n/locales/zh.ts +90 -5
- package/src/i18n/review.ts +14 -2
- package/src/i18n/swiss.ts +5 -1
- package/src/index.ts +13 -0
- package/tokens.css +4 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
1
|
+
import { useId, useRef, useState } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import {
|
|
4
4
|
Ban,
|
|
@@ -19,8 +19,10 @@ import { cn } from "../lib/cn";
|
|
|
19
19
|
import { Button, Textarea } from "../components/ui";
|
|
20
20
|
import type { ButtonSize, ButtonVariant } from "../components/ui";
|
|
21
21
|
import { Tooltip } from "../components/tooltip";
|
|
22
|
+
import { useCommitReason, useWriteLock } from "../components/write-lock";
|
|
22
23
|
import { FeedbackAttachmentField, type FeedbackAttachmentErrorInfo } from "./feedback-attachment";
|
|
23
24
|
import type { FeedbackAttachmentLabels } from "./feedback-dialog";
|
|
25
|
+
import { useFeedbackCategoryLabels, useFeedbackStatusLabels } from "./feedback-labels";
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* The feedback **inbox**, as the parts two apps were each writing separately.
|
|
@@ -37,13 +39,20 @@ import type { FeedbackAttachmentLabels } from "./feedback-dialog";
|
|
|
37
39
|
* Design's was thinner in every one of those places and read as a different
|
|
38
40
|
* product for no reason anyone chose.
|
|
39
41
|
*
|
|
40
|
-
* **
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* the row
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
42
|
+
* **The report itself lives next door (0.27.0).** This file used to say there
|
|
43
|
+
* was deliberately no `Feedback` type here: the apps stored a report differently
|
|
44
|
+
* (a context JSON column and an object store in one, a `page_path` and the bytes
|
|
45
|
+
* in the row in another). Marcel's feedback round (2026-10-04,
|
|
46
|
+
* docs/feedback-harmonization.md §5) gave all three apps one contract, so the
|
|
47
|
+
* shape is now shared — `FeedbackRecord` / `FeedbackContext` in
|
|
48
|
+
* feedback-record.ts, with the constants and body helpers. The components HERE
|
|
49
|
+
* still take the values they draw rather than a row, which keeps them usable
|
|
50
|
+
* from any surface; the detail panel stays a *shell* the row detail fills.
|
|
51
|
+
*
|
|
52
|
+
* **The words are the kit's too (0.27.0).** The status and category names are
|
|
53
|
+
* the `feedbackStatus` / `feedbackCategory` namespaces (feedback-labels.ts,
|
|
54
|
+
* keksdose's wording), read by default by the badges and the transitions below;
|
|
55
|
+
* a `label` the caller passes still wins.
|
|
47
56
|
*/
|
|
48
57
|
|
|
49
58
|
/** The seven states a report can be in.
|
|
@@ -264,8 +273,9 @@ export function feedbackCategoryRank(category: FeedbackCategory): number {
|
|
|
264
273
|
* Falls back to OTHER's treatment for a category this build has never heard of,
|
|
265
274
|
* so an older client against a newer API degrades to a readable neutral pill
|
|
266
275
|
* instead of throwing on `meta.icon` — a crash report must not be able to cause
|
|
267
|
-
* one. The *label*
|
|
268
|
-
*
|
|
276
|
+
* one. The *label* gets the same guard: by default the `feedbackCategory`
|
|
277
|
+
* namespace's word (0.27.0), and for a value it has no word for, what OTHER is
|
|
278
|
+
* called.
|
|
269
279
|
*/
|
|
270
280
|
export function FeedbackCategoryBadge({
|
|
271
281
|
category,
|
|
@@ -274,10 +284,13 @@ export function FeedbackCategoryBadge({
|
|
|
274
284
|
className,
|
|
275
285
|
}: {
|
|
276
286
|
category: FeedbackCategory;
|
|
277
|
-
label:
|
|
287
|
+
/** Over the `feedbackCategory` label (0.27.0: optional — before, every app passed
|
|
288
|
+
* its own translation). */
|
|
289
|
+
label?: ReactNode;
|
|
278
290
|
compact?: boolean;
|
|
279
291
|
className?: string;
|
|
280
292
|
}) {
|
|
293
|
+
const names = useFeedbackCategoryLabels();
|
|
281
294
|
// Annotated `| undefined` because the Record's index signature promises a hit
|
|
282
295
|
// for every FeedbackCategory, and at runtime the API can hand us one that is not.
|
|
283
296
|
const known: (typeof FEEDBACK_CATEGORY_META)["OTHER"] | undefined = FEEDBACK_CATEGORY_META[category];
|
|
@@ -294,23 +307,28 @@ export function FeedbackCategoryBadge({
|
|
|
294
307
|
)}
|
|
295
308
|
>
|
|
296
309
|
<Icon className={compact ? "size-3" : "size-3.5"} aria-hidden />
|
|
297
|
-
{label}
|
|
310
|
+
{label ?? (known ? names[category] : names.OTHER)}
|
|
298
311
|
</span>
|
|
299
312
|
);
|
|
300
313
|
}
|
|
301
314
|
|
|
302
315
|
/** Where a report stands, as a badge — for the places that only *report* the
|
|
303
|
-
* status rather than offering to change it.
|
|
316
|
+
* status rather than offering to change it. Named by the `feedbackStatus`
|
|
317
|
+
* namespace unless `label` says otherwise (0.27.0); a status this build has never
|
|
318
|
+
* heard of shows OPEN's look and its own raw value, never OPEN's name. */
|
|
304
319
|
export function FeedbackStatusBadge({
|
|
305
320
|
status,
|
|
306
321
|
label,
|
|
307
322
|
className,
|
|
308
323
|
}: {
|
|
309
324
|
status: FeedbackStatus;
|
|
310
|
-
label:
|
|
325
|
+
/** Over the `feedbackStatus` label (0.27.0: optional). */
|
|
326
|
+
label?: ReactNode;
|
|
311
327
|
className?: string;
|
|
312
328
|
}) {
|
|
329
|
+
const names = useFeedbackStatusLabels();
|
|
313
330
|
const meta = FEEDBACK_STATUS_META[status] ?? FEEDBACK_STATUS_META.OPEN;
|
|
331
|
+
const name: string | undefined = names[status];
|
|
314
332
|
const Icon = meta.icon;
|
|
315
333
|
return (
|
|
316
334
|
<span
|
|
@@ -322,7 +340,7 @@ export function FeedbackStatusBadge({
|
|
|
322
340
|
)}
|
|
323
341
|
>
|
|
324
342
|
<Icon className="size-3" aria-hidden />
|
|
325
|
-
{label}
|
|
343
|
+
{label ?? name ?? status}
|
|
326
344
|
</span>
|
|
327
345
|
);
|
|
328
346
|
}
|
|
@@ -349,6 +367,8 @@ export function FeedbackStatusTransitions({
|
|
|
349
367
|
onPick,
|
|
350
368
|
variant,
|
|
351
369
|
label,
|
|
370
|
+
commit,
|
|
371
|
+
disabledReason: ownDisabledReason,
|
|
352
372
|
className,
|
|
353
373
|
}: {
|
|
354
374
|
status: FeedbackStatus;
|
|
@@ -356,10 +376,30 @@ export function FeedbackStatusTransitions({
|
|
|
356
376
|
canEdit: boolean;
|
|
357
377
|
onPick: (status: FeedbackStatus) => void;
|
|
358
378
|
variant: "icon" | "pill";
|
|
359
|
-
/** What each status is called
|
|
360
|
-
|
|
379
|
+
/** What each status is called. Default (0.27.0): the `feedbackStatus` namespace —
|
|
380
|
+
* before, every app passed its own translation. */
|
|
381
|
+
label?: (status: FeedbackStatus) => string;
|
|
382
|
+
/**
|
|
383
|
+
* Every button here SAVES the moment it is pressed (0.27.0) — opt in and, under a
|
|
384
|
+
* locked {@link WriteLockProvider}, the row stays as it is and says why: each status
|
|
385
|
+
* the row could move to is `aria-disabled` but focusable, the click does nothing, and
|
|
386
|
+
* the lock's reason is in the kit Tooltip and the button's description — the
|
|
387
|
+
* `disabledReason` path `Button` takes. Off by default: a feedback inbox is not what
|
|
388
|
+
* every app's lock is about (keksdose's shell lock is a read-only demo BUDGET, and a
|
|
389
|
+
* report is not budget data).
|
|
390
|
+
*/
|
|
391
|
+
commit?: boolean;
|
|
392
|
+
/** Why the statuses cannot be changed right now, the same way (a lock's own reason
|
|
393
|
+
* wins over it). Only read while `canEdit`: a read-only row is a picture of the
|
|
394
|
+
* workflow, not a refused action. */
|
|
395
|
+
disabledReason?: ReactNode;
|
|
361
396
|
className?: string;
|
|
362
397
|
}) {
|
|
398
|
+
const names = useFeedbackStatusLabels();
|
|
399
|
+
const nameOf = label ?? ((value: FeedbackStatus) => names[value]);
|
|
400
|
+
const reason = useCommitReason(commit, ownDisabledReason);
|
|
401
|
+
const locked = canEdit && reason !== undefined && reason !== null && reason !== false && reason !== "";
|
|
402
|
+
const reasonId = useId();
|
|
363
403
|
return (
|
|
364
404
|
// Not a control: it only stops clicks on the buttons inside from bubbling to
|
|
365
405
|
// the row. The buttons are the keyboard path, and Enter/Space on them fires
|
|
@@ -370,17 +410,29 @@ export function FeedbackStatusTransitions({
|
|
|
370
410
|
// A status control inside a clickable row must not also open the row.
|
|
371
411
|
onClick={(event) => event.stopPropagation()}
|
|
372
412
|
>
|
|
413
|
+
{locked && (
|
|
414
|
+
<span id={reasonId} hidden>
|
|
415
|
+
{reason}
|
|
416
|
+
</span>
|
|
417
|
+
)}
|
|
373
418
|
{statuses.map((value) => {
|
|
374
419
|
const meta = FEEDBACK_STATUS_META[value];
|
|
375
420
|
const Icon = meta.icon;
|
|
376
421
|
const active = status === value;
|
|
377
|
-
const name =
|
|
422
|
+
const name = nameOf(value);
|
|
423
|
+
// Locked: focusable and described, never firing (see `commit`). The current
|
|
424
|
+
// status stays natively disabled — it was never something to press.
|
|
425
|
+
const refused = locked && !active;
|
|
378
426
|
const button = (
|
|
379
427
|
<button
|
|
380
428
|
key={value}
|
|
381
429
|
type="button"
|
|
382
|
-
disabled={!canEdit || active}
|
|
383
|
-
|
|
430
|
+
disabled={refused ? undefined : !canEdit || active}
|
|
431
|
+
aria-disabled={refused || undefined}
|
|
432
|
+
aria-describedby={refused ? reasonId : undefined}
|
|
433
|
+
onClick={() => {
|
|
434
|
+
if (!refused) onPick(value);
|
|
435
|
+
}}
|
|
384
436
|
aria-label={name}
|
|
385
437
|
aria-pressed={active}
|
|
386
438
|
className={cn(
|
|
@@ -404,12 +456,24 @@ export function FeedbackStatusTransitions({
|
|
|
404
456
|
// "unavailable" rather than as "this is where the row stands".
|
|
405
457
|
(!canEdit || active) && "cursor-default",
|
|
406
458
|
!canEdit && "opacity-60",
|
|
459
|
+
refused && "cursor-not-allowed opacity-50",
|
|
407
460
|
)}
|
|
408
461
|
>
|
|
409
462
|
<Icon className={variant === "icon" ? "size-4" : "size-3.5"} aria-hidden />
|
|
410
463
|
{variant === "pill" && name}
|
|
411
464
|
</button>
|
|
412
465
|
);
|
|
466
|
+
// Locked, the bubble says WHY in both variants; the icon's name is still its
|
|
467
|
+
// `aria-label`, and the reason its description — the hidden copy above, which
|
|
468
|
+
// stays put, rather than the bubble (as on `Button`: the FRAGMENT keeps Tooltip
|
|
469
|
+
// from adding the bubble as a second, coming-and-going description).
|
|
470
|
+
if (refused) {
|
|
471
|
+
return (
|
|
472
|
+
<Tooltip key={value} label={reason} side="top" portal={variant === "icon" || undefined}>
|
|
473
|
+
<>{button}</>
|
|
474
|
+
</Tooltip>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
413
477
|
// The icon row has no visible label, so it needs the tooltip; the pills
|
|
414
478
|
// carry theirs inline.
|
|
415
479
|
//
|
|
@@ -474,6 +538,8 @@ export function FeedbackNoteEditor({
|
|
|
474
538
|
rows = 3,
|
|
475
539
|
attachment,
|
|
476
540
|
resetKey,
|
|
541
|
+
required = false,
|
|
542
|
+
commit,
|
|
477
543
|
}: {
|
|
478
544
|
initial: string;
|
|
479
545
|
pending: boolean;
|
|
@@ -496,6 +562,19 @@ export function FeedbackNoteEditor({
|
|
|
496
562
|
* that cannot put a `key` on the editor (see the note above). Leave it out and
|
|
497
563
|
* the draft is never thrown away behind the user's back. */
|
|
498
564
|
resetKey?: string | number;
|
|
565
|
+
/**
|
|
566
|
+
* A blank note cannot be saved (0.27.0): Save stays disabled — and Ctrl/⌘+Enter does
|
|
567
|
+
* nothing — until the trimmed text is non-empty, and the box is `aria-required`. For
|
|
568
|
+
* the rework note, which the feedback contract requires (§3.4: a file alone cannot
|
|
569
|
+
* be sent; keksdose `feedback-page.tsx:577` dropped the click silently instead, so a
|
|
570
|
+
* press on Send with an empty box looked like a lost send). Default `false`: an
|
|
571
|
+
* outcome or a description may be cleared on purpose.
|
|
572
|
+
*/
|
|
573
|
+
required?: boolean;
|
|
574
|
+
/** Save COMMITS (0.27.0): under a locked `WriteLockProvider` it is disabled the
|
|
575
|
+
* `disabledReason` way, with the lock's reason, and Ctrl/⌘+Enter saves nothing. The
|
|
576
|
+
* text box stays editable — nothing in it reaches the server until the save. */
|
|
577
|
+
commit?: boolean;
|
|
499
578
|
}) {
|
|
500
579
|
const [draft, setDraft] = useState(initial);
|
|
501
580
|
const [file, setFile] = useState<File | null>(null);
|
|
@@ -514,6 +593,12 @@ export function FeedbackNoteEditor({
|
|
|
514
593
|
// never passes through the field's own subtree. It listens here instead
|
|
515
594
|
// (Steering Design feedback #140).
|
|
516
595
|
const root = useRef<HTMLDivElement>(null);
|
|
596
|
+
const hintId = useId();
|
|
597
|
+
const lock = useWriteLock();
|
|
598
|
+
const blank = required && !draft.trim();
|
|
599
|
+
// The shortcut obeys what the button shows: no save while one is in flight, while the
|
|
600
|
+
// note a `required` editor needs is missing, or while a `commit` save is locked.
|
|
601
|
+
const canSubmit = !pending && !blank && !(commit && lock.locked);
|
|
517
602
|
const submit = () => onSave(draft, file);
|
|
518
603
|
return (
|
|
519
604
|
// Not a control: a delegated Ctrl/Cmd+Enter shortcut for the textarea inside,
|
|
@@ -523,18 +608,34 @@ export function FeedbackNoteEditor({
|
|
|
523
608
|
ref={root}
|
|
524
609
|
className="space-y-2"
|
|
525
610
|
onKeyDown={(event) => {
|
|
526
|
-
if ((event.ctrlKey || event.metaKey) && event.key === "Enter" &&
|
|
611
|
+
if ((event.ctrlKey || event.metaKey) && event.key === "Enter" && canSubmit) {
|
|
527
612
|
event.preventDefault();
|
|
528
613
|
submit();
|
|
529
614
|
}
|
|
530
615
|
}}
|
|
531
616
|
>
|
|
532
|
-
{
|
|
533
|
-
|
|
617
|
+
{/* The line names the box (0.27.0): it is the only text that says what to write
|
|
618
|
+
in it, and an unnamed textarea is read out as just "edit text". */}
|
|
619
|
+
{placeholder && (
|
|
620
|
+
<p id={hintId} className="text-xs text-[var(--text-muted)]">
|
|
621
|
+
{placeholder}
|
|
622
|
+
</p>
|
|
623
|
+
)}
|
|
624
|
+
<Textarea
|
|
625
|
+
rows={rows}
|
|
626
|
+
value={draft}
|
|
627
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
628
|
+
aria-labelledby={placeholder ? hintId : undefined}
|
|
629
|
+
aria-required={required || undefined}
|
|
630
|
+
/>
|
|
534
631
|
{attachment && (
|
|
535
632
|
<FeedbackAttachmentField
|
|
536
633
|
value={file}
|
|
537
634
|
onChange={setFile}
|
|
635
|
+
// A pause while the save is in flight (the field's G5b `disabled`): the note
|
|
636
|
+
// goes out with the file AS IT WAS at the press, so a chip removed or added
|
|
637
|
+
// mid-send would be a lie about what was sent.
|
|
638
|
+
disabled={pending}
|
|
538
639
|
labels={attachment.labels}
|
|
539
640
|
accept={attachment.accept}
|
|
540
641
|
maxBytes={attachment.maxBytes}
|
|
@@ -554,7 +655,7 @@ export function FeedbackNoteEditor({
|
|
|
554
655
|
<Button variant="ghost" onClick={onCancel}>
|
|
555
656
|
{cancelLabel}
|
|
556
657
|
</Button>
|
|
557
|
-
<Button variant="brand" disabled={pending} onClick={submit}>
|
|
658
|
+
<Button variant="brand" disabled={pending || blank} commit={commit} onClick={submit}>
|
|
558
659
|
{saveLabel}
|
|
559
660
|
</Button>
|
|
560
661
|
</div>
|
|
@@ -567,7 +668,10 @@ export function FeedbackNoteEditor({
|
|
|
567
668
|
* held to the app's own limits — and drawn in its own look — rather than the
|
|
568
669
|
* defaults. */
|
|
569
670
|
export interface FeedbackNoteAttachment {
|
|
570
|
-
|
|
671
|
+
/** Over the field's own `feedbackAttachment` namespace. Optional since 0.27.0 — the
|
|
672
|
+
* field has read the provider's words since 0.7.0, so an app with a catalogue has
|
|
673
|
+
* nothing to restate. */
|
|
674
|
+
labels?: Partial<FeedbackAttachmentLabels>;
|
|
571
675
|
accept?: string[];
|
|
572
676
|
maxBytes?: number;
|
|
573
677
|
/** `info` (0.23.0) names the refused file and the limit. */
|
|
@@ -623,8 +727,8 @@ export function FeedbackDetailSection({
|
|
|
623
727
|
|
|
624
728
|
/** The stack a detail panel is. Here so the spacing between sections is decided
|
|
625
729
|
* once rather than by whichever app was written second. */
|
|
626
|
-
export function FeedbackDetail({ children }: { children: ReactNode }) {
|
|
627
|
-
return <div className="space-y-3 text-sm">{children}</div>;
|
|
730
|
+
export function FeedbackDetail({ children, className }: { children: ReactNode; className?: string }) {
|
|
731
|
+
return <div className={cn("space-y-3 text-sm", className)}>{children}</div>;
|
|
628
732
|
}
|
|
629
733
|
|
|
630
734
|
/** Prose inside a section — a body, an outcome, a note.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
2
|
+
import type { FeedbackCategory, FeedbackStatus } from "./feedback-inbox";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The words of a report's status and category, and the toasts a feedback page shows —
|
|
6
|
+
* three `<UiKitProvider labels>` namespaces (0.27.0).
|
|
7
|
+
*
|
|
8
|
+
* Until 0.27 every app kept its own map from the enum to a translation key (keksdose
|
|
9
|
+
* `STATUS_LABEL` and `categoryLabelKey`, "the package has no translations"), and the
|
|
10
|
+
* three German maps had drifted apart: Kurvenschmiede said "In Arbeit", "In Prüfung",
|
|
11
|
+
* "Test auf der Live-Umgebung" where keksdose and kastlan said "In Bearbeitung",
|
|
12
|
+
* "Zur Prüfung", "Live testen". Marcel's feedback round (2026-10-04,
|
|
13
|
+
* docs/feedback-harmonization.md §2.4) made keksdose's wording the canon and the kit its
|
|
14
|
+
* owner, in all seven languages. The English defaults below are keksdose's
|
|
15
|
+
* `frontend/src/shared/i18n/locales/en.json` (`feedback.*`); the de-CH canon for the
|
|
16
|
+
* catalogues is noted on each key (keksdose `de-CH.json` — ss, never ß).
|
|
17
|
+
*
|
|
18
|
+
* `FeedbackStatusBadge`, `FeedbackCategoryBadge` and `FeedbackStatusTransitions` read
|
|
19
|
+
* the first two by default, so an app drops its own maps; a `label` passed to them still
|
|
20
|
+
* wins.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `feedbackStatus` — one word per status, keyed by the enum value so
|
|
25
|
+
* `labels[row.status]` is the whole lookup.
|
|
26
|
+
*
|
|
27
|
+
* de-CH canon: OPEN "Offen" · IN_PROGRESS "In Bearbeitung" · IN_EVALUATION "Zur Prüfung"
|
|
28
|
+
* · NEEDS_LIVE_TEST "Live testen" · POSTPONED "Zurückgestellt" · DONE "Erledigt" ·
|
|
29
|
+
* WONT_DO "Wird nicht umgesetzt".
|
|
30
|
+
*/
|
|
31
|
+
export type FeedbackStatusLabels = Record<FeedbackStatus, string>;
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_FEEDBACK_STATUS_LABELS: FeedbackStatusLabels = {
|
|
34
|
+
OPEN: "Open",
|
|
35
|
+
IN_PROGRESS: "In progress",
|
|
36
|
+
IN_EVALUATION: "In evaluation",
|
|
37
|
+
NEEDS_LIVE_TEST: "Test when live",
|
|
38
|
+
POSTPONED: "Postponed",
|
|
39
|
+
DONE: "Done",
|
|
40
|
+
WONT_DO: "Won't do",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `feedbackCategory` — one word per category, keyed by the enum value. `CRASH` is never
|
|
45
|
+
* pickable, but it is shown: on the badge, in the filter, in a row's detail.
|
|
46
|
+
*
|
|
47
|
+
* de-CH canon: CRASH "Absturz" · BUG "Fehler" · IDEA "Idee" · QUESTION "Frage" ·
|
|
48
|
+
* OTHER "Sonstiges".
|
|
49
|
+
*/
|
|
50
|
+
export type FeedbackCategoryLabels = Record<FeedbackCategory, string>;
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_FEEDBACK_CATEGORY_LABELS: FeedbackCategoryLabels = {
|
|
53
|
+
CRASH: "Crash",
|
|
54
|
+
BUG: "Bug",
|
|
55
|
+
IDEA: "Idea",
|
|
56
|
+
QUESTION: "Question",
|
|
57
|
+
OTHER: "Other",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `feedbackToast` — what the submit dialog, a failed save and a status change say in a
|
|
62
|
+
* toast (§4.2, §4.4, §4.5 of the contract). A server's `detail` sentence, when it sent
|
|
63
|
+
* one, wins over `submitFailed` and `updateFailed` (§3.7); that choice is the caller's.
|
|
64
|
+
*/
|
|
65
|
+
export interface FeedbackToastLabels {
|
|
66
|
+
/** A report was filed. de-CH: "Danke für Ihr Feedback!" */
|
|
67
|
+
submitted: string;
|
|
68
|
+
/** `POST /feedback` failed with no `detail` of its own.
|
|
69
|
+
* de-CH: "Feedback konnte nicht gesendet werden" */
|
|
70
|
+
submitFailed: string;
|
|
71
|
+
/** A picked file is not an image, a PDF or a text file (§3.5).
|
|
72
|
+
* de-CH: "Nur Bilder, PDF- oder Textdateien sind erlaubt" */
|
|
73
|
+
attachmentUnsupported: string;
|
|
74
|
+
/** A picked file is over the 10 MB the contract allows (§3.5).
|
|
75
|
+
* de-CH: "Datei ist grösser als 10 MB" */
|
|
76
|
+
attachmentTooLarge: string;
|
|
77
|
+
/** More files were picked than fit; `count` is how many fit.
|
|
78
|
+
* de-CH: "Es passen nur {{count}} Anhänge – die übrigen wurden weggelassen." */
|
|
79
|
+
attachmentTooMany: (count: number) => string;
|
|
80
|
+
/** The screenshot capture failed (or its optional peer is not installed).
|
|
81
|
+
* de-CH: "Screenshot konnte nicht aufgenommen werden" */
|
|
82
|
+
captureFailed: string;
|
|
83
|
+
/** A `PATCH /feedback/{id}` failed with no `detail` of its own (§4.4).
|
|
84
|
+
* de-CH: "Änderung konnte nicht gespeichert werden." */
|
|
85
|
+
updateFailed: string;
|
|
86
|
+
/** A status change landed — the 8 s Undo toast (§4.5). `status` is the NEW status's
|
|
87
|
+
* label, `title` the report's title. de-CH: "Auf „{{status}}“ gesetzt: {{title}}" */
|
|
88
|
+
statusChanged: (status: string, title: string) => string;
|
|
89
|
+
/** That toast's action. de-CH: "Rückgängig" */
|
|
90
|
+
statusUndo: string;
|
|
91
|
+
/** The Undo landed — `status` is the RESTORED status's label.
|
|
92
|
+
* de-CH: "Zurück auf „{{status}}“: {{title}}" */
|
|
93
|
+
statusRestored: (status: string, title: string) => string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const DEFAULT_FEEDBACK_TOAST_LABELS: FeedbackToastLabels = {
|
|
97
|
+
submitted: "Thanks for the feedback!",
|
|
98
|
+
submitFailed: "Could not submit feedback",
|
|
99
|
+
attachmentUnsupported: "Only images, PDF or text files are allowed",
|
|
100
|
+
attachmentTooLarge: "File is larger than 10 MB",
|
|
101
|
+
attachmentTooMany: (count) =>
|
|
102
|
+
count === 1
|
|
103
|
+
? "Only 1 attachment fits — the rest were left out."
|
|
104
|
+
: `Only ${count} attachments fit — the rest were left out.`,
|
|
105
|
+
captureFailed: "Could not capture a screenshot",
|
|
106
|
+
updateFailed: "Could not save that change.",
|
|
107
|
+
statusChanged: (status, title) => `Set to “${status}”: ${title}`,
|
|
108
|
+
statusUndo: "Undo",
|
|
109
|
+
statusRestored: (status, title) => `Back to “${status}”: ${title}`,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** `feedbackStatus` resolved: English, then `<UiKitProvider labels>`, then `labels`. For
|
|
113
|
+
* the places that need a status's name outside the kit's own parts — a filter's options,
|
|
114
|
+
* `feedbackSwipePlan`'s panels, an app's overview tile. */
|
|
115
|
+
export function useFeedbackStatusLabels(labels?: Partial<FeedbackStatusLabels>): FeedbackStatusLabels {
|
|
116
|
+
return useKitLabels("feedbackStatus", DEFAULT_FEEDBACK_STATUS_LABELS, labels);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `feedbackCategory` resolved: English, then the provider, then `labels`. */
|
|
120
|
+
export function useFeedbackCategoryLabels(labels?: Partial<FeedbackCategoryLabels>): FeedbackCategoryLabels {
|
|
121
|
+
return useKitLabels("feedbackCategory", DEFAULT_FEEDBACK_CATEGORY_LABELS, labels);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `feedbackToast` resolved: English, then the provider, then `labels`. */
|
|
125
|
+
export function useFeedbackToastLabels(labels?: Partial<FeedbackToastLabels>): FeedbackToastLabels {
|
|
126
|
+
return useKitLabels("feedbackToast", DEFAULT_FEEDBACK_TOAST_LABELS, labels);
|
|
127
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Inbox, MessageSquare } from "lucide-react";
|
|
2
|
+
import type { UserAvatarBadge } from "../components/user-avatar";
|
|
3
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
4
|
+
import { TopBarActionMenu, type TopBarMenuEntry } from "../shell/topbar-action-menu";
|
|
5
|
+
import { FEEDBACK_CATEGORY_META, type FeedbackCategory } from "./feedback-inbox";
|
|
6
|
+
import { useFeedbackCategoryLabels } from "./feedback-labels";
|
|
7
|
+
import { FEEDBACK_PICKABLE_CATEGORIES } from "./feedback-record";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `feedbackMenu` — the top-bar feedback menu's own words (0.27.0). The four category rows
|
|
11
|
+
* are `feedbackCategory`'s, shared with the badges and the dialog's picker. de-CH canon
|
|
12
|
+
* (keksdose `de-CH.json`): `trigger` "Feedback senden", `myFeedback` "Mein Feedback",
|
|
13
|
+
* `viewFeedback` "Feedback ansehen".
|
|
14
|
+
*/
|
|
15
|
+
export interface FeedbackMenuLabels {
|
|
16
|
+
/** The trigger's accessible name, and the menu's. keksdose `feedback.send`. */
|
|
17
|
+
trigger: string;
|
|
18
|
+
/** The link to the reporter's own reports — everyone but an admin. keksdose
|
|
19
|
+
* `feedback.my_title`. */
|
|
20
|
+
myFeedback: string;
|
|
21
|
+
/** The link to the inbox — an admin's one list link, in place of "My feedback".
|
|
22
|
+
* keksdose `feedback.view_all`. */
|
|
23
|
+
viewFeedback: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_FEEDBACK_MENU_LABELS: FeedbackMenuLabels = {
|
|
27
|
+
trigger: "Send feedback",
|
|
28
|
+
myFeedback: "My feedback",
|
|
29
|
+
viewFeedback: "View feedback",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface FeedbackMenuProps {
|
|
33
|
+
/** A category row was chosen — open the submit dialog on it (`useFeedbackSubmit`'s
|
|
34
|
+
* `open`). Never called with `CRASH`: there is no row for it. */
|
|
35
|
+
onFile: (category: FeedbackCategory) => void;
|
|
36
|
+
/** The list link is "View feedback" → {@link inboxHref} INSTEAD of "My feedback" (§4.1,
|
|
37
|
+
* keksdose `top-bar.tsx:205-214`). The admin check is the app's (kastlan's is
|
|
38
|
+
* company-scoped). Default `false`. */
|
|
39
|
+
isAdmin?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Rows of the app's own, between the divider and the list link — keksdose's
|
|
42
|
+
* `Sparkles` Help assistant → `/assistant` and `MessagesSquare` Support chat →
|
|
43
|
+
* `/support` with its unread chip. Any {@link TopBarMenuEntry}; keep their `key`s clear
|
|
44
|
+
* of the menu's own (`BUG`, `IDEA`, `QUESTION`, `OTHER`, `feedback-divider`,
|
|
45
|
+
* `my-feedback`, `view-feedback`).
|
|
46
|
+
*/
|
|
47
|
+
extraEntries?: TopBarMenuEntry[];
|
|
48
|
+
/** The user's own reports, linked for everyone but an admin. Default `"/my-feedback"`
|
|
49
|
+
* (§2.3). */
|
|
50
|
+
myFeedbackHref?: string;
|
|
51
|
+
/** The admin inbox, linked for an admin. Default `"/feedback"` (§2.3). */
|
|
52
|
+
inboxHref?: string;
|
|
53
|
+
/** A dot on the trigger — keksdose's unread support replies (`tone: "danger"`). Its
|
|
54
|
+
* `label` joins the trigger's name ("Send feedback 2 unread"). The words are the app's. */
|
|
55
|
+
iconBadge?: UserAvatarBadge | null;
|
|
56
|
+
/** Prop > `<UiKitProvider labels={{ feedbackMenu }}>` > English. */
|
|
57
|
+
labels?: Partial<FeedbackMenuLabels>;
|
|
58
|
+
/** Which edge of the trigger the panel lines up with. Default `end`. */
|
|
59
|
+
align?: "start" | "end" | "left" | "right";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The top-bar feedback menu, the same in every app (0.27.0, docs/feedback-harmonization.md
|
|
64
|
+
* §4.1) — keksdose `frontend/src/app/top-bar.tsx:156` on the kit's `TopBarActionMenu`:
|
|
65
|
+
*
|
|
66
|
+
* 1. a `MessageSquare` trigger named "Send feedback", with an optional `iconBadge`;
|
|
67
|
+
* 2. Bug · Idea · Question · Other (`Bug`, `Lightbulb`, `HelpCircle`, `MoreHorizontal` —
|
|
68
|
+
* the category badges' own glyphs), each calling `onFile(category)`;
|
|
69
|
+
* 3. a divider, always;
|
|
70
|
+
* 4. the app's `extraEntries`;
|
|
71
|
+
* 5. ONE list link, `Inbox`: an admin's is "View feedback" → `/feedback`, everyone
|
|
72
|
+
* else's "My feedback" → `/my-feedback` — keksdose's (`top-bar.tsx:205-214`, feedback
|
|
73
|
+
* #331: admins get the management view, everyone else their own submissions).
|
|
74
|
+
*
|
|
75
|
+
* WHY A PRESET. Three apps had drawn three menus on the same primitive and they disagreed:
|
|
76
|
+
* kastlan headed it "New submission", Kurvenschmiede "Send feedback" with a single link
|
|
77
|
+
* that changed its words by role, keksdose had no heading and one link by role. keksdose
|
|
78
|
+
* is the source (§2.4, §7.11): no heading, one link — the inbox for an admin, who reaches
|
|
79
|
+
* their own reports there too — and the kit draws it, so the row order, the icons and the
|
|
80
|
+
* words cannot drift again. Where the menu sits in the bar stays each app's.
|
|
81
|
+
*
|
|
82
|
+
* `CRASH` has no row and never will: crash reports are filed by the error boundary with
|
|
83
|
+
* the stack and a fingerprint (keksdose feedback #160), and a hand-filed "crash" would sort
|
|
84
|
+
* to the top like one with none of what makes it actionable. The server refuses it with a
|
|
85
|
+
* 422 anyway (§3.4).
|
|
86
|
+
*
|
|
87
|
+
* The menu forwards nothing to its trigger, as `TopBarActionMenu` does not: keksdose's
|
|
88
|
+
* tour anchor (`data-tour="feedback-menu"`) stays on a wrapping `<span>`. Needs a router,
|
|
89
|
+
* as every `TopBarActionMenu` with link rows does.
|
|
90
|
+
*/
|
|
91
|
+
export function FeedbackMenu({
|
|
92
|
+
onFile,
|
|
93
|
+
isAdmin = false,
|
|
94
|
+
extraEntries,
|
|
95
|
+
myFeedbackHref = "/my-feedback",
|
|
96
|
+
inboxHref = "/feedback",
|
|
97
|
+
iconBadge,
|
|
98
|
+
labels: labelsProp,
|
|
99
|
+
align,
|
|
100
|
+
}: FeedbackMenuProps) {
|
|
101
|
+
const labels = useKitLabels("feedbackMenu", DEFAULT_FEEDBACK_MENU_LABELS, labelsProp);
|
|
102
|
+
const categoryLabels = useFeedbackCategoryLabels();
|
|
103
|
+
const entries: TopBarMenuEntry[] = [
|
|
104
|
+
...FEEDBACK_PICKABLE_CATEGORIES.map((category): TopBarMenuEntry => {
|
|
105
|
+
const Icon = FEEDBACK_CATEGORY_META[category].icon;
|
|
106
|
+
return {
|
|
107
|
+
key: category,
|
|
108
|
+
icon: <Icon className="size-4" />,
|
|
109
|
+
label: categoryLabels[category],
|
|
110
|
+
onSelect: () => onFile(category),
|
|
111
|
+
};
|
|
112
|
+
}),
|
|
113
|
+
{ kind: "divider", key: "feedback-divider" },
|
|
114
|
+
...(extraEntries ?? []),
|
|
115
|
+
{
|
|
116
|
+
kind: "link",
|
|
117
|
+
key: isAdmin ? "view-feedback" : "my-feedback",
|
|
118
|
+
to: isAdmin ? inboxHref : myFeedbackHref,
|
|
119
|
+
icon: <Inbox className="size-4" />,
|
|
120
|
+
label: isAdmin ? labels.viewFeedback : labels.myFeedback,
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
return (
|
|
124
|
+
<TopBarActionMenu
|
|
125
|
+
icon={<MessageSquare className="size-5" />}
|
|
126
|
+
iconBadge={iconBadge}
|
|
127
|
+
ariaLabel={labels.trigger}
|
|
128
|
+
entries={entries}
|
|
129
|
+
align={align}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
}
|