@eifi1/ui-kit 0.46.1 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +5 -5
  2. package/dist/components/data-table.js +18 -1
  3. package/dist/components/data-table.js.map +1 -1
  4. package/dist/components/row-actions.d.ts +25 -0
  5. package/dist/components/row-actions.js +13 -3
  6. package/dist/components/row-actions.js.map +1 -1
  7. package/dist/components/tabs.d.ts +24 -16
  8. package/dist/components/tabs.js +37 -31
  9. package/dist/components/tabs.js.map +1 -1
  10. package/dist/components/ui-labels.js +1 -1
  11. package/dist/components/ui-labels.js.map +1 -1
  12. package/dist/eslint/index.js +1 -1
  13. package/dist/feedback/feedback-attachment-download.d.ts +19 -0
  14. package/dist/feedback/feedback-attachment-download.js +59 -0
  15. package/dist/feedback/feedback-attachment-download.js.map +1 -0
  16. package/dist/feedback/feedback-comment-thread.d.ts +17 -6
  17. package/dist/feedback/feedback-comment-thread.js +21 -2
  18. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  19. package/dist/feedback/feedback-entry-file.d.ts +61 -0
  20. package/dist/feedback/feedback-entry-file.js +76 -0
  21. package/dist/feedback/feedback-entry-file.js.map +1 -0
  22. package/dist/feedback/feedback-public-list.d.ts +18 -5
  23. package/dist/feedback/feedback-public-list.js +8 -0
  24. package/dist/feedback/feedback-public-list.js.map +1 -1
  25. package/dist/feedback/feedback-public-types.d.ts +7 -2
  26. package/dist/feedback/feedback-replies.d.ts +26 -9
  27. package/dist/feedback/feedback-replies.js +20 -10
  28. package/dist/feedback/feedback-replies.js.map +1 -1
  29. package/dist/feedback/feedback-row-detail-labels.js +0 -1
  30. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  31. package/dist/feedback/feedback-row-detail.d.ts +23 -10
  32. package/dist/feedback/feedback-row-detail.js +10 -54
  33. package/dist/feedback/feedback-row-detail.js.map +1 -1
  34. package/dist/feedback.d.ts +1 -0
  35. package/dist/feedback.js +1 -0
  36. package/dist/feedback.js.map +1 -1
  37. package/dist/i18n/german.js +1 -6
  38. package/dist/i18n/german.js.map +1 -1
  39. package/dist/i18n/locales/en.js +1 -6
  40. package/dist/i18n/locales/en.js.map +2 -2
  41. package/dist/i18n/locales/es.js +1 -6
  42. package/dist/i18n/locales/es.js.map +1 -1
  43. package/dist/i18n/locales/fr.js +1 -6
  44. package/dist/i18n/locales/fr.js.map +1 -1
  45. package/dist/i18n/locales/hu.js +1 -6
  46. package/dist/i18n/locales/hu.js.map +1 -1
  47. package/dist/i18n/locales/it.js +1 -6
  48. package/dist/i18n/locales/it.js.map +1 -1
  49. package/dist/i18n/locales/zh.js +1 -6
  50. package/dist/i18n/locales/zh.js.map +1 -1
  51. package/dist/i18n/review.js +0 -1
  52. package/dist/i18n/review.js.map +1 -1
  53. package/dist/index.d.ts +2 -4
  54. package/dist/index.js +11 -4
  55. package/dist/index.js.map +1 -1
  56. package/dist/notifications/device-zone-report.d.ts +1 -1
  57. package/dist/notifications/device-zone-report.js.map +1 -1
  58. package/dist/notifications/notification-types.d.ts +0 -3
  59. package/dist/notifications/notifications-labels.d.ts +2 -15
  60. package/dist/notifications/notifications-labels.js +0 -4
  61. package/dist/notifications/notifications-labels.js.map +1 -1
  62. package/dist/notifications/quiet-hours-setting.d.ts +3 -8
  63. package/dist/notifications/quiet-hours-setting.js +8 -3
  64. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  65. package/dist/theme/theme-store.d.ts +4 -4
  66. package/dist/theme/theme-store.js +1 -0
  67. package/dist/theme/theme-store.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/components/data-table.tsx +29 -3
  70. package/src/components/row-actions.tsx +42 -3
  71. package/src/components/tabs.tsx +66 -44
  72. package/src/components/ui-labels.ts +1 -1
  73. package/src/feedback/feedback-attachment-download.tsx +77 -0
  74. package/src/feedback/feedback-comment-thread.tsx +42 -7
  75. package/src/feedback/feedback-entry-file.tsx +150 -0
  76. package/src/feedback/feedback-public-list.tsx +25 -4
  77. package/src/feedback/feedback-public-types.ts +7 -2
  78. package/src/feedback/feedback-replies.tsx +46 -19
  79. package/src/feedback/feedback-row-detail-labels.ts +0 -1
  80. package/src/feedback/feedback-row-detail.tsx +35 -86
  81. package/src/feedback.ts +1 -0
  82. package/src/i18n/german.ts +1 -6
  83. package/src/i18n/locales/es.ts +1 -6
  84. package/src/i18n/locales/fr.ts +1 -6
  85. package/src/i18n/locales/hu.ts +1 -6
  86. package/src/i18n/locales/it.ts +1 -6
  87. package/src/i18n/locales/zh.ts +1 -6
  88. package/src/i18n/review.ts +0 -1
  89. package/src/index.ts +10 -3
  90. package/src/notifications/device-zone-report.ts +1 -1
  91. package/src/notifications/notification-types.ts +0 -3
  92. package/src/notifications/notifications-labels.ts +2 -19
  93. package/src/notifications/quiet-hours-setting.tsx +16 -11
  94. package/src/theme/theme-store.ts +13 -5
  95. package/dist/notifications/time-zone-select.d.ts +0 -54
  96. package/dist/notifications/time-zone-select.js +0 -71
  97. package/dist/notifications/time-zone-select.js.map +0 -1
  98. package/src/notifications/time-zone-select.tsx +0 -125
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/tabs.tsx"],"sourcesContent":["import { useLayoutEffect, useMemo, useRef } from \"react\";\nimport { ChevronLeft, ChevronRight, Plus, X } from \"lucide-react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { scrollIntoStrip, useStripFade } from \"../lib/strip-fade\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { prefersReducedMotion } from \"../lib/reduced-motion\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { mergeRefs } from \"./field-parts\";\nimport { DEFAULT_TABS_LABELS } from \"./ui-labels\";\nimport { IconButton } from \"./icon-button\";\n\n/** One tab of a {@link Tabs} strip. */\nexport interface TabItem<T extends string> {\n id: T;\n /** A ReactNode so a tab can pair an icon with text. */\n label: ReactNode;\n /** Optional trailing node (e.g. a count pill). */\n badge?: ReactNode;\n /** Marks a tab that IS a route — see {@link TabsProps}. */\n href?: string;\n /** Leading decoration: an icon, or the colour swatches of the lines this tab\n * draws in a chart above the strip. Hidden from assistive tech — it repeats\n * what the label says, or it says something the label must say instead. */\n icon?: ReactNode;\n /** A second, smaller line under the label — the one fact that tells two tabs\n * with similar labels apart (\"80 km/h\", \"Level 3\"). Part of the tab's name. */\n detail?: ReactNode;\n /** A tab that exists as a slot with nothing in it yet, drawn with a dashed\n * outline so a gap in a sequence looks like a gap. Visual only: say \"empty\" in\n * the label or `detail` if a screen reader needs to know. */\n empty?: boolean;\n /** `false` keeps this one tab when the strip has `onRemove` — the one sheet a\n * workbook cannot be without. Default: removable. */\n removable?: boolean;\n /** Plain-text name for the labels the strip composes about this tab (\"Remove\n * …\"), for when `label` is not a string. Default: `label` if it is a string,\n * else the id. */\n name?: string;\n}\n\n/** The strings the strip renders on its own behalf — only when it can add or\n * remove tabs. */\nexport interface TabsLabels {\n /** The \"add\" button's visible text when the caller gives no `addLabel`. */\n add: string;\n /** Accessible name of a tab's × — \"Remove <tab>\". */\n remove: (tab: string) => string;\n /** The `overflowCue` caret while tabs lie past the strip's end: \"More tabs\". */\n more: string;\n /** The `overflowCue` caret at the strip's end, which goes back to the first tab. */\n backToFirst: string;\n}\n\n// Defined in ./ui-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_TABS_LABELS };\n\n/**\n * The strip's own props sit on a `<div>`: the tablist IS the root element, so anything\n * a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an\n * `aria-describedby` — lands there. `onChange` is omitted from the div's props because\n * this component's `onChange` hands over the chosen TAB ID, not a DOM event, and\n * `children` because the strip renders `tabs` — a caller who wants a PANEL wires it up\n * through `panelId`, which is the whole point of that prop.\n */\nexport interface TabsProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n /** `label` is a ReactNode so tabs can pair an icon with text; `badge` is an\n * optional trailing node (e.g. a count pill). `href` marks a tab that IS a route:\n * it renders as an anchor so it can be middle-/⌘-clicked into a new tab (Keksdose\n * feedback #451), while a plain click still goes through `onChange` and stays\n * client-side. Tabs that only flip local state leave it unset — a link to a URL\n * that does not select the tab would be worse than no link. */\n tabs: TabItem<T>[];\n active: T;\n onChange: (id: T) => void;\n className?: string;\n /**\n * The caret beside a SCROLLING strip (0.40, kk run 79 gap 1): a › while\n * tabs lie past the strip's end — a tap scrolls it on by three quarters of its width —\n * turning into a ‹ at the end, which goes back to the first. Shown only while the strip\n * actually overflows.\n *\n * The edge fade alone could read as the end of the row: at the default scroll position\n * the last visible tab can finish exactly where the fade begins, so nothing looks cut\n * off (keksdose live #410, whose `tab-strip-more.tsx` this replaces). One button that\n * turns round rather than two that come and go, so the strip's width never changes\n * under the thumb. Out of the tab order — the keyboard walks the strip with the arrow\n * keys, which bring the focused tab into view. At every width since 0.46 (keksdose live\n * #410 round 3: eleven report tabs overflow a laptop too); before, `md:hidden`. Ignored on\n * a vertical strip (a side nav is a column, not a row). Names:\n * `tabs.more` / `tabs.backToFirst`.\n */\n overflowCue?: boolean;\n /**\n * `id` of the element the caller renders the open tab's content into — the missing\n * half of `role=\"tab\"`. A tab that controls nothing is a tab in name only: a screen\n * reader announces \"tab, 2 of 5\" and then has no way to take the user to what it\n * opened, and no way back.\n *\n * **This component does not render the panel, on purpose.** `Tabs` is the STRIP; the\n * content lives wherever the caller put it — three Keksdose pages render it in a\n * sibling `<Card>`, one renders it through a router outlet, and a tab can BE a route\n * (`href`), in which case the panel is a whole page this component never sees.\n * Wrapping `children` here would mean either moving that content into the strip's\n * subtree — a layout change on every page that uses tabs — or shipping a wrapper\n * that only some callers could use. So the two halves are joined by an id instead.\n *\n * Wire the other end yourself:\n *\n * ```tsx\n * <Tabs tabs={tabs} active={active} onChange={setActive} panelId=\"report-panel\" />\n * <div id=\"report-panel\" role=\"tabpanel\" aria-labelledby=\"report-panel-tab\" tabIndex={-1}>\n * ```\n *\n * `aria-controls` goes on the OPEN tab only, and `${panelId}-tab` is its id — the\n * closed tabs' panels are not in the document, and a dangling `aria-controls`\n * describes a tab as opening something that is not there.\n */\n panelId?: string;\n /**\n * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab\n * (only on the open one with `removeOn=\"active\"`).\n *\n * The × is on the open tab only — a row of tabs each carrying one is a row of\n * things to hit by accident, and the tab you can remove should be the one you\n * are looking at — but its room is reserved on every removable tab, so opening a\n * tab never widens it and never pushes the rest of the strip along (lenkbank\n * feedback #72). It is not a tab stop of its own: the strip stays one stop, and the\n * keyboard path is Delete (announced through `aria-keyshortcuts`), which removes\n * whichever tab has focus (see `removeOn`).\n *\n * Assistive tech meets the remove as a \"Remove <tab>\" button right AFTER the strip,\n * not as the × (0.34): a tablist may own tabs only, so the drawn × is the pointer's\n * and `aria-hidden`, and the named button beside the tablist is visually hidden.\n *\n * The strip only ASKS: it calls this, and the tab goes when the caller drops it\n * from `tabs` — after a confirmation, a request, whatever it needs. When it does,\n * focus that was on the removed tab (or its ×) moves to the open tab if the\n * removed one was open, else to its neighbour, instead of falling to the page.\n */\n onRemove?: (id: T) => void;\n /**\n * Which tabs the Delete key removes.\n *\n * - `\"every\"` (default): any focused removable tab — the ARIA pattern's reading.\n * - `\"active\"`: only the OPEN tab, the same one that wears the ×. For a strip whose\n * remove is immediate and unconfirmed (lenkbank's sheet tabs delete a measurement\n * sheet at once), so arrowing across the strip and pressing Delete cannot take\n * out a sheet the user is not looking at. Delete on a closed tab does nothing,\n * and only the open tab announces the shortcut (`aria-keyshortcuts`).\n *\n * Either way the × is drawn on the open tab only and its room stays reserved on\n * every removable tab, so the strip never relayouts when the selection moves.\n */\n removeOn?: \"every\" | \"active\";\n /** Renders an \"add\" button after the last tab, outside the tablist — it is an\n * action, not a tab, and a tablist may own only tabs. */\n onAdd?: () => void;\n /** The add button's text. Say what is added — \"Add loop\", \"Add report\" says\n * more than \"Add\". Default: `tabs.add` from the {@link UiKitProvider}. */\n addLabel?: string;\n /** Overrides for this strip's strings. See {@link TabsLabels}. */\n labels?: LabelOverride<TabsLabels>;\n /** An add or a remove is in flight: the × and the add button are disabled and\n * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */\n busy?: boolean;\n /**\n * `\"vertical\"`: the same strip as a SIDE NAV — one item per row, the open one\n * filled, ↑/↓ walking it (Home/End as before) and `aria-orientation=\"vertical\"` on\n * the tablist. For keksdose's admin page and settings page, which each hand-built\n * the identical sidebar with none of this strip's keyboard, roving tab stop or\n * routed-`href` handling. Everything else — `href` tabs, `badge`, `panelId`,\n * `onRemove`, `onAdd` — means exactly what it means on the horizontal strip; the\n * badge moves to the row's end.\n *\n * **On a phone (`usePhoneLayout()`) it becomes the horizontal strip**, rather than\n * staying a column. A side nav only works beside its content; on\n * a phone it has to go ABOVE it, and eight full-width rows there push the panel\n * the user picked below the fold on every visit — the two keksdose pages both\n * collapsed theirs into a scrolling row by hand for exactly that reason. The\n * orientation is switched in JavaScript, not with `md:` classes, so the\n * `aria-orientation` a screen reader hears and the arrow keys that work always\n * match the layout on screen. The caller's own two-column layout has to stack at\n * the same breakpoint (`md:grid-cols-[14rem_1fr]`).\n */\n orientation?: \"horizontal\" | \"vertical\";\n /** The `role=\"tablist\"` strip, where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10). Merged with the strip's own ref, which scrolls the open tab into\n * view and measures the fade, so neither loses the element. */\n ref?: Ref<HTMLDivElement>;\n}\n\nconst TABLIST_CLASSES =\n \"flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle\";\n// The side nav. A column of full-width rows with the open one FILLED rather than\n// underlined: an underline under one row of a list reads as a separator, not as a\n// selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the\n// admin page's nav and the app's own sidebar mark \"you are here\" the same way.\nconst TABLIST_VERTICAL_CLASSES = \"flex flex-col gap-0.5\";\nconst TAB_VERTICAL_CLASSES =\n FOCUS_RING_WIDTH +\n \" flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus-visible:ring-strong\";\nconst TAB_VERTICAL_ACTIVE_CLASSES = \"bg-active text-primary\";\nconst TAB_VERTICAL_INACTIVE_CLASSES =\n \"text-muted hover:bg-hover hover:text-primary\";\n\n// `max-w-full`: no tab outgrows its strip. A sheet's `detail` line (\"Spurstangenkraft\n// links · … · 31 Punkte\") made one tab 448px wide in a 390px strip, and a tab wider\n// than the strip cannot be scrolled into view — its end was simply off screen\n// (lenkbank L3). The label and detail truncate inside it instead; see `inner`.\nconst TAB_CLASSES =\n FOCUS_RING_WIDTH +\n \" max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus-visible:ring-strong\";\nconst TAB_ACTIVE_CLASSES = \"border-[var(--text-primary)] text-primary\";\nconst TAB_INACTIVE_CLASSES =\n \"border-transparent text-muted hover:text-secondary hover:border-strong\";\n\n// The add/remove chrome. The × reserves its gutter on every removable tab (see\n// `onRemove`).\nconst TAB_REMOVABLE_CLASSES = \"pe-8\";\nconst TAB_EMPTY_CLASSES = \"-mx-1.5 rounded border border-dashed border-strong px-1.5\";\nconst TAB_ADD_CLASSES =\n FOCUS_RING_WIDTH +\n \" inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none focus-visible:ring-strong disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4\";\n// `relative`: the phone's visually hidden label needs a positioned ancestor (see the\n// sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone \"+\".\nconst TAB_ADD_PHONE_CLASSES = \"relative max-md:px-2\";\n\nexport function Tabs<T extends string>({\n tabs,\n active,\n onChange,\n className,\n overflowCue = false,\n panelId,\n onRemove,\n removeOn = \"every\",\n onAdd,\n addLabel,\n labels,\n busy = false,\n orientation = \"horizontal\",\n \"aria-label\": ariaLabel,\n ref,\n ...rest\n}: TabsProps<T>) {\n const text = useKitLabels(\"tabs\", DEFAULT_TABS_LABELS, labels);\n // See `orientation`: a vertical strip is the horizontal one on a phone.\n const phone = usePhoneLayout();\n const vertical = orientation === \"vertical\" && !phone;\n const stripRef = useRef<HTMLDivElement>(null);\n const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);\n const fade = useStripFade(stripRef);\n // See `overflowCue`. `start` alone means the strip is scrolled to its end.\n const cue = overflowCue && !vertical && fade.overflow !== undefined;\n const cueAtEnd = fade.overflow === \"start\";\n const onCue = () => {\n const strip = stripRef.current;\n if (!strip) return;\n const behavior: ScrollBehavior = prefersReducedMotion() ? \"auto\" : \"smooth\";\n if (cueAtEnd) {\n strip.scrollTo({ left: 0, behavior });\n return;\n }\n const rtl = getComputedStyle(strip).direction === \"rtl\";\n strip.scrollBy({ left: (rtl ? -1 : 1) * Math.round(strip.clientWidth * 0.75), behavior });\n };\n const cueButton = cue ? (\n // The strip's bottom rule continues under the caret, so the row reads as one.\n <div data-tabs-overflow-cue=\"\" className=\"flex shrink-0 items-center self-stretch border-b border-subtle\">\n <IconButton\n size=\"sm\"\n variant=\"ghost\"\n tone=\"muted\"\n tabIndex={-1}\n type=\"button\"\n aria-label={cueAtEnd ? text.backToFirst : text.more}\n onClick={onCue}\n >\n {cueAtEnd ? <ChevronLeft className=\"rtl:-scale-x-100\" /> : <ChevronRight className=\"rtl:-scale-x-100\" />}\n </IconButton>\n </div>\n ) : null;\n // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a\n // deep link to the seventh report. A selection behind the cut edge was the worst\n // case of the overflow above: the one tab the panel below belongs to, sliced to\n // \"80 km,\" under the add button. Only the strip scrolls (`scrollBy` on it, not\n // `scrollIntoView`, which would scroll the page as well), and only as far as needed\n // to clear the fade.\n useLayoutEffect(() => {\n const strip = stripRef.current;\n const tab = strip?.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]');\n if (strip && tab) scrollIntoStrip(strip, tab);\n }, [active]);\n const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;\n // Whether Delete reaches this tab: every removable tab, or only the open one.\n const deletesOnKey = (tab: TabItem<T>) =>\n isRemovable(tab) && (removeOn === \"every\" || tab.id === active);\n const nameOf = (tab: TabItem<T>) =>\n tab.name ?? (typeof tab.label === \"string\" ? tab.label : tab.id);\n\n // Where focus goes once a removal the strip asked for has happened. The caller\n // owns `tabs` and may take its time (a confirmation, a request), so this waits\n // for the id to actually leave the list rather than guessing at the moment.\n const pendingFocus = useRef<{ removed: T; wasActive: boolean; neighbour?: T } | null>(null);\n const removeRef = useRef<HTMLButtonElement>(null);\n const requestRemove = (id: T) => {\n const index = tabs.findIndex((t) => t.id === id);\n pendingFocus.current = {\n removed: id,\n wasActive: id === active,\n neighbour: (tabs[index + 1] ?? tabs[index - 1])?.id,\n };\n onRemove?.(id);\n };\n useLayoutEffect(() => {\n const pending = pendingFocus.current;\n if (!pending || tabs.some((t) => t.id === pending.removed)) return;\n pendingFocus.current = null;\n const strip = stripRef.current;\n if (!strip) return;\n // Only focus that fell out of the strip with the removed tab is ours to place —\n // or that sits on the remove button after it, which a pick removed through.\n // If the user has moved on — into the panel, into a dialog — leave it there.\n const focused = document.activeElement;\n if (focused && focused !== document.body && !strip.contains(focused) && focused !== removeRef.current) return;\n // The removed tab was open: focus follows the caller's new selection, which is\n // also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.\n const target =\n !pending.wasActive && tabs.some((t) => t.id === pending.neighbour)\n ? pending.neighbour\n : active;\n const index = tabs.findIndex((t) => t.id === target);\n strip.querySelectorAll<HTMLElement>('[role=\"tab\"]')[index]?.focus();\n }, [tabs, active]);\n\n // Arrow keys walk the strip in DOM order (ARIA tabs pattern). Home/End jump\n // to the ends. Focus only — activation stays on click/Enter/Space, because a tab\n // here can be a real route and moving focus must not navigate.\n //\n // On the TABS, not on the tablist. The container carried it, which left a `<div>`\n // wearing an interactive role and a key handler while being unfocusable —\n // `jsx-a11y/interactive-supports-focus`, and the only instance of it left in `src/`.\n // The fix that rule wants is `tabIndex` on the div; the fix the pattern wants is\n // the handler on the elements that are natively focusable and actually receive the\n // keystroke. Those are the tabs, so the warning goes away by being right rather\n // than by being satisfied.\n const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {\n // Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable\n // tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it\n // rather than letting it fall through to the page. With `removeOn=\"active\"` only\n // the open tab answers; a closed one lets the key pass untouched.\n if (e.key === \"Delete\" && deletesOnKey(tab)) {\n e.preventDefault();\n if (!busy) requestRemove(tab.id);\n return;\n }\n // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits\n // to the left, and ArrowLeft has to reach it. A vertical strip answers ↑/↓ instead\n // — and ONLY those, as the pattern has it: ←/→ are left to the page, where a side\n // nav's neighbour (the panel) may well want them.\n const step: 1 | -1 | 0 = vertical\n ? e.key === \"ArrowDown\"\n ? 1\n : e.key === \"ArrowUp\"\n ? -1\n : 0\n : horizontalStep(e.key, e.currentTarget);\n if (step === 0 && e.key !== \"Home\" && e.key !== \"End\") return;\n const strip = e.currentTarget.closest('[role=\"tablist\"]');\n if (!strip) return;\n const items = Array.from(strip.querySelectorAll<HTMLElement>('[role=\"tab\"]'));\n const from = items.indexOf(e.currentTarget);\n if (from === -1) return;\n e.preventDefault();\n const to =\n e.key === \"Home\"\n ? 0\n : e.key === \"End\"\n ? items.length - 1\n : (from + step + items.length) % items.length;\n items[to]?.focus();\n };\n const tablist = (\n <div\n // Before the role and the name: a caller's arbitrary attribute is welcome on the\n // strip, but a `role` or an `aria-label` arriving through a spread props object\n // must not be able to unmake the tablist the tabs below are registered against.\n {...rest}\n ref={setStrip}\n role=\"tablist\"\n // Names the GROUP — every tab carries its own text — so a strip under a visible\n // heading that already says it can leave it unset.\n aria-label={ariaLabel}\n // Only when vertical: `horizontal` is the tablist's implicit value, and the\n // attribute on every existing strip would be noise in every snapshot of them.\n aria-orientation={vertical ? \"vertical\" : undefined}\n // Merged over a caller's own style rather than replacing it; see useStripFade.\n // `data-overflow` names the cut ends in reading-direction terms, for a caller's\n // own affordance (a scroll button) and for tests, which cannot read the mask.\n style={fade.mask ? { ...rest.style, maskImage: fade.mask, WebkitMaskImage: fade.mask } : rest.style}\n data-overflow={fade.overflow}\n // With an add button the strip gains an outer box, and `className` goes there\n // — it is the box a caller's margin or width is meant for.\n className={cn(\n vertical ? TABLIST_VERTICAL_CLASSES : TABLIST_CLASSES,\n onAdd ? \"min-w-0\" : overflowCue && !vertical ? \"min-w-0 flex-1\" : className,\n )}\n >\n {tabs.map((tab) => {\n const isActive = tab.id === active;\n const removable = isRemovable(tab);\n // Written once and worn by either tag below, so a routed tab and a\n // state-only tab stay indistinguishable to the eye and to a screen reader.\n const shared = {\n role: \"tab\" as const,\n \"aria-selected\": isActive,\n // Roving tabindex: the whole strip is ONE stop in the page's tab order, and\n // it is the open tab. Ten report tabs otherwise cost ten Tab presses to step\n // over on the way to the table below them (live #262's strip is the extreme\n // case, but every strip in the app paid it).\n //\n // The stop follows the SELECTION, not the focus. Arrowing is a look around —\n // activation here is manual, because a tab can be a real route — so re-\n // pointing the stop at a report the user only arrowed past would hand the\n // strip back on the next visit in a state they never chose. The cost is that\n // Tab out and back returns to the open tab rather than to the last one\n // looked at; the ARIA pattern allows either, and this one needs no second\n // piece of state shadowing `active`.\n tabIndex: isActive ? 0 : -1,\n // Only the open tab: see `panelId`.\n \"aria-controls\": isActive ? panelId : undefined,\n id: isActive && panelId ? `${panelId}-tab` : undefined,\n \"aria-keyshortcuts\": deletesOnKey(tab) ? \"Delete\" : undefined,\n onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),\n className: vertical\n ? cn(\n TAB_VERTICAL_CLASSES,\n isActive ? TAB_VERTICAL_ACTIVE_CLASSES : TAB_VERTICAL_INACTIVE_CLASSES,\n removable && \"pe-8\",\n )\n : cn(\n TAB_CLASSES,\n isActive ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES,\n removable && TAB_REMOVABLE_CLASSES,\n ),\n };\n const inner = (\n <span\n className={cn(\n // In a side nav the row is the full width, so the label takes it and a\n // badge lands at the row's end, where a column of counts lines up.\n vertical ? \"flex min-w-0 flex-1 items-center gap-1.5\" : \"inline-flex max-w-full min-w-0 items-center gap-1.5\",\n tab.empty && TAB_EMPTY_CLASSES,\n )}\n >\n {tab.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 items-center gap-0.5\">\n {tab.icon}\n </span>\n )}\n {tab.detail != null ? (\n // `min-w-0` + `truncate-until-large` on each line: capped by the tab's\n // `max-w-full`, a long detail ends in an ellipsis at Normal and wraps from\n // Large up (text size §10.17; round 0.34 T1), where the tab grows a line\n // rather than cut the words. The whole text stays the tab's name.\n <span className=\"flex min-w-0 flex-col items-start text-start\">\n <span className=\"max-w-full truncate-until-large\">{tab.label}</span>\n {/* A separator for the NAME only — whitespace between flex items is\n not drawn, and without it the two lines ran together into one\n word for a screen reader (\"80 km/hloop 2\"). */}{\" \"}\n <span className=\"max-w-full truncate-until-large text-caption font-normal leading-tight text-muted\">\n {tab.detail}\n </span>\n </span>\n ) : (\n tab.label\n )}\n {tab.badge != null ? (\n vertical ? <span className=\"ms-auto flex shrink-0 items-center\">{tab.badge}</span> : tab.badge\n ) : null}\n </span>\n );\n const element = tab.href ? (\n <a\n key={tab.id}\n {...shared}\n href={tab.href}\n draggable={false}\n onClick={(e) => {\n // Middle-, ⌘/Ctrl-, Shift- and Alt-click belong to the browser\n // (feedback #451); only the plain left click is ours to cancel and\n // route through `onChange`, which keeps the switch client-side.\n if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n e.preventDefault();\n onChange(tab.id);\n }}\n // A <button> tab activated on Space; an anchor does not, so becoming a\n // link (feedback #451) would have quietly cost keyboard users the tab\n // strip. Enter is deliberately untouched — it is the anchor's own\n // default and already arrives at `onChange` through the click handler\n // above, so handling it here too would switch tabs twice. Same three\n // lines the mobile row card carries, for the same reason; the rejected\n // alternative — keeping a button and faking the middle click with\n // window.open — is what the whole item is moving away from.\n //\n // Composed with the strip's arrow-key walk rather than replacing it: this\n // prop overrides the one in `shared`, and an anchor tab that lost the\n // arrows would be a strip that is navigable only where it is not a link.\n onKeyDown={(e) => {\n onTabKeyDown(e, tab);\n if (e.defaultPrevented || e.key !== \" \") return;\n e.preventDefault();\n onChange(tab.id);\n }}\n >\n {inner}\n </a>\n ) : (\n <button key={tab.id} {...shared} onClick={() => onChange(tab.id)}>\n {inner}\n </button>\n );\n if (!removable) return element;\n return (\n // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and\n // a tab's children are presentational, so a reader would flatten it into the\n // tab's name. It sits beside the tab, over the gutter the tab reserves.\n <div key={tab.id} className={cn(\"relative flex max-w-full\", !vertical && \"shrink-0\")}>\n {element}\n {isActive && (\n <IconButton\n size=\"2xs\"\n tone=\"danger\"\n // The POINTER's ×, hidden from assistive tech (round 0.34, the showcase's\n // axe check): a tablist may own tabs only, and this button sat in it. The\n // named \"Remove <tab>\" a screen reader's cursor and a touch reader's swipe\n // reach is `removeButton`, after the strip. Not a tab stop either way:\n // the strip is one, and Delete is the keyboard's path.\n aria-hidden\n data-tab-remove=\"\"\n tabIndex={-1}\n type=\"button\"\n disabled={busy}\n onClick={() => requestRemove(tab.id)}\n className=\"absolute inset-y-0 end-1 my-auto\"\n >\n <X />\n </IconButton>\n )}\n </div>\n );\n })}\n </div>\n );\n // The open tab's remove action as assistive tech meets it: right after the strip,\n // OUTSIDE the tablist, which may own tabs only (round 0.34, the showcase's axe check).\n // Visually hidden, since the × drawn on the tab is the pointer's, and not a tab stop,\n // since Delete is the keyboard's. `sr-only-fixed`: it sits wherever the caller put the\n // strip, with no positioned box of the kit's around it.\n const activeTab = tabs.find((tab) => tab.id === active);\n const removeButton =\n activeTab && isRemovable(activeTab) ? (\n <button\n ref={removeRef}\n type=\"button\"\n tabIndex={-1}\n disabled={busy}\n onClick={() => requestRemove(activeTab.id)}\n className=\"sr-only-fixed\"\n >\n {text.remove(nameOf(activeTab))}\n </button>\n ) : null;\n if (!onAdd && overflowCue && !vertical) {\n // The caret needs a row to sit in beside the strip, and `className` goes there — it\n // is the box a caller's margin or width is meant for, as with an add button. The box\n // is there whether or not the strip overflows right now, so a resize never moves\n // the caller's class from one element to another.\n return (\n <div className={cn(\"flex items-end\", className)}>\n {tablist}\n {removeButton}\n {cueButton}\n </div>\n );\n }\n if (!onAdd) {\n // No outer box: the strip stays the root, as callers' `className` and tests expect.\n return removeButton ? (\n <>\n {tablist}\n {removeButton}\n </>\n ) : (\n tablist\n );\n }\n if (vertical) {\n // Under the column, not beside it, and with no rule to carry on: a side nav has none.\n return (\n <div className={cn(\"flex flex-col gap-0.5\", className)}>\n {tablist}\n {removeButton}\n <button type=\"button\" onClick={onAdd} disabled={busy} className={TAB_ADD_CLASSES}>\n <Plus aria-hidden />\n {addLabel ?? text.add}\n </button>\n </div>\n );\n }\n return (\n <div className={cn(\"flex items-end\", className)}>\n {tablist}\n {removeButton}\n {cueButton}\n {/* Outside the tablist: an action, not a tab — and a tablist may own only\n tabs. The rule under the strip carries on beneath it. */}\n <div\n className=\"flex flex-1 items-center self-stretch border-b border-subtle\"\n >\n <button\n type=\"button\"\n onClick={onAdd}\n disabled={busy}\n className={cn(TAB_ADD_CLASSES, TAB_ADD_PHONE_CLASSES)}\n >\n <Plus aria-hidden />\n {/* Below md a scrolling strip keeps the room for tabs: the label is visually\n hidden and the \"+\" carries it as its name. Beside \"+ Add loop\" a 390px strip\n showed the open tab and a sliver of the next (showcase audit). */}\n <span className=\"max-md:sr-only\">{addLabel ?? text.add}</span>\n </button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/tabs.tsx"],"sourcesContent":["import { useLayoutEffect, useMemo, useRef } from \"react\";\nimport { ChevronLeft, ChevronRight, Plus, X } from \"lucide-react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { scrollIntoStrip, useStripFade } from \"../lib/strip-fade\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { prefersReducedMotion } from \"../lib/reduced-motion\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { mergeRefs } from \"./field-parts\";\nimport { DEFAULT_TABS_LABELS } from \"./ui-labels\";\nimport { IconButton } from \"./icon-button\";\n\n/** One tab of a {@link Tabs} strip. */\nexport interface TabItem<T extends string> {\n id: T;\n /** A ReactNode so a tab can pair an icon with text. */\n label: ReactNode;\n /** Optional trailing node (e.g. a count pill). */\n badge?: ReactNode;\n /** Marks a tab that IS a route — see {@link TabsProps}. */\n href?: string;\n /** Leading decoration: an icon, or the colour swatches of the lines this tab\n * draws in a chart above the strip. Hidden from assistive tech — it repeats\n * what the label says, or it says something the label must say instead. */\n icon?: ReactNode;\n /** A second, smaller line under the label — the one fact that tells two tabs\n * with similar labels apart (\"80 km/h\", \"Level 3\"). Part of the tab's name. */\n detail?: ReactNode;\n /** A tab that exists as a slot with nothing in it yet, drawn with a dashed\n * outline so a gap in a sequence looks like a gap. Visual only: say \"empty\" in\n * the label or `detail` if a screen reader needs to know. */\n empty?: boolean;\n /** `false` keeps this one tab when the strip has `onRemove` — the one sheet a\n * workbook cannot be without. Default: removable. */\n removable?: boolean;\n /** Plain-text name for the labels the strip composes about this tab (\"Remove\n * …\"), for when `label` is not a string. Default: `label` if it is a string,\n * else the id. */\n name?: string;\n}\n\n/** The strings the strip renders on its own behalf — only when it can add or\n * remove tabs. */\nexport interface TabsLabels {\n /** The \"add\" button's visible text when the caller gives no `addLabel`. */\n add: string;\n /** Accessible name of a tab's × — \"Remove <tab>\". */\n remove: (tab: string) => string;\n /** The `overflowCue` › over the strip's end, while tabs lie past it: \"More tabs\". */\n more: string;\n /** The `overflowCue` ‹ over the strip's start, while tabs lie before it: \"Previous tabs\". */\n previous: string;\n /**\n * @deprecated 0.47: not drawn any more — the caret that turned into \"back to the\n * first tab\" at the strip's end is now a ‹ of its own (`previous`). Accepted and\n * ignored; removed in 0.48.\n */\n backToFirst?: string;\n}\n\n// Defined in ./ui-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_TABS_LABELS };\n\n/**\n * The strip's own props sit on a `<div>`: the tablist IS the root element, so anything\n * a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an\n * `aria-describedby` — lands there. `onChange` is omitted from the div's props because\n * this component's `onChange` hands over the chosen TAB ID, not a DOM event, and\n * `children` because the strip renders `tabs` — a caller who wants a PANEL wires it up\n * through `panelId`, which is the whole point of that prop.\n */\nexport interface TabsProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n /** `label` is a ReactNode so tabs can pair an icon with text; `badge` is an\n * optional trailing node (e.g. a count pill). `href` marks a tab that IS a route:\n * it renders as an anchor so it can be middle-/⌘-clicked into a new tab (Keksdose\n * feedback #451), while a plain click still goes through `onChange` and stays\n * client-side. Tabs that only flip local state leave it unset — a link to a URL\n * that does not select the tab would be worse than no link. */\n tabs: TabItem<T>[];\n active: T;\n onChange: (id: T) => void;\n className?: string;\n /**\n * Carets over the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose\n * live #410 round 4): a › over the strip's end while tabs lie past it, and a ‹ over\n * its start while tabs lie before it — each there only while something is hidden\n * past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three\n * quarters of the strip's width toward its own side. Neither ever changes direction:\n * until 0.46 one caret turned round into \"back to the first\" at the end, and read as\n * an arrow pointing the wrong way. Start and end follow the reading direction.\n *\n * The edge fade alone could read as the end of the row: the last visible tab can\n * finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).\n * The carets sit OVER the strip's ends (positioned, on the page's surface), not beside\n * it, so the strip's width never changes as they come and go. Out of the tab order —\n * the keyboard walks the strip with the arrow keys, which bring the focused tab into\n * view. At every width. Ignored on a vertical strip (a side nav is a column, not a\n * row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is\n * the reference this replaces.\n */\n overflowCue?: boolean;\n /**\n * `id` of the element the caller renders the open tab's content into — the missing\n * half of `role=\"tab\"`. A tab that controls nothing is a tab in name only: a screen\n * reader announces \"tab, 2 of 5\" and then has no way to take the user to what it\n * opened, and no way back.\n *\n * **This component does not render the panel, on purpose.** `Tabs` is the STRIP; the\n * content lives wherever the caller put it — three Keksdose pages render it in a\n * sibling `<Card>`, one renders it through a router outlet, and a tab can BE a route\n * (`href`), in which case the panel is a whole page this component never sees.\n * Wrapping `children` here would mean either moving that content into the strip's\n * subtree — a layout change on every page that uses tabs — or shipping a wrapper\n * that only some callers could use. So the two halves are joined by an id instead.\n *\n * Wire the other end yourself:\n *\n * ```tsx\n * <Tabs tabs={tabs} active={active} onChange={setActive} panelId=\"report-panel\" />\n * <div id=\"report-panel\" role=\"tabpanel\" aria-labelledby=\"report-panel-tab\" tabIndex={-1}>\n * ```\n *\n * `aria-controls` goes on the OPEN tab only, and `${panelId}-tab` is its id — the\n * closed tabs' panels are not in the document, and a dangling `aria-controls`\n * describes a tab as opening something that is not there.\n */\n panelId?: string;\n /**\n * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab\n * (only on the open one with `removeOn=\"active\"`).\n *\n * The × is on the open tab only — a row of tabs each carrying one is a row of\n * things to hit by accident, and the tab you can remove should be the one you\n * are looking at — but its room is reserved on every removable tab, so opening a\n * tab never widens it and never pushes the rest of the strip along (lenkbank\n * feedback #72). It is not a tab stop of its own: the strip stays one stop, and the\n * keyboard path is Delete (announced through `aria-keyshortcuts`), which removes\n * whichever tab has focus (see `removeOn`).\n *\n * Assistive tech meets the remove as a \"Remove <tab>\" button right AFTER the strip,\n * not as the × (0.34): a tablist may own tabs only, so the drawn × is the pointer's\n * and `aria-hidden`, and the named button beside the tablist is visually hidden.\n *\n * The strip only ASKS: it calls this, and the tab goes when the caller drops it\n * from `tabs` — after a confirmation, a request, whatever it needs. When it does,\n * focus that was on the removed tab (or its ×) moves to the open tab if the\n * removed one was open, else to its neighbour, instead of falling to the page.\n */\n onRemove?: (id: T) => void;\n /**\n * Which tabs the Delete key removes.\n *\n * - `\"every\"` (default): any focused removable tab — the ARIA pattern's reading.\n * - `\"active\"`: only the OPEN tab, the same one that wears the ×. For a strip whose\n * remove is immediate and unconfirmed (lenkbank's sheet tabs delete a measurement\n * sheet at once), so arrowing across the strip and pressing Delete cannot take\n * out a sheet the user is not looking at. Delete on a closed tab does nothing,\n * and only the open tab announces the shortcut (`aria-keyshortcuts`).\n *\n * Either way the × is drawn on the open tab only and its room stays reserved on\n * every removable tab, so the strip never relayouts when the selection moves.\n */\n removeOn?: \"every\" | \"active\";\n /** Renders an \"add\" button after the last tab, outside the tablist — it is an\n * action, not a tab, and a tablist may own only tabs. */\n onAdd?: () => void;\n /** The add button's text. Say what is added — \"Add loop\", \"Add report\" says\n * more than \"Add\". Default: `tabs.add` from the {@link UiKitProvider}. */\n addLabel?: string;\n /** Overrides for this strip's strings. See {@link TabsLabels}. */\n labels?: LabelOverride<TabsLabels>;\n /** An add or a remove is in flight: the × and the add button are disabled and\n * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */\n busy?: boolean;\n /**\n * `\"vertical\"`: the same strip as a SIDE NAV — one item per row, the open one\n * filled, ↑/↓ walking it (Home/End as before) and `aria-orientation=\"vertical\"` on\n * the tablist. For keksdose's admin page and settings page, which each hand-built\n * the identical sidebar with none of this strip's keyboard, roving tab stop or\n * routed-`href` handling. Everything else — `href` tabs, `badge`, `panelId`,\n * `onRemove`, `onAdd` — means exactly what it means on the horizontal strip; the\n * badge moves to the row's end.\n *\n * **On a phone (`usePhoneLayout()`) it becomes the horizontal strip**, rather than\n * staying a column. A side nav only works beside its content; on\n * a phone it has to go ABOVE it, and eight full-width rows there push the panel\n * the user picked below the fold on every visit — the two keksdose pages both\n * collapsed theirs into a scrolling row by hand for exactly that reason. The\n * orientation is switched in JavaScript, not with `md:` classes, so the\n * `aria-orientation` a screen reader hears and the arrow keys that work always\n * match the layout on screen. The caller's own two-column layout has to stack at\n * the same breakpoint (`md:grid-cols-[14rem_1fr]`).\n */\n orientation?: \"horizontal\" | \"vertical\";\n /** The `role=\"tablist\"` strip, where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10). Merged with the strip's own ref, which scrolls the open tab into\n * view and measures the fade, so neither loses the element. */\n ref?: Ref<HTMLDivElement>;\n}\n\nconst TABLIST_CLASSES =\n \"flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle\";\n// The side nav. A column of full-width rows with the open one FILLED rather than\n// underlined: an underline under one row of a list reads as a separator, not as a\n// selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the\n// admin page's nav and the app's own sidebar mark \"you are here\" the same way.\nconst TABLIST_VERTICAL_CLASSES = \"flex flex-col gap-0.5\";\nconst TAB_VERTICAL_CLASSES =\n FOCUS_RING_WIDTH +\n \" flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus-visible:ring-strong\";\nconst TAB_VERTICAL_ACTIVE_CLASSES = \"bg-active text-primary\";\nconst TAB_VERTICAL_INACTIVE_CLASSES =\n \"text-muted hover:bg-hover hover:text-primary\";\n\n// `max-w-full`: no tab outgrows its strip. A sheet's `detail` line (\"Spurstangenkraft\n// links · … · 31 Punkte\") made one tab 448px wide in a 390px strip, and a tab wider\n// than the strip cannot be scrolled into view — its end was simply off screen\n// (lenkbank L3). The label and detail truncate inside it instead; see `inner`.\nconst TAB_CLASSES =\n FOCUS_RING_WIDTH +\n \" max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus-visible:ring-strong\";\nconst TAB_ACTIVE_CLASSES = \"border-[var(--text-primary)] text-primary\";\nconst TAB_INACTIVE_CLASSES =\n \"border-transparent text-muted hover:text-secondary hover:border-strong\";\n\n// The add/remove chrome. The × reserves its gutter on every removable tab (see\n// `onRemove`).\nconst TAB_REMOVABLE_CLASSES = \"pe-8\";\nconst TAB_EMPTY_CLASSES = \"-mx-1.5 rounded border border-dashed border-strong px-1.5\";\nconst TAB_ADD_CLASSES =\n FOCUS_RING_WIDTH +\n \" inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none focus-visible:ring-strong disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4\";\n// `relative`: the phone's visually hidden label needs a positioned ancestor (see the\n// sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone \"+\".\nconst TAB_ADD_PHONE_CLASSES = \"relative max-md:px-2\";\n\nexport function Tabs<T extends string>({\n tabs,\n active,\n onChange,\n className,\n overflowCue = false,\n panelId,\n onRemove,\n removeOn = \"every\",\n onAdd,\n addLabel,\n labels,\n busy = false,\n orientation = \"horizontal\",\n \"aria-label\": ariaLabel,\n ref,\n ...rest\n}: TabsProps<T>) {\n const text = useKitLabels(\"tabs\", DEFAULT_TABS_LABELS, labels);\n // See `orientation`: a vertical strip is the horizontal one on a phone.\n const phone = usePhoneLayout();\n const vertical = orientation === \"vertical\" && !phone;\n const stripRef = useRef<HTMLDivElement>(null);\n const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);\n const fade = useStripFade(stripRef);\n // See `overflowCue`: a ‹ over the start while tabs lie before it, a › over the end\n // while tabs lie past it — `fade.overflow` names the cut ends in reading-direction terms.\n const cue = overflowCue && !vertical;\n const cueStart = cue && (fade.overflow === \"start\" || fade.overflow === \"both\");\n const cueEnd = cue && (fade.overflow === \"end\" || fade.overflow === \"both\");\n // Three quarters of the visible width toward `side`; an RTL strip runs leftward.\n const onCue = (side: \"start\" | \"end\") => {\n const strip = stripRef.current;\n if (!strip) return;\n const behavior: ScrollBehavior = prefersReducedMotion() ? \"auto\" : \"smooth\";\n const rtl = getComputedStyle(strip).direction === \"rtl\";\n const toward = (side === \"end\" ? 1 : -1) * (rtl ? -1 : 1);\n strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });\n };\n // OVER the strip's end, on the page's surface, so the caret reads as a control and not\n // as one more faded tab — and so it can come and go without the strip changing width\n // under the thumb. The position sits on a wrapper of ours: IconButton puts its\n // tooltip's span around the button, and a class on the button would land inside it.\n const cueAt = (side: \"start\" | \"end\") => (\n <div\n data-tabs-overflow-cue={side}\n className={cn(\"absolute top-1/2 z-10 flex -translate-y-1/2\", side === \"start\" ? \"start-0\" : \"end-0\")}\n >\n <IconButton\n size=\"sm\"\n variant=\"secondary\"\n className=\"bg-surface shadow-sm\"\n tabIndex={-1}\n type=\"button\"\n label={side === \"start\" ? text.previous : text.more}\n labelVisible={false}\n onClick={() => onCue(side)}\n >\n {side === \"start\" ? <ChevronLeft className=\"rtl:-scale-x-100\" /> : <ChevronRight className=\"rtl:-scale-x-100\" />}\n </IconButton>\n </div>\n );\n // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a\n // deep link to the seventh report. A selection behind the cut edge was the worst\n // case of the overflow above: the one tab the panel below belongs to, sliced to\n // \"80 km,\" under the add button. Only the strip scrolls (`scrollBy` on it, not\n // `scrollIntoView`, which would scroll the page as well), and only as far as needed\n // to clear the fade.\n useLayoutEffect(() => {\n const strip = stripRef.current;\n const tab = strip?.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]');\n if (strip && tab) scrollIntoStrip(strip, tab);\n }, [active]);\n const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;\n // Whether Delete reaches this tab: every removable tab, or only the open one.\n const deletesOnKey = (tab: TabItem<T>) =>\n isRemovable(tab) && (removeOn === \"every\" || tab.id === active);\n const nameOf = (tab: TabItem<T>) =>\n tab.name ?? (typeof tab.label === \"string\" ? tab.label : tab.id);\n\n // Where focus goes once a removal the strip asked for has happened. The caller\n // owns `tabs` and may take its time (a confirmation, a request), so this waits\n // for the id to actually leave the list rather than guessing at the moment.\n const pendingFocus = useRef<{ removed: T; wasActive: boolean; neighbour?: T } | null>(null);\n const removeRef = useRef<HTMLButtonElement>(null);\n const requestRemove = (id: T) => {\n const index = tabs.findIndex((t) => t.id === id);\n pendingFocus.current = {\n removed: id,\n wasActive: id === active,\n neighbour: (tabs[index + 1] ?? tabs[index - 1])?.id,\n };\n onRemove?.(id);\n };\n useLayoutEffect(() => {\n const pending = pendingFocus.current;\n if (!pending || tabs.some((t) => t.id === pending.removed)) return;\n pendingFocus.current = null;\n const strip = stripRef.current;\n if (!strip) return;\n // Only focus that fell out of the strip with the removed tab is ours to place —\n // or that sits on the remove button after it, which a pick removed through.\n // If the user has moved on — into the panel, into a dialog — leave it there.\n const focused = document.activeElement;\n if (focused && focused !== document.body && !strip.contains(focused) && focused !== removeRef.current) return;\n // The removed tab was open: focus follows the caller's new selection, which is\n // also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.\n const target =\n !pending.wasActive && tabs.some((t) => t.id === pending.neighbour)\n ? pending.neighbour\n : active;\n const index = tabs.findIndex((t) => t.id === target);\n strip.querySelectorAll<HTMLElement>('[role=\"tab\"]')[index]?.focus();\n }, [tabs, active]);\n\n // Arrow keys walk the strip in DOM order (ARIA tabs pattern). Home/End jump\n // to the ends. Focus only — activation stays on click/Enter/Space, because a tab\n // here can be a real route and moving focus must not navigate.\n //\n // On the TABS, not on the tablist. The container carried it, which left a `<div>`\n // wearing an interactive role and a key handler while being unfocusable —\n // `jsx-a11y/interactive-supports-focus`, and the only instance of it left in `src/`.\n // The fix that rule wants is `tabIndex` on the div; the fix the pattern wants is\n // the handler on the elements that are natively focusable and actually receive the\n // keystroke. Those are the tabs, so the warning goes away by being right rather\n // than by being satisfied.\n const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {\n // Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable\n // tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it\n // rather than letting it fall through to the page. With `removeOn=\"active\"` only\n // the open tab answers; a closed one lets the key pass untouched.\n if (e.key === \"Delete\" && deletesOnKey(tab)) {\n e.preventDefault();\n if (!busy) requestRemove(tab.id);\n return;\n }\n // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits\n // to the left, and ArrowLeft has to reach it. A vertical strip answers ↑/↓ instead\n // — and ONLY those, as the pattern has it: ←/→ are left to the page, where a side\n // nav's neighbour (the panel) may well want them.\n const step: 1 | -1 | 0 = vertical\n ? e.key === \"ArrowDown\"\n ? 1\n : e.key === \"ArrowUp\"\n ? -1\n : 0\n : horizontalStep(e.key, e.currentTarget);\n if (step === 0 && e.key !== \"Home\" && e.key !== \"End\") return;\n const strip = e.currentTarget.closest('[role=\"tablist\"]');\n if (!strip) return;\n const items = Array.from(strip.querySelectorAll<HTMLElement>('[role=\"tab\"]'));\n const from = items.indexOf(e.currentTarget);\n if (from === -1) return;\n e.preventDefault();\n const to =\n e.key === \"Home\"\n ? 0\n : e.key === \"End\"\n ? items.length - 1\n : (from + step + items.length) % items.length;\n items[to]?.focus();\n };\n const tablist = (\n <div\n // Before the role and the name: a caller's arbitrary attribute is welcome on the\n // strip, but a `role` or an `aria-label` arriving through a spread props object\n // must not be able to unmake the tablist the tabs below are registered against.\n {...rest}\n ref={setStrip}\n role=\"tablist\"\n // Names the GROUP — every tab carries its own text — so a strip under a visible\n // heading that already says it can leave it unset.\n aria-label={ariaLabel}\n // Only when vertical: `horizontal` is the tablist's implicit value, and the\n // attribute on every existing strip would be noise in every snapshot of them.\n aria-orientation={vertical ? \"vertical\" : undefined}\n // Merged over a caller's own style rather than replacing it; see useStripFade.\n // `data-overflow` names the cut ends in reading-direction terms, for a caller's\n // own affordance (a scroll button) and for tests, which cannot read the mask.\n style={fade.mask ? { ...rest.style, maskImage: fade.mask, WebkitMaskImage: fade.mask } : rest.style}\n data-overflow={fade.overflow}\n // With an add button the strip gains an outer box, and `className` goes there\n // — it is the box a caller's margin or width is meant for.\n className={cn(\n vertical ? TABLIST_VERTICAL_CLASSES : TABLIST_CLASSES,\n cue ? \"min-w-0 flex-1\" : onAdd ? \"min-w-0\" : className,\n )}\n >\n {tabs.map((tab) => {\n const isActive = tab.id === active;\n const removable = isRemovable(tab);\n // Written once and worn by either tag below, so a routed tab and a\n // state-only tab stay indistinguishable to the eye and to a screen reader.\n const shared = {\n role: \"tab\" as const,\n \"aria-selected\": isActive,\n // Roving tabindex: the whole strip is ONE stop in the page's tab order, and\n // it is the open tab. Ten report tabs otherwise cost ten Tab presses to step\n // over on the way to the table below them (live #262's strip is the extreme\n // case, but every strip in the app paid it).\n //\n // The stop follows the SELECTION, not the focus. Arrowing is a look around —\n // activation here is manual, because a tab can be a real route — so re-\n // pointing the stop at a report the user only arrowed past would hand the\n // strip back on the next visit in a state they never chose. The cost is that\n // Tab out and back returns to the open tab rather than to the last one\n // looked at; the ARIA pattern allows either, and this one needs no second\n // piece of state shadowing `active`.\n tabIndex: isActive ? 0 : -1,\n // Only the open tab: see `panelId`.\n \"aria-controls\": isActive ? panelId : undefined,\n id: isActive && panelId ? `${panelId}-tab` : undefined,\n \"aria-keyshortcuts\": deletesOnKey(tab) ? \"Delete\" : undefined,\n onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),\n className: vertical\n ? cn(\n TAB_VERTICAL_CLASSES,\n isActive ? TAB_VERTICAL_ACTIVE_CLASSES : TAB_VERTICAL_INACTIVE_CLASSES,\n removable && \"pe-8\",\n )\n : cn(\n TAB_CLASSES,\n isActive ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES,\n removable && TAB_REMOVABLE_CLASSES,\n ),\n };\n const inner = (\n <span\n className={cn(\n // In a side nav the row is the full width, so the label takes it and a\n // badge lands at the row's end, where a column of counts lines up.\n vertical ? \"flex min-w-0 flex-1 items-center gap-1.5\" : \"inline-flex max-w-full min-w-0 items-center gap-1.5\",\n tab.empty && TAB_EMPTY_CLASSES,\n )}\n >\n {tab.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 items-center gap-0.5\">\n {tab.icon}\n </span>\n )}\n {tab.detail != null ? (\n // `min-w-0` + `truncate-until-large` on each line: capped by the tab's\n // `max-w-full`, a long detail ends in an ellipsis at Normal and wraps from\n // Large up (text size §10.17; round 0.34 T1), where the tab grows a line\n // rather than cut the words. The whole text stays the tab's name.\n <span className=\"flex min-w-0 flex-col items-start text-start\">\n <span className=\"max-w-full truncate-until-large\">{tab.label}</span>\n {/* A separator for the NAME only — whitespace between flex items is\n not drawn, and without it the two lines ran together into one\n word for a screen reader (\"80 km/hloop 2\"). */}{\" \"}\n <span className=\"max-w-full truncate-until-large text-caption font-normal leading-tight text-muted\">\n {tab.detail}\n </span>\n </span>\n ) : (\n tab.label\n )}\n {tab.badge != null ? (\n vertical ? <span className=\"ms-auto flex shrink-0 items-center\">{tab.badge}</span> : tab.badge\n ) : null}\n </span>\n );\n const element = tab.href ? (\n <a\n key={tab.id}\n {...shared}\n href={tab.href}\n draggable={false}\n onClick={(e) => {\n // Middle-, ⌘/Ctrl-, Shift- and Alt-click belong to the browser\n // (feedback #451); only the plain left click is ours to cancel and\n // route through `onChange`, which keeps the switch client-side.\n if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n e.preventDefault();\n onChange(tab.id);\n }}\n // A <button> tab activated on Space; an anchor does not, so becoming a\n // link (feedback #451) would have quietly cost keyboard users the tab\n // strip. Enter is deliberately untouched — it is the anchor's own\n // default and already arrives at `onChange` through the click handler\n // above, so handling it here too would switch tabs twice. Same three\n // lines the mobile row card carries, for the same reason; the rejected\n // alternative — keeping a button and faking the middle click with\n // window.open — is what the whole item is moving away from.\n //\n // Composed with the strip's arrow-key walk rather than replacing it: this\n // prop overrides the one in `shared`, and an anchor tab that lost the\n // arrows would be a strip that is navigable only where it is not a link.\n onKeyDown={(e) => {\n onTabKeyDown(e, tab);\n if (e.defaultPrevented || e.key !== \" \") return;\n e.preventDefault();\n onChange(tab.id);\n }}\n >\n {inner}\n </a>\n ) : (\n <button key={tab.id} {...shared} onClick={() => onChange(tab.id)}>\n {inner}\n </button>\n );\n if (!removable) return element;\n return (\n // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and\n // a tab's children are presentational, so a reader would flatten it into the\n // tab's name. It sits beside the tab, over the gutter the tab reserves.\n <div key={tab.id} className={cn(\"relative flex max-w-full\", !vertical && \"shrink-0\")}>\n {element}\n {isActive && (\n <IconButton\n size=\"2xs\"\n tone=\"danger\"\n // The POINTER's ×, hidden from assistive tech (round 0.34, the showcase's\n // axe check): a tablist may own tabs only, and this button sat in it. The\n // named \"Remove <tab>\" a screen reader's cursor and a touch reader's swipe\n // reach is `removeButton`, after the strip. Not a tab stop either way:\n // the strip is one, and Delete is the keyboard's path.\n aria-hidden\n data-tab-remove=\"\"\n tabIndex={-1}\n type=\"button\"\n disabled={busy}\n onClick={() => requestRemove(tab.id)}\n className=\"absolute inset-y-0 end-1 my-auto\"\n >\n <X />\n </IconButton>\n )}\n </div>\n );\n })}\n </div>\n );\n // The open tab's remove action as assistive tech meets it: right after the strip,\n // OUTSIDE the tablist, which may own tabs only (round 0.34, the showcase's axe check).\n // Visually hidden, since the × drawn on the tab is the pointer's, and not a tab stop,\n // since Delete is the keyboard's. `sr-only-fixed`: it sits wherever the caller put the\n // strip, with no positioned box of the kit's around it.\n const activeTab = tabs.find((tab) => tab.id === active);\n const removeButton =\n activeTab && isRemovable(activeTab) ? (\n <button\n ref={removeRef}\n type=\"button\"\n tabIndex={-1}\n disabled={busy}\n onClick={() => requestRemove(activeTab.id)}\n className=\"sr-only-fixed\"\n >\n {text.remove(nameOf(activeTab))}\n </button>\n ) : null;\n // The positioned box the carets sit in, over the strip's two ends (see `overflowCue`).\n // There whether or not the strip overflows right now, so a resize never re-parents it.\n const stripBox = cue ? (\n <div className={cn(\"relative flex min-w-0\", !onAdd && \"flex-1\")}>\n {tablist}\n {cueStart && cueAt(\"start\")}\n {cueEnd && cueAt(\"end\")}\n </div>\n ) : (\n tablist\n );\n if (!onAdd && cue) {\n // `className` goes on the row around the strip — it is the box a caller's margin or\n // width is meant for, as with an add button — and stays there at every scroll position.\n return (\n <div className={cn(\"flex items-end\", className)}>\n {stripBox}\n {removeButton}\n </div>\n );\n }\n if (!onAdd) {\n // No outer box: the strip stays the root, as callers' `className` and tests expect.\n return removeButton ? (\n <>\n {tablist}\n {removeButton}\n </>\n ) : (\n tablist\n );\n }\n if (vertical) {\n // Under the column, not beside it, and with no rule to carry on: a side nav has none.\n return (\n <div className={cn(\"flex flex-col gap-0.5\", className)}>\n {tablist}\n {removeButton}\n <button type=\"button\" onClick={onAdd} disabled={busy} className={TAB_ADD_CLASSES}>\n <Plus aria-hidden />\n {addLabel ?? text.add}\n </button>\n </div>\n );\n }\n return (\n <div className={cn(\"flex items-end\", className)}>\n {stripBox}\n {removeButton}\n {/* Outside the tablist: an action, not a tab — and a tablist may own only\n tabs. The rule under the strip carries on beneath it. */}\n <div\n className=\"flex flex-1 items-center self-stretch border-b border-subtle\"\n >\n <button\n type=\"button\"\n onClick={onAdd}\n disabled={busy}\n className={cn(TAB_ADD_CLASSES, TAB_ADD_PHONE_CLASSES)}\n >\n <Plus aria-hidden />\n {/* Below md a scrolling strip keeps the room for tabs: the label is visually\n hidden and the \"+\" carries it as its name. Beside \"+ Add loop\" a 390px strip\n showed the open tab and a sliver of the next (showcase audit). */}\n <span className=\"max-md:sr-only\">{addLabel ?? text.add}</span>\n </button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -12,7 +12,7 @@ const DEFAULT_TABS_LABELS = {
12
12
  add: "Add tab",
13
13
  remove: (tab) => `Remove ${tab}`,
14
14
  more: "More tabs",
15
- backToFirst: "Back to the first tab"
15
+ previous: "Previous tabs"
16
16
  };
