@eifi1/ui-kit 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +3 -3
  2. package/dist/components/data-table.js +3 -2
  3. package/dist/components/data-table.js.map +1 -1
  4. package/dist/components/swipeable-row.d.ts +5 -1
  5. package/dist/components/swipeable-row.js.map +1 -1
  6. package/dist/components/translation-review-labels.d.ts +4 -0
  7. package/dist/components/translation-review-labels.js +2 -1
  8. package/dist/components/translation-review-labels.js.map +1 -1
  9. package/dist/components/translation-review.d.ts +67 -7
  10. package/dist/components/translation-review.js +115 -29
  11. package/dist/components/translation-review.js.map +1 -1
  12. package/dist/hooks/use-search-param-state.d.ts +1 -1
  13. package/dist/hooks/use-search-param-state.js +8 -1
  14. package/dist/hooks/use-search-param-state.js.map +1 -1
  15. package/dist/i18n/german.js +1 -0
  16. package/dist/i18n/german.js.map +1 -1
  17. package/dist/i18n/locales/en.js +1 -0
  18. package/dist/i18n/locales/en.js.map +1 -1
  19. package/dist/i18n/locales/es.js +1 -0
  20. package/dist/i18n/locales/es.js.map +1 -1
  21. package/dist/i18n/locales/fr.js +1 -0
  22. package/dist/i18n/locales/fr.js.map +1 -1
  23. package/dist/i18n/locales/hu.js +1 -0
  24. package/dist/i18n/locales/hu.js.map +1 -1
  25. package/dist/i18n/locales/it.js +1 -0
  26. package/dist/i18n/locales/it.js.map +1 -1
  27. package/dist/i18n/locales/zh.js +1 -0
  28. package/dist/i18n/locales/zh.js.map +1 -1
  29. package/dist/i18n/review.js +1 -0
  30. package/dist/i18n/review.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +7 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/lib/translation-review.d.ts +76 -1
  35. package/dist/lib/translation-review.js +30 -0
  36. package/dist/lib/translation-review.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/components/data-table.tsx +9 -2
  39. package/src/components/swipeable-row.tsx +5 -1
  40. package/src/components/translation-review-labels.ts +5 -0
  41. package/src/components/translation-review.tsx +275 -48
  42. package/src/hooks/use-search-param-state.ts +19 -4
  43. package/src/i18n/german.ts +1 -0
  44. package/src/i18n/locales/en.ts +1 -0
  45. package/src/i18n/locales/es.ts +1 -0
  46. package/src/i18n/locales/fr.ts +1 -0
  47. package/src/i18n/locales/hu.ts +1 -0
  48. package/src/i18n/locales/it.ts +1 -0
  49. package/src/i18n/locales/zh.ts +1 -0
  50. package/src/i18n/review.ts +1 -0
  51. package/src/index.ts +6 -0
  52. package/src/lib/translation-review.ts +109 -0
  53. package/tokens.css +4 -0
