@eifi1/ui-kit 0.45.0 → 0.46.1

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 (238) hide show
  1. package/README.md +26 -14
  2. package/dist/admin/admin-area-labels.d.ts +9 -0
  3. package/dist/admin/admin-area-labels.js +5 -1
  4. package/dist/admin/admin-area-labels.js.map +1 -1
  5. package/dist/admin/admin-overview.d.ts +16 -1
  6. package/dist/admin/admin-overview.js +12 -1
  7. package/dist/admin/admin-overview.js.map +1 -1
  8. package/dist/admin/mail-test-card.d.ts +3 -2
  9. package/dist/admin/mail-test-card.js +3 -1
  10. package/dist/admin/mail-test-card.js.map +1 -1
  11. package/dist/admin/user-roster.d.ts +6 -2
  12. package/dist/admin/user-roster.js +7 -3
  13. package/dist/admin/user-roster.js.map +1 -1
  14. package/dist/components/appearance-labels.d.ts +10 -0
  15. package/dist/components/appearance-labels.js +6 -1
  16. package/dist/components/appearance-labels.js.map +1 -1
  17. package/dist/components/data-table-column-settings.js +2 -2
  18. package/dist/components/data-table-column-settings.js.map +1 -1
  19. package/dist/components/data-table-head.js +2 -2
  20. package/dist/components/data-table-head.js.map +1 -1
  21. package/dist/components/data-table-row-actions.d.ts +35 -2
  22. package/dist/components/data-table-row-actions.js +98 -5
  23. package/dist/components/data-table-row-actions.js.map +1 -1
  24. package/dist/components/data-table-shared.js +9 -2
  25. package/dist/components/data-table-shared.js.map +1 -1
  26. package/dist/components/data-table.d.ts +58 -2
  27. package/dist/components/data-table.js +11 -4
  28. package/dist/components/data-table.js.map +1 -1
  29. package/dist/components/date-format-setting.d.ts +29 -0
  30. package/dist/components/date-format-setting.js +36 -0
  31. package/dist/components/date-format-setting.js.map +1 -0
  32. package/dist/components/row-actions.d.ts +12 -5
  33. package/dist/components/row-actions.js +11 -4
  34. package/dist/components/row-actions.js.map +1 -1
  35. package/dist/components/swipeable-row.d.ts +8 -0
  36. package/dist/components/swipeable-row.js.map +1 -1
  37. package/dist/components/tabs.d.ts +3 -2
  38. package/dist/components/tabs.js +1 -1
  39. package/dist/components/tabs.js.map +1 -1
  40. package/dist/components/translation-review-editor.js +1 -0
  41. package/dist/components/translation-review-editor.js.map +1 -1
  42. package/dist/components/translation-review-labels.d.ts +5 -0
  43. package/dist/components/translation-review-labels.js +2 -0
  44. package/dist/components/translation-review-labels.js.map +1 -1
  45. package/dist/components/translation-review.d.ts +18 -8
  46. package/dist/components/translation-review.js +67 -8
  47. package/dist/components/translation-review.js.map +1 -1
  48. package/dist/components/write-lock-labels.js +2 -1
  49. package/dist/components/write-lock-labels.js.map +1 -1
  50. package/dist/components/write-lock.d.ts +33 -3
  51. package/dist/components/write-lock.js +9 -0
  52. package/dist/components/write-lock.js.map +1 -1
  53. package/dist/data-table.d.ts +2 -1
  54. package/dist/data-table.js +2 -0
  55. package/dist/data-table.js.map +1 -1
  56. package/dist/eslint/index.js +1 -1
  57. package/dist/feedback/feedback-row-detail.d.ts +4 -2
  58. package/dist/feedback/feedback-row-detail.js +17 -26
  59. package/dist/feedback/feedback-row-detail.js.map +1 -1
  60. package/dist/feedback/feedback-swipe.d.ts +18 -0
  61. package/dist/feedback/feedback-swipe.js +10 -1
  62. package/dist/feedback/feedback-swipe.js.map +1 -1
  63. package/dist/i18n/german.js +41 -5
  64. package/dist/i18n/german.js.map +1 -1
  65. package/dist/i18n/kit-labels.d.ts +12 -1
  66. package/dist/i18n/kit-labels.js +8 -2
  67. package/dist/i18n/kit-labels.js.map +1 -1
  68. package/dist/i18n/locales/en.js +40 -6
  69. package/dist/i18n/locales/en.js.map +2 -2
  70. package/dist/i18n/locales/es.js +41 -5
  71. package/dist/i18n/locales/es.js.map +1 -1
  72. package/dist/i18n/locales/fr.js +41 -5
  73. package/dist/i18n/locales/fr.js.map +1 -1
  74. package/dist/i18n/locales/hu.js +41 -5
  75. package/dist/i18n/locales/hu.js.map +1 -1
  76. package/dist/i18n/locales/it.js +41 -5
  77. package/dist/i18n/locales/it.js.map +1 -1
  78. package/dist/i18n/locales/zh.js +41 -5
  79. package/dist/i18n/locales/zh.js.map +1 -1
  80. package/dist/i18n/review.js +3 -1
  81. package/dist/i18n/review.js.map +1 -1
  82. package/dist/index.d.ts +17 -4
  83. package/dist/index.js +38 -1
  84. package/dist/index.js.map +1 -1
  85. package/dist/lib/date-format.d.ts +81 -0
  86. package/dist/lib/date-format.js +56 -0
  87. package/dist/lib/date-format.js.map +1 -0
  88. package/dist/lib/format.d.ts +3 -0
  89. package/dist/lib/format.js +5 -4
  90. package/dist/lib/format.js.map +1 -1
  91. package/dist/notifications/device-zone-report.d.ts +34 -0
  92. package/dist/notifications/device-zone-report.js +31 -0
  93. package/dist/notifications/device-zone-report.js.map +1 -0
  94. package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
  95. package/dist/notifications/feedback-notification-types-labels.js +4 -1
  96. package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
  97. package/dist/notifications/feedback-notification-types.d.ts +24 -5
  98. package/dist/notifications/feedback-notification-types.js +7 -2
  99. package/dist/notifications/feedback-notification-types.js.map +1 -1
  100. package/dist/notifications/notification-bell.d.ts +33 -0
  101. package/dist/notifications/notification-bell.js +74 -0
  102. package/dist/notifications/notification-bell.js.map +1 -0
  103. package/dist/notifications/notification-inbox.d.ts +86 -0
  104. package/dist/notifications/notification-inbox.js +206 -0
  105. package/dist/notifications/notification-inbox.js.map +1 -0
  106. package/dist/notifications/notification-types-setting.d.ts +6 -4
  107. package/dist/notifications/notification-types-setting.js +103 -45
  108. package/dist/notifications/notification-types-setting.js.map +1 -1
  109. package/dist/notifications/notification-types.d.ts +19 -7
  110. package/dist/notifications/notifications-labels.d.ts +44 -8
  111. package/dist/notifications/notifications-labels.js +18 -1
  112. package/dist/notifications/notifications-labels.js.map +1 -1
  113. package/dist/notifications/push-devices-setting.d.ts +20 -3
  114. package/dist/notifications/push-devices-setting.js +93 -33
  115. package/dist/notifications/push-devices-setting.js.map +1 -1
  116. package/dist/notifications/push-worker.d.ts +108 -0
  117. package/dist/notifications/push-worker.js +62 -0
  118. package/dist/notifications/push-worker.js.map +1 -0
  119. package/dist/notifications/quiet-hours-setting.d.ts +29 -17
  120. package/dist/notifications/quiet-hours-setting.js +9 -41
  121. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  122. package/dist/notifications/time-zone-select.d.ts +8 -4
  123. package/dist/notifications/time-zone-select.js +2 -7
  124. package/dist/notifications/time-zone-select.js.map +1 -1
  125. package/dist/notifications/use-push-device.d.ts +4 -17
  126. package/dist/notifications/use-push-device.js +7 -15
  127. package/dist/notifications/use-push-device.js.map +1 -1
  128. package/dist/offline/local-first.d.ts +42 -0
  129. package/dist/offline/local-first.js +53 -0
  130. package/dist/offline/local-first.js.map +1 -0
  131. package/dist/offline/query-persistence.d.ts +26 -1
  132. package/dist/offline/query-persistence.js +27 -23
  133. package/dist/offline/query-persistence.js.map +1 -1
  134. package/dist/offline/service-worker-updates.d.ts +46 -0
  135. package/dist/offline/service-worker-updates.js +39 -0
  136. package/dist/offline/service-worker-updates.js.map +1 -0
  137. package/dist/offline.d.ts +5 -2
  138. package/dist/offline.js +12 -2
  139. package/dist/offline.js.map +1 -1
  140. package/dist/push-worker.d.ts +2 -0
  141. package/dist/push-worker.js +14 -0
  142. package/dist/push-worker.js.map +1 -0
  143. package/dist/shell/account-menu-text-size.d.ts +33 -0
  144. package/dist/shell/account-menu-text-size.js +51 -0
  145. package/dist/shell/account-menu-text-size.js.map +1 -0
  146. package/dist/shell/top-bar-actions.d.ts +6 -2
  147. package/dist/shell/top-bar-actions.js +1 -0
  148. package/dist/shell/top-bar-actions.js.map +1 -1
  149. package/dist/shell.d.ts +2 -0
  150. package/dist/shell.js +2 -0
  151. package/dist/shell.js.map +1 -1
  152. package/dist/sync/db.d.ts +93 -0
  153. package/dist/sync/db.js +33 -0
  154. package/dist/sync/db.js.map +1 -0
  155. package/dist/sync/engine.d.ts +126 -0
  156. package/dist/sync/engine.js +474 -0
  157. package/dist/sync/engine.js.map +1 -0
  158. package/dist/sync/hooks.d.ts +35 -0
  159. package/dist/sync/hooks.js +67 -0
  160. package/dist/sync/hooks.js.map +1 -0
  161. package/dist/sync/testing.d.ts +28 -0
  162. package/dist/sync/testing.js +41 -0
  163. package/dist/sync/testing.js.map +1 -0
  164. package/dist/sync/wire.d.ts +50 -0
  165. package/dist/sync/wire.js +2 -0
  166. package/dist/sync/wire.js.map +1 -0
  167. package/dist/sync.d.ts +7 -0
  168. package/dist/sync.js +25 -0
  169. package/dist/sync.js.map +1 -0
  170. package/dist/tour/tour-labels.js +1 -0
  171. package/dist/tour/tour-labels.js.map +1 -1
  172. package/dist/tour/tour.d.ts +30 -0
  173. package/dist/tour/tour.js +50 -6
  174. package/dist/tour/tour.js.map +1 -1
  175. package/package.json +19 -1
  176. package/src/admin/admin-area-labels.ts +13 -0
  177. package/src/admin/admin-overview.tsx +37 -2
  178. package/src/admin/mail-test-card.tsx +7 -3
  179. package/src/admin/user-roster.tsx +12 -3
  180. package/src/components/appearance-labels.ts +16 -0
  181. package/src/components/data-table-column-settings.tsx +2 -2
  182. package/src/components/data-table-head.tsx +2 -2
  183. package/src/components/data-table-row-actions.tsx +145 -5
  184. package/src/components/data-table-shared.tsx +21 -2
  185. package/src/components/data-table.tsx +68 -6
  186. package/src/components/date-format-setting.tsx +64 -0
  187. package/src/components/row-actions.tsx +33 -9
  188. package/src/components/swipeable-row.tsx +8 -0
  189. package/src/components/tabs.tsx +4 -3
  190. package/src/components/translation-review-editor.tsx +2 -0
  191. package/src/components/translation-review-labels.ts +7 -0
  192. package/src/components/translation-review.tsx +105 -15
  193. package/src/components/write-lock-labels.ts +1 -0
  194. package/src/components/write-lock.tsx +36 -3
  195. package/src/data-table.ts +4 -0
  196. package/src/feedback/feedback-row-detail.tsx +18 -23
  197. package/src/feedback/feedback-swipe.tsx +28 -1
  198. package/src/i18n/german.ts +44 -4
  199. package/src/i18n/kit-labels.tsx +18 -1
  200. package/src/i18n/locales/en.ts +1 -0
  201. package/src/i18n/locales/es.ts +44 -4
  202. package/src/i18n/locales/fr.ts +44 -4
  203. package/src/i18n/locales/hu.ts +44 -4
  204. package/src/i18n/locales/it.ts +44 -4
  205. package/src/i18n/locales/zh.ts +44 -2
  206. package/src/i18n/review.ts +3 -1
  207. package/src/index.ts +50 -1
  208. package/src/lib/date-format.ts +151 -0
  209. package/src/lib/format.ts +10 -4
  210. package/src/notifications/device-zone-report.ts +67 -0
  211. package/src/notifications/feedback-notification-types-labels.ts +10 -1
  212. package/src/notifications/feedback-notification-types.ts +30 -5
  213. package/src/notifications/notification-bell.tsx +116 -0
  214. package/src/notifications/notification-inbox.tsx +314 -0
  215. package/src/notifications/notification-types-setting.tsx +151 -55
  216. package/src/notifications/notification-types.ts +20 -8
  217. package/src/notifications/notifications-labels.ts +72 -12
  218. package/src/notifications/push-devices-setting.tsx +123 -34
  219. package/src/notifications/push-worker.ts +160 -0
  220. package/src/notifications/quiet-hours-setting.tsx +41 -58
  221. package/src/notifications/time-zone-select.tsx +9 -10
  222. package/src/notifications/use-push-device.ts +20 -33
  223. package/src/offline/local-first.ts +100 -0
  224. package/src/offline/query-persistence.ts +57 -24
  225. package/src/offline/service-worker-updates.ts +81 -0
  226. package/src/offline.ts +7 -1
  227. package/src/push-worker.ts +25 -0
  228. package/src/shell/account-menu-text-size.tsx +77 -0
  229. package/src/shell/top-bar-actions.tsx +6 -1
  230. package/src/shell.ts +3 -0
  231. package/src/sync/db.ts +125 -0
  232. package/src/sync/engine.ts +701 -0
  233. package/src/sync/hooks.ts +98 -0
  234. package/src/sync/testing.ts +73 -0
  235. package/src/sync/wire.ts +61 -0
  236. package/src/sync.ts +29 -0
  237. package/src/tour/tour-labels.ts +1 -0
  238. package/src/tour/tour.tsx +100 -7