17
17
  export {
18
18
  DEFAULT_CHARACTER_COUNT_LABELS,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/ui-labels.ts"],"sourcesContent":["// The English words of ./ui, in a module with no component code: the English catalogue\n// (src/i18n/defaults.ts) imports them without the component, so `en` loads as one chunk\n// like the other languages (0.34, KS U8). ./ui re-exports them.\n\nimport type { CharacterCountLabels, PasswordRevealLabels } from \"./field-base\";\nimport type { TabsLabels } from \"./tabs\";\n\nexport const DEFAULT_PASSWORD_REVEAL_LABELS: PasswordRevealLabels = {\n show: \"Show password\",\n hide: \"Hide password\",\n};\n\nexport const DEFAULT_CHARACTER_COUNT_LABELS: CharacterCountLabels = {\n count: (used, max) => `${used} of ${max} characters`,\n remaining: (left) => (left === 1 ? \"1 character left\" : `${left} characters left`),\n limitReached: \"Character limit reached\",\n};\n\nexport const DEFAULT_TABS_LABELS: TabsLabels = {\n add: \"Add tab\",\n remove: (tab) => `Remove ${tab}`,\n more: \"More tabs\",\n backToFirst: \"Back to the first tab\",\n};\n"],"mappings":";AAOO,MAAM,iCAAuD;AAAA,EAClE,MAAM;AAAA,EACN,MAAM;AACR;AAEO,MAAM,iCAAuD;AAAA,EAClE,OAAO,CAAC,MAAM,QAAQ,GAAG,IAAI,OAAO,GAAG;AAAA,EACvC,WAAW,CAAC,SAAU,SAAS,IAAI,qBAAqB,GAAG,IAAI;AAAA,EAC/D,cAAc;AAChB;AAEO,MAAM,sBAAkC;AAAA,EAC7C,KAAK;AAAA,EACL,QAAQ,CAAC,QAAQ,UAAU,GAAG;AAAA,EAC9B,MAAM;AAAA,EACN,aAAa;AACf;","names":[]}
1
+ {"version":3,"sources":["../../src/components/ui-labels.ts"],"sourcesContent":["// The English words of ./ui, in a module with no component code: the English catalogue\n// (src/i18n/defaults.ts) imports them without the component, so `en` loads as one chunk\n// like the other languages (0.34, KS U8). ./ui re-exports them.\n\nimport type { CharacterCountLabels, PasswordRevealLabels } from \"./field-base\";\nimport type { TabsLabels } from \"./tabs\";\n\nexport const DEFAULT_PASSWORD_REVEAL_LABELS: PasswordRevealLabels = {\n show: \"Show password\",\n hide: \"Hide password\",\n};\n\nexport const DEFAULT_CHARACTER_COUNT_LABELS: CharacterCountLabels = {\n count: (used, max) => `${used} of ${max} characters`,\n remaining: (left) => (left === 1 ? \"1 character left\" : `${left} characters left`),\n limitReached: \"Character limit reached\",\n};\n\nexport const DEFAULT_TABS_LABELS: TabsLabels = {\n add: \"Add tab\",\n remove: (tab) => `Remove ${tab}`,\n more: \"More tabs\",\n previous: \"Previous tabs\",\n};\n"],"mappings":";AAOO,MAAM,iCAAuD;AAAA,EAClE,MAAM;AAAA,EACN,MAAM;AACR;AAEO,MAAM,iCAAuD;AAAA,EAClE,OAAO,CAAC,MAAM,QAAQ,GAAG,IAAI,OAAO,GAAG;AAAA,EACvC,WAAW,CAAC,SAAU,SAAS,IAAI,qBAAqB,GAAG,IAAI;AAAA,EAC/D,cAAc;AAChB;AAEO,MAAM,sBAAkC;AAAA,EAC7C,KAAK;AAAA,EACL,QAAQ,CAAC,QAAQ,UAAU,GAAG;AAAA,EAC9B,MAAM;AAAA,EACN,UAAU;AACZ;","names":[]}
@@ -47,7 +47,7 @@ const roleUtilities = {
47
47
  };
48
48
  }