@@ -15,7 +15,11 @@ interface SwipeAction {
15
15
  /** Rendered inside the panel. Icons stay the consumer's choice — this package
16
16
  * ships no opinion about which icon set an app uses. */
17
17
  icon?: ReactNode;
18
- /** Background utility class while the action is previewed but not yet armed. */
18
+ /** Background utility class while the action is previewed but not yet armed. The
19
+ * panel's text is white unless this sets a text colour too — and a fill that turns
20
+ * light in dark mode needs one: pass the fill's contrast token
21
+ * (`bg-[var(--danger)] text-[var(--danger-contrast)]`; also `--brand-contrast`,
22
+ * `--success-contrast`), as the kit's own swipes do since 0.26. */
19
23
  className: string;
20
24
  /** Background utility class once the drag has passed this action's threshold. */
21
25
  armedClassName: string;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/swipeable-row.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useRowSwipe, type SwipeStage } from \"../hooks/use-row-swipe\";\nimport { DEFAULT_SWIPEABLE_ROW_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * One armed swipe action: what it does, and how the reveal panel presents it.\n *\n * The two background classes are the idle and armed states. Before the first\n * threshold is crossed the panel previews the nearest action in its IDLE colour, so\n * the user can see what a little more drag will do; crossing the threshold flips it to\n * `armedClassName`. Passing the same value for both simply removes that feedback.\n */\nexport interface SwipeAction {\n onCommit: () => void;\n label: string;\n /** Rendered inside the panel. Icons stay the consumer's choice — this package\n * ships no opinion about which icon set an app uses. */\n icon?: ReactNode;\n /** Background utility class while the action is previewed but not yet armed. */\n className: string;\n /** Background utility class once the drag has passed this action's threshold. */\n armedClassName: string;\n}\n\n/**\n * `right`/`left` are PHYSICAL drag directions in every writing direction, and stay so on\n * purpose: a swipe is a movement of the finger across the glass, the gesture hook\n * measures it in screen pixels, and \"which way is destructive\" is an app decision that\n * RTL guidelines do not agree on. An app that wants mirrored actions in RTL swaps the\n * two arrays itself. What the component does mirror is everything it lays out: the\n * reveal panel's contents hug the physical edge being uncovered, and the keyboard\n * buttons sit at the row's logical END.\n */\nexport interface SwipeableRowProps {\n /** Actions committed by dragging (physically) RIGHT, nearest threshold first. */\n right?: SwipeAction[];\n /** Actions committed by dragging (physically) LEFT, nearest threshold first. */\n left?: SwipeAction[];\n /** Turn the gesture off — an open editor, a pending mutation, a locked row. This\n * also withdraws the keyboard buttons below: a row that must not be acted on must\n * not be actionable by ANY input. */\n enabled?: boolean;\n /** Names the revealed action buttons as a set (\"Row actions\"), for a reader that\n * reaches the group before it reaches any one button. Default:\n * `swipeableRow.actions` from the {@link UiKitProvider}, else English. */\n actionsLabel?: string;\n className?: string;\n children: ReactNode;\n}\n\n/** How much of the row stays on screen at full drag. A row that can be pulled\n * entirely off its own track reads as already deleted, and there is nothing left to\n * drag back; a sliver keeps the gesture reversible and the row identifiable. */\nconst PEEK_PX = 40;\n\n/**\n * A row whose horizontal drag reveals actions underneath it.\n *\n * The reveal panel spans the FULL row width (Keksdose feedback #174). It used to be\n * two half-width blocks side by side, which capped the drag at half the row so the\n * opposite panel could never be exposed — and on a row bound to three actions that\n * left ~55px between stages on a phone, close enough that the wrong one committed.\n * Only the side being dragged toward is painted now, so full width costs nothing:\n * at rest the content covers both anyway, and a drag only ever reveals one side.\n *\n * Thresholds are still not fixed pixel values — they are spread evenly across the\n * available drag, which is now the row width less a {@link PEEK_PX} sliver. A fixed\n * 72/150/228px ladder overshoots a narrow phone; a proportional one keeps every\n * action reachable and roughly doubles the room between them.\n *\n * **Whether the action will fire is stated three ways, not one** (feedback #174): the\n * panel goes from dimmed to solid, the icon grows and gains a filled disc, and the\n * label turns bold — plus the existing haptic tick per newly armed stage. A colour\n * shift alone is easy to miss mid-gesture, and it is invisible to anyone who cannot\n * distinguish the two tones.\n *\n * Clicks that follow a drag are swallowed in the capture phase: without that, a swipe\n * would also fire whatever click handler the row content carries (expanding it, opening\n * a dialog) on top of the action it just committed.\n *\n * ## The keyboard path is buttons, and deliberately not a typed gesture\n *\n * Every action here used to live exclusively inside a pointer drag — `onCommit` had no\n * other caller — so with a keyboard, with a screen reader, or on any input that cannot\n * express 120px of horizontal travel, the row's actions did not exist at all. That is\n * not a rough edge on a phone control: {@link DataTable} mounts this for every row of\n * its mobile layout, and the actions an app puts here are its destructive ones.\n *\n * The rejected fix was to map the gesture onto keys — arrows nudging `dx`, a chord that\n * \"swipes\". That invents a private idiom with nothing to discover it by, and it makes\n * the keyboard imitate a touchscreen instead of doing the job the touchscreen is doing.\n * So each action is ALSO a real button, `sr-only` until it takes focus and then shown at\n * the row's trailing edge, firing the identical `onCommit`: one definition of what an\n * action is, two ways to reach it, and nothing to drift apart. `sr-only` hides from\n * the eye and not from the accessibility tree, so the row also ANNOUNCES what can be\n * done to it rather than keeping it behind a gesture nobody can hear.\n */\nexport function SwipeableRow({\n right,\n left,\n enabled = true,\n actionsLabel,\n className,\n children,\n}: SwipeableRowProps) {\n const labels = useKitLabels(\"swipeableRow\", DEFAULT_SWIPEABLE_ROW_LABELS, {\n actions: actionsLabel,\n });\n const rowRef = useRef<HTMLDivElement>(null);\n const [rowWidth, setRowWidth] = useState(0);\n useEffect(() => {\n const el = rowRef.current;\n if (!el) return;\n setRowWidth(el.clientWidth);\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver((entries) => {\n for (const e of entries) setRowWidth(e.contentRect.width);\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n const rightActions = right ?? [];\n const leftActions = left ?? [];\n const active = enabled && (rightActions.length > 0 || leftActions.length > 0);\n // The whole row less a sliver, not half of it (feedback #174). `Math.max` keeps a\n // very narrow row (or one measured before layout) from producing a zero or\n // negative drag, which would arm every stage at once.\n const maxDrag = rowWidth > 0 ? Math.max(96, Math.round(rowWidth - PEEK_PX)) : 200;\n const spread = (actions: SwipeAction[]): SwipeStage[] =>\n actions.map((a, i) => ({\n threshold: Math.round((maxDrag * (i + 1)) / (actions.length + 1)),\n onCommit: a.onCommit,\n }));\n\n const swipe = useRowSwipe({\n enabled: active,\n rightStages: spread(rightActions),\n leftStages: spread(leftActions),\n maxDrag,\n });\n\n // A haptic tick per newly armed threshold, so each stage is felt and not only seen.\n const armedNow = swipe.dx >= 0 ? swipe.armedRightIndex : swipe.armedLeftIndex;\n const prevArmed = useRef(-1);\n useEffect(() => {\n if (armedNow > prevArmed.current && armedNow >= 0) navigator.vibrate?.(12);\n prevArmed.current = armedNow;\n }, [armedNow]);\n\n // Index -1 (nothing armed yet) still previews the nearest action, idle-coloured.\n const dx = swipe.dx;\n // Only the side being dragged toward is painted, which is what makes a full-width\n // panel safe.\n //\n // The side has to SURVIVE the release, though, and that is what this ref is for\n // (Keksdose feedback dev#467): *\"Swiping a category to the left with no option — if\n // letting it go it gets, for the way back, the background color of the opposite\n // swipe direction where there should be no color change at all.\"* On release dx\n // snaps to 0 while the row itself slides back over 150ms, so a plain `dx < 0` read\n // \"right\" for exactly the frames where the left side was still uncovered — and\n // painted the RIGHT action's colour into the gap. With no left action at all that\n // colour was the only thing the gesture ever showed.\n //\n // Adjusted during render rather than in an effect: an effect would repaint a frame\n // later (visibly, at 150ms). State set while rendering re-renders this component\n // before anything is committed, so the released frame already has the right side.\n const [lastSide, setLastSide] = useState<\"left\" | \"right\">(\"right\");\n const side = dx === 0 ? null : dx < 0 ? \"left\" : \"right\";\n if (side && side !== lastSide) setLastSide(side);\n const draggingLeft = side ? side === \"left\" : lastSide === \"left\";\n const shown = draggingLeft\n ? leftActions[Math.max(0, swipe.armedLeftIndex)]\n : rightActions[Math.max(0, swipe.armedRightIndex)];\n const armed = draggingLeft ? swipe.armedLeftIndex >= 0 : swipe.armedRightIndex >= 0;\n\n return (\n <div ref={rowRef} className={cn(\"relative overflow-hidden\", className)}>\n {active && shown && (\n <div\n className={cn(\n \"pointer-events-none absolute inset-0 flex items-center gap-2 px-4\",\n \"text-xs font-medium text-white transition-all\",\n // Dimmed until the drag has actually passed a threshold: \"nothing will\n // happen yet\" has to look different from \"let go and this fires\", and on\n // a moving row a one-step colour change was too quiet to notice.\n armed ? \"opacity-100\" : \"opacity-60\",\n armed ? shown.armedClassName : shown.className,\n // The panel is anchored to the edge the row is uncovering, so the label\n // sits where the eye already is rather than across the screen. That edge is\n // physical, while a flex row starts at the INLINE start — so in RTL the two\n // directions swap, or the label sat under the row on the covered side.\n draggingLeft ? \"flex-row-reverse rtl:flex-row\" : \"rtl:flex-row-reverse\",\n )}\n >\n {shown.icon && (\n <span\n className={cn(\n \"flex shrink-0 items-center justify-center rounded-full transition-all\",\n // Armed, the icon gets a disc of its own and grows — a second,\n // non-colour signal, and the one that stays readable at a glance.\n armed ? \"size-8 scale-110 bg-white/25\" : \"size-7 bg-white/10\",\n )}\n >\n {shown.icon}\n </span>\n )}\n <span className={cn(\"truncate transition-all\", armed && \"text-sm font-semibold\")}>\n {shown.label}\n </span>\n </div>\n )}\n <div\n // Capture phase: the row's own click handler lives on a DESCENDANT, so a\n // bubble-phase listener here would run after it — too late to suppress.\n onClickCapture={(e) => {\n if (!swipe.consumeClick()) return;\n e.preventDefault();\n e.stopPropagation();\n }}\n className={cn(\n \"relative bg-[var(--bg-surface)]\",\n // A mouse drag is a supported way to swipe (use-row-swipe gates only on\n // the button, not the pointer type), and a horizontal mouse drag over\n // text also SELECTS that text — so committing an action left the row\n // highlighted blue. Suppressed only while dragging, so text in a row at\n // rest stays selectable.\n swipe.dragging ? \"select-none\" : \"transition-transform duration-150 motion-reduce:transition-none\",\n )}\n style={{\n transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,\n touchAction: active ? \"pan-y\" : undefined,\n }}\n {...(active ? swipe.handlers : {})}\n >\n {children}\n </div>\n {active && (\n <div\n role=\"group\"\n aria-label={labels.actions}\n // OUTSIDE the sliding div on purpose: an action that travels with the row it\n // acts on is a moving target, and the drag transform would carry it off the\n // screen mid-gesture.\n //\n // `pointer-events-none` here, restored on a button only once it HAS focus: a\n // sr-only button is clipped to nothing but is still a hit target, and this\n // strip sits at the row's trailing edge — exactly where a drag toward the\n // start begins.\n //\n // `end-0`/`pe-2`, not `right-0`/`pr-2`: in RTL the right edge is where the\n // row's title starts, and a focused button there covered it.\n // A gesture that silently failed to start because it landed on an invisible\n // button is not a bug anyone would find by looking at the screen.\n className=\"pointer-events-none absolute inset-y-0 end-0 z-10 flex items-center gap-1 pe-2\"\n >\n {/* Both sides, each still nearest-threshold first, so the tab order reads in\n the order the gesture arms them. */}\n {[...rightActions, ...leftActions].map((action, i) => (\n <button\n key={`${action.label}-${i}`}\n type=\"button\"\n onClick={action.onCommit}\n className={cn(\n \"sr-only whitespace-nowrap rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-sm\",\n \"focus:not-sr-only focus:pointer-events-auto focus:inline-flex focus:items-center focus:gap-1.5\",\n \"focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n )}\n >\n {/* The label is the button's accessible name, so the caller's icon is\n decoration on top of it rather than a second reading of it. */}\n {action.icon && (\n <span aria-hidden=\"true\" className=\"[&_svg]:size-4\">\n {action.icon}\n </span>\n )}\n {action.label}\n </button>\n ))}\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/swipeable-row.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useRowSwipe, type SwipeStage } from \"../hooks/use-row-swipe\";\nimport { DEFAULT_SWIPEABLE_ROW_LABELS, useKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * One armed swipe action: what it does, and how the reveal panel presents it.\n *\n * The two background classes are the idle and armed states. Before the first\n * threshold is crossed the panel previews the nearest action in its IDLE colour, so\n * the user can see what a little more drag will do; crossing the threshold flips it to\n * `armedClassName`. Passing the same value for both simply removes that feedback.\n */\nexport interface SwipeAction {\n onCommit: () => void;\n label: string;\n /** Rendered inside the panel. Icons stay the consumer's choice — this package\n * ships no opinion about which icon set an app uses. */\n icon?: ReactNode;\n /** Background utility class while the action is previewed but not yet armed. The\n * panel's text is white unless this sets a text colour too — and a fill that turns\n * light in dark mode needs one: pass the fill's contrast token\n * (`bg-[var(--danger)] text-[var(--danger-contrast)]`; also `--brand-contrast`,\n * `--success-contrast`), as the kit's own swipes do since 0.26. */\n className: string;\n /** Background utility class once the drag has passed this action's threshold. */\n armedClassName: string;\n}\n\n/**\n * `right`/`left` are PHYSICAL drag directions in every writing direction, and stay so on\n * purpose: a swipe is a movement of the finger across the glass, the gesture hook\n * measures it in screen pixels, and \"which way is destructive\" is an app decision that\n * RTL guidelines do not agree on. An app that wants mirrored actions in RTL swaps the\n * two arrays itself. What the component does mirror is everything it lays out: the\n * reveal panel's contents hug the physical edge being uncovered, and the keyboard\n * buttons sit at the row's logical END.\n */\nexport interface SwipeableRowProps {\n /** Actions committed by dragging (physically) RIGHT, nearest threshold first. */\n right?: SwipeAction[];\n /** Actions committed by dragging (physically) LEFT, nearest threshold first. */\n left?: SwipeAction[];\n /** Turn the gesture off — an open editor, a pending mutation, a locked row. This\n * also withdraws the keyboard buttons below: a row that must not be acted on must\n * not be actionable by ANY input. */\n enabled?: boolean;\n /** Names the revealed action buttons as a set (\"Row actions\"), for a reader that\n * reaches the group before it reaches any one button. Default:\n * `swipeableRow.actions` from the {@link UiKitProvider}, else English. */\n actionsLabel?: string;\n className?: string;\n children: ReactNode;\n}\n\n/** How much of the row stays on screen at full drag. A row that can be pulled\n * entirely off its own track reads as already deleted, and there is nothing left to\n * drag back; a sliver keeps the gesture reversible and the row identifiable. */\nconst PEEK_PX = 40;\n\n/**\n * A row whose horizontal drag reveals actions underneath it.\n *\n * The reveal panel spans the FULL row width (Keksdose feedback #174). It used to be\n * two half-width blocks side by side, which capped the drag at half the row so the\n * opposite panel could never be exposed — and on a row bound to three actions that\n * left ~55px between stages on a phone, close enough that the wrong one committed.\n * Only the side being dragged toward is painted now, so full width costs nothing:\n * at rest the content covers both anyway, and a drag only ever reveals one side.\n *\n * Thresholds are still not fixed pixel values — they are spread evenly across the\n * available drag, which is now the row width less a {@link PEEK_PX} sliver. A fixed\n * 72/150/228px ladder overshoots a narrow phone; a proportional one keeps every\n * action reachable and roughly doubles the room between them.\n *\n * **Whether the action will fire is stated three ways, not one** (feedback #174): the\n * panel goes from dimmed to solid, the icon grows and gains a filled disc, and the\n * label turns bold — plus the existing haptic tick per newly armed stage. A colour\n * shift alone is easy to miss mid-gesture, and it is invisible to anyone who cannot\n * distinguish the two tones.\n *\n * Clicks that follow a drag are swallowed in the capture phase: without that, a swipe\n * would also fire whatever click handler the row content carries (expanding it, opening\n * a dialog) on top of the action it just committed.\n *\n * ## The keyboard path is buttons, and deliberately not a typed gesture\n *\n * Every action here used to live exclusively inside a pointer drag — `onCommit` had no\n * other caller — so with a keyboard, with a screen reader, or on any input that cannot\n * express 120px of horizontal travel, the row's actions did not exist at all. That is\n * not a rough edge on a phone control: {@link DataTable} mounts this for every row of\n * its mobile layout, and the actions an app puts here are its destructive ones.\n *\n * The rejected fix was to map the gesture onto keys — arrows nudging `dx`, a chord that\n * \"swipes\". That invents a private idiom with nothing to discover it by, and it makes\n * the keyboard imitate a touchscreen instead of doing the job the touchscreen is doing.\n * So each action is ALSO a real button, `sr-only` until it takes focus and then shown at\n * the row's trailing edge, firing the identical `onCommit`: one definition of what an\n * action is, two ways to reach it, and nothing to drift apart. `sr-only` hides from\n * the eye and not from the accessibility tree, so the row also ANNOUNCES what can be\n * done to it rather than keeping it behind a gesture nobody can hear.\n */\nexport function SwipeableRow({\n right,\n left,\n enabled = true,\n actionsLabel,\n className,\n children,\n}: SwipeableRowProps) {\n const labels = useKitLabels(\"swipeableRow\", DEFAULT_SWIPEABLE_ROW_LABELS, {\n actions: actionsLabel,\n });\n const rowRef = useRef<HTMLDivElement>(null);\n const [rowWidth, setRowWidth] = useState(0);\n useEffect(() => {\n const el = rowRef.current;\n if (!el) return;\n setRowWidth(el.clientWidth);\n if (typeof ResizeObserver === \"undefined\") return;\n const ro = new ResizeObserver((entries) => {\n for (const e of entries) setRowWidth(e.contentRect.width);\n });\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n const rightActions = right ?? [];\n const leftActions = left ?? [];\n const active = enabled && (rightActions.length > 0 || leftActions.length > 0);\n // The whole row less a sliver, not half of it (feedback #174). `Math.max` keeps a\n // very narrow row (or one measured before layout) from producing a zero or\n // negative drag, which would arm every stage at once.\n const maxDrag = rowWidth > 0 ? Math.max(96, Math.round(rowWidth - PEEK_PX)) : 200;\n const spread = (actions: SwipeAction[]): SwipeStage[] =>\n actions.map((a, i) => ({\n threshold: Math.round((maxDrag * (i + 1)) / (actions.length + 1)),\n onCommit: a.onCommit,\n }));\n\n const swipe = useRowSwipe({\n enabled: active,\n rightStages: spread(rightActions),\n leftStages: spread(leftActions),\n maxDrag,\n });\n\n // A haptic tick per newly armed threshold, so each stage is felt and not only seen.\n const armedNow = swipe.dx >= 0 ? swipe.armedRightIndex : swipe.armedLeftIndex;\n const prevArmed = useRef(-1);\n useEffect(() => {\n if (armedNow > prevArmed.current && armedNow >= 0) navigator.vibrate?.(12);\n prevArmed.current = armedNow;\n }, [armedNow]);\n\n // Index -1 (nothing armed yet) still previews the nearest action, idle-coloured.\n const dx = swipe.dx;\n // Only the side being dragged toward is painted, which is what makes a full-width\n // panel safe.\n //\n // The side has to SURVIVE the release, though, and that is what this ref is for\n // (Keksdose feedback dev#467): *\"Swiping a category to the left with no option — if\n // letting it go it gets, for the way back, the background color of the opposite\n // swipe direction where there should be no color change at all.\"* On release dx\n // snaps to 0 while the row itself slides back over 150ms, so a plain `dx < 0` read\n // \"right\" for exactly the frames where the left side was still uncovered — and\n // painted the RIGHT action's colour into the gap. With no left action at all that\n // colour was the only thing the gesture ever showed.\n //\n // Adjusted during render rather than in an effect: an effect would repaint a frame\n // later (visibly, at 150ms). State set while rendering re-renders this component\n // before anything is committed, so the released frame already has the right side.\n const [lastSide, setLastSide] = useState<\"left\" | \"right\">(\"right\");\n const side = dx === 0 ? null : dx < 0 ? \"left\" : \"right\";\n if (side && side !== lastSide) setLastSide(side);\n const draggingLeft = side ? side === \"left\" : lastSide === \"left\";\n const shown = draggingLeft\n ? leftActions[Math.max(0, swipe.armedLeftIndex)]\n : rightActions[Math.max(0, swipe.armedRightIndex)];\n const armed = draggingLeft ? swipe.armedLeftIndex >= 0 : swipe.armedRightIndex >= 0;\n\n return (\n <div ref={rowRef} className={cn(\"relative overflow-hidden\", className)}>\n {active && shown && (\n <div\n className={cn(\n \"pointer-events-none absolute inset-0 flex items-center gap-2 px-4\",\n \"text-xs font-medium text-white transition-all\",\n // Dimmed until the drag has actually passed a threshold: \"nothing will\n // happen yet\" has to look different from \"let go and this fires\", and on\n // a moving row a one-step colour change was too quiet to notice.\n armed ? \"opacity-100\" : \"opacity-60\",\n armed ? shown.armedClassName : shown.className,\n // The panel is anchored to the edge the row is uncovering, so the label\n // sits where the eye already is rather than across the screen. That edge is\n // physical, while a flex row starts at the INLINE start — so in RTL the two\n // directions swap, or the label sat under the row on the covered side.\n draggingLeft ? \"flex-row-reverse rtl:flex-row\" : \"rtl:flex-row-reverse\",\n )}\n >\n {shown.icon && (\n <span\n className={cn(\n \"flex shrink-0 items-center justify-center rounded-full transition-all\",\n // Armed, the icon gets a disc of its own and grows — a second,\n // non-colour signal, and the one that stays readable at a glance.\n armed ? \"size-8 scale-110 bg-white/25\" : \"size-7 bg-white/10\",\n )}\n >\n {shown.icon}\n </span>\n )}\n <span className={cn(\"truncate transition-all\", armed && \"text-sm font-semibold\")}>\n {shown.label}\n </span>\n </div>\n )}\n <div\n // Capture phase: the row's own click handler lives on a DESCENDANT, so a\n // bubble-phase listener here would run after it — too late to suppress.\n onClickCapture={(e) => {\n if (!swipe.consumeClick()) return;\n e.preventDefault();\n e.stopPropagation();\n }}\n className={cn(\n \"relative bg-[var(--bg-surface)]\",\n // A mouse drag is a supported way to swipe (use-row-swipe gates only on\n // the button, not the pointer type), and a horizontal mouse drag over\n // text also SELECTS that text — so committing an action left the row\n // highlighted blue. Suppressed only while dragging, so text in a row at\n // rest stays selectable.\n swipe.dragging ? \"select-none\" : \"transition-transform duration-150 motion-reduce:transition-none\",\n )}\n style={{\n transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,\n touchAction: active ? \"pan-y\" : undefined,\n }}\n {...(active ? swipe.handlers : {})}\n >\n {children}\n </div>\n {active && (\n <div\n role=\"group\"\n aria-label={labels.actions}\n // OUTSIDE the sliding div on purpose: an action that travels with the row it\n // acts on is a moving target, and the drag transform would carry it off the\n // screen mid-gesture.\n //\n // `pointer-events-none` here, restored on a button only once it HAS focus: a\n // sr-only button is clipped to nothing but is still a hit target, and this\n // strip sits at the row's trailing edge — exactly where a drag toward the\n // start begins.\n //\n // `end-0`/`pe-2`, not `right-0`/`pr-2`: in RTL the right edge is where the\n // row's title starts, and a focused button there covered it.\n // A gesture that silently failed to start because it landed on an invisible\n // button is not a bug anyone would find by looking at the screen.\n className=\"pointer-events-none absolute inset-y-0 end-0 z-10 flex items-center gap-1 pe-2\"\n >\n {/* Both sides, each still nearest-threshold first, so the tab order reads in\n the order the gesture arms them. */}\n {[...rightActions, ...leftActions].map((action, i) => (\n <button\n key={`${action.label}-${i}`}\n type=\"button\"\n onClick={action.onCommit}\n className={cn(\n \"sr-only whitespace-nowrap rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-2 py-1 text-xs font-medium text-[var(--text-primary)] shadow-sm\",\n \"focus:not-sr-only focus:pointer-events-auto focus:inline-flex focus:items-center focus:gap-1.5\",\n \"focus-visible:ring-2 focus-visible:ring-[var(--brand)]\",\n )}\n >\n {/* The label is the button's accessible name, so the caller's icon is\n decoration on top of it rather than a second reading of it. */}\n {action.icon && (\n <span aria-hidden=\"true\" className=\"[&_svg]:size-4\">\n {action.icon}\n </span>\n )}\n {action.label}\n </button>\n ))}\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -94,6 +94,10 @@ interface TranslationReviewLabels {
