@eifi1/ui-kit 0.47.0 → 0.48.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/admin/admin-sections.d.ts +8 -0
- package/dist/admin/admin-sections.js +2 -1
- package/dist/admin/admin-sections.js.map +1 -1
- package/dist/auth/verify-email.d.ts +9 -1
- package/dist/auth/verify-email.js +22 -13
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/components/alert-banner.d.ts +29 -1
- package/dist/components/alert-banner.js +84 -7
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/button-look.d.ts +4 -0
- package/dist/components/button-look.js +2 -0
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/combobox-core.js +5 -1
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/data-table-column-settings.d.ts +6 -1
- package/dist/components/data-table-column-settings.js +12 -2
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-fit.d.ts +44 -0
- package/dist/components/data-table-fit.js +103 -0
- package/dist/components/data-table-fit.js.map +1 -0
- package/dist/components/data-table-groups.d.ts +95 -0
- package/dist/components/data-table-groups.js +165 -0
- package/dist/components/data-table-groups.js.map +1 -0
- package/dist/components/data-table-head.js +3 -1
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +7 -0
- package/dist/components/data-table-labels.js +3 -0
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-leading-row.d.ts +17 -0
- package/dist/components/data-table-leading-row.js +41 -0
- package/dist/components/data-table-leading-row.js.map +1 -0
- package/dist/components/data-table-resize.js +1 -1
- package/dist/components/data-table-resize.js.map +1 -1
- package/dist/components/data-table.d.ts +95 -2
- package/dist/components/data-table.js +360 -267
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/disclosure.d.ts +3 -1
- package/dist/components/disclosure.js +10 -4
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/dropdown.js +8 -1
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/floating-panel.d.ts +14 -1
- package/dist/components/floating-panel.js +7 -2
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/hover-menu.js +5 -1
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/icon-button.js +2 -1
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/popover.js +5 -1
- package/dist/components/popover.js.map +1 -1
- package/dist/components/table-skeleton.d.ts +35 -0
- package/dist/components/table-skeleton.js +75 -0
- package/dist/components/table-skeleton.js.map +1 -0
- package/dist/components/tabs.d.ts +10 -12
- package/dist/components/tabs.js +20 -10
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/use-table-state.d.ts +8 -7
- package/dist/components/use-table-state.js +37 -3
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/data-table.d.ts +1 -0
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-table.d.ts +5 -0
- package/dist/feedback/feedback-table.js +30 -7
- package/dist/feedback/feedback-table.js.map +1 -1
- package/dist/hooks/use-dismiss.d.ts +30 -1
- package/dist/hooks/use-dismiss.js +30 -2
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/i18n/german.js +6 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +4 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +6 -1
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +6 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +6 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +6 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +6 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +6 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/shared-labels.js +3 -1
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/float-clearance.d.ts +8 -0
- package/dist/lib/float-clearance.js +29 -0
- package/dist/lib/float-clearance.js.map +1 -0
- package/dist/settings/settings-catalogue.d.ts +10 -0
- package/dist/settings/settings-catalogue.js.map +1 -1
- package/dist/settings/settings-layout.js +10 -3
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/shell/app-shell.d.ts +5 -0
- package/dist/shell/app-shell.js +5 -3
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.d.ts +2 -0
- package/dist/shell/option-switcher-menu.js +6 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +2 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.js +5 -3
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/tour/tour.js +1 -0
- package/dist/tour/tour.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-sections.tsx +8 -0
- package/src/auth/verify-email.tsx +34 -9
- package/src/components/alert-banner.tsx +131 -7
- package/src/components/button-look.ts +6 -0
- package/src/components/combobox-core.tsx +7 -1
- package/src/components/data-table-column-settings.tsx +23 -2
- package/src/components/data-table-fit.ts +187 -0
- package/src/components/data-table-groups.tsx +275 -0
- package/src/components/data-table-head.tsx +4 -1
- package/src/components/data-table-labels.ts +13 -0
- package/src/components/data-table-leading-row.tsx +55 -0
- package/src/components/data-table-resize.ts +2 -1
- package/src/components/data-table.tsx +279 -54
- package/src/components/disclosure.tsx +22 -5
- package/src/components/dropdown.tsx +11 -1
- package/src/components/floating-panel.tsx +24 -1
- package/src/components/hover-menu.tsx +6 -1
- package/src/components/icon-button.tsx +3 -3
- package/src/components/popover.tsx +7 -1
- package/src/components/table-skeleton.tsx +133 -0
- package/src/components/tabs.tsx +67 -37
- package/src/components/use-table-state.ts +54 -3
- package/src/data-table.ts +2 -0
- package/src/feedback/feedback-table.tsx +33 -1
- package/src/hooks/use-dismiss.ts +65 -3
- package/src/i18n/german.ts +5 -0
- package/src/i18n/kit-labels.tsx +4 -0
- package/src/i18n/locales/es.ts +5 -0
- package/src/i18n/locales/fr.ts +5 -0
- package/src/i18n/locales/hu.ts +5 -0
- package/src/i18n/locales/it.ts +5 -0
- package/src/i18n/locales/zh.ts +5 -0
- package/src/i18n/shared-labels.ts +2 -0
- package/src/index.ts +3 -0
- package/src/lib/float-clearance.ts +54 -0
- package/src/settings/settings-catalogue.ts +10 -0
- package/src/settings/settings-layout.tsx +18 -4
- package/src/shell/app-shell.tsx +17 -3
- package/src/shell/option-switcher-menu.tsx +16 -4
- package/src/shell/topbar-action-menu.tsx +22 -10
- package/src/shell/topbar-controls.tsx +18 -12
- package/src/tour/tour.tsx +5 -0
package/dist/components/tabs.js
CHANGED
|
@@ -12,6 +12,7 @@ import { useKitLabels } from "../i18n/kit-labels.js";
|
|
|
12
12
|
import { mergeRefs } from "./field-parts.js";
|
|
13
13
|
import { DEFAULT_TABS_LABELS } from "./ui-labels.js";
|
|
14
14
|
import { IconButton } from "./icon-button.js";
|
|
15
|
+
import { HIT_AREA_LARGE } from "./button-look.js";
|
|
15
16
|
const TABLIST_CLASSES = "flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle";
|
|
16
17
|
const TABLIST_VERTICAL_CLASSES = "flex flex-col gap-0.5";
|
|
17
18
|
const TAB_VERTICAL_CLASSES = FOCUS_RING_WIDTH + " flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus-visible:ring-strong";
|
|
@@ -59,17 +60,25 @@ function Tabs({
|
|
|
59
60
|
const toward = (side === "end" ? 1 : -1) * (rtl ? -1 : 1);
|
|
60
61
|
strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });
|
|
61
62
|
};
|
|
62
|
-
const
|
|
63
|
+
const gutterAt = (side, shown) => /* @__PURE__ */ jsx(
|
|
63
64
|
"div",
|
|
64
65
|
{
|
|
65
|
-
"data-tabs-
|
|
66
|
-
className: cn(
|
|
67
|
-
|
|
66
|
+
"data-tabs-cue-gutter": side,
|
|
67
|
+
className: cn(
|
|
68
|
+
"absolute inset-y-0 flex w-[24px] items-center justify-center border-b border-subtle pb-px",
|
|
69
|
+
side === "start" ? "start-0" : "end-0"
|
|
70
|
+
),
|
|
71
|
+
children: shown && /* @__PURE__ */ jsx("div", { "data-tabs-overflow-cue": side, className: "flex", children: /* @__PURE__ */ jsx(
|
|
68
72
|
IconButton,
|
|
69
73
|
{
|
|
70
74
|
size: "sm",
|
|
71
|
-
variant: "
|
|
72
|
-
className:
|
|
75
|
+
variant: "ghost",
|
|
76
|
+
className: cn(
|
|
77
|
+
"w-[24px] large:min-w-0 bg-transparent text-muted hover:bg-transparent hover:text-primary",
|
|
78
|
+
// Over the strip's faded edge, for ‹ too.
|
|
79
|
+
HIT_AREA_LARGE,
|
|
80
|
+
"large:after:z-[1]"
|
|
81
|
+
),
|
|
73
82
|
tabIndex: -1,
|
|
74
83
|
type: "button",
|
|
75
84
|
label: side === "start" ? text.previous : text.more,
|
|
@@ -77,7 +86,7 @@ function Tabs({
|
|
|
77
86
|
onClick: () => onCue(side),
|
|
78
87
|
children: side === "start" ? /* @__PURE__ */ jsx(ChevronLeft, { className: "rtl:-scale-x-100" }) : /* @__PURE__ */ jsx(ChevronRight, { className: "rtl:-scale-x-100" })
|
|
79
88
|
}
|
|
80
|
-
)
|
|
89
|
+
) })
|
|
81
90
|
}
|
|
82
91
|
);
|
|
83
92
|
useLayoutEffect(() => {
|
|
@@ -263,10 +272,11 @@ function Tabs({
|
|
|
263
272
|
children: text.remove(nameOf(activeTab))
|
|
264
273
|
}
|
|
265
274
|
) : null;
|
|
266
|
-
const
|
|
275
|
+
const gutters = cue && fade.overflow !== void 0;
|
|
276
|
+
const stripBox = cue ? /* @__PURE__ */ jsxs("div", { className: cn("relative flex min-w-0", !onAdd && "flex-1", gutters && "px-[24px]"), children: [
|
|
267
277
|
tablist,
|
|
268
|
-
|
|
269
|
-
|
|
278
|
+
gutters && gutterAt("start", cueStart),
|
|
279
|
+
gutters && gutterAt("end", cueEnd)
|
|
270
280
|
] }) : tablist;
|
|
271
281
|
if (!onAdd && cue) {
|
|
272
282
|
return /* @__PURE__ */ jsxs("div", { className: cn("flex items-end", className), children: [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/tabs.tsx"],"sourcesContent":["import { useLayoutEffect, useMemo, useRef } from \"react\";\nimport { ChevronLeft, ChevronRight, Plus, X } from \"lucide-react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { scrollIntoStrip, useStripFade } from \"../lib/strip-fade\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { prefersReducedMotion } from \"../lib/reduced-motion\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { mergeRefs } from \"./field-parts\";\nimport { DEFAULT_TABS_LABELS } from \"./ui-labels\";\nimport { IconButton } from \"./icon-button\";\n\n/** One tab of a {@link Tabs} strip. */\nexport interface TabItem<T extends string> {\n id: T;\n /** A ReactNode so a tab can pair an icon with text. */\n label: ReactNode;\n /** Optional trailing node (e.g. a count pill). */\n badge?: ReactNode;\n /** Marks a tab that IS a route — see {@link TabsProps}. */\n href?: string;\n /** Leading decoration: an icon, or the colour swatches of the lines this tab\n * draws in a chart above the strip. Hidden from assistive tech — it repeats\n * what the label says, or it says something the label must say instead. */\n icon?: ReactNode;\n /** A second, smaller line under the label — the one fact that tells two tabs\n * with similar labels apart (\"80 km/h\", \"Level 3\"). Part of the tab's name. */\n detail?: ReactNode;\n /** A tab that exists as a slot with nothing in it yet, drawn with a dashed\n * outline so a gap in a sequence looks like a gap. Visual only: say \"empty\" in\n * the label or `detail` if a screen reader needs to know. */\n empty?: boolean;\n /** `false` keeps this one tab when the strip has `onRemove` — the one sheet a\n * workbook cannot be without. Default: removable. */\n removable?: boolean;\n /** Plain-text name for the labels the strip composes about this tab (\"Remove\n * …\"), for when `label` is not a string. Default: `label` if it is a string,\n * else the id. */\n name?: string;\n}\n\n/** The strings the strip renders on its own behalf — only when it can add or\n * remove tabs. */\nexport interface TabsLabels {\n /** The \"add\" button's visible text when the caller gives no `addLabel`. */\n add: string;\n /** Accessible name of a tab's × — \"Remove <tab>\". */\n remove: (tab: string) => string;\n /** The `overflowCue` › over the strip's end, while tabs lie past it: \"More tabs\". */\n more: string;\n /** The `overflowCue` ‹ over the strip's start, while tabs lie before it: \"Previous tabs\". */\n previous: string;\n /**\n * @deprecated 0.47: not drawn any more — the caret that turned into \"back to the\n * first tab\" at the strip's end is now a ‹ of its own (`previous`). Accepted and\n * ignored; removed in 0.48.\n */\n backToFirst?: string;\n}\n\n// Defined in ./ui-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_TABS_LABELS };\n\n/**\n * The strip's own props sit on a `<div>`: the tablist IS the root element, so anything\n * a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an\n * `aria-describedby` — lands there. `onChange` is omitted from the div's props because\n * this component's `onChange` hands over the chosen TAB ID, not a DOM event, and\n * `children` because the strip renders `tabs` — a caller who wants a PANEL wires it up\n * through `panelId`, which is the whole point of that prop.\n */\nexport interface TabsProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n /** `label` is a ReactNode so tabs can pair an icon with text; `badge` is an\n * optional trailing node (e.g. a count pill). `href` marks a tab that IS a route:\n * it renders as an anchor so it can be middle-/⌘-clicked into a new tab (Keksdose\n * feedback #451), while a plain click still goes through `onChange` and stays\n * client-side. Tabs that only flip local state leave it unset — a link to a URL\n * that does not select the tab would be worse than no link. */\n tabs: TabItem<T>[];\n active: T;\n onChange: (id: T) => void;\n className?: string;\n /**\n * Carets over the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose\n * live #410 round 4): a › over the strip's end while tabs lie past it, and a ‹ over\n * its start while tabs lie before it — each there only while something is hidden\n * past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three\n * quarters of the strip's width toward its own side. Neither ever changes direction:\n * until 0.46 one caret turned round into \"back to the first\" at the end, and read as\n * an arrow pointing the wrong way. Start and end follow the reading direction.\n *\n * The edge fade alone could read as the end of the row: the last visible tab can\n * finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).\n * The carets sit OVER the strip's ends (positioned, on the page's surface), not beside\n * it, so the strip's width never changes as they come and go. Out of the tab order —\n * the keyboard walks the strip with the arrow keys, which bring the focused tab into\n * view. At every width. Ignored on a vertical strip (a side nav is a column, not a\n * row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is\n * the reference this replaces.\n */\n overflowCue?: boolean;\n /**\n * `id` of the element the caller renders the open tab's content into — the missing\n * half of `role=\"tab\"`. A tab that controls nothing is a tab in name only: a screen\n * reader announces \"tab, 2 of 5\" and then has no way to take the user to what it\n * opened, and no way back.\n *\n * **This component does not render the panel, on purpose.** `Tabs` is the STRIP; the\n * content lives wherever the caller put it — three Keksdose pages render it in a\n * sibling `<Card>`, one renders it through a router outlet, and a tab can BE a route\n * (`href`), in which case the panel is a whole page this component never sees.\n * Wrapping `children` here would mean either moving that content into the strip's\n * subtree — a layout change on every page that uses tabs — or shipping a wrapper\n * that only some callers could use. So the two halves are joined by an id instead.\n *\n * Wire the other end yourself:\n *\n * ```tsx\n * <Tabs tabs={tabs} active={active} onChange={setActive} panelId=\"report-panel\" />\n * <div id=\"report-panel\" role=\"tabpanel\" aria-labelledby=\"report-panel-tab\" tabIndex={-1}>\n * ```\n *\n * `aria-controls` goes on the OPEN tab only, and `${panelId}-tab` is its id — the\n * closed tabs' panels are not in the document, and a dangling `aria-controls`\n * describes a tab as opening something that is not there.\n */\n panelId?: string;\n /**\n * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab\n * (only on the open one with `removeOn=\"active\"`).\n *\n * The × is on the open tab only — a row of tabs each carrying one is a row of\n * things to hit by accident, and the tab you can remove should be the one you\n * are looking at — but its room is reserved on every removable tab, so opening a\n * tab never widens it and never pushes the rest of the strip along (lenkbank\n * feedback #72). It is not a tab stop of its own: the strip stays one stop, and the\n * keyboard path is Delete (announced through `aria-keyshortcuts`), which removes\n * whichever tab has focus (see `removeOn`).\n *\n * Assistive tech meets the remove as a \"Remove <tab>\" button right AFTER the strip,\n * not as the × (0.34): a tablist may own tabs only, so the drawn × is the pointer's\n * and `aria-hidden`, and the named button beside the tablist is visually hidden.\n *\n * The strip only ASKS: it calls this, and the tab goes when the caller drops it\n * from `tabs` — after a confirmation, a request, whatever it needs. When it does,\n * focus that was on the removed tab (or its ×) moves to the open tab if the\n * removed one was open, else to its neighbour, instead of falling to the page.\n */\n onRemove?: (id: T) => void;\n /**\n * Which tabs the Delete key removes.\n *\n * - `\"every\"` (default): any focused removable tab — the ARIA pattern's reading.\n * - `\"active\"`: only the OPEN tab, the same one that wears the ×. For a strip whose\n * remove is immediate and unconfirmed (lenkbank's sheet tabs delete a measurement\n * sheet at once), so arrowing across the strip and pressing Delete cannot take\n * out a sheet the user is not looking at. Delete on a closed tab does nothing,\n * and only the open tab announces the shortcut (`aria-keyshortcuts`).\n *\n * Either way the × is drawn on the open tab only and its room stays reserved on\n * every removable tab, so the strip never relayouts when the selection moves.\n */\n removeOn?: \"every\" | \"active\";\n /** Renders an \"add\" button after the last tab, outside the tablist — it is an\n * action, not a tab, and a tablist may own only tabs. */\n onAdd?: () => void;\n /** The add button's text. Say what is added — \"Add loop\", \"Add report\" says\n * more than \"Add\". Default: `tabs.add` from the {@link UiKitProvider}. */\n addLabel?: string;\n /** Overrides for this strip's strings. See {@link TabsLabels}. */\n labels?: LabelOverride<TabsLabels>;\n /** An add or a remove is in flight: the × and the add button are disabled and\n * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */\n busy?: boolean;\n /**\n * `\"vertical\"`: the same strip as a SIDE NAV — one item per row, the open one\n * filled, ↑/↓ walking it (Home/End as before) and `aria-orientation=\"vertical\"` on\n * the tablist. For keksdose's admin page and settings page, which each hand-built\n * the identical sidebar with none of this strip's keyboard, roving tab stop or\n * routed-`href` handling. Everything else — `href` tabs, `badge`, `panelId`,\n * `onRemove`, `onAdd` — means exactly what it means on the horizontal strip; the\n * badge moves to the row's end.\n *\n * **On a phone (`usePhoneLayout()`) it becomes the horizontal strip**, rather than\n * staying a column. A side nav only works beside its content; on\n * a phone it has to go ABOVE it, and eight full-width rows there push the panel\n * the user picked below the fold on every visit — the two keksdose pages both\n * collapsed theirs into a scrolling row by hand for exactly that reason. The\n * orientation is switched in JavaScript, not with `md:` classes, so the\n * `aria-orientation` a screen reader hears and the arrow keys that work always\n * match the layout on screen. The caller's own two-column layout has to stack at\n * the same breakpoint (`md:grid-cols-[14rem_1fr]`).\n */\n orientation?: \"horizontal\" | \"vertical\";\n /** The `role=\"tablist\"` strip, where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10). Merged with the strip's own ref, which scrolls the open tab into\n * view and measures the fade, so neither loses the element. */\n ref?: Ref<HTMLDivElement>;\n}\n\nconst TABLIST_CLASSES =\n \"flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle\";\n// The side nav. A column of full-width rows with the open one FILLED rather than\n// underlined: an underline under one row of a list reads as a separator, not as a\n// selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the\n// admin page's nav and the app's own sidebar mark \"you are here\" the same way.\nconst TABLIST_VERTICAL_CLASSES = \"flex flex-col gap-0.5\";\nconst TAB_VERTICAL_CLASSES =\n FOCUS_RING_WIDTH +\n \" flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus-visible:ring-strong\";\nconst TAB_VERTICAL_ACTIVE_CLASSES = \"bg-active text-primary\";\nconst TAB_VERTICAL_INACTIVE_CLASSES =\n \"text-muted hover:bg-hover hover:text-primary\";\n\n// `max-w-full`: no tab outgrows its strip. A sheet's `detail` line (\"Spurstangenkraft\n// links · … · 31 Punkte\") made one tab 448px wide in a 390px strip, and a tab wider\n// than the strip cannot be scrolled into view — its end was simply off screen\n// (lenkbank L3). The label and detail truncate inside it instead; see `inner`.\nconst TAB_CLASSES =\n FOCUS_RING_WIDTH +\n \" max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus-visible:ring-strong\";\nconst TAB_ACTIVE_CLASSES = \"border-[var(--text-primary)] text-primary\";\nconst TAB_INACTIVE_CLASSES =\n \"border-transparent text-muted hover:text-secondary hover:border-strong\";\n\n// The add/remove chrome. The × reserves its gutter on every removable tab (see\n// `onRemove`).\nconst TAB_REMOVABLE_CLASSES = \"pe-8\";\nconst TAB_EMPTY_CLASSES = \"-mx-1.5 rounded border border-dashed border-strong px-1.5\";\nconst TAB_ADD_CLASSES =\n FOCUS_RING_WIDTH +\n \" inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none focus-visible:ring-strong disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4\";\n// `relative`: the phone's visually hidden label needs a positioned ancestor (see the\n// sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone \"+\".\nconst TAB_ADD_PHONE_CLASSES = \"relative max-md:px-2\";\n\nexport function Tabs<T extends string>({\n tabs,\n active,\n onChange,\n className,\n overflowCue = false,\n panelId,\n onRemove,\n removeOn = \"every\",\n onAdd,\n addLabel,\n labels,\n busy = false,\n orientation = \"horizontal\",\n \"aria-label\": ariaLabel,\n ref,\n ...rest\n}: TabsProps<T>) {\n const text = useKitLabels(\"tabs\", DEFAULT_TABS_LABELS, labels);\n // See `orientation`: a vertical strip is the horizontal one on a phone.\n const phone = usePhoneLayout();\n const vertical = orientation === \"vertical\" && !phone;\n const stripRef = useRef<HTMLDivElement>(null);\n const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);\n const fade = useStripFade(stripRef);\n // See `overflowCue`: a ‹ over the start while tabs lie before it, a › over the end\n // while tabs lie past it — `fade.overflow` names the cut ends in reading-direction terms.\n const cue = overflowCue && !vertical;\n const cueStart = cue && (fade.overflow === \"start\" || fade.overflow === \"both\");\n const cueEnd = cue && (fade.overflow === \"end\" || fade.overflow === \"both\");\n // Three quarters of the visible width toward `side`; an RTL strip runs leftward.\n const onCue = (side: \"start\" | \"end\") => {\n const strip = stripRef.current;\n if (!strip) return;\n const behavior: ScrollBehavior = prefersReducedMotion() ? \"auto\" : \"smooth\";\n const rtl = getComputedStyle(strip).direction === \"rtl\";\n const toward = (side === \"end\" ? 1 : -1) * (rtl ? -1 : 1);\n strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });\n };\n // OVER the strip's end, on the page's surface, so the caret reads as a control and not\n // as one more faded tab — and so it can come and go without the strip changing width\n // under the thumb. The position sits on a wrapper of ours: IconButton puts its\n // tooltip's span around the button, and a class on the button would land inside it.\n const cueAt = (side: \"start\" | \"end\") => (\n <div\n data-tabs-overflow-cue={side}\n className={cn(\"absolute top-1/2 z-10 flex -translate-y-1/2\", side === \"start\" ? \"start-0\" : \"end-0\")}\n >\n <IconButton\n size=\"sm\"\n variant=\"secondary\"\n className=\"bg-surface shadow-sm\"\n tabIndex={-1}\n type=\"button\"\n label={side === \"start\" ? text.previous : text.more}\n labelVisible={false}\n onClick={() => onCue(side)}\n >\n {side === \"start\" ? <ChevronLeft className=\"rtl:-scale-x-100\" /> : <ChevronRight className=\"rtl:-scale-x-100\" />}\n </IconButton>\n </div>\n );\n // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a\n // deep link to the seventh report. A selection behind the cut edge was the worst\n // case of the overflow above: the one tab the panel below belongs to, sliced to\n // \"80 km,\" under the add button. Only the strip scrolls (`scrollBy` on it, not\n // `scrollIntoView`, which would scroll the page as well), and only as far as needed\n // to clear the fade.\n useLayoutEffect(() => {\n const strip = stripRef.current;\n const tab = strip?.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]');\n if (strip && tab) scrollIntoStrip(strip, tab);\n }, [active]);\n const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;\n // Whether Delete reaches this tab: every removable tab, or only the open one.\n const deletesOnKey = (tab: TabItem<T>) =>\n isRemovable(tab) && (removeOn === \"every\" || tab.id === active);\n const nameOf = (tab: TabItem<T>) =>\n tab.name ?? (typeof tab.label === \"string\" ? tab.label : tab.id);\n\n // Where focus goes once a removal the strip asked for has happened. The caller\n // owns `tabs` and may take its time (a confirmation, a request), so this waits\n // for the id to actually leave the list rather than guessing at the moment.\n const pendingFocus = useRef<{ removed: T; wasActive: boolean; neighbour?: T } | null>(null);\n const removeRef = useRef<HTMLButtonElement>(null);\n const requestRemove = (id: T) => {\n const index = tabs.findIndex((t) => t.id === id);\n pendingFocus.current = {\n removed: id,\n wasActive: id === active,\n neighbour: (tabs[index + 1] ?? tabs[index - 1])?.id,\n };\n onRemove?.(id);\n };\n useLayoutEffect(() => {\n const pending = pendingFocus.current;\n if (!pending || tabs.some((t) => t.id === pending.removed)) return;\n pendingFocus.current = null;\n const strip = stripRef.current;\n if (!strip) return;\n // Only focus that fell out of the strip with the removed tab is ours to place —\n // or that sits on the remove button after it, which a pick removed through.\n // If the user has moved on — into the panel, into a dialog — leave it there.\n const focused = document.activeElement;\n if (focused && focused !== document.body && !strip.contains(focused) && focused !== removeRef.current) return;\n // The removed tab was open: focus follows the caller's new selection, which is\n // also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.\n const target =\n !pending.wasActive && tabs.some((t) => t.id === pending.neighbour)\n ? pending.neighbour\n : active;\n const index = tabs.findIndex((t) => t.id === target);\n strip.querySelectorAll<HTMLElement>('[role=\"tab\"]')[index]?.focus();\n }, [tabs, active]);\n\n // Arrow keys walk the strip in DOM order (ARIA tabs pattern). Home/End jump\n // to the ends. Focus only — activation stays on click/Enter/Space, because a tab\n // here can be a real route and moving focus must not navigate.\n //\n // On the TABS, not on the tablist. The container carried it, which left a `<div>`\n // wearing an interactive role and a key handler while being unfocusable —\n // `jsx-a11y/interactive-supports-focus`, and the only instance of it left in `src/`.\n // The fix that rule wants is `tabIndex` on the div; the fix the pattern wants is\n // the handler on the elements that are natively focusable and actually receive the\n // keystroke. Those are the tabs, so the warning goes away by being right rather\n // than by being satisfied.\n const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {\n // Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable\n // tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it\n // rather than letting it fall through to the page. With `removeOn=\"active\"` only\n // the open tab answers; a closed one lets the key pass untouched.\n if (e.key === \"Delete\" && deletesOnKey(tab)) {\n e.preventDefault();\n if (!busy) requestRemove(tab.id);\n return;\n }\n // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits\n // to the left, and ArrowLeft has to reach it. A vertical strip answers ↑/↓ instead\n // — and ONLY those, as the pattern has it: ←/→ are left to the page, where a side\n // nav's neighbour (the panel) may well want them.\n const step: 1 | -1 | 0 = vertical\n ? e.key === \"ArrowDown\"\n ? 1\n : e.key === \"ArrowUp\"\n ? -1\n : 0\n : horizontalStep(e.key, e.currentTarget);\n if (step === 0 && e.key !== \"Home\" && e.key !== \"End\") return;\n const strip = e.currentTarget.closest('[role=\"tablist\"]');\n if (!strip) return;\n const items = Array.from(strip.querySelectorAll<HTMLElement>('[role=\"tab\"]'));\n const from = items.indexOf(e.currentTarget);\n if (from === -1) return;\n e.preventDefault();\n const to =\n e.key === \"Home\"\n ? 0\n : e.key === \"End\"\n ? items.length - 1\n : (from + step + items.length) % items.length;\n items[to]?.focus();\n };\n const tablist = (\n <div\n // Before the role and the name: a caller's arbitrary attribute is welcome on the\n // strip, but a `role` or an `aria-label` arriving through a spread props object\n // must not be able to unmake the tablist the tabs below are registered against.\n {...rest}\n ref={setStrip}\n role=\"tablist\"\n // Names the GROUP — every tab carries its own text — so a strip under a visible\n // heading that already says it can leave it unset.\n aria-label={ariaLabel}\n // Only when vertical: `horizontal` is the tablist's implicit value, and the\n // attribute on every existing strip would be noise in every snapshot of them.\n aria-orientation={vertical ? \"vertical\" : undefined}\n // Merged over a caller's own style rather than replacing it; see useStripFade.\n // `data-overflow` names the cut ends in reading-direction terms, for a caller's\n // own affordance (a scroll button) and for tests, which cannot read the mask.\n style={fade.mask ? { ...rest.style, maskImage: fade.mask, WebkitMaskImage: fade.mask } : rest.style}\n data-overflow={fade.overflow}\n // With an add button the strip gains an outer box, and `className` goes there\n // — it is the box a caller's margin or width is meant for.\n className={cn(\n vertical ? TABLIST_VERTICAL_CLASSES : TABLIST_CLASSES,\n cue ? \"min-w-0 flex-1\" : onAdd ? \"min-w-0\" : className,\n )}\n >\n {tabs.map((tab) => {\n const isActive = tab.id === active;\n const removable = isRemovable(tab);\n // Written once and worn by either tag below, so a routed tab and a\n // state-only tab stay indistinguishable to the eye and to a screen reader.\n const shared = {\n role: \"tab\" as const,\n \"aria-selected\": isActive,\n // Roving tabindex: the whole strip is ONE stop in the page's tab order, and\n // it is the open tab. Ten report tabs otherwise cost ten Tab presses to step\n // over on the way to the table below them (live #262's strip is the extreme\n // case, but every strip in the app paid it).\n //\n // The stop follows the SELECTION, not the focus. Arrowing is a look around —\n // activation here is manual, because a tab can be a real route — so re-\n // pointing the stop at a report the user only arrowed past would hand the\n // strip back on the next visit in a state they never chose. The cost is that\n // Tab out and back returns to the open tab rather than to the last one\n // looked at; the ARIA pattern allows either, and this one needs no second\n // piece of state shadowing `active`.\n tabIndex: isActive ? 0 : -1,\n // Only the open tab: see `panelId`.\n \"aria-controls\": isActive ? panelId : undefined,\n id: isActive && panelId ? `${panelId}-tab` : undefined,\n \"aria-keyshortcuts\": deletesOnKey(tab) ? \"Delete\" : undefined,\n onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),\n className: vertical\n ? cn(\n TAB_VERTICAL_CLASSES,\n isActive ? TAB_VERTICAL_ACTIVE_CLASSES : TAB_VERTICAL_INACTIVE_CLASSES,\n removable && \"pe-8\",\n )\n : cn(\n TAB_CLASSES,\n isActive ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES,\n removable && TAB_REMOVABLE_CLASSES,\n ),\n };\n const inner = (\n <span\n className={cn(\n // In a side nav the row is the full width, so the label takes it and a\n // badge lands at the row's end, where a column of counts lines up.\n vertical ? \"flex min-w-0 flex-1 items-center gap-1.5\" : \"inline-flex max-w-full min-w-0 items-center gap-1.5\",\n tab.empty && TAB_EMPTY_CLASSES,\n )}\n >\n {tab.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 items-center gap-0.5\">\n {tab.icon}\n </span>\n )}\n {tab.detail != null ? (\n // `min-w-0` + `truncate-until-large` on each line: capped by the tab's\n // `max-w-full`, a long detail ends in an ellipsis at Normal and wraps from\n // Large up (text size §10.17; round 0.34 T1), where the tab grows a line\n // rather than cut the words. The whole text stays the tab's name.\n <span className=\"flex min-w-0 flex-col items-start text-start\">\n <span className=\"max-w-full truncate-until-large\">{tab.label}</span>\n {/* A separator for the NAME only — whitespace between flex items is\n not drawn, and without it the two lines ran together into one\n word for a screen reader (\"80 km/hloop 2\"). */}{\" \"}\n <span className=\"max-w-full truncate-until-large text-caption font-normal leading-tight text-muted\">\n {tab.detail}\n </span>\n </span>\n ) : (\n tab.label\n )}\n {tab.badge != null ? (\n vertical ? <span className=\"ms-auto flex shrink-0 items-center\">{tab.badge}</span> : tab.badge\n ) : null}\n </span>\n );\n const element = tab.href ? (\n <a\n key={tab.id}\n {...shared}\n href={tab.href}\n draggable={false}\n onClick={(e) => {\n // Middle-, ⌘/Ctrl-, Shift- and Alt-click belong to the browser\n // (feedback #451); only the plain left click is ours to cancel and\n // route through `onChange`, which keeps the switch client-side.\n if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n e.preventDefault();\n onChange(tab.id);\n }}\n // A <button> tab activated on Space; an anchor does not, so becoming a\n // link (feedback #451) would have quietly cost keyboard users the tab\n // strip. Enter is deliberately untouched — it is the anchor's own\n // default and already arrives at `onChange` through the click handler\n // above, so handling it here too would switch tabs twice. Same three\n // lines the mobile row card carries, for the same reason; the rejected\n // alternative — keeping a button and faking the middle click with\n // window.open — is what the whole item is moving away from.\n //\n // Composed with the strip's arrow-key walk rather than replacing it: this\n // prop overrides the one in `shared`, and an anchor tab that lost the\n // arrows would be a strip that is navigable only where it is not a link.\n onKeyDown={(e) => {\n onTabKeyDown(e, tab);\n if (e.defaultPrevented || e.key !== \" \") return;\n e.preventDefault();\n onChange(tab.id);\n }}\n >\n {inner}\n </a>\n ) : (\n <button key={tab.id} {...shared} onClick={() => onChange(tab.id)}>\n {inner}\n </button>\n );\n if (!removable) return element;\n return (\n // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and\n // a tab's children are presentational, so a reader would flatten it into the\n // tab's name. It sits beside the tab, over the gutter the tab reserves.\n <div key={tab.id} className={cn(\"relative flex max-w-full\", !vertical && \"shrink-0\")}>\n {element}\n {isActive && (\n <IconButton\n size=\"2xs\"\n tone=\"danger\"\n // The POINTER's ×, hidden from assistive tech (round 0.34, the showcase's\n // axe check): a tablist may own tabs only, and this button sat in it. The\n // named \"Remove <tab>\" a screen reader's cursor and a touch reader's swipe\n // reach is `removeButton`, after the strip. Not a tab stop either way:\n // the strip is one, and Delete is the keyboard's path.\n aria-hidden\n data-tab-remove=\"\"\n tabIndex={-1}\n type=\"button\"\n disabled={busy}\n onClick={() => requestRemove(tab.id)}\n className=\"absolute inset-y-0 end-1 my-auto\"\n >\n <X />\n </IconButton>\n )}\n </div>\n );\n })}\n </div>\n );\n // The open tab's remove action as assistive tech meets it: right after the strip,\n // OUTSIDE the tablist, which may own tabs only (round 0.34, the showcase's axe check).\n // Visually hidden, since the × drawn on the tab is the pointer's, and not a tab stop,\n // since Delete is the keyboard's. `sr-only-fixed`: it sits wherever the caller put the\n // strip, with no positioned box of the kit's around it.\n const activeTab = tabs.find((tab) => tab.id === active);\n const removeButton =\n activeTab && isRemovable(activeTab) ? (\n <button\n ref={removeRef}\n type=\"button\"\n tabIndex={-1}\n disabled={busy}\n onClick={() => requestRemove(activeTab.id)}\n className=\"sr-only-fixed\"\n >\n {text.remove(nameOf(activeTab))}\n </button>\n ) : null;\n // The positioned box the carets sit in, over the strip's two ends (see `overflowCue`).\n // There whether or not the strip overflows right now, so a resize never re-parents it.\n const stripBox = cue ? (\n <div className={cn(\"relative flex min-w-0\", !onAdd && \"flex-1\")}>\n {tablist}\n {cueStart && cueAt(\"start\")}\n {cueEnd && cueAt(\"end\")}\n </div>\n ) : (\n tablist\n );\n if (!onAdd && cue) {\n // `className` goes on the row around the strip — it is the box a caller's margin or\n // width is meant for, as with an add button — and stays there at every scroll position.\n return (\n <div className={cn(\"flex items-end\", className)}>\n {stripBox}\n {removeButton}\n </div>\n );\n }\n if (!onAdd) {\n // No outer box: the strip stays the root, as callers' `className` and tests expect.\n return removeButton ? (\n <>\n {tablist}\n {removeButton}\n </>\n ) : (\n tablist\n );\n }\n if (vertical) {\n // Under the column, not beside it, and with no rule to carry on: a side nav has none.\n return (\n <div className={cn(\"flex flex-col gap-0.5\", className)}>\n {tablist}\n {removeButton}\n <button type=\"button\" onClick={onAdd} disabled={busy} className={TAB_ADD_CLASSES}>\n <Plus aria-hidden />\n {addLabel ?? text.add}\n </button>\n </div>\n );\n }\n return (\n <div className={cn(\"flex items-end\", className)}>\n {stripBox}\n {removeButton}\n {/* Outside the tablist: an action, not a tab — and a tablist may own only\n tabs. The rule under the strip carries on beneath it. */}\n <div\n className=\"flex flex-1 items-center self-stretch border-b border-subtle\"\n >\n <button\n type=\"button\"\n onClick={onAdd}\n disabled={busy}\n className={cn(TAB_ADD_CLASSES, TAB_ADD_PHONE_CLASSES)}\n >\n <Plus aria-hidden />\n {/* Below md a scrolling strip keeps the room for tabs: the label is visually\n hidden and the \"+\" carries it as its name. Beside \"+ Add loop\" a 390px strip\n showed the open tab and a sliver of the next (showcase audit). */}\n <span className=\"max-md:sr-only\">{addLabel ?? text.add}</span>\n </button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/tabs.tsx"],"sourcesContent":["import { useLayoutEffect, useMemo, useRef } from \"react\";\nimport { ChevronLeft, ChevronRight, Plus, X } from \"lucide-react\";\nimport type { ComponentPropsWithoutRef, KeyboardEvent, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { FOCUS_RING_WIDTH } from \"./focus-ring\";\nimport { scrollIntoStrip, useStripFade } from \"../lib/strip-fade\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { usePhoneLayout } from \"../hooks/use-breakpoint\";\nimport { prefersReducedMotion } from \"../lib/reduced-motion\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { mergeRefs } from \"./field-parts\";\nimport { DEFAULT_TABS_LABELS } from \"./ui-labels\";\nimport { IconButton } from \"./icon-button\";\nimport { HIT_AREA_LARGE } from \"./button-look\";\n\n/** One tab of a {@link Tabs} strip. */\nexport interface TabItem<T extends string> {\n id: T;\n /** A ReactNode so a tab can pair an icon with text. */\n label: ReactNode;\n /** Optional trailing node (e.g. a count pill). */\n badge?: ReactNode;\n /** Marks a tab that IS a route — see {@link TabsProps}. */\n href?: string;\n /** Leading decoration: an icon, or the colour swatches of the lines this tab\n * draws in a chart above the strip. Hidden from assistive tech — it repeats\n * what the label says, or it says something the label must say instead. */\n icon?: ReactNode;\n /** A second, smaller line under the label — the one fact that tells two tabs\n * with similar labels apart (\"80 km/h\", \"Level 3\"). Part of the tab's name. */\n detail?: ReactNode;\n /** A tab that exists as a slot with nothing in it yet, drawn with a dashed\n * outline so a gap in a sequence looks like a gap. Visual only: say \"empty\" in\n * the label or `detail` if a screen reader needs to know. */\n empty?: boolean;\n /** `false` keeps this one tab when the strip has `onRemove` — the one sheet a\n * workbook cannot be without. Default: removable. */\n removable?: boolean;\n /** Plain-text name for the labels the strip composes about this tab (\"Remove\n * …\"), for when `label` is not a string. Default: `label` if it is a string,\n * else the id. */\n name?: string;\n}\n\n/** The strings the strip renders on its own behalf — only when it can add or\n * remove tabs. */\nexport interface TabsLabels {\n /** The \"add\" button's visible text when the caller gives no `addLabel`. */\n add: string;\n /** Accessible name of a tab's × — \"Remove <tab>\". */\n remove: (tab: string) => string;\n /** The `overflowCue` › after the strip's end, while tabs lie past it: \"More tabs\". */\n more: string;\n /** The `overflowCue` ‹ before the strip's start, while tabs lie before it: \"Previous tabs\". */\n previous: string;\n}\n\n// Defined in ./ui-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_TABS_LABELS };\n\n/**\n * The strip's own props sit on a `<div>`: the tablist IS the root element, so anything\n * a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an\n * `aria-describedby` — lands there. `onChange` is omitted from the div's props because\n * this component's `onChange` hands over the chosen TAB ID, not a DOM event, and\n * `children` because the strip renders `tabs` — a caller who wants a PANEL wires it up\n * through `panelId`, which is the whole point of that prop.\n */\nexport interface TabsProps<T extends string>\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"children\"> {\n /** `label` is a ReactNode so tabs can pair an icon with text; `badge` is an\n * optional trailing node (e.g. a count pill). `href` marks a tab that IS a route:\n * it renders as an anchor so it can be middle-/⌘-clicked into a new tab (Keksdose\n * feedback #451), while a plain click still goes through `onChange` and stays\n * client-side. Tabs that only flip local state leave it unset — a link to a URL\n * that does not select the tab would be worse than no link. */\n tabs: TabItem<T>[];\n active: T;\n onChange: (id: T) => void;\n className?: string;\n /**\n * Carets at the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose\n * live #410 round 4): a › after the strip's end while tabs lie past it, and a ‹ before\n * its start while tabs lie before it — each there only while something is hidden\n * past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three\n * quarters of the strip's width toward its own side. Neither ever changes direction:\n * until 0.46 one caret turned round into \"back to the first\" at the end, and read as\n * an arrow pointing the wrong way. Start and end follow the reading direction.\n *\n * The edge fade alone could read as the end of the row: the last visible tab can\n * finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).\n * Since 0.48 (live #410 rounds 5–6) the carets are bare glyphs BESIDE the strip — the ‹\n * before it, the › after it — centred on the labels, muted and primary on hover, with\n * no frame, fill or shadow; until 0.47 they were framed buttons laid over the strip's\n * last visible tabs. While the strip overflows at all it keeps a 24px gutter at both\n * ends, and each caret sits in its own, so scrolling never changes the strip's width\n * or moves its labels; a strip that fits has no gutters. Out of the tab order —\n * the keyboard walks the strip with the arrow keys, which bring the focused tab into\n * view. At every width. Ignored on a vertical strip (a side nav is a column, not a\n * row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is\n * the reference this replaces.\n */\n overflowCue?: boolean;\n /**\n * `id` of the element the caller renders the open tab's content into — the missing\n * half of `role=\"tab\"`. A tab that controls nothing is a tab in name only: a screen\n * reader announces \"tab, 2 of 5\" and then has no way to take the user to what it\n * opened, and no way back.\n *\n * **This component does not render the panel, on purpose.** `Tabs` is the STRIP; the\n * content lives wherever the caller put it — three Keksdose pages render it in a\n * sibling `<Card>`, one renders it through a router outlet, and a tab can BE a route\n * (`href`), in which case the panel is a whole page this component never sees.\n * Wrapping `children` here would mean either moving that content into the strip's\n * subtree — a layout change on every page that uses tabs — or shipping a wrapper\n * that only some callers could use. So the two halves are joined by an id instead.\n *\n * Wire the other end yourself:\n *\n * ```tsx\n * <Tabs tabs={tabs} active={active} onChange={setActive} panelId=\"report-panel\" />\n * <div id=\"report-panel\" role=\"tabpanel\" aria-labelledby=\"report-panel-tab\" tabIndex={-1}>\n * ```\n *\n * `aria-controls` goes on the OPEN tab only, and `${panelId}-tab` is its id — the\n * closed tabs' panels are not in the document, and a dangling `aria-controls`\n * describes a tab as opening something that is not there.\n */\n panelId?: string;\n /**\n * Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab\n * (only on the open one with `removeOn=\"active\"`).\n *\n * The × is on the open tab only — a row of tabs each carrying one is a row of\n * things to hit by accident, and the tab you can remove should be the one you\n * are looking at — but its room is reserved on every removable tab, so opening a\n * tab never widens it and never pushes the rest of the strip along (lenkbank\n * feedback #72). It is not a tab stop of its own: the strip stays one stop, and the\n * keyboard path is Delete (announced through `aria-keyshortcuts`), which removes\n * whichever tab has focus (see `removeOn`).\n *\n * Assistive tech meets the remove as a \"Remove <tab>\" button right AFTER the strip,\n * not as the × (0.34): a tablist may own tabs only, so the drawn × is the pointer's\n * and `aria-hidden`, and the named button beside the tablist is visually hidden.\n *\n * The strip only ASKS: it calls this, and the tab goes when the caller drops it\n * from `tabs` — after a confirmation, a request, whatever it needs. When it does,\n * focus that was on the removed tab (or its ×) moves to the open tab if the\n * removed one was open, else to its neighbour, instead of falling to the page.\n */\n onRemove?: (id: T) => void;\n /**\n * Which tabs the Delete key removes.\n *\n * - `\"every\"` (default): any focused removable tab — the ARIA pattern's reading.\n * - `\"active\"`: only the OPEN tab, the same one that wears the ×. For a strip whose\n * remove is immediate and unconfirmed (lenkbank's sheet tabs delete a measurement\n * sheet at once), so arrowing across the strip and pressing Delete cannot take\n * out a sheet the user is not looking at. Delete on a closed tab does nothing,\n * and only the open tab announces the shortcut (`aria-keyshortcuts`).\n *\n * Either way the × is drawn on the open tab only and its room stays reserved on\n * every removable tab, so the strip never relayouts when the selection moves.\n */\n removeOn?: \"every\" | \"active\";\n /** Renders an \"add\" button after the last tab, outside the tablist — it is an\n * action, not a tab, and a tablist may own only tabs. */\n onAdd?: () => void;\n /** The add button's text. Say what is added — \"Add loop\", \"Add report\" says\n * more than \"Add\". Default: `tabs.add` from the {@link UiKitProvider}. */\n addLabel?: string;\n /** Overrides for this strip's strings. See {@link TabsLabels}. */\n labels?: LabelOverride<TabsLabels>;\n /** An add or a remove is in flight: the × and the add button are disabled and\n * Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */\n busy?: boolean;\n /**\n * `\"vertical\"`: the same strip as a SIDE NAV — one item per row, the open one\n * filled, ↑/↓ walking it (Home/End as before) and `aria-orientation=\"vertical\"` on\n * the tablist. For keksdose's admin page and settings page, which each hand-built\n * the identical sidebar with none of this strip's keyboard, roving tab stop or\n * routed-`href` handling. Everything else — `href` tabs, `badge`, `panelId`,\n * `onRemove`, `onAdd` — means exactly what it means on the horizontal strip; the\n * badge moves to the row's end.\n *\n * **On a phone (`usePhoneLayout()`) it becomes the horizontal strip**, rather than\n * staying a column. A side nav only works beside its content; on\n * a phone it has to go ABOVE it, and eight full-width rows there push the panel\n * the user picked below the fold on every visit — the two keksdose pages both\n * collapsed theirs into a scrolling row by hand for exactly that reason. The\n * orientation is switched in JavaScript, not with `md:` classes, so the\n * `aria-orientation` a screen reader hears and the arrow keys that work always\n * match the layout on screen. The caller's own two-column layout has to stack at\n * the same breakpoint (`md:grid-cols-[14rem_1fr]`).\n */\n orientation?: \"horizontal\" | \"vertical\";\n /** The `role=\"tablist\"` strip, where the rest of the props go (0.34, API-2, refactor\n * audit 2026-10). Merged with the strip's own ref, which scrolls the open tab into\n * view and measures the fade, so neither loses the element. */\n ref?: Ref<HTMLDivElement>;\n}\n\nconst TABLIST_CLASSES =\n \"flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle\";\n// The side nav. A column of full-width rows with the open one FILLED rather than\n// underlined: an underline under one row of a list reads as a separator, not as a\n// selection. `--bg-active` is the kit's own selected-row fill (the sidebar's), so the\n// admin page's nav and the app's own sidebar mark \"you are here\" the same way.\nconst TABLIST_VERTICAL_CLASSES = \"flex flex-col gap-0.5\";\nconst TAB_VERTICAL_CLASSES =\n FOCUS_RING_WIDTH +\n \" flex w-full items-center rounded-md px-3 py-2 text-start text-sm font-medium transition-colors focus:outline-none focus-visible:ring-strong\";\nconst TAB_VERTICAL_ACTIVE_CLASSES = \"bg-active text-primary\";\nconst TAB_VERTICAL_INACTIVE_CLASSES =\n \"text-muted hover:bg-hover hover:text-primary\";\n\n// `max-w-full`: no tab outgrows its strip. A sheet's `detail` line (\"Spurstangenkraft\n// links · … · 31 Punkte\") made one tab 448px wide in a 390px strip, and a tab wider\n// than the strip cannot be scrolled into view — its end was simply off screen\n// (lenkbank L3). The label and detail truncate inside it instead; see `inner`.\nconst TAB_CLASSES =\n FOCUS_RING_WIDTH +\n \" max-w-full whitespace-nowrap px-3 py-2 text-sm font-medium transition-colors border-b-2 -mb-px focus:outline-none focus-visible:ring-strong\";\nconst TAB_ACTIVE_CLASSES = \"border-[var(--text-primary)] text-primary\";\nconst TAB_INACTIVE_CLASSES =\n \"border-transparent text-muted hover:text-secondary hover:border-strong\";\n\n// The add/remove chrome. The × reserves its gutter on every removable tab (see\n// `onRemove`).\nconst TAB_REMOVABLE_CLASSES = \"pe-8\";\nconst TAB_EMPTY_CLASSES = \"-mx-1.5 rounded border border-dashed border-strong px-1.5\";\nconst TAB_ADD_CLASSES =\n FOCUS_RING_WIDTH +\n \" inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-muted transition-colors hover:text-primary focus:outline-none focus-visible:ring-strong disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4\";\n// `relative`: the phone's visually hidden label needs a positioned ancestor (see the\n// sr-only rule in CONTRIBUTING); `max-md:px-2` squares the lone \"+\".\nconst TAB_ADD_PHONE_CLASSES = \"relative max-md:px-2\";\n\nexport function Tabs<T extends string>({\n tabs,\n active,\n onChange,\n className,\n overflowCue = false,\n panelId,\n onRemove,\n removeOn = \"every\",\n onAdd,\n addLabel,\n labels,\n busy = false,\n orientation = \"horizontal\",\n \"aria-label\": ariaLabel,\n ref,\n ...rest\n}: TabsProps<T>) {\n const text = useKitLabels(\"tabs\", DEFAULT_TABS_LABELS, labels);\n // See `orientation`: a vertical strip is the horizontal one on a phone.\n const phone = usePhoneLayout();\n const vertical = orientation === \"vertical\" && !phone;\n const stripRef = useRef<HTMLDivElement>(null);\n const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);\n const fade = useStripFade(stripRef);\n // See `overflowCue`: a ‹ before the strip while tabs lie before it, a › after it\n // while tabs lie past it — `fade.overflow` names the cut ends in reading-direction terms.\n const cue = overflowCue && !vertical;\n const cueStart = cue && (fade.overflow === \"start\" || fade.overflow === \"both\");\n const cueEnd = cue && (fade.overflow === \"end\" || fade.overflow === \"both\");\n // Three quarters of the visible width toward `side`; an RTL strip runs leftward.\n const onCue = (side: \"start\" | \"end\") => {\n const strip = stripRef.current;\n if (!strip) return;\n const behavior: ScrollBehavior = prefersReducedMotion() ? \"auto\" : \"smooth\";\n const rtl = getComputedStyle(strip).direction === \"rtl\";\n const toward = (side === \"end\" ? 1 : -1) * (rtl ? -1 : 1);\n strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });\n };\n // In a GUTTER of its own at the strip's end (0.48, keksdose live #410 rounds 5–6): a\n // bare glyph — no frame, fill or shadow — muted until the pointer comes. Round 5 put\n // each cue in the flow beside the strip, so it took 24px the moment it appeared and the\n // labels jumped sideways at every edge the scroll crossed (\"It has problems with moving\n // to left and right especially when moving the scroll bar\"). Now the strip box keeps a\n // 24px gutter at BOTH ends for as long as the strip overflows at all (`px-[24px]`, see\n // `stripBox`), and each gutter is absolute in that padding, so a cue coming or going\n // never moves the strip, its labels or the scroll position under the thumb. A strip\n // that fits has no gutters: its width changes only when it starts or stops overflowing.\n // The gutter carries the strip's 1px rule on beneath the glyph, and lifts the glyph\n // onto the LABELS' centre rather than the box's: a tab's 2px underline (`border-b-2`)\n // sits on the rule (`-mb-px`), so the labels are centred 2px above the box's bottom —\n // the gutter's 1px rule (`border-b`) plus 1px more (`pb-px`).\n // 24px wide at every text size (KS N3): at Large `sm`'s 48px minimum made each cue\n // 48px, and at 360px the two left a two-tab strip 100px. 24 PIXELS, not `w-6`: the rem\n // grows with the text size (24 / 30 / 36px), and at Extra large on a 360px phone the two\n // gutters took 72px of a 226px strip and a sheet tab wrapped to seven lines (0.48 build\n // review 2, KS N1). The 48px touch target is an\n // invisible hit area round the glyph instead (`HIT_AREA_LARGE`, as `2xs`), over the\n // strip's faded edge, not room taken from the row. The wrapper inside the gutter is\n // ours because IconButton puts its tooltip's span around the button.\n const gutterAt = (side: \"start\" | \"end\", shown: boolean) => (\n <div\n data-tabs-cue-gutter={side}\n className={cn(\n \"absolute inset-y-0 flex w-[24px] items-center justify-center border-b border-subtle pb-px\",\n side === \"start\" ? \"start-0\" : \"end-0\",\n )}\n >\n {shown && (\n <div data-tabs-overflow-cue={side} className=\"flex\">\n <IconButton\n size=\"sm\"\n variant=\"ghost\"\n className={cn(\n \"w-[24px] large:min-w-0 bg-transparent text-muted hover:bg-transparent hover:text-primary\",\n // Over the strip's faded edge, for ‹ too.\n HIT_AREA_LARGE,\n \"large:after:z-[1]\",\n )}\n tabIndex={-1}\n type=\"button\"\n label={side === \"start\" ? text.previous : text.more}\n labelVisible={false}\n onClick={() => onCue(side)}\n >\n {side === \"start\" ? <ChevronLeft className=\"rtl:-scale-x-100\" /> : <ChevronRight className=\"rtl:-scale-x-100\" />}\n </IconButton>\n </div>\n )}\n </div>\n );\n // The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a\n // deep link to the seventh report. A selection behind the cut edge was the worst\n // case of the overflow above: the one tab the panel below belongs to, sliced to\n // \"80 km,\" under the add button. Only the strip scrolls (`scrollBy` on it, not\n // `scrollIntoView`, which would scroll the page as well), and only as far as needed\n // to clear the fade.\n useLayoutEffect(() => {\n const strip = stripRef.current;\n const tab = strip?.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]');\n if (strip && tab) scrollIntoStrip(strip, tab);\n }, [active]);\n const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;\n // Whether Delete reaches this tab: every removable tab, or only the open one.\n const deletesOnKey = (tab: TabItem<T>) =>\n isRemovable(tab) && (removeOn === \"every\" || tab.id === active);\n const nameOf = (tab: TabItem<T>) =>\n tab.name ?? (typeof tab.label === \"string\" ? tab.label : tab.id);\n\n // Where focus goes once a removal the strip asked for has happened. The caller\n // owns `tabs` and may take its time (a confirmation, a request), so this waits\n // for the id to actually leave the list rather than guessing at the moment.\n const pendingFocus = useRef<{ removed: T; wasActive: boolean; neighbour?: T } | null>(null);\n const removeRef = useRef<HTMLButtonElement>(null);\n const requestRemove = (id: T) => {\n const index = tabs.findIndex((t) => t.id === id);\n pendingFocus.current = {\n removed: id,\n wasActive: id === active,\n neighbour: (tabs[index + 1] ?? tabs[index - 1])?.id,\n };\n onRemove?.(id);\n };\n useLayoutEffect(() => {\n const pending = pendingFocus.current;\n if (!pending || tabs.some((t) => t.id === pending.removed)) return;\n pendingFocus.current = null;\n const strip = stripRef.current;\n if (!strip) return;\n // Only focus that fell out of the strip with the removed tab is ours to place —\n // or that sits on the remove button after it, which a pick removed through.\n // If the user has moved on — into the panel, into a dialog — leave it there.\n const focused = document.activeElement;\n if (focused && focused !== document.body && !strip.contains(focused) && focused !== removeRef.current) return;\n // The removed tab was open: focus follows the caller's new selection, which is\n // also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.\n const target =\n !pending.wasActive && tabs.some((t) => t.id === pending.neighbour)\n ? pending.neighbour\n : active;\n const index = tabs.findIndex((t) => t.id === target);\n strip.querySelectorAll<HTMLElement>('[role=\"tab\"]')[index]?.focus();\n }, [tabs, active]);\n\n // Arrow keys walk the strip in DOM order (ARIA tabs pattern). Home/End jump\n // to the ends. Focus only — activation stays on click/Enter/Space, because a tab\n // here can be a real route and moving focus must not navigate.\n //\n // On the TABS, not on the tablist. The container carried it, which left a `<div>`\n // wearing an interactive role and a key handler while being unfocusable —\n // `jsx-a11y/interactive-supports-focus`, and the only instance of it left in `src/`.\n // The fix that rule wants is `tabIndex` on the div; the fix the pattern wants is\n // the handler on the elements that are natively focusable and actually receive the\n // keystroke. Those are the tabs, so the warning goes away by being right rather\n // than by being satisfied.\n const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {\n // Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable\n // tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it\n // rather than letting it fall through to the page. With `removeOn=\"active\"` only\n // the open tab answers; a closed one lets the key pass untouched.\n if (e.key === \"Delete\" && deletesOnKey(tab)) {\n e.preventDefault();\n if (!busy) requestRemove(tab.id);\n return;\n }\n // Along the reading direction: the strip is a flex row, so in RTL the NEXT tab sits\n // to the left, and ArrowLeft has to reach it. A vertical strip answers ↑/↓ instead\n // — and ONLY those, as the pattern has it: ←/→ are left to the page, where a side\n // nav's neighbour (the panel) may well want them.\n const step: 1 | -1 | 0 = vertical\n ? e.key === \"ArrowDown\"\n ? 1\n : e.key === \"ArrowUp\"\n ? -1\n : 0\n : horizontalStep(e.key, e.currentTarget);\n if (step === 0 && e.key !== \"Home\" && e.key !== \"End\") return;\n const strip = e.currentTarget.closest('[role=\"tablist\"]');\n if (!strip) return;\n const items = Array.from(strip.querySelectorAll<HTMLElement>('[role=\"tab\"]'));\n const from = items.indexOf(e.currentTarget);\n if (from === -1) return;\n e.preventDefault();\n const to =\n e.key === \"Home\"\n ? 0\n : e.key === \"End\"\n ? items.length - 1\n : (from + step + items.length) % items.length;\n items[to]?.focus();\n };\n const tablist = (\n <div\n // Before the role and the name: a caller's arbitrary attribute is welcome on the\n // strip, but a `role` or an `aria-label` arriving through a spread props object\n // must not be able to unmake the tablist the tabs below are registered against.\n {...rest}\n ref={setStrip}\n role=\"tablist\"\n // Names the GROUP — every tab carries its own text — so a strip under a visible\n // heading that already says it can leave it unset.\n aria-label={ariaLabel}\n // Only when vertical: `horizontal` is the tablist's implicit value, and the\n // attribute on every existing strip would be noise in every snapshot of them.\n aria-orientation={vertical ? \"vertical\" : undefined}\n // Merged over a caller's own style rather than replacing it; see useStripFade.\n // `data-overflow` names the cut ends in reading-direction terms, for a caller's\n // own affordance (a scroll button) and for tests, which cannot read the mask.\n style={fade.mask ? { ...rest.style, maskImage: fade.mask, WebkitMaskImage: fade.mask } : rest.style}\n data-overflow={fade.overflow}\n // With an add button the strip gains an outer box, and `className` goes there\n // — it is the box a caller's margin or width is meant for.\n className={cn(\n vertical ? TABLIST_VERTICAL_CLASSES : TABLIST_CLASSES,\n cue ? \"min-w-0 flex-1\" : onAdd ? \"min-w-0\" : className,\n )}\n >\n {tabs.map((tab) => {\n const isActive = tab.id === active;\n const removable = isRemovable(tab);\n // Written once and worn by either tag below, so a routed tab and a\n // state-only tab stay indistinguishable to the eye and to a screen reader.\n const shared = {\n role: \"tab\" as const,\n \"aria-selected\": isActive,\n // Roving tabindex: the whole strip is ONE stop in the page's tab order, and\n // it is the open tab. Ten report tabs otherwise cost ten Tab presses to step\n // over on the way to the table below them (live #262's strip is the extreme\n // case, but every strip in the app paid it).\n //\n // The stop follows the SELECTION, not the focus. Arrowing is a look around —\n // activation here is manual, because a tab can be a real route — so re-\n // pointing the stop at a report the user only arrowed past would hand the\n // strip back on the next visit in a state they never chose. The cost is that\n // Tab out and back returns to the open tab rather than to the last one\n // looked at; the ARIA pattern allows either, and this one needs no second\n // piece of state shadowing `active`.\n tabIndex: isActive ? 0 : -1,\n // Only the open tab: see `panelId`.\n \"aria-controls\": isActive ? panelId : undefined,\n id: isActive && panelId ? `${panelId}-tab` : undefined,\n \"aria-keyshortcuts\": deletesOnKey(tab) ? \"Delete\" : undefined,\n onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),\n className: vertical\n ? cn(\n TAB_VERTICAL_CLASSES,\n isActive ? TAB_VERTICAL_ACTIVE_CLASSES : TAB_VERTICAL_INACTIVE_CLASSES,\n removable && \"pe-8\",\n )\n : cn(\n TAB_CLASSES,\n isActive ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES,\n removable && TAB_REMOVABLE_CLASSES,\n ),\n };\n const inner = (\n <span\n className={cn(\n // In a side nav the row is the full width, so the label takes it and a\n // badge lands at the row's end, where a column of counts lines up.\n vertical ? \"flex min-w-0 flex-1 items-center gap-1.5\" : \"inline-flex max-w-full min-w-0 items-center gap-1.5\",\n tab.empty && TAB_EMPTY_CLASSES,\n )}\n >\n {tab.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 items-center gap-0.5\">\n {tab.icon}\n </span>\n )}\n {tab.detail != null ? (\n // `min-w-0` + `truncate-until-large` on each line: capped by the tab's\n // `max-w-full`, a long detail ends in an ellipsis at Normal and wraps from\n // Large up (text size §10.17; round 0.34 T1), where the tab grows a line\n // rather than cut the words. The whole text stays the tab's name.\n <span className=\"flex min-w-0 flex-col items-start text-start\">\n <span className=\"max-w-full truncate-until-large\">{tab.label}</span>\n {/* A separator for the NAME only — whitespace between flex items is\n not drawn, and without it the two lines ran together into one\n word for a screen reader (\"80 km/hloop 2\"). */}{\" \"}\n <span className=\"max-w-full truncate-until-large text-caption font-normal leading-tight text-muted\">\n {tab.detail}\n </span>\n </span>\n ) : (\n tab.label\n )}\n {tab.badge != null ? (\n vertical ? <span className=\"ms-auto flex shrink-0 items-center\">{tab.badge}</span> : tab.badge\n ) : null}\n </span>\n );\n const element = tab.href ? (\n <a\n key={tab.id}\n {...shared}\n href={tab.href}\n draggable={false}\n onClick={(e) => {\n // Middle-, ⌘/Ctrl-, Shift- and Alt-click belong to the browser\n // (feedback #451); only the plain left click is ours to cancel and\n // route through `onChange`, which keeps the switch client-side.\n if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n e.preventDefault();\n onChange(tab.id);\n }}\n // A <button> tab activated on Space; an anchor does not, so becoming a\n // link (feedback #451) would have quietly cost keyboard users the tab\n // strip. Enter is deliberately untouched — it is the anchor's own\n // default and already arrives at `onChange` through the click handler\n // above, so handling it here too would switch tabs twice. Same three\n // lines the mobile row card carries, for the same reason; the rejected\n // alternative — keeping a button and faking the middle click with\n // window.open — is what the whole item is moving away from.\n //\n // Composed with the strip's arrow-key walk rather than replacing it: this\n // prop overrides the one in `shared`, and an anchor tab that lost the\n // arrows would be a strip that is navigable only where it is not a link.\n onKeyDown={(e) => {\n onTabKeyDown(e, tab);\n if (e.defaultPrevented || e.key !== \" \") return;\n e.preventDefault();\n onChange(tab.id);\n }}\n >\n {inner}\n </a>\n ) : (\n <button key={tab.id} {...shared} onClick={() => onChange(tab.id)}>\n {inner}\n </button>\n );\n if (!removable) return element;\n return (\n // The × cannot go INSIDE the tab: a button in a button is invalid HTML, and\n // a tab's children are presentational, so a reader would flatten it into the\n // tab's name. It sits beside the tab, over the gutter the tab reserves.\n <div key={tab.id} className={cn(\"relative flex max-w-full\", !vertical && \"shrink-0\")}>\n {element}\n {isActive && (\n <IconButton\n size=\"2xs\"\n tone=\"danger\"\n // The POINTER's ×, hidden from assistive tech (round 0.34, the showcase's\n // axe check): a tablist may own tabs only, and this button sat in it. The\n // named \"Remove <tab>\" a screen reader's cursor and a touch reader's swipe\n // reach is `removeButton`, after the strip. Not a tab stop either way:\n // the strip is one, and Delete is the keyboard's path.\n aria-hidden\n data-tab-remove=\"\"\n tabIndex={-1}\n type=\"button\"\n disabled={busy}\n onClick={() => requestRemove(tab.id)}\n className=\"absolute inset-y-0 end-1 my-auto\"\n >\n <X />\n </IconButton>\n )}\n </div>\n );\n })}\n </div>\n );\n // The open tab's remove action as assistive tech meets it: right after the strip,\n // OUTSIDE the tablist, which may own tabs only (round 0.34, the showcase's axe check).\n // Visually hidden, since the × drawn on the tab is the pointer's, and not a tab stop,\n // since Delete is the keyboard's. `sr-only-fixed`: it sits wherever the caller put the\n // strip, with no positioned box of the kit's around it.\n const activeTab = tabs.find((tab) => tab.id === active);\n const removeButton =\n activeTab && isRemovable(activeTab) ? (\n <button\n ref={removeRef}\n type=\"button\"\n tabIndex={-1}\n disabled={busy}\n onClick={() => requestRemove(activeTab.id)}\n className=\"sr-only-fixed\"\n >\n {text.remove(nameOf(activeTab))}\n </button>\n ) : null;\n // The box the cues sit in, ‹ in the gutter before the strip and › in the one after it\n // (see `gutterAt`). There whether or not the strip overflows right now, so a resize\n // never re-parents it; the gutters only while it overflows at all.\n const gutters = cue && fade.overflow !== undefined;\n const stripBox = cue ? (\n <div className={cn(\"relative flex min-w-0\", !onAdd && \"flex-1\", gutters && \"px-[24px]\")}>\n {tablist}\n {gutters && gutterAt(\"start\", cueStart)}\n {gutters && gutterAt(\"end\", cueEnd)}\n </div>\n ) : (\n tablist\n );\n if (!onAdd && cue) {\n // `className` goes on the row around the strip — it is the box a caller's margin or\n // width is meant for, as with an add button — and stays there at every scroll position.\n return (\n <div className={cn(\"flex items-end\", className)}>\n {stripBox}\n {removeButton}\n </div>\n );\n }\n if (!onAdd) {\n // No outer box: the strip stays the root, as callers' `className` and tests expect.\n return removeButton ? (\n <>\n {tablist}\n {removeButton}\n </>\n ) : (\n tablist\n );\n }\n if (vertical) {\n // Under the column, not beside it, and with no rule to carry on: a side nav has none.\n return (\n <div className={cn(\"flex flex-col gap-0.5\", className)}>\n {tablist}\n {removeButton}\n <button type=\"button\" onClick={onAdd} disabled={busy} className={TAB_ADD_CLASSES}>\n <Plus aria-hidden />\n {addLabel ?? text.add}\n </button>\n </div>\n );\n }\n return (\n <div className={cn(\"flex items-end\", className)}>\n {stripBox}\n {removeButton}\n {/* Outside the tablist: an action, not a tab — and a tablist may own only\n tabs. The rule under the strip carries on beneath it. */}\n <div\n className=\"flex flex-1 items-center self-stretch border-b border-subtle\"\n >\n <button\n type=\"button\"\n onClick={onAdd}\n disabled={busy}\n className={cn(TAB_ADD_CLASSES, TAB_ADD_PHONE_CLASSES)}\n >\n <Plus aria-hidden />\n {/* Below md a scrolling strip keeps the room for tabs: the label is visually\n hidden and the \"+\" carries it as its name. Beside \"+ Add loop\" a 390px strip\n showed the open tab and a sliver of the next (showcase audit). */}\n <span className=\"max-md:sr-only\">{addLabel ?? text.add}</span>\n </button>\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SetStateAction } from "react";
|
|
1
2
|
import { type SortState } from "./data-table-sort.js";
|
|
2
3
|
import type { DataTableColumn, FilterState, ServerPagination } from "./data-table.js";
|
|
3
4
|
export declare const DEFAULT_PERSIST_PREFIX = "hbui-table:";
|
|
@@ -70,18 +71,18 @@ export declare function useTableState<T>({ columns, storageKey, storageKeyPrefix
|
|
|
70
71
|
}): {
|
|
71
72
|
sorts: SortState[];
|
|
72
73
|
filters: FilterState;
|
|
73
|
-
setInternalSorts: import("react").Dispatch<
|
|
74
|
-
setInternalFilters: import("react").Dispatch<
|
|
74
|
+
setInternalSorts: import("react").Dispatch<SetStateAction<SortState[]>>;
|
|
75
|
+
setInternalFilters: import("react").Dispatch<SetStateAction<FilterState>>;
|
|
75
76
|
page: number;
|
|
76
|
-
setPage: import("react").Dispatch<
|
|
77
|
+
setPage: import("react").Dispatch<SetStateAction<number>>;
|
|
77
78
|
pageSize: number;
|
|
78
|
-
setPageSize: import("react").Dispatch<
|
|
79
|
+
setPageSize: import("react").Dispatch<SetStateAction<number>>;
|
|
79
80
|
widths: Record<string, number>;
|
|
80
|
-
setWidths: import("react").Dispatch<
|
|
81
|
+
setWidths: import("react").Dispatch<SetStateAction<Record<string, number>>>;
|
|
81
82
|
hiddenCols: Set<string>;
|
|
82
|
-
setHiddenCols:
|
|
83
|
+
setHiddenCols: (action: SetStateAction<Set<string>>) => void;
|
|
83
84
|
showSettings: boolean;
|
|
84
|
-
setShowSettings: import("react").Dispatch<
|
|
85
|
+
setShowSettings: import("react").Dispatch<SetStateAction<boolean>>;
|
|
85
86
|
headRefs: Map<string, HTMLTableCellElement>;
|
|
86
87
|
};
|
|
87
88
|
/** What {@link useSharedTableView} keeps. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { useLocation, useSearchParams } from "react-router";
|
|
4
4
|
import { useKitTableStorageKeyPrefix } from "../i18n/kit-labels.js";
|
|
5
5
|
import { readStored, writeStored } from "../lib/safe-storage.js";
|
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
resolveFilter
|
|
11
11
|
} from "./data-table-filters.js";
|
|
12
12
|
import { decodeSorts, encodeSorts, normalizeSorts } from "./data-table-sort.js";
|
|
13
|
+
function withDefaultHidden(view, defaults) {
|
|
14
|
+
const extra = defaults.filter((k) => !view.hidden.has(k) && !view.shown.has(k));
|
|
15
|
+
return extra.length === 0 ? view.hidden : /* @__PURE__ */ new Set([...view.hidden, ...extra]);
|
|
16
|
+
}
|
|
13
17
|
const DEFAULT_PERSIST_PREFIX = "hbui-table:";
|
|
14
18
|
function loadPersisted(storageKey, prefix) {
|
|
15
19
|
if (!storageKey) return {};
|
|
@@ -156,7 +160,36 @@ function useTableState({
|
|
|
156
160
|
);
|
|
157
161
|
const pageSize = pageSizeProp ?? internalPageSize;
|
|
158
162
|
const [widths, setWidths] = useState(() => initial.widths ?? {});
|
|
159
|
-
const [
|
|
163
|
+
const [view, setView] = useState(() => ({
|
|
164
|
+
hidden: new Set(initial.hidden ?? []),
|
|
165
|
+
shown: new Set(initial.shown ?? [])
|
|
166
|
+
}));
|
|
167
|
+
const defaultHiddenSig = columns.filter((c) => c.defaultHidden).map((c) => c.key).join("\0");
|
|
168
|
+
const hiddenCols = useMemo(
|
|
169
|
+
() => withDefaultHidden(view, defaultHiddenSig ? defaultHiddenSig.split("\0") : []),
|
|
170
|
+
[view, defaultHiddenSig]
|
|
171
|
+
);
|
|
172
|
+
const setHiddenCols = useCallback(
|
|
173
|
+
(action) => setView((v) => {
|
|
174
|
+
const defaults = defaultHiddenSig ? defaultHiddenSig.split("\0") : [];
|
|
175
|
+
const prev = withDefaultHidden(v, defaults);
|
|
176
|
+
const next = typeof action === "function" ? action(prev) : action;
|
|
177
|
+
const hidden = new Set(v.hidden);
|
|
178
|
+
const shown = new Set(v.shown);
|
|
179
|
+
for (const k of next) {
|
|
180
|
+
if (prev.has(k)) continue;
|
|
181
|
+
hidden.add(k);
|
|
182
|
+
shown.delete(k);
|
|
183
|
+
}
|
|
184
|
+
for (const k of prev) {
|
|
185
|
+
if (next.has(k)) continue;
|
|
186
|
+
hidden.delete(k);
|
|
187
|
+
if (defaults.includes(k)) shown.add(k);
|
|
188
|
+
}
|
|
189
|
+
return { hidden, shown };
|
|
190
|
+
}),
|
|
191
|
+
[defaultHiddenSig]
|
|
192
|
+
);
|
|
160
193
|
const [showSettings, setShowSettings] = useState(false);
|
|
161
194
|
const headRefs = useMemo(() => /* @__PURE__ */ new Map(), []);
|
|
162
195
|
const lastPersisted = useRef(null);
|
|
@@ -165,7 +198,8 @@ function useTableState({
|
|
|
165
198
|
filters,
|
|
166
199
|
pageSize: pageSize === Infinity ? "all" : pageSize,
|
|
167
200
|
widths,
|
|
168
|
-
hidden: Array.from(
|
|
201
|
+
hidden: Array.from(view.hidden),
|
|
202
|
+
...view.shown.size > 0 ? { shown: Array.from(view.shown) } : {}
|
|
169
203
|
});
|
|
170
204
|
useEffect(() => {
|
|
171
205
|
if (!storageKey) return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/use-table-state.ts"],"sourcesContent":["import { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useLocation, useSearchParams } from \"react-router\";\nimport { useKitTableStorageKeyPrefix } from \"../i18n/kit-labels\";\nimport { readStored, writeStored } from \"../lib/safe-storage\";\nimport {\n decodeFilterValue,\n encodeFilterValue,\n isFilterActive,\n resolveFilter,\n} from \"./data-table-filters\";\nimport { decodeSorts, encodeSorts, normalizeSorts, type SortState } from \"./data-table-sort\";\nimport type { DataTableColumn, FilterState, ServerPagination } from \"./data-table\";\n\n// ---------- Persisted + URL-encoded view state ----------\n//\n// Moved here with `useTableState` when `DataTable` was split: these are what the hook\n// reads and writes, and nothing else in the table ever called them.\n\ninterface PersistedState {\n // Multi-sort array; blobs from before the upgrade hold a single {key, dir}\n // object (or null) — read back through normalizeSorts.\n sort: SortState[] | { key: string; dir: string } | null;\n filters: FilterState;\n pageSize: number | \"all\";\n widths?: Record<string, number>;\n hidden?: string[];\n}\n\nexport const DEFAULT_PERSIST_PREFIX = \"hbui-table:\";\n\nfunction loadPersisted(storageKey: string | undefined, prefix: string): Partial<PersistedState> {\n if (!storageKey) return {};\n const raw = readStored(prefix + storageKey);\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw) as Partial<PersistedState>;\n return parsed && typeof parsed === \"object\" ? parsed : {};\n } catch {\n return {}; // corrupt JSON — the storage access itself is guarded by readStored\n }\n}\n\n/** The blob that would be stored — also the value the effect below compares by, so\n * the comparison and the write can never disagree about what \"unchanged\" means. */\nfunction serializePersisted(state: PersistedState): string {\n return JSON.stringify(state);\n}\n\n/** A stored page size as a table holds it — `\"all\"` is `Infinity` — or `fallback`. */\nfunction storedPageSize(stored: Partial<PersistedState>, fallback: number): number {\n if (stored.pageSize === \"all\") return Infinity;\n if (typeof stored.pageSize === \"number\" && stored.pageSize > 0) return stored.pageSize;\n return fallback;\n}\n\n// ---------- URL sync helpers ----------\n\nconst URL_FILTER_PREFIX = \"f.\";\nconst URL_SORT_KEY = \"sort\";\nconst URL_PAGE_KEY = \"p\";\nconst URL_PAGE_SIZE_KEY = \"ps\";\n/** The free-text search term — managed only by {@link useTableUrlState}, and only when\n * it is asked to (`search: true`); a plain `urlSync` table leaves a `q` alone. */\nconst URL_SEARCH_KEY = \"q\";\n\n/**\n * How a table is mirrored into the URL: `true`, or `{ prefix }` for a table that shares\n * its page with another URL-synced one. The prefix is put verbatim in front of EVERY key\n * the table writes and reads — `{ prefix: \"inv.\" }` gives `inv.f.<column>`, `inv.sort`,\n * `inv.p`, `inv.ps` (and `inv.q`) — so two tables on one page stop sharing a sort and a\n * page. Without one the keys are unprefixed, as they always were.\n */\nexport type TableUrlSync = boolean | { prefix?: string };\n\n/** The query keys of one table, with its prefix applied. */\ninterface UrlKeys {\n filter: string;\n sort: string;\n page: string;\n pageSize: string;\n search: string;\n}\n\nfunction urlKeys(prefix = \"\"): UrlKeys {\n return {\n filter: prefix + URL_FILTER_PREFIX,\n sort: prefix + URL_SORT_KEY,\n page: prefix + URL_PAGE_KEY,\n pageSize: prefix + URL_PAGE_SIZE_KEY,\n search: prefix + URL_SEARCH_KEY,\n };\n}\n\n/** `urlSync` split into \"is it on\" and \"under which prefix\". */\nfunction resolveTableUrlSync(urlSync: TableUrlSync | undefined): { enabled: boolean; prefix: string } {\n if (!urlSync) return { enabled: false, prefix: \"\" };\n return { enabled: true, prefix: urlSync === true ? \"\" : (urlSync.prefix ?? \"\") };\n}\n\n/** What {@link readTableUrlState} found in a query string. */\nexport interface TableUrlState {\n sorts?: SortState[];\n filters?: FilterState;\n /** `Infinity` for `ps=all`. */\n pageSize?: number;\n /** 0-based (the URL's `p` is 1-based). */\n page?: number;\n}\n\n/**\n * What a table's URL says, read out of a query string: the column filters (`f.<key>`),\n * the sort, the page size and the 0-based page. Absent keys are absent here too, so a\n * caller can tell \"not in the link\" from \"the default\". The same reader `urlSync` uses\n * on mount, exported for an owner that keeps the state itself — see\n * {@link useTableUrlState}.\n *\n * Pass the ROUTER's query string — the one `setSearchParams` writes — and the table's\n * `prefix` when it has one (see {@link TableUrlSync}).\n *\n * This read `window.location.search` until 0.7.0, which is the same string only under a\n * BrowserRouter. Under a HashRouter the router's query lives after the `#`\n * (`/#/tx?f.payee=Rewe`) and `window.location.search` is empty: a shared link opened\n * unfiltered, and the table's first write then replaced the link's filter with its own\n * empty state. Reading and writing through `useSearchParams` is one source for both\n * directions under every router, MemoryRouter included.\n */\nexport function readTableUrlState<T>(\n sp: URLSearchParams,\n columns: DataTableColumn<T>[],\n prefix?: string,\n): TableUrlState {\n const keys = urlKeys(prefix);\n const out: TableUrlState = {};\n\n const filters: FilterState = {};\n for (const col of columns) {\n const filter = resolveFilter(col);\n if (!filter) continue;\n const raw = sp.get(keys.filter + col.key);\n if (raw == null) continue;\n filters[col.key] = decodeFilterValue(filter, raw);\n }\n if (Object.keys(filters).length) out.filters = filters;\n\n const sortRaw = sp.get(keys.sort);\n if (sortRaw) {\n const decoded = decodeSorts(sortRaw, new Set(columns.map((c) => c.key)));\n if (decoded.length) out.sorts = decoded;\n }\n\n const psRaw = sp.get(keys.pageSize);\n if (psRaw === \"all\") out.pageSize = Infinity;\n else if (psRaw && Number(psRaw) > 0) out.pageSize = Number(psRaw);\n\n const pRaw = sp.get(keys.page);\n if (pRaw && Number(pRaw) >= 1) out.page = Number(pRaw) - 1;\n\n return out;\n}\n\n/**\n * The managed part of the query string, as a string, for comparing by VALUE.\n *\n * Everything the table puts in the address and nothing else, in a fixed order.\n * `columns` is walked for its keys only, which is why it can be left out of the\n * effect's dependencies without lying: a new column ARRAY carrying the same keys\n * produces the same signature, and a genuinely different set of filterable columns\n * produces a different one.\n */\nfunction urlSignature<T>(\n columns: DataTableColumn<T>[],\n sorts: SortState[],\n filters: FilterState,\n pageSize: number,\n page: number,\n defaultPageSize: number,\n keys: UrlKeys,\n): string {\n const parts: string[] = [];\n for (const col of columns) {\n const state = filters[col.key];\n if (!state || !isFilterActive(state)) continue;\n const enc = encodeFilterValue(state);\n if (enc != null) parts.push(`${keys.filter}${col.key}=${enc}`);\n }\n parts.push(`${keys.sort}=${encodeSorts(sorts)}`);\n parts.push(\n `${keys.pageSize}=${\n pageSize === Infinity ? \"all\" : pageSize === defaultPageSize ? \"\" : String(pageSize)\n }`,\n );\n parts.push(`${keys.page}=${page > 0 ? String(page + 1) : \"\"}`);\n return parts.join(\"&\");\n}\n\nfunction writeUrlState<T>(\n setSearchParams: ReturnType<typeof useSearchParams>[1],\n columns: DataTableColumn<T>[],\n sorts: SortState[],\n filters: FilterState,\n pageSize: number,\n page: number,\n defaultPageSize: number,\n // `undefined`: the search term is not ours to manage, and a `q` is left as it is.\n search: string | undefined,\n // The current entry's route state, carried over the replace. A query rewrite is not\n // a navigation, and dropping the state would, for one, lose the mark a URL-bound\n // dialog's opener leaves there (see useDialogParam).\n state: unknown,\n keys: UrlKeys,\n): void {\n setSearchParams(\n (prev) => {\n const next = new URLSearchParams(prev);\n // clear managed keys\n for (const col of columns) next.delete(keys.filter + col.key);\n next.delete(keys.sort);\n next.delete(keys.page);\n next.delete(keys.pageSize);\n if (search !== undefined) {\n next.delete(keys.search);\n if (search.trim()) next.set(keys.search, search);\n }\n // write filters\n for (const col of columns) {\n const state = filters[col.key];\n if (!state || !isFilterActive(state)) continue;\n const enc = encodeFilterValue(state);\n if (enc != null) next.set(keys.filter + col.key, enc);\n }\n // write sort\n const sortEnc = encodeSorts(sorts);\n if (sortEnc) next.set(keys.sort, sortEnc);\n // write page size if non-default\n if (pageSize === Infinity) next.set(keys.pageSize, \"all\");\n else if (pageSize !== defaultPageSize) next.set(keys.pageSize, String(pageSize));\n // write page if not first\n if (page > 0) next.set(keys.page, String(page + 1));\n return next;\n },\n { replace: true, state },\n );\n}\n\n/**\n * Where a table's view lives: what it is sorted and filtered by, which page, how wide\n * the columns are and which are hidden — plus the two places that outlives a render.\n *\n * Lifted out of `DataTable`, which was 984 lines and twenty hooks in one function. This\n * is the part that is genuinely about STATE rather than about drawing, and the three\n * rules it encodes are easy to lose among the markup:\n *\n * * **The URL is read once, on mount.** A shared or deep link populates the initial view;\n * after that the state is local, or the browser's own history would fight the user's\n * next click.\n * * **Sorting and filtering are optionally CONTROLLED.** A server-driven table passes\n * state and a callback and owns them; everything else is self-owned. That is the\n * `sortsProp ?? internalSorts` pair, and it is why the setters are exposed separately.\n * * **Persistence writes on every change, the URL only when asked.** `storageKey` is the\n * per-table identity; `urlSync` is opt-in because only some tables want their view in\n * a shareable address.\n * * **A controlled filter, sort or page size that changes sends the table back to page\n * 1.** See the note at the reset below.\n */\nexport function useTableState<T>({\n columns,\n storageKey,\n storageKeyPrefix,\n urlSync: urlSyncOption,\n defaultPageSize,\n sortsProp,\n filtersProp,\n pageSizeProp,\n}: {\n columns: DataTableColumn<T>[];\n storageKey?: string;\n storageKeyPrefix: string;\n urlSync?: TableUrlSync;\n defaultPageSize: number;\n sortsProp?: SortState[];\n filtersProp?: FilterState;\n pageSizeProp?: number;\n}) {\n const { enabled: urlSync, prefix: urlPrefix } = resolveTableUrlSync(urlSyncOption);\n const keys = useMemo(() => urlKeys(urlPrefix), [urlPrefix]);\n // Called unconditionally (a Router is required either way — see src/data-table.ts),\n // and the SAME pair is used to read and to write; see `readTableUrlState`.\n const [searchParams, setSearchParams] = useSearchParams();\n const location = useLocation();\n // Read URL once on mount so URL-encoded views (e.g. shared links, deep links from\n // other pages) populate initial state. After mount, state is local.\n const [urlInitial] = useState<TableUrlState>(() =>\n urlSync ? readTableUrlState(searchParams, columns, urlPrefix) : {},\n );\n const initial = useMemo(\n () => loadPersisted(storageKey, storageKeyPrefix),\n [storageKey, storageKeyPrefix],\n );\n const [internalSorts, setInternalSorts] = useState<SortState[]>(() =>\n urlInitial.sorts !== undefined ? urlInitial.sorts : normalizeSorts(initial.sort),\n );\n const [internalFilters, setInternalFilters] = useState<FilterState>(\n () => urlInitial.filters ?? initial.filters ?? {},\n );\n // Controlled when the owner passes state + callback (server-driven tables);\n // self-owned otherwise.\n const sorts = sortsProp ?? internalSorts;\n const filters = filtersProp ?? internalFilters;\n const [page, setPage] = useState(() => urlInitial.page ?? 0);\n\n // ---- Controlled filters/sorts reset the page ----\n //\n // A self-owned table resets to page 1 in the filter handlers. A CONTROLLED one could\n // not: the handlers hand the change to the owner and return, and `page` lives here,\n // where the owner has no way to reach it — so narrowing a filter from outside (a\n // saved view, a chip dismissed above the table) left a client-side table on page 4\n // of what was now two pages, clamped to the last one instead of the first.\n //\n // So the table does it: whenever the controlled VALUE changes, back to the first\n // page. Compared by value, because a controlled prop is a fresh object on every\n // parent render. Adjusted during render rather than in an effect, so the stale page\n // is never painted and never written to the URL. Not on mount: a deep link's `p=3`\n // must survive the owner's first render. (In `serverPagination` mode this state is\n // unused — the owner's `page` is the page, and resetting it stays the owner's job.)\n // A controlled page size counts too: the groups of `TranslationReviewPanel` share one,\n // and a group paged deep into its rows starts over when another group's pager changes it.\n const controlledSig =\n sortsProp !== undefined || filtersProp !== undefined || pageSizeProp !== undefined\n ? JSON.stringify([sortsProp ?? null, filtersProp ?? null, String(pageSizeProp)])\n : null;\n const [seenControlledSig, setSeenControlledSig] = useState(controlledSig);\n if (seenControlledSig !== controlledSig) {\n setSeenControlledSig(controlledSig);\n setPage(0);\n }\n const [internalPageSize, setPageSize] = useState<number>(() =>\n urlInitial.pageSize !== undefined ? urlInitial.pageSize : storedPageSize(initial, defaultPageSize),\n );\n const pageSize = pageSizeProp ?? internalPageSize;\n const [widths, setWidths] = useState<Record<string, number>>(() => initial.widths ?? {});\n const [hiddenCols, setHiddenCols] = useState<Set<string>>(() => new Set(initial.hidden ?? []));\n const [showSettings, setShowSettings] = useState(false);\n const headRefs = useMemo(() => new Map<string, HTMLTableCellElement>(), []);\n\n // ---- Writing out: both of these compare by VALUE ----\n //\n // A CONTROLLED table (`sorts`/`filters` props plus callbacks — every server-driven\n // one) receives a freshly-built object on every render of the page around it, and\n // `{}` is never `===` the `{}` before it. Keyed on identity, both effects below\n // therefore fired on every unrelated parent render: persistence wrote the same JSON\n // into `localStorage` each time — a synchronous main-thread write, on a page whose\n // other half may be a form being typed into — and the URL one handed react-router a\n // `replace` it answers with a render, which is the loop the audit records. A `ref`\n // holding what was last written is what makes \"changed\" mean changed.\n const lastPersisted = useRef<string | null>(null);\n const persisted = !storageKey\n ? \"\"\n : serializePersisted({\n sort: sorts,\n filters,\n pageSize: pageSize === Infinity ? \"all\" : pageSize,\n widths,\n hidden: Array.from(hiddenCols),\n });\n useEffect(() => {\n if (!storageKey) return;\n // The destination is part of the signature: two tables can hold identical state,\n // and a table that swaps `storageKey` has to write under the new one even though\n // nothing about the view changed.\n const target = storageKeyPrefix + storageKey;\n const sig = `${target}\\u0000${persisted}`;\n if (lastPersisted.current === sig) return;\n lastPersisted.current = sig;\n writeStored(target, persisted);\n }, [storageKey, storageKeyPrefix, persisted]);\n\n const lastUrl = useRef<string | null>(null);\n const urlSig = urlSync\n ? urlSignature(columns, sorts, filters, pageSize, page, defaultPageSize, keys)\n : \"\";\n useEffect(() => {\n if (!urlSync) return;\n if (lastUrl.current === urlSig) return;\n lastUrl.current = urlSig;\n // `columns` is deliberately not a dependency and does not need to be: the\n // signature above is computed from it, so a column set that would produce a\n // different address produces a different signature first.\n writeUrlState(setSearchParams, columns, sorts, filters, pageSize, page, defaultPageSize, undefined, location.state, keys);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlSync, urlSig]);\n\n return {\n sorts,\n filters,\n setInternalSorts,\n setInternalFilters,\n page,\n setPage,\n pageSize,\n setPageSize,\n widths,\n setWidths,\n hiddenCols,\n setHiddenCols,\n showSettings,\n setShowSettings,\n headRefs,\n };\n}\n\n// ---------- One view over several tables ----------\n\n/** What {@link useSharedTableView} keeps. */\nexport interface SharedTableView {\n sorts: SortState[];\n setSorts: (next: SortState[]) => void;\n /** `Infinity` for \"all\", as a table holds it. */\n pageSize: number;\n setPageSize: (next: number) => void;\n}\n\n/**\n * One table view — the sort and the page size — for an owner that draws it as SEVERAL\n * tables over one `storageKey`: `TranslationReviewPanel`'s groups, which sort and page\n * alike (0.35, Kurvenschmiede: its grouped review stored no view at all). Pass them to\n * each table as `sorts`/`onSortsChange` and `pageSize`/`onPageSizeChange`.\n *\n * Kept in the very blob a `DataTable` with that `storageKey` keeps, not in a format of\n * its own, so the single table and the groups read each other's view. What the blob\n * holds besides (filters, column widths, hidden columns) is carried over as stored, for\n * the single table to find as it left it. Read when `active` turns on — on mount, or\n * when the owner switches to this view — and written on a change, never on a read. The\n * prefix resolves as DataTable's does: prop > provider > the kit's.\n */\nexport function useSharedTableView({\n active,\n storageKey,\n storageKeyPrefix,\n defaultPageSize,\n}: {\n active: boolean;\n storageKey?: string;\n storageKeyPrefix?: string;\n defaultPageSize: number;\n}): SharedTableView {\n const providerPrefix = useKitTableStorageKeyPrefix();\n const prefix = storageKeyPrefix ?? providerPrefix ?? DEFAULT_PERSIST_PREFIX;\n const read = () => {\n const stored = active ? loadPersisted(storageKey, prefix) : {};\n return { sorts: normalizeSorts(stored.sort), pageSize: storedPageSize(stored, defaultPageSize) };\n };\n const [view, setView] = useState(read);\n // Adjusted during render when `active` turns on, so the first render of the groups\n // already has the view the single table left, not a frame of the default.\n const [wasActive, setWasActive] = useState(active);\n if (active !== wasActive) {\n setWasActive(active);\n if (active) setView(read());\n }\n const change = (patch: Partial<typeof view>) => {\n const next = { ...view, ...patch };\n setView(next);\n if (!active || !storageKey) return;\n const stored = loadPersisted(storageKey, prefix);\n const blob: PersistedState = {\n sort: next.sorts,\n filters: stored.filters ?? {},\n pageSize: next.pageSize === Infinity ? \"all\" : next.pageSize,\n widths: stored.widths,\n hidden: stored.hidden,\n };\n writeStored(prefix + storageKey, serializePersisted(blob));\n };\n return {\n sorts: view.sorts,\n setSorts: (sorts) => change({ sorts }),\n pageSize: view.pageSize,\n setPageSize: (pageSize) => change({ pageSize }),\n };\n}\n\n// ---------- Owner-held table state, mirrored to the URL ----------\n\nexport interface UseTableUrlStateOptions<T> {\n /** The table's columns — read for their keys and filter types only. */\n columns: DataTableColumn<T>[];\n /** Default 25. A page size equal to it is left out of the URL. */\n defaultPageSize?: number;\n /** Mirror the state into the URL and seed it from there on mount. Default true.\n * `{ prefix }` puts a prefix in front of every key, for a page with two URL-synced\n * tables — see {@link TableUrlSync}. */\n urlSync?: TableUrlSync;\n /** Shorthand for `urlSync: { prefix }`; wins over a prefix given there. */\n urlPrefix?: string;\n /** Also hold a free-text search term, under `q`. Off by default, so a `q` that\n * belongs to someone else on the page is never touched. */\n search?: boolean;\n}\n\n/** The part of {@link DataTableProps} a server-driven table takes from its owner. */\nexport interface TableUrlStateProps {\n serverPagination: ServerPagination;\n filters: FilterState;\n onFiltersChange: (next: FilterState) => void;\n sorts: SortState[];\n onSortsChange: (next: SortState[]) => void;\n}\n\nexport interface UseTableUrlStateReturn {\n /** 0-based. */\n page: number;\n setPage: (page: number) => void;\n pageSize: number;\n /** Also back to the first page. */\n setPageSize: (pageSize: number) => void;\n filters: FilterState;\n /** Also back to the first page. */\n setFilters: (next: FilterState) => void;\n sorts: SortState[];\n /** Also back to the first page. */\n setSorts: (next: SortState[]) => void;\n /** The search term (`\"\"` unless `search: true`). */\n search: string;\n /** Also back to the first page. */\n setSearch: (next: string) => void;\n /**\n * Everything the table needs, to spread onto it:\n * `<DataTable {...table.tableProps(data?.total ?? 0, { isLoading })} rows={…} … />`.\n */\n tableProps: (total: number, options?: { isLoading?: boolean }) => TableUrlStateProps;\n}\n\ninterface OwnedState {\n sorts: SortState[];\n filters: FilterState;\n pageSize: number;\n page: number;\n search: string;\n}\n\n/**\n * The controlled state of a `serverPagination` table — filters, sorts, page, page size\n * and optionally a search term — kept by the OWNER and mirrored into the URL in the\n * table's own scheme (`f.<key>`, `sort`, `p`, `ps`, and `q`).\n *\n * A server-driven table owns none of this: the owner has to hold it to turn it into\n * the query that fetches `rows`, and until now it also had to restate the table's URL\n * codec to keep the view bookmarkable (kastlan's use-server-table.ts copied\n * `readUrlState` / `writeUrlState` to do it). Same rules as `urlSync`:\n *\n * * **The URL is read once, on mount** — a shared or deep link (see {@link filterHref})\n * seeds the view; after that the state is local and written out with `replace`.\n * * **Any change but the page itself goes back to page 1** — a narrower filter must not\n * leave the user on page 7 of 2.\n * * **Written by value**: an unchanged view writes nothing, however often the owner\n * renders.\n *\n * Mapping the state onto the backend's query parameters stays the owner's: that is the\n * part the kit cannot know.\n */\nexport function useTableUrlState<T>({\n columns,\n defaultPageSize = 25,\n urlSync: urlSyncOption = true,\n urlPrefix: urlPrefixOption,\n search: manageSearch = false,\n}: UseTableUrlStateOptions<T>): UseTableUrlStateReturn {\n const resolved = resolveTableUrlSync(urlSyncOption);\n const urlSync = resolved.enabled;\n const urlPrefix = urlPrefixOption ?? resolved.prefix;\n const keys = useMemo(() => urlKeys(urlPrefix), [urlPrefix]);\n const [searchParams, setSearchParams] = useSearchParams();\n const location = useLocation();\n const [state, setState] = useState<OwnedState>(() => {\n const url = urlSync ? readTableUrlState(searchParams, columns, urlPrefix) : {};\n return {\n sorts: url.sorts ?? [],\n filters: url.filters ?? {},\n pageSize: url.pageSize ?? defaultPageSize,\n page: url.page ?? 0,\n search: urlSync && manageSearch ? (searchParams.get(keys.search) ?? \"\") : \"\",\n };\n });\n\n const lastUrl = useRef<string | null>(null);\n const urlSig = urlSync\n ? urlSignature(columns, state.sorts, state.filters, state.pageSize, state.page, defaultPageSize, keys) +\n (manageSearch ? `&${keys.search}=${state.search.trim() ? state.search : \"\"}` : \"\")\n : \"\";\n useEffect(() => {\n if (!urlSync) return;\n // First run included: a link that arrives with `p=1` or a default `ps` is\n // normalised to the table's own spelling, as `urlSync` does.\n if (lastUrl.current === urlSig) return;\n lastUrl.current = urlSig;\n writeUrlState(\n setSearchParams,\n columns,\n state.sorts,\n state.filters,\n state.pageSize,\n state.page,\n defaultPageSize,\n manageSearch ? state.search : undefined,\n location.state,\n keys,\n );\n // Keyed on the signature, which is computed from everything the write reads — see\n // the same effect in useTableState.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlSync, urlSig]);\n\n const setPage = (page: number) => setState((s) => ({ ...s, page }));\n const setPageSize = (pageSize: number) => setState((s) => ({ ...s, pageSize, page: 0 }));\n const setFilters = (filters: FilterState) => setState((s) => ({ ...s, filters, page: 0 }));\n const setSorts = (sorts: SortState[]) => setState((s) => ({ ...s, sorts, page: 0 }));\n const setSearch = (search: string) => setState((s) => ({ ...s, search, page: 0 }));\n\n return {\n page: state.page,\n setPage,\n pageSize: state.pageSize,\n setPageSize,\n filters: state.filters,\n setFilters,\n sorts: state.sorts,\n setSorts,\n search: state.search,\n setSearch,\n tableProps: (total, options) => ({\n serverPagination: {\n page: state.page,\n pageSize: state.pageSize,\n total,\n onPageChange: setPage,\n onPageSizeChange: setPageSize,\n isLoading: options?.isLoading,\n },\n filters: state.filters,\n onFiltersChange: setFilters,\n sorts: state.sorts,\n onSortsChange: setSorts,\n }),\n };\n}\n"],"mappings":"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","names":["state"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/use-table-state.ts"],"sourcesContent":["import { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { SetStateAction } from \"react\";\nimport { useLocation, useSearchParams } from \"react-router\";\nimport { useKitTableStorageKeyPrefix } from \"../i18n/kit-labels\";\nimport { readStored, writeStored } from \"../lib/safe-storage\";\nimport {\n decodeFilterValue,\n encodeFilterValue,\n isFilterActive,\n resolveFilter,\n} from \"./data-table-filters\";\nimport { decodeSorts, encodeSorts, normalizeSorts, type SortState } from \"./data-table-sort\";\nimport type { DataTableColumn, FilterState, ServerPagination } from \"./data-table\";\n\n// ---------- Persisted + URL-encoded view state ----------\n//\n// Moved here with `useTableState` when `DataTable` was split: these are what the hook\n// reads and writes, and nothing else in the table ever called them.\n\ninterface PersistedState {\n // Multi-sort array; blobs from before the upgrade hold a single {key, dir}\n // object (or null) — read back through normalizeSorts.\n sort: SortState[] | { key: string; dir: string } | null;\n filters: FilterState;\n pageSize: number | \"all\";\n widths?: Record<string, number>;\n hidden?: string[];\n /** 0.48: the `defaultHidden` columns the user ticked back on — an entry that beats the\n * default. Written only when non-empty, so a table without such columns stores what\n * it always did. */\n shown?: string[];\n}\n\n/** The hidden set as drawn: the stored one plus every `defaultHidden` column the view\n * holds no entry for (neither hidden nor `shown`). */\nfunction withDefaultHidden(view: { hidden: Set<string>; shown: Set<string> }, defaults: string[]): Set<string> {\n const extra = defaults.filter((k) => !view.hidden.has(k) && !view.shown.has(k));\n return extra.length === 0 ? view.hidden : new Set([...view.hidden, ...extra]);\n}\n\nexport const DEFAULT_PERSIST_PREFIX = \"hbui-table:\";\n\nfunction loadPersisted(storageKey: string | undefined, prefix: string): Partial<PersistedState> {\n if (!storageKey) return {};\n const raw = readStored(prefix + storageKey);\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw) as Partial<PersistedState>;\n return parsed && typeof parsed === \"object\" ? parsed : {};\n } catch {\n return {}; // corrupt JSON — the storage access itself is guarded by readStored\n }\n}\n\n/** The blob that would be stored — also the value the effect below compares by, so\n * the comparison and the write can never disagree about what \"unchanged\" means. */\nfunction serializePersisted(state: PersistedState): string {\n return JSON.stringify(state);\n}\n\n/** A stored page size as a table holds it — `\"all\"` is `Infinity` — or `fallback`. */\nfunction storedPageSize(stored: Partial<PersistedState>, fallback: number): number {\n if (stored.pageSize === \"all\") return Infinity;\n if (typeof stored.pageSize === \"number\" && stored.pageSize > 0) return stored.pageSize;\n return fallback;\n}\n\n// ---------- URL sync helpers ----------\n\nconst URL_FILTER_PREFIX = \"f.\";\nconst URL_SORT_KEY = \"sort\";\nconst URL_PAGE_KEY = \"p\";\nconst URL_PAGE_SIZE_KEY = \"ps\";\n/** The free-text search term — managed only by {@link useTableUrlState}, and only when\n * it is asked to (`search: true`); a plain `urlSync` table leaves a `q` alone. */\nconst URL_SEARCH_KEY = \"q\";\n\n/**\n * How a table is mirrored into the URL: `true`, or `{ prefix }` for a table that shares\n * its page with another URL-synced one. The prefix is put verbatim in front of EVERY key\n * the table writes and reads — `{ prefix: \"inv.\" }` gives `inv.f.<column>`, `inv.sort`,\n * `inv.p`, `inv.ps` (and `inv.q`) — so two tables on one page stop sharing a sort and a\n * page. Without one the keys are unprefixed, as they always were.\n */\nexport type TableUrlSync = boolean | { prefix?: string };\n\n/** The query keys of one table, with its prefix applied. */\ninterface UrlKeys {\n filter: string;\n sort: string;\n page: string;\n pageSize: string;\n search: string;\n}\n\nfunction urlKeys(prefix = \"\"): UrlKeys {\n return {\n filter: prefix + URL_FILTER_PREFIX,\n sort: prefix + URL_SORT_KEY,\n page: prefix + URL_PAGE_KEY,\n pageSize: prefix + URL_PAGE_SIZE_KEY,\n search: prefix + URL_SEARCH_KEY,\n };\n}\n\n/** `urlSync` split into \"is it on\" and \"under which prefix\". */\nfunction resolveTableUrlSync(urlSync: TableUrlSync | undefined): { enabled: boolean; prefix: string } {\n if (!urlSync) return { enabled: false, prefix: \"\" };\n return { enabled: true, prefix: urlSync === true ? \"\" : (urlSync.prefix ?? \"\") };\n}\n\n/** What {@link readTableUrlState} found in a query string. */\nexport interface TableUrlState {\n sorts?: SortState[];\n filters?: FilterState;\n /** `Infinity` for `ps=all`. */\n pageSize?: number;\n /** 0-based (the URL's `p` is 1-based). */\n page?: number;\n}\n\n/**\n * What a table's URL says, read out of a query string: the column filters (`f.<key>`),\n * the sort, the page size and the 0-based page. Absent keys are absent here too, so a\n * caller can tell \"not in the link\" from \"the default\". The same reader `urlSync` uses\n * on mount, exported for an owner that keeps the state itself — see\n * {@link useTableUrlState}.\n *\n * Pass the ROUTER's query string — the one `setSearchParams` writes — and the table's\n * `prefix` when it has one (see {@link TableUrlSync}).\n *\n * This read `window.location.search` until 0.7.0, which is the same string only under a\n * BrowserRouter. Under a HashRouter the router's query lives after the `#`\n * (`/#/tx?f.payee=Rewe`) and `window.location.search` is empty: a shared link opened\n * unfiltered, and the table's first write then replaced the link's filter with its own\n * empty state. Reading and writing through `useSearchParams` is one source for both\n * directions under every router, MemoryRouter included.\n */\nexport function readTableUrlState<T>(\n sp: URLSearchParams,\n columns: DataTableColumn<T>[],\n prefix?: string,\n): TableUrlState {\n const keys = urlKeys(prefix);\n const out: TableUrlState = {};\n\n const filters: FilterState = {};\n for (const col of columns) {\n const filter = resolveFilter(col);\n if (!filter) continue;\n const raw = sp.get(keys.filter + col.key);\n if (raw == null) continue;\n filters[col.key] = decodeFilterValue(filter, raw);\n }\n if (Object.keys(filters).length) out.filters = filters;\n\n const sortRaw = sp.get(keys.sort);\n if (sortRaw) {\n const decoded = decodeSorts(sortRaw, new Set(columns.map((c) => c.key)));\n if (decoded.length) out.sorts = decoded;\n }\n\n const psRaw = sp.get(keys.pageSize);\n if (psRaw === \"all\") out.pageSize = Infinity;\n else if (psRaw && Number(psRaw) > 0) out.pageSize = Number(psRaw);\n\n const pRaw = sp.get(keys.page);\n if (pRaw && Number(pRaw) >= 1) out.page = Number(pRaw) - 1;\n\n return out;\n}\n\n/**\n * The managed part of the query string, as a string, for comparing by VALUE.\n *\n * Everything the table puts in the address and nothing else, in a fixed order.\n * `columns` is walked for its keys only, which is why it can be left out of the\n * effect's dependencies without lying: a new column ARRAY carrying the same keys\n * produces the same signature, and a genuinely different set of filterable columns\n * produces a different one.\n */\nfunction urlSignature<T>(\n columns: DataTableColumn<T>[],\n sorts: SortState[],\n filters: FilterState,\n pageSize: number,\n page: number,\n defaultPageSize: number,\n keys: UrlKeys,\n): string {\n const parts: string[] = [];\n for (const col of columns) {\n const state = filters[col.key];\n if (!state || !isFilterActive(state)) continue;\n const enc = encodeFilterValue(state);\n if (enc != null) parts.push(`${keys.filter}${col.key}=${enc}`);\n }\n parts.push(`${keys.sort}=${encodeSorts(sorts)}`);\n parts.push(\n `${keys.pageSize}=${\n pageSize === Infinity ? \"all\" : pageSize === defaultPageSize ? \"\" : String(pageSize)\n }`,\n );\n parts.push(`${keys.page}=${page > 0 ? String(page + 1) : \"\"}`);\n return parts.join(\"&\");\n}\n\nfunction writeUrlState<T>(\n setSearchParams: ReturnType<typeof useSearchParams>[1],\n columns: DataTableColumn<T>[],\n sorts: SortState[],\n filters: FilterState,\n pageSize: number,\n page: number,\n defaultPageSize: number,\n // `undefined`: the search term is not ours to manage, and a `q` is left as it is.\n search: string | undefined,\n // The current entry's route state, carried over the replace. A query rewrite is not\n // a navigation, and dropping the state would, for one, lose the mark a URL-bound\n // dialog's opener leaves there (see useDialogParam).\n state: unknown,\n keys: UrlKeys,\n): void {\n setSearchParams(\n (prev) => {\n const next = new URLSearchParams(prev);\n // clear managed keys\n for (const col of columns) next.delete(keys.filter + col.key);\n next.delete(keys.sort);\n next.delete(keys.page);\n next.delete(keys.pageSize);\n if (search !== undefined) {\n next.delete(keys.search);\n if (search.trim()) next.set(keys.search, search);\n }\n // write filters\n for (const col of columns) {\n const state = filters[col.key];\n if (!state || !isFilterActive(state)) continue;\n const enc = encodeFilterValue(state);\n if (enc != null) next.set(keys.filter + col.key, enc);\n }\n // write sort\n const sortEnc = encodeSorts(sorts);\n if (sortEnc) next.set(keys.sort, sortEnc);\n // write page size if non-default\n if (pageSize === Infinity) next.set(keys.pageSize, \"all\");\n else if (pageSize !== defaultPageSize) next.set(keys.pageSize, String(pageSize));\n // write page if not first\n if (page > 0) next.set(keys.page, String(page + 1));\n return next;\n },\n { replace: true, state },\n );\n}\n\n/**\n * Where a table's view lives: what it is sorted and filtered by, which page, how wide\n * the columns are and which are hidden — plus the two places that outlives a render.\n *\n * Lifted out of `DataTable`, which was 984 lines and twenty hooks in one function. This\n * is the part that is genuinely about STATE rather than about drawing, and the three\n * rules it encodes are easy to lose among the markup:\n *\n * * **The URL is read once, on mount.** A shared or deep link populates the initial view;\n * after that the state is local, or the browser's own history would fight the user's\n * next click.\n * * **Sorting and filtering are optionally CONTROLLED.** A server-driven table passes\n * state and a callback and owns them; everything else is self-owned. That is the\n * `sortsProp ?? internalSorts` pair, and it is why the setters are exposed separately.\n * * **Persistence writes on every change, the URL only when asked.** `storageKey` is the\n * per-table identity; `urlSync` is opt-in because only some tables want their view in\n * a shareable address.\n * * **A controlled filter, sort or page size that changes sends the table back to page\n * 1.** See the note at the reset below.\n */\nexport function useTableState<T>({\n columns,\n storageKey,\n storageKeyPrefix,\n urlSync: urlSyncOption,\n defaultPageSize,\n sortsProp,\n filtersProp,\n pageSizeProp,\n}: {\n columns: DataTableColumn<T>[];\n storageKey?: string;\n storageKeyPrefix: string;\n urlSync?: TableUrlSync;\n defaultPageSize: number;\n sortsProp?: SortState[];\n filtersProp?: FilterState;\n pageSizeProp?: number;\n}) {\n const { enabled: urlSync, prefix: urlPrefix } = resolveTableUrlSync(urlSyncOption);\n const keys = useMemo(() => urlKeys(urlPrefix), [urlPrefix]);\n // Called unconditionally (a Router is required either way — see src/data-table.ts),\n // and the SAME pair is used to read and to write; see `readTableUrlState`.\n const [searchParams, setSearchParams] = useSearchParams();\n const location = useLocation();\n // Read URL once on mount so URL-encoded views (e.g. shared links, deep links from\n // other pages) populate initial state. After mount, state is local.\n const [urlInitial] = useState<TableUrlState>(() =>\n urlSync ? readTableUrlState(searchParams, columns, urlPrefix) : {},\n );\n const initial = useMemo(\n () => loadPersisted(storageKey, storageKeyPrefix),\n [storageKey, storageKeyPrefix],\n );\n const [internalSorts, setInternalSorts] = useState<SortState[]>(() =>\n urlInitial.sorts !== undefined ? urlInitial.sorts : normalizeSorts(initial.sort),\n );\n const [internalFilters, setInternalFilters] = useState<FilterState>(\n () => urlInitial.filters ?? initial.filters ?? {},\n );\n // Controlled when the owner passes state + callback (server-driven tables);\n // self-owned otherwise.\n const sorts = sortsProp ?? internalSorts;\n const filters = filtersProp ?? internalFilters;\n const [page, setPage] = useState(() => urlInitial.page ?? 0);\n\n // ---- Controlled filters/sorts reset the page ----\n //\n // A self-owned table resets to page 1 in the filter handlers. A CONTROLLED one could\n // not: the handlers hand the change to the owner and return, and `page` lives here,\n // where the owner has no way to reach it — so narrowing a filter from outside (a\n // saved view, a chip dismissed above the table) left a client-side table on page 4\n // of what was now two pages, clamped to the last one instead of the first.\n //\n // So the table does it: whenever the controlled VALUE changes, back to the first\n // page. Compared by value, because a controlled prop is a fresh object on every\n // parent render. Adjusted during render rather than in an effect, so the stale page\n // is never painted and never written to the URL. Not on mount: a deep link's `p=3`\n // must survive the owner's first render. (In `serverPagination` mode this state is\n // unused — the owner's `page` is the page, and resetting it stays the owner's job.)\n // A controlled page size counts too: the groups of `TranslationReviewPanel` share one,\n // and a group paged deep into its rows starts over when another group's pager changes it.\n const controlledSig =\n sortsProp !== undefined || filtersProp !== undefined || pageSizeProp !== undefined\n ? JSON.stringify([sortsProp ?? null, filtersProp ?? null, String(pageSizeProp)])\n : null;\n const [seenControlledSig, setSeenControlledSig] = useState(controlledSig);\n if (seenControlledSig !== controlledSig) {\n setSeenControlledSig(controlledSig);\n setPage(0);\n }\n const [internalPageSize, setPageSize] = useState<number>(() =>\n urlInitial.pageSize !== undefined ? urlInitial.pageSize : storedPageSize(initial, defaultPageSize),\n );\n const pageSize = pageSizeProp ?? internalPageSize;\n const [widths, setWidths] = useState<Record<string, number>>(() => initial.widths ?? {});\n // Hidden columns (0.48, keksdose #617): what the user chose — `hidden`, and `shown`\n // for a `defaultHidden` column ticked back on — kept apart from the defaults, which\n // are applied on read and never stored. Keyed on the default keys as a string: every\n // call site builds `columns` inline, so the array is new on every render.\n const [view, setView] = useState(() => ({\n hidden: new Set<string>(initial.hidden ?? []),\n shown: new Set<string>(initial.shown ?? []),\n }));\n const defaultHiddenSig = columns\n .filter((c) => c.defaultHidden)\n .map((c) => c.key)\n .join(\"\\u0000\");\n const hiddenCols = useMemo(\n () => withDefaultHidden(view, defaultHiddenSig ? defaultHiddenSig.split(\"\\u0000\") : []),\n [view, defaultHiddenSig],\n );\n // The panel hands over the next DRAWN set; what changed is recorded as a choice.\n const setHiddenCols = useCallback(\n (action: SetStateAction<Set<string>>) =>\n setView((v) => {\n const defaults = defaultHiddenSig ? defaultHiddenSig.split(\"\\u0000\") : [];\n const prev = withDefaultHidden(v, defaults);\n const next = typeof action === \"function\" ? action(prev) : action;\n const hidden = new Set(v.hidden);\n const shown = new Set(v.shown);\n for (const k of next) {\n if (prev.has(k)) continue;\n hidden.add(k);\n shown.delete(k);\n }\n for (const k of prev) {\n if (next.has(k)) continue;\n hidden.delete(k);\n if (defaults.includes(k)) shown.add(k);\n }\n return { hidden, shown };\n }),\n [defaultHiddenSig],\n );\n const [showSettings, setShowSettings] = useState(false);\n const headRefs = useMemo(() => new Map<string, HTMLTableCellElement>(), []);\n\n // ---- Writing out: both of these compare by VALUE ----\n //\n // A CONTROLLED table (`sorts`/`filters` props plus callbacks — every server-driven\n // one) receives a freshly-built object on every render of the page around it, and\n // `{}` is never `===` the `{}` before it. Keyed on identity, both effects below\n // therefore fired on every unrelated parent render: persistence wrote the same JSON\n // into `localStorage` each time — a synchronous main-thread write, on a page whose\n // other half may be a form being typed into — and the URL one handed react-router a\n // `replace` it answers with a render, which is the loop the audit records. A `ref`\n // holding what was last written is what makes \"changed\" mean changed.\n const lastPersisted = useRef<string | null>(null);\n const persisted = !storageKey\n ? \"\"\n : serializePersisted({\n sort: sorts,\n filters,\n pageSize: pageSize === Infinity ? \"all\" : pageSize,\n widths,\n hidden: Array.from(view.hidden),\n ...(view.shown.size > 0 ? { shown: Array.from(view.shown) } : {}),\n });\n useEffect(() => {\n if (!storageKey) return;\n // The destination is part of the signature: two tables can hold identical state,\n // and a table that swaps `storageKey` has to write under the new one even though\n // nothing about the view changed.\n const target = storageKeyPrefix + storageKey;\n const sig = `${target}\\u0000${persisted}`;\n if (lastPersisted.current === sig) return;\n lastPersisted.current = sig;\n writeStored(target, persisted);\n }, [storageKey, storageKeyPrefix, persisted]);\n\n const lastUrl = useRef<string | null>(null);\n const urlSig = urlSync\n ? urlSignature(columns, sorts, filters, pageSize, page, defaultPageSize, keys)\n : \"\";\n useEffect(() => {\n if (!urlSync) return;\n if (lastUrl.current === urlSig) return;\n lastUrl.current = urlSig;\n // `columns` is deliberately not a dependency and does not need to be: the\n // signature above is computed from it, so a column set that would produce a\n // different address produces a different signature first.\n writeUrlState(setSearchParams, columns, sorts, filters, pageSize, page, defaultPageSize, undefined, location.state, keys);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlSync, urlSig]);\n\n return {\n sorts,\n filters,\n setInternalSorts,\n setInternalFilters,\n page,\n setPage,\n pageSize,\n setPageSize,\n widths,\n setWidths,\n hiddenCols,\n setHiddenCols,\n showSettings,\n setShowSettings,\n headRefs,\n };\n}\n\n// ---------- One view over several tables ----------\n\n/** What {@link useSharedTableView} keeps. */\nexport interface SharedTableView {\n sorts: SortState[];\n setSorts: (next: SortState[]) => void;\n /** `Infinity` for \"all\", as a table holds it. */\n pageSize: number;\n setPageSize: (next: number) => void;\n}\n\n/**\n * One table view — the sort and the page size — for an owner that draws it as SEVERAL\n * tables over one `storageKey`: `TranslationReviewPanel`'s groups, which sort and page\n * alike (0.35, Kurvenschmiede: its grouped review stored no view at all). Pass them to\n * each table as `sorts`/`onSortsChange` and `pageSize`/`onPageSizeChange`.\n *\n * Kept in the very blob a `DataTable` with that `storageKey` keeps, not in a format of\n * its own, so the single table and the groups read each other's view. What the blob\n * holds besides (filters, column widths, hidden columns) is carried over as stored, for\n * the single table to find as it left it. Read when `active` turns on — on mount, or\n * when the owner switches to this view — and written on a change, never on a read. The\n * prefix resolves as DataTable's does: prop > provider > the kit's.\n */\nexport function useSharedTableView({\n active,\n storageKey,\n storageKeyPrefix,\n defaultPageSize,\n}: {\n active: boolean;\n storageKey?: string;\n storageKeyPrefix?: string;\n defaultPageSize: number;\n}): SharedTableView {\n const providerPrefix = useKitTableStorageKeyPrefix();\n const prefix = storageKeyPrefix ?? providerPrefix ?? DEFAULT_PERSIST_PREFIX;\n const read = () => {\n const stored = active ? loadPersisted(storageKey, prefix) : {};\n return { sorts: normalizeSorts(stored.sort), pageSize: storedPageSize(stored, defaultPageSize) };\n };\n const [view, setView] = useState(read);\n // Adjusted during render when `active` turns on, so the first render of the groups\n // already has the view the single table left, not a frame of the default.\n const [wasActive, setWasActive] = useState(active);\n if (active !== wasActive) {\n setWasActive(active);\n if (active) setView(read());\n }\n const change = (patch: Partial<typeof view>) => {\n const next = { ...view, ...patch };\n setView(next);\n if (!active || !storageKey) return;\n const stored = loadPersisted(storageKey, prefix);\n const blob: PersistedState = {\n sort: next.sorts,\n filters: stored.filters ?? {},\n pageSize: next.pageSize === Infinity ? \"all\" : next.pageSize,\n widths: stored.widths,\n hidden: stored.hidden,\n };\n writeStored(prefix + storageKey, serializePersisted(blob));\n };\n return {\n sorts: view.sorts,\n setSorts: (sorts) => change({ sorts }),\n pageSize: view.pageSize,\n setPageSize: (pageSize) => change({ pageSize }),\n };\n}\n\n// ---------- Owner-held table state, mirrored to the URL ----------\n\nexport interface UseTableUrlStateOptions<T> {\n /** The table's columns — read for their keys and filter types only. */\n columns: DataTableColumn<T>[];\n /** Default 25. A page size equal to it is left out of the URL. */\n defaultPageSize?: number;\n /** Mirror the state into the URL and seed it from there on mount. Default true.\n * `{ prefix }` puts a prefix in front of every key, for a page with two URL-synced\n * tables — see {@link TableUrlSync}. */\n urlSync?: TableUrlSync;\n /** Shorthand for `urlSync: { prefix }`; wins over a prefix given there. */\n urlPrefix?: string;\n /** Also hold a free-text search term, under `q`. Off by default, so a `q` that\n * belongs to someone else on the page is never touched. */\n search?: boolean;\n}\n\n/** The part of {@link DataTableProps} a server-driven table takes from its owner. */\nexport interface TableUrlStateProps {\n serverPagination: ServerPagination;\n filters: FilterState;\n onFiltersChange: (next: FilterState) => void;\n sorts: SortState[];\n onSortsChange: (next: SortState[]) => void;\n}\n\nexport interface UseTableUrlStateReturn {\n /** 0-based. */\n page: number;\n setPage: (page: number) => void;\n pageSize: number;\n /** Also back to the first page. */\n setPageSize: (pageSize: number) => void;\n filters: FilterState;\n /** Also back to the first page. */\n setFilters: (next: FilterState) => void;\n sorts: SortState[];\n /** Also back to the first page. */\n setSorts: (next: SortState[]) => void;\n /** The search term (`\"\"` unless `search: true`). */\n search: string;\n /** Also back to the first page. */\n setSearch: (next: string) => void;\n /**\n * Everything the table needs, to spread onto it:\n * `<DataTable {...table.tableProps(data?.total ?? 0, { isLoading })} rows={…} … />`.\n */\n tableProps: (total: number, options?: { isLoading?: boolean }) => TableUrlStateProps;\n}\n\ninterface OwnedState {\n sorts: SortState[];\n filters: FilterState;\n pageSize: number;\n page: number;\n search: string;\n}\n\n/**\n * The controlled state of a `serverPagination` table — filters, sorts, page, page size\n * and optionally a search term — kept by the OWNER and mirrored into the URL in the\n * table's own scheme (`f.<key>`, `sort`, `p`, `ps`, and `q`).\n *\n * A server-driven table owns none of this: the owner has to hold it to turn it into\n * the query that fetches `rows`, and until now it also had to restate the table's URL\n * codec to keep the view bookmarkable (kastlan's use-server-table.ts copied\n * `readUrlState` / `writeUrlState` to do it). Same rules as `urlSync`:\n *\n * * **The URL is read once, on mount** — a shared or deep link (see {@link filterHref})\n * seeds the view; after that the state is local and written out with `replace`.\n * * **Any change but the page itself goes back to page 1** — a narrower filter must not\n * leave the user on page 7 of 2.\n * * **Written by value**: an unchanged view writes nothing, however often the owner\n * renders.\n *\n * Mapping the state onto the backend's query parameters stays the owner's: that is the\n * part the kit cannot know.\n */\nexport function useTableUrlState<T>({\n columns,\n defaultPageSize = 25,\n urlSync: urlSyncOption = true,\n urlPrefix: urlPrefixOption,\n search: manageSearch = false,\n}: UseTableUrlStateOptions<T>): UseTableUrlStateReturn {\n const resolved = resolveTableUrlSync(urlSyncOption);\n const urlSync = resolved.enabled;\n const urlPrefix = urlPrefixOption ?? resolved.prefix;\n const keys = useMemo(() => urlKeys(urlPrefix), [urlPrefix]);\n const [searchParams, setSearchParams] = useSearchParams();\n const location = useLocation();\n const [state, setState] = useState<OwnedState>(() => {\n const url = urlSync ? readTableUrlState(searchParams, columns, urlPrefix) : {};\n return {\n sorts: url.sorts ?? [],\n filters: url.filters ?? {},\n pageSize: url.pageSize ?? defaultPageSize,\n page: url.page ?? 0,\n search: urlSync && manageSearch ? (searchParams.get(keys.search) ?? \"\") : \"\",\n };\n });\n\n const lastUrl = useRef<string | null>(null);\n const urlSig = urlSync\n ? urlSignature(columns, state.sorts, state.filters, state.pageSize, state.page, defaultPageSize, keys) +\n (manageSearch ? `&${keys.search}=${state.search.trim() ? state.search : \"\"}` : \"\")\n : \"\";\n useEffect(() => {\n if (!urlSync) return;\n // First run included: a link that arrives with `p=1` or a default `ps` is\n // normalised to the table's own spelling, as `urlSync` does.\n if (lastUrl.current === urlSig) return;\n lastUrl.current = urlSig;\n writeUrlState(\n setSearchParams,\n columns,\n state.sorts,\n state.filters,\n state.pageSize,\n state.page,\n defaultPageSize,\n manageSearch ? state.search : undefined,\n location.state,\n keys,\n );\n // Keyed on the signature, which is computed from everything the write reads — see\n // the same effect in useTableState.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlSync, urlSig]);\n\n const setPage = (page: number) => setState((s) => ({ ...s, page }));\n const setPageSize = (pageSize: number) => setState((s) => ({ ...s, pageSize, page: 0 }));\n const setFilters = (filters: FilterState) => setState((s) => ({ ...s, filters, page: 0 }));\n const setSorts = (sorts: SortState[]) => setState((s) => ({ ...s, sorts, page: 0 }));\n const setSearch = (search: string) => setState((s) => ({ ...s, search, page: 0 }));\n\n return {\n page: state.page,\n setPage,\n pageSize: state.pageSize,\n setPageSize,\n filters: state.filters,\n setFilters,\n sorts: state.sorts,\n setSorts,\n search: state.search,\n setSearch,\n tableProps: (total, options) => ({\n serverPagination: {\n page: state.page,\n pageSize: state.pageSize,\n total,\n onPageChange: setPage,\n onPageSizeChange: setPageSize,\n isLoading: options?.isLoading,\n },\n filters: state.filters,\n onFiltersChange: setFilters,\n sorts: state.sorts,\n onSortsChange: setSorts,\n }),\n };\n}\n"],"mappings":"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","names":["state"]}
|
package/dist/data-table.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { FilterPopover } from "./components/data-table-filter-popover.js";
|
|
|
6
6
|
export { DataTable, DEFAULT_DATA_TABLE_SORT_LABELS } from "./components/data-table.js";
|
|
7
7
|
export type { DataTableSortLabels, DataTableColumn, DataTableCellProps, DataTableHeadProps, DataTableProps, ServerPagination, FilterState, MobileSwipeActions, DataTableDensity, DataTableChrome, DataTableRowAction, DataTableDesktopSwipeActions, } from "./components/data-table.js";
|
|
8
8
|
export { swipePlanRowActions } from "./components/data-table-row-actions.js";
|
|
9
|
+
export type { DataTableGroupBy, DataTableLoadMore } from "./components/data-table-groups.js";
|
|
9
10
|
export * from "./components/data-table-cells.js";
|
|
10
11
|
export { useTableUrlState, readTableUrlState } from "./components/use-table-state.js";
|
|
11
12
|
export type { TableUrlState, TableUrlSync, TableUrlStateProps, UseTableUrlStateOptions, UseTableUrlStateReturn, } from "./components/use-table-state.js";
|
package/dist/data-table.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/data-table.ts"],"sourcesContent":["// `@eifi1/ui-kit/data-table` — the table suite.\n//\n// A re-slicing of the main barrel, not a new API. This entry point requires\n// `react-router`: DataTable calls useSearchParams unconditionally, above any `urlSync`\n// branch, so a Router is needed even with URL sync switched off.\nexport * from \"./components/data-table-labels\";\nexport * from \"./components/data-table-sort\";\nexport * from \"./components/data-table-filters\";\nexport * from \"./components/data-table-pagination\";\nexport { FilterPopover } from \"./components/data-table-filter-popover\";\n// `SortState` comes from data-table-sort above; re-exporting the module wholesale\n// would collide with it, which is why the main barrel names these explicitly too.\nexport { DataTable, DEFAULT_DATA_TABLE_SORT_LABELS } from \"./components/data-table\";\nexport type {\n DataTableSortLabels,\n DataTableColumn,\n // The barrel had these two and this entry did not (refactor audit 2026-10, MOD-5):\n // the types of a column's `cellProps` and `headProps`.\n DataTableCellProps,\n DataTableHeadProps,\n DataTableProps,\n ServerPagination,\n FilterState,\n MobileSwipeActions,\n DataTableDensity,\n DataTableChrome,\n DataTableRowAction,\n DataTableDesktopSwipeActions,\n} from \"./components/data-table\";\n// 0.46.1 (keksdose N3): a swipe plan as `RowActions`' actions — the desktop column's own\n// derivation, for an app's hand-built actions column.\nexport { swipePlanRowActions } from \"./components/data-table-row-actions\";\nexport * from \"./components/data-table-cells\";\nexport { useTableUrlState, readTableUrlState } from \"./components/use-table-state\";\nexport type {\n TableUrlState,\n TableUrlSync,\n TableUrlStateProps,\n UseTableUrlStateOptions,\n UseTableUrlStateReturn,\n} from \"./components/use-table-state\";\n"],"mappings":";AAKA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAAS,qBAAqB;AAG9B,SAAS,WAAW,sCAAsC;AAmB1D,SAAS,2BAA2B;
|
|
1
|
+
{"version":3,"sources":["../src/data-table.ts"],"sourcesContent":["// `@eifi1/ui-kit/data-table` — the table suite.\n//\n// A re-slicing of the main barrel, not a new API. This entry point requires\n// `react-router`: DataTable calls useSearchParams unconditionally, above any `urlSync`\n// branch, so a Router is needed even with URL sync switched off.\nexport * from \"./components/data-table-labels\";\nexport * from \"./components/data-table-sort\";\nexport * from \"./components/data-table-filters\";\nexport * from \"./components/data-table-pagination\";\nexport { FilterPopover } from \"./components/data-table-filter-popover\";\n// `SortState` comes from data-table-sort above; re-exporting the module wholesale\n// would collide with it, which is why the main barrel names these explicitly too.\nexport { DataTable, DEFAULT_DATA_TABLE_SORT_LABELS } from \"./components/data-table\";\nexport type {\n DataTableSortLabels,\n DataTableColumn,\n // The barrel had these two and this entry did not (refactor audit 2026-10, MOD-5):\n // the types of a column's `cellProps` and `headProps`.\n DataTableCellProps,\n DataTableHeadProps,\n DataTableProps,\n ServerPagination,\n FilterState,\n MobileSwipeActions,\n DataTableDensity,\n DataTableChrome,\n DataTableRowAction,\n DataTableDesktopSwipeActions,\n} from \"./components/data-table\";\n// 0.46.1 (keksdose N3): a swipe plan as `RowActions`' actions — the desktop column's own\n// derivation, for an app's hand-built actions column.\nexport { swipePlanRowActions } from \"./components/data-table-row-actions\";\n// 0.48 (keksdose dev #605): a `groupBy` level (also `mobileGroupBy`'s) and the `loadMore` footer.\nexport type { DataTableGroupBy, DataTableLoadMore } from \"./components/data-table-groups\";\nexport * from \"./components/data-table-cells\";\nexport { useTableUrlState, readTableUrlState } from \"./components/use-table-state\";\nexport type {\n TableUrlState,\n TableUrlSync,\n TableUrlStateProps,\n UseTableUrlStateOptions,\n UseTableUrlStateReturn,\n} from \"./components/use-table-state\";\n"],"mappings":";AAKA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAAS,qBAAqB;AAG9B,SAAS,WAAW,sCAAsC;AAmB1D,SAAS,2BAA2B;AAGpC,cAAc;AACd,SAAS,kBAAkB,yBAAyB;","names":[]}
|
package/dist/eslint/index.js
CHANGED
|
@@ -47,7 +47,7 @@ const roleUtilities = {
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
};
|
|
50
|
-
const VERSION = true ? "0.
|
|
50
|
+
const VERSION = true ? "0.48.1" : "0.0.0-source";
|
|
51
51
|
const recommended = {
|
|
52
52
|
name: "@eifi1/ui-kit/recommended",
|
|
53
53
|
// Not the tests, as check:tokens skips the kit's: a test names the old class to assert it
|
|
@@ -218,6 +218,11 @@ export interface FeedbackColumnsOptions {
|
|
|
218
218
|
* filter that never offered it.
|
|
219
219
|
* - The status column owns the status filter, and only it (keksdose live #330's revert:
|
|
220
220
|
* the phone's filter sheet already exposes every column filter).
|
|
221
|
+
* - Fitted to the table's width (0.48, keksdose live #451): Subject `grow`s and
|
|
222
|
+
* ellipsises, and a narrow table leaves out URL, then Email, then Resolved
|
|
223
|
+
* (`fitPriority` 1–3) — the open report repeats all three — then Votes and Category
|
|
224
|
+
* (4, 5) before Subject would drop below 10rem. Drop the two keys with
|
|
225
|
+
* `{ ...col, grow: undefined, fitPriority: undefined }` for the 0.47 layout.
|
|
221
226
|
* - `/my-feedback` shows the same nine: the Email cell's context fallback exists for it
|
|
222
227
|
* (the admin list's join is absent there, §3.1), and its phone card drops the submitter
|
|
223
228
|
* instead ({@link FeedbackMobileCardProps.showSubmitter}).
|