49
49
  };
50
- const VERSION = true ? "0.46.1" : "0.0.0-source";
50
+ const VERSION = true ? "0.47.0" : "0.0.0-source";
51
51
  const recommended = {
52
52
  name: "@eifi1/ui-kit/recommended",
53
53
  // Not the tests, as check:tokens skips the kit's: a test names the old class to assert it
@@ -0,0 +1,19 @@
1
+ import type { AuthedFetcher } from "../hooks/use-authed-src.js";
2
+ /** No fetcher: the browser's own `fetch`, with the cookies a same-origin app uses. */
3
+ export declare const plainFeedbackFetch: AuthedFetcher;
4
+ /**
5
+ * A PDF or a text log (keksdose `FeedbackFileDownload`, dev #578): the bytes sit behind
6
+ * an authenticated route that serves them as `attachment`, never inline (§3.5), so a
7
+ * plain link would be a 401 the moment it was opened. Fetched through the app's
8
+ * fetcher and saved with {@link saveBlob}; a failure says so in a toast.
9
+ *
10
+ * Saved under the name the server gives it when the fetcher answers with a `Response`
11
+ * (server-kit's `content_disposition` puts the uploader's own name in `filename*`), and
12
+ * under the URL's last segment — the name on the button — when it answers with a bare
13
+ * `Blob`, which has no headers to ask. Words: `feedbackDetail.download` /
14
+ * `downloadFailed`.
15
+ */
16
+ export declare function FeedbackAttachmentDownload({ url, fetcher }: {
17
+ url: string;
18
+ fetcher: AuthedFetcher;
19
+ }): import("react").JSX.Element;
@@ -0,0 +1,59 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { Download, FileText } from "lucide-react";
5
+ import { Button } from "../components/button.js";
6
+ import { toast } from "../components/toast.js";
7
+ import { useKitLabels } from "../i18n/kit-labels.js";
8
+ import { filenameFromDisposition, saveBlob } from "../lib/download.js";
9
+ import { attachmentName } from "./feedback-record.js";
10
+ import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels.js";
11
+ async function toBlob(answer) {
12
+ if (typeof Response !== "undefined" && answer instanceof Response) {
13
+ if (!answer.ok) throw new Error(`HTTP ${answer.status}`);
14
+ return answer.blob();
15
+ }
16
+ return answer;
17
+ }
18
+ const plainFeedbackFetch = (url, { signal }) => fetch(url, { signal, credentials: "same-origin" });
19
+ function FeedbackAttachmentDownload({ url, fetcher }) {
20
+ const labels = useKitLabels("feedbackDetail", DEFAULT_FEEDBACK_DETAIL_LABELS);
21
+ const [busy, setBusy] = useState(false);
22
+ const name = attachmentName(url);
23
+ const download = async (event) => {
24
+ event.stopPropagation();
25
+ setBusy(true);
26
+ try {
27
+ const answer = await fetcher(url, { signal: new AbortController().signal });
28
+ const blob = await toBlob(answer);
29
+ const saveAs = typeof Response !== "undefined" && answer instanceof Response ? filenameFromDisposition(answer.headers.get("Content-Disposition"), name) : name;
30
+ saveBlob(blob, saveAs);
31
+ } catch {
32
+ toast.error(labels.downloadFailed);
33
+ } finally {
34
+ setBusy(false);
35
+ }
36
+ };
37
+ return /* @__PURE__ */ jsxs(
38
+ Button,
39
+ {
40
+ type: "button",
41
+ variant: "secondary",
42
+ size: "sm",
43
+ pending: busy,
44
+ onClick: (event) => void download(event),
45
+ "aria-label": labels.download(name),
46
+ className: "max-w-full",
47
+ children: [
48
+ /* @__PURE__ */ jsx(FileText, { className: "size-3.5 shrink-0", "aria-hidden": true }),
49
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 max-w-[14rem] truncate-until-large", children: name }),
50
+ /* @__PURE__ */ jsx(Download, { className: "size-3.5 shrink-0", "aria-hidden": true })
51
+ ]
52
+ }
53
+ );
54
+ }
55
+ export {
56
+ FeedbackAttachmentDownload,
57
+ plainFeedbackFetch
58
+ };
59
+ //# sourceMappingURL=feedback-attachment-download.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/feedback/feedback-attachment-download.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { MouseEvent } from \"react\";\nimport { Download, FileText } from \"lucide-react\";\nimport { Button } from \"../components/button\";\nimport { toast } from \"../components/toast\";\nimport type { AuthedFetcher } from \"../hooks/use-authed-src\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { filenameFromDisposition, saveBlob } from \"../lib/download\";\nimport { attachmentName } from \"./feedback-record\";\nimport { DEFAULT_FEEDBACK_DETAIL_LABELS } from \"./feedback-row-detail-labels\";\n\n// Internal (not in the `./feedback` entry): the row detail's download button and its\n// plain fetch, shared since 0.47 with ./feedback-entry-file's inline entry files.\n\n/** The bytes behind a fetcher's answer — a `Blob` as it is, a `Response` checked. */\nasync function toBlob(answer: Blob | Response): Promise<Blob> {\n if (typeof Response !== \"undefined\" && answer instanceof Response) {\n if (!answer.ok) throw new Error(`HTTP ${answer.status}`);\n return answer.blob();\n }\n return answer as Blob;\n}\n\n/** No fetcher: the browser's own `fetch`, with the cookies a same-origin app uses. */\nexport const plainFeedbackFetch: AuthedFetcher = (url, { signal }) =>\n fetch(url, { signal, credentials: \"same-origin\" });\n\n/**\n * A PDF or a text log (keksdose `FeedbackFileDownload`, dev #578): the bytes sit behind\n * an authenticated route that serves them as `attachment`, never inline (§3.5), so a\n * plain link would be a 401 the moment it was opened. Fetched through the app's\n * fetcher and saved with {@link saveBlob}; a failure says so in a toast.\n *\n * Saved under the name the server gives it when the fetcher answers with a `Response`\n * (server-kit's `content_disposition` puts the uploader's own name in `filename*`), and\n * under the URL's last segment — the name on the button — when it answers with a bare\n * `Blob`, which has no headers to ask. Words: `feedbackDetail.download` /\n * `downloadFailed`.\n */\nexport function FeedbackAttachmentDownload({ url, fetcher }: { url: string; fetcher: AuthedFetcher }) {\n const labels = useKitLabels(\"feedbackDetail\", DEFAULT_FEEDBACK_DETAIL_LABELS);\n const [busy, setBusy] = useState(false);\n const name = attachmentName(url);\n const download = async (event: MouseEvent) => {\n // The row around the detail may toggle on a click.\n event.stopPropagation();\n setBusy(true);\n try {\n const answer = await fetcher(url, { signal: new AbortController().signal });\n const blob = await toBlob(answer);\n const saveAs =\n typeof Response !== \"undefined\" && answer instanceof Response\n ? filenameFromDisposition(answer.headers.get(\"Content-Disposition\"), name)\n : name;\n saveBlob(blob, saveAs);\n } catch {\n toast.error(labels.downloadFailed);\n } finally {\n setBusy(false);\n }\n };\n return (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n pending={busy}\n onClick={(event) => void download(event)}\n aria-label={labels.download(name)}\n className=\"max-w-full\"\n >\n <FileText className=\"size-3.5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 max-w-[14rem] truncate-until-large\">{name}</span>\n <Download className=\"size-3.5 shrink-0\" aria-hidden />\n </Button>\n );\n}\n"],"mappings":";AA8DI,SASE,KATF;AA9DJ,SAAS,gBAAgB;AAEzB,SAAS,UAAU,gBAAgB;AACnC,SAAS,cAAc;AACvB,SAAS,aAAa;AAEtB,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB,gBAAgB;AAClD,SAAS,sBAAsB;AAC/B,SAAS,sCAAsC;AAM/C,eAAe,OAAO,QAAwC;AAC5D,MAAI,OAAO,aAAa,eAAe,kBAAkB,UAAU;AACjE,QAAI,CAAC,OAAO,GAAI,OAAM,IAAI,MAAM,QAAQ,OAAO,MAAM,EAAE;AACvD,WAAO,OAAO,KAAK;AAAA,EACrB;AACA,SAAO;AACT;AAGO,MAAM,qBAAoC,CAAC,KAAK,EAAE,OAAO,MAC9D,MAAM,KAAK,EAAE,QAAQ,aAAa,cAAc,CAAC;AAc5C,SAAS,2BAA2B,EAAE,KAAK,QAAQ,GAA4C;AACpG,QAAM,SAAS,aAAa,kBAAkB,8BAA8B;AAC5E,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,OAAO,eAAe,GAAG;AAC/B,QAAM,WAAW,OAAO,UAAsB;AAE5C,UAAM,gBAAgB;AACtB,YAAQ,IAAI;AACZ,QAAI;AACF,YAAM,SAAS,MAAM,QAAQ,KAAK,EAAE,QAAQ,IAAI,gBAAgB,EAAE,OAAO,CAAC;AAC1E,YAAM,OAAO,MAAM,OAAO,MAAM;AAChC,YAAM,SACJ,OAAO,aAAa,eAAe,kBAAkB,WACjD,wBAAwB,OAAO,QAAQ,IAAI,qBAAqB,GAAG,IAAI,IACvE;AACN,eAAS,MAAM,MAAM;AAAA,IACvB,QAAQ;AACN,YAAM,MAAM,OAAO,cAAc;AAAA,IACnC,UAAE;AACA,cAAQ,KAAK;AAAA,IACf;AAAA,EACF;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,SAAQ;AAAA,MACR,MAAK;AAAA,MACL,SAAS;AAAA,MACT,SAAS,CAAC,UAAU,KAAK,SAAS,KAAK;AAAA,MACvC,cAAY,OAAO,SAAS,IAAI;AAAA,MAChC,WAAU;AAAA,MAEV;AAAA,4BAAC,YAAS,WAAU,qBAAoB,eAAW,MAAC;AAAA,QACpD,oBAAC,UAAK,WAAU,8CAA8C,gBAAK;AAAA,QACnE,oBAAC,YAAS,WAAU,qBAAoB,eAAW,MAAC;AAAA;AAAA;AAAA,EACtD;AAEJ;","names":[]}
@@ -1,6 +1,8 @@
1
1
  import type { CommitScope } from "../components/write-lock.js";
2
+ import type { AuthedFetcher } from "../hooks/use-authed-src.js";
2
3
  import { type LabelOverride } from "../i18n/kit-labels.js";
3
4
  import type { FeedbackComment, FeedbackReply } from "./feedback-public-types.js";
5
+ import { type FeedbackEntryUploadOptions } from "./feedback-entry-file.js";
4
6
  import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels.js";
5
7
  /**
6
8
  * A report's conversation as everyone who may read it sees it
@@ -20,8 +22,13 @@ import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels
20
22
  * sees their text with that line; the admin sees it struck through, with **Unhide**.
21
23
  * An admin has **Hide** on every visible comment; the writer **Delete** on their own,
22
24
  * after a confirm (it is a hard delete). Nobody edits a comment.
23
- * - **The composer** (1–4 000 characters, no files) only with `onComment`: left out for
24
- * a reader who may not write (a demo, a CRASH report — the app knows).
25
+ * - **The composer** (1–4 000 characters) only with `onComment`: left out for a reader
26
+ * who may not write (a demo, a CRASH report — the app knows). Since 0.47 (§12) with
27
+ * `onUpload` it takes one file as the rework note does — pasted into the box,
28
+ * captured or picked — uploaded first, its URL `onComment`'s second argument.
29
+ * - **Files** (0.47, §12): a comment's or an entry's file under its text, a picture
30
+ * INLINE — a thumbnail through `fetcher` that opens full size — any other file a
31
+ * download ({@link FeedbackEntryFile}). A veiled comment shows none.
25
32
  *
26
33
  * Delete asks through `useConfirm`, so a thread with `onDelete` and a comment of the
27
34
  * viewer's needs the app's `ConfirmProvider` (each app mounts one already).
@@ -54,7 +61,7 @@ export interface FeedbackCommentsLabels {
54
61
  send: string;
55
62
  }
56
63
  export { DEFAULT_FEEDBACK_COMMENTS_LABELS };
57
- export interface FeedbackCommentThreadProps {
64
+ export interface FeedbackCommentThreadProps extends FeedbackEntryUploadOptions {
58
65
  /** The report's replies (§9.7), oldest first. Left out or `[]`: none.
59
66
  *
60
67
  * Inside `FeedbackRowDetail`'s `comments` they are dropped (0.38.1): the detail's
@@ -71,8 +78,12 @@ export interface FeedbackCommentThreadProps {
71
78
  /** `POST /feedback/{id}/comments`. Return the promise: the box clears when it
72
79
  * resolves, and keeps the draft when it rejects. A failure is the app's to toast —
73
80
  * the server's `detail`, else `feedbackToast.commentFailed` (0.38.1). Left out, no
74
- * composer. */
75
- onComment?: (body: string) => void | Promise<unknown>;
81
+ * composer. 0.47 (§12): `attachmentUrl` is the one file uploaded with it (with
82
+ * `onUpload`), `undefined` for none — `attachment_url` in the body (server-kit 0.18). */
83
+ onComment?: (body: string, attachmentUrl?: string) => void | Promise<unknown>;
84
+ /** 0.47: the app's authenticated GET (`AuthedImage`'s) for the files in the thread.
85
+ * Left out, the browser's own fetch with same-origin cookies. */
86
+ fetcher?: AuthedFetcher | null;
76
87
  /** `PATCH /feedback/{id}/comments/{comment_id} {hidden}` — the admin's. A rejection
77
88
  * is caught (0.38.1) and is the app's to toast (`feedbackToast.updateFailed`). Left
78
89
  * out, no Hide / Unhide. */
@@ -91,4 +102,4 @@ export interface FeedbackCommentThreadProps {
91
102
  labels?: LabelOverride<FeedbackCommentsLabels>;
92
103
  className?: string;
93
104
  }
94
- export declare function FeedbackCommentThread({ replies: repliesProp, comments, isAdmin, isAuthor, onComment, onHide, onDelete, pending, loading, commit, now, labels: labelsProp, className, }: FeedbackCommentThreadProps): import("react").JSX.Element;
105
+ export declare function FeedbackCommentThread({ replies: repliesProp, comments, isAdmin, isAuthor, onComment, onHide, onDelete, pending, loading, commit, now, fetcher, onUpload, onCaptureScreenshot, attachment, labels: labelsProp, className, }: FeedbackCommentThreadProps): import("react").JSX.Element;
@@ -8,6 +8,7 @@ import { useKitLabels } from "../i18n/kit-labels.js";
8
8
  import { FeedbackStatusBadge } from "./feedback-inbox.js";
9
9
  import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels.js";
10
10
  import { FEEDBACK_COMMENT_MAX, attachmentName } from "./feedback-record.js";
11
+ import { FeedbackEntryFile, useFeedbackEntryUpload } from "./feedback-entry-file.js";
11
12
  import { useFeedbackRepliesLabels } from "./feedback-replies.js";
12
13
  import { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context.js";
13
14
  import { FeedbackReworkEntryChip } from "./feedback-rework-entry-chip.js";
@@ -32,9 +33,18 @@ function FeedbackCommentThread({
32
33
  loading = false,
33
34
  commit,
34
35
  now,
36
+ fetcher,
37
+ onUpload,
38
+ onCaptureScreenshot,
39
+ attachment,
35
40
  labels: labelsProp,
36
41
  className
37
42
  }) {
43
+ const { composerAttachment, upload, uploading } = useFeedbackEntryUpload({
44
+ onUpload,
45
+ onCaptureScreenshot,
46
+ attachment
47
+ });
38
48
  const labels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS, labelsProp);
39
49
  const anonymous = useKitLabels("feedbackPublic", DEFAULT_FEEDBACK_PUBLIC_LABELS).anonymous;
40
50
  const replyLabels = useFeedbackRepliesLabels();
@@ -73,6 +83,8 @@ function FeedbackCommentThread({
73
83
  author: reply.author_name ?? replyLabels.admin,
74
84
  createdAt: reply.created_at,
75
85
  body: reply.body,
86
+ // 0.47 (§12): an admin reply's file, where the view carries it.
87
+ attachments: reply.attachment_url ? [{ name: attachmentName(reply.attachment_url), url: reply.attachment_url }] : void 0,
76
88
  staff: true,
77
89
  badge: reply.status_to ? /* @__PURE__ */ jsxs("span", { className: "inline-flex min-w-0 max-w-full flex-wrap items-center gap-1", "data-feedback-reply-status": reply.status_to, children: [
78
90
  /* @__PURE__ */ jsx("span", { className: "sr-only", children: replyLabels.statusChanged }),
@@ -126,6 +138,8 @@ function FeedbackCommentThread({
126
138
  author: veiled ? void 0 : comment.mine ? void 0 : comment.author_name ?? anonymous,
127
139
  createdAt: comment.created_at,
128
140
  body: veiled ? "" : comment.body ?? "",
141
+ // 0.47 (§12): its one file; none when veiled (the server sends none either).
142
+ attachments: !veiled && comment.attachment_url ? [{ name: attachmentName(comment.attachment_url), url: comment.attachment_url }] : void 0,
129
143
  staff: !veiled && comment.staff,
130
144
  struck: comment.hidden && isAdmin && !veiled,
131
145
  footer: actions.length > 0 ? actions : void 0
@@ -140,6 +154,7 @@ function FeedbackCommentThread({
140
154
  messages: entries.map((entry) => entry.message),
141
155
  loading,
142
156
  now,
157
+ renderAttachment: (file) => file.url ? /* @__PURE__ */ jsx(FeedbackEntryFile, { url: file.url, fetcher }) : file.name,
143
158
  labels: { thread: labels.comments, empty: labels.empty }
144
159
  }
145
160
  ),
@@ -147,8 +162,12 @@ function FeedbackCommentThread({
147
162
  ChatComposer,
148
163
  {
149
164
  className: "mt-3",
150
- onSend: (body) => onComment(body),
151
- pending,
165
+ onSend: async (body, file) => {
166
+ const url = await upload(file);
167
+ return url === void 0 ? onComment(body) : onComment(body, url);
168
+ },
169
+ pending: pending || uploading,
170
+ attachment: composerAttachment,
152
171
  commit,
153
172
  maxLength: FEEDBACK_COMMENT_MAX,
154
173
  showCount: true,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/feedback/feedback-comment-thread.tsx"],"sourcesContent":["import { useContext } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { EyeOff } from \"lucide-react\";\nimport { Button } from \"../components/button\";\nimport { useConfirm } from \"../components/confirm-dialog\";\nimport type { CommitScope } from \"../components/write-lock\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { FeedbackStatusBadge } from \"./feedback-inbox\";\nimport type { FeedbackComment, FeedbackReply } from \"./feedback-public-types\";\nimport { DEFAULT_FEEDBACK_PUBLIC_LABELS } from \"./feedback-public-labels\";\nimport { FEEDBACK_COMMENT_MAX, attachmentName } from \"./feedback-record\";\nimport { useFeedbackRepliesLabels } from \"./feedback-replies\";\nimport { FeedbackRepliesDrawnAboveContext } from \"./feedback-replies-context\";\nimport { FeedbackReworkEntryChip } from \"./feedback-rework-entry-chip\";\nimport { ChatComposer, FeedbackThread } from \"./feedback-thread\";\nimport type { FeedbackThreadMessage } from \"./feedback-thread\";\nimport { DEFAULT_FEEDBACK_COMMENTS_LABELS } from \"./feedback-thread-parts-labels\";\n\n/**\n * A report's conversation as everyone who may read it sees it\n * (docs/feedback-harmonization.md §9.8): the admin's replies and other people's\n * comments, each its own row, oldest first — merged by time, on the\n * {@link FeedbackThread} look — and the box to add one.\n *\n * - **Replies** are drawn as staff posts with the status they set (\"→ Done\"); a reply\n * without an author is \"Admin\" (§9.7).\n * - **Reworks** (0.40, §10.3, `kind: \"rework\"`) are the report's author's: the\n * **Rework** chip, \"→ status\" when the entry moved one (a migrated one did not), the\n * file when the view carries it; at the reading end for the author ({@link\n * FeedbackCommentThreadProps.isAuthor}), and a `null` name is \"A user\", never \"Admin\".\n * - **Comments**: the viewer's own at the reading end (\"You\"); someone else's under the\n * first name the server sent, `null` as \"A user\" (§9.5, `feedbackPublic.anonymous`).\n * - **Hidden** (§9.8): others see \"Hidden by an admin\" and no name, no text; the writer\n * sees their text with that line; the admin sees it struck through, with **Unhide**.\n * An admin has **Hide** on every visible comment; the writer **Delete** on their own,\n * after a confirm (it is a hard delete). Nobody edits a comment.\n * - **The composer** (1–4 000 characters, no files) only with `onComment`: left out for\n * a reader who may not write (a demo, a CRASH report — the app knows).\n *\n * Delete asks through `useConfirm`, so a thread with `onDelete` and a comment of the\n * viewer's needs the app's `ConfirmProvider` (each app mounts one already).\n */\n\n/* ── Labels ───────────────────────────────────────────────────────────────── */\n\n/** The `feedbackComments` namespace of `<UiKitProvider labels>`. A `null` name is\n * `feedbackPublic.anonymous`, the public pages' word, so the two say the same. */\nexport interface FeedbackCommentsLabels {\n /** The thread's accessible name. de-CH: \"Kommentare\" */\n comments: string;\n /** Nothing yet. de-CH: \"Noch keine Kommentare\" */\n empty: string;\n /** In place of a hidden comment. de-CH: \"Von einem Admin ausgeblendet\" */\n hidden: string;\n /** The admin's button. de-CH: \"Ausblenden\" */\n hide: string;\n /** The admin's button on a hidden comment. de-CH: \"Einblenden\" */\n unhide: string;\n /** The writer's button. de-CH: \"Löschen\" */\n delete: string;\n /** The confirm's question. de-CH: \"Diesen Kommentar löschen?\" */\n deleteTitle: string;\n /** The confirm's line. de-CH: \"Er wird für alle entfernt. Das kann nicht rückgängig\n * gemacht werden.\" */\n deleteBody: string;\n /** The box's accessible name. de-CH: \"Kommentar schreiben\" */\n field: string;\n /** de-CH: \"Kommentar schreiben…\" */\n placeholder: string;\n /** The send button. de-CH: \"Kommentieren\" */\n send: string;\n}\n\nexport { DEFAULT_FEEDBACK_COMMENTS_LABELS };\n\n/* ── The thread ───────────────────────────────────────────────────────────── */\n\nexport interface FeedbackCommentThreadProps {\n /** The report's replies (§9.7), oldest first. Left out or `[]`: none.\n *\n * Inside `FeedbackRowDetail`'s `comments` they are dropped (0.38.1): the detail's\n * Replies section draws them already, and the thread there is the comments alone —\n * so passing them there cannot draw them twice. */\n replies?: readonly FeedbackReply[];\n /** `GET /feedback/{id}/comments`, oldest first. */\n comments: readonly FeedbackComment[];\n /** The viewer is the app's admin: Hide / Unhide, the hidden text struck through. */\n isAdmin: boolean;\n /** 0.40: the viewer filed the report (the record's `mine`) — its rework entries are\n * theirs, drawn at the reading end as \"You\". Default `false`. */\n isAuthor?: boolean;\n /** `POST /feedback/{id}/comments`. Return the promise: the box clears when it\n * resolves, and keeps the draft when it rejects. A failure is the app's to toast —\n * the server's `detail`, else `feedbackToast.commentFailed` (0.38.1). Left out, no\n * composer. */\n onComment?: (body: string) => void | Promise<unknown>;\n /** `PATCH /feedback/{id}/comments/{comment_id} {hidden}` — the admin's. A rejection\n * is caught (0.38.1) and is the app's to toast (`feedbackToast.updateFailed`). Left\n * out, no Hide / Unhide. */\n onHide?: (comment: FeedbackComment, hidden: boolean) => void | Promise<unknown>;\n /** `DELETE /feedback/{id}/comments/{comment_id}` — the writer's own, after the\n * confirm. Left out, no Delete. */\n onDelete?: (comment: FeedbackComment) => void | Promise<unknown>;\n /** A comment is being sent. */\n pending?: boolean;\n /** The comments are loading. */\n loading?: boolean;\n /** Comment, Hide and Delete COMMIT — see Button's `commit`. */\n commit?: CommitScope;\n /** \"Now\" for the relative times — pin it in tests and stories. */\n now?: Date | number;\n labels?: LabelOverride<FeedbackCommentsLabels>;\n className?: string;\n}\n\ntype Entry = { at: number; order: number; message: FeedbackThreadMessage };\n\n/** The admin's Hide / Unhide as a promise — a sync throw or a `void` return included —\n * so the click can catch it as Delete's does. */\nconst hideComment = async (\n onHide: NonNullable<FeedbackCommentThreadProps[\"onHide\"]>,\n comment: FeedbackComment,\n) => {\n await onHide(comment, !comment.hidden);\n};\n\nconst instant = (iso: string) => {\n const ms = Date.parse(iso);\n return Number.isNaN(ms) ? 0 : ms;\n};\n\nexport function FeedbackCommentThread({\n replies: repliesProp = [],\n comments,\n isAdmin,\n isAuthor = false,\n onComment,\n onHide,\n onDelete,\n pending = false,\n loading = false,\n commit,\n now,\n labels: labelsProp,\n className,\n}: FeedbackCommentThreadProps) {\n const labels = useKitLabels(\"feedbackComments\", DEFAULT_FEEDBACK_COMMENTS_LABELS, labelsProp);\n const anonymous = useKitLabels(\"feedbackPublic\", DEFAULT_FEEDBACK_PUBLIC_LABELS).anonymous;\n const replyLabels = useFeedbackRepliesLabels();\n const repliesDrawnAbove = useContext(FeedbackRepliesDrawnAboveContext);\n const replies = repliesDrawnAbove ? [] : repliesProp;\n\n const entries: Entry[] = [];\n replies.forEach((reply, order) => {\n if (reply.kind === \"rework\") {\n entries.push({\n at: instant(reply.created_at),\n order,\n message: {\n id: `reply-${reply.id}`,\n own: isAuthor,\n author: isAuthor ? undefined : (reply.author_name ?? anonymous),\n createdAt: reply.created_at,\n body: reply.body,\n attachments: reply.attachment_url\n ? [{ name: attachmentName(reply.attachment_url), url: reply.attachment_url }]\n : undefined,\n badge: (\n <span className=\"inline-flex min-w-0 max-w-full flex-wrap items-center gap-1\" data-feedback-entry-kind=\"rework\">\n <FeedbackReworkEntryChip />\n {reply.status_to && (\n <span className=\"inline-flex items-center gap-1\" data-feedback-reply-status={reply.status_to}>\n <span className=\"sr-only\">{replyLabels.statusChanged}</span>\n <span aria-hidden>→</span>\n <FeedbackStatusBadge status={reply.status_to} />\n </span>\n )}\n </span>\n ),\n },\n });\n return;\n }\n entries.push({\n at: instant(reply.created_at),\n order,\n message: {\n id: `reply-${reply.id}`,\n author: reply.author_name ?? replyLabels.admin,\n createdAt: reply.created_at,\n body: reply.body,\n staff: true,\n badge: reply.status_to ? (\n <span className=\"inline-flex min-w-0 max-w-full flex-wrap items-center gap-1\" data-feedback-reply-status={reply.status_to}>\n <span className=\"sr-only\">{replyLabels.statusChanged}</span>\n <span aria-hidden>→</span>\n <FeedbackStatusBadge status={reply.status_to} />\n </span>\n ) : undefined,\n },\n });\n });\n comments.forEach((comment, index) => {\n // Shown as hidden — no name, no text — to anyone but its writer and an admin.\n const veiled = comment.hidden && !comment.mine && !isAdmin;\n const actions: ReactNode[] = [];\n if (comment.hidden) {\n actions.push(\n <span key=\"hidden\" className=\"inline-flex items-center gap-1\" data-feedback-comment-hidden=\"\">\n <EyeOff className=\"size-3\" aria-hidden />\n {labels.hidden}\n </span>,\n );\n }\n if (isAdmin && onHide) {\n actions.push(\n <Button\n key=\"hide\"\n type=\"button\"\n variant=\"link\"\n tone=\"muted\"\n size=\"sm\"\n commit={commit}\n // Caught as Delete's is (0.38.1): a rejection is the app's to toast — its\n // mutation's `onError` — and must not surface as an unhandled one.\n onClick={() => void hideComment(onHide, comment).catch(() => {})}\n >\n {comment.hidden ? labels.unhide : labels.hide}\n </Button>,\n );\n }\n if (comment.mine && onDelete) {\n actions.push(\n <DeleteCommentButton key=\"delete\" comment={comment} onDelete={onDelete} commit={commit} labels={labels} />,\n );\n }\n entries.push({\n at: instant(comment.created_at),\n // Replies first on a tie, then the comments in their order.\n order: replies.length + index,\n message: {\n id: `comment-${comment.id}`,\n own: comment.mine,\n author: veiled ? undefined : comment.mine ? undefined : (comment.author_name ?? anonymous),\n createdAt: comment.created_at,\n body: veiled ? \"\" : (comment.body ?? \"\"),\n staff: !veiled && comment.staff,\n struck: comment.hidden && isAdmin && !veiled,\n footer: actions.length > 0 ? actions : undefined,\n },\n });\n });\n entries.sort((a, b) => a.at - b.at || a.order - b.order);\n\n return (\n <div className={className} data-feedback-comment-thread=\"\">\n <FeedbackThread\n messages={entries.map((entry) => entry.message)}\n loading={loading}\n now={now}\n labels={{ thread: labels.comments, empty: labels.empty }}\n />\n {onComment && (\n <ChatComposer\n className=\"mt-3\"\n onSend={(body) => onComment(body)}\n pending={pending}\n commit={commit}\n maxLength={FEEDBACK_COMMENT_MAX}\n showCount\n labels={{ field: labels.field, placeholder: labels.placeholder, send: labels.send }}\n />\n )}\n </div>\n );\n}\n\n/** The writer's Delete: a confirm (danger tone, focus on Cancel), then `onDelete`. Its\n * own component, so `useConfirm` — which needs a `ConfirmProvider` — is only called\n * where there is something to delete. */\nfunction DeleteCommentButton({\n comment,\n onDelete,\n commit,\n labels,\n}: {\n comment: FeedbackComment;\n onDelete: (comment: FeedbackComment) => void | Promise<unknown>;\n commit?: CommitScope;\n labels: FeedbackCommentsLabels;\n}) {\n const confirm = useConfirm();\n const remove = async () => {\n const ok = await confirm({\n title: labels.deleteTitle,\n body: labels.deleteBody,\n confirmLabel: labels.delete,\n tone: \"danger\",\n commit,\n });\n if (ok) await onDelete(comment);\n };\n return (\n <Button\n type=\"button\"\n variant=\"link\"\n tone=\"danger\"\n size=\"sm\"\n commit={commit}\n onClick={() => void remove().catch(() => {})}\n >\n {labels.delete}\n </Button>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/feedback/feedback-comment-thread.tsx"],"sourcesContent":["import { useContext } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { EyeOff } from \"lucide-react\";\nimport { Button } from \"../components/button\";\nimport { useConfirm } from \"../components/confirm-dialog\";\nimport type { CommitScope } from \"../components/write-lock\";\nimport type { AuthedFetcher } from \"../hooks/use-authed-src\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { FeedbackStatusBadge } from \"./feedback-inbox\";\nimport type { FeedbackComment, FeedbackReply } from \"./feedback-public-types\";\nimport { DEFAULT_FEEDBACK_PUBLIC_LABELS } from \"./feedback-public-labels\";\nimport { FEEDBACK_COMMENT_MAX, attachmentName } from \"./feedback-record\";\nimport { FeedbackEntryFile, useFeedbackEntryUpload, type FeedbackEntryUploadOptions } from \"./feedback-entry-file\";\nimport { useFeedbackRepliesLabels } from \"./feedback-replies\";\nimport { FeedbackRepliesDrawnAboveContext } from \"./feedback-replies-context\";\nimport { FeedbackReworkEntryChip } from \"./feedback-rework-entry-chip\";\nimport { ChatComposer, FeedbackThread } from \"./feedback-thread\";\nimport type { FeedbackThreadMessage } from \"./feedback-thread\";\nimport { DEFAULT_FEEDBACK_COMMENTS_LABELS } from \"./feedback-thread-parts-labels\";\n\n/**\n * A report's conversation as everyone who may read it sees it\n * (docs/feedback-harmonization.md §9.8): the admin's replies and other people's\n * comments, each its own row, oldest first — merged by time, on the\n * {@link FeedbackThread} look — and the box to add one.\n *\n * - **Replies** are drawn as staff posts with the status they set (\"→ Done\"); a reply\n * without an author is \"Admin\" (§9.7).\n * - **Reworks** (0.40, §10.3, `kind: \"rework\"`) are the report's author's: the\n * **Rework** chip, \"→ status\" when the entry moved one (a migrated one did not), the\n * file when the view carries it; at the reading end for the author ({@link\n * FeedbackCommentThreadProps.isAuthor}), and a `null` name is \"A user\", never \"Admin\".\n * - **Comments**: the viewer's own at the reading end (\"You\"); someone else's under the\n * first name the server sent, `null` as \"A user\" (§9.5, `feedbackPublic.anonymous`).\n * - **Hidden** (§9.8): others see \"Hidden by an admin\" and no name, no text; the writer\n * sees their text with that line; the admin sees it struck through, with **Unhide**.\n * An admin has **Hide** on every visible comment; the writer **Delete** on their own,\n * after a confirm (it is a hard delete). Nobody edits a comment.\n * - **The composer** (1–4 000 characters) only with `onComment`: left out for a reader\n * who may not write (a demo, a CRASH report — the app knows). Since 0.47 (§12) with\n * `onUpload` it takes one file as the rework note does — pasted into the box,\n * captured or picked — uploaded first, its URL `onComment`'s second argument.\n * - **Files** (0.47, §12): a comment's or an entry's file under its text, a picture\n * INLINE — a thumbnail through `fetcher` that opens full size — any other file a\n * download ({@link FeedbackEntryFile}). A veiled comment shows none.\n *\n * Delete asks through `useConfirm`, so a thread with `onDelete` and a comment of the\n * viewer's needs the app's `ConfirmProvider` (each app mounts one already).\n */\n\n/* ── Labels ───────────────────────────────────────────────────────────────── */\n\n/** The `feedbackComments` namespace of `<UiKitProvider labels>`. A `null` name is\n * `feedbackPublic.anonymous`, the public pages' word, so the two say the same. */\nexport interface FeedbackCommentsLabels {\n /** The thread's accessible name. de-CH: \"Kommentare\" */\n comments: string;\n /** Nothing yet. de-CH: \"Noch keine Kommentare\" */\n empty: string;\n /** In place of a hidden comment. de-CH: \"Von einem Admin ausgeblendet\" */\n hidden: string;\n /** The admin's button. de-CH: \"Ausblenden\" */\n hide: string;\n /** The admin's button on a hidden comment. de-CH: \"Einblenden\" */\n unhide: string;\n /** The writer's button. de-CH: \"Löschen\" */\n delete: string;\n /** The confirm's question. de-CH: \"Diesen Kommentar löschen?\" */\n deleteTitle: string;\n /** The confirm's line. de-CH: \"Er wird für alle entfernt. Das kann nicht rückgängig\n * gemacht werden.\" */\n deleteBody: string;\n /** The box's accessible name. de-CH: \"Kommentar schreiben\" */\n field: string;\n /** de-CH: \"Kommentar schreiben…\" */\n placeholder: string;\n /** The send button. de-CH: \"Kommentieren\" */\n send: string;\n}\n\nexport { DEFAULT_FEEDBACK_COMMENTS_LABELS };\n\n/* ── The thread ───────────────────────────────────────────────────────────── */\n\nexport interface FeedbackCommentThreadProps extends FeedbackEntryUploadOptions {\n /** The report's replies (§9.7), oldest first. Left out or `[]`: none.\n *\n * Inside `FeedbackRowDetail`'s `comments` they are dropped (0.38.1): the detail's\n * Replies section draws them already, and the thread there is the comments alone —\n * so passing them there cannot draw them twice. */\n replies?: readonly FeedbackReply[];\n /** `GET /feedback/{id}/comments`, oldest first. */\n comments: readonly FeedbackComment[];\n /** The viewer is the app's admin: Hide / Unhide, the hidden text struck through. */\n isAdmin: boolean;\n /** 0.40: the viewer filed the report (the record's `mine`) — its rework entries are\n * theirs, drawn at the reading end as \"You\". Default `false`. */\n isAuthor?: boolean;\n /** `POST /feedback/{id}/comments`. Return the promise: the box clears when it\n * resolves, and keeps the draft when it rejects. A failure is the app's to toast —\n * the server's `detail`, else `feedbackToast.commentFailed` (0.38.1). Left out, no\n * composer. 0.47 (§12): `attachmentUrl` is the one file uploaded with it (with\n * `onUpload`), `undefined` for none — `attachment_url` in the body (server-kit 0.18). */\n onComment?: (body: string, attachmentUrl?: string) => void | Promise<unknown>;\n /** 0.47: the app's authenticated GET (`AuthedImage`'s) for the files in the thread.\n * Left out, the browser's own fetch with same-origin cookies. */\n fetcher?: AuthedFetcher | null;\n /** `PATCH /feedback/{id}/comments/{comment_id} {hidden}` — the admin's. A rejection\n * is caught (0.38.1) and is the app's to toast (`feedbackToast.updateFailed`). Left\n * out, no Hide / Unhide. */\n onHide?: (comment: FeedbackComment, hidden: boolean) => void | Promise<unknown>;\n /** `DELETE /feedback/{id}/comments/{comment_id}` — the writer's own, after the\n * confirm. Left out, no Delete. */\n onDelete?: (comment: FeedbackComment) => void | Promise<unknown>;\n /** A comment is being sent. */\n pending?: boolean;\n /** The comments are loading. */\n loading?: boolean;\n /** Comment, Hide and Delete COMMIT — see Button's `commit`. */\n commit?: CommitScope;\n /** \"Now\" for the relative times — pin it in tests and stories. */\n now?: Date | number;\n labels?: LabelOverride<FeedbackCommentsLabels>;\n className?: string;\n}\n\ntype Entry = { at: number; order: number; message: FeedbackThreadMessage };\n\n/** The admin's Hide / Unhide as a promise — a sync throw or a `void` return included —\n * so the click can catch it as Delete's does. */\nconst hideComment = async (\n onHide: NonNullable<FeedbackCommentThreadProps[\"onHide\"]>,\n comment: FeedbackComment,\n) => {\n await onHide(comment, !comment.hidden);\n};\n\nconst instant = (iso: string) => {\n const ms = Date.parse(iso);\n return Number.isNaN(ms) ? 0 : ms;\n};\n\nexport function FeedbackCommentThread({\n replies: repliesProp = [],\n comments,\n isAdmin,\n isAuthor = false,\n onComment,\n onHide,\n onDelete,\n pending = false,\n loading = false,\n commit,\n now,\n fetcher,\n onUpload,\n onCaptureScreenshot,\n attachment,\n labels: labelsProp,\n className,\n}: FeedbackCommentThreadProps) {\n const { composerAttachment, upload, uploading } = useFeedbackEntryUpload({\n onUpload,\n onCaptureScreenshot,\n attachment,\n });\n const labels = useKitLabels(\"feedbackComments\", DEFAULT_FEEDBACK_COMMENTS_LABELS, labelsProp);\n const anonymous = useKitLabels(\"feedbackPublic\", DEFAULT_FEEDBACK_PUBLIC_LABELS).anonymous;\n const replyLabels = useFeedbackRepliesLabels();\n const repliesDrawnAbove = useContext(FeedbackRepliesDrawnAboveContext);\n const replies = repliesDrawnAbove ? [] : repliesProp;\n\n const entries: Entry[] = [];\n replies.forEach((reply, order) => {\n if (reply.kind === \"rework\") {\n entries.push({\n at: instant(reply.created_at),\n order,\n message: {\n id: `reply-${reply.id}`,\n own: isAuthor,\n author: isAuthor ? undefined : (reply.author_name ?? anonymous),\n createdAt: reply.created_at,\n body: reply.body,\n attachments: reply.attachment_url\n ? [{ name: attachmentName(reply.attachment_url), url: reply.attachment_url }]\n : undefined,\n badge: (\n <span className=\"inline-flex min-w-0 max-w-full flex-wrap items-center gap-1\" data-feedback-entry-kind=\"rework\">\n <FeedbackReworkEntryChip />\n {reply.status_to && (\n <span className=\"inline-flex items-center gap-1\" data-feedback-reply-status={reply.status_to}>\n <span className=\"sr-only\">{replyLabels.statusChanged}</span>\n <span aria-hidden>→</span>\n <FeedbackStatusBadge status={reply.status_to} />\n </span>\n )}\n </span>\n ),\n },\n });\n return;\n }\n entries.push({\n at: instant(reply.created_at),\n order,\n message: {\n id: `reply-${reply.id}`,\n author: reply.author_name ?? replyLabels.admin,\n createdAt: reply.created_at,\n body: reply.body,\n // 0.47 (§12): an admin reply's file, where the view carries it.\n attachments: reply.attachment_url\n ? [{ name: attachmentName(reply.attachment_url), url: reply.attachment_url }]\n : undefined,\n staff: true,\n badge: reply.status_to ? (\n <span className=\"inline-flex min-w-0 max-w-full flex-wrap items-center gap-1\" data-feedback-reply-status={reply.status_to}>\n <span className=\"sr-only\">{replyLabels.statusChanged}</span>\n <span aria-hidden>→</span>\n <FeedbackStatusBadge status={reply.status_to} />\n </span>\n ) : undefined,\n },\n });\n });\n comments.forEach((comment, index) => {\n // Shown as hidden — no name, no text — to anyone but its writer and an admin.\n const veiled = comment.hidden && !comment.mine && !isAdmin;\n const actions: ReactNode[] = [];\n if (comment.hidden) {\n actions.push(\n <span key=\"hidden\" className=\"inline-flex items-center gap-1\" data-feedback-comment-hidden=\"\">\n <EyeOff className=\"size-3\" aria-hidden />\n {labels.hidden}\n </span>,\n );\n }\n if (isAdmin && onHide) {\n actions.push(\n <Button\n key=\"hide\"\n type=\"button\"\n variant=\"link\"\n tone=\"muted\"\n size=\"sm\"\n commit={commit}\n // Caught as Delete's is (0.38.1): a rejection is the app's to toast — its\n // mutation's `onError` — and must not surface as an unhandled one.\n onClick={() => void hideComment(onHide, comment).catch(() => {})}\n >\n {comment.hidden ? labels.unhide : labels.hide}\n </Button>,\n );\n }\n if (comment.mine && onDelete) {\n actions.push(\n <DeleteCommentButton key=\"delete\" comment={comment} onDelete={onDelete} commit={commit} labels={labels} />,\n );\n }\n entries.push({\n at: instant(comment.created_at),\n // Replies first on a tie, then the comments in their order.\n order: replies.length + index,\n message: {\n id: `comment-${comment.id}`,\n own: comment.mine,\n author: veiled ? undefined : comment.mine ? undefined : (comment.author_name ?? anonymous),\n createdAt: comment.created_at,\n body: veiled ? \"\" : (comment.body ?? \"\"),\n // 0.47 (§12): its one file; none when veiled (the server sends none either).\n attachments:\n !veiled && comment.attachment_url\n ? [{ name: attachmentName(comment.attachment_url), url: comment.attachment_url }]\n : undefined,\n staff: !veiled && comment.staff,\n struck: comment.hidden && isAdmin && !veiled,\n footer: actions.length > 0 ? actions : undefined,\n },\n });\n });\n entries.sort((a, b) => a.at - b.at || a.order - b.order);\n\n return (\n <div className={className} data-feedback-comment-thread=\"\">\n <FeedbackThread\n messages={entries.map((entry) => entry.message)}\n loading={loading}\n now={now}\n renderAttachment={(file) => (file.url ? <FeedbackEntryFile url={file.url} fetcher={fetcher} /> : file.name)}\n labels={{ thread: labels.comments, empty: labels.empty }}\n />\n {onComment && (\n <ChatComposer\n className=\"mt-3\"\n onSend={async (body, file) => {\n const url = await upload(file);\n // The second argument only with a file, so a text comment calls as before 0.47.\n return url === undefined ? onComment(body) : onComment(body, url);\n }}\n pending={pending || uploading}\n attachment={composerAttachment}\n commit={commit}\n maxLength={FEEDBACK_COMMENT_MAX}\n showCount\n labels={{ field: labels.field, placeholder: labels.placeholder, send: labels.send }}\n />\n )}\n </div>\n );\n}\n\n/** The writer's Delete: a confirm (danger tone, focus on Cancel), then `onDelete`. Its\n * own component, so `useConfirm` — which needs a `ConfirmProvider` — is only called\n * where there is something to delete. */\nfunction DeleteCommentButton({\n comment,\n onDelete,\n commit,\n labels,\n}: {\n comment: FeedbackComment;\n onDelete: (comment: FeedbackComment) => void | Promise<unknown>;\n commit?: CommitScope;\n labels: FeedbackCommentsLabels;\n}) {\n const confirm = useConfirm();\n const remove = async () => {\n const ok = await confirm({\n title: labels.deleteTitle,\n body: labels.deleteBody,\n confirmLabel: labels.delete,\n tone: \"danger\",\n commit,\n });\n if (ok) await onDelete(comment);\n };\n return (\n <Button\n type=\"button\"\n variant=\"link\"\n tone=\"danger\"\n size=\"sm\"\n commit={commit}\n onClick={() => void remove().catch(() => {})}\n >\n {labels.delete}\n </Button>\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,61 @@
1
+ import type { AuthedFetcher } from "../hooks/use-authed-src.js";
2
+ import type { FeedbackNoteAttachment } from "./feedback-inbox.js";
3
+ import type { FeedbackComposerAttachment } from "./feedback-thread.js";
4
+ /**
5
+ * One file of a conversation entry, and the upload behind an entry's composer
6
+ * (docs/feedback-harmonization.md §12, round 0.47; keksdose run 82 R1, live #447).
7
+ *
8
+ * - {@link FeedbackEntryFile}: a picture drawn INLINE — a thumbnail fetched through the
9
+ * app's authenticated GET, a tap opens it full size in a tab — and any other file a
10
+ * download button, as the row detail's Attachment section draws them. The reply
11
+ * timeline, the rework entries and the comment thread all draw their file with it.
12
+ * - {@link useFeedbackEntryUpload}: the rework note's one-file field (paste from the
13
+ * clipboard, capture, pick; the contract's types and 10 MB, §3.5) for a
14
+ * {@link ChatComposer}, and the upload that runs FIRST on send, so an entry never names
15
+ * a file that did not arrive.
16
+ */
17
+ export interface FeedbackEntryFileProps {
18
+ /** The entry's `attachment_url` — `/api/v1/feedback/attachments/<key>`. */
19
+ url: string;
20
+ /** The app's authenticated GET (`AuthedImage`'s): the attachment route wants the
21
+ * bearer token (§3.5). Left out, the browser's own fetch with same-origin cookies. */
22
+ fetcher?: AuthedFetcher | null;
23
+ className?: string;
24
+ }
25
+ /**
26
+ * An entry's one file (§12): a picture (`png`, `jpg`, `webp`, `gif` — by the key's
27
+ * extension, which the server minted from the sniffed bytes) as an inline thumbnail that
28
+ * opens full size in a new tab (`AuthedImage`'s `link`: the `blob:` URL, never the API
29
+ * path); anything else a download button. A picture that will not load falls back to the
30
+ * download button, so a broken file still says it is there. `alt`:
31
+ * `feedbackDetail.attachment`.
32
+ */
33
+ export declare function FeedbackEntryFile({ url, fetcher, className }: FeedbackEntryFileProps): import("react").JSX.Element;
34
+ /** `POST /feedback/attachments` — the file, answered with its URL. */
35
+ export type FeedbackEntryUpload = (file: File) => Promise<string>;
36
+ export interface FeedbackEntryUploadOptions {
37
+ /** The upload. Left out, the composer is text only. */
38
+ onUpload?: FeedbackEntryUpload;
39
+ /** The "Capture screenshot" button — the kit's `captureAppScreenshot` or the app's
40
+ * own. A rejection toasts `feedbackToast.captureFailed` and adds nothing. */
41
+ onCaptureScreenshot?: () => Promise<File | null>;
42
+ /** Over the field's defaults: the contract's types and 10 MB (§3.5), the
43
+ * `feedbackToast` refusals. The same object the rework section takes. */
44
+ attachment?: Partial<FeedbackNoteAttachment>;
45
+ }
46
+ export interface FeedbackEntryUploadState {
47
+ /** For {@link ChatComposer}'s `attachment`; `undefined` without `onUpload`. */
48
+ composerAttachment: FeedbackComposerAttachment | undefined;
49
+ /** Upload the picked file, if any: its URL, `undefined` for none. Rejects after a
50
+ * toast (`feedbackAttachment.attachmentUploadFailed`) when the upload fails — the
51
+ * send then stops and the composer keeps the draft and the file. */
52
+ upload: (file: File | null) => Promise<string | undefined>;
53
+ /** An upload is running — add it to the composer's `pending`. */
54
+ uploading: boolean;
55
+ }
56
+ /**
57
+ * The rework note's file field for a reply or a comment composer (§12): one file, added,
58
+ * captured or pasted from the clipboard into the box, the contract's types and 10 MB; and
59
+ * the upload, awaited before the entry is sent.
60
+ */
61
+ export declare function useFeedbackEntryUpload({ onUpload, onCaptureScreenshot, attachment, }: FeedbackEntryUploadOptions): FeedbackEntryUploadState;