94
94
  /** 0.25: asked first when a group's bulk approve is larger than a page — more than the
95
95
  * reviewer can have had on screen. `group` is the group's name. */
96
96
  confirmGroup: (count: number, group: string) => string;
97
+ /** The Undo toast after a swipe bound to `clear` took a verdict back (keksdose, live
98
+ * #377 rework: the swipes bound in its settings) — one string, or a batch of them.
99
+ * The swipe itself is named by {@link reset}. */
100
+ clearedToast: (count: number) => string;
97
101
  }
98
102
  declare const DEFAULT_TRANSLATION_REVIEW_LABELS: TranslationReviewLabels;
99
103
  /** A status's word, from the namespace's five. */
@@ -48,7 +48,8 @@ const DEFAULT_TRANSLATION_REVIEW_LABELS = {
48
48
  approvedToast: (count) => count === 1 ? "String approved" : `${count} strings approved`,
49
49
  groupCount: (unreviewed, total) => `${unreviewed} unreviewed / ${total}`,
50
50
  approveGroup: (count) => `Approve unreviewed (${count})`,
51
- confirmGroup: (count, group) => count === 1 ? `Approve the unreviewed string in ${group}?` : `Approve all ${count} unreviewed strings in ${group}, including those not on screen?`
51
+ confirmGroup: (count, group) => count === 1 ? `Approve the unreviewed string in ${group}?` : `Approve all ${count} unreviewed strings in ${group}, including those not on screen?`,
52
+ clearedToast: (count) => count === 1 ? "String marked unreviewed" : `${count} strings marked unreviewed`
52
53
  };
