@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.
- package/README.md +3 -3
- package/dist/components/data-table.js +3 -2
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/swipeable-row.d.ts +5 -1
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/translation-review-labels.d.ts +4 -0
- package/dist/components/translation-review-labels.js +2 -1
- package/dist/components/translation-review-labels.js.map +1 -1
- package/dist/components/translation-review.d.ts +67 -7
- package/dist/components/translation-review.js +115 -29
- package/dist/components/translation-review.js.map +1 -1
- package/dist/hooks/use-search-param-state.d.ts +1 -1
- package/dist/hooks/use-search-param-state.js +8 -1
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/dist/i18n/german.js +1 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/locales/en.js +1 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.js +1 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +1 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +1 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +1 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +1 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +1 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/translation-review.d.ts +76 -1
- package/dist/lib/translation-review.js +30 -0
- package/dist/lib/translation-review.js.map +1 -1
- package/package.json +1 -1
- package/src/components/data-table.tsx +9 -2
- package/src/components/swipeable-row.tsx +5 -1
- package/src/components/translation-review-labels.ts +5 -0
- package/src/components/translation-review.tsx +275 -48
- package/src/hooks/use-search-param-state.ts +19 -4
- package/src/i18n/german.ts +1 -0
- package/src/i18n/locales/en.ts +1 -0
- package/src/i18n/locales/es.ts +1 -0
- package/src/i18n/locales/fr.ts +1 -0
- package/src/i18n/locales/hu.ts +1 -0
- package/src/i18n/locales/it.ts +1 -0
- package/src/i18n/locales/zh.ts +1 -0
- package/src/i18n/review.ts +1 -0
- package/src/index.ts +6 -0
- package/src/lib/translation-review.ts +109 -0
- package/tokens.css +4 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/translation-review.tsx"],"sourcesContent":["import { useId, useMemo, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Check, ListChecks, PencilLine, RotateCcw } from \"lucide-react\";\n\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport {\n DEFAULT_TRANSLATION_REVIEW_FILTER,\n REVIEW_STATUSES,\n filterTranslationRows,\n groupTranslationRows,\n keyInAreas,\n reviewUndo,\n reviewWrite,\n summariseRows,\n unreviewedRows,\n} from \"../lib/translation-review\";\nimport type {\n ReviewStatus,\n TranslationReviewFilter,\n TranslationReviewKey,\n TranslationReviewWrite,\n TranslationRow,\n TranslationRowGroup,\n} from \"../lib/translation-review\";\nimport { AlertBanner } from \"./alert-banner\";\nimport { BulkActionBar } from \"./bulk-action-bar\";\nimport { Checkbox } from \"./checkbox\";\nimport { Chip } from \"./chip\";\nimport { DataTable } from \"./data-table\";\nimport type { DataTableColumn, DataTableProps, MobileSwipeActions, SortState } from \"./data-table\";\nimport { Disclosure } from \"./disclosure\";\nimport { SearchField } from \"./search-field\";\nimport { toast } from \"./toast\";\nimport { ToggleGroup } from \"./toggle-group\";\nimport { TranslationReviewEditor } from \"./translation-review-editor\";\nimport { DEFAULT_TRANSLATION_REVIEW_LABELS, reviewStatusLabel } from \"./translation-review-labels\";\nimport type { TranslationReviewLabels } from \"./translation-review-labels\";\nimport { ReviewStatusChip, TranslationProgress } from \"./translation-review-parts\";\nimport { Button, IconButton, Select } from \"./ui\";\nimport { useWriteLock } from \"./write-lock\";\n\nexport {\n REVIEW_STATUS_TONES,\n ReviewStatusChip,\n TranslationExportButton,\n TranslationLocaleTabs,\n TranslationProgress,\n} from \"./translation-review-parts\";\nexport type {\n ReviewStatusChipProps,\n TranslationExportButtonProps,\n TranslationLocaleTab,\n TranslationLocaleTabsProps,\n TranslationProgressProps,\n} from \"./translation-review-parts\";\nexport { TranslationReviewEditor } from \"./translation-review-editor\";\nexport type { TranslationReviewEditorProps } from \"./translation-review-editor\";\nexport { DEFAULT_TRANSLATION_REVIEW_LABELS } from \"./translation-review-labels\";\nexport type { TranslationReviewLabels } from \"./translation-review-labels\";\n\n/**\n * An app's translation review page, as kit parts (H1, docs/i18n-harmonization.md):\n * keksdose's /translations, which kastlan was about to build a second time. The app keeps\n * what is its own — where the strings come from, which locale is read against which, who\n * may review what, every request — and hands the kit rows (`translationRows`) and two\n * callbacks. Everything a reviewer touches is here.\n *\n * Presentational — no request, no route — and one LOCALE at a time. Its table is the\n * kit's DataTable, so like every DataTable it renders under the app's Router. The page\n * around it puts\n * {@link TranslationLocaleTabs} above it and {@link TranslationExportButton} in its header,\n * because both span every locale while this panel is the one open.\n *\n * Callbacks may return promises, and the panel waits for them: an editor stays open with\n * its text and the error when a save is refused (a 403 for a key outside the reviewer's\n * areas, a network failure), a bulk action keeps its selection. Chunking a long bulk\n * write to the server's batch limit is the app's; the panel sends one call per action.\n */\n\ntype MaybePromise = void | Promise<unknown>;\n\n/** What asked for a write — see {@link TranslationReviewSaveInfo}. */\nexport type TranslationReviewOrigin = \"editor\" | \"row\" | \"swipe\" | \"group\" | \"selection\" | \"undo\";\n\n/**\n * The second argument of `onSave` and `onClear` (0.25): where the write came from, and\n * whether the panel answers it with a toast of its own.\n *\n * WHY THE APP IS TOLD. keksdose and kastlan toast \"Saved\" from their mutation on every\n * save. With {@link TranslationReviewPanelProps.undo} on, an approval is answered by the\n * kit's Undo toast instead — and a reviewer swiping through a list then read two toasts\n * per card. The app skips its own where `toasted` is true:\n *\n * onSave={(writes, { toasted }) => save.mutateAsync({ items: writes.map(toApiWrite), quiet: toasted })}\n *\n * An app that ignores the argument keeps working; it only says \"Saved\" twice.\n */\nexport interface TranslationReviewSaveInfo {\n /** The open editor, a row's approve button, a swipe on a phone card, a group's\n * \"Approve unreviewed\", the selection's bulk bar, or an Undo pressed on the toast. */\n origin: TranslationReviewOrigin;\n /** The panel shows its own toast for this write (an approval's Undo toast). */\n toasted: boolean;\n}\n\n/** How {@link TranslationReviewPanelProps.groupBy} names a row's group. */\nexport type TranslationReviewGroupBy = \"namespace\" | \"source\" | ((row: TranslationRow) => string);\n\nexport interface TranslationReviewPanelProps {\n /** One locale's rows, from `translationRows`. */\n rows: readonly TranslationRow[];\n /** The reviewed language's own name — the text column's header (\"Français\"). */\n localeLabel: string;\n /** The reference language's own name — the reference column's header. */\n referenceLabel: string;\n /** Store verdicts: one for an editor or a row's approve button, many for a bulk\n * approve. Left out (or `readOnly`): nothing can be changed. The second argument\n * says where the write came from ({@link TranslationReviewSaveInfo}). */\n onSave?: (writes: TranslationReviewWrite[], info: TranslationReviewSaveInfo) => MaybePromise;\n /** Clear verdicts — back to unreviewed. Left out: no reset, and an approval of a row\n * that had no verdict cannot be undone. */\n onClear?: (keys: TranslationReviewKey[], info: TranslationReviewSaveInfo) => MaybePromise;\n /** A locale the viewer may read but not review: the rows and their verdicts, no\n * actions, and a line saying so. */\n readOnly?: boolean;\n /**\n * The areas the reviewer is limited to (keksdose's lawyer: `[\"legal\"]`), as the server\n * answers them; `null` is every area. Rows outside are not shown or counted — build the\n * rows with the same `areas` and they never exist — and a notice names the areas.\n */\n areas?: readonly string[] | null;\n /** An area's name for the notice (\"the legal pages (Imprint, …)\"). Default: the key. */\n areaLabels?: Readonly<Record<string, string>>;\n /** Default `keyInArea`: the key is the area or sits under it (`legal.…`, `legal:…`). */\n inArea?: (key: string, area: string) => boolean;\n /** A source's name for the source filter, which appears once the rows come from more\n * than one (kastlan: `{ screen: \"Screen\", documents: \"PDFs and emails\" }`). Default:\n * the source itself. */\n sourceLabels?: Readonly<Record<string, string>>;\n /**\n * Controlled filters — for an app that keeps them in the URL (keksdose's `?status=`,\n * `?ns=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.\n */\n filter?: Partial<TranslationReviewFilter>;\n /** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s\n * setter takes this object as it is (`filter={filter} onFilterChange={setFilter}`).\n * Single `useSearchParamState` setters compose since 0.25; before, four in a row\n * clobbered each other (keksdose live #378). */\n onFilterChange?: (filter: TranslationReviewFilter) => void;\n /** The progress bar over the filters. Default true. */\n progress?: boolean;\n /** Rows per page. Default 50. */\n pageSize?: number;\n /** Persists the table's sort and page size (DataTable's `storageKey`). */\n storageKey?: string;\n /** How a verdict's date reads in the editor. */\n formatDate?: (iso: string) => string;\n /** Turns a callback's rejection into words. Default {@link TranslationReviewLabels.failed}. */\n formatError?: (error: unknown) => ReactNode;\n /**\n * Swipe the phone cards (0.25, keksdose live #377 — Marcel reviewing on a phone): toward\n * the reading END to approve, toward the START for \"Needs a change\", which OPENS the\n * editor with the cursor in the wording rather than sending the string back blind — a\n * verdict without a note or a better wording tells whoever fixes it nothing. In a\n * right-to-left page the two sides mirror with the reading direction.\n *\n * DataTable's swipe sides (`mobileSwipeActions`) on {@link SwipeableRow}: the same\n * gesture, the same feedback, and the same keyboard path — every swipe is also a real\n * button, read out and reachable by Tab, so the gesture is a shortcut and never the only\n * way. A row has no swipe while the editor under it is open, while its own write is out,\n * under a {@link WriteLockProvider} lock, in a `readOnly` locale, and toward \"Approve\"\n * where there is nothing to approve (a missing string, an approved one). A reviewer\n * limited to `areas` never sees a row outside them, so never swipes one.\n *\n * Off by default, so no existing page gains a gesture on the bump. Phones only; the\n * desktop table keeps its approve button.\n */\n swipe?: boolean;\n /**\n * Answer every approval with the kit's Undo toast (`toast.undo`): a row's button, a\n * swipe, the editor's Approve, a group's and the selection's bulk approve. Undo stores\n * again what the rows said before ({@link reviewUndo}) — or clears them, for rows that\n * had no verdict, which needs `onClear` (without it the toast reports and offers\n * nothing). A batch has ONE Undo.\n *\n * Default: on with `swipe` or `groupBy` — the two ways to approve in one movement or many\n * rows at once bring their way back — and off otherwise, so an existing page does not\n * gain a toast on the bump. Needs the app's `<Toaster>`. Tell the app's own \"Saved\"\n * toast apart with the `toasted` flag ({@link TranslationReviewSaveInfo}).\n */\n undo?: boolean;\n /**\n * Group the strings under a header per area (0.25, keksdose live #377: *\"grouping to the\n * translation review entries for faster reviewing\"*): `\"namespace\"` — the row's\n * `namespace`, which `translationRows` derives from the key (`budget`, `kit.dataTable`\n * with keksdose's `namespaceOf`) — `\"source\"`, or a function of the row.\n *\n * Each group is a section with a heading ({@link groupHeadingAs}): its name, \"n\n * unreviewed / total\", and \"Approve unreviewed (n)\", which approves the group's rows\n * nobody has given a verdict ({@link unreviewedRows}) under the same rules as one\n * approval — write lock, `readOnly`, one write at a time — with one Undo for the batch.\n * A batch larger than `pageSize` asks first, inside the opened group: more than a page\n * is more than the reviewer can have had on screen.\n *\n * WHAT IT MEANS FOR THE FILTERS, THE SORT AND THE PAGES. The filters narrow the rows\n * first, then the rows are grouped: a header counts what the filters show in it (so\n * \"Approve unreviewed\" never takes a row the reviewer filtered away), and an area with\n * nothing left is not shown. Groups stand in the order their first string has in the\n * reference; a column sort orders the rows INSIDE every group alike. Each group pages\n * on its own, `pageSize` rows at a time — a header then always stands over its own\n * rows, and paging one area does not move another — rather than one pager cutting\n * through the headers. `storageKey` keeps persisting the ungrouped table only.\n *\n * Groups fold (each on its own, remembered while the panel lives). On a phone they\n * start folded when there are several — the headers are then the area list, and the\n * reviewer opens the one to work through; on a wide screen they start open.\n */\n groupBy?: TranslationReviewGroupBy;\n /** A group's name. Default: `sourceLabels` for `groupBy=\"source\"`, else the key. */\n groupLabel?: (group: string) => string;\n /** The level of the group headings. Default `h2`: the panel is a page's body, under\n * the page's `h1`. */\n groupHeadingAs?: \"h2\" | \"h3\" | \"h4\";\n className?: string;\n labels?: Partial<TranslationReviewLabels>;\n}\n\n/** The wide screen's query — the same one DataTable switches its layout on, so \"a phone\"\n * means the same here as in the table. */\nconst MD_UP = \"(min-width: 768px)\";\n\n/** The row's text — or, for a missing string, the line that says so. */\nfunction TextCell({ row, labels, clamp }: { row: TranslationRow; labels: TranslationReviewLabels; clamp?: boolean }) {\n return row.text === \"\" ? (\n <span className=\"italic text-[var(--text-muted)]\">{labels.missingText}</span>\n ) : (\n <span lang={row.locale} className={cn(\"whitespace-pre-line break-words\", clamp && \"line-clamp-3\")}>\n {row.text}\n </span>\n );\n}\n\n/**\n * The page body for one locale: progress, filters (status, source, namespace, search,\n * placeholder problems), the strings with an editor that unfolds under each, and the\n * bulk actions over a selection.\n *\n * The status filter is the work queue in order — what is not there, what nobody has\n * read, what changed after it was read, what was sent back, what is done — with each\n * count on it. \"Missing\" is offered only where something is.\n *\n * Bulk approve takes the selected rows that are not approved yet and have a text:\n * approving an absent string says nothing, and re-approving one says it again. On a\n * phone the table has no checkboxes, so \"Select all shown\" selects what the filters\n * show — filter to `Unreviewed` in one area, read, select, approve. With `groupBy`, a\n * group's own \"Approve unreviewed\" is the shorter way, and with `swipe` a card is approved\n * by a swipe.\n *\n * The panel's writes go out ONE AT A TIME, in the order they were asked for: two writes on\n * one locale's verdicts can answer out of order, and an Undo must not overtake the\n * approval it takes back. A swipe on the next card while one is out is queued rather than\n * refused — a reviewer swiping down a list is faster than the server.\n */\nexport function TranslationReviewPanel({\n rows: rowsProp,\n localeLabel,\n referenceLabel,\n onSave,\n onClear,\n readOnly = false,\n areas,\n areaLabels,\n inArea,\n sourceLabels,\n filter: filterProp,\n onFilterChange,\n progress = true,\n pageSize = 50,\n storageKey,\n formatDate,\n formatError,\n swipe = false,\n undo: undoProp,\n groupBy,\n groupLabel,\n groupHeadingAs = \"h2\",\n className,\n labels: labelsProp,\n}: TranslationReviewPanelProps) {\n const labels = useKitLabels(\"translationReview\", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);\n const locale = useKitLocale();\n const lock = useWriteLock();\n const phone = !useMediaQuery(MD_UP, true);\n const baseId = useId();\n const rootRef = useRef<HTMLDivElement>(null);\n const editable = !readOnly && onSave !== undefined;\n const undoOn = undoProp ?? (swipe || groupBy !== undefined);\n\n const [ownFilter, setOwnFilter] = useState<TranslationReviewFilter>(DEFAULT_TRANSLATION_REVIEW_FILTER);\n // The app's fields over the panel's own; a field the app leaves `undefined` is the\n // panel's, not a blank.\n const filter: TranslationReviewFilter = { ...ownFilter };\n for (const [name, value] of Object.entries(filterProp ?? {})) {\n if (value !== undefined) Object.assign(filter, { [name]: value });\n }\n const [openId, setOpenId] = useState<string | null>(null);\n // The row whose editor a swipe opened, to be written in: its wording field takes focus.\n const [focusId, setFocusId] = useState<string | null>(null);\n const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());\n // The writes asked for and not yet answered, in order: the first is the one out. The\n // editor keeps its own, for its own buttons.\n const [queued, setQueued] = useState<readonly string[]>([]);\n const claimed = useRef(new Set<string>());\n const tail = useRef<Promise<unknown>>(Promise.resolve());\n const undoCount = useRef(0);\n const [failure, setFailure] = useState<ReactNode>(null);\n const busy = queued.length > 0;\n const isQueued = (key: string) => queued.includes(key);\n\n const rows = useMemo(\n () => (areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp]),\n [rowsProp, areas, inArea],\n );\n const summary = useMemo(() => summariseRows(rows), [rows]);\n const sources = useMemo(() => [...new Set(rows.map((r) => r.source))], [rows]);\n const source = sources.includes(filter.source) ? filter.source : \"all\";\n const namespaces = useMemo(\n () => [...new Set(rows.filter((r) => source === \"all\" || r.source === source).map((r) => r.namespace))],\n [rows, source],\n );\n const namespace = namespaces.includes(filter.namespace) ? filter.namespace : \"all\";\n const mismatches = useMemo(() => rows.filter((r) => r.placeholderMismatch).length, [rows]);\n // `filter` is rebuilt every render; its fields are what the list depends on.\n const { status, query, placeholdersOnly } = filter;\n const visible = useMemo(\n () => filterTranslationRows(rows, { status, source, namespace, query, placeholdersOnly }),\n [rows, status, source, namespace, query, placeholdersOnly],\n );\n\n // ── Groups ──\n const groups = useMemo(() => {\n if (groupBy === undefined) return null;\n const of =\n groupBy === \"namespace\"\n ? (r: TranslationRow) => r.namespace\n : groupBy === \"source\"\n ? (r: TranslationRow) => r.source\n : groupBy;\n return groupTranslationRows(visible, of);\n }, [visible, groupBy]);\n const groupName = (key: string) =>\n groupLabel?.(key) ?? (groupBy === \"source\" ? sourceLabels?.[key] : undefined) ?? (key || \"—\");\n // Only what the reviewer chose; a group nobody has touched follows the default.\n const [groupOpen, setGroupOpen] = useState<Readonly<Record<string, boolean>>>({});\n const groupOpenByDefault = !(phone && (groups?.length ?? 0) > 1);\n const isGroupOpen = (key: string) => groupOpen[key] ?? groupOpenByDefault;\n const setGroupOpenFor = (key: string, open: boolean) => setGroupOpen((prev) => ({ ...prev, [key]: open }));\n // The group whose large batch is waiting for a yes — and, until its confirm button\n // has mounted and taken focus, the group that asked.\n const [confirming, setConfirming] = useState<string | null>(null);\n const confirmFocus = useRef<string | null>(null);\n // One sort for every group's table, so a header click orders all of them alike.\n const [groupSorts, setGroupSorts] = useState<SortState[]>([]);\n\n const setFilter = (patch: Partial<TranslationReviewFilter>) => {\n const next = { ...filter, ...patch };\n setOwnFilter(next);\n onFilterChange?.(next);\n // A selection the new filter hides would be acted on unseen.\n setSelected(new Set());\n };\n\n /** Queue a write under `key` (one per key at a time); resolves whether it worked. */\n const run = (key: string, action: () => MaybePromise): Promise<boolean> => {\n if (claimed.current.has(key)) return Promise.resolve(false);\n claimed.current.add(key);\n setQueued((q) => [...q, key]);\n const step = tail.current.then(async () => {\n setFailure(null);\n try {\n await action();\n return true;\n } catch (caught) {\n setFailure(formatError ? formatError(caught) : labels.failed);\n return false;\n } finally {\n claimed.current.delete(key);\n setQueued((q) => q.filter((k) => k !== key));\n }\n });\n tail.current = step;\n return step;\n };\n\n /** \"String approved — Undo\", once `targets` (as they were BEFORE the write) are\n * approved. Undo goes through the same queue as every write. */\n const sayApproved = (targets: readonly TranslationRow[], description?: ReactNode) => {\n const back = reviewUndo(targets);\n const message = labels.approvedToast(targets.length);\n const save = onSave;\n const clear = onClear;\n // Taking back the approval of a row that had no verdict is a clear: without\n // `onClear` there is no way to say it, so the toast reports and offers nothing.\n if (!save || (back.clears.length > 0 && !clear)) {\n toast.success(message, { description });\n return;\n }\n const key = `undo:${++undoCount.current}`;\n toast.undo(message, {\n description,\n onUndo: () =>\n void run(key, async () => {\n if (back.writes.length > 0) await save(back.writes, { origin: \"undo\", toasted: false });\n if (back.clears.length > 0) await clear?.(back.clears, { origin: \"undo\", toasted: false });\n }),\n });\n };\n\n const approve = async (\n key: string,\n targets: readonly TranslationRow[],\n origin: TranslationReviewOrigin,\n description?: ReactNode,\n ): Promise<boolean> => {\n if (!onSave || targets.length === 0) return false;\n const save = onSave;\n const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, \"APPROVED\")), { origin, toasted: undoOn }));\n if (ok && undoOn) sayApproved(targets, description);\n return ok;\n };\n\n /** The editor's own save: it waits, closes and shows its errors itself, so it does\n * not go through the queue — but an approval there is answered like any other. */\n const saveFromEditor = async (row: TranslationRow, write: TranslationReviewWrite) => {\n const toasted = undoOn && write.verdict === \"APPROVED\";\n await onSave?.([write], { origin: \"editor\", toasted });\n if (toasted) sayApproved([row], row.key);\n };\n\n /**\n * Focus back into the panel once a group's batch has settled, if it fell out: the\n * button that was pressed goes with the rows it approved (and under the `Unreviewed`\n * filter the whole group can go). Two frames, so the app's new rows have been drawn.\n */\n const keepFocusAt = (groupKey: string) => {\n const check = () => {\n const root = rootRef.current;\n const active = document.activeElement;\n if (!root || (active && active !== document.body && active.isConnected)) return;\n const sections = [...root.querySelectorAll<HTMLElement>(\"[data-review-group]\")];\n const section = sections.find((el) => el.dataset.reviewGroup === groupKey) ?? sections[0];\n section?.querySelector<HTMLElement>(\"[data-disclosure-trigger]\")?.focus();\n };\n requestAnimationFrame(() => requestAnimationFrame(check));\n };\n\n const approveGroup = async (group: TranslationRowGroup, confirmed: boolean) => {\n const targets = unreviewedRows(group.rows);\n if (targets.length === 0) return;\n if (!confirmed && targets.length > pageSize) {\n // Asked inside the opened group, over the rows it is about.\n setGroupOpenFor(group.key, true);\n setConfirming(group.key);\n confirmFocus.current = group.key;\n return;\n }\n const ok = await approve(`group:${group.key}`, targets, \"group\", groupName(group.key));\n if (ok) setConfirming((key) => (key === group.key ? null : key));\n keepFocusAt(group.key);\n };\n\n const visibleIds = new Set(visible.map((r) => r.id));\n const selectedRows = visible.filter((r) => selected.has(r.id));\n // What \"Select all shown\" takes: grouped, the rows of the open groups — a folded\n // group's rows are not shown.\n const shownRows = groups ? groups.filter((g) => isGroupOpen(g.key)).flatMap((g) => g.rows) : visible;\n const allShownSelected = shownRows.length > 0 && shownRows.every((r) => selected.has(r.id));\n const toApprove = selectedRows.filter((r) => r.text !== \"\" && r.status !== \"approved\");\n const toReset = selectedRows.filter((r) => r.review !== null);\n\n const approveSelected = async () => {\n if (await approve(\"bulk-approve\", toApprove, \"selection\")) setSelected(new Set());\n };\n const resetSelected = async () => {\n if (!onClear || toReset.length === 0) return;\n const clear = onClear;\n const keys = toReset.map((r) => ({ locale: r.locale, key: r.key }));\n if (await run(\"bulk-reset\", () => clear(keys, { origin: \"selection\", toasted: false }))) {\n setSelected(new Set());\n }\n };\n\n // The status options: \"missing\" only where something is (or it is the filter).\n const statuses = REVIEW_STATUSES.filter((s) => s !== \"missing\" || summary.missing > 0 || filter.status === s);\n const scopeNames = (areas ?? []).map((area) => areaLabels?.[area] ?? area);\n const scopeText =\n scopeNames.length > 0 ? new Intl.ListFormat(locale, { type: \"conjunction\", style: \"long\" }).format(scopeNames) : \"\";\n\n const columns: DataTableColumn<TranslationRow>[] = [\n {\n key: \"key\",\n header: labels.key,\n cell: (r) => <code className=\"break-all text-xs text-[var(--text-secondary)]\">{r.key}</code>,\n sortBy: (r) => r.key,\n mobileHidden: true,\n },\n {\n key: \"reference\",\n header: referenceLabel,\n cell: (r) => (\n <span className=\"line-clamp-3 whitespace-pre-line break-words text-[var(--text-secondary)]\">{r.reference}</span>\n ),\n mobileHidden: true,\n },\n {\n key: \"text\",\n header: localeLabel,\n cell: (r) => <TextCell row={r} labels={labels} clamp />,\n mobilePrimary: true,\n },\n {\n key: \"status\",\n header: labels.statusColumn,\n cell: (r) => (\n <span className=\"inline-flex flex-wrap gap-1\">\n <ReviewStatusChip status={r.status} labels={labelsProp} />\n {r.placeholderMismatch && (\n <Chip size=\"sm\" tone=\"danger\" variant=\"outline\">\n {labels.placeholderChip}\n </Chip>\n )}\n </span>\n ),\n sortBy: (r) => REVIEW_STATUSES.indexOf(r.status),\n },\n ];\n if (editable) {\n columns.push({\n key: \"approve\",\n header: <span className=\"sr-only\">{labels.approve}</span>,\n cell: (r) =>\n r.status === \"approved\" || r.text === \"\" ? null : (\n <IconButton\n type=\"button\"\n size=\"xs\"\n tone=\"muted\"\n commit\n label={labels.approve}\n pending={isQueued(`row:${r.id}`)}\n disabled={busy}\n onClick={(event) => {\n // The row's own click opens the editor; this one only approves.\n event.stopPropagation();\n void approve(`row:${r.id}`, [r], \"row\", r.key);\n }}\n >\n <Check />\n </IconButton>\n ),\n });\n }\n\n /**\n * A phone card's swipes: Approve toward the end, \"Needs a change\" toward the start.\n * None where the row must not be acted on — the same conditions under which its\n * buttons are locked or absent.\n */\n const swipeActions = (r: TranslationRow): MobileSwipeActions | null => {\n if (!editable || lock.locked || isQueued(`row:${r.id}`)) return null;\n const missing = r.text === \"\";\n return {\n end:\n missing || r.status === \"approved\"\n ? []\n : [\n {\n label: labels.approve,\n icon: <Check className=\"size-4\" aria-hidden />,\n onCommit: () => void approve(`row:${r.id}`, [r], \"swipe\", r.key),\n className: \"bg-[var(--success)]\",\n armedClassName: \"bg-[var(--success)]\",\n },\n ],\n start: [\n {\n label: missing ? labels.suggest : labels.flag,\n icon: <PencilLine className=\"size-4\" aria-hidden />,\n // Opens the editor rather than sending the string back: \"needs a change\"\n // with neither a note nor a better wording tells nobody anything.\n onCommit: () => {\n setOpenId(r.id);\n setFocusId(r.id);\n },\n className: missing ? \"bg-[var(--brand)]\" : \"bg-[var(--danger)]\",\n armedClassName: missing ? \"bg-[var(--brand-hover)]\" : \"bg-[var(--danger-hover)]\",\n },\n ],\n };\n };\n\n const toggleIn = (prev: ReadonlySet<string>, many: readonly TranslationRow[], checked: boolean) => {\n const next = new Set([...prev].filter((id) => visibleIds.has(id)));\n for (const r of many) {\n if (checked) next.add(r.id);\n else next.delete(r.id);\n }\n return next;\n };\n /** The checkbox column of a table over `list` — the whole list, or one group. */\n const selectionFor = (list: readonly TranslationRow[]): DataTableProps<TranslationRow>[\"selection\"] => {\n if (!editable) return undefined;\n const picked = list.filter((r) => selected.has(r.id)).length;\n return {\n isSelected: (r) => selected.has(r.id),\n onToggle: (r, checked) => setSelected((prev) => toggleIn(prev, [r], checked)),\n onToggleMany: (many, checked) => setSelected((prev) => toggleIn(prev, many, checked)),\n allSelected: list.length > 0 && picked === list.length,\n someSelected: picked > 0 && picked < list.length,\n onToggleAll: (checked) => setSelected((prev) => toggleIn(prev, list, checked)),\n };\n };\n\n const table = (list: TranslationRow[], group?: { name: string }) => (\n <DataTable<TranslationRow>\n rows={list}\n rowKey={(r) => r.id}\n columns={columns}\n empty={labels.empty}\n defaultPageSize={pageSize}\n {...(group\n ? {\n // Inside the group's own card: no frame in a frame, no column rail per group,\n // no scroller per group, and the group's name as the table's.\n frame: false,\n chrome: \"minimal\" as const,\n maxBodyHeight: \"none\",\n sorts: groupSorts,\n onSortsChange: setGroupSorts,\n labels: { table: group.name },\n }\n : { storageKey })}\n isExpanded={(r) => r.id === openId}\n onRowClick={(r) => {\n setFocusId(null);\n setOpenId((open) => (open === r.id ? null : r.id));\n }}\n expandedRow={(r) => (\n <TranslationReviewEditor\n key={r.id}\n row={r}\n referenceLabel={referenceLabel}\n localeLabel={localeLabel}\n readOnly={!editable}\n focusWording={focusId === r.id}\n onSave={onSave ? (write) => saveFromEditor(r, write) : undefined}\n onClear={onClear ? (key) => onClear([key], { origin: \"editor\", toasted: false }) : undefined}\n onClose={() => {\n setOpenId(null);\n setFocusId(null);\n }}\n formatDate={formatDate}\n formatError={formatError}\n labels={labelsProp}\n />\n )}\n mobileCard={(r) => (\n <div className=\"min-w-0 space-y-1\">\n <code className=\"block break-all text-xs text-[var(--text-muted)]\">{r.key}</code>\n <div className=\"text-sm text-[var(--text-primary)]\">\n <TextCell row={r} labels={labels} clamp />\n </div>\n <div className=\"flex flex-wrap gap-1\">\n <ReviewStatusChip status={r.status} labels={labelsProp} />\n {r.placeholderMismatch && (\n <Chip size=\"sm\" tone=\"danger\" variant=\"outline\">\n {labels.placeholderChip}\n </Chip>\n )}\n </div>\n </div>\n )}\n mobileSwipeActions={swipe ? swipeActions : undefined}\n selection={selectionFor(list)}\n />\n );\n\n const groupSection = (g: TranslationRowGroup, index: number) => {\n const name = groupName(g.key);\n const titleId = `${baseId}-group-${index}`;\n const questionId = `${baseId}-confirm-${index}`;\n const targets = editable ? unreviewedRows(g.rows) : [];\n const key = `group:${g.key}`;\n return (\n <Disclosure\n key={g.key}\n data-review-group={g.key}\n headingAs={groupHeadingAs}\n title={<span id={titleId}>{name}</span>}\n hint={labels.groupCount(g.summary.unreviewed, g.summary.total)}\n open={isGroupOpen(g.key)}\n onOpenChange={(open) => {\n setGroupOpenFor(g.key, open);\n if (!open) setConfirming((c) => (c === g.key ? null : c));\n }}\n trailing={\n targets.length > 0 ? (\n <Button\n variant=\"secondary\"\n size=\"sm\"\n commit\n // The visible words say what; the group's name, read after them, says where.\n aria-describedby={titleId}\n pending={isQueued(key) && confirming !== g.key}\n disabled={busy}\n onClick={() => void approveGroup(g, false)}\n >\n <Check className=\"size-4\" aria-hidden />\n {labels.approveGroup(targets.length)}\n </Button>\n ) : undefined\n }\n bodyClassName=\"space-y-0 px-0 pb-0 border-t border-[var(--border)]\"\n >\n {confirming === g.key && targets.length > 0 && (\n <div className=\"border-b border-[var(--border)] p-3\">\n <AlertBanner tone=\"warning\" size=\"sm\">\n <div className=\"min-w-0 space-y-2\">\n <p id={questionId}>{labels.confirmGroup(targets.length, name)}</p>\n <div className=\"flex flex-wrap gap-2\">\n <Button\n // Focus lands here when the question appears: the expected answer to a\n // question the reviewer just asked for, and Undo stays behind it.\n ref={(el) => {\n if (el && confirmFocus.current === g.key) {\n confirmFocus.current = null;\n el.focus();\n }\n }}\n variant=\"primary\"\n size=\"sm\"\n commit\n aria-describedby={questionId}\n pending={isQueued(key)}\n disabled={busy}\n onClick={() => void approveGroup(g, true)}\n >\n {labels.approveGroup(targets.length)}\n </Button>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n disabled={isQueued(key)}\n onClick={() => {\n setConfirming(null);\n keepFocusAt(g.key);\n }}\n >\n {labels.cancel}\n </Button>\n </div>\n </div>\n </AlertBanner>\n </div>\n )}\n {table(g.rows, { name })}\n </Disclosure>\n );\n };\n return (\n <div ref={rootRef} className={cn(\"min-w-0 space-y-4\", className)}>\n {scopeText && (\n <AlertBanner tone=\"info\" variant=\"inline\" size=\"sm\" block live={false}>\n {labels.scope(scopeText)}\n </AlertBanner>\n )}\n {readOnly && (\n <AlertBanner tone=\"info\" variant=\"inline\" size=\"sm\" block live={false}>\n {labels.readOnly}\n </AlertBanner>\n )}\n\n {progress && <TranslationProgress summary={summary} labels={labelsProp} />}\n\n <ToggleGroup<ReviewStatus | \"all\">\n aria-label={labels.statusFilter}\n overflow=\"wrap\"\n size=\"sm\"\n value={filter.status}\n onChange={(next) => setFilter({ status: next })}\n options={[\n { value: \"all\", label: labels.filterCount(labels.all, summary.total) },\n ...statuses.map((s) => ({ value: s, label: labels.filterCount(reviewStatusLabel(labels, s), summary[s]) })),\n ]}\n />\n\n <div className=\"flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end\">\n {sources.length > 1 && (\n <div className=\"md:w-48\">\n <Select label={labels.source} value={source} onChange={(e) => setFilter({ source: e.target.value })}>\n <option value=\"all\">{labels.allSources}</option>\n {sources.map((s) => (\n <option key={s} value={s}>\n {sourceLabels?.[s] ?? s}\n </option>\n ))}\n </Select>\n </div>\n )}\n <div className=\"md:w-56\">\n <Select label={labels.namespace} value={namespace} onChange={(e) => setFilter({ namespace: e.target.value })}>\n <option value=\"all\">{labels.allNamespaces}</option>\n {namespaces.map((ns) => (\n <option key={ns} value={ns}>\n {ns}\n </option>\n ))}\n </Select>\n </div>\n <SearchField\n className=\"min-w-0 md:flex-1\"\n label={labels.search}\n value={filter.query}\n onChange={(next) => setFilter({ query: next })}\n />\n <Checkbox\n label={labels.placeholdersOnly(mismatches)}\n checked={filter.placeholdersOnly}\n onCheckedChange={(next) => setFilter({ placeholdersOnly: next })}\n />\n </div>\n\n {editable && shownRows.length > 0 && (\n // The phone's table has no checkboxes; this is its way into the bulk actions.\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"md:hidden\"\n disabled={allShownSelected}\n onClick={() => setSelected((prev) => toggleIn(prev, shownRows, true))}\n >\n <ListChecks className=\"size-4\" aria-hidden />\n {labels.selectShown}\n </Button>\n )}\n\n {failure && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n\n {editable && (\n <BulkActionBar count={selectedRows.length} onClear={() => setSelected(new Set())}>\n <Button\n variant=\"primary\"\n size=\"sm\"\n commit\n pending={isQueued(\"bulk-approve\")}\n disabled={busy || toApprove.length === 0}\n onClick={() => void approveSelected()}\n >\n <Check className=\"size-4\" aria-hidden />\n {labels.approveSelected}\n </Button>\n {onClear && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n commit\n pending={isQueued(\"bulk-reset\")}\n disabled={busy || toReset.length === 0}\n onClick={() => void resetSelected()}\n >\n <RotateCcw className=\"size-4\" aria-hidden />\n {labels.resetSelected}\n </Button>\n )}\n </BulkActionBar>\n )}\n\n {groups && groups.length > 0 ? (\n <div className=\"space-y-3\">{groups.map(groupSection)}</div>\n ) : (\n table(visible)\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["ReviewStatusChip","TranslationProgress","TranslationReviewEditor","DEFAULT_TRANSLATION_REVIEW_LABELS"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/translation-review.tsx"],"sourcesContent":["import { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Check, ListChecks, PencilLine, RotateCcw } from \"lucide-react\";\n\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport {\n DEFAULT_TRANSLATION_REVIEW_FILTER,\n DEFAULT_TRANSLATION_REVIEW_SWIPE,\n REVIEW_STATUSES,\n filterTranslationRows,\n groupTranslationRows,\n keyInAreas,\n reviewUndo,\n reviewWrite,\n summariseRows,\n translationReviewSwipePlan,\n unreviewedRows,\n} from \"../lib/translation-review\";\nimport type {\n ReviewStatus,\n TranslationReviewFilter,\n TranslationReviewKey,\n TranslationReviewSwipeAction,\n TranslationReviewSwipeBinding,\n TranslationReviewWrite,\n TranslationRow,\n TranslationRowGroup,\n} from \"../lib/translation-review\";\nimport { AlertBanner } from \"./alert-banner\";\nimport { BulkActionBar } from \"./bulk-action-bar\";\nimport { Checkbox } from \"./checkbox\";\nimport { Chip } from \"./chip\";\nimport { DataTable } from \"./data-table\";\nimport type { DataTableColumn, DataTableProps, MobileSwipeActions, SortState } from \"./data-table\";\nimport { Disclosure } from \"./disclosure\";\nimport { SearchField } from \"./search-field\";\nimport { toast } from \"./toast\";\nimport type { SwipeAction } from \"./swipeable-row\";\nimport { ToggleGroup } from \"./toggle-group\";\nimport { TranslationReviewEditor } from \"./translation-review-editor\";\nimport { DEFAULT_TRANSLATION_REVIEW_LABELS, reviewStatusLabel } from \"./translation-review-labels\";\nimport type { TranslationReviewLabels } from \"./translation-review-labels\";\nimport { ReviewStatusChip, TranslationProgress } from \"./translation-review-parts\";\nimport { Button, IconButton, Select } from \"./ui\";\nimport { useWriteLock } from \"./write-lock\";\n\nexport {\n REVIEW_STATUS_TONES,\n ReviewStatusChip,\n TranslationExportButton,\n TranslationLocaleTabs,\n TranslationProgress,\n} from \"./translation-review-parts\";\nexport type {\n ReviewStatusChipProps,\n TranslationExportButtonProps,\n TranslationLocaleTab,\n TranslationLocaleTabsProps,\n TranslationProgressProps,\n} from \"./translation-review-parts\";\nexport { TranslationReviewEditor } from \"./translation-review-editor\";\nexport type { TranslationReviewEditorProps } from \"./translation-review-editor\";\nexport { DEFAULT_TRANSLATION_REVIEW_LABELS } from \"./translation-review-labels\";\nexport type { TranslationReviewLabels } from \"./translation-review-labels\";\n\n/**\n * An app's translation review page, as kit parts (H1, docs/i18n-harmonization.md):\n * keksdose's /translations, which kastlan was about to build a second time. The app keeps\n * what is its own — where the strings come from, which locale is read against which, who\n * may review what, every request — and hands the kit rows (`translationRows`) and two\n * callbacks. Everything a reviewer touches is here.\n *\n * Presentational — no request, no route — and one LOCALE at a time. Its table is the\n * kit's DataTable, so like every DataTable it renders under the app's Router. The page\n * around it puts\n * {@link TranslationLocaleTabs} above it and {@link TranslationExportButton} in its header,\n * because both span every locale while this panel is the one open.\n *\n * Callbacks may return promises, and the panel waits for them: an editor stays open with\n * its text and the error when a save is refused (a 403 for a key outside the reviewer's\n * areas, a network failure), a bulk action keeps its selection. Chunking a long bulk\n * write to the server's batch limit is the app's; the panel sends one call per action.\n */\n\ntype MaybePromise = void | Promise<unknown>;\n\n/** What asked for a write — see {@link TranslationReviewSaveInfo}. */\nexport type TranslationReviewOrigin = \"editor\" | \"row\" | \"swipe\" | \"group\" | \"selection\" | \"undo\";\n\n/**\n * The second argument of `onSave` and `onClear` (0.25): where the write came from, and\n * whether the panel answers it with a toast of its own.\n *\n * WHY THE APP IS TOLD. keksdose and kastlan toast \"Saved\" from their mutation on every\n * save. With {@link TranslationReviewPanelProps.undo} on, an approval is answered by the\n * kit's Undo toast instead — and a reviewer swiping through a list then read two toasts\n * per card. The app skips its own where `toasted` is true:\n *\n * onSave={(writes, { toasted }) => save.mutateAsync({ items: writes.map(toApiWrite), quiet: toasted })}\n *\n * An app that ignores the argument keeps working; it only says \"Saved\" twice.\n */\nexport interface TranslationReviewSaveInfo {\n /** The open editor, a row's approve button, a swipe on a phone card, a group's\n * \"Approve unreviewed\", the selection's bulk bar, or an Undo pressed on the toast. */\n origin: TranslationReviewOrigin;\n /** The panel shows its own toast for this write (an approval's Undo toast, or a\n * swiped `clear`'s). */\n toasted: boolean;\n}\n\n/** How {@link TranslationReviewPanelProps.groupBy} names a row's group. */\nexport type TranslationReviewGroupBy = \"namespace\" | \"source\" | ((row: TranslationRow) => string);\n\nexport interface TranslationReviewPanelProps {\n /** One locale's rows, from `translationRows`. */\n rows: readonly TranslationRow[];\n /** The reviewed language's own name — the text column's header (\"Français\"). */\n localeLabel: string;\n /** The reference language's own name — the reference column's header. */\n referenceLabel: string;\n /** Store verdicts: one for an editor or a row's approve button, many for a bulk\n * approve. Left out (or `readOnly`): nothing can be changed. The second argument\n * says where the write came from ({@link TranslationReviewSaveInfo}). */\n onSave?: (writes: TranslationReviewWrite[], info: TranslationReviewSaveInfo) => MaybePromise;\n /** Clear verdicts — back to unreviewed. Left out: no reset, and an approval of a row\n * that had no verdict cannot be undone. */\n onClear?: (keys: TranslationReviewKey[], info: TranslationReviewSaveInfo) => MaybePromise;\n /** A locale the viewer may read but not review: the rows and their verdicts, no\n * actions, and a line saying so. */\n readOnly?: boolean;\n /**\n * The areas the reviewer is limited to (keksdose's lawyer: `[\"legal\"]`), as the server\n * answers them; `null` is every area. Rows outside are not shown or counted — build the\n * rows with the same `areas` and they never exist — and a notice names the areas.\n */\n areas?: readonly string[] | null;\n /** An area's name for the notice (\"the legal pages (Imprint, …)\"). Default: the key. */\n areaLabels?: Readonly<Record<string, string>>;\n /** Default `keyInArea`: the key is the area or sits under it (`legal.…`, `legal:…`). */\n inArea?: (key: string, area: string) => boolean;\n /** A source's name for the source filter, which appears once the rows come from more\n * than one (kastlan: `{ screen: \"Screen\", documents: \"PDFs and emails\" }`). Default:\n * the source itself. */\n sourceLabels?: Readonly<Record<string, string>>;\n /**\n * Controlled filters — for an app that keeps them in the URL (keksdose's `?status=`,\n * `?ns=`, `?q=`). Whatever is left out is the panel's own. Pair with `onFilterChange`.\n *\n * A field passed here is the app's, and the control shows what the app says: one the app\n * passes but does not write back from `onFilterChange` is a control that does nothing.\n * A development build warns about that once per field (0.25.1, Kurvenschmiede); a field\n * left out entirely is fine — the panel keeps it.\n */\n filter?: Partial<TranslationReviewFilter>;\n /** Every filter, on every change — write them in ONE update: `useSearchParamsState`'s\n * setter takes this object as it is (`filter={filter} onFilterChange={setFilter}`).\n * Single `useSearchParamState` setters compose since 0.25; before, four in a row\n * clobbered each other (keksdose live #378). */\n onFilterChange?: (filter: TranslationReviewFilter) => void;\n /** The progress bar over the filters. Default true. */\n progress?: boolean;\n /** Rows per page. Default 50. */\n pageSize?: number;\n /** Persists the table's sort and page size (DataTable's `storageKey`). */\n storageKey?: string;\n /** How a verdict's date reads in the editor. */\n formatDate?: (iso: string) => string;\n /** Turns a callback's rejection into words. Default {@link TranslationReviewLabels.failed}. */\n formatError?: (error: unknown) => ReactNode;\n /**\n * Swipe the phone cards (0.25, keksdose live #377 — Marcel reviewing on a phone): toward\n * the reading END to approve, toward the START for \"Needs a change\", which OPENS the\n * editor with the cursor in the wording rather than sending the string back blind — a\n * verdict without a note or a better wording tells whoever fixes it nothing. In a\n * right-to-left page the two sides mirror with the reading direction.\n *\n * DataTable's swipe sides (`mobileSwipeActions`) on {@link SwipeableRow}: the same\n * gesture, the same feedback, and the same keyboard path — every swipe is also a real\n * button, read out and reachable by Tab, so the gesture is a shortcut and never the only\n * way. A row has no swipe while the editor under it is open, while its own write is out,\n * under a {@link WriteLockProvider} lock, in a `readOnly` locale, and toward \"Approve\"\n * where there is nothing to approve (a missing string, an approved one). A reviewer\n * limited to `areas` never sees a row outside them, so never swipes one.\n *\n * Off by default, so no existing page gains a gesture on the bump. Phones only; the\n * desktop table keeps its approve button.\n *\n * A BINDING instead of `true` (keksdose — Marcel's live #377 rework, 2026-10-03: *\"Add\n * the swipe options to be shown also to the settings /settings#interaction area where\n * the other swipe options are defined\"*): keksdose lets the user bind every list's\n * swipes in Settings → Interaction, per side a primary action at the first threshold and\n * a secondary one at a longer drag, and a fixed mapping is one its settings cannot\n * reach. So each logical side takes an ordered ladder of\n * {@link TranslationReviewSwipeAction}s — index 0 at the first threshold, index 1 at the\n * longer drag, as {@link SwipeableRow} stages them:\n *\n * swipe={{ end: [\"approve\", \"clear\"], start: [\"edit\"] }}\n *\n * `approve` (with Undo), `edit` (the editor, cursor in the wording — \"Needs a change\"),\n * `clear` (the verdict taken back, with Undo, which stores it again; only with\n * `onClear`). An action a row cannot offer drops out and the ladder closes up behind it\n * ({@link translationReviewSwipePlan}): `approve` on an approved or missing string,\n * `clear` on a row with no verdict. Every rule above still holds for every binding —\n * no side swipes in a `readOnly` locale, under a write lock, with the editor open or\n * the row's write out.\n *\n * `true` is {@link DEFAULT_TRANSLATION_REVIEW_SWIPE} — `{ end: [\"approve\"], start:\n * [\"edit\"] }`, 0.25's mapping exactly — and `false` or nothing is no swipe. A binding\n * turns the Undo toast on by default, as `true` does.\n */\n swipe?: boolean | TranslationReviewSwipeBinding;\n /**\n * Answer every approval with the kit's Undo toast (`toast.undo`): a row's button, a\n * swipe, the editor's Approve, a group's and the selection's bulk approve. Undo stores\n * again what the rows said before ({@link reviewUndo}) — or clears them, for rows that\n * had no verdict, which needs `onClear` (without it the toast reports and offers\n * nothing). A batch has ONE Undo.\n *\n * Default: on with `swipe` or `groupBy` — the two ways to approve in one movement or many\n * rows at once bring their way back — and off otherwise, so an existing page does not\n * gain a toast on the bump. Needs the app's `<Toaster>`. Tell the app's own \"Saved\"\n * toast apart with the `toasted` flag ({@link TranslationReviewSaveInfo}).\n */\n undo?: boolean;\n /**\n * Group the strings under a header per area (0.25, keksdose live #377: *\"grouping to the\n * translation review entries for faster reviewing\"*): `\"namespace\"` — the row's\n * `namespace`, which `translationRows` derives from the key (`budget`, `kit.dataTable`\n * with keksdose's `namespaceOf`) — `\"source\"`, or a function of the row.\n *\n * Each group is a section with a heading ({@link groupHeadingAs}): its name, \"n\n * unreviewed / total\", and \"Approve unreviewed (n)\", which approves the group's rows\n * nobody has given a verdict ({@link unreviewedRows}) under the same rules as one\n * approval — write lock, `readOnly`, one write at a time — with one Undo for the batch.\n * A batch larger than `pageSize` asks first, inside the opened group: more than a page\n * is more than the reviewer can have had on screen.\n *\n * WHAT IT MEANS FOR THE FILTERS, THE SORT AND THE PAGES. The filters narrow the rows\n * first, then the rows are grouped: a header counts what the filters show in it (so\n * \"Approve unreviewed\" never takes a row the reviewer filtered away), and an area with\n * nothing left is not shown. Groups stand in the order their first string has in the\n * reference; a column sort orders the rows INSIDE every group alike. Each group pages\n * on its own, `pageSize` rows at a time — a header then always stands over its own\n * rows, and paging one area does not move another — rather than one pager cutting\n * through the headers. `storageKey` keeps persisting the ungrouped table only.\n *\n * Groups fold (each on its own, remembered while the panel lives), and a folded group's\n * rows are not rendered at all — only its header. Which groups start open is\n * {@link defaultGroupsOpen}: by default, on a phone they start folded when there are\n * several — the headers are then the area list, and the reviewer opens the one to work\n * through — and on a wide screen they start open while that is cheap (see there).\n */\n groupBy?: TranslationReviewGroupBy;\n /**\n * Which groups start open under {@link groupBy} (0.25.1). A group the reviewer opens or\n * folds keeps that; this is what every other group follows.\n *\n * - `\"auto\"` (default): on a phone, open when there is one group and folded when there\n * are several, as in 0.25.0. On a wide screen, open while opening every group renders\n * at most 100 rows (each group shows up to `pageSize` of them; the budget is one page\n * where `pageSize` is larger) — and folded above that, the headers then being the area\n * list, as on a phone.\n * - `true`: every group starts open, on a phone too.\n * - `false`: every group starts folded, on a wide screen too.\n *\n * WHY A BUDGET OF ROWS (keksdose, measured on 0.25.0): a wide screen opened EVERY group.\n * keksdose's /translations has 122 areas and ~4700 strings — 122 tables and 2053 rows at\n * once, ~46 000 elements, 5.2 s before the panel appeared and 2.5 s for a filter click\n * to paint, against 50 rows and 0.34 s ungrouped; its page test went from 4 s to 146 s.\n * kastlan (~31 areas, ~1300 rows open) and Kurvenschmiede (28, ~700) had the same page.\n * What costs is the rows on screen, so the rule counts them rather than the groups:\n * 100 rows is two of the panel's default pages — any one or two areas still open as\n * before, however long, and so do a dozen small ones, or a search whose hits fall in\n * many areas — while a whole catalogue starts as its list of areas: keksdose's shape\n * in jsdom, 122 headers and ~2100 elements instead of ~44 000 (the ungrouped panel has\n * ~1200). The rule follows the filters: narrow them and the groups open.\n *\n * A boolean rather than a controlled open state: the panel already keeps each group's\n * own state, and an app that only wants \"all open\" or \"all folded\" should not have to\n * keep a record of 122 flags to say it.\n */\n defaultGroupsOpen?: boolean | \"auto\";\n /** A group's name. Default: `sourceLabels` for `groupBy=\"source\"`, else the key. */\n groupLabel?: (group: string) => string;\n /** The level of the group headings. Default `h2`: the panel is a page's body, under\n * the page's `h1`. */\n groupHeadingAs?: \"h2\" | \"h3\" | \"h4\";\n className?: string;\n labels?: Partial<TranslationReviewLabels>;\n}\n\n/** The wide screen's query — the same one DataTable switches its layout on, so \"a phone\"\n * means the same here as in the table. */\nconst MD_UP = \"(min-width: 768px)\";\n\n/**\n * How many rows the groups may render at once on a wide screen before they start folded\n * ({@link TranslationReviewPanelProps.defaultGroupsOpen}): two of the default 50-row pages.\n * A row costs about twenty elements in either layout, so this is the cost of two pages of\n * the ungrouped table — not of 2053 rows in 122 tables.\n */\nconst GROUPS_OPEN_ROW_BUDGET = 100;\n\n/**\n * Would opening every group stay within the budget? Each group renders up to `pageSize`\n * rows (it pages on its own); the budget is never less than one page, so a single group —\n * which renders at most one — always starts open, as on a phone.\n */\nfunction groupsFitOpen(groups: readonly TranslationRowGroup[], pageSize: number): boolean {\n const budget = Math.max(GROUPS_OPEN_ROW_BUDGET, pageSize);\n let rendered = 0;\n for (const group of groups) {\n rendered += Math.min(group.rows.length, pageSize);\n if (rendered > budget) return false;\n }\n return true;\n}\n\n/**\n * The kit's dev switch, as NumberInput's money guard and `lib/logger.ts` read it: a\n * development build — the consumer's bundler makes `import.meta.env.DEV` false in a\n * production one, and the branch is dead code there — and never under vitest, where every\n * consumer suite would print it; a test opts in with `vi.stubEnv(\"VITEST\", \"\")`. Read per\n * call, and optional-chained: `import.meta.env` is a Vite injection, absent under Node.\n */\nfunction devWarnings(): boolean {\n return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;\n}\n\ntype FilterField = keyof TranslationReviewFilter;\n\n/** How long after the last filter change the app's `filter` is looked at again: well past\n * a router's transition, so a URL that is still being written is not mistaken for one\n * that never will be. */\nconst FILTER_ECHO_MS = 1500;\n\n/**\n * The development warning for a controlled filter that does not follow (Kurvenschmiede,\n * 0.25.1). A field the app passes in `filter` is the app's: the panel shows the app's\n * value, whatever the reviewer picked. An app that passes a field but does not write the\n * reported value back — its `onFilterChange` hands on only some fields, `filter` is built\n * as `{ ...DEFAULT_TRANSLATION_REVIEW_FILTER, ...fromTheUrl }`, a URL parser rejects what\n * its serialiser wrote, or there is no `onFilterChange` at all — has a control that\n * silently does nothing: the box unticks itself, the select jumps back.\n *\n * So, after the panel reports a change of a field the app controls, it looks at the\n * app's `filter` again once the change has had time to land ({@link FILTER_ECHO_MS}, read\n * from the last COMMITTED props, so a router transition still rendering does not count),\n * and warns — once per field — when that field still holds the value it had before.\n *\n * Quiet by construction for an app that passes no `filter`, and for a field the app LEAVES\n * OUT: that field is the panel's own and works (keksdose keeps the placeholder switch so,\n * on purpose). Quiet too when the app moved the field somewhere else itself. Development\n * builds only (see {@link devWarnings}): in production and under vitest nothing is\n * recorded and no timer is set.\n */\nfunction useFilterEchoCheck(filterProp: Partial<TranslationReviewFilter> | undefined) {\n const committed = useRef(filterProp);\n useEffect(() => {\n committed.current = filterProp;\n });\n // Per field: what the app's filter said when the reviewer first changed it, and what\n // the panel reported last.\n const pending = useRef(new Map<FilterField, { from: unknown; to: unknown }>());\n const warned = useRef(new Set<FilterField>());\n const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(timer.current), []);\n\n return (next: TranslationReviewFilter) => {\n if (filterProp === undefined || !devWarnings()) return;\n for (const field of Object.keys(next) as FilterField[]) {\n const from = filterProp[field];\n if (from === undefined || warned.current.has(field)) continue;\n const seen = pending.current.get(field);\n if (seen) seen.to = next[field];\n else if (next[field] !== from) pending.current.set(field, { from, to: next[field] });\n }\n if (pending.current.size === 0) return;\n clearTimeout(timer.current);\n timer.current = setTimeout(() => {\n const now = committed.current;\n for (const [field, { from, to }] of pending.current) {\n if (to === from || now?.[field] !== from || warned.current.has(field)) continue;\n warned.current.add(field);\n console.warn(\n `[ui-kit] TranslationReviewPanel: the \"${field}\" filter was changed to ${JSON.stringify(to)}, but the ` +\n `\\`filter\\` prop still says ${JSON.stringify(from)}, so the control does nothing. A field passed in ` +\n `\\`filter\\` is the app's: write back every field \\`onFilterChange\\` reports, in one update ` +\n `(useSearchParamsState's setter takes the object as it is), or leave the field out of \\`filter\\` and ` +\n `the panel keeps it. (Development builds only; once per field.)`,\n );\n }\n pending.current.clear();\n }, FILTER_ECHO_MS);\n };\n}\n\n/** The row's text — or, for a missing string, the line that says so. */\nfunction TextCell({ row, labels, clamp }: { row: TranslationRow; labels: TranslationReviewLabels; clamp?: boolean }) {\n return row.text === \"\" ? (\n <span className=\"italic text-[var(--text-muted)]\">{labels.missingText}</span>\n ) : (\n <span lang={row.locale} className={cn(\"whitespace-pre-line break-words\", clamp && \"line-clamp-3\")}>\n {row.text}\n </span>\n );\n}\n\n/**\n * The page body for one locale: progress, filters (status, source, namespace, search,\n * placeholder problems), the strings with an editor that unfolds under each, and the\n * bulk actions over a selection.\n *\n * The status filter is the work queue in order — what is not there, what nobody has\n * read, what changed after it was read, what was sent back, what is done — with each\n * count on it. \"Missing\" is offered only where something is.\n *\n * Bulk approve takes the selected rows that are not approved yet and have a text:\n * approving an absent string says nothing, and re-approving one says it again. On a\n * phone the table has no checkboxes, so \"Select all shown\" selects what the filters\n * show — filter to `Unreviewed` in one area, read, select, approve. With `groupBy`, a\n * group's own \"Approve unreviewed\" is the shorter way, and with `swipe` a card is approved\n * by a swipe.\n *\n * The panel's writes go out ONE AT A TIME, in the order they were asked for: two writes on\n * one locale's verdicts can answer out of order, and an Undo must not overtake the\n * approval it takes back. A swipe on the next card while one is out is queued rather than\n * refused — a reviewer swiping down a list is faster than the server.\n */\nexport function TranslationReviewPanel({\n rows: rowsProp,\n localeLabel,\n referenceLabel,\n onSave,\n onClear,\n readOnly = false,\n areas,\n areaLabels,\n inArea,\n sourceLabels,\n filter: filterProp,\n onFilterChange,\n progress = true,\n pageSize = 50,\n storageKey,\n formatDate,\n formatError,\n swipe = false,\n undo: undoProp,\n groupBy,\n groupLabel,\n groupHeadingAs = \"h2\",\n defaultGroupsOpen = \"auto\",\n className,\n labels: labelsProp,\n}: TranslationReviewPanelProps) {\n const labels = useKitLabels(\"translationReview\", DEFAULT_TRANSLATION_REVIEW_LABELS, labelsProp);\n const locale = useKitLocale();\n const lock = useWriteLock();\n const phone = !useMediaQuery(MD_UP, true);\n const baseId = useId();\n const rootRef = useRef<HTMLDivElement>(null);\n const editable = !readOnly && onSave !== undefined;\n const binding: TranslationReviewSwipeBinding | null =\n swipe === true ? DEFAULT_TRANSLATION_REVIEW_SWIPE : swipe || null;\n const undoOn = undoProp ?? (binding !== null || groupBy !== undefined);\n\n const [ownFilter, setOwnFilter] = useState<TranslationReviewFilter>(DEFAULT_TRANSLATION_REVIEW_FILTER);\n // The app's fields over the panel's own; a field the app leaves `undefined` is the\n // panel's, not a blank.\n const filter: TranslationReviewFilter = { ...ownFilter };\n for (const [name, value] of Object.entries(filterProp ?? {})) {\n if (value !== undefined) Object.assign(filter, { [name]: value });\n }\n const echoFilter = useFilterEchoCheck(filterProp);\n const [openId, setOpenId] = useState<string | null>(null);\n // The row whose editor a swipe opened, to be written in: its wording field takes focus.\n const [focusId, setFocusId] = useState<string | null>(null);\n const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());\n // The writes asked for and not yet answered, in order: the first is the one out. The\n // editor keeps its own, for its own buttons.\n const [queued, setQueued] = useState<readonly string[]>([]);\n const claimed = useRef(new Set<string>());\n const tail = useRef<Promise<unknown>>(Promise.resolve());\n const undoCount = useRef(0);\n const [failure, setFailure] = useState<ReactNode>(null);\n const busy = queued.length > 0;\n const isQueued = (key: string) => queued.includes(key);\n\n const rows = useMemo(\n () => (areas ? rowsProp.filter((row) => keyInAreas(row.key, areas, inArea)) : [...rowsProp]),\n [rowsProp, areas, inArea],\n );\n const summary = useMemo(() => summariseRows(rows), [rows]);\n const sources = useMemo(() => [...new Set(rows.map((r) => r.source))], [rows]);\n const source = sources.includes(filter.source) ? filter.source : \"all\";\n const namespaces = useMemo(\n () => [...new Set(rows.filter((r) => source === \"all\" || r.source === source).map((r) => r.namespace))],\n [rows, source],\n );\n const namespace = namespaces.includes(filter.namespace) ? filter.namespace : \"all\";\n const mismatches = useMemo(() => rows.filter((r) => r.placeholderMismatch).length, [rows]);\n // `filter` is rebuilt every render; its fields are what the list depends on.\n const { status, query, placeholdersOnly } = filter;\n const visible = useMemo(\n () => filterTranslationRows(rows, { status, source, namespace, query, placeholdersOnly }),\n [rows, status, source, namespace, query, placeholdersOnly],\n );\n\n // ── Groups ──\n const groups = useMemo(() => {\n if (groupBy === undefined) return null;\n const of =\n groupBy === \"namespace\"\n ? (r: TranslationRow) => r.namespace\n : groupBy === \"source\"\n ? (r: TranslationRow) => r.source\n : groupBy;\n return groupTranslationRows(visible, of);\n }, [visible, groupBy]);\n const groupName = (key: string) =>\n groupLabel?.(key) ?? (groupBy === \"source\" ? sourceLabels?.[key] : undefined) ?? (key || \"—\");\n // Only what the reviewer chose; a group nobody has touched follows the default, which\n // follows the filters (see `defaultGroupsOpen`).\n const [groupOpen, setGroupOpen] = useState<Readonly<Record<string, boolean>>>({});\n const groupOpenByDefault =\n defaultGroupsOpen !== \"auto\"\n ? defaultGroupsOpen\n : groups !== null && (phone ? groups.length <= 1 : groupsFitOpen(groups, pageSize));\n const isGroupOpen = (key: string) => groupOpen[key] ?? groupOpenByDefault;\n const setGroupOpenFor = (key: string, open: boolean) => setGroupOpen((prev) => ({ ...prev, [key]: open }));\n /** A group the reviewer works in — an editor opened in it — stays open when the default\n * turns (a search cleared, the rows grown past the budget): folding it would drop the\n * editor and whatever was typed in it. */\n const keepGroupOpen = (key: string | undefined) => {\n if (key !== undefined) setGroupOpen((prev) => (key in prev ? prev : { ...prev, [key]: true }));\n };\n // The group whose large batch is waiting for a yes — and, until its confirm button\n // has mounted and taken focus, the group that asked.\n const [confirming, setConfirming] = useState<string | null>(null);\n const confirmFocus = useRef<string | null>(null);\n // One sort for every group's table, so a header click orders all of them alike.\n const [groupSorts, setGroupSorts] = useState<SortState[]>([]);\n\n const setFilter = (patch: Partial<TranslationReviewFilter>) => {\n const next = { ...filter, ...patch };\n setOwnFilter(next);\n onFilterChange?.(next);\n echoFilter(next);\n // A selection the new filter hides would be acted on unseen.\n setSelected(new Set());\n };\n\n /** Queue a write under `key` (one per key at a time); resolves whether it worked. */\n const run = (key: string, action: () => MaybePromise): Promise<boolean> => {\n if (claimed.current.has(key)) return Promise.resolve(false);\n claimed.current.add(key);\n setQueued((q) => [...q, key]);\n const step = tail.current.then(async () => {\n setFailure(null);\n try {\n await action();\n return true;\n } catch (caught) {\n setFailure(formatError ? formatError(caught) : labels.failed);\n return false;\n } finally {\n claimed.current.delete(key);\n setQueued((q) => q.filter((k) => k !== key));\n }\n });\n tail.current = step;\n return step;\n };\n\n /** \"String approved — Undo\", once `targets` (as they were BEFORE the write) are\n * approved — or, after a swiped `clear`, \"String marked unreviewed — Undo\". Either way\n * Undo puts back what the rows said before ({@link reviewUndo}), through the same queue\n * as every write. */\n const sayDone = (targets: readonly TranslationRow[], description?: ReactNode, cleared = false) => {\n const back = reviewUndo(targets);\n const message = (cleared ? labels.clearedToast : labels.approvedToast)(targets.length);\n const save = onSave;\n const clear = onClear;\n // Taking back the approval of a row that had no verdict is a clear: without\n // `onClear` there is no way to say it, so the toast reports and offers nothing.\n if (!save || (back.clears.length > 0 && !clear)) {\n toast.success(message, { description });\n return;\n }\n const key = `undo:${++undoCount.current}`;\n toast.undo(message, {\n description,\n onUndo: () =>\n void run(key, async () => {\n if (back.writes.length > 0) await save(back.writes, { origin: \"undo\", toasted: false });\n if (back.clears.length > 0) await clear?.(back.clears, { origin: \"undo\", toasted: false });\n }),\n });\n };\n\n const approve = async (\n key: string,\n targets: readonly TranslationRow[],\n origin: TranslationReviewOrigin,\n description?: ReactNode,\n ): Promise<boolean> => {\n if (!onSave || targets.length === 0) return false;\n const save = onSave;\n const ok = await run(key, () => save(targets.map((r) => reviewWrite(r, \"APPROVED\")), { origin, toasted: undoOn }));\n if (ok && undoOn) sayDone(targets, description);\n return ok;\n };\n\n /** A swipe bound to `clear`: the row's verdict taken back, answered like an approval —\n * the Undo toast stores the verdict again. Under the row's own key, so it queues behind\n * (and never overlaps) an approval of the same row. */\n const clearRow = async (row: TranslationRow) => {\n if (!onClear || row.review === null) return;\n const clear = onClear;\n const keys = [{ locale: row.locale, key: row.key }];\n const ok = await run(`row:${row.id}`, () => clear(keys, { origin: \"swipe\", toasted: undoOn }));\n if (ok && undoOn) sayDone([row], row.key, true);\n };\n\n /** The editor's own save: it waits, closes and shows its errors itself, so it does\n * not go through the queue — but an approval there is answered like any other. */\n const saveFromEditor = async (row: TranslationRow, write: TranslationReviewWrite) => {\n const toasted = undoOn && write.verdict === \"APPROVED\";\n await onSave?.([write], { origin: \"editor\", toasted });\n if (toasted) sayDone([row], row.key);\n };\n\n /**\n * Focus back into the panel once a group's batch has settled, if it fell out: the\n * button that was pressed goes with the rows it approved (and under the `Unreviewed`\n * filter the whole group can go). Two frames, so the app's new rows have been drawn.\n */\n const keepFocusAt = (groupKey: string) => {\n const check = () => {\n const root = rootRef.current;\n const active = document.activeElement;\n if (!root || (active && active !== document.body && active.isConnected)) return;\n const sections = [...root.querySelectorAll<HTMLElement>(\"[data-review-group]\")];\n const section = sections.find((el) => el.dataset.reviewGroup === groupKey) ?? sections[0];\n section?.querySelector<HTMLElement>(\"[data-disclosure-trigger]\")?.focus();\n };\n requestAnimationFrame(() => requestAnimationFrame(check));\n };\n\n const approveGroup = async (group: TranslationRowGroup, confirmed: boolean) => {\n const targets = unreviewedRows(group.rows);\n if (targets.length === 0) return;\n if (!confirmed && targets.length > pageSize) {\n // Asked inside the opened group, over the rows it is about.\n setGroupOpenFor(group.key, true);\n setConfirming(group.key);\n confirmFocus.current = group.key;\n return;\n }\n const ok = await approve(`group:${group.key}`, targets, \"group\", groupName(group.key));\n if (ok) setConfirming((key) => (key === group.key ? null : key));\n keepFocusAt(group.key);\n };\n\n const visibleIds = new Set(visible.map((r) => r.id));\n const selectedRows = visible.filter((r) => selected.has(r.id));\n // What \"Select all shown\" takes: grouped, the rows of the open groups — a folded\n // group's rows are not shown.\n const shownRows = groups ? groups.filter((g) => isGroupOpen(g.key)).flatMap((g) => g.rows) : visible;\n const allShownSelected = shownRows.length > 0 && shownRows.every((r) => selected.has(r.id));\n const toApprove = selectedRows.filter((r) => r.text !== \"\" && r.status !== \"approved\");\n const toReset = selectedRows.filter((r) => r.review !== null);\n\n const approveSelected = async () => {\n if (await approve(\"bulk-approve\", toApprove, \"selection\")) setSelected(new Set());\n };\n const resetSelected = async () => {\n if (!onClear || toReset.length === 0) return;\n const clear = onClear;\n const keys = toReset.map((r) => ({ locale: r.locale, key: r.key }));\n if (await run(\"bulk-reset\", () => clear(keys, { origin: \"selection\", toasted: false }))) {\n setSelected(new Set());\n }\n };\n\n // The status options: \"missing\" only where something is (or it is the filter).\n const statuses = REVIEW_STATUSES.filter((s) => s !== \"missing\" || summary.missing > 0 || filter.status === s);\n const scopeNames = (areas ?? []).map((area) => areaLabels?.[area] ?? area);\n const scopeText =\n scopeNames.length > 0 ? new Intl.ListFormat(locale, { type: \"conjunction\", style: \"long\" }).format(scopeNames) : \"\";\n\n const columns: DataTableColumn<TranslationRow>[] = [\n {\n key: \"key\",\n header: labels.key,\n cell: (r) => <code className=\"break-all text-xs text-[var(--text-secondary)]\">{r.key}</code>,\n sortBy: (r) => r.key,\n mobileHidden: true,\n },\n {\n key: \"reference\",\n header: referenceLabel,\n cell: (r) => (\n <span className=\"line-clamp-3 whitespace-pre-line break-words text-[var(--text-secondary)]\">{r.reference}</span>\n ),\n mobileHidden: true,\n },\n {\n key: \"text\",\n header: localeLabel,\n cell: (r) => <TextCell row={r} labels={labels} clamp />,\n mobilePrimary: true,\n },\n {\n key: \"status\",\n header: labels.statusColumn,\n cell: (r) => (\n <span className=\"inline-flex flex-wrap gap-1\">\n <ReviewStatusChip status={r.status} labels={labelsProp} />\n {r.placeholderMismatch && (\n <Chip size=\"sm\" tone=\"danger\" variant=\"outline\">\n {labels.placeholderChip}\n </Chip>\n )}\n </span>\n ),\n sortBy: (r) => REVIEW_STATUSES.indexOf(r.status),\n },\n ];\n if (editable) {\n columns.push({\n key: \"approve\",\n header: <span className=\"sr-only\">{labels.approve}</span>,\n cell: (r) =>\n r.status === \"approved\" || r.text === \"\" ? null : (\n <IconButton\n type=\"button\"\n size=\"xs\"\n tone=\"muted\"\n commit\n label={labels.approve}\n pending={isQueued(`row:${r.id}`)}\n disabled={busy}\n onClick={(event) => {\n // The row's own click opens the editor; this one only approves.\n event.stopPropagation();\n void approve(`row:${r.id}`, [r], \"row\", r.key);\n }}\n >\n <Check />\n </IconButton>\n ),\n });\n }\n\n /** One bound swipe action on one row, as {@link SwipeableRow} draws and commits it. */\n const swipeAction = (r: TranslationRow, id: TranslationReviewSwipeAction, groupKey?: string): SwipeAction => {\n const missing = r.text === \"\";\n switch (id) {\n case \"approve\":\n return {\n label: labels.approve,\n icon: <Check className=\"size-4\" aria-hidden />,\n onCommit: () => void approve(`row:${r.id}`, [r], \"swipe\", r.key),\n // Text on the fill in its own contrast colour: the fills are pastels in dark\n // mode, where SwipeableRow's default white text was unreadable (0.26).\n className: \"bg-[var(--success)] text-[var(--success-contrast)]\",\n armedClassName: \"bg-[var(--success)] text-[var(--success-contrast)]\",\n };\n case \"edit\":\n return {\n label: missing ? labels.suggest : labels.flag,\n icon: <PencilLine className=\"size-4\" aria-hidden />,\n // Opens the editor rather than sending the string back: \"needs a change\"\n // with neither a note nor a better wording tells nobody anything.\n onCommit: () => {\n keepGroupOpen(groupKey);\n setOpenId(r.id);\n setFocusId(r.id);\n },\n className: missing\n ? \"bg-[var(--brand)] text-[var(--brand-contrast)]\"\n : \"bg-[var(--danger)] text-[var(--danger-contrast)]\",\n armedClassName: missing\n ? \"bg-[var(--brand-hover)] text-[var(--brand-contrast)]\"\n : \"bg-[var(--danger-hover)] text-[var(--danger-contrast)]\",\n };\n case \"clear\":\n return {\n label: labels.reset,\n icon: <RotateCcw className=\"size-4\" aria-hidden />,\n onCommit: () => void clearRow(r),\n // A step back, not a verdict: neither the approval's green nor the send-back's red.\n // The surface colour as text: white on the grey in light mode, the dark page on\n // the light grey in dark mode.\n className: \"bg-[var(--text-muted)] text-[var(--bg-surface)]\",\n armedClassName: \"bg-[var(--text-secondary)] text-[var(--bg-surface)]\",\n };\n }\n };\n\n /**\n * A phone card's swipes: the binding ({@link TranslationReviewPanelProps.swipe}) resolved\n * against the row, each side's ladder closed up over what the row cannot offer. None\n * where the row must not be acted on — the same conditions under which its buttons are\n * locked or absent.\n */\n const swipeActions = (r: TranslationRow, groupKey?: string): MobileSwipeActions | null => {\n if (!binding || !editable || lock.locked || isQueued(`row:${r.id}`)) return null;\n const plan = translationReviewSwipePlan(binding, r, { canClear: onClear !== undefined });\n if (plan.start.length === 0 && plan.end.length === 0) return null;\n return {\n start: plan.start.map((id) => swipeAction(r, id, groupKey)),\n end: plan.end.map((id) => swipeAction(r, id, groupKey)),\n };\n };\n\n const toggleIn = (prev: ReadonlySet<string>, many: readonly TranslationRow[], checked: boolean) => {\n const next = new Set([...prev].filter((id) => visibleIds.has(id)));\n for (const r of many) {\n if (checked) next.add(r.id);\n else next.delete(r.id);\n }\n return next;\n };\n /** The checkbox column of a table over `list` — the whole list, or one group. */\n const selectionFor = (list: readonly TranslationRow[]): DataTableProps<TranslationRow>[\"selection\"] => {\n if (!editable) return undefined;\n const picked = list.filter((r) => selected.has(r.id)).length;\n return {\n isSelected: (r) => selected.has(r.id),\n onToggle: (r, checked) => setSelected((prev) => toggleIn(prev, [r], checked)),\n onToggleMany: (many, checked) => setSelected((prev) => toggleIn(prev, many, checked)),\n allSelected: list.length > 0 && picked === list.length,\n someSelected: picked > 0 && picked < list.length,\n onToggleAll: (checked) => setSelected((prev) => toggleIn(prev, list, checked)),\n };\n };\n\n const table = (list: TranslationRow[], group?: { key: string; name: string }) => (\n <DataTable<TranslationRow>\n rows={list}\n rowKey={(r) => r.id}\n columns={columns}\n empty={labels.empty}\n defaultPageSize={pageSize}\n {...(group\n ? {\n // Inside the group's own card: no frame in a frame, no column rail per group,\n // no scroller per group, and the group's name as the table's.\n frame: false,\n chrome: \"minimal\" as const,\n maxBodyHeight: \"none\",\n sorts: groupSorts,\n onSortsChange: setGroupSorts,\n labels: { table: group.name },\n }\n : { storageKey })}\n isExpanded={(r) => r.id === openId}\n onRowClick={(r) => {\n if (openId !== r.id) keepGroupOpen(group?.key);\n setFocusId(null);\n setOpenId((open) => (open === r.id ? null : r.id));\n }}\n expandedRow={(r) => (\n <TranslationReviewEditor\n key={r.id}\n row={r}\n referenceLabel={referenceLabel}\n localeLabel={localeLabel}\n readOnly={!editable}\n focusWording={focusId === r.id}\n onSave={onSave ? (write) => saveFromEditor(r, write) : undefined}\n onClear={onClear ? (key) => onClear([key], { origin: \"editor\", toasted: false }) : undefined}\n onClose={() => {\n setOpenId(null);\n setFocusId(null);\n }}\n formatDate={formatDate}\n formatError={formatError}\n labels={labelsProp}\n />\n )}\n mobileCard={(r) => (\n <div className=\"min-w-0 space-y-1\">\n <code className=\"block break-all text-xs text-[var(--text-muted)]\">{r.key}</code>\n <div className=\"text-sm text-[var(--text-primary)]\">\n <TextCell row={r} labels={labels} clamp />\n </div>\n <div className=\"flex flex-wrap gap-1\">\n <ReviewStatusChip status={r.status} labels={labelsProp} />\n {r.placeholderMismatch && (\n <Chip size=\"sm\" tone=\"danger\" variant=\"outline\">\n {labels.placeholderChip}\n </Chip>\n )}\n </div>\n </div>\n )}\n mobileSwipeActions={binding ? (r) => swipeActions(r, group?.key) : undefined}\n selection={selectionFor(list)}\n />\n );\n\n const groupSection = (g: TranslationRowGroup, index: number) => {\n const name = groupName(g.key);\n const titleId = `${baseId}-group-${index}`;\n const questionId = `${baseId}-confirm-${index}`;\n const targets = editable ? unreviewedRows(g.rows) : [];\n const key = `group:${g.key}`;\n const open = isGroupOpen(g.key);\n // A folded group renders its header and nothing else. Its body is handed over only\n // while it is open, or when the REVIEWER folded it — so the fold animates over the\n // rows it hides. A group folded by the default gets none: when the default turns (a\n // search cleared over 122 areas), the groups that close would otherwise render their\n // grown tables for the length of the fold.\n const body = open || groupOpen[g.key] === false;\n return (\n <Disclosure\n key={g.key}\n data-review-group={g.key}\n headingAs={groupHeadingAs}\n title={<span id={titleId}>{name}</span>}\n hint={labels.groupCount(g.summary.unreviewed, g.summary.total)}\n open={open}\n onOpenChange={(next) => {\n setGroupOpenFor(g.key, next);\n if (!next) setConfirming((c) => (c === g.key ? null : c));\n }}\n trailing={\n targets.length > 0 ? (\n <Button\n variant=\"secondary\"\n size=\"sm\"\n commit\n // The visible words say what; the group's name, read after them, says where.\n aria-describedby={titleId}\n pending={isQueued(key) && confirming !== g.key}\n disabled={busy}\n onClick={() => void approveGroup(g, false)}\n >\n <Check className=\"size-4\" aria-hidden />\n {labels.approveGroup(targets.length)}\n </Button>\n ) : undefined\n }\n bodyClassName=\"space-y-0 px-0 pb-0 border-t border-[var(--border)]\"\n >\n {body && confirming === g.key && targets.length > 0 && (\n <div className=\"border-b border-[var(--border)] p-3\">\n <AlertBanner tone=\"warning\" size=\"sm\">\n <div className=\"min-w-0 space-y-2\">\n <p id={questionId}>{labels.confirmGroup(targets.length, name)}</p>\n <div className=\"flex flex-wrap gap-2\">\n <Button\n // Focus lands here when the question appears: the expected answer to a\n // question the reviewer just asked for, and Undo stays behind it.\n ref={(el) => {\n if (el && confirmFocus.current === g.key) {\n confirmFocus.current = null;\n el.focus();\n }\n }}\n variant=\"primary\"\n size=\"sm\"\n commit\n aria-describedby={questionId}\n pending={isQueued(key)}\n disabled={busy}\n onClick={() => void approveGroup(g, true)}\n >\n {labels.approveGroup(targets.length)}\n </Button>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n disabled={isQueued(key)}\n onClick={() => {\n setConfirming(null);\n keepFocusAt(g.key);\n }}\n >\n {labels.cancel}\n </Button>\n </div>\n </div>\n </AlertBanner>\n </div>\n )}\n {body && table(g.rows, { key: g.key, name })}\n </Disclosure>\n );\n };\n return (\n <div ref={rootRef} className={cn(\"min-w-0 space-y-4\", className)}>\n {scopeText && (\n <AlertBanner tone=\"info\" variant=\"inline\" size=\"sm\" block live={false}>\n {labels.scope(scopeText)}\n </AlertBanner>\n )}\n {readOnly && (\n <AlertBanner tone=\"info\" variant=\"inline\" size=\"sm\" block live={false}>\n {labels.readOnly}\n </AlertBanner>\n )}\n\n {progress && <TranslationProgress summary={summary} labels={labelsProp} />}\n\n <ToggleGroup<ReviewStatus | \"all\">\n aria-label={labels.statusFilter}\n overflow=\"wrap\"\n size=\"sm\"\n value={filter.status}\n onChange={(next) => setFilter({ status: next })}\n options={[\n { value: \"all\", label: labels.filterCount(labels.all, summary.total) },\n ...statuses.map((s) => ({ value: s, label: labels.filterCount(reviewStatusLabel(labels, s), summary[s]) })),\n ]}\n />\n\n <div className=\"flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end\">\n {sources.length > 1 && (\n <div className=\"md:w-48\">\n <Select label={labels.source} value={source} onChange={(e) => setFilter({ source: e.target.value })}>\n <option value=\"all\">{labels.allSources}</option>\n {sources.map((s) => (\n <option key={s} value={s}>\n {sourceLabels?.[s] ?? s}\n </option>\n ))}\n </Select>\n </div>\n )}\n <div className=\"md:w-56\">\n <Select label={labels.namespace} value={namespace} onChange={(e) => setFilter({ namespace: e.target.value })}>\n <option value=\"all\">{labels.allNamespaces}</option>\n {namespaces.map((ns) => (\n <option key={ns} value={ns}>\n {ns}\n </option>\n ))}\n </Select>\n </div>\n <SearchField\n className=\"min-w-0 md:flex-1\"\n label={labels.search}\n value={filter.query}\n onChange={(next) => setFilter({ query: next })}\n />\n <Checkbox\n label={labels.placeholdersOnly(mismatches)}\n checked={filter.placeholdersOnly}\n onCheckedChange={(next) => setFilter({ placeholdersOnly: next })}\n />\n </div>\n\n {editable && shownRows.length > 0 && (\n // The phone's table has no checkboxes; this is its way into the bulk actions.\n <Button\n variant=\"ghost\"\n size=\"sm\"\n className=\"md:hidden\"\n disabled={allShownSelected}\n onClick={() => setSelected((prev) => toggleIn(prev, shownRows, true))}\n >\n <ListChecks className=\"size-4\" aria-hidden />\n {labels.selectShown}\n </Button>\n )}\n\n {failure && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n\n {editable && (\n <BulkActionBar count={selectedRows.length} onClear={() => setSelected(new Set())}>\n <Button\n variant=\"primary\"\n size=\"sm\"\n commit\n pending={isQueued(\"bulk-approve\")}\n disabled={busy || toApprove.length === 0}\n onClick={() => void approveSelected()}\n >\n <Check className=\"size-4\" aria-hidden />\n {labels.approveSelected}\n </Button>\n {onClear && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n commit\n pending={isQueued(\"bulk-reset\")}\n disabled={busy || toReset.length === 0}\n onClick={() => void resetSelected()}\n >\n <RotateCcw className=\"size-4\" aria-hidden />\n {labels.resetSelected}\n </Button>\n )}\n </BulkActionBar>\n )}\n\n {groups && groups.length > 0 ? (\n <div className=\"space-y-3\">{groups.map(groupSection)}</div>\n ) : (\n table(visible)\n )}\n </div>\n );\n}\n"],"mappings":"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","names":["ReviewStatusChip","TranslationProgress","TranslationReviewEditor","DEFAULT_TRANSLATION_REVIEW_LABELS"]}
|
|
@@ -95,7 +95,7 @@ type SearchParamsUpdate<V> = Partial<V> | ((prev: V) => Partial<V>);
|
|
|
95
95
|
* The value object keeps its identity while its params do not change, so it can sit in
|
|
96
96
|
* a dependency array. Requires a react-router Router.
|
|
97
97
|
*/
|
|
98
|
-
declare function useSearchParamsState<V extends
|
|
98
|
+
declare function useSearchParamsState<V extends object>(fields: SearchParamFields<V>): [V, (next: SearchParamsUpdate<V>) => void];
|
|
99
99
|
/**
|
|
100
100
|
* The active tab in `?tab=` (or `key`), with the default tab as the clean URL, for the
|
|
101
101
|
* kit's `Tabs`:
|
|
@@ -37,7 +37,14 @@ function putParam(params, rule, value) {
|
|
|
37
37
|
}
|
|
38
38
|
const queuedByLocation = /* @__PURE__ */ new WeakMap();
|
|
39
39
|
const NO_DATA_ROUTER = createContext(null);
|
|
40
|
-
const DataRouterContext =
|
|
40
|
+
const DataRouterContext = readDataRouterContext();
|
|
41
|
+
function readDataRouterContext() {
|
|
42
|
+
try {
|
|
43
|
+
return ReactRouter.UNSAFE_DataRouterContext ?? NO_DATA_ROUTER;
|
|
44
|
+
} catch {
|
|
45
|
+
return NO_DATA_ROUTER;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
41
48
|
function writeQuery(location, navigate, dataRouter, change) {
|
|
42
49
|
let queued = queuedByLocation.get(location);
|
|
43
50
|
if (!queued) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/use-search-param-state.ts"],"sourcesContent":["import { createContext, useContext, useMemo, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, useSearchParams, type Location, type NavigateFunction } from \"react-router\";\n\ntype Updater<T> = T | ((prev: T) => T);\n\nexport interface SearchParamStateOptions<T> {\n /**\n * The value of a param that IS present. Return `undefined` (or throw) for a value\n * that does not parse — a hand-edited or stale link — and the default is used.\n * Default: the raw string.\n */\n parse?: (raw: string) => T | undefined;\n /**\n * The param for a value, or `null` to remove it. Default: `String(value)`, and\n * `null` for `null` / `undefined`. A value that serialises the same as the default\n * removes the param too, so the default is always the clean URL.\n */\n serialize?: (value: T) => string | null;\n /** Shorthand for `history: \"replace\"`. */\n replace?: boolean;\n /**\n * What a change does to the browser history:\n * - `\"push\"` (default): a new entry, so Back undoes the change.\n * - `\"replace\"`: the current entry is rewritten — a tab, a sort, a view toggle,\n * which are not steps anyone wants Back to walk through.\n * - `\"replace-on-clear\"`: push when a value is set, replace when it is cleared (the\n * \"open a form, close it\" pattern).\n *\n * Several writes in one tick (one handler) add at most ONE entry — see\n * {@link useSearchParamState}.\n */\n history?: \"push\" | \"replace\" | \"replace-on-clear\";\n}\n\nconst defaultSerialize = (value: unknown): string | null =>\n value === null || value === undefined ? null : String(value);\n\n/** The route state of the current entry, carried over a `replace` so a query rewrite\n * does not drop what a navigation put there (a dialog's opener mark, below). */\nfunction carriedState(state: unknown): Record<string, unknown> | undefined {\n return state && typeof state === \"object\" ? (state as Record<string, unknown>) : undefined;\n}\n\n/* ── One param: read and write ───────────────────────────────────────────── */\n\n/** One param's rules — the single hook's arguments, or one field of the multi-key hook. */\ninterface ParamRule<T> {\n key: string;\n defaultValue: T;\n parse?: (raw: string) => T | undefined;\n serialize: (value: T) => string | null;\n history: \"push\" | \"replace\" | \"replace-on-clear\";\n}\n\nfunction paramRule<T>(key: string, defaultValue: T, options: SearchParamStateOptions<T>): ParamRule<T> {\n return {\n key,\n defaultValue,\n parse: options.parse,\n serialize: options.serialize ?? (defaultSerialize as (value: T) => string | null),\n history: options.history ?? (options.replace ? \"replace\" : \"push\"),\n };\n}\n\n/** An absent or unparseable param reads as the default. */\nfunction readParam<T>(params: URLSearchParams, rule: ParamRule<T>): T {\n const raw = params.get(rule.key);\n if (raw === null) return rule.defaultValue;\n if (!rule.parse) return raw as unknown as T;\n try {\n const parsed = rule.parse(raw);\n return parsed === undefined ? rule.defaultValue : parsed;\n } catch {\n return rule.defaultValue;\n }\n}\n\n/**\n * Writes `value` into `params` — the default removes the param. Returns `null` when\n * the param already says that (a write that changes nothing has no say in the history),\n * otherwise whether this change asks for an entry of its own.\n */\nfunction putParam<T>(params: URLSearchParams, rule: ParamRule<T>, value: T): boolean | null {\n const serialized = rule.serialize(value);\n const next = serialized === rule.serialize(rule.defaultValue) ? null : serialized;\n const current = params.getAll(rule.key);\n if (next === null ? current.length === 0 : current.length === 1 && current[0] === next) return null;\n if (next === null) params.delete(rule.key);\n else params.set(rule.key, next);\n return rule.history === \"push\" || (rule.history === \"replace-on-clear\" && next !== null);\n}\n\n/* ── Writes that compose ─────────────────────────────────────────────────── */\n\n/**\n * The query as the writes made against ONE rendered location leave it — shared by every\n * hook on that router, so a write builds on the one before it instead of on the render.\n *\n * Why this exists (keksdose live #378): react-router's `setSearchParams(updater)` hands\n * its updater `new URLSearchParams(searchParams)` — the params of the RENDER the setter\n * came from, in react-router 7 and 8 alike — not the previous call's result. keksdose\n * wired TranslationReviewPanel's `onFilterChange` to four of these setters (status,\n * source, ns, q); each started from the rendered URL, so the last wrote the old values\n * of the other three back and no filter select had any effect.\n *\n * Keyed by react-router's location object, which is one object per committed location\n * per router: every hook under a router reads the same one, a second router (another\n * test) has its own, and when react-router renders the next location this record is\n * unreachable — nothing queued outlives the navigation it belongs to. Until then a\n * later write builds on it even a tick on (a transition or a data router's loaders still\n * pending), which is what keeps two quick clicks from clobbering each other either. The\n * one cost: a navigation a data router's blocker stops leaves its writes here, and the\n * page's next write carries them again — the blocker asks again.\n */\ninterface QueuedQuery {\n /** The query with every write against this location so far. */\n params: URLSearchParams;\n /** The route state of the entry those writes now sit on, carried over a replace. */\n state: unknown;\n /** A write in the current tick pushed; the tick's later writes rewrite that entry. */\n pushedThisTick: boolean;\n /** The last navigation sent was a push. */\n lastPushed: boolean;\n}\n\nconst queuedByLocation = new WeakMap<Location, QueuedQuery>();\n\n/**\n * The data router's own state, read for ONE question: is a navigation still loading?\n * A data router whose loaders run commits a navigation only when they finish, and a\n * second `navigate` before then cancels the first — so a push followed by a replace\n * in the same tick would land as a replace alone. `UNSAFE_DataRouterContext` is in\n * react-router 6.4 through 8; read off the namespace with a stand-in, a release\n * without it costs only that refinement, not the import.\n */\ntype DataRouterProbe = { router?: { state?: { navigation?: { state?: string } } } } | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> =\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER;\n\n/**\n * One write: `change` edits the queued query in place and says whether a param it\n * changed asks for its own entry. The navigation goes out at once — a lone write\n * behaves exactly as before, and a test can assert right after `act` — carrying the\n * whole queued query, so it includes every write before it.\n *\n * History, per tick (the writes one handler makes before a microtask runs):\n * - the FIRST write that asks to push pushes; every later write in the tick REPLACES\n * that entry, so a handler that sets four params adds one entry, and Back undoes\n * all four at once — they were one change;\n * - a write that asks to replace, before any push, rewrites the entry it found;\n * - so mixed modes combine as \"push if any write asked to push\", which keeps both\n * promises: a push-mode param's change is undone by Back, and a replace-mode param\n * never adds an entry of its own (it rides on the one the push made). A replace\n * write ahead of a push stays on the entry it rewrote, as it would one tick apart.\n * - a write that changes nothing has no say, and sends nothing.\n */\nfunction writeQuery(\n location: Location,\n navigate: NavigateFunction,\n dataRouter: DataRouterProbe,\n change: (params: URLSearchParams) => boolean,\n): void {\n let queued = queuedByLocation.get(location);\n if (!queued) {\n queued = {\n params: new URLSearchParams(location.search),\n state: location.state,\n pushedThisTick: false,\n lastPushed: false,\n };\n queuedByLocation.set(location, queued);\n }\n const before = queued.params.toString();\n const wantsPush = change(queued.params);\n const search = queued.params.toString();\n if (search === before) return;\n\n // A data router still loading our push has not written it to the history yet: a\n // replace now would cancel it. Push again — the new navigation takes its place.\n const loading = (dataRouter?.router?.state?.navigation?.state ?? \"idle\") !== \"idle\";\n const push = (queued.lastPushed && loading) || (wantsPush && !queued.pushedThisTick);\n if (push) {\n queued.state = null;\n if (!queued.pushedThisTick) {\n queued.pushedThisTick = true;\n const record = queued;\n queueMicrotask(() => {\n record.pushedThisTick = false;\n });\n }\n }\n queued.lastPushed = push;\n void navigate(`?${search}`, push ? undefined : { replace: true, state: carriedState(queued.state) });\n}\n\n/** The rendered query, and a write that composes with every other one on the router. */\nfunction useQueryWriter(): [URLSearchParams, (change: (params: URLSearchParams) => boolean) => void] {\n const location = useLocation();\n const navigate = useNavigate();\n const dataRouter = useContext(DataRouterContext);\n const params = useMemo(() => new URLSearchParams(location.search), [location.search]);\n return [params, (change) => writeQuery(location, navigate, dataRouter, change)];\n}\n\n/* ── The hooks ───────────────────────────────────────────────────────────── */\n\n/**\n * One URL search param as React state: `const [view, setView] = useSearchParamState(\"view\", \"list\")`.\n *\n * Replaces the hand-rolled `setParams((prev) => { const p = new URLSearchParams(prev);\n * …set/delete…; return p })` blocks that keksdose had in a dozen pages and kastlan ported\n * as its own copy of this hook (shared/hooks/use-search-param-state.ts, both apps). The\n * kit's version takes the DEFAULT as an argument rather than inside `parse`, so the\n * two rules that follow from it cannot be forgotten at a call site:\n *\n * * an absent or unparseable param reads as the default, and\n * * setting the default removes the param — the default view is the clean URL.\n *\n * Every other param in the query string is left as it is.\n *\n * **Setters compose** (0.25, keksdose live #378): setters called in a row — of one hook,\n * or of several hooks under the same router — each build on the write before them, so\n * `setStatus(\"open\"); setNs(\"legal\")` in one handler leaves both in the URL. react-\n * router's own `setSearchParams(updater)` does not: it hands every updater the params of\n * the render, so the last call used to write the others' old values back. The tick's\n * writes add at most one history entry; see `history`. When the params belong\n * together, {@link useSearchParamsState} writes them with one setter. A functional\n * update receives the value as the writes before it left it.\n *\n * Requires a react-router Router.\n */\nexport function useSearchParamState<T = string>(\n key: string,\n defaultValue: T,\n options: SearchParamStateOptions<T> = {},\n): [T, (next: Updater<T>) => void] {\n const [params, write] = useQueryWriter();\n const rule = paramRule(key, defaultValue, options);\n const value = readParam(params, rule);\n\n const setValue = (next: Updater<T>) =>\n write((query) => {\n // Resolved against the queued query, not the render: a second update in the same\n // handler sees the first one's result, as React's own functional updates do.\n const resolved = typeof next === \"function\" ? (next as (prev: T) => T)(readParam(query, rule)) : next;\n return putParam(query, rule, resolved) === true;\n });\n\n return [value, setValue];\n}\n\n/** One field of {@link useSearchParamsState}: its default, and the single hook's options. */\nexport interface SearchParamField<T> extends SearchParamStateOptions<T> {\n /** The value when the param is absent or does not parse; writing it removes the param. */\n default: T;\n /** The param's name in the URL. Default: the field's name (`namespace: { param: \"ns\" }`). */\n param?: string;\n}\n\n/** The fields of {@link useSearchParamsState}, one per key of the value object. */\nexport type SearchParamFields<V> = { [K in keyof V]: SearchParamField<V[K]> };\n\n/** A partial value (fields left out — or `undefined` — stay as they are), or a function\n * of the current value returning one. */\nexport type SearchParamsUpdate<V> = Partial<V> | ((prev: V) => Partial<V>);\n\n/**\n * Several URL search params as ONE state object, with one setter that writes them in\n * one navigation:\n *\n * const [filter, setFilter] = useSearchParamsState({\n * status: { default: \"all\" as ReviewStatus | \"all\", parse: toStatus },\n * namespace: { param: \"ns\", default: \"all\" },\n * query: { param: \"q\", default: \"\", history: \"replace\" },\n * });\n * <TranslationReviewPanel filter={filter} onFilterChange={setFilter} />\n *\n * keksdose live #378: a filter bar reports all its filters on every change, and four\n * single-param setters in a row clobbered each other (see {@link useSearchParamState},\n * whose setters now compose too). This is the shape that never could: one setter, one\n * write, every field's rules in one place.\n *\n * Each field takes what the single hook takes — `parse`, `serialize`, `history` /\n * `replace` — and its `default` (the clean URL, as there) and optional `param` name.\n * The setter takes a partial object or a function of the current one; fields it leaves\n * out, or sets to `undefined`, stay as they are, and keys that are not fields are\n * ignored, so a component's whole filter object can be handed over as it is. Its\n * history is the single hook's rule over the fields that change: one entry, pushed if\n * any of them asks to push, else the current entry rewritten. Setting what the URL\n * already says navigates nowhere.\n *\n * The value object keeps its identity while its params do not change, so it can sit in\n * a dependency array. Requires a react-router Router.\n */\nexport function useSearchParamsState<V extends Record<string, unknown>>(\n fields: SearchParamFields<V>,\n): [V, (next: SearchParamsUpdate<V>) => void] {\n const [params, write] = useQueryWriter();\n const rules = (Object.keys(fields) as Array<keyof V & string>).map((name) => {\n const field = fields[name];\n return [name, paramRule(field.param ?? name, field.default, field)] as const;\n });\n const readAll = (query: URLSearchParams): V =>\n Object.fromEntries(rules.map(([name, rule]) => [name, readParam(query, rule)])) as V;\n\n // The fields' own params, not the whole query: another param changing (a tab, a\n // dialog) leaves this object as it was. `fields` is a fresh literal every render;\n // what it reads is the same while the params and the field names are.\n const ownParams = JSON.stringify(rules.map(([name, rule]) => [name, params.getAll(rule.key)]));\n // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on what the value is read from; see above\n const values = useMemo(() => readAll(params), [ownParams]);\n\n const setValues = (next: SearchParamsUpdate<V>) =>\n write((query) => {\n const patch = typeof next === \"function\" ? next(readAll(query)) : next;\n let push = false;\n for (const [name, rule] of rules) {\n const value = patch[name];\n if (value === undefined) continue;\n if (putParam(query, rule as ParamRule<unknown>, value) === true) push = true;\n }\n return push;\n });\n\n return [values, setValues];\n}\n\n/**\n * The active tab in `?tab=` (or `key`), with the default tab as the clean URL, for the\n * kit's `Tabs`:\n *\n * const [tab, setTab] = useTabParam(\"overview\", { tabs: [\"overview\", \"history\"] });\n * <Tabs tabs={…} active={tab} onChange={setTab} />\n *\n * Switching tabs REPLACES the history entry — a tab is a view of this page, not a\n * place Back should step through. `tabs`, when given, turns a param naming no tab (a\n * renamed tab, an old link) into the default rather than into a blank panel.\n * kastlan's use-tab-param.ts, with the validation it did not have.\n */\nexport function useTabParam<T extends string>(\n defaultTab: T,\n options: { key?: string; tabs?: readonly T[] } = {},\n): [T, (tab: T) => void] {\n const { key = \"tab\", tabs } = options;\n return useSearchParamState<T>(key, defaultTab, {\n parse: (raw) => (!tabs || (tabs as readonly string[]).includes(raw) ? (raw as T) : undefined),\n history: \"replace\",\n });\n}\n\n/** Where a dialog's open state lives: a param's presence (`\"edit\"` → `?edit=1`), or one\n * value of a shared param (`{ key: \"dialog\", value: \"create-lease\" }`). */\nexport type DialogParam = string | { key: string; value: string };\n\n/** The route-state mark an opener leaves on the entry it pushes, so the close knows it\n * may go BACK over it rather than add a second entry. */\nconst OPENED_BY_KIT = \"__uiKitDialogParam\";\n\n/**\n * A dialog's open state in the URL, for `Modal`'s `urlParam` and anything else that\n * opens one: `const [open, setOpen] = useDialogParam({ key: \"dialog\", value: \"create\" })`.\n *\n * * **Opening pushes** an entry, so the platform Back gesture closes the dialog — by\n * leaving the entry — and Forward opens it again, like any other step.\n * * **Closing goes back** over that entry when this hook pushed it, so a closed dialog\n * leaves no dead entry behind. A dialog that arrived OPEN (a deep link, a reload, a\n * plain `<Link>` to it) has nothing of ours to go back over, and its close removes the\n * param in place instead — Back from there leaves the page, as it should.\n *\n * That is why it is not `useSearchParamState`: going back is the only close that\n * leaves the history as it was before the dialog, and only the opener knows whether it\n * may.\n */\nexport function useDialogParam(param: DialogParam): [boolean, (open: boolean) => void] {\n const { key, value } = typeof param === \"string\" ? { key: param, value: \"1\" } : param;\n const [params, setParams] = useSearchParams();\n const location = useLocation();\n const navigate = useNavigate();\n const open = typeof param === \"string\" ? params.has(key) : params.get(key) === value;\n const mark = `${key}=${value}`;\n\n const setOpen = (next: boolean) => {\n if (next === open) return;\n const state = carriedState(location.state);\n if (next) {\n setParams(\n (prev) => {\n const p = new URLSearchParams(prev);\n p.set(key, value);\n return p;\n },\n { state: { ...state, [OPENED_BY_KIT]: mark } },\n );\n return;\n }\n if (state?.[OPENED_BY_KIT] === mark) {\n void navigate(-1);\n return;\n }\n setParams(\n (prev) => {\n const p = new URLSearchParams(prev);\n p.delete(key);\n return p;\n },\n { replace: true, state },\n );\n };\n\n return [open, setOpen];\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/use-search-param-state.ts"],"sourcesContent":["import { createContext, useContext, useMemo, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, useSearchParams, type Location, type NavigateFunction } from \"react-router\";\n\ntype Updater<T> = T | ((prev: T) => T);\n\nexport interface SearchParamStateOptions<T> {\n /**\n * The value of a param that IS present. Return `undefined` (or throw) for a value\n * that does not parse — a hand-edited or stale link — and the default is used.\n * Default: the raw string.\n */\n parse?: (raw: string) => T | undefined;\n /**\n * The param for a value, or `null` to remove it. Default: `String(value)`, and\n * `null` for `null` / `undefined`. A value that serialises the same as the default\n * removes the param too, so the default is always the clean URL.\n */\n serialize?: (value: T) => string | null;\n /** Shorthand for `history: \"replace\"`. */\n replace?: boolean;\n /**\n * What a change does to the browser history:\n * - `\"push\"` (default): a new entry, so Back undoes the change.\n * - `\"replace\"`: the current entry is rewritten — a tab, a sort, a view toggle,\n * which are not steps anyone wants Back to walk through.\n * - `\"replace-on-clear\"`: push when a value is set, replace when it is cleared (the\n * \"open a form, close it\" pattern).\n *\n * Several writes in one tick (one handler) add at most ONE entry — see\n * {@link useSearchParamState}.\n */\n history?: \"push\" | \"replace\" | \"replace-on-clear\";\n}\n\nconst defaultSerialize = (value: unknown): string | null =>\n value === null || value === undefined ? null : String(value);\n\n/** The route state of the current entry, carried over a `replace` so a query rewrite\n * does not drop what a navigation put there (a dialog's opener mark, below). */\nfunction carriedState(state: unknown): Record<string, unknown> | undefined {\n return state && typeof state === \"object\" ? (state as Record<string, unknown>) : undefined;\n}\n\n/* ── One param: read and write ───────────────────────────────────────────── */\n\n/** One param's rules — the single hook's arguments, or one field of the multi-key hook. */\ninterface ParamRule<T> {\n key: string;\n defaultValue: T;\n parse?: (raw: string) => T | undefined;\n serialize: (value: T) => string | null;\n history: \"push\" | \"replace\" | \"replace-on-clear\";\n}\n\nfunction paramRule<T>(key: string, defaultValue: T, options: SearchParamStateOptions<T>): ParamRule<T> {\n return {\n key,\n defaultValue,\n parse: options.parse,\n serialize: options.serialize ?? (defaultSerialize as (value: T) => string | null),\n history: options.history ?? (options.replace ? \"replace\" : \"push\"),\n };\n}\n\n/** An absent or unparseable param reads as the default. */\nfunction readParam<T>(params: URLSearchParams, rule: ParamRule<T>): T {\n const raw = params.get(rule.key);\n if (raw === null) return rule.defaultValue;\n if (!rule.parse) return raw as unknown as T;\n try {\n const parsed = rule.parse(raw);\n return parsed === undefined ? rule.defaultValue : parsed;\n } catch {\n return rule.defaultValue;\n }\n}\n\n/**\n * Writes `value` into `params` — the default removes the param. Returns `null` when\n * the param already says that (a write that changes nothing has no say in the history),\n * otherwise whether this change asks for an entry of its own.\n */\nfunction putParam<T>(params: URLSearchParams, rule: ParamRule<T>, value: T): boolean | null {\n const serialized = rule.serialize(value);\n const next = serialized === rule.serialize(rule.defaultValue) ? null : serialized;\n const current = params.getAll(rule.key);\n if (next === null ? current.length === 0 : current.length === 1 && current[0] === next) return null;\n if (next === null) params.delete(rule.key);\n else params.set(rule.key, next);\n return rule.history === \"push\" || (rule.history === \"replace-on-clear\" && next !== null);\n}\n\n/* ── Writes that compose ─────────────────────────────────────────────────── */\n\n/**\n * The query as the writes made against ONE rendered location leave it — shared by every\n * hook on that router, so a write builds on the one before it instead of on the render.\n *\n * Why this exists (keksdose live #378): react-router's `setSearchParams(updater)` hands\n * its updater `new URLSearchParams(searchParams)` — the params of the RENDER the setter\n * came from, in react-router 7 and 8 alike — not the previous call's result. keksdose\n * wired TranslationReviewPanel's `onFilterChange` to four of these setters (status,\n * source, ns, q); each started from the rendered URL, so the last wrote the old values\n * of the other three back and no filter select had any effect.\n *\n * Keyed by react-router's location object, which is one object per committed location\n * per router: every hook under a router reads the same one, a second router (another\n * test) has its own, and when react-router renders the next location this record is\n * unreachable — nothing queued outlives the navigation it belongs to. Until then a\n * later write builds on it even a tick on (a transition or a data router's loaders still\n * pending), which is what keeps two quick clicks from clobbering each other either. The\n * one cost: a navigation a data router's blocker stops leaves its writes here, and the\n * page's next write carries them again — the blocker asks again.\n */\ninterface QueuedQuery {\n /** The query with every write against this location so far. */\n params: URLSearchParams;\n /** The route state of the entry those writes now sit on, carried over a replace. */\n state: unknown;\n /** A write in the current tick pushed; the tick's later writes rewrite that entry. */\n pushedThisTick: boolean;\n /** The last navigation sent was a push. */\n lastPushed: boolean;\n}\n\nconst queuedByLocation = new WeakMap<Location, QueuedQuery>();\n\n/**\n * The data router's own state, read for ONE question: is a navigation still loading?\n * A data router whose loaders run commits a navigation only when they finish, and a\n * second `navigate` before then cancels the first — so a push followed by a replace\n * in the same tick would land as a replace alone. `UNSAFE_DataRouterContext` is in\n * react-router 6.4 through 8; read off the namespace with a stand-in, a release\n * without it costs only that refinement, not the import.\n */\ntype DataRouterProbe = { router?: { state?: { navigation?: { state?: string } } } } | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> = readDataRouterContext();\n\n/**\n * A static member read (so a bundler still tree-shakes the namespace), inside a `try`:\n * a test's `vi.mock(\"react-router\", () => ({ … }))` without the original module throws\n * on ANY export it does not define, and it threw here at import (keksdose, 0.25.0) —\n * the hook then runs without the refinement, as on a router without the context.\n */\nfunction readDataRouterContext(): Context<DataRouterProbe> {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER\n );\n } catch {\n return NO_DATA_ROUTER;\n }\n}\n\n/**\n * One write: `change` edits the queued query in place and says whether a param it\n * changed asks for its own entry. The navigation goes out at once — a lone write\n * behaves exactly as before, and a test can assert right after `act` — carrying the\n * whole queued query, so it includes every write before it.\n *\n * History, per tick (the writes one handler makes before a microtask runs):\n * - the FIRST write that asks to push pushes; every later write in the tick REPLACES\n * that entry, so a handler that sets four params adds one entry, and Back undoes\n * all four at once — they were one change;\n * - a write that asks to replace, before any push, rewrites the entry it found;\n * - so mixed modes combine as \"push if any write asked to push\", which keeps both\n * promises: a push-mode param's change is undone by Back, and a replace-mode param\n * never adds an entry of its own (it rides on the one the push made). A replace\n * write ahead of a push stays on the entry it rewrote, as it would one tick apart.\n * - a write that changes nothing has no say, and sends nothing.\n */\nfunction writeQuery(\n location: Location,\n navigate: NavigateFunction,\n dataRouter: DataRouterProbe,\n change: (params: URLSearchParams) => boolean,\n): void {\n let queued = queuedByLocation.get(location);\n if (!queued) {\n queued = {\n params: new URLSearchParams(location.search),\n state: location.state,\n pushedThisTick: false,\n lastPushed: false,\n };\n queuedByLocation.set(location, queued);\n }\n const before = queued.params.toString();\n const wantsPush = change(queued.params);\n const search = queued.params.toString();\n if (search === before) return;\n\n // A data router still loading our push has not written it to the history yet: a\n // replace now would cancel it. Push again — the new navigation takes its place.\n const loading = (dataRouter?.router?.state?.navigation?.state ?? \"idle\") !== \"idle\";\n const push = (queued.lastPushed && loading) || (wantsPush && !queued.pushedThisTick);\n if (push) {\n queued.state = null;\n if (!queued.pushedThisTick) {\n queued.pushedThisTick = true;\n const record = queued;\n queueMicrotask(() => {\n record.pushedThisTick = false;\n });\n }\n }\n queued.lastPushed = push;\n void navigate(`?${search}`, push ? undefined : { replace: true, state: carriedState(queued.state) });\n}\n\n/** The rendered query, and a write that composes with every other one on the router. */\nfunction useQueryWriter(): [URLSearchParams, (change: (params: URLSearchParams) => boolean) => void] {\n const location = useLocation();\n const navigate = useNavigate();\n const dataRouter = useContext(DataRouterContext);\n const params = useMemo(() => new URLSearchParams(location.search), [location.search]);\n return [params, (change) => writeQuery(location, navigate, dataRouter, change)];\n}\n\n/* ── The hooks ───────────────────────────────────────────────────────────── */\n\n/**\n * One URL search param as React state: `const [view, setView] = useSearchParamState(\"view\", \"list\")`.\n *\n * Replaces the hand-rolled `setParams((prev) => { const p = new URLSearchParams(prev);\n * …set/delete…; return p })` blocks that keksdose had in a dozen pages and kastlan ported\n * as its own copy of this hook (shared/hooks/use-search-param-state.ts, both apps). The\n * kit's version takes the DEFAULT as an argument rather than inside `parse`, so the\n * two rules that follow from it cannot be forgotten at a call site:\n *\n * * an absent or unparseable param reads as the default, and\n * * setting the default removes the param — the default view is the clean URL.\n *\n * Every other param in the query string is left as it is.\n *\n * **Setters compose** (0.25, keksdose live #378): setters called in a row — of one hook,\n * or of several hooks under the same router — each build on the write before them, so\n * `setStatus(\"open\"); setNs(\"legal\")` in one handler leaves both in the URL. react-\n * router's own `setSearchParams(updater)` does not: it hands every updater the params of\n * the render, so the last call used to write the others' old values back. The tick's\n * writes add at most one history entry; see `history`. When the params belong\n * together, {@link useSearchParamsState} writes them with one setter. A functional\n * update receives the value as the writes before it left it.\n *\n * Requires a react-router Router.\n */\nexport function useSearchParamState<T = string>(\n key: string,\n defaultValue: T,\n options: SearchParamStateOptions<T> = {},\n): [T, (next: Updater<T>) => void] {\n const [params, write] = useQueryWriter();\n const rule = paramRule(key, defaultValue, options);\n const value = readParam(params, rule);\n\n const setValue = (next: Updater<T>) =>\n write((query) => {\n // Resolved against the queued query, not the render: a second update in the same\n // handler sees the first one's result, as React's own functional updates do.\n const resolved = typeof next === \"function\" ? (next as (prev: T) => T)(readParam(query, rule)) : next;\n return putParam(query, rule, resolved) === true;\n });\n\n return [value, setValue];\n}\n\n/** One field of {@link useSearchParamsState}: its default, and the single hook's options. */\nexport interface SearchParamField<T> extends SearchParamStateOptions<T> {\n /** The value when the param is absent or does not parse; writing it removes the param. */\n default: T;\n /** The param's name in the URL. Default: the field's name (`namespace: { param: \"ns\" }`). */\n param?: string;\n}\n\n/** The fields of {@link useSearchParamsState}, one per key of the value object. */\nexport type SearchParamFields<V> = { [K in keyof V]: SearchParamField<V[K]> };\n\n/** A partial value (fields left out — or `undefined` — stay as they are), or a function\n * of the current value returning one. */\nexport type SearchParamsUpdate<V> = Partial<V> | ((prev: V) => Partial<V>);\n\n/**\n * Several URL search params as ONE state object, with one setter that writes them in\n * one navigation:\n *\n * const [filter, setFilter] = useSearchParamsState({\n * status: { default: \"all\" as ReviewStatus | \"all\", parse: toStatus },\n * namespace: { param: \"ns\", default: \"all\" },\n * query: { param: \"q\", default: \"\", history: \"replace\" },\n * });\n * <TranslationReviewPanel filter={filter} onFilterChange={setFilter} />\n *\n * keksdose live #378: a filter bar reports all its filters on every change, and four\n * single-param setters in a row clobbered each other (see {@link useSearchParamState},\n * whose setters now compose too). This is the shape that never could: one setter, one\n * write, every field's rules in one place.\n *\n * Each field takes what the single hook takes — `parse`, `serialize`, `history` /\n * `replace` — and its `default` (the clean URL, as there) and optional `param` name.\n * The setter takes a partial object or a function of the current one; fields it leaves\n * out, or sets to `undefined`, stay as they are, and keys that are not fields are\n * ignored, so a component's whole filter object can be handed over as it is. Its\n * history is the single hook's rule over the fields that change: one entry, pushed if\n * any of them asks to push, else the current entry rewritten. Setting what the URL\n * already says navigates nowhere.\n *\n * The value object keeps its identity while its params do not change, so it can sit in\n * a dependency array. Requires a react-router Router.\n */\nexport function useSearchParamsState<V extends object>(\n fields: SearchParamFields<V>,\n): [V, (next: SearchParamsUpdate<V>) => void] {\n const [params, write] = useQueryWriter();\n const rules = (Object.keys(fields) as Array<keyof V & string>).map((name) => {\n const field = fields[name];\n return [name, paramRule(field.param ?? name, field.default, field)] as const;\n });\n const readAll = (query: URLSearchParams): V =>\n Object.fromEntries(rules.map(([name, rule]) => [name, readParam(query, rule)])) as V;\n\n // The fields' own params, not the whole query: another param changing (a tab, a\n // dialog) leaves this object as it was. `fields` is a fresh literal every render;\n // what it reads is the same while the params and the field names are.\n const ownParams = JSON.stringify(rules.map(([name, rule]) => [name, params.getAll(rule.key)]));\n // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on what the value is read from; see above\n const values = useMemo(() => readAll(params), [ownParams]);\n\n const setValues = (next: SearchParamsUpdate<V>) =>\n write((query) => {\n const patch = typeof next === \"function\" ? next(readAll(query)) : next;\n let push = false;\n for (const [name, rule] of rules) {\n const value = patch[name];\n if (value === undefined) continue;\n if (putParam(query, rule as ParamRule<unknown>, value) === true) push = true;\n }\n return push;\n });\n\n return [values, setValues];\n}\n\n/**\n * The active tab in `?tab=` (or `key`), with the default tab as the clean URL, for the\n * kit's `Tabs`:\n *\n * const [tab, setTab] = useTabParam(\"overview\", { tabs: [\"overview\", \"history\"] });\n * <Tabs tabs={…} active={tab} onChange={setTab} />\n *\n * Switching tabs REPLACES the history entry — a tab is a view of this page, not a\n * place Back should step through. `tabs`, when given, turns a param naming no tab (a\n * renamed tab, an old link) into the default rather than into a blank panel.\n * kastlan's use-tab-param.ts, with the validation it did not have.\n */\nexport function useTabParam<T extends string>(\n defaultTab: T,\n options: { key?: string; tabs?: readonly T[] } = {},\n): [T, (tab: T) => void] {\n const { key = \"tab\", tabs } = options;\n return useSearchParamState<T>(key, defaultTab, {\n parse: (raw) => (!tabs || (tabs as readonly string[]).includes(raw) ? (raw as T) : undefined),\n history: \"replace\",\n });\n}\n\n/** Where a dialog's open state lives: a param's presence (`\"edit\"` → `?edit=1`), or one\n * value of a shared param (`{ key: \"dialog\", value: \"create-lease\" }`). */\nexport type DialogParam = string | { key: string; value: string };\n\n/** The route-state mark an opener leaves on the entry it pushes, so the close knows it\n * may go BACK over it rather than add a second entry. */\nconst OPENED_BY_KIT = \"__uiKitDialogParam\";\n\n/**\n * A dialog's open state in the URL, for `Modal`'s `urlParam` and anything else that\n * opens one: `const [open, setOpen] = useDialogParam({ key: \"dialog\", value: \"create\" })`.\n *\n * * **Opening pushes** an entry, so the platform Back gesture closes the dialog — by\n * leaving the entry — and Forward opens it again, like any other step.\n * * **Closing goes back** over that entry when this hook pushed it, so a closed dialog\n * leaves no dead entry behind. A dialog that arrived OPEN (a deep link, a reload, a\n * plain `<Link>` to it) has nothing of ours to go back over, and its close removes the\n * param in place instead — Back from there leaves the page, as it should.\n *\n * That is why it is not `useSearchParamState`: going back is the only close that\n * leaves the history as it was before the dialog, and only the opener knows whether it\n * may.\n */\nexport function useDialogParam(param: DialogParam): [boolean, (open: boolean) => void] {\n const { key, value } = typeof param === \"string\" ? { key: param, value: \"1\" } : param;\n const [params, setParams] = useSearchParams();\n const location = useLocation();\n const navigate = useNavigate();\n const open = typeof param === \"string\" ? params.has(key) : params.get(key) === value;\n const mark = `${key}=${value}`;\n\n const setOpen = (next: boolean) => {\n if (next === open) return;\n const state = carriedState(location.state);\n if (next) {\n setParams(\n (prev) => {\n const p = new URLSearchParams(prev);\n p.set(key, value);\n return p;\n },\n { state: { ...state, [OPENED_BY_KIT]: mark } },\n );\n return;\n }\n if (state?.[OPENED_BY_KIT] === mark) {\n void navigate(-1);\n return;\n }\n setParams(\n (prev) => {\n const p = new URLSearchParams(prev);\n p.delete(key);\n return p;\n },\n { replace: true, state },\n );\n };\n\n return [open, setOpen];\n}\n"],"mappings":"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","names":[]}
|
package/dist/i18n/german.js
CHANGED
|
@@ -748,6 +748,7 @@ function germanLabels(numberLocale) {
|
|
|
748
748
|
exportCorrections: (count) => `Korrekturen exportieren (${n(count)})`,
|
|
749
749
|
failed: "Das hat nicht geklappt. Bitte erneut versuchen.",
|
|
750
750
|
approvedToast: (count) => count === 1 ? "Text best\xE4tigt" : `${n(count)} Texte best\xE4tigt`,
|
|
751
|
+
clearedToast: (count) => count === 1 ? "Text als ungepr\xFCft markiert" : `${n(count)} Texte als ungepr\xFCft markiert`,
|
|
751
752
|
groupCount: (unreviewed, total) => `${n(unreviewed)} ungepr\xFCft / ${n(total)}`,
|
|
752
753
|
approveGroup: (count) => `Ungepr\xFCfte best\xE4tigen (${n(count)})`,
|
|
753
754
|
// The group's name quoted, as `columnMapper.roleOf`'s column: it is often a key
|