@@ -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 below `md` (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. From `md` up nothing changes. 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 md:hidden\">\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` 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":[]}
@@ -106,6 +106,7 @@ function TranslationReviewEditor({
106
106
  Textarea,
107
107
  {
108
108
  ref: wordingRef,
109
+ "data-review-wording": "",
109
110
  label: missing ? labels.translation : labels.suggestion,
110
111
  lang: row.locale,
111
112
  rows: Math.min(8, Math.max(2, Math.ceil(Math.max(row.text.length, row.reference.length) / 80))),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/translation-review-editor.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { placeholderTokens, reviewWrite } from \"../lib/translation-review\";\nimport type {\n TranslationReviewKey,\n TranslationReviewWrite,\n TranslationRow,\n TranslationVerdict,\n} from \"../lib/translation-review\";\nimport { AlertBanner } from \"./alert-banner\";\nimport { Caption, SectionLabel } from \"./text\";\nimport { DEFAULT_TRANSLATION_REVIEW_LABELS } from \"./translation-review-labels\";\nimport type { TranslationReviewLabels } from \"./translation-review-labels\";\nimport { Button } from \"./button\";\nimport { Textarea } from \"./textarea\";\nimport { COMMIT_EXCEPT_BILLING } from \"./write-lock\";\n\n/** A callback may return a promise: resolve and the editor closes, reject and it stays\n * open with the error. */\ntype MaybePromise = void | Promise<unknown>;\n\nexport interface TranslationReviewEditorProps {\n row: TranslationRow;\n /** The reference language's own name (\"English\", \"Deutsch\") — the app's choice of\n * reference, so the app's word for it. */\n referenceLabel: ReactNode;\n /** The reviewed language's own name (\"Français\"). */\n localeLabel: ReactNode;\n /** Store a verdict. Left out (or `readOnly`): the editor shows the row and its last\n * verdict, with no fields and no actions. */\n onSave?: (write: TranslationReviewWrite) => MaybePromise;\n /** Clear the verdict — back to unreviewed. Left out: no reset button. */\n onClear?: (key: TranslationReviewKey) => MaybePromise;\n /** The Cancel button — and called after a save or a reset resolves, so the panel\n * that opened the editor closes it. */\n onClose?: () => void;\n /** Look, do not touch: a locale the viewer may read but not review. */\n readOnly?: boolean;\n /**\n * 0.25: put the cursor in the wording field when the editor opens, at the end of the\n * text. The panel sets it when a phone card is swiped toward \"Needs a change\" (keksdose\n * live #377): that swipe opens the editor to be written in, and a reviewer who has just\n * said \"this is wrong\" should not have to find the field first. Not for an editor\n * opened by a tap — that one may only be opened to read.\n */\n focusWording?: boolean;\n /** How the verdict's date reads. Default: the kit's `formatDate`, medium, in the\n * provider's locale. */\n formatDate?: (iso: string) => string;\n /** Turns a rejection into words. Default {@link TranslationReviewLabels.failed}. */\n formatError?: (error: unknown) => ReactNode;\n className?: string;\n labels?: LabelOverride<TranslationReviewLabels>;\n}\n\n/** The wording a verdict was given on, under the line that says it changed. */\nfunction Then({ label, text }: { label: string; text: string }) {\n return (\n <div className=\"mt-1.5 min-w-0\">\n <span className=\"text-xs font-medium\">{label}</span>\n <p className=\"whitespace-pre-line break-words text-secondary\">{text}</p>\n </div>\n );\n}\n\n/**\n * One string, opened: the reference beside the translation, what changed since the last\n * verdict, what a machine can see is wrong (the placeholders), and the two things a\n * reviewer can say about it — keksdose's and kastlan's `translation-review-editor.tsx`.\n *\n * \"Better wording\" opens on the CURRENT text rather than empty, because the usual\n * correction is a word or an ending, and retyping a sentence to change one word is how\n * new mistakes get in. It is sent only with \"Needs a change\" — approving says the text is\n * fine as it stands. A \"Needs a change\" with neither a note nor a different wording tells\n * whoever fixes it nothing, so it takes one of the two.\n *\n * A MISSING string (kastlan: the locale lacks the key) cannot be approved — there is\n * nothing to approve. Its field starts empty and is the translation to add; the verdict\n * is NEEDS_CHANGE on the empty text, which the export hands a developer as the string to\n * add.\n *\n * Every action waits for its callback: the buttons hold while the promise is out, a\n * resolution closes the editor through `onClose`, and a rejection keeps it open with what\n * was typed and the error under the buttons.\n *\n * Each verdict is a `commit` exempt from billing (`COMMIT_EXCEPT_BILLING`, 0.33): a review\n * is an admin route, which a lapsed plan never locks (docs/billing-harmonization.md\n * §12.13, §12.36); a demo's lock still holds it.\n */\nexport function TranslationReviewEditor({\n row,\n referenceLabel,\n localeLabel,\n onSave,\n onClear,\n onClose,\n readOnly = false,\n focusWording = false,\n formatDate: formatDateProp,\n formatError,\n className,\n labels: labelsProp,\n}: TranslationReviewEditorProps) {\n const labels = useKitLabels(\"translationReview\", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);\n const { formatDate } = useKitFormat();\n const review = row.review;\n const missing = row.text === \"\";\n const editable = !readOnly && onSave !== undefined;\n const [suggestion, setSuggestion] = useState(review?.suggestion ?? row.text);\n const [note, setNote] = useState(review?.note ?? \"\");\n const [busy, setBusy] = useState<\"approve\" | \"flag\" | \"clear\" | null>(null);\n const [failure, setFailure] = useState<ReactNode>(null);\n const wordingRef = useRef<HTMLTextAreaElement>(null);\n // On mount only: the prop says how the editor was OPENED, not where focus belongs\n // for the rest of its life.\n const focusOnOpen = useRef(focusWording && editable);\n useEffect(() => {\n const field = wordingRef.current;\n if (!focusOnOpen.current || !field) return;\n field.focus();\n field.setSelectionRange(field.value.length, field.value.length);\n }, []);\n\n const canFlag = note.trim() !== \"\" || suggestion.trim() !== row.text.trim();\n const changed = row.status === \"changed\" ? review : null;\n const date = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, \"medium\"));\n\n const run = async (kind: \"approve\" | \"flag\" | \"clear\", action: () => MaybePromise) => {\n setBusy(kind);\n setFailure(null);\n try {\n await action();\n onClose?.();\n } catch (caught) {\n setFailure(formatError ? formatError(caught) : labels.failed);\n } finally {\n setBusy(null);\n }\n };\n\n const save = (verdict: TranslationVerdict) => {\n if (!onSave) return;\n const trimmedNote = note.trim() || null;\n // Approving says the text is right as it stands: there is no wording to suggest.\n const sent = verdict === \"APPROVED\" ? null : suggestion.trim() || null;\n void run(verdict === \"APPROVED\" ? \"approve\" : \"flag\", () =>\n onSave(reviewWrite(row, verdict, trimmedNote, sent)),\n );\n };\n\n return (\n <div className={cn(\"min-w-0 space-y-3 text-sm\", className)}>\n <code className=\"block break-all text-xs text-muted\">{row.key}</code>\n\n <div className=\"grid grid-cols-1 gap-3 md:grid-cols-2\">\n <div className=\"min-w-0\">\n <SectionLabel as=\"span\" size=\"xs\">\n {referenceLabel}\n </SectionLabel>\n <p className=\"mt-1 whitespace-pre-line break-words text-secondary\">{row.reference}</p>\n </div>\n <div className=\"min-w-0\">\n <SectionLabel as=\"span\" size=\"xs\">\n {localeLabel}\n </SectionLabel>\n {missing ? (\n <p className=\"mt-1 italic text-muted\">{labels.missingText}</p>\n ) : (\n <p lang={row.locale} className=\"mt-1 whitespace-pre-line break-words\">\n {row.text}\n </p>\n )}\n </div>\n </div>\n\n {changed && (\n <AlertBanner tone=\"warning\" variant=\"inline\" size=\"sm\" block live={false}>\n <div className=\"min-w-0\">\n <p>{labels.changedSince}</p>\n {changed.text !== row.text && <Then label={labels.reviewedWording} text={changed.text || \"—\"} />}\n {changed.referenceText != null && changed.referenceText !== row.reference && (\n <Then label={labels.reviewedReference} text={changed.referenceText} />\n )}\n </div>\n </AlertBanner>\n )}\n\n {row.placeholderMismatch && (\n <AlertBanner tone=\"danger\" variant=\"inline\" size=\"sm\" block live={false}>\n <span className=\"min-w-0 break-words\">\n {labels.placeholderMismatch(\n placeholderTokens(row.reference).join(\" \") || \"—\",\n placeholderTokens(row.text).join(\" \") || \"—\",\n )}\n </span>\n </AlertBanner>\n )}\n\n {review && (\n <Caption>\n {(review.verdict === \"APPROVED\" ? labels.lastApproved : labels.lastFlagged)(\n review.reviewerName ?? labels.erasedReviewer,\n date(review.reviewedAt),\n )}\n </Caption>\n )}\n\n {/* Read-only: what the reviewer said, as text — the fields are for saying it. */}\n {!editable && review?.verdict === \"NEEDS_CHANGE\" && (review.suggestion || review.note) && (\n <div className=\"space-y-2\">\n {review.suggestion && <Then label={missing ? labels.translation : labels.suggestion} text={review.suggestion} />}\n {review.note && <Then label={labels.note} text={review.note} />}\n </div>\n )}\n\n {editable && (\n <>\n <Textarea\n ref={wordingRef}\n label={missing ? labels.translation : labels.suggestion}\n lang={row.locale}\n rows={Math.min(8, Math.max(2, Math.ceil(Math.max(row.text.length, row.reference.length) / 80)))}\n value={suggestion}\n disabled={busy !== null}\n onChange={(e) => setSuggestion(e.target.value)}\n />\n <Textarea\n label={labels.note}\n rows={2}\n value={note}\n placeholder={labels.notePlaceholder}\n disabled={busy !== null}\n onChange={(e) => setNote(e.target.value)}\n />\n <div className=\"flex flex-wrap gap-2\">\n {!missing && (\n <Button\n variant=\"primary\"\n commit={COMMIT_EXCEPT_BILLING}\n pending={busy === \"approve\"}\n disabled={busy !== null}\n onClick={() => save(\"APPROVED\")}\n >\n {labels.approve}\n </Button>\n )}\n <Button\n variant={missing ? \"primary\" : \"danger\"}\n commit={COMMIT_EXCEPT_BILLING}\n pending={busy === \"flag\"}\n disabled={busy !== null || !canFlag}\n onClick={() => save(\"NEEDS_CHANGE\")}\n >\n {missing ? labels.suggest : labels.flag}\n </Button>\n {review && onClear && (\n <Button\n variant=\"ghost\"\n commit={COMMIT_EXCEPT_BILLING}\n pending={busy === \"clear\"}\n disabled={busy !== null}\n onClick={() => void run(\"clear\", () => onClear({ locale: row.locale, key: row.key }))}\n >\n {labels.reset}\n </Button>\n )}\n {onClose && (\n <Button variant=\"ghost\" disabled={busy !== null} onClick={onClose}>\n {labels.cancel}\n </Button>\n )}\n </div>\n </>\n )}\n\n {failure && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/translation-review-editor.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { placeholderTokens, reviewWrite } from \"../lib/translation-review\";\nimport type {\n TranslationReviewKey,\n TranslationReviewWrite,\n TranslationRow,\n TranslationVerdict,\n} from \"../lib/translation-review\";\nimport { AlertBanner } from \"./alert-banner\";\nimport { Caption, SectionLabel } from \"./text\";\nimport { DEFAULT_TRANSLATION_REVIEW_LABELS } from \"./translation-review-labels\";\nimport type { TranslationReviewLabels } from \"./translation-review-labels\";\nimport { Button } from \"./button\";\nimport { Textarea } from \"./textarea\";\nimport { COMMIT_EXCEPT_BILLING } from \"./write-lock\";\n\n/** A callback may return a promise: resolve and the editor closes, reject and it stays\n * open with the error. */\ntype MaybePromise = void | Promise<unknown>;\n\nexport interface TranslationReviewEditorProps {\n row: TranslationRow;\n /** The reference language's own name (\"English\", \"Deutsch\") — the app's choice of\n * reference, so the app's word for it. */\n referenceLabel: ReactNode;\n /** The reviewed language's own name (\"Français\"). */\n localeLabel: ReactNode;\n /** Store a verdict. Left out (or `readOnly`): the editor shows the row and its last\n * verdict, with no fields and no actions. */\n onSave?: (write: TranslationReviewWrite) => MaybePromise;\n /** Clear the verdict — back to unreviewed. Left out: no reset button. */\n onClear?: (key: TranslationReviewKey) => MaybePromise;\n /** The Cancel button — and called after a save or a reset resolves, so the panel\n * that opened the editor closes it. */\n onClose?: () => void;\n /** Look, do not touch: a locale the viewer may read but not review. */\n readOnly?: boolean;\n /**\n * 0.25: put the cursor in the wording field when the editor opens, at the end of the\n * text. The panel sets it when a phone card is swiped toward \"Needs a change\" (keksdose\n * live #377): that swipe opens the editor to be written in, and a reviewer who has just\n * said \"this is wrong\" should not have to find the field first. Not for an editor\n * opened by a tap — that one may only be opened to read.\n */\n focusWording?: boolean;\n /** How the verdict's date reads. Default: the kit's `formatDate`, medium, in the\n * provider's locale. */\n formatDate?: (iso: string) => string;\n /** Turns a rejection into words. Default {@link TranslationReviewLabels.failed}. */\n formatError?: (error: unknown) => ReactNode;\n className?: string;\n labels?: LabelOverride<TranslationReviewLabels>;\n}\n\n/** The wording a verdict was given on, under the line that says it changed. */\nfunction Then({ label, text }: { label: string; text: string }) {\n return (\n <div className=\"mt-1.5 min-w-0\">\n <span className=\"text-xs font-medium\">{label}</span>\n <p className=\"whitespace-pre-line break-words text-secondary\">{text}</p>\n </div>\n );\n}\n\n/**\n * One string, opened: the reference beside the translation, what changed since the last\n * verdict, what a machine can see is wrong (the placeholders), and the two things a\n * reviewer can say about it — keksdose's and kastlan's `translation-review-editor.tsx`.\n *\n * \"Better wording\" opens on the CURRENT text rather than empty, because the usual\n * correction is a word or an ending, and retyping a sentence to change one word is how\n * new mistakes get in. It is sent only with \"Needs a change\" — approving says the text is\n * fine as it stands. A \"Needs a change\" with neither a note nor a different wording tells\n * whoever fixes it nothing, so it takes one of the two.\n *\n * A MISSING string (kastlan: the locale lacks the key) cannot be approved — there is\n * nothing to approve. Its field starts empty and is the translation to add; the verdict\n * is NEEDS_CHANGE on the empty text, which the export hands a developer as the string to\n * add.\n *\n * Every action waits for its callback: the buttons hold while the promise is out, a\n * resolution closes the editor through `onClose`, and a rejection keeps it open with what\n * was typed and the error under the buttons.\n *\n * Each verdict is a `commit` exempt from billing (`COMMIT_EXCEPT_BILLING`, 0.33): a review\n * is an admin route, which a lapsed plan never locks (docs/billing-harmonization.md\n * §12.13, §12.36); a demo's lock still holds it.\n */\nexport function TranslationReviewEditor({\n row,\n referenceLabel,\n localeLabel,\n onSave,\n onClear,\n onClose,\n readOnly = false,\n focusWording = false,\n formatDate: formatDateProp,\n formatError,\n className,\n labels: labelsProp,\n}: TranslationReviewEditorProps) {\n const labels = useKitLabels(\"translationReview\", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);\n const { formatDate } = useKitFormat();\n const review = row.review;\n const missing = row.text === \"\";\n const editable = !readOnly && onSave !== undefined;\n const [suggestion, setSuggestion] = useState(review?.suggestion ?? row.text);\n const [note, setNote] = useState(review?.note ?? \"\");\n const [busy, setBusy] = useState<\"approve\" | \"flag\" | \"clear\" | null>(null);\n const [failure, setFailure] = useState<ReactNode>(null);\n const wordingRef = useRef<HTMLTextAreaElement>(null);\n // On mount only: the prop says how the editor was OPENED, not where focus belongs\n // for the rest of its life.\n const focusOnOpen = useRef(focusWording && editable);\n useEffect(() => {\n const field = wordingRef.current;\n if (!focusOnOpen.current || !field) return;\n field.focus();\n field.setSelectionRange(field.value.length, field.value.length);\n }, []);\n\n const canFlag = note.trim() !== \"\" || suggestion.trim() !== row.text.trim();\n const changed = row.status === \"changed\" ? review : null;\n const date = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, \"medium\"));\n\n const run = async (kind: \"approve\" | \"flag\" | \"clear\", action: () => MaybePromise) => {\n setBusy(kind);\n setFailure(null);\n try {\n await action();\n onClose?.();\n } catch (caught) {\n setFailure(formatError ? formatError(caught) : labels.failed);\n } finally {\n setBusy(null);\n }\n };\n\n const save = (verdict: TranslationVerdict) => {\n if (!onSave) return;\n const trimmedNote = note.trim() || null;\n // Approving says the text is right as it stands: there is no wording to suggest.\n const sent = verdict === \"APPROVED\" ? null : suggestion.trim() || null;\n void run(verdict === \"APPROVED\" ? \"approve\" : \"flag\", () =>\n onSave(reviewWrite(row, verdict, trimmedNote, sent)),\n );\n };\n\n return (\n <div className={cn(\"min-w-0 space-y-3 text-sm\", className)}>\n <code className=\"block break-all text-xs text-muted\">{row.key}</code>\n\n <div className=\"grid grid-cols-1 gap-3 md:grid-cols-2\">\n <div className=\"min-w-0\">\n <SectionLabel as=\"span\" size=\"xs\">\n {referenceLabel}\n </SectionLabel>\n <p className=\"mt-1 whitespace-pre-line break-words text-secondary\">{row.reference}</p>\n </div>\n <div className=\"min-w-0\">\n <SectionLabel as=\"span\" size=\"xs\">\n {localeLabel}\n </SectionLabel>\n {missing ? (\n <p className=\"mt-1 italic text-muted\">{labels.missingText}</p>\n ) : (\n <p lang={row.locale} className=\"mt-1 whitespace-pre-line break-words\">\n {row.text}\n </p>\n )}\n </div>\n </div>\n\n {changed && (\n <AlertBanner tone=\"warning\" variant=\"inline\" size=\"sm\" block live={false}>\n <div className=\"min-w-0\">\n <p>{labels.changedSince}</p>\n {changed.text !== row.text && <Then label={labels.reviewedWording} text={changed.text || \"—\"} />}\n {changed.referenceText != null && changed.referenceText !== row.reference && (\n <Then label={labels.reviewedReference} text={changed.referenceText} />\n )}\n </div>\n </AlertBanner>\n )}\n\n {row.placeholderMismatch && (\n <AlertBanner tone=\"danger\" variant=\"inline\" size=\"sm\" block live={false}>\n <span className=\"min-w-0 break-words\">\n {labels.placeholderMismatch(\n placeholderTokens(row.reference).join(\" \") || \"—\",\n placeholderTokens(row.text).join(\" \") || \"—\",\n )}\n </span>\n </AlertBanner>\n )}\n\n {review && (\n <Caption>\n {(review.verdict === \"APPROVED\" ? labels.lastApproved : labels.lastFlagged)(\n review.reviewerName ?? labels.erasedReviewer,\n date(review.reviewedAt),\n )}\n </Caption>\n )}\n\n {/* Read-only: what the reviewer said, as text — the fields are for saying it. */}\n {!editable && review?.verdict === \"NEEDS_CHANGE\" && (review.suggestion || review.note) && (\n <div className=\"space-y-2\">\n {review.suggestion && <Then label={missing ? labels.translation : labels.suggestion} text={review.suggestion} />}\n {review.note && <Then label={labels.note} text={review.note} />}\n </div>\n )}\n\n {editable && (\n <>\n <Textarea\n ref={wordingRef}\n // The panel's \"Needs a change\" swipe on an already open row finds it here (#416).\n data-review-wording=\"\"\n label={missing ? labels.translation : labels.suggestion}\n lang={row.locale}\n rows={Math.min(8, Math.max(2, Math.ceil(Math.max(row.text.length, row.reference.length) / 80)))}\n value={suggestion}\n disabled={busy !== null}\n onChange={(e) => setSuggestion(e.target.value)}\n />\n <Textarea\n label={labels.note}\n rows={2}\n value={note}\n placeholder={labels.notePlaceholder}\n disabled={busy !== null}\n onChange={(e) => setNote(e.target.value)}\n />\n <div className=\"flex flex-wrap gap-2\">\n {!missing && (\n <Button\n variant=\"primary\"\n commit={COMMIT_EXCEPT_BILLING}\n pending={busy === \"approve\"}\n disabled={busy !== null}\n onClick={() => save(\"APPROVED\")}\n >\n {labels.approve}\n </Button>\n )}\n <Button\n variant={missing ? \"primary\" : \"danger\"}\n commit={COMMIT_EXCEPT_BILLING}\n pending={busy === \"flag\"}\n disabled={busy !== null || !canFlag}\n onClick={() => save(\"NEEDS_CHANGE\")}\n >\n {missing ? labels.suggest : labels.flag}\n </Button>\n {review && onClear && (\n <Button\n variant=\"ghost\"\n commit={COMMIT_EXCEPT_BILLING}\n pending={busy === \"clear\"}\n disabled={busy !== null}\n onClick={() => void run(\"clear\", () => onClear({ locale: row.locale, key: row.key }))}\n >\n {labels.reset}\n </Button>\n )}\n {onClose && (\n <Button variant=\"ghost\" disabled={busy !== null} onClick={onClose}>\n {labels.cancel}\n </Button>\n )}\n </div>\n </>\n )}\n\n {failure && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -51,6 +51,11 @@ export interface TranslationReviewLabels {
51
51
  flag: string;
52
52
  /** "Needs a change" for a MISSING string: the suggestion is the translation to add. */
53
53
  suggest: string;
54
+ /** 0.46 (keksdose B2/S3): the desktop row's button that OPENS the editor to send a
55
+ * string back — its own name, apart from the editor's `flag`, which saves. */
56
+ flagRow: string;
57
+ /** The same for a MISSING string: opens the editor to add the translation. */
58
+ suggestRow: string;
54
59
  /** Clear a verdict: back to unreviewed. */
55
60
  reset: string;
56
61
  /** The editor's way out without saving. */
@@ -25,6 +25,8 @@ const DEFAULT_TRANSLATION_REVIEW_LABELS = {
25
25
  approve: "Approve",
26
26
  flag: "Needs a change",
27
27
  suggest: "Suggest translation",
28
+ flagRow: "Flag for a change\u2026",
29
+ suggestRow: "Add a translation\u2026",
28
30
  reset: "Mark unreviewed",
29
31
  cancel: "Cancel",
30
32
  approveSelected: "Approve selected",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/translation-review-labels.ts"],"sourcesContent":["import type { ReviewStatus } from \"../lib/translation-review\";\n\n/**\n * The `translationReview` namespace: every word of the translation-review parts\n * (`ReviewStatusChip`, `TranslationProgress`, `TranslationLocaleTabs`,\n * `TranslationExportButton`, `TranslationReviewEditor`, `TranslationReviewPanel`).\n *\n * Its own module, like `data-table-labels.ts`: `kit-labels.tsx` and `defaults.ts` import\n * it, and neither has to load the components to get the words. The English is keksdose's\n * and kastlan's own (`translation_review.*`, `translations:*`), British.\n */\nexport interface TranslationReviewLabels {\n /** The five statuses — the chip, the filter, the progress legend. */\n statusMissing: string;\n statusUnreviewed: string;\n statusChanged: string;\n statusNeedsChange: string;\n statusApproved: string;\n /** Accessible name of the status filter. */\n statusFilter: string;\n /** The status filter's first option. */\n all: string;\n /** A status filter option with its count: \"Unreviewed · 12\". */\n filterCount: (label: string, count: number) => string;\n /** The source filter's label — shown only when the rows come from several sources\n * (kastlan's screen texts and documents). */\n source: string;\n allSources: string;\n /** The namespace filter's label and its \"every one\" option. */\n namespace: string;\n allNamespaces: string;\n search: string;\n /** The placeholder filter, with how many rows it would show. */\n placeholdersOnly: (count: number) => string;\n /** The chip on a row whose placeholders differ from the reference's. */\n placeholderChip: string;\n /** The key column, and the key's caption in the editor. */\n key: string;\n statusColumn: string;\n /** No row matches the filters. */\n empty: string;\n /** In place of the text of a string the locale lacks. */\n missingText: string;\n /** The progress bar's label. */\n progress: (approved: number, total: number) => string;\n /** A locale tab's badge. */\n localeProgress: (approved: number, total: number) => string;\n /** Accessible name of the locale tabs. */\n locales: string;\n approve: string;\n /** Send a string back: \"Needs a change\". */\n flag: string;\n /** \"Needs a change\" for a MISSING string: the suggestion is the translation to add. */\n suggest: string;\n /** Clear a verdict: back to unreviewed. */\n reset: string;\n /** The editor's way out without saving. */\n cancel: string;\n approveSelected: string;\n resetSelected: string;\n /** Selects every row the filters show — the phone's way into the bulk actions, where\n * the table has no checkboxes. */\n selectShown: string;\n /** Over the old wording of a row that changed since its verdict. */\n changedSince: string;\n reviewedWording: string;\n reviewedReference: string;\n /** The placeholders differ: the reference's and the text's, each a list. */\n placeholderMismatch: (reference: string, text: string) => string;\n lastApproved: (name: string, date: string) => string;\n lastFlagged: (name: string, date: string) => string;\n /** The reviewer's name once the account is gone — what `name` above becomes. */\n erasedReviewer: string;\n /** The editor's wording field, opened on the current text. */\n suggestion: string;\n /** The same field for a missing string. */\n translation: string;\n note: string;\n notePlaceholder: string;\n /** The viewer may read this locale but not review it. */\n readOnly: string;\n /** The reviewer is limited to some areas — `areas` is their names, joined. */\n scope: (areas: string) => string;\n exportCorrections: (count: number) => string;\n /** A callback rejected and no `formatError` says better. */\n failed: string;\n /** 0.25: the Undo toast after an approval (`undo`) — one string, or a batch of them. */\n approvedToast: (count: number) => string;\n /** 0.25: a group's header under `groupBy` — how many of its rows nobody has read yet,\n * of how many the filters show in it. */\n groupCount: (unreviewed: number, total: number) => string;\n /** 0.25: a group's bulk action — approve its unreviewed rows. */\n approveGroup: (count: number) => string;\n /** 0.25: asked first when a group's bulk approve is larger than a page — more than the\n * reviewer can have had on screen. `group` is the group's name. */\n confirmGroup: (count: number, group: string) => string;\n /** The Undo toast after a swipe bound to `clear` took a verdict back (keksdose, live\n * #377 rework: the swipes bound in its settings) — one string, or a batch of them.\n * The swipe itself is named by {@link reset}. */\n clearedToast: (count: number) => string;\n}\n\nexport const DEFAULT_TRANSLATION_REVIEW_LABELS: TranslationReviewLabels = {\n statusMissing: \"Missing\",\n statusUnreviewed: \"Unreviewed\",\n statusChanged: \"Changed since review\",\n statusNeedsChange: \"Needs a change\",\n statusApproved: \"Approved\",\n statusFilter: \"Status\",\n all: \"All\",\n filterCount: (label, count) => `${label} · ${count}`,\n source: \"Texts\",\n allSources: \"All texts\",\n namespace: \"Area\",\n allNamespaces: \"All areas\",\n search: \"Search keys and text\",\n placeholdersOnly: (count) => `Only placeholder problems (${count})`,\n placeholderChip: \"Placeholders\",\n key: \"Key\",\n statusColumn: \"Status\",\n empty: \"No strings match these filters.\",\n missingText: \"Missing in this language\",\n progress: (approved, total) => `${approved} of ${total} approved`,\n localeProgress: (approved, total) => `${approved}/${total}`,\n locales: \"Languages\",\n approve: \"Approve\",\n flag: \"Needs a change\",\n suggest: \"Suggest translation\",\n reset: \"Mark unreviewed\",\n cancel: \"Cancel\",\n approveSelected: \"Approve selected\",\n resetSelected: \"Mark selected unreviewed\",\n selectShown: \"Select all shown\",\n changedSince: \"This string changed after it was reviewed. Please read it again.\",\n reviewedWording: \"Wording when reviewed\",\n reviewedReference: \"Reference when reviewed\",\n placeholderMismatch: (reference, text) =>\n `The placeholders differ from the reference — reference: ${reference}; this text: ${text}. The app fills these in, so they must stay exactly as they are.`,\n lastApproved: (name, date) => `Approved by ${name} on ${date}`,\n lastFlagged: (name, date) => `Marked as needing a change by ${name} on ${date}`,\n erasedReviewer: \"a deleted account\",\n suggestion: \"Better wording\",\n translation: \"Translation\",\n note: \"Note (optional)\",\n notePlaceholder: \"What is wrong, or what to keep in mind\",\n readOnly: \"You can read this language but not review it.\",\n scope: (areas) => `Your review is limited to: ${areas}.`,\n exportCorrections: (count) => `Export corrections (${count})`,\n failed: \"That did not work. Please try again.\",\n approvedToast: (count) => (count === 1 ? \"String approved\" : `${count} strings approved`),\n groupCount: (unreviewed, total) => `${unreviewed} unreviewed / ${total}`,\n approveGroup: (count) => `Approve unreviewed (${count})`,\n confirmGroup: (count, group) =>\n count === 1\n ? `Approve the unreviewed string in ${group}?`\n : `Approve all ${count} unreviewed strings in ${group}, including those not on screen?`,\n clearedToast: (count) => (count === 1 ? \"String marked unreviewed\" : `${count} strings marked unreviewed`),\n};\n\n/** A status's word, from the namespace's five. */\nexport function reviewStatusLabel(labels: TranslationReviewLabels, status: ReviewStatus): string {\n switch (status) {\n case \"missing\":\n return labels.statusMissing;\n case \"unreviewed\":\n return labels.statusUnreviewed;\n case \"changed\":\n return labels.statusChanged;\n case \"needs_change\":\n return labels.statusNeedsChange;\n case \"approved\":\n return labels.statusApproved;\n }\n}\n"],"mappings":";AAsGO,MAAM,oCAA6D;AAAA,EACxE,eAAe;AAAA,EACf,kBAAkB;AAAA,EAClB,eAAe;AAAA,EACf,mBAAmB;AAAA,EACnB,gBAAgB;AAAA,EAChB,cAAc;AAAA,EACd,KAAK;AAAA,EACL,aAAa,CAAC,OAAO,UAAU,GAAG,KAAK,SAAM,KAAK;AAAA,EAClD,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,kBAAkB,CAAC,UAAU,8BAA8B,KAAK;AAAA,EAChE,iBAAiB;AAAA,EACjB,KAAK;AAAA,EACL,cAAc;AAAA,EACd,OAAO;AAAA,EACP,aAAa;AAAA,EACb,UAAU,CAAC,UAAU,UAAU,GAAG,QAAQ,OAAO,KAAK;AAAA,EACtD,gBAAgB,CAAC,UAAU,UAAU,GAAG,QAAQ,IAAI,KAAK;AAAA,EACzD,SAAS;AAAA,EACT,SAAS;AAAA,EACT,MAAM;AAAA,EACN,SAAS;AAAA,EACT,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,iBAAiB;AAAA,EACjB,eAAe;AAAA,EACf,aAAa;AAAA,EACb,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,mBAAmB;AAAA,EACnB,qBAAqB,CAAC,WAAW,SAC/B,gEAA2D,SAAS,gBAAgB,IAAI;AAAA,EAC1F,cAAc,CAAC,MAAM,SAAS,eAAe,IAAI,OAAO,IAAI;AAAA,EAC5D,aAAa,CAAC,MAAM,SAAS,iCAAiC,IAAI,OAAO,IAAI;AAAA,EAC7E,gBAAgB;AAAA,EAChB,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,MAAM;AAAA,EACN,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,OAAO,CAAC,UAAU,8BAA8B,KAAK;AAAA,EACrD,mBAAmB,CAAC,UAAU,uBAAuB,KAAK;AAAA,EAC1D,QAAQ;AAAA,EACR,eAAe,CAAC,UAAW,UAAU,IAAI,oBAAoB,GAAG,KAAK;AAAA,EACrE,YAAY,CAAC,YAAY,UAAU,GAAG,UAAU,iBAAiB,KAAK;AAAA,EACtE,cAAc,CAAC,UAAU,uBAAuB,KAAK;AAAA,EACrD,cAAc,CAAC,OAAO,UACpB,UAAU,IACN,oCAAoC,KAAK,MACzC,eAAe,KAAK,0BAA0B,KAAK;AAAA,EACzD,cAAc,CAAC,UAAW,UAAU,IAAI,6BAA6B,GAAG,KAAK;AAC/E;AAGO,SAAS,kBAAkB,QAAiC,QAA8B;AAC/F,UAAQ,QAAQ;AAAA,IACd,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,EAClB;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/components/translation-review-labels.ts"],"sourcesContent":["import type { ReviewStatus } from \"../lib/translation-review\";\n\n/**\n * The `translationReview` namespace: every word of the translation-review parts\n * (`ReviewStatusChip`, `TranslationProgress`, `TranslationLocaleTabs`,\n * `TranslationExportButton`, `TranslationReviewEditor`, `TranslationReviewPanel`).\n *\n * Its own module, like `data-table-labels.ts`: `kit-labels.tsx` and `defaults.ts` import\n * it, and neither has to load the components to get the words. The English is keksdose's\n * and kastlan's own (`translation_review.*`, `translations:*`), British.\n */\nexport interface TranslationReviewLabels {\n /** The five statuses — the chip, the filter, the progress legend. */\n statusMissing: string;\n statusUnreviewed: string;\n statusChanged: string;\n statusNeedsChange: string;\n statusApproved: string;\n /** Accessible name of the status filter. */\n statusFilter: string;\n /** The status filter's first option. */\n all: string;\n /** A status filter option with its count: \"Unreviewed · 12\". */\n filterCount: (label: string, count: number) => string;\n /** The source filter's label — shown only when the rows come from several sources\n * (kastlan's screen texts and documents). */\n source: string;\n allSources: string;\n /** The namespace filter's label and its \"every one\" option. */\n namespace: string;\n allNamespaces: string;\n search: string;\n /** The placeholder filter, with how many rows it would show. */\n placeholdersOnly: (count: number) => string;\n /** The chip on a row whose placeholders differ from the reference's. */\n placeholderChip: string;\n /** The key column, and the key's caption in the editor. */\n key: string;\n statusColumn: string;\n /** No row matches the filters. */\n empty: string;\n /** In place of the text of a string the locale lacks. */\n missingText: string;\n /** The progress bar's label. */\n progress: (approved: number, total: number) => string;\n /** A locale tab's badge. */\n localeProgress: (approved: number, total: number) => string;\n /** Accessible name of the locale tabs. */\n locales: string;\n approve: string;\n /** Send a string back: \"Needs a change\". */\n flag: string;\n /** \"Needs a change\" for a MISSING string: the suggestion is the translation to add. */\n suggest: string;\n /** 0.46 (keksdose B2/S3): the desktop row's button that OPENS the editor to send a\n * string back — its own name, apart from the editor's `flag`, which saves. */\n flagRow: string;\n /** The same for a MISSING string: opens the editor to add the translation. */\n suggestRow: string;\n /** Clear a verdict: back to unreviewed. */\n reset: string;\n /** The editor's way out without saving. */\n cancel: string;\n approveSelected: string;\n resetSelected: string;\n /** Selects every row the filters show — the phone's way into the bulk actions, where\n * the table has no checkboxes. */\n selectShown: string;\n /** Over the old wording of a row that changed since its verdict. */\n changedSince: string;\n reviewedWording: string;\n reviewedReference: string;\n /** The placeholders differ: the reference's and the text's, each a list. */\n placeholderMismatch: (reference: string, text: string) => string;\n lastApproved: (name: string, date: string) => string;\n lastFlagged: (name: string, date: string) => string;\n /** The reviewer's name once the account is gone — what `name` above becomes. */\n erasedReviewer: string;\n /** The editor's wording field, opened on the current text. */\n suggestion: string;\n /** The same field for a missing string. */\n translation: string;\n note: string;\n notePlaceholder: string;\n /** The viewer may read this locale but not review it. */\n readOnly: string;\n /** The reviewer is limited to some areas — `areas` is their names, joined. */\n scope: (areas: string) => string;\n exportCorrections: (count: number) => string;\n /** A callback rejected and no `formatError` says better. */\n failed: string;\n /** 0.25: the Undo toast after an approval (`undo`) — one string, or a batch of them. */\n approvedToast: (count: number) => string;\n /** 0.25: a group's header under `groupBy` — how many of its rows nobody has read yet,\n * of how many the filters show in it. */\n groupCount: (unreviewed: number, total: number) => string;\n /** 0.25: a group's bulk action — approve its unreviewed rows. */\n approveGroup: (count: number) => string;\n /** 0.25: asked first when a group's bulk approve is larger than a page — more than the\n * reviewer can have had on screen. `group` is the group's name. */\n confirmGroup: (count: number, group: string) => string;\n /** The Undo toast after a swipe bound to `clear` took a verdict back (keksdose, live\n * #377 rework: the swipes bound in its settings) — one string, or a batch of them.\n * The swipe itself is named by {@link reset}. */\n clearedToast: (count: number) => string;\n}\n\nexport const DEFAULT_TRANSLATION_REVIEW_LABELS: TranslationReviewLabels = {\n statusMissing: \"Missing\",\n statusUnreviewed: \"Unreviewed\",\n statusChanged: \"Changed since review\",\n statusNeedsChange: \"Needs a change\",\n statusApproved: \"Approved\",\n statusFilter: \"Status\",\n all: \"All\",\n filterCount: (label, count) => `${label} · ${count}`,\n source: \"Texts\",\n allSources: \"All texts\",\n namespace: \"Area\",\n allNamespaces: \"All areas\",\n search: \"Search keys and text\",\n placeholdersOnly: (count) => `Only placeholder problems (${count})`,\n placeholderChip: \"Placeholders\",\n key: \"Key\",\n statusColumn: \"Status\",\n empty: \"No strings match these filters.\",\n missingText: \"Missing in this language\",\n progress: (approved, total) => `${approved} of ${total} approved`,\n localeProgress: (approved, total) => `${approved}/${total}`,\n locales: \"Languages\",\n approve: \"Approve\",\n flag: \"Needs a change\",\n suggest: \"Suggest translation\",\n flagRow: \"Flag for a change…\",\n suggestRow: \"Add a translation…\",\n reset: \"Mark unreviewed\",\n cancel: \"Cancel\",\n approveSelected: \"Approve selected\",\n resetSelected: \"Mark selected unreviewed\",\n selectShown: \"Select all shown\",\n changedSince: \"This string changed after it was reviewed. Please read it again.\",\n reviewedWording: \"Wording when reviewed\",\n reviewedReference: \"Reference when reviewed\",\n placeholderMismatch: (reference, text) =>\n `The placeholders differ from the reference — reference: ${reference}; this text: ${text}. The app fills these in, so they must stay exactly as they are.`,\n lastApproved: (name, date) => `Approved by ${name} on ${date}`,\n lastFlagged: (name, date) => `Marked as needing a change by ${name} on ${date}`,\n erasedReviewer: \"a deleted account\",\n suggestion: \"Better wording\",\n translation: \"Translation\",\n note: \"Note (optional)\",\n notePlaceholder: \"What is wrong, or what to keep in mind\",\n readOnly: \"You can read this language but not review it.\",\n scope: (areas) => `Your review is limited to: ${areas}.`,\n exportCorrections: (count) => `Export corrections (${count})`,\n failed: \"That did not work. Please try again.\",\n approvedToast: (count) => (count === 1 ? \"String approved\" : `${count} strings approved`),\n groupCount: (unreviewed, total) => `${unreviewed} unreviewed / ${total}`,\n approveGroup: (count) => `Approve unreviewed (${count})`,\n confirmGroup: (count, group) =>\n count === 1\n ? `Approve the unreviewed string in ${group}?`\n : `Approve all ${count} unreviewed strings in ${group}, including those not on screen?`,\n clearedToast: (count) => (count === 1 ? \"String marked unreviewed\" : `${count} strings marked unreviewed`),\n};\n\n/** A status's word, from the namespace's five. */\nexport function reviewStatusLabel(labels: TranslationReviewLabels, status: ReviewStatus): string {\n switch (status) {\n case \"missing\":\n return labels.statusMissing;\n case \"unreviewed\":\n return labels.statusUnreviewed;\n case \"changed\":\n return labels.statusChanged;\n case \"needs_change\":\n return labels.statusNeedsChange;\n case \"approved\":\n return labels.statusApproved;\n }\n}\n"],"mappings":";AA2GO,MAAM,oCAA6D;AAAA,EACxE,eAAe;AAAA,EACf,kBAAkB;AAAA,EAClB,eAAe;AAAA,EACf,mBAAmB;AAAA,EACnB,gBAAgB;AAAA,EAChB,cAAc;AAAA,EACd,KAAK;AAAA,EACL,aAAa,CAAC,OAAO,UAAU,GAAG,KAAK,SAAM,KAAK;AAAA,EAClD,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,kBAAkB,CAAC,UAAU,8BAA8B,KAAK;AAAA,EAChE,iBAAiB;AAAA,EACjB,KAAK;AAAA,EACL,cAAc;AAAA,EACd,OAAO;AAAA,EACP,aAAa;AAAA,EACb,UAAU,CAAC,UAAU,UAAU,GAAG,QAAQ,OAAO,KAAK;AAAA,EACtD,gBAAgB,CAAC,UAAU,UAAU,GAAG,QAAQ,IAAI,KAAK;AAAA,EACzD,SAAS;AAAA,EACT,SAAS;AAAA,EACT,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,iBAAiB;AAAA,EACjB,eAAe;AAAA,EACf,aAAa;AAAA,EACb,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,mBAAmB;AAAA,EACnB,qBAAqB,CAAC,WAAW,SAC/B,gEAA2D,SAAS,gBAAgB,IAAI;AAAA,EAC1F,cAAc,CAAC,MAAM,SAAS,eAAe,IAAI,OAAO,IAAI;AAAA,EAC5D,aAAa,CAAC,MAAM,SAAS,iCAAiC,IAAI,OAAO,IAAI;AAAA,EAC7E,gBAAgB;AAAA,EAChB,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,MAAM;AAAA,EACN,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,OAAO,CAAC,UAAU,8BAA8B,KAAK;AAAA,EACrD,mBAAmB,CAAC,UAAU,uBAAuB,KAAK;AAAA,EAC1D,QAAQ;AAAA,EACR,eAAe,CAAC,UAAW,UAAU,IAAI,oBAAoB,GAAG,KAAK;AAAA,EACrE,YAAY,CAAC,YAAY,UAAU,GAAG,UAAU,iBAAiB,KAAK;AAAA,EACtE,cAAc,CAAC,UAAU,uBAAuB,KAAK;AAAA,EACrD,cAAc,CAAC,OAAO,UACpB,UAAU,IACN,oCAAoC,KAAK,MACzC,eAAe,KAAK,0BAA0B,KAAK;AAAA,EACzD,cAAc,CAAC,UAAW,UAAU,IAAI,6BAA6B,GAAG,KAAK;AAC/E;AAGO,SAAS,kBAAkB,QAAiC,QAA8B;AAC/F,UAAQ,QAAQ;AAAA,IACd,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AACH,aAAO,OAAO;AAAA,EAClB;AACF;","names":[]}
@@ -129,13 +129,23 @@ export interface TranslationReviewPanelProps {
129
129
  * DataTable's swipe sides (`mobileSwipeActions`) on {@link SwipeableRow}: the same
130
130
  * gesture, the same feedback, and the same keyboard path — every swipe is also a real
131
131
  * button, read out and reachable by Tab, so the gesture is a shortcut and never the only
132
- * way. A row has no swipe while the editor under it is open, while its own write is out,
133
- * under a {@link WriteLockProvider} lock, in a `readOnly` locale, and toward "Approve"
134
- * where there is nothing to approve (a missing string, an approved one). A reviewer
135
- * limited to `areas` never sees a row outside them, so never swipes one.
132
+ * way. A row has no swipe while its own write is out (a swipe's, a button's, or the open
133
+ * editor's own), under a {@link WriteLockProvider} lock, in a `readOnly` locale, and
134
+ * toward "Approve" where there is nothing to approve (a missing string, an approved one).
135
+ * A reviewer limited to `areas` never sees a row outside them, so never swipes one.
136
136
  *
137
- * Off by default, so no existing page gains a gesture on the bump. Phones only; the
138
- * desktop table keeps its approve button.
137
+ * An OPEN row swipes too, on its top line (keksdose live #416, Marcel: *"The swipe shall
138
+ * still be possible since it is faster than scrolling down to the buttons"*), as
139
+ * DataTable's open card does since 0.40: the editor below is outside the gesture, and a
140
+ * swipe neither toggles it shut nor resets it. A swipe acts on the STORED string, as the
141
+ * row's buttons do — Approve approves what the locale says, not the editor's draft — and
142
+ * the editor stays open with whatever was typed in it; "Needs a change" puts the cursor
143
+ * back at the end of that draft.
144
+ *
145
+ * Off by default, so no existing page gains a gesture on the bump. The gesture is the
146
+ * phone's; on a wide screen the binding is the row's actions column instead (0.46,
147
+ * keksdose dev#598): Approve where the row can take it, bound or not, then the bound
148
+ * `edit` and `clear`, each once — under a write lock they stay and say why.
139
149
  *
140
150
  * A BINDING instead of `true` (keksdose — Marcel's live #377 rework, 2026-10-03: *"Add
141
151
  * the swipe options to be shown also to the settings /settings#interaction area where
@@ -153,8 +163,8 @@ export interface TranslationReviewPanelProps {
153
163
  * `onClear`). An action a row cannot offer drops out and the ladder closes up behind it
154
164
  * ({@link translationReviewSwipePlan}): `approve` on an approved or missing string,
155
165
  * `clear` on a row with no verdict. Every rule above still holds for every binding —
156
- * no side swipes in a `readOnly` locale, under a write lock, with the editor open or
157
- * the row's write out.
166
+ * no side swipes in a `readOnly` locale, under a write lock, or while the row's write
167
+ * (or its open editor's) is out.
158
168
  *
159
169
  * `true` is {@link DEFAULT_TRANSLATION_REVIEW_SWIPE} — `{ end: ["approve"], start:
160
170
  * ["edit"] }`, 0.25's mapping exactly — and `false` or nothing is no swipe. A binding
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useId, useMemo, useRef, useState } from "react";
4
4
  import { Check, ListChecks, PencilLine, RotateCcw } from "lucide-react";
5
5
  import { usePhoneLayout } from "../hooks/use-breakpoint.js";
@@ -33,6 +33,7 @@ import { DEFAULT_TRANSLATION_REVIEW_LABELS, reviewStatusLabel } from "./translat
33
33
  import { ReviewStatusChip, TranslationProgress } from "./translation-review-parts.js";
34
34
  import { Button } from "./button.js";
35
35
  import { IconButton } from "./icon-button.js";
36
+ import { DEFAULT_ROW_ACTIONS_LABELS, rowActionsColumn } from "./row-actions.js";
36
37
  import { Select } from "./select.js";
37
38
  import { COMMIT_EXCEPT_BILLING, useWriteLock } from "./write-lock.js";
38
39
  import {
@@ -125,6 +126,7 @@ function TranslationReviewPanel({
125
126
  const labels = useKitLabels("translationReview", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);
126
127
  const locale = useKitLocale();
127
128
  const lock = useWriteLock(COMMIT_EXCEPT_BILLING);
129
+ const rowActionLabels = useKitLabels("rowActions", DEFAULT_ROW_ACTIONS_LABELS);
128
130
  const phone = usePhoneLayout();
129
131
  const baseId = useId();
130
132
  const rootRef = useRef(null);
@@ -147,6 +149,19 @@ function TranslationReviewPanel({
147
149
  const [failure, setFailure] = useState(null);
148
150
  const busy = queued.length > 0;
149
151
  const isQueued = (key) => queued.includes(key);
152
+ const [editorWrites, setEditorWrites] = useState(() => /* @__PURE__ */ new Set());
153
+ const whileEditorWrites = async (rowId, write) => {
154
+ setEditorWrites((prev) => new Set(prev).add(rowId));
155
+ try {
156
+ await write();
157
+ } finally {
158
+ setEditorWrites((prev) => {
159
+ const next = new Set(prev);
160
+ next.delete(rowId);
161
+ return next;
162
+ });
163
+ }
164
+ };
150
165
  const rows = useMemo(
151
166
  () => areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp],
152
167
  [rowsProp, areas, inArea]
@@ -238,16 +253,16 @@ function TranslationReviewPanel({
238
253
  if (ok && undoOn) sayDone(targets, description);
239
254
  return ok;
240
255
  };
241
- const clearRow = async (row) => {
256
+ const clearRow = async (row, origin = "swipe") => {
242
257
  if (!onClear || row.review === null) return;
243
258
  const clear = onClear;
244
259
  const keys = [{ locale: row.locale, key: row.key }];
245
- const ok = await run(`row:${row.id}`, () => clear(keys, { origin: "swipe", toasted: undoOn }));
260
+ const ok = await run(`row:${row.id}`, () => clear(keys, { origin, toasted: undoOn }));
246
261
  if (ok && undoOn) sayDone([row], row.key, true);
247
262
  };
248
263
  const saveFromEditor = async (row, write) => {
249
264
  const toasted = undoOn && write.verdict === "APPROVED";
250
- await onSave?.([write], { origin: "editor", toasted });
265
+ await whileEditorWrites(row.id, () => onSave?.([write], { origin: "editor", toasted }));
251
266
  if (toasted) sayDone([row], row.key);
252
267
  };
253
268
  const keepFocusAt = (groupKey) => {
@@ -324,10 +339,45 @@ function TranslationReviewPanel({
324
339
  sortBy: (r) => REVIEW_STATUSES.indexOf(r.status)
325
340
  }
326
341
  ];
327
- if (editable) {
342
+ if (editable && binding) {
343
+ columns.push(
344
+ rowActionsColumn({
345
+ key: "actions",
346
+ headerText: rowActionLabels.actions,
347
+ name: (r) => r.key,
348
+ tooltipSide: "start",
349
+ actions: (r) => translationReviewSwipePlan(
350
+ { end: ["approve", ...binding.end ?? [], ...binding.start ?? []] },
351
+ r,
352
+ { canClear: onClear !== void 0 }
353
+ ).end.map((id) => {
354
+ const swipe2 = swipeAction(r, id);
355
+ const writes = id !== "edit";
356
+ return {
357
+ key: id,
358
+ // `edit` opens the editor: its own name, apart from the editor's submit
359
+ // ("Needs a change"), which saves (keksdose B2/S3).
360
+ label: id === "edit" ? r.text === "" ? labels.suggestRow : labels.flagRow : swipe2.label,
361
+ icon: /* @__PURE__ */ jsx(Fragment, { children: swipe2.icon }),
362
+ tone: "muted",
363
+ commit: writes ? COMMIT_EXCEPT_BILLING : void 0,
364
+ pending: writes && isQueued(`row:${r.id}`),
365
+ disabled: busy,
366
+ onSelect: id === "approve" ? () => void approve(`row:${r.id}`, [r], "row", r.key) : id === "clear" ? () => void clearRow(r, "row") : (
367
+ // The row's group stays open while its editor is (`keepGroupOpen`).
368
+ () => swipeAction(r, id, groups?.find((g) => g.rows.includes(r))?.key).onCommit()
369
+ )
370
+ };
371
+ }),
372
+ column: { mobileHidden: true }
373
+ })
374
+ );
375
+ } else if (editable) {
328
376
  columns.push({
329
377
  key: "approve",
330
- header: /* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.approve }),
378
+ // On screen, as every actions column's heading is (0.46.1, keksdose dev#598 rework).
379
+ header: rowActionLabels.actions,
380
+ headerText: rowActionLabels.actions,
331
381
  cell: (r) => r.status === "approved" || r.text === "" ? null : /* @__PURE__ */ jsx(
332
382
  IconButton,
333
383
  {
@@ -353,6 +403,7 @@ function TranslationReviewPanel({
353
403
  switch (id) {
354
404
  case "approve":
355
405
  return {
406
+ id,
356
407
  label: labels.approve,
357
408
  icon: /* @__PURE__ */ jsx(Check, { className: "size-4", "aria-hidden": true }),
358
409
  onCommit: () => void approve(`row:${r.id}`, [r], "swipe", r.key),
@@ -362,6 +413,7 @@ function TranslationReviewPanel({
362
413
  };
363
414
  case "edit":
364
415
  return {
416
+ id,
365
417
  label: missing ? labels.suggest : labels.flag,
366
418
  icon: /* @__PURE__ */ jsx(PencilLine, { className: "size-4", "aria-hidden": true }),
367
419
  // Opens the editor rather than sending the string back: "needs a change"
@@ -370,11 +422,17 @@ function TranslationReviewPanel({
370
422
  keepGroupOpen(groupKey);
371
423
  setOpenId(r.id);
372
424
  setFocusId(r.id);
425
+ if (openId === r.id) {
426
+ const field = rootRef.current?.querySelector("[data-review-wording]");
427
+ field?.focus();
428
+ field?.setSelectionRange(field.value.length, field.value.length);
429
+ }
373
430
  },
374
431
  tone: missing ? "brand" : "danger"
375
432
  };
376
433
  case "clear":
377
434
  return {
435
+ id,
378
436
  label: labels.reset,
379
437
  icon: /* @__PURE__ */ jsx(RotateCcw, { className: "size-4", "aria-hidden": true }),
380
438
  onCommit: () => void clearRow(r),
@@ -386,7 +444,7 @@ function TranslationReviewPanel({
386
444
  }
387
445
  };
388
446
  const swipeActions = (r, groupKey) => {
389
- if (!binding || !editable || lock.locked || isQueued(`row:${r.id}`)) return null;
447
+ if (!binding || !editable || lock.locked || isQueued(`row:${r.id}`) || editorWrites.has(r.id)) return null;
390
448
  const plan = translationReviewSwipePlan(binding, r, { canClear: onClear !== void 0 });
391
449
  if (plan.start.length === 0 && plan.end.length === 0) return null;
392
450
  return {
@@ -449,7 +507,7 @@ function TranslationReviewPanel({
449
507
  readOnly: !editable,
450
508
  focusWording: focusId === r.id,
451
509
  onSave: onSave ? (write) => saveFromEditor(r, write) : void 0,
452
- onClear: onClear ? (key) => onClear([key], { origin: "editor", toasted: false }) : void 0,
510
+ onClear: onClear ? (key) => whileEditorWrites(r.id, () => onClear([key], { origin: "editor", toasted: false })) : void 0,
453
511
  onClose: () => {
454
512
  setOpenId(null);
455
513
  setFocusId(null);
@@ -469,6 +527,7 @@ function TranslationReviewPanel({
469
527
  ] })
470
528
  ] }),
471
529
  mobileSwipeActions: binding ? (r) => swipeActions(r, group?.key) : void 0,
530
+ desktopSwipeActions: false,
472
531
  selection: selectionFor(list)
473
532
  }
474
533
  );