53
54
  function reviewStatusLabel(labels, status) {
54
55
  switch (status) {
@@ -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}\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};\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":";AAkGO,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;AAC3D;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 /** 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,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { TranslationRow, TranslationReviewWrite, TranslationReviewKey, TranslationReviewFilter } from '../lib/translation-review.js';
3
+ import { TranslationRow, TranslationReviewWrite, TranslationReviewKey, TranslationReviewFilter, TranslationReviewSwipeBinding } from '../lib/translation-review.js';
4
4
  import { TranslationReviewLabels } from './translation-review-labels.js';
5
5
  export { DEFAULT_TRANSLATION_REVIEW_LABELS } from './translation-review-labels.js';
6
6
  export { REVIEW_STATUS_TONES, ReviewStatusChip, ReviewStatusChipProps, TranslationExportButton, TranslationExportButtonProps, TranslationLocaleTab, TranslationLocaleTabs, TranslationLocaleTabsProps, TranslationProgress, TranslationProgressProps } from './translation-review-parts.js';
@@ -46,7 +46,8 @@ interface TranslationReviewSaveInfo {
46
46
  /** The open editor, a row's approve button, a swipe on a phone card, a group's
47
47
  * "Approve unreviewed", the selection's bulk bar, or an Undo pressed on the toast. */
