@eifi1/ui-kit 0.48.2 → 0.49.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 +26 -12
- package/dist/admin/admin-action-confirm.js +1 -1
- package/dist/admin/admin-action-confirm.js.map +1 -1
- package/dist/admin/invitations-panel.js +5 -1
- package/dist/admin/invitations-panel.js.map +1 -1
- package/dist/admin/user-roster.d.ts +11 -5
- package/dist/admin/user-roster.js +15 -4
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/accept-invitation.js +2 -2
- package/dist/auth/accept-invitation.js.map +1 -1
- package/dist/auth/not-found-page.js +1 -1
- package/dist/auth/not-found-page.js.map +1 -1
- package/dist/auth/reset-password-form.js +2 -2
- package/dist/auth/reset-password-form.js.map +1 -1
- package/dist/auth/verify-email.js +2 -2
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/billing/plan-card.js +2 -2
- package/dist/billing/plan-card.js.map +1 -1
- package/dist/components/breadcrumbs.d.ts +19 -1
- package/dist/components/breadcrumbs.js +11 -4
- package/dist/components/breadcrumbs.js.map +1 -1
- package/dist/components/button-look.d.ts +37 -7
- package/dist/components/button-look.js +33 -6
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/button.d.ts +4 -1
- package/dist/components/button.js +2 -2
- package/dist/components/button.js.map +1 -1
- package/dist/components/calendar-heatmap.js +2 -1
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/chart-edge.d.ts +10 -0
- package/dist/components/chart-edge.js +57 -0
- package/dist/components/chart-edge.js.map +1 -0
- package/dist/components/chart.js +2 -1
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.d.ts +8 -1
- package/dist/components/chip.js +4 -1
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-context.d.ts +5 -0
- package/dist/components/confirm-context.js +7 -0
- package/dist/components/confirm-context.js.map +1 -0
- package/dist/components/confirm-dialog.js +3 -3
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +2 -2
- package/dist/components/danger-confirm.js +1 -1
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-fit.js +21 -3
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-mobile.d.ts +48 -2
- package/dist/components/data-table-mobile.js +87 -10
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-row-actions.d.ts +2 -1
- package/dist/components/data-table-row-actions.js +7 -0
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-row.d.ts +1 -1
- package/dist/components/data-table-row.js.map +1 -1
- package/dist/components/data-table.d.ts +34 -1
- package/dist/components/data-table.js +34 -9
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/error-boundary.js +2 -2
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/floating-panel.d.ts +36 -13
- package/dist/components/floating-panel.js +27 -7
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/form-actions.js +1 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/icon-button.d.ts +11 -0
- package/dist/components/icon-button.js +7 -5
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/nav-pills.d.ts +8 -1
- package/dist/components/nav-pills.js +61 -38
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/page-header-context.d.ts +8 -0
- package/dist/components/page-header-context.js +7 -0
- package/dist/components/page-header-context.js.map +1 -0
- package/dist/components/page-header.d.ts +14 -2
- package/dist/components/page-header.js +10 -1
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/pie-chart.js +2 -1
- package/dist/components/pie-chart.js.map +1 -1
- package/dist/components/progress-bar.js +5 -4
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.js +1 -1
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/row-actions-labels.js +3 -1
- package/dist/components/row-actions-labels.js.map +1 -1
- package/dist/components/row-actions.d.ts +43 -9
- package/dist/components/row-actions.js +78 -15
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/section-nav.d.ts +37 -0
- package/dist/components/section-nav.js +33 -0
- package/dist/components/section-nav.js.map +1 -0
- package/dist/components/series-chart.js +8 -3
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/share-card.js +1 -1
- package/dist/components/share-card.js.map +1 -1
- package/dist/components/signature-pad.js +1 -1
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/sparkline.js +2 -1
- package/dist/components/sparkline.js.map +1 -1
- package/dist/components/stat-tile.js +14 -7
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/status-dot.d.ts +5 -1
- package/dist/components/status-dot.js +4 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/tooltip.js +1 -1
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/translation-review-editor.js +2 -2
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review.js +2 -2
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/treemap.d.ts +5 -2
- package/dist/components/treemap.js +26 -6
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/use-table-state.js +37 -6
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/data-table.js.map +1 -1
- package/dist/demo/demo-banner.js +1 -1
- package/dist/demo/demo-banner.js.map +1 -1
- package/dist/demo/demo-ended.js +1 -1
- package/dist/demo/demo-ended.js.map +1 -1
- package/dist/demo/demo-start.js +1 -1
- package/dist/demo/demo-start.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-inbox.js +1 -1
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/hooks/use-dismiss.d.ts +9 -1
- package/dist/hooks/use-dismiss.js +25 -5
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/hooks/use-document-title.d.ts +14 -0
- package/dist/hooks/use-document-title.js +22 -0
- package/dist/hooks/use-document-title.js.map +1 -0
- package/dist/i18n/german.js +6 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +31 -1
- package/dist/i18n/kit-labels.js +43 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +6 -2
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +6 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +6 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +6 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +6 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +6 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +2 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/shared-labels.js +1 -0
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/landing/landing-actions.js +2 -2
- package/dist/landing/landing-actions.js.map +1 -1
- package/dist/landing/public-header.js +2 -2
- package/dist/landing/public-header.js.map +1 -1
- package/dist/lib/clipboard-minus.d.ts +27 -0
- package/dist/lib/clipboard-minus.js +63 -0
- package/dist/lib/clipboard-minus.js.map +1 -0
- package/dist/lib/document-title.d.ts +17 -0
- package/dist/lib/document-title.js +29 -0
- package/dist/lib/document-title.js.map +1 -0
- package/dist/lib/float-clearance.js +8 -2
- package/dist/lib/float-clearance.js.map +1 -1
- package/dist/lib/format.d.ts +35 -10
- package/dist/lib/format.js +55 -11
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/minus.d.ts +22 -0
- package/dist/lib/minus.js +14 -0
- package/dist/lib/minus.js.map +1 -0
- package/dist/lib/status-tone.d.ts +30 -0
- package/dist/lib/status-tone.js +17 -0
- package/dist/lib/status-tone.js.map +1 -0
- package/dist/settings/settings-layout.js +3 -2
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/shell/account-menu-language.d.ts +1 -1
- package/dist/shell/account-menu-language.js +9 -1
- package/dist/shell/account-menu-language.js.map +1 -1
- package/dist/shell/app-shell-labels.js +2 -1
- package/dist/shell/app-shell-labels.js.map +1 -1
- package/dist/shell/app-shell.d.ts +58 -16
- package/dist/shell/app-shell.js +151 -43
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/language-code.d.ts +3 -0
- package/dist/shell/language-code.js +8 -0
- package/dist/shell/language-code.js.map +1 -0
- package/dist/shell/topbar-controls.d.ts +14 -6
- package/dist/shell/topbar-controls.js +14 -17
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/testing/css-variables.generated.d.ts +1 -1
- package/dist/testing/css-variables.generated.js +1 -1
- package/dist/testing/css-variables.generated.js.map +1 -1
- package/dist/theme/chart-palette.d.ts +82 -15
- package/dist/theme/chart-palette.js +51 -24
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/theme/palette-presets.d.ts +9 -0
- package/dist/theme/palette-presets.js +77 -62
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/tour/tour.js +1 -1
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard/stepper-nav.js +3 -3
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-action-confirm.tsx +1 -1
- package/src/admin/invitations-panel.tsx +7 -3
- package/src/admin/user-roster.tsx +23 -6
- package/src/auth/accept-invitation.tsx +3 -3
- package/src/auth/not-found-page.tsx +1 -1
- package/src/auth/reset-password-form.tsx +3 -3
- package/src/auth/verify-email.tsx +3 -3
- package/src/billing/plan-card.tsx +2 -2
- package/src/components/breadcrumbs.tsx +32 -4
- package/src/components/button-look.ts +86 -15
- package/src/components/button.tsx +6 -3
- package/src/components/calendar-heatmap.tsx +2 -1
- package/src/components/chart-edge.ts +73 -0
- package/src/components/chart.tsx +3 -1
- package/src/components/chip.tsx +10 -1
- package/src/components/confirm-context.ts +7 -0
- package/src/components/confirm-dialog.tsx +3 -3
- package/src/components/danger-confirm.tsx +3 -3
- package/src/components/data-table-fit.ts +41 -6
- package/src/components/data-table-mobile.tsx +162 -11
- package/src/components/data-table-row-actions.tsx +9 -1
- package/src/components/data-table-row.tsx +2 -0
- package/src/components/data-table.tsx +82 -18
- package/src/components/error-boundary.tsx +2 -2
- package/src/components/floating-panel.tsx +67 -16
- package/src/components/form-actions.tsx +2 -2
- package/src/components/icon-button.tsx +21 -8
- package/src/components/nav-pills.tsx +29 -2
- package/src/components/page-header-context.ts +8 -0
- package/src/components/page-header.tsx +34 -2
- package/src/components/pie-chart.tsx +2 -1
- package/src/components/progress-bar.tsx +6 -3
- package/src/components/reauth-dialog.tsx +1 -1
- package/src/components/row-actions-labels.ts +2 -0
- package/src/components/row-actions.tsx +154 -33
- package/src/components/section-nav.tsx +65 -0
- package/src/components/series-chart.tsx +12 -3
- package/src/components/share-card.tsx +1 -1
- package/src/components/signature-pad.tsx +1 -1
- package/src/components/sparkline.tsx +2 -1
- package/src/components/stat-tile.tsx +19 -7
- package/src/components/status-dot.tsx +7 -1
- package/src/components/tooltip.tsx +3 -1
- package/src/components/translation-review-editor.tsx +2 -2
- package/src/components/translation-review.tsx +2 -2
- package/src/components/treemap.tsx +34 -8
- package/src/components/use-table-state.ts +63 -8
- package/src/data-table.ts +3 -3
- package/src/demo/demo-banner.tsx +1 -1
- package/src/demo/demo-ended.tsx +1 -1
- package/src/demo/demo-start.tsx +1 -1
- package/src/feedback/feedback-inbox.tsx +1 -1
- package/src/hooks/use-dismiss.ts +43 -4
- package/src/hooks/use-document-title.ts +31 -0
- package/src/i18n/german.ts +4 -0
- package/src/i18n/kit-labels.tsx +76 -1
- package/src/i18n/locales/es.ts +4 -0
- package/src/i18n/locales/fr.ts +4 -0
- package/src/i18n/locales/hu.ts +4 -0
- package/src/i18n/locales/it.ts +4 -0
- package/src/i18n/locales/zh.ts +4 -0
- package/src/i18n/review.ts +2 -0
- package/src/i18n/shared-labels.ts +1 -0
- package/src/index.ts +13 -0
- package/src/landing/landing-actions.tsx +2 -2
- package/src/landing/public-header.tsx +2 -2
- package/src/lib/clipboard-minus.ts +94 -0
- package/src/lib/document-title.ts +41 -0
- package/src/lib/float-clearance.ts +19 -3
- package/src/lib/format.ts +115 -30
- package/src/lib/minus.ts +40 -0
- package/src/lib/status-tone.ts +43 -0
- package/src/settings/settings-layout.tsx +3 -2
- package/src/shell/account-menu-language.tsx +14 -2
- package/src/shell/app-shell-labels.ts +1 -0
- package/src/shell/app-shell.tsx +291 -68
- package/src/shell/language-code.ts +5 -0
- package/src/shell/topbar-controls.tsx +35 -15
- package/src/testing/css-variables.generated.ts +6 -6
- package/src/theme/chart-palette.ts +102 -20
- package/src/theme/palette-presets.ts +121 -84
- package/src/tour/tour.tsx +1 -1
- package/src/wizard/stepper-nav.tsx +4 -4
- package/tokens.css +28 -18
|
@@ -237,7 +237,7 @@ function FeedbackNoteEditor({
|
|
|
237
237
|
),
|
|
238
238
|
/* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2", children: [
|
|
239
239
|
/* @__PURE__ */ jsx(Button, { variant: "ghost", onClick: onCancel, children: cancelLabel }),
|
|
240
|
-
/* @__PURE__ */ jsx(Button, { variant: "
|
|
240
|
+
/* @__PURE__ */ jsx(Button, { variant: "accent", disabled: pending || blank, commit, onClick: submit, children: saveLabel })
|
|
241
241
|
] })
|
|
242
242
|
]
|
|
243
243
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/feedback/feedback-inbox.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { Button } from \"../components/button\";\nimport { Textarea } from \"../components/textarea\";\nimport type { ButtonSize, ButtonVariant } from \"../components/button-look\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { useCommitReason, useWriteLock, type CommitScope } from \"../components/write-lock\";\nimport { FeedbackAttachmentField, type FeedbackAttachmentErrorInfo } from \"./feedback-attachment\";\nimport type { FeedbackAttachmentLabels } from \"./feedback-dialog\";\nimport { useFeedbackCategoryLabels, useFeedbackStatusLabels } from \"./feedback-labels\";\nimport {\n FEEDBACK_CATEGORY_META,\n FEEDBACK_STATUS_META,\n type FeedbackCategory,\n type FeedbackStatus,\n} from \"./feedback-vocabulary\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\n// The words module, not ./feedback-row-detail: that one imports this file.\nimport { DEFAULT_FEEDBACK_DETAIL_LABELS } from \"./feedback-row-detail-labels\";\n\n/**\n * The feedback **inbox**, as the parts two apps were each writing separately.\n *\n * `feedback-dialog.tsx` next door is the other half — the form somebody files a\n * report with — and the split between them is the same one: the kit owns the\n * *vocabulary*, the *policy* about how a report moves (both in\n * feedback-vocabulary.ts since 0.34, re-exported below), and the *look* (here);\n * the app owns the data, the API and every string.\n *\n * It exists because both apps had grown their own copy. Keksdose's is the one\n * this is lifted from, comments and all, because it is the one that had been\n * argued with users for a year — the glyph per status, the two sizes of the\n * status control, what a row offers from where it currently stands. Steering\n * Design's was thinner in every one of those places and read as a different\n * product for no reason anyone chose.\n *\n * **The report itself lives next door (0.27.0).** This file used to say there\n * was deliberately no `Feedback` type here: the apps stored a report differently\n * (a context JSON column and an object store in one, a `page_path` and the bytes\n * in the row in another). Marcel's feedback round (2026-10-04,\n * docs/feedback-harmonization.md §5) gave all three apps one contract, so the\n * shape is now shared — `FeedbackRecord` / `FeedbackContext` in\n * feedback-record.ts, with the constants and body helpers. The components HERE\n * still take the values they draw rather than a row, which keeps them usable\n * from any surface; the detail panel stays a *shell* the row detail fills.\n *\n * **The words are the kit's too (0.27.0).** The status and category names are\n * the `feedbackStatus` / `feedbackCategory` namespaces (feedback-labels.ts,\n * keksdose's wording), read by default by the badges and the transitions below;\n * a `label` the caller passes still wins.\n */\n\n// The vocabulary — statuses, categories, their glyphs and orders, the transitions — is\n// feedback-vocabulary.ts since 0.34 (MOD-1): data the top bar's feedback menu imports\n// without this file's components. Re-exported, so every name is still found here.\nexport {\n FEEDBACK_CATEGORY_META,\n FEEDBACK_CATEGORY_ORDER,\n FEEDBACK_STATUS_META,\n FEEDBACK_STATUS_ORDER,\n feedbackCategoryRank,\n nextFeedbackStatus,\n selectableFeedbackStatuses,\n visibleFeedbackStatuses,\n} from \"./feedback-vocabulary\";\nexport type { FeedbackCategory, FeedbackStatus } from \"./feedback-vocabulary\";\n\n/**\n * The category as a badge.\n *\n * Falls back to OTHER's treatment for a category this build has never heard of,\n * so an older client against a newer API degrades to a readable neutral pill\n * instead of throwing on `meta.icon` — a crash report must not be able to cause\n * one. The *label* gets the same guard: by default the `feedbackCategory`\n * namespace's word (0.27.0), and for a value it has no word for, what OTHER is\n * called.\n */\nexport function FeedbackCategoryBadge({\n category,\n label,\n compact = false,\n className,\n}: {\n category: FeedbackCategory;\n /** Over the `feedbackCategory` label (0.27.0: optional — before, every app passed\n * its own translation). */\n label?: ReactNode;\n compact?: boolean;\n className?: string;\n}) {\n const names = useFeedbackCategoryLabels();\n // Annotated `| undefined` because the Record's index signature promises a hit\n // for every FeedbackCategory, and at runtime the API can hand us one that is not.\n const known: (typeof FEEDBACK_CATEGORY_META)[\"OTHER\"] | undefined = FEEDBACK_CATEGORY_META[category];\n const meta = known ?? FEEDBACK_CATEGORY_META.OTHER;\n const Icon = meta.icon;\n return (\n <span\n className={cn(\n \"inline-flex items-center gap-1 rounded font-medium\",\n compact ? \"px-1.5 py-0.5 text-caption\" : \"px-2 py-0.5 text-xs\",\n meta.badgeBg,\n meta.badgeText,\n className,\n )}\n >\n <Icon className={compact ? \"size-3\" : \"size-3.5\"} aria-hidden />\n {label ?? (known ? names[category] : names.OTHER)}\n </span>\n );\n}\n\n/** Where a report stands, as a badge — for the places that only *report* the\n * status rather than offering to change it. Named by the `feedbackStatus`\n * namespace unless `label` says otherwise (0.27.0); a status this build has never\n * heard of shows OPEN's look and its own raw value, never OPEN's name. */\nexport function FeedbackStatusBadge({\n status,\n label,\n className,\n}: {\n status: FeedbackStatus;\n /** Over the `feedbackStatus` label (0.27.0: optional). */\n label?: ReactNode;\n className?: string;\n}) {\n const names = useFeedbackStatusLabels();\n const meta = FEEDBACK_STATUS_META[status] ?? FEEDBACK_STATUS_META.OPEN;\n const name: string | undefined = names[status];\n const Icon = meta.icon;\n return (\n <span\n className={cn(\n \"inline-flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-0.5 text-caption font-medium\",\n meta.activeBg,\n meta.activeText,\n className,\n )}\n >\n <Icon className=\"size-3\" aria-hidden />\n {label ?? name ?? status}\n </span>\n );\n}\n\n/**\n * A row of statuses to switch a report to — either a table column's icon row or\n * an expanded panel's labelled pills.\n *\n * One component for both, because everything except the class strings is the\n * same decision: which one is current, and when the buttons are disabled. Two\n * copies had already drifted — only the icon variant carried `aria-label` and\n * `aria-pressed`, so the pill row was unlabelled for a screen reader.\n *\n * WHICH statuses to offer is the caller's, not this component's:\n * {@link visibleFeedbackStatuses} for a compact column,\n * {@link selectableFeedbackStatuses} for an expanded row. Passing the list keeps\n * that policy readable at the two call sites instead of hiding it behind\n * `variant`.\n */\nexport function FeedbackStatusTransitions({\n status,\n statuses,\n canEdit,\n onPick,\n variant,\n label,\n commit,\n disabledReason: ownDisabledReason,\n className,\n}: {\n status: FeedbackStatus;\n statuses: FeedbackStatus[];\n canEdit: boolean;\n onPick: (status: FeedbackStatus) => void;\n variant: \"icon\" | \"pill\";\n /** What each status is called. Default (0.27.0): the `feedbackStatus` namespace —\n * before, every app passed its own translation. */\n label?: (status: FeedbackStatus) => string;\n /**\n * Every button here SAVES the moment it is pressed (0.27.0) — opt in and, under a\n * locked {@link WriteLockProvider}, the row stays as it is and says why: each status\n * the row could move to is `aria-disabled` but focusable, the click does nothing, and\n * the lock's reason is in the kit Tooltip and the button's description — the\n * `disabledReason` path `Button` takes. Off by default: a feedback inbox is not what\n * every app's lock is about (keksdose's shell lock is a read-only demo BUDGET, and a\n * report is not budget data).\n */\n commit?: CommitScope;\n /** Why the statuses cannot be changed right now, the same way (a lock's own reason\n * wins over it). Only read while `canEdit`: a read-only row is a picture of the\n * workflow, not a refused action. */\n disabledReason?: ReactNode;\n className?: string;\n}) {\n const names = useFeedbackStatusLabels();\n const nameOf = label ?? ((value: FeedbackStatus) => names[value]);\n const reason = useCommitReason(commit, ownDisabledReason);\n const locked = canEdit && reason !== undefined && reason !== null && reason !== false && reason !== \"\";\n const reasonId = useId();\n return (\n // Not a control: it only stops clicks on the buttons inside from bubbling to\n // the row. The buttons are the keyboard path, and Enter/Space on them fires\n // their own click, which lands here the same way.\n // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- propagation guard around real buttons\n <div\n className={cn(variant === \"icon\" ? \"flex items-center gap-0.5\" : \"flex flex-wrap gap-1.5\", className)}\n // A status control inside a clickable row must not also open the row.\n onClick={(event) => event.stopPropagation()}\n >\n {locked && (\n <span id={reasonId} hidden>\n {reason}\n </span>\n )}\n {statuses.map((value) => {\n const meta = FEEDBACK_STATUS_META[value];\n const Icon = meta.icon;\n const active = status === value;\n const name = nameOf(value);\n // Locked: focusable and described, never firing (see `commit`). The current\n // status stays natively disabled — it was never something to press.\n const refused = locked && !active;\n const button = (\n <button\n key={value}\n type=\"button\"\n disabled={refused ? undefined : !canEdit || active}\n aria-disabled={refused || undefined}\n aria-describedby={refused ? reasonId : undefined}\n onClick={() => {\n if (!refused) onPick(value);\n }}\n aria-label={name}\n aria-pressed={active}\n className={cn(\n \"transition-colors\",\n variant === \"icon\"\n ? cn(\n \"flex size-7 items-center justify-center rounded\",\n active\n ? cn(meta.activeBg, meta.activeText)\n : \"text-placeholder hover:text-secondary\",\n )\n : cn(\n \"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium\",\n active\n ? cn(meta.activeBg, meta.activeText, \"border-transparent\")\n : // The translucent ink on hover (0.33, B′), as Button's secondary:\n // the well is the page in light, and these pills sit on either.\n \"border-subtle bg-transparent text-primary hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,transparent)]\",\n ),\n // Both stay unclickable, but only the read-only case is DIMMED: the\n // current status is the one thing in the row that has to be legible\n // at a glance, and among eight pills a faded active one reads as\n // \"unavailable\" rather than as \"this is where the row stands\".\n (!canEdit || active) && \"cursor-default\",\n !canEdit && \"opacity-60\",\n refused && \"cursor-not-allowed opacity-50\",\n )}\n >\n <Icon className={variant === \"icon\" ? \"size-4\" : \"size-3.5\"} aria-hidden />\n {variant === \"pill\" && name}\n </button>\n );\n // Locked, the bubble says WHY in both variants; the icon's name is still its\n // `aria-label`, and the reason its description — the hidden copy above, which\n // stays put, rather than the bubble (as on `Button`: the FRAGMENT keeps Tooltip\n // from adding the bubble as a second, coming-and-going description).\n if (refused) {\n return (\n <Tooltip key={value} label={reason} side=\"top\" portal={variant === \"icon\" || undefined}>\n <>{button}</>\n </Tooltip>\n );\n }\n // The icon row has no visible label, so it needs the tooltip; the pills\n // carry theirs inline.\n //\n // ⚠️ ABOVE, and portalled (Keksdose live #339). `side=\"bottom\"` put the bubble\n // straight over the NEXT ROW's icons — in a queue you triage by running down it\n // with the pointer, that is the row you are about to reach, and it was covered\n // every time you paused on one. Above covers the row you have just left instead.\n //\n // `portal` is not cosmetic here. An absolutely-positioned bubble is clipped by\n // any ancestor that scrolls — this cell lives inside the DataTable's own\n // scroller — and it cannot flip: `placeTooltip` is what moves the bubble below\n // the icon for the FIRST row, where there is no room above it, instead of\n // painting it off the top of the list.\n return variant === \"icon\" ? (\n <Tooltip key={value} label={name} side=\"top\" portal>\n {button}\n </Tooltip>\n ) : (\n button\n );\n })}\n </div>\n );\n}\n\n/**\n * A textarea and its two buttons — for an outcome, for a note sent back with a\n * report, for a body being corrected.\n *\n * One component because all three are the same gesture: a draft that is not\n * committed until it is saved. Ctrl/⌘+Enter submits, which is the same shortcut\n * the compose dialog next door uses, so the habit carries across the feature.\n *\n * ## `initial` seeds the draft; it does not own it (changed 2026-09-22)\n *\n * This used to re-seed from `initial` in an effect, and that effect threw away\n * whatever was in the box — **and the picture attached to it** — every time the\n * prop arrived with a different value. `initial` is normally the saved note, so\n * the list refreshing under the editor, or the owner re-deriving the same string,\n * was enough: a triager halfway through a reply, with a screenshot picked out,\n * lost both and got the saved text back with no way to undo it.\n *\n * A draft belongs to the thing being edited, so switching to a different note is a\n * different editor — say so with a `key`:\n *\n * ```tsx\n * <FeedbackNoteEditor key={note.id} initial={note.body} … />\n * ```\n *\n * Where the id is not in hand at the call site, pass {@link resetKey} instead and\n * the editor re-seeds when THAT changes. Either way the decision is the caller's,\n * which is the point: the editor cannot tell a new subject from a new render.\n */\nexport function FeedbackNoteEditor({\n initial,\n pending,\n onSave,\n onCancel,\n saveLabel,\n cancelLabel,\n placeholder,\n label,\n rows = 3,\n attachment,\n resetKey,\n required = false,\n commit,\n}: {\n initial: string;\n pending: boolean;\n onSave: (value: string, attachment?: File | null) => void;\n onCancel: () => void;\n saveLabel: ReactNode;\n cancelLabel: ReactNode;\n /** A line above the field saying what to write, not an in-field placeholder:\n * a hint that disappears the moment somebody starts typing is a hint that is\n * gone exactly when it is being followed. */\n placeholder?: ReactNode;\n /** The box's accessible name (0.34). Without it the `placeholder` line names the\n * box, and without that the `feedbackDetail` namespace's `note` (\"Note\") does —\n * a box with neither was read out as just \"edit text\" (the showcase's axe check).\n * Given with a `placeholder`, this names the box and the line describes it. */\n label?: string;\n rows?: number;\n /** Offer a picture with the note (Steering Design feedback #128). Omitted,\n * the editor is exactly the text box it always was — which is what the\n * *outcome* editor beside it wants, since an outcome is the answer rather\n * than the evidence. */\n attachment?: FeedbackNoteAttachment;\n /** Change this to say \"the editor is now editing something ELSE\", and the draft\n * and its attachment are dropped and re-seeded from `initial`. For call sites\n * that cannot put a `key` on the editor (see the note above). Leave it out and\n * the draft is never thrown away behind the user's back. */\n resetKey?: string | number;\n /**\n * A blank note cannot be saved (0.27.0): Save stays disabled — and Ctrl/⌘+Enter does\n * nothing — until the trimmed text is non-empty, and the box is `aria-required`. For\n * the rework note, which the feedback contract requires (§3.4: a file alone cannot\n * be sent; keksdose `feedback-page.tsx:577` dropped the click silently instead, so a\n * press on Send with an empty box looked like a lost send). Default `false`: an\n * outcome or a description may be cleared on purpose.\n */\n required?: boolean;\n /** Save COMMITS (0.27.0): under a locked `WriteLockProvider` it is disabled the\n * `disabledReason` way, with the lock's reason, and Ctrl/⌘+Enter saves nothing. The\n * text box stays editable — nothing in it reaches the server until the save. */\n commit?: CommitScope;\n}) {\n const [draft, setDraft] = useState(initial);\n const [file, setFile] = useState<File | null>(null);\n // Adjusted during render rather than in an effect: an effect would paint the old\n // draft first and then replace it, and — far worse — it is a second definition of\n // when a draft dies that the caller cannot see. React re-runs this component\n // immediately, before anything is committed to the screen.\n const [seededFor, setSeededFor] = useState(resetKey);\n if (resetKey !== seededFor) {\n setSeededFor(resetKey);\n setDraft(initial);\n setFile(null);\n }\n // The editor's own root, which is where a paste made in the text box bubbles\n // to: the attachment field below is the box's sibling, so a paste in the box\n // never passes through the field's own subtree. It listens here instead\n // (Steering Design feedback #140).\n const root = useRef<HTMLDivElement>(null);\n const hintId = useId();\n const detailText = useKitLabels(\"feedbackDetail\", DEFAULT_FEEDBACK_DETAIL_LABELS);\n // The lock as the save button sees it, with its scope (§12.36): `commit &&\n // lock.locked` read a `{ except }` scope as `true`.\n const lock = useWriteLock(commit ?? false);\n const blank = required && !draft.trim();\n // The shortcut obeys what the button shows: no save while one is in flight, while the\n // note a `required` editor needs is missing, or while a `commit` save is locked.\n const canSubmit = !pending && !blank && !lock.locked;\n const submit = () => onSave(draft, file);\n return (\n // Not a control: a delegated Ctrl/Cmd+Enter shortcut for the textarea inside,\n // which is what holds focus. The save button is the non-shortcut path.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- delegated keyboard shortcut for the focused field\n <div\n ref={root}\n className=\"space-y-2\"\n onKeyDown={(event) => {\n if ((event.ctrlKey || event.metaKey) && event.key === \"Enter\" && canSubmit) {\n event.preventDefault();\n submit();\n }\n }}\n >\n {/* The line names the box (0.27.0): it is the only text that says what to write\n in it, and an unnamed textarea is read out as just \"edit text\". */}\n {placeholder && (\n <p id={hintId} className=\"text-xs text-muted\">\n {placeholder}\n </p>\n )}\n <Textarea\n rows={rows}\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n // Never nameless (round 0.34): the caller's `label`, else the line, else the\n // `feedbackDetail` namespace's `note`. With both, the line describes the box.\n aria-label={label ?? (placeholder ? undefined : detailText.note)}\n aria-labelledby={placeholder && label === undefined ? hintId : undefined}\n aria-describedby={placeholder && label !== undefined ? hintId : undefined}\n aria-required={required || undefined}\n />\n {attachment && (\n <FeedbackAttachmentField\n value={file}\n onChange={setFile}\n // A pause while the save is in flight (the field's G5b `disabled`): the note\n // goes out with the file AS IT WAS at the press, so a chip removed or added\n // mid-send would be a lie about what was sent.\n disabled={pending}\n labels={attachment.labels}\n accept={attachment.accept}\n maxBytes={attachment.maxBytes}\n onError={attachment.onError}\n onCaptureScreenshot={attachment.onCaptureScreenshot}\n buttonVariant={attachment.buttonVariant}\n buttonSize={attachment.buttonSize}\n // Within THIS editor's subtree, not on `document`: the editor sits\n // inline on a page that has other fields, so a paste made in one of\n // them is meant for that one. The textarea above is where the caret\n // already is, and it is the field's sibling — so the field is told\n // to listen on their common parent, where the paste bubbles to.\n pasteFrom={root}\n />\n )}\n <div className=\"flex justify-end gap-2\">\n <Button variant=\"ghost\" onClick={onCancel}>\n {cancelLabel}\n </Button>\n <Button variant=\"brand\" disabled={pending || blank} commit={commit} onClick={submit}>\n {saveLabel}\n </Button>\n </div>\n </div>\n );\n}\n\n/** What {@link FeedbackNoteEditor} needs in order to offer a picture with the\n * note: the same things {@link FeedbackAttachmentField} takes, so the reply path is\n * held to the app's own limits — and drawn in its own look — rather than the\n * defaults. */\nexport interface FeedbackNoteAttachment {\n /** Over the field's own `feedbackAttachment` namespace. Optional since 0.27.0 — the\n * field has read the provider's words since 0.7.0, so an app with a catalogue has\n * nothing to restate. */\n labels?: LabelOverride<FeedbackAttachmentLabels>;\n accept?: string[];\n maxBytes?: number;\n /** `info` (0.23.0) names the refused file and the limit. */\n onError?: (kind: \"type\" | \"size\", info: FeedbackAttachmentErrorInfo) => void;\n onCaptureScreenshot?: () => Promise<File | null>;\n /**\n * The add and capture buttons' look (0.25.0) — the field's `buttonVariant` (0.24.0),\n * Button's own `variant`. Default `\"secondary\"`, as before.\n *\n * The note editor sits inline in a triage panel, and its buttons end in a ghost\n * Cancel and a brand Save: two bordered full-size attachment buttons above them read\n * as a second form. The field could already be told otherwise; the editor builds it\n * itself and had nowhere to say so.\n */\n buttonVariant?: ButtonVariant;\n /** The add and capture buttons' size (0.25.0) — the field's `buttonSize`. Default\n * `\"md\"`; `\"sm\"` draws their icons at 14px. */\n buttonSize?: ButtonSize;\n}\n\n/**\n * One labelled block of an opened report.\n *\n * The detail panel is a stack of these, and it is a stack rather than a\n * component with fixed fields on purpose: the two apps keep a report's context\n * and its screenshot in genuinely different places, and a shell that insisted on\n * both would force one of them to invent a shape it does not have. What is\n * shared is what a section *looks* like — and that is all that made the two\n * panels read as different products.\n */\nexport function FeedbackDetailSection({\n title,\n action,\n children,\n}: {\n title: ReactNode;\n /** Something on the title's own line — an edit button, a resolved-at date. */\n action?: ReactNode;\n children: ReactNode;\n}) {\n return (\n <div>\n <div className=\"mb-1 flex items-center justify-between gap-2\">\n <div className=\"text-xs font-medium uppercase tracking-wide text-muted\">\n {title}\n </div>\n {action}\n </div>\n {children}\n </div>\n );\n}\n\n/** The stack a detail panel is. Here so the spacing between sections is decided\n * once rather than by whichever app was written second. */\nexport function FeedbackDetail({ children, className }: { children: ReactNode; className?: string }) {\n return <div className={cn(\"space-y-3 text-sm\", className)}>{children}</div>;\n}\n\n/** Prose inside a section — a body, an outcome, a note.\n *\n * `whitespace-pre-wrap` is the whole component: a report is written in\n * paragraphs, and a note appended to it later is separated by blank lines that\n * carry the entire \"this arrived after the answer\" reading. */\nexport function FeedbackProse({ children, empty }: { children?: string; empty?: ReactNode }) {\n if (!children) return <p className=\"text-placeholder\">{empty ?? \"—\"}</p>;\n return (\n <p className=\"whitespace-pre-wrap text-secondary\">{children}</p>\n );\n}\n"],"mappings":"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","names":["FEEDBACK_CATEGORY_META","FEEDBACK_STATUS_META"]}
|
|
1
|
+
{"version":3,"sources":["../../src/feedback/feedback-inbox.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { Button } from \"../components/button\";\nimport { Textarea } from \"../components/textarea\";\nimport type { ButtonSize, ButtonVariant } from \"../components/button-look\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { useCommitReason, useWriteLock, type CommitScope } from \"../components/write-lock\";\nimport { FeedbackAttachmentField, type FeedbackAttachmentErrorInfo } from \"./feedback-attachment\";\nimport type { FeedbackAttachmentLabels } from \"./feedback-dialog\";\nimport { useFeedbackCategoryLabels, useFeedbackStatusLabels } from \"./feedback-labels\";\nimport {\n FEEDBACK_CATEGORY_META,\n FEEDBACK_STATUS_META,\n type FeedbackCategory,\n type FeedbackStatus,\n} from \"./feedback-vocabulary\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\n// The words module, not ./feedback-row-detail: that one imports this file.\nimport { DEFAULT_FEEDBACK_DETAIL_LABELS } from \"./feedback-row-detail-labels\";\n\n/**\n * The feedback **inbox**, as the parts two apps were each writing separately.\n *\n * `feedback-dialog.tsx` next door is the other half — the form somebody files a\n * report with — and the split between them is the same one: the kit owns the\n * *vocabulary*, the *policy* about how a report moves (both in\n * feedback-vocabulary.ts since 0.34, re-exported below), and the *look* (here);\n * the app owns the data, the API and every string.\n *\n * It exists because both apps had grown their own copy. Keksdose's is the one\n * this is lifted from, comments and all, because it is the one that had been\n * argued with users for a year — the glyph per status, the two sizes of the\n * status control, what a row offers from where it currently stands. Steering\n * Design's was thinner in every one of those places and read as a different\n * product for no reason anyone chose.\n *\n * **The report itself lives next door (0.27.0).** This file used to say there\n * was deliberately no `Feedback` type here: the apps stored a report differently\n * (a context JSON column and an object store in one, a `page_path` and the bytes\n * in the row in another). Marcel's feedback round (2026-10-04,\n * docs/feedback-harmonization.md §5) gave all three apps one contract, so the\n * shape is now shared — `FeedbackRecord` / `FeedbackContext` in\n * feedback-record.ts, with the constants and body helpers. The components HERE\n * still take the values they draw rather than a row, which keeps them usable\n * from any surface; the detail panel stays a *shell* the row detail fills.\n *\n * **The words are the kit's too (0.27.0).** The status and category names are\n * the `feedbackStatus` / `feedbackCategory` namespaces (feedback-labels.ts,\n * keksdose's wording), read by default by the badges and the transitions below;\n * a `label` the caller passes still wins.\n */\n\n// The vocabulary — statuses, categories, their glyphs and orders, the transitions — is\n// feedback-vocabulary.ts since 0.34 (MOD-1): data the top bar's feedback menu imports\n// without this file's components. Re-exported, so every name is still found here.\nexport {\n FEEDBACK_CATEGORY_META,\n FEEDBACK_CATEGORY_ORDER,\n FEEDBACK_STATUS_META,\n FEEDBACK_STATUS_ORDER,\n feedbackCategoryRank,\n nextFeedbackStatus,\n selectableFeedbackStatuses,\n visibleFeedbackStatuses,\n} from \"./feedback-vocabulary\";\nexport type { FeedbackCategory, FeedbackStatus } from \"./feedback-vocabulary\";\n\n/**\n * The category as a badge.\n *\n * Falls back to OTHER's treatment for a category this build has never heard of,\n * so an older client against a newer API degrades to a readable neutral pill\n * instead of throwing on `meta.icon` — a crash report must not be able to cause\n * one. The *label* gets the same guard: by default the `feedbackCategory`\n * namespace's word (0.27.0), and for a value it has no word for, what OTHER is\n * called.\n */\nexport function FeedbackCategoryBadge({\n category,\n label,\n compact = false,\n className,\n}: {\n category: FeedbackCategory;\n /** Over the `feedbackCategory` label (0.27.0: optional — before, every app passed\n * its own translation). */\n label?: ReactNode;\n compact?: boolean;\n className?: string;\n}) {\n const names = useFeedbackCategoryLabels();\n // Annotated `| undefined` because the Record's index signature promises a hit\n // for every FeedbackCategory, and at runtime the API can hand us one that is not.\n const known: (typeof FEEDBACK_CATEGORY_META)[\"OTHER\"] | undefined = FEEDBACK_CATEGORY_META[category];\n const meta = known ?? FEEDBACK_CATEGORY_META.OTHER;\n const Icon = meta.icon;\n return (\n <span\n className={cn(\n \"inline-flex items-center gap-1 rounded font-medium\",\n compact ? \"px-1.5 py-0.5 text-caption\" : \"px-2 py-0.5 text-xs\",\n meta.badgeBg,\n meta.badgeText,\n className,\n )}\n >\n <Icon className={compact ? \"size-3\" : \"size-3.5\"} aria-hidden />\n {label ?? (known ? names[category] : names.OTHER)}\n </span>\n );\n}\n\n/** Where a report stands, as a badge — for the places that only *report* the\n * status rather than offering to change it. Named by the `feedbackStatus`\n * namespace unless `label` says otherwise (0.27.0); a status this build has never\n * heard of shows OPEN's look and its own raw value, never OPEN's name. */\nexport function FeedbackStatusBadge({\n status,\n label,\n className,\n}: {\n status: FeedbackStatus;\n /** Over the `feedbackStatus` label (0.27.0: optional). */\n label?: ReactNode;\n className?: string;\n}) {\n const names = useFeedbackStatusLabels();\n const meta = FEEDBACK_STATUS_META[status] ?? FEEDBACK_STATUS_META.OPEN;\n const name: string | undefined = names[status];\n const Icon = meta.icon;\n return (\n <span\n className={cn(\n \"inline-flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-0.5 text-caption font-medium\",\n meta.activeBg,\n meta.activeText,\n className,\n )}\n >\n <Icon className=\"size-3\" aria-hidden />\n {label ?? name ?? status}\n </span>\n );\n}\n\n/**\n * A row of statuses to switch a report to — either a table column's icon row or\n * an expanded panel's labelled pills.\n *\n * One component for both, because everything except the class strings is the\n * same decision: which one is current, and when the buttons are disabled. Two\n * copies had already drifted — only the icon variant carried `aria-label` and\n * `aria-pressed`, so the pill row was unlabelled for a screen reader.\n *\n * WHICH statuses to offer is the caller's, not this component's:\n * {@link visibleFeedbackStatuses} for a compact column,\n * {@link selectableFeedbackStatuses} for an expanded row. Passing the list keeps\n * that policy readable at the two call sites instead of hiding it behind\n * `variant`.\n */\nexport function FeedbackStatusTransitions({\n status,\n statuses,\n canEdit,\n onPick,\n variant,\n label,\n commit,\n disabledReason: ownDisabledReason,\n className,\n}: {\n status: FeedbackStatus;\n statuses: FeedbackStatus[];\n canEdit: boolean;\n onPick: (status: FeedbackStatus) => void;\n variant: \"icon\" | \"pill\";\n /** What each status is called. Default (0.27.0): the `feedbackStatus` namespace —\n * before, every app passed its own translation. */\n label?: (status: FeedbackStatus) => string;\n /**\n * Every button here SAVES the moment it is pressed (0.27.0) — opt in and, under a\n * locked {@link WriteLockProvider}, the row stays as it is and says why: each status\n * the row could move to is `aria-disabled` but focusable, the click does nothing, and\n * the lock's reason is in the kit Tooltip and the button's description — the\n * `disabledReason` path `Button` takes. Off by default: a feedback inbox is not what\n * every app's lock is about (keksdose's shell lock is a read-only demo BUDGET, and a\n * report is not budget data).\n */\n commit?: CommitScope;\n /** Why the statuses cannot be changed right now, the same way (a lock's own reason\n * wins over it). Only read while `canEdit`: a read-only row is a picture of the\n * workflow, not a refused action. */\n disabledReason?: ReactNode;\n className?: string;\n}) {\n const names = useFeedbackStatusLabels();\n const nameOf = label ?? ((value: FeedbackStatus) => names[value]);\n const reason = useCommitReason(commit, ownDisabledReason);\n const locked = canEdit && reason !== undefined && reason !== null && reason !== false && reason !== \"\";\n const reasonId = useId();\n return (\n // Not a control: it only stops clicks on the buttons inside from bubbling to\n // the row. The buttons are the keyboard path, and Enter/Space on them fires\n // their own click, which lands here the same way.\n // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- propagation guard around real buttons\n <div\n className={cn(variant === \"icon\" ? \"flex items-center gap-0.5\" : \"flex flex-wrap gap-1.5\", className)}\n // A status control inside a clickable row must not also open the row.\n onClick={(event) => event.stopPropagation()}\n >\n {locked && (\n <span id={reasonId} hidden>\n {reason}\n </span>\n )}\n {statuses.map((value) => {\n const meta = FEEDBACK_STATUS_META[value];\n const Icon = meta.icon;\n const active = status === value;\n const name = nameOf(value);\n // Locked: focusable and described, never firing (see `commit`). The current\n // status stays natively disabled — it was never something to press.\n const refused = locked && !active;\n const button = (\n <button\n key={value}\n type=\"button\"\n disabled={refused ? undefined : !canEdit || active}\n aria-disabled={refused || undefined}\n aria-describedby={refused ? reasonId : undefined}\n onClick={() => {\n if (!refused) onPick(value);\n }}\n aria-label={name}\n aria-pressed={active}\n className={cn(\n \"transition-colors\",\n variant === \"icon\"\n ? cn(\n \"flex size-7 items-center justify-center rounded\",\n active\n ? cn(meta.activeBg, meta.activeText)\n : \"text-placeholder hover:text-secondary\",\n )\n : cn(\n \"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium\",\n active\n ? cn(meta.activeBg, meta.activeText, \"border-transparent\")\n : // The translucent ink on hover (0.33, B′), as Button's secondary:\n // the well is the page in light, and these pills sit on either.\n \"border-subtle bg-transparent text-primary hover:bg-[color-mix(in_srgb,var(--text-primary)_7%,transparent)]\",\n ),\n // Both stay unclickable, but only the read-only case is DIMMED: the\n // current status is the one thing in the row that has to be legible\n // at a glance, and among eight pills a faded active one reads as\n // \"unavailable\" rather than as \"this is where the row stands\".\n (!canEdit || active) && \"cursor-default\",\n !canEdit && \"opacity-60\",\n refused && \"cursor-not-allowed opacity-50\",\n )}\n >\n <Icon className={variant === \"icon\" ? \"size-4\" : \"size-3.5\"} aria-hidden />\n {variant === \"pill\" && name}\n </button>\n );\n // Locked, the bubble says WHY in both variants; the icon's name is still its\n // `aria-label`, and the reason its description — the hidden copy above, which\n // stays put, rather than the bubble (as on `Button`: the FRAGMENT keeps Tooltip\n // from adding the bubble as a second, coming-and-going description).\n if (refused) {\n return (\n <Tooltip key={value} label={reason} side=\"top\" portal={variant === \"icon\" || undefined}>\n <>{button}</>\n </Tooltip>\n );\n }\n // The icon row has no visible label, so it needs the tooltip; the pills\n // carry theirs inline.\n //\n // ⚠️ ABOVE, and portalled (Keksdose live #339). `side=\"bottom\"` put the bubble\n // straight over the NEXT ROW's icons — in a queue you triage by running down it\n // with the pointer, that is the row you are about to reach, and it was covered\n // every time you paused on one. Above covers the row you have just left instead.\n //\n // `portal` is not cosmetic here. An absolutely-positioned bubble is clipped by\n // any ancestor that scrolls — this cell lives inside the DataTable's own\n // scroller — and it cannot flip: `placeTooltip` is what moves the bubble below\n // the icon for the FIRST row, where there is no room above it, instead of\n // painting it off the top of the list.\n return variant === \"icon\" ? (\n <Tooltip key={value} label={name} side=\"top\" portal>\n {button}\n </Tooltip>\n ) : (\n button\n );\n })}\n </div>\n );\n}\n\n/**\n * A textarea and its two buttons — for an outcome, for a note sent back with a\n * report, for a body being corrected.\n *\n * One component because all three are the same gesture: a draft that is not\n * committed until it is saved. Ctrl/⌘+Enter submits, which is the same shortcut\n * the compose dialog next door uses, so the habit carries across the feature.\n *\n * ## `initial` seeds the draft; it does not own it (changed 2026-09-22)\n *\n * This used to re-seed from `initial` in an effect, and that effect threw away\n * whatever was in the box — **and the picture attached to it** — every time the\n * prop arrived with a different value. `initial` is normally the saved note, so\n * the list refreshing under the editor, or the owner re-deriving the same string,\n * was enough: a triager halfway through a reply, with a screenshot picked out,\n * lost both and got the saved text back with no way to undo it.\n *\n * A draft belongs to the thing being edited, so switching to a different note is a\n * different editor — say so with a `key`:\n *\n * ```tsx\n * <FeedbackNoteEditor key={note.id} initial={note.body} … />\n * ```\n *\n * Where the id is not in hand at the call site, pass {@link resetKey} instead and\n * the editor re-seeds when THAT changes. Either way the decision is the caller's,\n * which is the point: the editor cannot tell a new subject from a new render.\n */\nexport function FeedbackNoteEditor({\n initial,\n pending,\n onSave,\n onCancel,\n saveLabel,\n cancelLabel,\n placeholder,\n label,\n rows = 3,\n attachment,\n resetKey,\n required = false,\n commit,\n}: {\n initial: string;\n pending: boolean;\n onSave: (value: string, attachment?: File | null) => void;\n onCancel: () => void;\n saveLabel: ReactNode;\n cancelLabel: ReactNode;\n /** A line above the field saying what to write, not an in-field placeholder:\n * a hint that disappears the moment somebody starts typing is a hint that is\n * gone exactly when it is being followed. */\n placeholder?: ReactNode;\n /** The box's accessible name (0.34). Without it the `placeholder` line names the\n * box, and without that the `feedbackDetail` namespace's `note` (\"Note\") does —\n * a box with neither was read out as just \"edit text\" (the showcase's axe check).\n * Given with a `placeholder`, this names the box and the line describes it. */\n label?: string;\n rows?: number;\n /** Offer a picture with the note (Steering Design feedback #128). Omitted,\n * the editor is exactly the text box it always was — which is what the\n * *outcome* editor beside it wants, since an outcome is the answer rather\n * than the evidence. */\n attachment?: FeedbackNoteAttachment;\n /** Change this to say \"the editor is now editing something ELSE\", and the draft\n * and its attachment are dropped and re-seeded from `initial`. For call sites\n * that cannot put a `key` on the editor (see the note above). Leave it out and\n * the draft is never thrown away behind the user's back. */\n resetKey?: string | number;\n /**\n * A blank note cannot be saved (0.27.0): Save stays disabled — and Ctrl/⌘+Enter does\n * nothing — until the trimmed text is non-empty, and the box is `aria-required`. For\n * the rework note, which the feedback contract requires (§3.4: a file alone cannot\n * be sent; keksdose `feedback-page.tsx:577` dropped the click silently instead, so a\n * press on Send with an empty box looked like a lost send). Default `false`: an\n * outcome or a description may be cleared on purpose.\n */\n required?: boolean;\n /** Save COMMITS (0.27.0): under a locked `WriteLockProvider` it is disabled the\n * `disabledReason` way, with the lock's reason, and Ctrl/⌘+Enter saves nothing. The\n * text box stays editable — nothing in it reaches the server until the save. */\n commit?: CommitScope;\n}) {\n const [draft, setDraft] = useState(initial);\n const [file, setFile] = useState<File | null>(null);\n // Adjusted during render rather than in an effect: an effect would paint the old\n // draft first and then replace it, and — far worse — it is a second definition of\n // when a draft dies that the caller cannot see. React re-runs this component\n // immediately, before anything is committed to the screen.\n const [seededFor, setSeededFor] = useState(resetKey);\n if (resetKey !== seededFor) {\n setSeededFor(resetKey);\n setDraft(initial);\n setFile(null);\n }\n // The editor's own root, which is where a paste made in the text box bubbles\n // to: the attachment field below is the box's sibling, so a paste in the box\n // never passes through the field's own subtree. It listens here instead\n // (Steering Design feedback #140).\n const root = useRef<HTMLDivElement>(null);\n const hintId = useId();\n const detailText = useKitLabels(\"feedbackDetail\", DEFAULT_FEEDBACK_DETAIL_LABELS);\n // The lock as the save button sees it, with its scope (§12.36): `commit &&\n // lock.locked` read a `{ except }` scope as `true`.\n const lock = useWriteLock(commit ?? false);\n const blank = required && !draft.trim();\n // The shortcut obeys what the button shows: no save while one is in flight, while the\n // note a `required` editor needs is missing, or while a `commit` save is locked.\n const canSubmit = !pending && !blank && !lock.locked;\n const submit = () => onSave(draft, file);\n return (\n // Not a control: a delegated Ctrl/Cmd+Enter shortcut for the textarea inside,\n // which is what holds focus. The save button is the non-shortcut path.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- delegated keyboard shortcut for the focused field\n <div\n ref={root}\n className=\"space-y-2\"\n onKeyDown={(event) => {\n if ((event.ctrlKey || event.metaKey) && event.key === \"Enter\" && canSubmit) {\n event.preventDefault();\n submit();\n }\n }}\n >\n {/* The line names the box (0.27.0): it is the only text that says what to write\n in it, and an unnamed textarea is read out as just \"edit text\". */}\n {placeholder && (\n <p id={hintId} className=\"text-xs text-muted\">\n {placeholder}\n </p>\n )}\n <Textarea\n rows={rows}\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n // Never nameless (round 0.34): the caller's `label`, else the line, else the\n // `feedbackDetail` namespace's `note`. With both, the line describes the box.\n aria-label={label ?? (placeholder ? undefined : detailText.note)}\n aria-labelledby={placeholder && label === undefined ? hintId : undefined}\n aria-describedby={placeholder && label !== undefined ? hintId : undefined}\n aria-required={required || undefined}\n />\n {attachment && (\n <FeedbackAttachmentField\n value={file}\n onChange={setFile}\n // A pause while the save is in flight (the field's G5b `disabled`): the note\n // goes out with the file AS IT WAS at the press, so a chip removed or added\n // mid-send would be a lie about what was sent.\n disabled={pending}\n labels={attachment.labels}\n accept={attachment.accept}\n maxBytes={attachment.maxBytes}\n onError={attachment.onError}\n onCaptureScreenshot={attachment.onCaptureScreenshot}\n buttonVariant={attachment.buttonVariant}\n buttonSize={attachment.buttonSize}\n // Within THIS editor's subtree, not on `document`: the editor sits\n // inline on a page that has other fields, so a paste made in one of\n // them is meant for that one. The textarea above is where the caret\n // already is, and it is the field's sibling — so the field is told\n // to listen on their common parent, where the paste bubbles to.\n pasteFrom={root}\n />\n )}\n <div className=\"flex justify-end gap-2\">\n <Button variant=\"ghost\" onClick={onCancel}>\n {cancelLabel}\n </Button>\n <Button variant=\"accent\" disabled={pending || blank} commit={commit} onClick={submit}>\n {saveLabel}\n </Button>\n </div>\n </div>\n );\n}\n\n/** What {@link FeedbackNoteEditor} needs in order to offer a picture with the\n * note: the same things {@link FeedbackAttachmentField} takes, so the reply path is\n * held to the app's own limits — and drawn in its own look — rather than the\n * defaults. */\nexport interface FeedbackNoteAttachment {\n /** Over the field's own `feedbackAttachment` namespace. Optional since 0.27.0 — the\n * field has read the provider's words since 0.7.0, so an app with a catalogue has\n * nothing to restate. */\n labels?: LabelOverride<FeedbackAttachmentLabels>;\n accept?: string[];\n maxBytes?: number;\n /** `info` (0.23.0) names the refused file and the limit. */\n onError?: (kind: \"type\" | \"size\", info: FeedbackAttachmentErrorInfo) => void;\n onCaptureScreenshot?: () => Promise<File | null>;\n /**\n * The add and capture buttons' look (0.25.0) — the field's `buttonVariant` (0.24.0),\n * Button's own `variant`. Default `\"secondary\"`, as before.\n *\n * The note editor sits inline in a triage panel, and its buttons end in a ghost\n * Cancel and a brand Save: two bordered full-size attachment buttons above them read\n * as a second form. The field could already be told otherwise; the editor builds it\n * itself and had nowhere to say so.\n */\n buttonVariant?: ButtonVariant;\n /** The add and capture buttons' size (0.25.0) — the field's `buttonSize`. Default\n * `\"md\"`; `\"sm\"` draws their icons at 14px. */\n buttonSize?: ButtonSize;\n}\n\n/**\n * One labelled block of an opened report.\n *\n * The detail panel is a stack of these, and it is a stack rather than a\n * component with fixed fields on purpose: the two apps keep a report's context\n * and its screenshot in genuinely different places, and a shell that insisted on\n * both would force one of them to invent a shape it does not have. What is\n * shared is what a section *looks* like — and that is all that made the two\n * panels read as different products.\n */\nexport function FeedbackDetailSection({\n title,\n action,\n children,\n}: {\n title: ReactNode;\n /** Something on the title's own line — an edit button, a resolved-at date. */\n action?: ReactNode;\n children: ReactNode;\n}) {\n return (\n <div>\n <div className=\"mb-1 flex items-center justify-between gap-2\">\n <div className=\"text-xs font-medium uppercase tracking-wide text-muted\">\n {title}\n </div>\n {action}\n </div>\n {children}\n </div>\n );\n}\n\n/** The stack a detail panel is. Here so the spacing between sections is decided\n * once rather than by whichever app was written second. */\nexport function FeedbackDetail({ children, className }: { children: ReactNode; className?: string }) {\n return <div className={cn(\"space-y-3 text-sm\", className)}>{children}</div>;\n}\n\n/** Prose inside a section — a body, an outcome, a note.\n *\n * `whitespace-pre-wrap` is the whole component: a report is written in\n * paragraphs, and a note appended to it later is separated by blank lines that\n * carry the entire \"this arrived after the answer\" reading. */\nexport function FeedbackProse({ children, empty }: { children?: string; empty?: ReactNode }) {\n if (!children) return <p className=\"text-placeholder\">{empty ?? \"—\"}</p>;\n return (\n <p className=\"whitespace-pre-wrap text-secondary\">{children}</p>\n );\n}\n"],"mappings":"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","names":["FEEDBACK_CATEGORY_META","FEEDBACK_STATUS_META"]}
|
|
@@ -2,6 +2,9 @@ import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from "react";
|
|
|
2
2
|
/** A layer's handler, read through a ref so the stack holds one stable entry per open. */
|
|
3
3
|
interface Layer {
|
|
4
4
|
escape: (e: KeyboardEvent) => void;
|
|
5
|
+
/** Whether it covers the page — a dialog, a sheet, a popover, a list — rather than
|
|
6
|
+
* annotating it (a tooltip). Counted by {@link useOverlayOpen}. */
|
|
7
|
+
overlay: boolean;
|
|
5
8
|
}
|
|
6
9
|
/** What {@link useDismissLayer} hands back: its layer while open, for
|
|
7
10
|
* {@link runOwnKeyDown} to tell whether that layer is the one on top. */
|
|
@@ -16,7 +19,12 @@ export type DismissLayer = RefObject<Layer | null>;
|
|
|
16
19
|
* read live, so it need not be memoised; the layer is pushed and popped only when
|
|
17
20
|
* `open` flips. The layer it returns is for the owner's {@link runOwnKeyDown}.
|
|
18
21
|
*/
|
|
19
|
-
export declare function useDismissLayer(open: boolean, onEscape: (e: KeyboardEvent) => void
|
|
22
|
+
export declare function useDismissLayer(open: boolean, onEscape: (e: KeyboardEvent) => void, { overlay }?: {
|
|
23
|
+
overlay?: boolean;
|
|
24
|
+
}): DismissLayer;
|
|
25
|
+
/** True while a kit dialog, sheet, popover, hover menu or list is open (any layer of the
|
|
26
|
+
* Escape stack but a tooltip's). Internal. */
|
|
27
|
+
export declare function useOverlayOpen(enabled?: boolean): boolean;
|
|
20
28
|
/**
|
|
21
29
|
* For a layer's own element, in its `onKeyDown`: hands an Escape on its way up to the
|
|
22
30
|
* topmost layer — this one, unless something opened over it without taking focus (a
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
3
3
|
import { isComposing } from "../lib/ime.js";
|
|
4
4
|
const layers = [];
|
|
5
5
|
const claimed = /* @__PURE__ */ new WeakSet();
|
|
@@ -32,16 +32,18 @@ function push(layer) {
|
|
|
32
32
|
document.addEventListener("keydown", onBubble);
|
|
33
33
|
}
|
|
34
34
|
layers.push(layer);
|
|
35
|
+
if (layer.overlay) notifyOverlays();
|
|
35
36
|
}
|
|
36
37
|
function pop(layer) {
|
|
37
38
|
const at = layers.indexOf(layer);
|
|
38
39
|
if (at >= 0) layers.splice(at, 1);
|
|
40
|
+
if (at >= 0 && layer.overlay) notifyOverlays();
|
|
39
41
|
if (layers.length > 0) return;
|
|
40
42
|
document.removeEventListener("keydown", onCapture, true);
|
|
41
43
|
document.removeEventListener("keydown", onBubble);
|
|
42
44
|
inFlight = null;
|
|
43
45
|
}
|
|
44
|
-
function useDismissLayer(open, onEscape) {
|
|
46
|
+
function useDismissLayer(open, onEscape, { overlay = true } = {}) {
|
|
45
47
|
const handlerRef = useRef(onEscape);
|
|
46
48
|
const layerRef = useRef(null);
|
|
47
49
|
useEffect(() => {
|
|
@@ -49,16 +51,33 @@ function useDismissLayer(open, onEscape) {
|
|
|
49
51
|
});
|
|
50
52
|
useEffect(() => {
|
|
51
53
|
if (!open || typeof document === "undefined") return;
|
|
52
|
-
const layer = { escape: (e) => handlerRef.current(e) };
|
|
54
|
+
const layer = { escape: (e) => handlerRef.current(e), overlay };
|
|
53
55
|
layerRef.current = layer;
|
|
54
56
|
push(layer);
|
|
55
57
|
return () => {
|
|
56
58
|
pop(layer);
|
|
57
59
|
layerRef.current = null;
|
|
58
60
|
};
|
|
59
|
-
}, [open]);
|
|
61
|
+
}, [open, overlay]);
|
|
60
62
|
return layerRef;
|
|
61
63
|
}
|
|
64
|
+
const overlayListeners = /* @__PURE__ */ new Set();
|
|
65
|
+
function notifyOverlays() {
|
|
66
|
+
for (const listener of overlayListeners) listener();
|
|
67
|
+
}
|
|
68
|
+
function subscribeOverlays(listener) {
|
|
69
|
+
overlayListeners.add(listener);
|
|
70
|
+
return () => {
|
|
71
|
+
overlayListeners.delete(listener);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function anyOverlayOpen() {
|
|
75
|
+
return layers.some((layer) => layer.overlay);
|
|
76
|
+
}
|
|
77
|
+
function useOverlayOpen(enabled = true) {
|
|
78
|
+
const open = useSyncExternalStore(subscribeOverlays, anyOverlayOpen, () => false);
|
|
79
|
+
return enabled && open;
|
|
80
|
+
}
|
|
62
81
|
function takeEscape(e) {
|
|
63
82
|
return handOver(e, true);
|
|
64
83
|
}
|
|
@@ -136,6 +155,7 @@ export {
|
|
|
136
155
|
takeEscape,
|
|
137
156
|
useDismissLayer,
|
|
138
157
|
useEscapeKey,
|
|
139
|
-
useOutsideClick
|
|
158
|
+
useOutsideClick,
|
|
159
|
+
useOverlayOpen
|
|
140
160
|
};
|
|
141
161
|
//# sourceMappingURL=use-dismiss.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-dismiss.ts"],"sourcesContent":["import { useCallback, useEffect, useRef } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, RefObject } from \"react\";\nimport { isComposing } from \"../lib/ime\";\n\n/*\n * # One Escape stack (round 0.34 Wave 2, DUP-1)\n *\n * Escape closes the TOPMOST thing on screen, and only that: a list open in a dialog\n * closes and the dialog stays; the next press closes the dialog.\n *\n * \"Who owns Escape\" used to be decided five ways. The anchored pickers listened on the\n * document; Modal, FullBleedDialog, PickerSheet and Popover closed in their own panel's\n * `onKeyDown` and stopped the key; CommandPalette closed without stopping it. A dialog's\n * handler sits earlier on the key's path than any document listener, so Escape in an\n * open list inside a Modal closed the MODAL and threw the form away (BUG-1, sev 4,\n * kastlan's lease dialog), and HoverMenu, the numpad and a tooltip could not be\n * dismissed inside a dialog at all. And Modal and FullBleedDialog disagreed on when a\n * child had \"claimed\" the key: a ChipInput clearing its draft kept a Modal open and\n * closed a FullBleedDialog.\n *\n * Now every overlay and picker pushes a layer while it is open ({@link useDismissLayer}),\n * last in, first out, the way `use-overlay-history` stacks the Back gesture. One\n * capture-phase listener on the document notes which layer is on top as each Escape\n * goes down — before any handler on the page runs, so a layer that closes on the way\n * cannot pass the key on to the one beneath it. That layer, and only that one, is\n * handed the key:\n *\n * - at the first layer element the key passes on its way back up ({@link takeEscape},\n * which Modal's panel, a picker's search box and the rest call from their\n * `onKeyDown`). The key is stopped there, so nothing around the layers — a\n * FloatingPanel, an app's own panel — closes with it;\n * - or, when focus is in none of them (a hover menu, a tooltip, the numpad over its\n * field, a press on the page), at the document once the key has been everywhere else.\n *\n * Layers stack in the order they OPEN, which is the order their effects run. Two that\n * open in the same commit stack child first — the one case that order gets wrong, and\n * one no overlay in the kit does: each opens on a press, after whatever it sits in.\n *\n * ## Claiming the key: one rule\n *\n * An Escape that means \"undo this step\" is not a dismissal: ChipInput clearing its\n * draft, InlineEditField cancelling its edit, the date picker's month grid going back to\n * the days. Those call {@link claimEscape}, and no layer is handed the key. The only\n * other claim is the layer owner's OWN handler — a dialog's `onKeyDown` prop calling\n * `preventDefault()` on Escape (a busy reauth dialog holding itself open, Kurvenschmiede's\n * gear wizard asking first), through {@link runOwnKeyDown}. A `preventDefault()`\n * anywhere else claims nothing: a search box keeping Chromium from clearing itself\n * must not hold a dialog open (FullBleedDialog's 0.8.0 fix, which Modal never had).\n *\n * The owner's handler hears Escape only while its own layer is on top (0.35, KS). It\n * runs before the stack is asked, so with a tooltip, a hover menu or a list open over\n * the dialog and focus on the panel, the gear wizard asked \"Discard this gear?\" and the\n * tooltip stayed. Now the layer above takes that press, and the next one is the\n * owner's. Every other key reaches the handler as before.\n *\n * A handler that STOPS the key before it reaches a layer element has taken it too —\n * that is the DOM, and it is what the kit's cell editors and confirm buttons do.\n */\n\n/** A layer's handler, read through a ref so the stack holds one stable entry per open. */\ninterface Layer {\n escape: (e: KeyboardEvent) => void;\n}\n\n/** What {@link useDismissLayer} hands back: its layer while open, for\n * {@link runOwnKeyDown} to tell whether that layer is the one on top. */\nexport type DismissLayer = RefObject<Layer | null>;\n\n/** Open layers, oldest first. Module-level, because the order is ACROSS instances. */\nconst layers: Layer[] = [];\n\n/** Escapes a claimant has taken — see {@link claimEscape}. Weak: an event is garbage\n * once its dispatch is over. */\nconst claimed = new WeakSet<Event>();\n\n/** The Escape on its way through the page: the layer that was topmost as it went down,\n * and whether that layer has been handed it yet. */\nlet inFlight: { event: KeyboardEvent; layer: Layer; done: boolean } | null = null;\n\nfunction nativeOf(e: KeyboardEvent | ReactKeyboardEvent): KeyboardEvent {\n return \"nativeEvent\" in e ? e.nativeEvent : e;\n}\n\nfunction onCapture(e: KeyboardEvent): void {\n if (e.key !== \"Escape\") return;\n // An Escape inside an IME composition cancels the composition and nothing else.\n const top = isComposing(e) ? undefined : layers[layers.length - 1];\n inFlight = top ? { event: e, layer: top, done: false } : null;\n}\n\n/** The fallback: no layer element was on the key's path, or none took it. */\nfunction onBubble(e: KeyboardEvent): void {\n handOver(e, false);\n}\n\nfunction handOver(e: KeyboardEvent | ReactKeyboardEvent, stop: boolean): boolean {\n const native = nativeOf(e);\n const flight = inFlight;\n if (native.key !== \"Escape\" || !flight || flight.event !== native || flight.done) return false;\n if (claimed.has(native)) return false;\n flight.done = true;\n // Consumed: a type=\"search\" box does not clear itself as well, and a handler further\n // out that checks `defaultPrevented` (FloatingPanel, an app's own panel) leaves it.\n e.preventDefault();\n if (stop) e.stopPropagation();\n // A layer that closed on the way, by a handler of its own, has had its Escape.\n if (layers.includes(flight.layer)) flight.layer.escape(native);\n return true;\n}\n\nfunction push(layer: Layer): void {\n if (layers.length === 0) {\n document.addEventListener(\"keydown\", onCapture, true);\n document.addEventListener(\"keydown\", onBubble);\n }\n layers.push(layer);\n}\n\nfunction pop(layer: Layer): void {\n const at = layers.indexOf(layer);\n if (at >= 0) layers.splice(at, 1);\n if (layers.length > 0) return;\n document.removeEventListener(\"keydown\", onCapture, true);\n document.removeEventListener(\"keydown\", onBubble);\n inFlight = null;\n}\n\n/**\n * An Escape layer on the stack while `open`: the topmost layer is handed the next\n * Escape and calls `onEscape`, every layer under it waits its turn. A list opened inside\n * a dialog is above the dialog; a popover opened from a list, above the list.\n *\n * Where focus can be INSIDE the layer, its element's `onKeyDown` calls\n * {@link takeEscape} too, so the key stops there — see the module note. `onEscape` is\n * read live, so it need not be memoised; the layer is pushed and popped only when\n * `open` flips. The layer it returns is for the owner's {@link runOwnKeyDown}.\n */\nexport function useDismissLayer(open: boolean, onEscape: (e: KeyboardEvent) => void): DismissLayer {\n const handlerRef = useRef(onEscape);\n const layerRef = useRef<Layer | null>(null);\n // Written in an effect, not during render, so it satisfies react-hooks/refs: a\n // render that React starts and then discards — a transition, a Suspense retry —\n // would otherwise leave this holding a handler from a tree that never committed,\n // and the stack would go on to call it. Keydown events fire after the commit, so\n // the ref is always current by the time one arrives.\n useEffect(() => {\n handlerRef.current = onEscape;\n });\n useEffect(() => {\n if (!open || typeof document === \"undefined\") return;\n const layer: Layer = { escape: (e) => handlerRef.current(e) };\n layerRef.current = layer;\n push(layer);\n return () => {\n pop(layer);\n layerRef.current = null;\n };\n }, [open]);\n return layerRef;\n}\n\n/**\n * For a layer's own element, in its `onKeyDown`: hands an Escape on its way up to the\n * topmost layer — this one, unless something opened over it without taking focus (a\n * tooltip, the numpad) — and stops it here. True when it did; false for any other key,\n * a claimed Escape, or one already handed over.\n */\nexport function takeEscape(e: KeyboardEvent | ReactKeyboardEvent): boolean {\n return handOver(e, true);\n}\n\n/**\n * For an Escape that means \"undo this step\", not \"close\": the key is the caller's, and\n * no layer is handed it. `preventDefault()`s it as well, so a handler further out that\n * checks `defaultPrevented` leaves it alone, as before.\n */\nexport function claimEscape(e: KeyboardEvent | ReactKeyboardEvent): void {\n claimed.add(nativeOf(e));\n e.preventDefault();\n}\n\n/**\n * Runs a layer owner's own key handler — a dialog's `onKeyDown` prop — and turns a\n * `preventDefault()` it makes on Escape into a claim: the one way, beside\n * {@link claimEscape}, to keep a layer open.\n *\n * Given the owner's `layer` (what {@link useDismissLayer} returned), an Escape that went\n * down while another layer was above that one skips the handler: the press is the\n * layer's on top, and the owner hears the next (0.35, KS). Without it, or while the\n * owner's layer is not open, the handler sees every key, as before.\n */\nexport function runOwnKeyDown<E extends ReactKeyboardEvent>(\n e: E,\n handler: ((e: E) => void) | undefined,\n layer?: DismissLayer,\n): void {\n if (!handler) return;\n if (layer && aboveOwner(e, layer.current)) return;\n const before = e.defaultPrevented;\n handler(e);\n if (e.key === \"Escape\" && !before && e.defaultPrevented) claimed.add(nativeOf(e));\n}\n\n/** Whether this Escape went down with a layer above `owner` — the topmost one then was\n * another, and `owner` is on the stack. An Escape the stack did not note (one inside an\n * IME composition) is nobody else's. */\nfunction aboveOwner(e: ReactKeyboardEvent, owner: Layer | null): boolean {\n const flight = inFlight;\n if (e.key !== \"Escape\" || !owner || !flight || flight.event !== nativeOf(e)) return false;\n return flight.layer !== owner && layers.includes(owner);\n}\n\n/**\n * Calls `handler` on an Escape while `enabled` — as an Escape layer. Since 0.34 this is\n * {@link useDismissLayer} with its arguments the other way round: it hears the key only\n * while nothing opened above it, and inside a dialog it no longer loses the key to the\n * dialog (Wave 2, DUP-1). `handler` is read live, so it need not be memoised.\n */\nexport function useEscapeKey(handler: () => void, enabled = true): void {\n useDismissLayer(enabled, handler);\n}\n\n/** Called with each native press an owner's React tree saw — see {@link useOutsideClick}. */\ntype PressMark = (press: Event) => void;\n\n/** The outside-click hooks watching each ref, for {@link pressInside}. */\nconst watchers = new WeakMap<RefObject<HTMLElement | null>, Set<PressMark>>();\n\n/** What an `onPointerDownCapture` hands over: a React event, its press inside. */\ntype ReactPress = { nativeEvent: Event };\n\n/**\n * An `onPointerDownCapture` for a panel whose `ref` an outside-click hook watches but\n * whose element the hook's owner does not render — {@link DropdownPanel} and\n * `ComboboxPanel` take `panelRef` from `useDropdown` / `useComboboxCore`. Every press\n * that reaches the panel through the React tree, a portalled list opened inside it\n * included, is marked inside for each hook watching `ref`. See {@link useOutsideClick}.\n */\nexport function pressInside(ref: RefObject<HTMLElement | null> | undefined): ((e: ReactPress) => void) | undefined {\n if (!ref) return undefined;\n return (e) => {\n watchers.get(ref)?.forEach((mark) => mark(e.nativeEvent));\n };\n}\n\n/**\n * Calls `handler` on a press that lands outside every supplied element, while\n * `enabled` — the outside-click-to-close half of the same dismissal story. Pass one\n * ref or several (trigger + panel); a press inside any of them is ignored. Refs and\n * `handler` are read live, so an inline `[triggerRef, panelRef]` array is fine and\n * won't churn the listener.\n *\n * ## A panel opened from inside is inside (0.48, keksdose dev#623)\n *\n * A CurrencySelect in a Popover: the currency list is portalled to `<body>`, so in the\n * DOM it is outside the popover's panel, and the first press on a currency closed the\n * POPOVER at pointerdown — the list went with it and the choice never landed. The same\n * for a combobox, a menu, a date picker's popover, a dropdown, at any depth.\n *\n * In the React tree the portal is still where it was opened from, and React passes a\n * press up that tree, through portals. So the hook hands back an\n * `onPointerDownCapture`: put it on the element the press should count as inside of —\n * the kit's Popover panel, HoverMenu, DropdownPanel and ComboboxPanel do — and every\n * press that travels through that element in React, portalled panels opened inside it\n * included, is not outside. React's listeners sit on the root and on each portal's\n * container, so they hear the press before this document listener does. No owner chain\n * to keep: depth is free, and an app's own portal inside a kit panel is covered too. A\n * panel the hook's owner does not render takes {@link pressInside} with the watched ref.\n *\n * **`pointerdown`, not `mousedown`** — one event covering mouse, touch and pen.\n * This listened for `mousedown` alone, which on a touch platform is a SYNTHESISED\n * event: the browser emits it for most taps, which is why nothing obviously broke,\n * but the synthesis is not guaranteed. A tap that begins a scroll, or one that lands\n * on an element which preventDefaults the touch sequence, may never produce one — and\n * on a phone-first PWA whose pickers, menus and flyouts all dismiss through this hook,\n * inheriting that is worse than choosing it.\n *\n * The one behaviour this changes deliberately: a touch-drag that starts outside an\n * open panel now dismisses it at touch-down, where before it did not dismiss at all\n * (a drag synthesises no mousedown). That matches every other platform surface, and\n * the anchored panels re-measure on scroll precisely because the page moves under\n * them.\n */\nexport function useOutsideClick(\n refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],\n handler: () => void,\n enabled = true,\n): (e: ReactPress) => void {\n const refsRef = useRef(refs);\n const handlerRef = useRef(handler);\n // The last press this owner's React tree saw: the one the document listener is about\n // to hear, when it went through a panel the owner marked.\n const insideRef = useRef<Event | null>(null);\n // In an effect rather than during render — see the note in useDismissLayer.\n useEffect(() => {\n refsRef.current = refs;\n handlerRef.current = handler;\n });\n const markInside = useCallback((e: ReactPress) => {\n insideRef.current = e.nativeEvent;\n }, []);\n useEffect(() => {\n if (!enabled) return;\n const mark: PressMark = (press) => {\n insideRef.current = press;\n };\n const watched = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of watched) {\n const set = watchers.get(r) ?? new Set<PressMark>();\n set.add(mark);\n watchers.set(r, set);\n }\n const onPointerDown = (e: Event) => {\n const inside = insideRef.current === e;\n insideRef.current = null;\n if (inside) return;\n const target = e.target as Node;\n const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of list) {\n if (r.current?.contains(target)) return;\n }\n handlerRef.current();\n };\n document.addEventListener(\"pointerdown\", onPointerDown);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointerDown);\n for (const r of watched) watchers.get(r)?.delete(mark);\n };\n }, [enabled]);\n return markInside;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-dismiss.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, RefObject } from \"react\";\nimport { isComposing } from \"../lib/ime\";\n\n/*\n * # One Escape stack (round 0.34 Wave 2, DUP-1)\n *\n * Escape closes the TOPMOST thing on screen, and only that: a list open in a dialog\n * closes and the dialog stays; the next press closes the dialog.\n *\n * \"Who owns Escape\" used to be decided five ways. The anchored pickers listened on the\n * document; Modal, FullBleedDialog, PickerSheet and Popover closed in their own panel's\n * `onKeyDown` and stopped the key; CommandPalette closed without stopping it. A dialog's\n * handler sits earlier on the key's path than any document listener, so Escape in an\n * open list inside a Modal closed the MODAL and threw the form away (BUG-1, sev 4,\n * kastlan's lease dialog), and HoverMenu, the numpad and a tooltip could not be\n * dismissed inside a dialog at all. And Modal and FullBleedDialog disagreed on when a\n * child had \"claimed\" the key: a ChipInput clearing its draft kept a Modal open and\n * closed a FullBleedDialog.\n *\n * Now every overlay and picker pushes a layer while it is open ({@link useDismissLayer}),\n * last in, first out, the way `use-overlay-history` stacks the Back gesture. One\n * capture-phase listener on the document notes which layer is on top as each Escape\n * goes down — before any handler on the page runs, so a layer that closes on the way\n * cannot pass the key on to the one beneath it. That layer, and only that one, is\n * handed the key:\n *\n * - at the first layer element the key passes on its way back up ({@link takeEscape},\n * which Modal's panel, a picker's search box and the rest call from their\n * `onKeyDown`). The key is stopped there, so nothing around the layers — a\n * FloatingPanel, an app's own panel — closes with it;\n * - or, when focus is in none of them (a hover menu, a tooltip, the numpad over its\n * field, a press on the page), at the document once the key has been everywhere else.\n *\n * Layers stack in the order they OPEN, which is the order their effects run. Two that\n * open in the same commit stack child first — the one case that order gets wrong, and\n * one no overlay in the kit does: each opens on a press, after whatever it sits in.\n *\n * ## Claiming the key: one rule\n *\n * An Escape that means \"undo this step\" is not a dismissal: ChipInput clearing its\n * draft, InlineEditField cancelling its edit, the date picker's month grid going back to\n * the days. Those call {@link claimEscape}, and no layer is handed the key. The only\n * other claim is the layer owner's OWN handler — a dialog's `onKeyDown` prop calling\n * `preventDefault()` on Escape (a busy reauth dialog holding itself open, Kurvenschmiede's\n * gear wizard asking first), through {@link runOwnKeyDown}. A `preventDefault()`\n * anywhere else claims nothing: a search box keeping Chromium from clearing itself\n * must not hold a dialog open (FullBleedDialog's 0.8.0 fix, which Modal never had).\n *\n * The owner's handler hears Escape only while its own layer is on top (0.35, KS). It\n * runs before the stack is asked, so with a tooltip, a hover menu or a list open over\n * the dialog and focus on the panel, the gear wizard asked \"Discard this gear?\" and the\n * tooltip stayed. Now the layer above takes that press, and the next one is the\n * owner's. Every other key reaches the handler as before.\n *\n * A handler that STOPS the key before it reaches a layer element has taken it too —\n * that is the DOM, and it is what the kit's cell editors and confirm buttons do.\n */\n\n/** A layer's handler, read through a ref so the stack holds one stable entry per open. */\ninterface Layer {\n escape: (e: KeyboardEvent) => void;\n /** Whether it covers the page — a dialog, a sheet, a popover, a list — rather than\n * annotating it (a tooltip). Counted by {@link useOverlayOpen}. */\n overlay: boolean;\n}\n\n/** What {@link useDismissLayer} hands back: its layer while open, for\n * {@link runOwnKeyDown} to tell whether that layer is the one on top. */\nexport type DismissLayer = RefObject<Layer | null>;\n\n/** Open layers, oldest first. Module-level, because the order is ACROSS instances. */\nconst layers: Layer[] = [];\n\n/** Escapes a claimant has taken — see {@link claimEscape}. Weak: an event is garbage\n * once its dispatch is over. */\nconst claimed = new WeakSet<Event>();\n\n/** The Escape on its way through the page: the layer that was topmost as it went down,\n * and whether that layer has been handed it yet. */\nlet inFlight: { event: KeyboardEvent; layer: Layer; done: boolean } | null = null;\n\nfunction nativeOf(e: KeyboardEvent | ReactKeyboardEvent): KeyboardEvent {\n return \"nativeEvent\" in e ? e.nativeEvent : e;\n}\n\nfunction onCapture(e: KeyboardEvent): void {\n if (e.key !== \"Escape\") return;\n // An Escape inside an IME composition cancels the composition and nothing else.\n const top = isComposing(e) ? undefined : layers[layers.length - 1];\n inFlight = top ? { event: e, layer: top, done: false } : null;\n}\n\n/** The fallback: no layer element was on the key's path, or none took it. */\nfunction onBubble(e: KeyboardEvent): void {\n handOver(e, false);\n}\n\nfunction handOver(e: KeyboardEvent | ReactKeyboardEvent, stop: boolean): boolean {\n const native = nativeOf(e);\n const flight = inFlight;\n if (native.key !== \"Escape\" || !flight || flight.event !== native || flight.done) return false;\n if (claimed.has(native)) return false;\n flight.done = true;\n // Consumed: a type=\"search\" box does not clear itself as well, and a handler further\n // out that checks `defaultPrevented` (FloatingPanel, an app's own panel) leaves it.\n e.preventDefault();\n if (stop) e.stopPropagation();\n // A layer that closed on the way, by a handler of its own, has had its Escape.\n if (layers.includes(flight.layer)) flight.layer.escape(native);\n return true;\n}\n\nfunction push(layer: Layer): void {\n if (layers.length === 0) {\n document.addEventListener(\"keydown\", onCapture, true);\n document.addEventListener(\"keydown\", onBubble);\n }\n layers.push(layer);\n if (layer.overlay) notifyOverlays();\n}\n\nfunction pop(layer: Layer): void {\n const at = layers.indexOf(layer);\n if (at >= 0) layers.splice(at, 1);\n if (at >= 0 && layer.overlay) notifyOverlays();\n if (layers.length > 0) return;\n document.removeEventListener(\"keydown\", onCapture, true);\n document.removeEventListener(\"keydown\", onBubble);\n inFlight = null;\n}\n\n/**\n * An Escape layer on the stack while `open`: the topmost layer is handed the next\n * Escape and calls `onEscape`, every layer under it waits its turn. A list opened inside\n * a dialog is above the dialog; a popover opened from a list, above the list.\n *\n * Where focus can be INSIDE the layer, its element's `onKeyDown` calls\n * {@link takeEscape} too, so the key stops there — see the module note. `onEscape` is\n * read live, so it need not be memoised; the layer is pushed and popped only when\n * `open` flips. The layer it returns is for the owner's {@link runOwnKeyDown}.\n */\nexport function useDismissLayer(\n open: boolean,\n onEscape: (e: KeyboardEvent) => void,\n { overlay = true }: { overlay?: boolean } = {},\n): DismissLayer {\n const handlerRef = useRef(onEscape);\n const layerRef = useRef<Layer | null>(null);\n // Written in an effect, not during render, so it satisfies react-hooks/refs: a\n // render that React starts and then discards — a transition, a Suspense retry —\n // would otherwise leave this holding a handler from a tree that never committed,\n // and the stack would go on to call it. Keydown events fire after the commit, so\n // the ref is always current by the time one arrives.\n useEffect(() => {\n handlerRef.current = onEscape;\n });\n useEffect(() => {\n if (!open || typeof document === \"undefined\") return;\n const layer: Layer = { escape: (e) => handlerRef.current(e), overlay };\n layerRef.current = layer;\n push(layer);\n return () => {\n pop(layer);\n layerRef.current = null;\n };\n }, [open, overlay]);\n return layerRef;\n}\n\n/*\n * Whether an overlay is open (0.49 KIT-11): every layer on the stack but a tooltip's is a\n * dialog, a sheet, a popover, a hover menu or a list covering part of the page, so the\n * stack is also the one place that knows. The floating launchers (`hideWhenOverlay`) step\n * aside while one is open instead of floating over it or beside it.\n */\nconst overlayListeners = new Set<() => void>();\n\nfunction notifyOverlays(): void {\n for (const listener of overlayListeners) listener();\n}\n\nfunction subscribeOverlays(listener: () => void): () => void {\n overlayListeners.add(listener);\n return () => {\n overlayListeners.delete(listener);\n };\n}\n\nfunction anyOverlayOpen(): boolean {\n return layers.some((layer) => layer.overlay);\n}\n\n/** True while a kit dialog, sheet, popover, hover menu or list is open (any layer of the\n * Escape stack but a tooltip's). Internal. */\nexport function useOverlayOpen(enabled = true): boolean {\n const open = useSyncExternalStore(subscribeOverlays, anyOverlayOpen, () => false);\n return enabled && open;\n}\n\n/**\n * For a layer's own element, in its `onKeyDown`: hands an Escape on its way up to the\n * topmost layer — this one, unless something opened over it without taking focus (a\n * tooltip, the numpad) — and stops it here. True when it did; false for any other key,\n * a claimed Escape, or one already handed over.\n */\nexport function takeEscape(e: KeyboardEvent | ReactKeyboardEvent): boolean {\n return handOver(e, true);\n}\n\n/**\n * For an Escape that means \"undo this step\", not \"close\": the key is the caller's, and\n * no layer is handed it. `preventDefault()`s it as well, so a handler further out that\n * checks `defaultPrevented` leaves it alone, as before.\n */\nexport function claimEscape(e: KeyboardEvent | ReactKeyboardEvent): void {\n claimed.add(nativeOf(e));\n e.preventDefault();\n}\n\n/**\n * Runs a layer owner's own key handler — a dialog's `onKeyDown` prop — and turns a\n * `preventDefault()` it makes on Escape into a claim: the one way, beside\n * {@link claimEscape}, to keep a layer open.\n *\n * Given the owner's `layer` (what {@link useDismissLayer} returned), an Escape that went\n * down while another layer was above that one skips the handler: the press is the\n * layer's on top, and the owner hears the next (0.35, KS). Without it, or while the\n * owner's layer is not open, the handler sees every key, as before.\n */\nexport function runOwnKeyDown<E extends ReactKeyboardEvent>(\n e: E,\n handler: ((e: E) => void) | undefined,\n layer?: DismissLayer,\n): void {\n if (!handler) return;\n if (layer && aboveOwner(e, layer.current)) return;\n const before = e.defaultPrevented;\n handler(e);\n if (e.key === \"Escape\" && !before && e.defaultPrevented) claimed.add(nativeOf(e));\n}\n\n/** Whether this Escape went down with a layer above `owner` — the topmost one then was\n * another, and `owner` is on the stack. An Escape the stack did not note (one inside an\n * IME composition) is nobody else's. */\nfunction aboveOwner(e: ReactKeyboardEvent, owner: Layer | null): boolean {\n const flight = inFlight;\n if (e.key !== \"Escape\" || !owner || !flight || flight.event !== nativeOf(e)) return false;\n return flight.layer !== owner && layers.includes(owner);\n}\n\n/**\n * Calls `handler` on an Escape while `enabled` — as an Escape layer. Since 0.34 this is\n * {@link useDismissLayer} with its arguments the other way round: it hears the key only\n * while nothing opened above it, and inside a dialog it no longer loses the key to the\n * dialog (Wave 2, DUP-1). `handler` is read live, so it need not be memoised.\n */\nexport function useEscapeKey(handler: () => void, enabled = true): void {\n useDismissLayer(enabled, handler);\n}\n\n/** Called with each native press an owner's React tree saw — see {@link useOutsideClick}. */\ntype PressMark = (press: Event) => void;\n\n/** The outside-click hooks watching each ref, for {@link pressInside}. */\nconst watchers = new WeakMap<RefObject<HTMLElement | null>, Set<PressMark>>();\n\n/** What an `onPointerDownCapture` hands over: a React event, its press inside. */\ntype ReactPress = { nativeEvent: Event };\n\n/**\n * An `onPointerDownCapture` for a panel whose `ref` an outside-click hook watches but\n * whose element the hook's owner does not render — {@link DropdownPanel} and\n * `ComboboxPanel` take `panelRef` from `useDropdown` / `useComboboxCore`. Every press\n * that reaches the panel through the React tree, a portalled list opened inside it\n * included, is marked inside for each hook watching `ref`. See {@link useOutsideClick}.\n */\nexport function pressInside(ref: RefObject<HTMLElement | null> | undefined): ((e: ReactPress) => void) | undefined {\n if (!ref) return undefined;\n return (e) => {\n watchers.get(ref)?.forEach((mark) => mark(e.nativeEvent));\n };\n}\n\n/**\n * Calls `handler` on a press that lands outside every supplied element, while\n * `enabled` — the outside-click-to-close half of the same dismissal story. Pass one\n * ref or several (trigger + panel); a press inside any of them is ignored. Refs and\n * `handler` are read live, so an inline `[triggerRef, panelRef]` array is fine and\n * won't churn the listener.\n *\n * ## A panel opened from inside is inside (0.48, keksdose dev#623)\n *\n * A CurrencySelect in a Popover: the currency list is portalled to `<body>`, so in the\n * DOM it is outside the popover's panel, and the first press on a currency closed the\n * POPOVER at pointerdown — the list went with it and the choice never landed. The same\n * for a combobox, a menu, a date picker's popover, a dropdown, at any depth.\n *\n * In the React tree the portal is still where it was opened from, and React passes a\n * press up that tree, through portals. So the hook hands back an\n * `onPointerDownCapture`: put it on the element the press should count as inside of —\n * the kit's Popover panel, HoverMenu, DropdownPanel and ComboboxPanel do — and every\n * press that travels through that element in React, portalled panels opened inside it\n * included, is not outside. React's listeners sit on the root and on each portal's\n * container, so they hear the press before this document listener does. No owner chain\n * to keep: depth is free, and an app's own portal inside a kit panel is covered too. A\n * panel the hook's owner does not render takes {@link pressInside} with the watched ref.\n *\n * **`pointerdown`, not `mousedown`** — one event covering mouse, touch and pen.\n * This listened for `mousedown` alone, which on a touch platform is a SYNTHESISED\n * event: the browser emits it for most taps, which is why nothing obviously broke,\n * but the synthesis is not guaranteed. A tap that begins a scroll, or one that lands\n * on an element which preventDefaults the touch sequence, may never produce one — and\n * on a phone-first PWA whose pickers, menus and flyouts all dismiss through this hook,\n * inheriting that is worse than choosing it.\n *\n * The one behaviour this changes deliberately: a touch-drag that starts outside an\n * open panel now dismisses it at touch-down, where before it did not dismiss at all\n * (a drag synthesises no mousedown). That matches every other platform surface, and\n * the anchored panels re-measure on scroll precisely because the page moves under\n * them.\n */\nexport function useOutsideClick(\n refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],\n handler: () => void,\n enabled = true,\n): (e: ReactPress) => void {\n const refsRef = useRef(refs);\n const handlerRef = useRef(handler);\n // The last press this owner's React tree saw: the one the document listener is about\n // to hear, when it went through a panel the owner marked.\n const insideRef = useRef<Event | null>(null);\n // In an effect rather than during render — see the note in useDismissLayer.\n useEffect(() => {\n refsRef.current = refs;\n handlerRef.current = handler;\n });\n const markInside = useCallback((e: ReactPress) => {\n insideRef.current = e.nativeEvent;\n }, []);\n useEffect(() => {\n if (!enabled) return;\n const mark: PressMark = (press) => {\n insideRef.current = press;\n };\n const watched = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of watched) {\n const set = watchers.get(r) ?? new Set<PressMark>();\n set.add(mark);\n watchers.set(r, set);\n }\n const onPointerDown = (e: Event) => {\n const inside = insideRef.current === e;\n insideRef.current = null;\n if (inside) return;\n const target = e.target as Node;\n const list = Array.isArray(refsRef.current) ? refsRef.current : [refsRef.current];\n for (const r of list) {\n if (r.current?.contains(target)) return;\n }\n handlerRef.current();\n };\n document.addEventListener(\"pointerdown\", onPointerDown);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointerDown);\n for (const r of watched) watchers.get(r)?.delete(mark);\n };\n }, [enabled]);\n return markInside;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes the window's title (`document.title`) while the calling component is mounted,
|
|
3
|
+
* and puts back the title it found when it unmounts (0.49 KIT-9).
|
|
4
|
+
*
|
|
5
|
+
* `parts` are positional: `[page, section, app]`. The app falls back to the provider's
|
|
6
|
+
* `appName`, the format to the provider's `documentTitleFormat` (default
|
|
7
|
+
* "{page} · {section} · {app}"); a missing part drops with its separator
|
|
8
|
+
* ({@link formatDocumentTitle}). Parts that are ALL empty write nothing — the provider's
|
|
9
|
+
* app alone is not a page's title.
|
|
10
|
+
*
|
|
11
|
+
* `PageHeader` calls it for its `h1` (`documentTitle`); call it yourself for a page
|
|
12
|
+
* without one.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useDocumentTitle(parts: ReadonlyArray<string | null | undefined | false>): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { useKitDocumentTitle } from "../i18n/kit-labels.js";
|
|
4
|
+
import { DEFAULT_DOCUMENT_TITLE_FORMAT, formatDocumentTitle } from "../lib/document-title.js";
|
|
5
|
+
function useDocumentTitle(parts) {
|
|
6
|
+
const { format, appName } = useKitDocumentTitle();
|
|
7
|
+
const [page, section, app] = parts.map((p) => typeof p === "string" && p.trim() !== "" ? p : void 0);
|
|
8
|
+
const any = parts.some((p) => typeof p === "string" && p.trim() !== "");
|
|
9
|
+
const title = any ? formatDocumentTitle(format ?? DEFAULT_DOCUMENT_TITLE_FORMAT, { page, section, app: app ?? appName }) : null;
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (title === null || title === "") return;
|
|
12
|
+
const before = document.title;
|
|
13
|
+
document.title = title;
|
|
14
|
+
return () => {
|
|
15
|
+
document.title = before;
|
|
16
|
+
};
|
|
17
|
+
}, [title]);
|
|
18
|
+
}
|
|
19
|
+
export {
|
|
20
|
+
useDocumentTitle
|
|
21
|
+
};
|
|
22
|
+
//# sourceMappingURL=use-document-title.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-document-title.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { useKitDocumentTitle } from \"../i18n/kit-labels\";\nimport { DEFAULT_DOCUMENT_TITLE_FORMAT, formatDocumentTitle } from \"../lib/document-title\";\n\n/**\n * Writes the window's title (`document.title`) while the calling component is mounted,\n * and puts back the title it found when it unmounts (0.49 KIT-9).\n *\n * `parts` are positional: `[page, section, app]`. The app falls back to the provider's\n * `appName`, the format to the provider's `documentTitleFormat` (default\n * \"{page} · {section} · {app}\"); a missing part drops with its separator\n * ({@link formatDocumentTitle}). Parts that are ALL empty write nothing — the provider's\n * app alone is not a page's title.\n *\n * `PageHeader` calls it for its `h1` (`documentTitle`); call it yourself for a page\n * without one.\n */\nexport function useDocumentTitle(parts: ReadonlyArray<string | null | undefined | false>): void {\n const { format, appName } = useKitDocumentTitle();\n const [page, section, app] = parts.map((p) => (typeof p === \"string\" && p.trim() !== \"\" ? p : undefined));\n const any = parts.some((p) => typeof p === \"string\" && p.trim() !== \"\");\n const title = any ? formatDocumentTitle(format ?? DEFAULT_DOCUMENT_TITLE_FORMAT, { page, section, app: app ?? appName }) : null;\n useEffect(() => {\n if (title === null || title === \"\") return;\n const before = document.title;\n document.title = title;\n return () => {\n document.title = before;\n };\n }, [title]);\n}\n"],"mappings":";AAAA,SAAS,iBAAiB;AAC1B,SAAS,2BAA2B;AACpC,SAAS,+BAA+B,2BAA2B;AAe5D,SAAS,iBAAiB,OAA+D;AAC9F,QAAM,EAAE,QAAQ,QAAQ,IAAI,oBAAoB;AAChD,QAAM,CAAC,MAAM,SAAS,GAAG,IAAI,MAAM,IAAI,CAAC,MAAO,OAAO,MAAM,YAAY,EAAE,KAAK,MAAM,KAAK,IAAI,MAAU;AACxG,QAAM,MAAM,MAAM,KAAK,CAAC,MAAM,OAAO,MAAM,YAAY,EAAE,KAAK,MAAM,EAAE;AACtE,QAAM,QAAQ,MAAM,oBAAoB,UAAU,+BAA+B,EAAE,MAAM,SAAS,KAAK,OAAO,QAAQ,CAAC,IAAI;AAC3H,YAAU,MAAM;AACd,QAAI,UAAU,QAAQ,UAAU,GAAI;AACpC,UAAM,SAAS,SAAS;AACxB,aAAS,QAAQ;AACjB,WAAO,MAAM;AACX,eAAS,QAAQ;AAAA,IACnB;AAAA,EACF,GAAG,CAAC,KAAK,CAAC;AACZ;","names":[]}
|
package/dist/i18n/german.js
CHANGED
|
@@ -519,6 +519,7 @@ function germanLabels(numberLocale) {
|
|
|
519
519
|
theme: "Theme wechseln",
|
|
520
520
|
palette: "Darstellungsvorlage",
|
|
521
521
|
language: "Sprache",
|
|
522
|
+
languageCurrent: (name) => `Sprache w\xE4hlen, aktuell: ${name}`,
|
|
522
523
|
switchRole: "Rolle wechseln",
|
|
523
524
|
role: (value) => `Rolle: ${value}`
|
|
524
525
|
},
|
|
@@ -1786,11 +1787,14 @@ Die vollst\xE4ndige Postanschrift wird betroffenen Personen und Aufsichtsbeh\xF6
|
|
|
1786
1787
|
},
|
|
1787
1788
|
rowActions: {
|
|
1788
1789
|
actions: "Aktionen",
|
|
1789
|
-
actionsFor: (name) => `Aktionen f\xFCr ${name}
|
|
1790
|
+
actionsFor: (name) => `Aktionen f\xFCr ${name}`,
|
|
1791
|
+
confirmTitle: (action, name) => name ? `${action}: \xAB${name}\xBB?` : `${action}?`,
|
|
1792
|
+
confirmBody: "Das l\xE4sst sich nicht r\xFCckg\xE4ngig machen."
|
|
1790
1793
|
},
|
|
1791
1794
|
appShellMore: {
|
|
1792
1795
|
more: "Mehr",
|
|
1793
|
-
moreTitle: "Weitere Seiten"
|
|
1796
|
+
moreTitle: "Weitere Seiten",
|
|
1797
|
+
overview: "\xDCbersicht"
|
|
1794
1798
|
},
|
|
1795
1799
|
// 0.42.0 — docs/admin-harmonization.md §3.1: the admin area's shared words. "Verwaltung"
|
|
1796
1800
|
// as settings §5 names the page; "mit Administratorrechten" rather than "für
|