48
48
  origin: TranslationReviewOrigin;
49
- /** The panel shows its own toast for this write (an approval's Undo toast). */
49
+ /** The panel shows its own toast for this write (an approval's Undo toast, or a
50
+ * swiped `clear`'s). */
50
51
  toasted: boolean;
51
52
  }
52
53
  /** How {@link TranslationReviewPanelProps.groupBy} names a row's group. */
@@ -85,6 +86,11 @@ interface TranslationReviewPanelProps {
85
86
  /**
86
87
  * Controlled filters — for an app that keeps them in the URL (keksdose's `?status=`,
87
88
  * `?ns=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.
89
+ *
90
+ * A field passed here is the app's, and the control shows what the app says: one the app
91
+ * passes but does not write back from `onFilterChange` is a control that does nothing.
92
+ * A development build warns about that once per field (0.25.1, Kurvenschmiede); a field
93
+ * left out entirely is fine — the panel keeps it.
88
94
  */
89
95
  filter?: Partial<TranslationReviewFilter>;
90
96
  /** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s
@@ -119,8 +125,31 @@ interface TranslationReviewPanelProps {
119
125
  *
120
126
  * Off by default, so no existing page gains a gesture on the bump. Phones only; the
121
127
  * desktop table keeps its approve button.
128
+ *
129
+ * A BINDING instead of `true` (keksdose — Marcel's live #377 rework, 2026-10-03: *"Add
130
+ * the swipe options to be shown also to the settings /settings#interaction area where
131
+ * the other swipe options are defined"*): keksdose lets the user bind every list's
132
+ * swipes in Settings → Interaction, per side a primary action at the first threshold and
133
+ * a secondary one at a longer drag, and a fixed mapping is one its settings cannot
134
+ * reach. So each logical side takes an ordered ladder of
135
+ * {@link TranslationReviewSwipeAction}s — index 0 at the first threshold, index 1 at the
136
+ * longer drag, as {@link SwipeableRow} stages them:
137
+ *
138
+ * swipe={{ end: ["approve", "clear"], start: ["edit"] }}
139
+ *
140
+ * `approve` (with Undo), `edit` (the editor, cursor in the wording — "Needs a change"),
141
+ * `clear` (the verdict taken back, with Undo, which stores it again; only with
142
+ * `onClear`). An action a row cannot offer drops out and the ladder closes up behind it
143
+ * ({@link translationReviewSwipePlan}): `approve` on an approved or missing string,
144
+ * `clear` on a row with no verdict. Every rule above still holds for every binding —
145
+ * no side swipes in a `readOnly` locale, under a write lock, with the editor open or
146
+ * the row's write out.
147
+ *
148
+ * `true` is {@link DEFAULT_TRANSLATION_REVIEW_SWIPE} — `{ end: ["approve"], start:
149
+ * ["edit"] }`, 0.25's mapping exactly — and `false` or nothing is no swipe. A binding
150
+ * turns the Undo toast on by default, as `true` does.
122
151
  */
123
- swipe?: boolean;
152
+ swipe?: boolean | TranslationReviewSwipeBinding;
124
153
  /**
125
154
  * Answer every approval with the kit's Undo toast (`toast.undo`): a row's button, a
126
155
  * swipe, the editor's Approve, a group's and the selection's bulk approve. Undo stores
@@ -156,11 +185,42 @@ interface TranslationReviewPanelProps {
156
185
  * rows, and paging one area does not move another — rather than one pager cutting
157
186
  * through the headers. `storageKey` keeps persisting the ungrouped table only.
158
187
  *
159
- * Groups fold (each on its own, remembered while the panel lives). On a phone they
160
- * start folded when there are several — the headers are then the area list, and the
161
- * reviewer opens the one to work through; on a wide screen they start open.
188
+ * Groups fold (each on its own, remembered while the panel lives), and a folded group's
189
+ * rows are not rendered at all — only its header. Which groups start open is
190
+ * {@link defaultGroupsOpen}: by default, on a phone they start folded when there are
191
+ * several — the headers are then the area list, and the reviewer opens the one to work
192
+ * through — and on a wide screen they start open while that is cheap (see there).
162
193
  */
163
194
  groupBy?: TranslationReviewGroupBy;
195
+ /**
196
+ * Which groups start open under {@link groupBy} (0.25.1). A group the reviewer opens or
197
+ * folds keeps that; this is what every other group follows.
198
+ *
199
+ * - `"auto"` (default): on a phone, open when there is one group and folded when there
200
+ * are several, as in 0.25.0. On a wide screen, open while opening every group renders
201
+ * at most 100 rows (each group shows up to `pageSize` of them; the budget is one page
202
+ * where `pageSize` is larger) — and folded above that, the headers then being the area
203
+ * list, as on a phone.
204
+ * - `true`: every group starts open, on a phone too.
205
+ * - `false`: every group starts folded, on a wide screen too.
206
+ *
207
+ * WHY A BUDGET OF ROWS (keksdose, measured on 0.25.0): a wide screen opened EVERY group.
208
+ * keksdose's /translations has 122 areas and ~4700 strings — 122 tables and 2053 rows at
209
+ * once, ~46 000 elements, 5.2 s before the panel appeared and 2.5 s for a filter click
210
+ * to paint, against 50 rows and 0.34 s ungrouped; its page test went from 4 s to 146 s.
211
+ * kastlan (~31 areas, ~1300 rows open) and Kurvenschmiede (28, ~700) had the same page.
212
+ * What costs is the rows on screen, so the rule counts them rather than the groups:
213
+ * 100 rows is two of the panel's default pages — any one or two areas still open as
214
+ * before, however long, and so do a dozen small ones, or a search whose hits fall in
215
+ * many areas — while a whole catalogue starts as its list of areas: keksdose's shape
216
+ * in jsdom, 122 headers and ~2100 elements instead of ~44 000 (the ungrouped panel has
217
+ * ~1200). The rule follows the filters: narrow them and the groups open.
218
+ *
219
+ * A boolean rather than a controlled open state: the panel already keeps each group's
220
+ * own state, and an app that only wants "all open" or "all folded" should not have to
221
+ * keep a record of 122 flags to say it.
222
+ */
223
+ defaultGroupsOpen?: boolean | "auto";
164
224
  /** A group's name. Default: `sourceLabels` for `groupBy="source"`, else the key. */
165
225
  groupLabel?: (group: string) => string;
166
226
  /** The level of the group headings. Default `h2`: the panel is a page's body, under
@@ -190,6 +250,6 @@ interface TranslationReviewPanelProps {
190
250
  * approval it takes back. A swipe on the next card while one is out is queued rather than
191
251
  * refused — a reviewer swiping down a list is faster than the server.
192
252
  */
193
- declare function TranslationReviewPanel({ rows: rowsProp, localeLabel, referenceLabel, onSave, onClear, readOnly, areas, areaLabels, inArea, sourceLabels, filter: filterProp, onFilterChange, progress, pageSize, storageKey, formatDate, formatError, swipe, undo: undoProp, groupBy, groupLabel, groupHeadingAs, className, labels: labelsProp, }: TranslationReviewPanelProps): react.JSX.Element;
253
+ declare function TranslationReviewPanel({ rows: rowsProp, localeLabel, referenceLabel, onSave, onClear, readOnly, areas, areaLabels, inArea, sourceLabels, filter: filterProp, onFilterChange, progress, pageSize, storageKey, formatDate, formatError, swipe, undo: undoProp, groupBy, groupLabel, groupHeadingAs, defaultGroupsOpen, className, labels: labelsProp, }: TranslationReviewPanelProps): react.JSX.Element;
194
254
 
195
255
  export { type TranslationReviewGroupBy, TranslationReviewLabels, type TranslationReviewOrigin, TranslationReviewPanel, type TranslationReviewPanelProps, type TranslationReviewSaveInfo };
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useId, useMemo, useRef, useState } from "react";
3
+ import { useEffect, useId, useMemo, useRef, useState } from "react";
4
4
  import { Check, ListChecks, PencilLine, RotateCcw } from "lucide-react";
5
5
  import { useMediaQuery } from "../hooks/use-media-query.js";
6
6
  import { useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
7
7
  import { cn } from "../lib/cn.js";
8
8
  import {
9
9
  DEFAULT_TRANSLATION_REVIEW_FILTER,
10
+ DEFAULT_TRANSLATION_REVIEW_SWIPE,
10
11
  REVIEW_STATUSES,
11
12
  filterTranslationRows,
12
13
  groupTranslationRows,
@@ -14,6 +15,7 @@ import {
14
15
  reviewUndo,
15
16
  reviewWrite,
16
17
  summariseRows,
18
+ translationReviewSwipePlan,
17
19
  unreviewedRows
18
20
  } from "../lib/translation-review.js";
19
21
  import { AlertBanner } from "./alert-banner.js";
@@ -40,6 +42,53 @@ import {
40
42
  import { TranslationReviewEditor as TranslationReviewEditor2 } from "./translation-review-editor.js";
41
43
  import { DEFAULT_TRANSLATION_REVIEW_LABELS as DEFAULT_TRANSLATION_REVIEW_LABELS2 } from "./translation-review-labels.js";
42
44
  const MD_UP = "(min-width: 768px)";
45
+ const GROUPS_OPEN_ROW_BUDGET = 100;
46
+ function groupsFitOpen(groups, pageSize) {
47
+ const budget = Math.max(GROUPS_OPEN_ROW_BUDGET, pageSize);
48
+ let rendered = 0;
49
+ for (const group of groups) {
50
+ rendered += Math.min(group.rows.length, pageSize);
51
+ if (rendered > budget) return false;
52
+ }
53
+ return true;
54
+ }
55
+ function devWarnings() {
56
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
57
+ }
58
+ const FILTER_ECHO_MS = 1500;
59
+ function useFilterEchoCheck(filterProp) {
60
+ const committed = useRef(filterProp);
61
+ useEffect(() => {
62
+ committed.current = filterProp;
63
+ });
64
+ const pending = useRef(/* @__PURE__ */ new Map());
65
+ const warned = useRef(/* @__PURE__ */ new Set());
66
+ const timer = useRef(void 0);
67
+ useEffect(() => () => clearTimeout(timer.current), []);
68
+ return (next) => {
69
+ if (filterProp === void 0 || !devWarnings()) return;
70
+ for (const field of Object.keys(next)) {
71
+ const from = filterProp[field];
72
+ if (from === void 0 || warned.current.has(field)) continue;
73
+ const seen = pending.current.get(field);
74
+ if (seen) seen.to = next[field];
75
+ else if (next[field] !== from) pending.current.set(field, { from, to: next[field] });
76
+ }
77
+ if (pending.current.size === 0) return;
78
+ clearTimeout(timer.current);
79
+ timer.current = setTimeout(() => {
80
+ const now = committed.current;
81
+ for (const [field, { from, to }] of pending.current) {
82
+ if (to === from || now?.[field] !== from || warned.current.has(field)) continue;
83
+ warned.current.add(field);
84
+ console.warn(
85
+ `[ui-kit] TranslationReviewPanel: the "${field}" filter was changed to ${JSON.stringify(to)}, but the \`filter\` prop still says ${JSON.stringify(from)}, so the control does nothing. A field passed in \`filter\` is the app's: write back every field \`onFilterChange\` reports, in one update (useSearchParamsState's setter takes the object as it is), or leave the field out of \`filter\` and the panel keeps it. (Development builds only; once per field.)`
86
+ );
87
+ }
88
+ pending.current.clear();
89
+ }, FILTER_ECHO_MS);
90
+ };
91
+ }
43
92
  function TextCell({ row, labels, clamp }) {
44
93
  return row.text === "" ? /* @__PURE__ */ jsx("span", { className: "italic text-[var(--text-muted)]", children: labels.missingText }) : /* @__PURE__ */ jsx("span", { lang: row.locale, className: cn("whitespace-pre-line break-words", clamp && "line-clamp-3"), children: row.text });
45
94
  }
@@ -66,6 +115,7 @@ function TranslationReviewPanel({
66
115
  groupBy,
67
116
  groupLabel,
68
117
  groupHeadingAs = "h2",
118
+ defaultGroupsOpen = "auto",
69
119
  className,
70
120
  labels: labelsProp
71
121
  }) {
@@ -76,12 +126,14 @@ function TranslationReviewPanel({
76
126
  const baseId = useId();
77
127
  const rootRef = useRef(null);
78
128
  const editable = !readOnly && onSave !== void 0;
79
- const undoOn = undoProp ?? (swipe || groupBy !== void 0);
129
+ const binding = swipe === true ? DEFAULT_TRANSLATION_REVIEW_SWIPE : swipe || null;
130
+ const undoOn = undoProp ?? (binding !== null || groupBy !== void 0);
80
131
  const [ownFilter, setOwnFilter] = useState(DEFAULT_TRANSLATION_REVIEW_FILTER);
81
132
  const filter = { ...ownFilter };
82
133
  for (const [name, value] of Object.entries(filterProp ?? {})) {
83
134
  if (value !== void 0) Object.assign(filter, { [name]: value });
84
135
  }
136
+ const echoFilter = useFilterEchoCheck(filterProp);
85
137
  const [openId, setOpenId] = useState(null);
86
138
  const [focusId, setFocusId] = useState(null);
87
139
  const [selected, setSelected] = useState(() => /* @__PURE__ */ new Set());
@@ -117,9 +169,12 @@ function TranslationReviewPanel({
117
169
  }, [visible, groupBy]);
118
170
  const groupName = (key) => groupLabel?.(key) ?? (groupBy === "source" ? sourceLabels?.[key] : void 0) ?? (key || "\u2014");
119
171
  const [groupOpen, setGroupOpen] = useState({});
120
- const groupOpenByDefault = !(phone && (groups?.length ?? 0) > 1);
172
+ const groupOpenByDefault = defaultGroupsOpen !== "auto" ? defaultGroupsOpen : groups !== null && (phone ? groups.length <= 1 : groupsFitOpen(groups, pageSize));
121
173
  const isGroupOpen = (key) => groupOpen[key] ?? groupOpenByDefault;
122
174
  const setGroupOpenFor = (key, open) => setGroupOpen((prev) => ({ ...prev, [key]: open }));
175
+ const keepGroupOpen = (key) => {
176
+ if (key !== void 0) setGroupOpen((prev) => key in prev ? prev : { ...prev, [key]: true });
177
+ };
123
178
  const [confirming, setConfirming] = useState(null);
124
179
  const confirmFocus = useRef(null);
125
180
  const [groupSorts, setGroupSorts] = useState([]);
@@ -127,6 +182,7 @@ function TranslationReviewPanel({
127
182
  const next = { ...filter, ...patch };
128
183
  setOwnFilter(next);
129
184
  onFilterChange?.(next);
185
+ echoFilter(next);
130
186
  setSelected(/* @__PURE__ */ new Set());
131
187
  };
132
188
  const run = (key, action) => {
@@ -149,9 +205,9 @@ function TranslationReviewPanel({
149
205
  tail.current = step;
150
206
  return step;
151
207
  };
152
- const sayApproved = (targets, description) => {
208
+ const sayDone = (targets, description, cleared = false) => {
153
209
  const back = reviewUndo(targets);
154
- const message = labels.approvedToast(targets.length);
210
+ const message = (cleared ? labels.clearedToast : labels.approvedToast)(targets.length);
155
211
  const save = onSave;
156
212
  const clear = onClear;
157
213
  if (!save || back.clears.length > 0 && !clear) {
@@ -171,13 +227,20 @@ function TranslationReviewPanel({
171
227
  if (!onSave || targets.length === 0) return false;
172
228
  const save = onSave;
173
229
  const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, "APPROVED")), { origin, toasted: undoOn }));
174
- if (ok && undoOn) sayApproved(targets, description);
230
+ if (ok && undoOn) sayDone(targets, description);
175
231
  return ok;
176
232
  };
233
+ const clearRow = async (row) => {
234
+ if (!onClear || row.review === null) return;
235
+ const clear = onClear;
236
+ const keys = [{ locale: row.locale, key: row.key }];
237
+ const ok = await run(`row:${row.id}`, () => clear(keys, { origin: "swipe", toasted: undoOn }));
238
+ if (ok && undoOn) sayDone([row], row.key, true);
239
+ };
177
240
  const saveFromEditor = async (row, write) => {
178
241
  const toasted = undoOn && write.verdict === "APPROVED";
179
242
  await onSave?.([write], { origin: "editor", toasted });
180
- if (toasted) sayApproved([row], row.key);
243
+ if (toasted) sayDone([row], row.key);
181
244
  };
182
245
  const keepFocusAt = (groupKey) => {
183
246
  const check = () => {
@@ -276,33 +339,53 @@ function TranslationReviewPanel({
276
339
  )
277
340
  });
278
341
  }
279
- const swipeActions = (r) => {
280
- if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;
342
+ const swipeAction = (r, id, groupKey) => {
281
343
  const missing = r.text === "";
282
- return {
283
- end: missing || r.status === "approved" ? [] : [
284
- {
344
+ switch (id) {
345
+ case "approve":
346
+ return {
285
347
  label: labels.approve,
286
348
  icon: /* @__PURE__ */ jsx(Check, { className: "size-4", "aria-hidden": true }),
287
349
  onCommit: () => void approve(`row:${r.id}`, [r], "swipe", r.key),
288
- className: "bg-[var(--success)]",
289
- armedClassName: "bg-[var(--success)]"
290
- }
291
- ],
292
- start: [
293
- {
350
+ // Text on the fill in its own contrast colour: the fills are pastels in dark
351
+ // mode, where SwipeableRow's default white text was unreadable (0.26).
352
+ className: "bg-[var(--success)] text-[var(--success-contrast)]",
353
+ armedClassName: "bg-[var(--success)] text-[var(--success-contrast)]"
354
+ };
355
+ case "edit":
356
+ return {
294
357
  label: missing ? labels.suggest : labels.flag,
295
358
  icon: /* @__PURE__ */ jsx(PencilLine, { className: "size-4", "aria-hidden": true }),
296
359
  // Opens the editor rather than sending the string back: "needs a change"
297
360
  // with neither a note nor a better wording tells nobody anything.
298
361
  onCommit: () => {
362
+ keepGroupOpen(groupKey);
299
363
  setOpenId(r.id);
300
364
  setFocusId(r.id);
301
365
  },
302
- className: missing ? "bg-[var(--brand)]" : "bg-[var(--danger)]",
303
- armedClassName: missing ? "bg-[var(--brand-hover)]" : "bg-[var(--danger-hover)]"
304
- }
305
- ]
366
+ className: missing ? "bg-[var(--brand)] text-[var(--brand-contrast)]" : "bg-[var(--danger)] text-[var(--danger-contrast)]",
367
+ armedClassName: missing ? "bg-[var(--brand-hover)] text-[var(--brand-contrast)]" : "bg-[var(--danger-hover)] text-[var(--danger-contrast)]"
368
+ };
369
+ case "clear":
370
+ return {
371
+ label: labels.reset,
372
+ icon: /* @__PURE__ */ jsx(RotateCcw, { className: "size-4", "aria-hidden": true }),
373
+ onCommit: () => void clearRow(r),
374
+ // A step back, not a verdict: neither the approval's green nor the send-back's red.
375
+ // The surface colour as text: white on the grey in light mode, the dark page on
376
+ // the light grey in dark mode.
377
+ className: "bg-[var(--text-muted)] text-[var(--bg-surface)]",
378
+ armedClassName: "bg-[var(--text-secondary)] text-[var(--bg-surface)]"
379
+ };
380
+ }
381
+ };
382
+ const swipeActions = (r, groupKey) => {
383
+ if (!binding || !editable || lock.locked || isQueued(`row:${r.id}`)) return null;
384
+ const plan = translationReviewSwipePlan(binding, r, { canClear: onClear !== void 0 });
385
+ if (plan.start.length === 0 && plan.end.length === 0) return null;
386
+ return {
387
+ start: plan.start.map((id) => swipeAction(r, id, groupKey)),
388
+ end: plan.end.map((id) => swipeAction(r, id, groupKey))
306
389
  };
307
390
  };
308
391
  const toggleIn = (prev, many, checked) => {
@@ -345,6 +428,7 @@ function TranslationReviewPanel({
345
428
  } : { storageKey },
346
429
  isExpanded: (r) => r.id === openId,
347
430
  onRowClick: (r) => {
431
+ if (openId !== r.id) keepGroupOpen(group?.key);
348
432
  setFocusId(null);
349
433
  setOpenId((open) => open === r.id ? null : r.id);
350
434
  },
@@ -376,7 +460,7 @@ function TranslationReviewPanel({
376
460
  r.placeholderMismatch && /* @__PURE__ */ jsx(Chip, { size: "sm", tone: "danger", variant: "outline", children: labels.placeholderChip })
377
461
  ] })
378
462
  ] }),
379
- mobileSwipeActions: swipe ? swipeActions : void 0,
463
+ mobileSwipeActions: binding ? (r) => swipeActions(r, group?.key) : void 0,
380
464
  selection: selectionFor(list)
381
465
  }
382
466
  );
@@ -386,6 +470,8 @@ function TranslationReviewPanel({
386
470
  const questionId = `${baseId}-confirm-${index}`;
387
471
  const targets = editable ? unreviewedRows(g.rows) : [];
388
472
  const key = `group:${g.key}`;
473
+ const open = isGroupOpen(g.key);
474
+ const body = open || groupOpen[g.key] === false;
389
475
  return /* @__PURE__ */ jsxs(
390
476
  Disclosure,
391
477
  {
@@ -393,10 +479,10 @@ function TranslationReviewPanel({
393
479
  headingAs: groupHeadingAs,
394
480
  title: /* @__PURE__ */ jsx("span", { id: titleId, children: name }),
395
481
  hint: labels.groupCount(g.summary.unreviewed, g.summary.total),
396
- open: isGroupOpen(g.key),
397
- onOpenChange: (open) => {
398
- setGroupOpenFor(g.key, open);
399
- if (!open) setConfirming((c) => c === g.key ? null : c);
482
+ open,
483
+ onOpenChange: (next) => {
484
+ setGroupOpenFor(g.key, next);
485
+ if (!next) setConfirming((c) => c === g.key ? null : c);
400
486
  },
401
487
  trailing: targets.length > 0 ? /* @__PURE__ */ jsxs(
402
488
  Button,
@@ -416,7 +502,7 @@ function TranslationReviewPanel({
416
502
  ) : void 0,
417
503
  bodyClassName: "space-y-0 px-0 pb-0 border-t border-[var(--border)]",
418
504
  children: [
419
- confirming === g.key && targets.length > 0 && /* @__PURE__ */ jsx("div", { className: "border-b border-[var(--border)] p-3", children: /* @__PURE__ */ jsx(AlertBanner, { tone: "warning", size: "sm", children: /* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-2", children: [
505
+ body && confirming === g.key && targets.length > 0 && /* @__PURE__ */ jsx("div", { className: "border-b border-[var(--border)] p-3", children: /* @__PURE__ */ jsx(AlertBanner, { tone: "warning", size: "sm", children: /* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-2", children: [
420
506
  /* @__PURE__ */ jsx("p", { id: questionId, children: labels.confirmGroup(targets.length, name) }),
421
507
  /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
422
508
  /* @__PURE__ */ jsx(
@@ -453,7 +539,7 @@ function TranslationReviewPanel({
453
539
  )
454
540
  ] })
455
541
  ] }) }) }),
456
- table(g.rows, { name })
542
+ body && table(g.rows, { key: g.key, name })
457
543
  ]
458
544
  },
459
545
  g.key