@eifi1/ui-kit 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/components/alert-banner.js +5 -2
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +9 -1
- package/dist/components/amount-input.js +10 -4
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.js +9 -2
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/bulk-action-bar.js +6 -3
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/calendar-heatmap.js +5 -5
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/copy-button.d.ts +1 -1
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filter-popover.js +3 -3
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +12 -4
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/error-boundary.js +2 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/field.js +4 -1
- package/dist/components/field.js.map +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/image-grid.d.ts +8 -1
- package/dist/components/image-grid.js +16 -3
- package/dist/components/image-grid.js.map +1 -1
- package/dist/components/measured-grid.js +2 -2
- package/dist/components/measured-grid.js.map +1 -1
- package/dist/components/money-field.d.ts +92 -0
- package/dist/components/money-field.js +40 -0
- package/dist/components/money-field.js.map +1 -0
- package/dist/components/multi-select.js +3 -0
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +13 -2
- package/dist/components/number-field.js +4 -0
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +5 -2
- package/dist/components/number-input.js +11 -3
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +1 -1
- package/dist/components/page-header.js +29 -3
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/series-chart.js +37 -8
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +1 -1
- package/dist/components/stat-tile.js +3 -3
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/text-link.d.ts +1 -1
- package/dist/components/text-link.js +2 -1
- package/dist/components/text-link.js.map +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/treemap.d.ts +3 -2
- package/dist/components/treemap.js +14 -3
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/components/ui.js +44 -10
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-Drun8Fk9.d.ts → data-table-CfVXtCeG.d.ts} +4 -3
- package/dist/data-table.d.ts +1 -1
- package/dist/feedback/feedback-dialog.d.ts +10 -1
- package/dist/feedback/feedback-dialog.js +13 -3
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-thread.js +31 -12
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/de-informal.d.ts +1 -1
- package/dist/i18n/locales/de.d.ts +1 -1
- package/dist/i18n/locales/de.js +1 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/es.js +1 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/fr.js +1 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/hu.js +1 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/it.js +1 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/i18n/locales/zh.js +1 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-Mz8zZHIF.d.ts → kit-labels-BHfRBbHs.d.ts} +5 -1
- package/dist/lib/document-navigation.d.ts +15 -0
- package/dist/lib/document-navigation.js +13 -0
- package/dist/lib/document-navigation.js.map +1 -0
- package/dist/lib/strip-fade.d.ts +31 -0
- package/dist/lib/strip-fade.js +54 -0
- package/dist/lib/strip-fade.js.map +1 -0
- package/dist/rhf/fields.d.ts +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf/form.js +1 -1
- package/dist/rhf/form.js.map +1 -1
- package/dist/rhf.d.ts +1 -1
- package/dist/shell/app-shell.d.ts +11 -2
- package/dist/shell/app-shell.js +20 -4
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard/stepper-nav.js +14 -2
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +7 -4
- package/src/components/alert-banner.tsx +10 -2
- package/src/components/amount-input.tsx +18 -4
- package/src/components/autocomplete.tsx +10 -1
- package/src/components/bulk-action-bar.tsx +12 -3
- package/src/components/calendar-heatmap.tsx +10 -5
- package/src/components/data-table-filter-popover.tsx +6 -3
- package/src/components/data-table.tsx +21 -7
- package/src/components/error-boundary.tsx +2 -1
- package/src/components/field.tsx +4 -1
- package/src/components/image-grid.tsx +30 -2
- package/src/components/measured-grid.tsx +3 -2
- package/src/components/money-field.tsx +75 -0
- package/src/components/multi-select.tsx +3 -0
- package/src/components/number-field.tsx +14 -0
- package/src/components/number-input.tsx +23 -4
- package/src/components/page-header.tsx +38 -3
- package/src/components/series-chart.tsx +56 -8
- package/src/components/stat-tile.tsx +8 -3
- package/src/components/text-link.tsx +2 -1
- package/src/components/treemap.tsx +30 -5
- package/src/components/ui.tsx +91 -11
- package/src/feedback/feedback-dialog.tsx +20 -2
- package/src/feedback/feedback-thread.tsx +36 -13
- package/src/i18n/locales/de.ts +1 -0
- package/src/i18n/locales/es.ts +1 -0
- package/src/i18n/locales/fr.ts +1 -0
- package/src/i18n/locales/hu.ts +1 -0
- package/src/i18n/locales/it.ts +1 -0
- package/src/i18n/locales/zh.ts +1 -0
- package/src/index.ts +1 -0
- package/src/lib/document-navigation.ts +17 -0
- package/src/lib/strip-fade.ts +82 -0
- package/src/rhf/form.tsx +3 -1
- package/src/shell/app-shell.tsx +34 -7
- package/src/wizard/stepper-nav.tsx +23 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/app-shell.tsx"],"sourcesContent":["import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Link, NavLink, matchPath, useLocation } from \"react-router\";\nimport { ChevronRight, PanelLeftClose, PanelLeftOpen } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { readStored, writeStored } from \"../lib/safe-storage\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { pickLinkRenderer } from \"../components/text-link\";\nimport { useAnchoredRect } from \"../hooks/use-anchored-rect\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { DEFAULT_APP_SHELL_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\n\nexport interface AppShellSubItem {\n to: string;\n label: string;\n icon: LucideIcon;\n /** Passed to NavLink's `end` (exact match). Defaults to true. */\n end?: boolean;\n}\n\nexport interface AppShellNavItem {\n to: string;\n label: string;\n /** Optional shorter label for the MOBILE BOTTOM BAR only (the sidebar, its\n * tooltips and any palette built on this list keep `label`).\n *\n * The bar divides the viewport into `nav.length` equal cells, so a label's room\n * shrinks with every entry added — at six entries on a 406 px phone that is ~67 px,\n * and \"Import & Export\" wrapped to two lines and pushed the row taller than its\n * neighbours (keksdose feedback live #210). A per-item override is better than\n * shortening the real label, which the sidebar has ample room for and which is the\n * name the rest of the product uses. */\n shortLabel?: string;\n icon: LucideIcon;\n /** Passed to NavLink's `end` (exact match). Defaults to true. */\n end?: boolean;\n /** Optional `data-tour` value rendered on this item's link (both the desktop\n * sidebar and the mobile bottom bar), so a guided tour can spotlight ONE nav\n * entry precisely instead of matching by href (feedback #338). */\n dataTour?: string;\n /** Optional sub-items. When present, hovering (or focusing) the item opens a\n * flyout to the right of the sidebar with a header (this item's label) and the\n * sub-items as icon + title links. Works collapsed or expanded; the mobile\n * bottom bar ignores sub-items and links to `to`. */\n items?: AppShellSubItem[];\n}\n\n/**\n * One entry of the sidebar's bottom area ({@link AppShellProps.sidebarFooterItems}) —\n * the version link and the legal links kastlan hand-built in `sidebarFooter`.\n *\n * - `link` (default): one link. Expanded it shows `icon` + `text` (default `label`),\n * with `hint` as a tooltip if given; collapsed it is the icon alone, named and\n * tooltipped by `label`.\n * - `links`: a small group — the legal links. Expanded, a `<nav>` named `label` holding\n * the links as a wrapping row of small text; collapsed, ONE icon link to `to` (default\n * the first link's), named and tooltipped by `label`, since a column of icons for\n * \"Imprint\", \"Privacy\" and \"Terms\" would say nothing an icon can.\n */\nexport type AppShellSidebarFooterItem =\n | {\n kind?: \"link\";\n key: string;\n to: string;\n icon: LucideIcon;\n /** The link's name, and the tooltip when collapsed (\"What's new · v1.4.0\"). */\n label: string;\n /** Visible text when expanded (\"Kastlan v1.4.0\"). Default `label`. */\n text?: ReactNode;\n /** A tooltip on the expanded link (\"What's new\"). */\n hint?: string;\n /** Leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`. */\n external?: boolean;\n }\n | {\n kind: \"links\";\n key: string;\n icon: LucideIcon;\n /** The group's name — its `<nav>` label, and the collapsed icon's name (\"Legal\"). */\n label: string;\n /** The collapsed icon's target. Default the first link's `to`. */\n to?: string;\n links: { to: string; label: string; external?: boolean }[];\n };\n\n/**\n * Exported and `<div>`-shaped. This is the outermost element of every consuming app, so\n * there is nothing above it to hang an id, a landmark label or a `data-tour` anchor on —\n * and nothing a consumer can wrap it in either, since the root owns the `h-dvh` and\n * `overflow-hidden` the whole layout is built on (audit §api-design).\n */\nexport interface AppShellProps extends ComponentPropsWithoutRef<\"div\"> {\n /** Navigation entries — shared by the desktop sidebar and mobile bottom bar. */\n nav: AppShellNavItem[];\n /** The composed top bar (e.g. the shared TopBar with app-owned actions). */\n topBar: ReactNode;\n /** Main content — typically the router `<Outlet />`. */\n children: ReactNode;\n /** Desktop-only footer rendered below the content (hidden on mobile). */\n footer?: ReactNode;\n /** Extra sidebar content above the collapse toggle (e.g. a version link).\n * Receives the collapsed state so it can render compact vs. full. */\n sidebarFooter?: (collapsed: boolean) => ReactNode;\n /** The sidebar's bottom area as data — a version link, the legal links — drawn full\n * when expanded and as icon + tooltip when collapsed. Rendered above\n * `sidebarFooter`, which stays for anything the data cannot describe. */\n sidebarFooterItems?: AppShellSidebarFooterItem[];\n /** The router link for `sidebarFooterItems`. Default: the provider's\n * `linkComponent`, else react-router's `Link`. */\n renderLink?: KitLinkComponent;\n /** localStorage key for the persisted collapse state. */\n collapseStorageKey?: string;\n /** Default: `appShell.collapse` from the {@link UiKitProvider}, else English. */\n collapseLabel?: string;\n /** Default: `appShell.expand` from the {@link UiKitProvider}, else English. */\n expandLabel?: string;\n /** How an entry's `items` are revealed in the EXPANDED desktop sidebar.\n *\n * - `\"flyout\"` (default) — a panel beside the sidebar on hover/focus. Compact: the\n * sidebar stays one row per group however many pages a group holds.\n * - `\"inline\"` — a disclosure under the entry, the pattern of MUI's nested List and\n * most documentation sites. Every page is visible at once, and the group holding\n * the current page opens on its own, so the reader can see where they are without\n * hovering anything.\n *\n * The icon-only (collapsed) sidebar always uses the flyout: an indented list has no\n * room there. The mobile bottom bar ignores `items` in both modes. */\n subNav?: \"flyout\" | \"inline\";\n /** Accessible name of the inline disclosure button, given the group's label.\n * Only used with `subNav=\"inline\"`. Its expanded state is carried by\n * `aria-expanded`, so one wording serves both directions. Default:\n * `appShell.toggleGroup` from the {@link UiKitProvider}, else English. */\n toggleGroupLabel?: (groupLabel: string) => string;\n /** Below `md`: show the current group's `items` as a scrollable row above the\n * bottom bar — the phone's counterpart of the sidebar's second level. Default true;\n * `false` keeps the single bar, where a group entry only links to its own `to`. */\n mobileSubNav?: boolean;\n /**\n * Lay the shell out inside its PARENT'S box instead of the viewport: `h-full` rather\n * than `h-dvh`, its own scroll container at every width, a bottom bar pinned to the\n * shell rather than the window, `--app-nav-h` published on the shell alone, and no\n * persisted collapse state unless `collapseStorageKey` is passed. For an app preview,\n * a device frame, a docs example.\n *\n * Implied for a shell rendered inside another AppShell, which is the case this\n * exists for: the inner one used to publish its own nav height on `<html>`, fight\n * the outer one over it, and remove it on unmount from under the outer app.\n */\n embedded?: boolean;\n}\n\n/** True below an AppShell — how a nested shell knows it is one. */\nconst AppShellNesting = createContext(false);\n\n/**\n * The bottom nav's own height, published as `--app-nav-h` so anything that has to sit\n * ON it can stop guessing.\n *\n * Keksdose live #314 is what this is for. Its rework — *\"Stick it to the bottom\n * touching the bottom icon bar with budget, accounts, … Currently there is a small gap\n * which confuses\"* — was a receipt footer pinned at `bottom-20`, because `<main>`\n * reserves `pb-20` for this nav and 20 looked like the nav's height. It is not: the\n * padding is generous CLEARANCE, and the nav is content-sized (icon + label + `py-2`,\n * about 56px), so the footer floated ~24px above the bar.\n *\n * Measured rather than named, because the height is not a constant anyone owns: it is\n * an icon, a translated label that can wrap or truncate, and whatever the platform does\n * with the safe-area inset. A hard-coded `bottom-14` would be the same guess one number\n * lower, and it would be wrong again the first time a label needed two lines.\n *\n * `0px` when the nav is not rendered — above `md` it is `display:none`, which observes\n * as a zero box — so a consumer can write `bottom-[var(--app-nav-h,0px)]` once and get\n * the right answer at both widths without a breakpoint of its own.\n */\nfunction useNavHeightVar(\n ref: React.RefObject<HTMLElement | null>,\n shellRef: React.RefObject<HTMLElement | null>,\n embedded: boolean,\n): void {\n // Re-run when the breakpoint flips. A ResizeObserver SKIPS an element that is not\n // being rendered, so the nav going `display:none` above `md` fires no callback and\n // would leave the last phone height published — a desktop footer would then sit 56px\n // off the bottom after a live resize. The query is the one the nav's own `md:hidden`\n // compiles to, so the two cannot disagree about where the boundary is.\n const isMdUp = useMediaQuery(\"(min-width: 768px)\", false);\n useEffect(() => {\n const node = ref.current;\n const shell = shellRef.current;\n // Always on the shell's own root, so everything inside it — a nested shell's\n // content included — reads the nearest bar's height. On <html> as well only for\n // the OUTERMOST shell, for what it portals to <body> (a sheet docking on the bar).\n // The DOM check backs up the context for a shell mounted in a separate React root.\n const global = !embedded && !shell?.parentElement?.closest(\"[data-app-shell]\");\n const targets = [shell, global ? document.documentElement : null].filter(\n (el): el is HTMLElement => el !== null,\n );\n // `getBoundingClientRect().height`, rounded DOWN — not `offsetHeight`, and not\n // ceil. Measured on the running app at 406x816: the bar is **55.5px** tall, and\n // both `offsetHeight` and `Math.ceil` answer 56. That is why live #314 round four\n // was *\"No change. Still a pixel inbetween\"* — the previous fix was arithmetically\n // a no-op on the very value it was meant to correct.\n //\n // 56 is the wrong side. A sticky element with `bottom: X` puts its bottom edge X\n // above the viewport bottom, and the bar's TOP edge is at its own height: so\n // X = 56 lands the footer at y=760.0 against a bar starting at y=760.5, i.e. half\n // a CSS pixel of page showing through — about 1.4 device pixels on his phone,\n // which is exactly the hairline in the screenshot.\n //\n // The two errors are not symmetric, and I had them backwards the first time.\n // Too SMALL tucks the sticky element under a bar that is opaque and painted above\n // it (`z-30` against `z-10`): invisible. Too LARGE opens the seam. So floor, and\n // consumers subtract a further pixel (see `invoice-line-totals.tsx`) for the case\n // where the height lands on a whole pixel and floor leaves no overlap at all.\n const publish = () => {\n const value = `${node ? Math.floor(node.getBoundingClientRect().height) : 0}px`;\n for (const el of targets) el.style.setProperty(\"--app-nav-h\", value);\n };\n publish();\n // The observer is the refinement, not the mechanism: `publish()` above is already\n // right for a static nav, and jsdom has no ResizeObserver unless a test stubs one.\n // ONE cleanup for every branch, though — an early `return` on the no-observer path\n // left the variable behind on unmount, which a stale `bottom:` offset on whatever\n // rendered next would have inherited.\n const ro = node && typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(publish) : null;\n if (ro && node) ro.observe(node);\n return () => {\n ro?.disconnect();\n for (const el of targets) el.style.removeProperty(\"--app-nav-h\");\n };\n }, [ref, shellRef, embedded, isMdUp]);\n}\n\n/**\n * The responsive application shell: a top bar, a collapsible desktop sidebar\n * (icon-only when collapsed, with tooltips), a mobile bottom nav bar, the main\n * content area, and an optional desktop footer. Router-aware via react-router\n * `NavLink`; domain-free — nav items, brand, footer and the top bar's actions\n * are all supplied by the app.\n */\nexport function AppShell({\n nav,\n topBar,\n children,\n footer,\n sidebarFooter,\n sidebarFooterItems,\n renderLink,\n collapseStorageKey: collapseStorageKeyProp,\n collapseLabel,\n expandLabel,\n subNav = \"flyout\",\n mobileSubNav = true,\n toggleGroupLabel,\n embedded: embeddedProp,\n className,\n ...rest\n}: AppShellProps) {\n const embedded = useContext(AppShellNesting) || !!embeddedProp;\n // An embedded shell sharing the default key would overwrite the outer app's sidebar\n // preference with its own — so it persists only when given a key of its own.\n const collapseStorageKey =\n collapseStorageKeyProp ?? (embedded ? null : \"appLayout.sidebarCollapsed\");\n // The three props are per-shell overrides of the provider's `appShell`; one left\n // undefined falls through to the provider rather than to English.\n const labels = useKitLabels(\"appShell\", DEFAULT_APP_SHELL_LABELS, {\n collapse: collapseLabel,\n expand: expandLabel,\n toggleGroup: toggleGroupLabel,\n });\n // Through the guarded helpers, not `window.localStorage` directly. The read runs\n // inside a `useState` initialiser — i.e. during render of the top-level shell — and\n // `localStorage` THROWS where site data is blocked (Safari private browsing, a\n // partitioned webview). Unguarded, that was not a lost sidebar preference: nothing\n // in the application mounted at all.\n const [collapsed, setCollapsed] = useState(\n () => collapseStorageKey !== null && readStored(collapseStorageKey) === \"1\",\n );\n // The inline sidebar is an ACCORDION: one group open at a time, keyed by its `to`.\n // Held here rather than per group so opening one can close the other — two lists\n // open at once pushed every entry below them off a laptop screen.\n const [openGroup, setOpenGroup] = useState<string | null>(null);\n // Publishes `--app-nav-h` for anything that has to sit on the bottom bar.\n const navRef = useRef<HTMLDivElement>(null);\n const rootRef = useRef<HTMLDivElement>(null);\n useNavHeightVar(navRef, rootRef, embedded);\n useEffect(() => {\n if (collapseStorageKey !== null) writeStored(collapseStorageKey, collapsed ? \"1\" : \"0\");\n }, [collapsed, collapseStorageKey]);\n\n return (\n // The caller's attributes go on the shell's own root, never on the `data-tour=\"nav\"`\n // wrappers below — those are the kit's anchors for its own guided tour (#313/#322),\n // and a second element answering to the same selector is a tour that highlights the\n // wrong thing or nothing at all.\n <AppShellNesting.Provider value={true}>\n <div\n {...rest}\n ref={rootRef}\n data-app-shell=\"\"\n className={cn(\n \"relative flex flex-col\",\n embedded\n ? // `contain: layout` makes this box the containing block for the `fixed`\n // bottom bar below, so it pins to the shell rather than to the window.\n \"h-full min-h-0 overflow-hidden [contain:layout]\"\n : \"min-h-screen md:h-dvh md:overflow-hidden\",\n className,\n )}\n >\n {topBar}\n <div className=\"flex flex-1 min-h-0\">\n <aside\n className={cn(\n // `border-e`: the sidebar sits at the START, which is the right in RTL.\n \"hidden md:flex md:flex-col border-e border-[var(--border)] bg-[var(--bg-surface)] transition-[width] duration-200 ease-out overflow-hidden\",\n // Embedded, the row it sits in is already the shell's height; the sticky\n // offsets below are measured against the window's top bar.\n embedded ? \"md:self-stretch\" : \"md:sticky md:top-12 md:self-start md:h-[calc(100vh-3rem)]\",\n collapsed ? \"md:w-14\" : \"md:w-60\",\n )}\n >\n {/* The `<nav>` is flex-1 so it fills the sidebar's height (with empty\n space below the items); the data-tour marker goes on the INNER,\n fit-content wrapper so the guided-tour spotlight hugs the actual nav\n items instead of the whole tall column (feedback #322). The marker\n also tags the mobile bottom bar below; the tour targets\n `[data-tour=\"nav\"]` and resolves to whichever is visible (#313). */}\n <nav className=\"flex-1 px-2 py-3 overflow-y-auto overflow-x-hidden\">\n <div data-tour=\"nav\" className=\"space-y-1\">\n {nav.map((item) => (\n <SidebarNavItem\n key={item.to}\n item={item}\n collapsed={collapsed}\n inline={subNav === \"inline\"}\n toggleGroupLabel={labels.toggleGroup}\n openGroup={openGroup}\n setOpenGroup={setOpenGroup}\n />\n ))}\n </div>\n </nav>\n {!!sidebarFooterItems?.length && (\n <SidebarFooterItems items={sidebarFooterItems} collapsed={collapsed} renderLink={renderLink} />\n )}\n {sidebarFooter?.(collapsed)}\n <div className=\"border-t border-[var(--border)] p-2\">\n {collapsed ? (\n <Tooltip label={labels.expand} side=\"end\" portal className=\"block\">\n <button\n type=\"button\"\n onClick={() => setCollapsed(false)}\n aria-label={labels.expand}\n className=\"flex w-full items-center justify-center min-h-9 rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <PanelLeftOpen className=\"size-4 shrink-0 rtl:-scale-x-100\" />\n </button>\n </Tooltip>\n ) : (\n <button\n type=\"button\"\n onClick={() => setCollapsed(true)}\n aria-label={labels.collapse}\n className=\"flex w-full items-center gap-3 min-h-9 px-3 py-2 rounded-md text-sm text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <PanelLeftClose className=\"size-4 shrink-0 rtl:-scale-x-100\" />\n <span className=\"truncate\">{labels.collapse}</span>\n </button>\n )}\n </div>\n </aside>\n\n {/* Content column beside the sidebar. main grows to fill so the footer\n sits at the bottom — at the viewport edge when content is short,\n beside the sidebar rather than under it. */}\n <div className=\"flex flex-1 flex-col min-w-0 md:min-h-0\">\n {/* `scrollbar-gutter: stable` reserves the scrollbar's track whether or\n not it is currently needed. Without it this element — the app's only\n scroll container from md up — narrows its own client box by the\n scrollbar width the moment a page's content outgrows it, and widens\n it again when the next page fits. Every centred `mx-auto` container\n inside then jumps sideways by half a scrollbar, and any fluid-width\n content changes width outright. Keksdose feedback #403 caught it as\n \"slight width increase/decrease\" when switching to the notifications\n settings section and back: that section is the only one tall enough\n to scroll. Platform-dependent, which is why it is easy to miss —\n overlay scrollbars (macOS, most Linux builds) take no layout space,\n classic ones (Windows) take ~15px.\n\n `both-edges`, not the bare `stable`: a one-sided reservation keeps the\n width stable but moves the middle. On a page short enough not to\n scroll, nothing is painted into the reserved track, so every centred\n `mx-auto` container sits half a scrollbar left of the optical centre\n and every full-bleed child stops ~15px short on the right. Keksdose\n feedback #491 (/settings#data) and #498 (/budgets) both reported it as\n \"the left boundary looks smaller / cut off compared to the right\" on\n the one element per page whose frame is a saturated colour — the rose\n destructive-action cards. Reserving the track on both edges keeps the\n content centred whether or not the scrollbar is showing. */}\n {/* `relative` makes <main> the containing block for everything absolutely\n positioned inside it. Tailwind's `sr-only` is `position:absolute`; with no\n positioned ancestor it resolves against the INITIAL containing block, is\n laid out at its offset from the top of the DOCUMENT, and grows\n `<html>`'s scroll height past the viewport — a second scrollbar beside this\n one that scrolls into empty space. Guarding it per component (see\n sr-only-containment.test) could never be complete: any page's own\n `sr-only` span re-opened it. Here it is closed for every child at once. */}\n <main\n className={cn(\n \"relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+1.5rem)] md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]\",\n // Embedded there is no document scroll to lean on below `md` either.\n embedded && \"min-h-0 overflow-y-auto\",\n )}\n >\n {children}\n </main>\n {footer}\n </div>\n </div>\n\n {/* The phone navigation: the group bar, and above it — when the current page\n belongs to a group with pages of its own — the row of those pages. The pair\n is the phone's version of the sidebar's two levels; without the upper row a\n group's `items` were simply unreachable below `md`. ONE measured box, so\n `--app-nav-h` covers both rows and whatever docks on the bar sits on the\n whole of it. */}\n <div\n ref={navRef}\n className=\"md:hidden fixed bottom-0 inset-x-0 z-30 border-t border-[var(--border)] bg-[var(--bg-surface)]\"\n >\n {mobileSubNav && <MobileSubNav nav={nav} />}\n <nav\n data-tour=\"nav\"\n className=\"grid\"\n style={{ gridTemplateColumns: `repeat(${nav.length}, minmax(0, 1fr))` }}\n >\n {nav.map((item) => (\n <MobileNavItem key={item.to} item={item} />\n ))}\n </nav>\n </div>\n </div>\n </AppShellNesting.Provider>\n );\n}\n\nconst routerLink: KitLinkComponent = ({ href, ...p }) => <Link to={href} {...p} />;\nconst plainLink: KitLinkComponent = ({ children, ...p }) => <a {...p}>{children}</a>;\n\nconst FOOTER_ICON_LINK =\n \"flex min-h-8 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\";\n\n/** Calls the link renderer as a component, so a router link's hooks are its own. */\nfunction RenderedLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {\n return render(props);\n}\n\n/** The sidebar's bottom area, from {@link AppShellSidebarFooterItem}s. */\nfunction SidebarFooterItems({\n items,\n collapsed,\n renderLink,\n}: {\n items: AppShellSidebarFooterItem[];\n collapsed: boolean;\n renderLink?: KitLinkComponent;\n}) {\n const kitLink = useKitLink();\n const linkTo = (to: string, external: boolean | undefined, { children, ...props }: Omit<KitLinkProps, \"href\">) => {\n // The kit's one link rule (`pickLinkRenderer`): an external `to` or an in-page\n // `#anchor` is a plain `<a>` even without `external`; only `external` opens a tab.\n const InApp = pickLinkRenderer(renderLink, kitLink ?? routerLink, to) ?? plainLink;\n return external ? (\n <a href={to} target=\"_blank\" rel=\"noopener noreferrer\" {...props}>\n {children}\n </a>\n ) : (\n <RenderedLink render={InApp} href={to} {...props}>\n {children}\n </RenderedLink>\n );\n };\n\n return (\n <div data-slot=\"sidebar-footer-items\" className={cn(\"px-2 py-1\", collapsed ? \"space-y-1\" : \"space-y-1.5\")}>\n {items.map((item) => {\n if (collapsed) {\n const to = item.kind === \"links\" ? (item.to ?? item.links[0]?.to) : item.to;\n if (!to) return null;\n const external = item.kind === \"links\" ? item.links.find((l) => l.to === to)?.external : item.external;\n return (\n <Tooltip key={item.key} label={item.label} side=\"end\" portal className=\"block\">\n {linkTo(to, external, {\n \"aria-label\": item.label,\n className: FOOTER_ICON_LINK,\n children: <item.icon className=\"size-4\" aria-hidden />,\n })}\n </Tooltip>\n );\n }\n if (item.kind === \"links\") {\n return (\n <nav\n key={item.key}\n aria-label={item.label}\n className=\"flex flex-wrap justify-center gap-x-3 gap-y-0.5 text-[11px] text-[var(--text-muted)]\"\n >\n {item.links.map((l) => (\n <span key={l.to}>\n {linkTo(l.to, l.external, {\n className: \"rounded-sm hover:text-[var(--text-secondary)] hover:underline\",\n children: l.label,\n })}\n </span>\n ))}\n </nav>\n );\n }\n const link = linkTo(item.to, item.external, {\n className: cn(FOOTER_ICON_LINK, \"gap-1.5 text-xs font-medium\"),\n children: (\n <>\n <item.icon className=\"size-3.5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\">{item.text ?? item.label}</span>\n </>\n ),\n });\n return item.hint ? (\n <Tooltip key={item.key} label={item.hint} side=\"top\" portal className=\"block\">\n {link}\n </Tooltip>\n ) : (\n <div key={item.key}>{link}</div>\n );\n })}\n </div>\n );\n}\n\n/** One cell of the phone's group bar. Marked while the group's own page OR any of its\n * pages is current — NavLink alone only knows its own `to`, which left the bar with\n * nothing marked on every sub-page (the same defect the sidebar had). */\nfunction MobileNavItem({ item }: { item: AppShellNavItem }) {\n const subActive = useSubItemActive(item);\n return (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n className={({ isActive }) =>\n // The active page needs a clear marker, not just a subtle text\n // shade (feedback #327): brand-accent icon + label, bolder weight,\n // and a top accent bar spanning the cell so it reads at a glance.\n cn(\n \"relative flex flex-col items-center justify-center py-2 text-[11px] gap-0.5 transition-colors\",\n \"before:absolute before:content-[''] before:inset-x-4 before:top-0 before:h-0.5 before:rounded-full before:transition-colors\",\n isActive || subActive\n ? \"font-semibold text-[var(--brand)] before:bg-[var(--brand)]\"\n : \"text-[var(--text-muted)] before:bg-transparent\",\n )\n }\n >\n <span className=\"relative\">\n <item.icon className=\"size-5\" />\n </span>\n {/* One line, always. `truncate` is the backstop for the case a\n `shortLabel` was not supplied (or a translation is longer than its\n author expected): an ellipsis in one cell is a far smaller problem\n than a bar whose rows are different heights. */}\n <span className=\"max-w-full truncate px-0.5\">{item.shortLabel ?? item.label}</span>\n </NavLink>\n );\n}\n\n/**\n * The upper row of the phone navigation: the pages of the group the reader is in, as\n * a sideways-scrolling row of pills. Rendered only while such a group is current —\n * on a page outside every group the bar stays one row, rather than showing an empty\n * shelf or guessing a group.\n *\n * It slides in over 200ms (fade only under reduced motion) and wraps onto more rows\n * rather than scrolling, so every page of the group is in view at once.\n */\nfunction MobileSubNav({ nav }: { nav: AppShellNavItem[] }) {\n const { pathname } = useLocation();\n const group = nav.find(\n (item) =>\n !!item.items?.length &&\n (matchPath({ path: item.to, end: item.end ?? true }, pathname) ||\n item.items.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname))),\n );\n\n if (!group) return null;\n return (\n <nav\n aria-label={group.label}\n // Keyed by group so switching groups replays the entrance.\n key={group.to}\n className=\"animate-subnav border-b border-[var(--border)]\"\n >\n <ul\n // Wraps onto as many rows as the group needs rather than scrolling sideways: a\n // sideways row hides pages past the edge, and every page of the group in view\n // is the point of the row. The bar grows with it — `--app-nav-h` measures it.\n className=\"flex flex-wrap gap-1.5 px-3 py-2\"\n >\n {group.items!.map((sub) => (\n <li key={sub.to} className=\"shrink-0\">\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n className={({ isActive }) =>\n cn(\n \"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors\",\n isActive\n ? \"border-[var(--brand)] bg-[var(--brand)] font-medium text-[var(--brand-contrast)]\"\n : \"border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-3.5 shrink-0\" aria-hidden />\n {sub.label}\n </NavLink>\n </li>\n ))}\n </ul>\n </nav>\n );\n}\n\n/** `strong` is the current page; `trail` is the group that CONTAINS the current page\n * while the page itself is shown elsewhere (the inline list below it). */\nconst navLinkClass = (collapsed: boolean, forceActive: boolean, trail = false) =>\n ({ isActive }: { isActive: boolean }) =>\n `flex items-center gap-3 min-h-9 ${collapsed ? \"justify-center px-2\" : \"px-3\"} py-2 rounded-md text-sm transition-colors duration-150 ${\n isActive || (forceActive && !trail)\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : forceActive\n ? \"font-medium text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\"\n }`;\n\n/** Whether any of an entry's sub-items is the current route. NavLink only knows about\n * its OWN `to`, so without this a group whose pages live at sibling paths\n * (`/fields`, `/numbers`, …) lost its highlight the moment the reader left the one\n * page the group linked to — the sidebar then marked nothing at all, and the only\n * \"you are here\" left was inside a flyout that is closed unless hovered. */\nfunction useSubItemActive(item: AppShellNavItem): boolean {\n const { pathname } = useLocation();\n return !!item.items?.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname));\n}\n\n/** A single desktop sidebar entry. Plain link (with a tooltip when collapsed) —\n * unless it has sub-items, in which case hovering/focusing opens a flyout, or (with\n * `inline`) a disclosure lists them underneath. */\nfunction SidebarNavItem({\n item,\n collapsed,\n inline,\n toggleGroupLabel,\n openGroup,\n setOpenGroup,\n}: {\n item: AppShellNavItem;\n collapsed: boolean;\n inline: boolean;\n toggleGroupLabel: (groupLabel: string) => string;\n openGroup: string | null;\n setOpenGroup: (to: string | null) => void;\n}) {\n const hasSub = !!item.items?.length;\n const subActive = useSubItemActive(item);\n\n // In the EXPANDED sidebar a group is one component in both styles, so switching\n // between them is a transition of the same element (the page list folding away or\n // unfolding) rather than one tree replaced by another. The icon-only sidebar keeps\n // the plain flyout below: an indented list has no room there.\n if (hasSub && !collapsed) {\n return (\n <SidebarGroup\n item={item}\n subActive={subActive}\n inline={inline}\n toggleGroupLabel={toggleGroupLabel}\n open={openGroup === item.to}\n setOpenGroup={setOpenGroup}\n />\n );\n }\n\n const link = (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n className={navLinkClass(collapsed, subActive)}\n aria-haspopup={hasSub ? \"menu\" : undefined}\n >\n <span className=\"relative shrink-0\">\n <item.icon className=\"size-4\" />\n </span>\n {/* Wraps rather than truncates: in the sidebar a label cut to \"Checkbox, switch\n & sl…\" hides the one word that tells two pages apart, and there is height\n to spare. (The phone bar keeps `truncate` — its cells must stay one row.) */}\n {!collapsed && <span className=\"min-w-0 flex-1 break-words leading-snug\">{item.label}</span>}\n {!collapsed && hasSub && <ChevronRight className=\"size-3.5 shrink-0 opacity-60 rtl:-scale-x-100\" />}\n </NavLink>\n );\n\n if (hasSub) return <SidebarFlyout item={item}>{link}</SidebarFlyout>;\n return collapsed ? (\n <Tooltip label={item.label} side=\"end\" portal className=\"block\">\n {link}\n </Tooltip>\n ) : (\n link\n );\n}\n\n/**\n * An entry with sub-items in the expanded sidebar.\n *\n * - `inline`: the entry's own link (its overview page) plus a chevron button that\n * shows or hides the sub-items indented beneath it. Two controls rather than one\n * because they do two different things — a row that both navigated AND toggled\n * would make \"open the overview\" and \"peek at the pages\" the same click.\n * - flyout: the link alone, with the sub-items in a panel beside the sidebar on\n * hover/focus. The inline list is still rendered, folded shut, so that switching\n * styles animates it rather than swapping it.\n *\n * THE FOLD is a `grid-template-rows` transition from `0fr` to `1fr` — the one way to\n * animate to a content-sized height without measuring it, so a translated label that\n * wraps to two lines unfolds exactly as far as it needs. `visibility` rides the same\n * transition so a shut list drops out of the accessibility tree only once it has\n * finished closing, and `inert` takes its links out of the tab order immediately.\n */\nfunction SidebarGroup({\n item,\n subActive,\n inline,\n toggleGroupLabel,\n open,\n setOpenGroup,\n}: {\n item: AppShellNavItem;\n subActive: boolean;\n inline: boolean;\n toggleGroupLabel: (groupLabel: string) => string;\n /** Whether this is the sidebar's one open group (see AppShell's `openGroup`). */\n open: boolean;\n setOpenGroup: (to: string | null) => void;\n}) {\n const listId = useId();\n const { pathname } = useLocation();\n const onOwnPage = !!matchPath({ path: item.to, end: item.end ?? true }, pathname);\n // Opening follows the reader: arriving on this group's overview or one of its pages\n // (by link, by the pager, by Back) opens it — and, the sidebar holding ONE open\n // group, closes whichever was open before. Both folds run at once, so the eye sees\n // one list hand over to the other. Run in an effect after mount, so the current\n // group UNFOLDS into view on load rather than appearing already open.\n const here = subActive || onOwnPage;\n useEffect(() => {\n if (here) setOpenGroup(item.to);\n }, [here, item.to, setOpenGroup]);\n const expanded = inline && open;\n\n const link = (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n aria-haspopup={inline ? undefined : \"menu\"}\n // `trail` (inline only): while a page of the group is current, the group row is\n // the breadcrumb, not the destination — the page's own row below carries the\n // strong marker, and two inverse rows would say \"you are here\" twice. In the\n // flyout style the pages are out of sight, so the group row IS the marker.\n className={(state) => cn(navLinkClass(false, subActive, inline)(state), \"min-w-0 flex-1\")}\n >\n <span className=\"relative shrink-0\">\n <item.icon className=\"size-4\" />\n </span>\n <span className=\"min-w-0 flex-1 break-words leading-snug\">{item.label}</span>\n {!inline && <ChevronRight className=\"size-3.5 shrink-0 opacity-60 rtl:-scale-x-100\" />}\n </NavLink>\n );\n\n return (\n <div>\n {inline ? (\n <div className=\"flex items-center gap-0.5\">\n {link}\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={listId}\n aria-label={toggleGroupLabel(item.label)}\n onClick={() => setOpenGroup(open ? null : item.to)}\n className=\"flex size-9 shrink-0 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <ChevronRight\n aria-hidden\n className={cn(\n \"size-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none rtl:-scale-x-100\",\n // Under dir=\"rtl\" the chevron is MIRRORED first (scale applies before rotate), so\n // it starts pointing left and a +90° turn would point it UP — hence −90° there.\n open && \"rotate-90 rtl:-rotate-90\",\n )}\n />\n </button>\n </div>\n ) : (\n <SidebarFlyout item={item}>{link}</SidebarFlyout>\n )}\n <div\n id={listId}\n inert={!expanded}\n className=\"grid transition-[grid-template-rows,visibility] duration-200 ease-out motion-reduce:transition-none\"\n style={{\n gridTemplateRows: expanded ? \"1fr\" : \"0fr\",\n visibility: expanded ? \"visible\" : \"hidden\",\n }}\n >\n <div\n className={cn(\n \"min-h-0 overflow-hidden transition-opacity duration-200 ease-out motion-reduce:transition-none\",\n expanded ? \"opacity-100\" : \"opacity-0\",\n )}\n >\n {/* The rule and indent live on the list INSIDE the clipping box: margin or\n padding on the element whose row is collapsing to 0fr would hold it open\n by that much. */}\n <ul className=\"ms-[1.3rem] mt-0.5 space-y-0.5 border-s border-[var(--border)] ps-2\">\n {item.items!.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n className={({ isActive }) =>\n cn(\n \"flex min-h-8 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors\",\n isActive\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-4 shrink-0 opacity-70\" aria-hidden />\n <span className=\"min-w-0 break-words leading-snug\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n </div>\n </div>\n </div>\n );\n}\n\n// Only ONE sidebar flyout should be on screen at a time. Each open flyout parks its\n// \"close now\" here so the next item to open can dismiss it immediately, instead of\n// leaving the previous panel up for the 140ms travel delay while the cursor moves\n// down the nav list — which showed two panels at once.\nlet activeFlyoutClose: (() => void) | null = null;\n\n/**\n * Wraps a nav item that has sub-items: on hover or keyboard focus it opens a\n * portalled flyout to the right of the sidebar (portalled so the sidebar's\n * `overflow-hidden` can't clip it) with the item's label as a header and each\n * sub-item as an icon + title link. A short close delay lets the cursor travel\n * from the item onto the panel; Escape / outside-move close it.\n */\nfunction SidebarFlyout({ item, children }: { item: AppShellNavItem; children: ReactNode }) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const [open, setOpen] = useState(false);\n // The flyout is portalled out of the subtree whose `dir` it inherited; read on open.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n\n // Anchor the flyout to the item's END edge — its right, or its left in RTL, where\n // the sidebar sits on the right of the window. The hook re-measures on\n // scroll/resize (the nav list can scroll).\n const rect = useAnchoredRect(wrapperRef, open);\n const pos = rect\n ? dir === \"rtl\"\n ? { top: rect.top, right: window.innerWidth - rect.left }\n : { top: rect.top, left: rect.right }\n : null;\n\n const cancelClose = useCallback(() => {\n if (closeTimer.current) {\n clearTimeout(closeTimer.current);\n closeTimer.current = null;\n }\n }, []);\n // Stable identity: the coordinator above holds this so a sibling item can close\n // THIS flyout without waiting out its travel delay.\n const closeNow = useCallback(() => {\n cancelClose();\n setOpen(false);\n }, [cancelClose]);\n const openMenu = useCallback(() => {\n cancelClose();\n if (activeFlyoutClose && activeFlyoutClose !== closeNow) activeFlyoutClose();\n activeFlyoutClose = closeNow;\n setDir(dirOf(wrapperRef.current));\n setOpen(true);\n }, [cancelClose, closeNow]);\n const scheduleClose = useCallback(() => {\n cancelClose();\n closeTimer.current = setTimeout(() => {\n setOpen(false);\n if (activeFlyoutClose === closeNow) activeFlyoutClose = null;\n }, 140);\n }, [cancelClose, closeNow]);\n\n // Close on Escape while open.\n useEscapeKey(() => setOpen(false), open);\n\n useEffect(\n () => () => {\n cancelClose();\n if (activeFlyoutClose === closeNow) activeFlyoutClose = null;\n },\n [cancelClose, closeNow],\n );\n\n return (\n // Not a control: hover and focus-within on the nav link inside. Focus (the\n // keyboard path) opens it exactly as hover does, Escape closes it.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- hover/focus-within wrapper around a real link\n <div\n ref={wrapperRef}\n className=\"relative\"\n onMouseEnter={openMenu}\n onMouseLeave={scheduleClose}\n onFocus={openMenu}\n onBlur={scheduleClose}\n >\n {children}\n {open &&\n pos &&\n createPortal(\n // Keeps the flyout open while the pointer crosses into it; keyboard users\n // reach it by focus, which the wrapper above already handles.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- pointer-only hover bridge\n <div\n dir={dir}\n style={{ position: \"fixed\", ...pos }}\n className=\"z-40 ps-1\"\n onMouseEnter={cancelClose}\n onMouseLeave={scheduleClose}\n >\n <div\n role=\"menu\"\n aria-label={item.label}\n className=\"animate-flyout min-w-52 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\"\n >\n <div className=\"border-b border-[var(--border)] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {item.label}\n </div>\n <ul className=\"py-1\">\n {item.items!.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n className={({ isActive }) =>\n cn(\n \"flex items-center gap-2.5 px-3 py-2 text-sm\",\n isActive\n ? \"bg-[var(--bg-active)] font-medium text-[var(--text-primary)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-4 shrink-0 text-[var(--text-placeholder)]\" />\n <span className=\"min-w-0 break-words leading-snug\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n </div>\n </div>,\n document.body,\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/app-shell.tsx"],"sourcesContent":["import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Link, NavLink, matchPath, useLocation } from \"react-router\";\nimport { ChevronRight, PanelLeftClose, PanelLeftOpen } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { scrollIntoStrip, useStripFade } from \"../lib/strip-fade\";\nimport { dirOf, type Direction } from \"../lib/direction\";\nimport { readStored, writeStored } from \"../lib/safe-storage\";\nimport { Tooltip } from \"../components/tooltip\";\nimport { pickLinkRenderer } from \"../components/text-link\";\nimport { useAnchoredRect } from \"../hooks/use-anchored-rect\";\nimport { useEscapeKey } from \"../hooks/use-dismiss\";\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { DEFAULT_APP_SHELL_LABELS, useKitLabels, useKitLink } from \"../i18n/kit-labels\";\nimport type { KitLinkComponent, KitLinkProps } from \"../i18n/kit-labels\";\n\nexport interface AppShellSubItem {\n to: string;\n label: string;\n icon: LucideIcon;\n /** Passed to NavLink's `end` (exact match). Defaults to true. */\n end?: boolean;\n}\n\nexport interface AppShellNavItem {\n to: string;\n label: string;\n /** Optional shorter label for the MOBILE BOTTOM BAR only (the sidebar, its\n * tooltips and any palette built on this list keep `label`).\n *\n * The bar divides the viewport into `nav.length` equal cells, so a label's room\n * shrinks with every entry added — at six entries on a 406 px phone that is ~67 px,\n * and \"Import & Export\" wrapped to two lines and pushed the row taller than its\n * neighbours (keksdose feedback live #210). A per-item override is better than\n * shortening the real label, which the sidebar has ample room for and which is the\n * name the rest of the product uses. */\n shortLabel?: string;\n icon: LucideIcon;\n /** Passed to NavLink's `end` (exact match). Defaults to true. */\n end?: boolean;\n /** Optional `data-tour` value rendered on this item's link (both the desktop\n * sidebar and the mobile bottom bar), so a guided tour can spotlight ONE nav\n * entry precisely instead of matching by href (feedback #338). */\n dataTour?: string;\n /** Optional sub-items. When present, hovering (or focusing) the item opens a\n * flyout to the right of the sidebar with a header (this item's label) and the\n * sub-items as icon + title links. Works collapsed or expanded; the mobile\n * bottom bar ignores sub-items and links to `to`. */\n items?: AppShellSubItem[];\n}\n\n/**\n * One entry of the sidebar's bottom area ({@link AppShellProps.sidebarFooterItems}) —\n * the version link and the legal links kastlan hand-built in `sidebarFooter`.\n *\n * - `link` (default): one link. Expanded it shows `icon` + `text` (default `label`),\n * with `hint` as a tooltip if given; collapsed it is the icon alone, named and\n * tooltipped by `label`.\n * - `links`: a small group — the legal links. Expanded, a `<nav>` named `label` holding\n * the links as a wrapping row of small text; collapsed, ONE icon link to `to` (default\n * the first link's), named and tooltipped by `label`, since a column of icons for\n * \"Imprint\", \"Privacy\" and \"Terms\" would say nothing an icon can.\n */\nexport type AppShellSidebarFooterItem =\n | {\n kind?: \"link\";\n key: string;\n to: string;\n icon: LucideIcon;\n /** The link's name, and the tooltip when collapsed (\"What's new · v1.4.0\"). */\n label: string;\n /** Visible text when expanded (\"Kastlan v1.4.0\"). Default `label`. */\n text?: ReactNode;\n /** A tooltip on the expanded link (\"What's new\"). */\n hint?: string;\n /** Leaves the app: a plain `<a target=\"_blank\" rel=\"noopener noreferrer\">`. */\n external?: boolean;\n }\n | {\n kind: \"links\";\n key: string;\n icon: LucideIcon;\n /** The group's name — its `<nav>` label, and the collapsed icon's name (\"Legal\"). */\n label: string;\n /** The collapsed icon's target. Default the first link's `to`. */\n to?: string;\n links: { to: string; label: string; external?: boolean }[];\n };\n\n/**\n * Exported and `<div>`-shaped. This is the outermost element of every consuming app, so\n * there is nothing above it to hang an id, a landmark label or a `data-tour` anchor on —\n * and nothing a consumer can wrap it in either, since the root owns the `h-dvh` and\n * `overflow-hidden` the whole layout is built on (audit §api-design).\n */\nexport interface AppShellProps extends ComponentPropsWithoutRef<\"div\"> {\n /** Navigation entries — shared by the desktop sidebar and mobile bottom bar. */\n nav: AppShellNavItem[];\n /** The composed top bar (e.g. the shared TopBar with app-owned actions). */\n topBar: ReactNode;\n /** Main content — typically the router `<Outlet />`. */\n children: ReactNode;\n /** Desktop-only footer rendered below the content (hidden on mobile). */\n footer?: ReactNode;\n /** Extra sidebar content above the collapse toggle (e.g. a version link).\n * Receives the collapsed state so it can render compact vs. full. */\n sidebarFooter?: (collapsed: boolean) => ReactNode;\n /** The sidebar's bottom area as data — a version link, the legal links — drawn full\n * when expanded and as icon + tooltip when collapsed. Rendered above\n * `sidebarFooter`, which stays for anything the data cannot describe. */\n sidebarFooterItems?: AppShellSidebarFooterItem[];\n /** The router link for `sidebarFooterItems`. Default: the provider's\n * `linkComponent`, else react-router's `Link`. */\n renderLink?: KitLinkComponent;\n /** localStorage key for the persisted collapse state. */\n collapseStorageKey?: string;\n /** Default: `appShell.collapse` from the {@link UiKitProvider}, else English. */\n collapseLabel?: string;\n /** Default: `appShell.expand` from the {@link UiKitProvider}, else English. */\n expandLabel?: string;\n /** How an entry's `items` are revealed in the EXPANDED desktop sidebar.\n *\n * - `\"flyout\"` (default) — a panel beside the sidebar on hover/focus. Compact: the\n * sidebar stays one row per group however many pages a group holds.\n * - `\"inline\"` — a disclosure under the entry, the pattern of MUI's nested List and\n * most documentation sites. Every page is visible at once, and the group holding\n * the current page opens on its own, so the reader can see where they are without\n * hovering anything.\n *\n * The icon-only (collapsed) sidebar always uses the flyout: an indented list has no\n * room there. The mobile bottom bar ignores `items` in both modes. */\n subNav?: \"flyout\" | \"inline\";\n /** Accessible name of the inline disclosure button, given the group's label.\n * Only used with `subNav=\"inline\"`. Its expanded state is carried by\n * `aria-expanded`, so one wording serves both directions. Default:\n * `appShell.toggleGroup` from the {@link UiKitProvider}, else English. */\n toggleGroupLabel?: (groupLabel: string) => string;\n /** Below `md`: show the current group's `items` as a scrollable row above the\n * bottom bar — the phone's counterpart of the sidebar's second level. Default true;\n * `false` keeps the single bar, where a group entry only links to its own `to`. */\n mobileSubNav?: boolean;\n /**\n * How the phone sub-nav lays out a group with more pages than fit on one row.\n * `\"wrap\"` (default): every page in view, on as many rows as it takes — the bar\n * grows. `\"scroll\"`: one row that scrolls sideways, faded at a cut edge, with the\n * current page scrolled into view. A long group took four rows (~130px) of a 390px\n * screen above the bottom bar (showcase audit); scrolling gives that back at the\n * price of pages hidden past the edge, so it is the app's call, per shell.\n */\n mobileSubNavLayout?: \"wrap\" | \"scroll\";\n /**\n * Lay the shell out inside its PARENT'S box instead of the viewport: `h-full` rather\n * than `h-dvh`, its own scroll container at every width, a bottom bar pinned to the\n * shell rather than the window, `--app-nav-h` published on the shell alone, and no\n * persisted collapse state unless `collapseStorageKey` is passed. For an app preview,\n * a device frame, a docs example.\n *\n * Implied for a shell rendered inside another AppShell, which is the case this\n * exists for: the inner one used to publish its own nav height on `<html>`, fight\n * the outer one over it, and remove it on unmount from under the outer app.\n */\n embedded?: boolean;\n}\n\n/** True below an AppShell — how a nested shell knows it is one. */\nconst AppShellNesting = createContext(false);\n\n/**\n * The bottom nav's own height, published as `--app-nav-h` so anything that has to sit\n * ON it can stop guessing.\n *\n * Keksdose live #314 is what this is for. Its rework — *\"Stick it to the bottom\n * touching the bottom icon bar with budget, accounts, … Currently there is a small gap\n * which confuses\"* — was a receipt footer pinned at `bottom-20`, because `<main>`\n * reserves `pb-20` for this nav and 20 looked like the nav's height. It is not: the\n * padding is generous CLEARANCE, and the nav is content-sized (icon + label + `py-2`,\n * about 56px), so the footer floated ~24px above the bar.\n *\n * Measured rather than named, because the height is not a constant anyone owns: it is\n * an icon, a translated label that can wrap or truncate, and whatever the platform does\n * with the safe-area inset. A hard-coded `bottom-14` would be the same guess one number\n * lower, and it would be wrong again the first time a label needed two lines.\n *\n * `0px` when the nav is not rendered — above `md` it is `display:none`, which observes\n * as a zero box — so a consumer can write `bottom-[var(--app-nav-h,0px)]` once and get\n * the right answer at both widths without a breakpoint of its own.\n */\nfunction useNavHeightVar(\n ref: React.RefObject<HTMLElement | null>,\n shellRef: React.RefObject<HTMLElement | null>,\n embedded: boolean,\n): void {\n // Re-run when the breakpoint flips. A ResizeObserver SKIPS an element that is not\n // being rendered, so the nav going `display:none` above `md` fires no callback and\n // would leave the last phone height published — a desktop footer would then sit 56px\n // off the bottom after a live resize. The query is the one the nav's own `md:hidden`\n // compiles to, so the two cannot disagree about where the boundary is.\n const isMdUp = useMediaQuery(\"(min-width: 768px)\", false);\n useEffect(() => {\n const node = ref.current;\n const shell = shellRef.current;\n // Always on the shell's own root, so everything inside it — a nested shell's\n // content included — reads the nearest bar's height. On <html> as well only for\n // the OUTERMOST shell, for what it portals to <body> (a sheet docking on the bar).\n // The DOM check backs up the context for a shell mounted in a separate React root.\n const global = !embedded && !shell?.parentElement?.closest(\"[data-app-shell]\");\n const targets = [shell, global ? document.documentElement : null].filter(\n (el): el is HTMLElement => el !== null,\n );\n // `getBoundingClientRect().height`, rounded DOWN — not `offsetHeight`, and not\n // ceil. Measured on the running app at 406x816: the bar is **55.5px** tall, and\n // both `offsetHeight` and `Math.ceil` answer 56. That is why live #314 round four\n // was *\"No change. Still a pixel inbetween\"* — the previous fix was arithmetically\n // a no-op on the very value it was meant to correct.\n //\n // 56 is the wrong side. A sticky element with `bottom: X` puts its bottom edge X\n // above the viewport bottom, and the bar's TOP edge is at its own height: so\n // X = 56 lands the footer at y=760.0 against a bar starting at y=760.5, i.e. half\n // a CSS pixel of page showing through — about 1.4 device pixels on his phone,\n // which is exactly the hairline in the screenshot.\n //\n // The two errors are not symmetric, and I had them backwards the first time.\n // Too SMALL tucks the sticky element under a bar that is opaque and painted above\n // it (`z-30` against `z-10`): invisible. Too LARGE opens the seam. So floor, and\n // consumers subtract a further pixel (see `invoice-line-totals.tsx`) for the case\n // where the height lands on a whole pixel and floor leaves no overlap at all.\n const publish = () => {\n const value = `${node ? Math.floor(node.getBoundingClientRect().height) : 0}px`;\n for (const el of targets) el.style.setProperty(\"--app-nav-h\", value);\n };\n publish();\n // The observer is the refinement, not the mechanism: `publish()` above is already\n // right for a static nav, and jsdom has no ResizeObserver unless a test stubs one.\n // ONE cleanup for every branch, though — an early `return` on the no-observer path\n // left the variable behind on unmount, which a stale `bottom:` offset on whatever\n // rendered next would have inherited.\n const ro = node && typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(publish) : null;\n if (ro && node) ro.observe(node);\n return () => {\n ro?.disconnect();\n for (const el of targets) el.style.removeProperty(\"--app-nav-h\");\n };\n }, [ref, shellRef, embedded, isMdUp]);\n}\n\n/**\n * The responsive application shell: a top bar, a collapsible desktop sidebar\n * (icon-only when collapsed, with tooltips), a mobile bottom nav bar, the main\n * content area, and an optional desktop footer. Router-aware via react-router\n * `NavLink`; domain-free — nav items, brand, footer and the top bar's actions\n * are all supplied by the app.\n */\nexport function AppShell({\n nav,\n topBar,\n children,\n footer,\n sidebarFooter,\n sidebarFooterItems,\n renderLink,\n collapseStorageKey: collapseStorageKeyProp,\n collapseLabel,\n expandLabel,\n subNav = \"flyout\",\n mobileSubNav = true,\n mobileSubNavLayout = \"wrap\",\n toggleGroupLabel,\n embedded: embeddedProp,\n className,\n ...rest\n}: AppShellProps) {\n const embedded = useContext(AppShellNesting) || !!embeddedProp;\n // An embedded shell sharing the default key would overwrite the outer app's sidebar\n // preference with its own — so it persists only when given a key of its own.\n const collapseStorageKey =\n collapseStorageKeyProp ?? (embedded ? null : \"appLayout.sidebarCollapsed\");\n // The three props are per-shell overrides of the provider's `appShell`; one left\n // undefined falls through to the provider rather than to English.\n const labels = useKitLabels(\"appShell\", DEFAULT_APP_SHELL_LABELS, {\n collapse: collapseLabel,\n expand: expandLabel,\n toggleGroup: toggleGroupLabel,\n });\n // Through the guarded helpers, not `window.localStorage` directly. The read runs\n // inside a `useState` initialiser — i.e. during render of the top-level shell — and\n // `localStorage` THROWS where site data is blocked (Safari private browsing, a\n // partitioned webview). Unguarded, that was not a lost sidebar preference: nothing\n // in the application mounted at all.\n const [collapsed, setCollapsed] = useState(\n () => collapseStorageKey !== null && readStored(collapseStorageKey) === \"1\",\n );\n // The inline sidebar is an ACCORDION: one group open at a time, keyed by its `to`.\n // Held here rather than per group so opening one can close the other — two lists\n // open at once pushed every entry below them off a laptop screen.\n const [openGroup, setOpenGroup] = useState<string | null>(null);\n // Publishes `--app-nav-h` for anything that has to sit on the bottom bar.\n const navRef = useRef<HTMLDivElement>(null);\n const rootRef = useRef<HTMLDivElement>(null);\n useNavHeightVar(navRef, rootRef, embedded);\n useEffect(() => {\n if (collapseStorageKey !== null) writeStored(collapseStorageKey, collapsed ? \"1\" : \"0\");\n }, [collapsed, collapseStorageKey]);\n\n return (\n // The caller's attributes go on the shell's own root, never on the `data-tour=\"nav\"`\n // wrappers below — those are the kit's anchors for its own guided tour (#313/#322),\n // and a second element answering to the same selector is a tour that highlights the\n // wrong thing or nothing at all.\n <AppShellNesting.Provider value={true}>\n <div\n {...rest}\n ref={rootRef}\n data-app-shell=\"\"\n className={cn(\n \"relative flex flex-col\",\n embedded\n ? // `contain: layout` makes this box the containing block for the `fixed`\n // bottom bar below, so it pins to the shell rather than to the window.\n \"h-full min-h-0 overflow-hidden [contain:layout]\"\n : \"min-h-screen md:h-dvh md:overflow-hidden\",\n className,\n )}\n >\n {topBar}\n <div className=\"flex flex-1 min-h-0\">\n <aside\n className={cn(\n // `border-e`: the sidebar sits at the START, which is the right in RTL.\n \"hidden md:flex md:flex-col border-e border-[var(--border)] bg-[var(--bg-surface)] transition-[width] duration-200 ease-out overflow-hidden\",\n // Embedded, the row it sits in is already the shell's height; the sticky\n // offsets below are measured against the window's top bar.\n embedded ? \"md:self-stretch\" : \"md:sticky md:top-12 md:self-start md:h-[calc(100vh-3rem)]\",\n collapsed ? \"md:w-14\" : \"md:w-60\",\n )}\n >\n {/* The `<nav>` is flex-1 so it fills the sidebar's height (with empty\n space below the items); the data-tour marker goes on the INNER,\n fit-content wrapper so the guided-tour spotlight hugs the actual nav\n items instead of the whole tall column (feedback #322). The marker\n also tags the mobile bottom bar below; the tour targets\n `[data-tour=\"nav\"]` and resolves to whichever is visible (#313). */}\n <nav className=\"flex-1 px-2 py-3 overflow-y-auto overflow-x-hidden\">\n <div data-tour=\"nav\" className=\"space-y-1\">\n {nav.map((item) => (\n <SidebarNavItem\n key={item.to}\n item={item}\n collapsed={collapsed}\n inline={subNav === \"inline\"}\n toggleGroupLabel={labels.toggleGroup}\n openGroup={openGroup}\n setOpenGroup={setOpenGroup}\n />\n ))}\n </div>\n </nav>\n {!!sidebarFooterItems?.length && (\n <SidebarFooterItems items={sidebarFooterItems} collapsed={collapsed} renderLink={renderLink} />\n )}\n {sidebarFooter?.(collapsed)}\n <div className=\"border-t border-[var(--border)] p-2\">\n {collapsed ? (\n <Tooltip label={labels.expand} side=\"end\" portal className=\"block\">\n <button\n type=\"button\"\n onClick={() => setCollapsed(false)}\n aria-label={labels.expand}\n className=\"flex w-full items-center justify-center min-h-9 rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <PanelLeftOpen className=\"size-4 shrink-0 rtl:-scale-x-100\" />\n </button>\n </Tooltip>\n ) : (\n <button\n type=\"button\"\n onClick={() => setCollapsed(true)}\n aria-label={labels.collapse}\n className=\"flex w-full items-center gap-3 min-h-9 px-3 py-2 rounded-md text-sm text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <PanelLeftClose className=\"size-4 shrink-0 rtl:-scale-x-100\" />\n <span className=\"truncate\">{labels.collapse}</span>\n </button>\n )}\n </div>\n </aside>\n\n {/* Content column beside the sidebar. main grows to fill so the footer\n sits at the bottom — at the viewport edge when content is short,\n beside the sidebar rather than under it. */}\n <div className=\"flex flex-1 flex-col min-w-0 md:min-h-0\">\n {/* `scrollbar-gutter: stable` reserves the scrollbar's track whether or\n not it is currently needed. Without it this element — the app's only\n scroll container from md up — narrows its own client box by the\n scrollbar width the moment a page's content outgrows it, and widens\n it again when the next page fits. Every centred `mx-auto` container\n inside then jumps sideways by half a scrollbar, and any fluid-width\n content changes width outright. Keksdose feedback #403 caught it as\n \"slight width increase/decrease\" when switching to the notifications\n settings section and back: that section is the only one tall enough\n to scroll. Platform-dependent, which is why it is easy to miss —\n overlay scrollbars (macOS, most Linux builds) take no layout space,\n classic ones (Windows) take ~15px.\n\n `both-edges`, not the bare `stable`: a one-sided reservation keeps the\n width stable but moves the middle. On a page short enough not to\n scroll, nothing is painted into the reserved track, so every centred\n `mx-auto` container sits half a scrollbar left of the optical centre\n and every full-bleed child stops ~15px short on the right. Keksdose\n feedback #491 (/settings#data) and #498 (/budgets) both reported it as\n \"the left boundary looks smaller / cut off compared to the right\" on\n the one element per page whose frame is a saturated colour — the rose\n destructive-action cards. Reserving the track on both edges keeps the\n content centred whether or not the scrollbar is showing. */}\n {/* `relative` makes <main> the containing block for everything absolutely\n positioned inside it. Tailwind's `sr-only` is `position:absolute`; with no\n positioned ancestor it resolves against the INITIAL containing block, is\n laid out at its offset from the top of the DOCUMENT, and grows\n `<html>`'s scroll height past the viewport — a second scrollbar beside this\n one that scrolls into empty space. Guarding it per component (see\n sr-only-containment.test) could never be complete: any page's own\n `sr-only` span re-opened it. Here it is closed for every child at once. */}\n <main\n className={cn(\n \"relative flex-1 max-w-full overflow-x-clip pb-[calc(var(--app-nav-h,4rem)+1.5rem)] md:pb-0 md:min-h-0 md:overflow-y-auto md:[scrollbar-gutter:stable_both-edges]\",\n // Embedded there is no document scroll to lean on below `md` either.\n embedded && \"min-h-0 overflow-y-auto\",\n )}\n >\n {children}\n </main>\n {footer}\n </div>\n </div>\n\n {/* The phone navigation: the group bar, and above it — when the current page\n belongs to a group with pages of its own — the row of those pages. The pair\n is the phone's version of the sidebar's two levels; without the upper row a\n group's `items` were simply unreachable below `md`. ONE measured box, so\n `--app-nav-h` covers both rows and whatever docks on the bar sits on the\n whole of it. */}\n <div\n ref={navRef}\n className=\"md:hidden fixed bottom-0 inset-x-0 z-30 border-t border-[var(--border)] bg-[var(--bg-surface)]\"\n >\n {mobileSubNav && <MobileSubNav nav={nav} layout={mobileSubNavLayout} />}\n <nav\n data-tour=\"nav\"\n className=\"grid\"\n style={{ gridTemplateColumns: `repeat(${nav.length}, minmax(0, 1fr))` }}\n >\n {nav.map((item) => (\n <MobileNavItem key={item.to} item={item} />\n ))}\n </nav>\n </div>\n </div>\n </AppShellNesting.Provider>\n );\n}\n\nconst routerLink: KitLinkComponent = ({ href, ...p }) => <Link to={href} {...p} />;\nconst plainLink: KitLinkComponent = ({ children, ...p }) => <a {...p}>{children}</a>;\n\nconst FOOTER_ICON_LINK =\n \"flex min-h-8 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\";\n\n/** Calls the link renderer as a component, so a router link's hooks are its own. */\nfunction RenderedLink({ render, ...props }: KitLinkProps & { render: KitLinkComponent }) {\n return render(props);\n}\n\n/** The sidebar's bottom area, from {@link AppShellSidebarFooterItem}s. */\nfunction SidebarFooterItems({\n items,\n collapsed,\n renderLink,\n}: {\n items: AppShellSidebarFooterItem[];\n collapsed: boolean;\n renderLink?: KitLinkComponent;\n}) {\n const kitLink = useKitLink();\n const linkTo = (to: string, external: boolean | undefined, { children, ...props }: Omit<KitLinkProps, \"href\">) => {\n // The kit's one link rule (`pickLinkRenderer`): an external `to` or an in-page\n // `#anchor` is a plain `<a>` even without `external`; only `external` opens a tab.\n const InApp = pickLinkRenderer(renderLink, kitLink ?? routerLink, to) ?? plainLink;\n return external ? (\n <a href={to} target=\"_blank\" rel=\"noopener noreferrer\" {...props}>\n {children}\n </a>\n ) : (\n <RenderedLink render={InApp} href={to} {...props}>\n {children}\n </RenderedLink>\n );\n };\n\n return (\n <div data-slot=\"sidebar-footer-items\" className={cn(\"px-2 py-1\", collapsed ? \"space-y-1\" : \"space-y-1.5\")}>\n {items.map((item) => {\n if (collapsed) {\n const to = item.kind === \"links\" ? (item.to ?? item.links[0]?.to) : item.to;\n if (!to) return null;\n const external = item.kind === \"links\" ? item.links.find((l) => l.to === to)?.external : item.external;\n return (\n <Tooltip key={item.key} label={item.label} side=\"end\" portal className=\"block\">\n {linkTo(to, external, {\n \"aria-label\": item.label,\n className: FOOTER_ICON_LINK,\n children: <item.icon className=\"size-4\" aria-hidden />,\n })}\n </Tooltip>\n );\n }\n if (item.kind === \"links\") {\n return (\n <nav\n key={item.key}\n aria-label={item.label}\n className=\"flex flex-wrap justify-center gap-x-3 gap-y-0.5 text-[11px] text-[var(--text-muted)]\"\n >\n {item.links.map((l) => (\n <span key={l.to}>\n {linkTo(l.to, l.external, {\n className: \"rounded-sm hover:text-[var(--text-secondary)] hover:underline\",\n children: l.label,\n })}\n </span>\n ))}\n </nav>\n );\n }\n const link = linkTo(item.to, item.external, {\n className: cn(FOOTER_ICON_LINK, \"gap-1.5 text-xs font-medium\"),\n children: (\n <>\n <item.icon className=\"size-3.5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\">{item.text ?? item.label}</span>\n </>\n ),\n });\n return item.hint ? (\n <Tooltip key={item.key} label={item.hint} side=\"top\" portal className=\"block\">\n {link}\n </Tooltip>\n ) : (\n <div key={item.key}>{link}</div>\n );\n })}\n </div>\n );\n}\n\n/** One cell of the phone's group bar. Marked while the group's own page OR any of its\n * pages is current — NavLink alone only knows its own `to`, which left the bar with\n * nothing marked on every sub-page (the same defect the sidebar had). */\nfunction MobileNavItem({ item }: { item: AppShellNavItem }) {\n const subActive = useSubItemActive(item);\n return (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n className={({ isActive }) =>\n // The active page needs a clear marker, not just a subtle text\n // shade (feedback #327): brand-accent icon + label, bolder weight,\n // and a top accent bar spanning the cell so it reads at a glance.\n cn(\n \"relative flex flex-col items-center justify-center py-2 text-[11px] gap-0.5 transition-colors\",\n \"before:absolute before:content-[''] before:inset-x-4 before:top-0 before:h-0.5 before:rounded-full before:transition-colors\",\n isActive || subActive\n ? \"font-semibold text-[var(--brand)] before:bg-[var(--brand)]\"\n : \"text-[var(--text-muted)] before:bg-transparent\",\n )\n }\n >\n <span className=\"relative\">\n <item.icon className=\"size-5\" />\n </span>\n {/* One line, always. `truncate` is the backstop for the case a\n `shortLabel` was not supplied (or a translation is longer than its\n author expected): an ellipsis in one cell is a far smaller problem\n than a bar whose rows are different heights. */}\n <span className=\"max-w-full truncate px-0.5\">{item.shortLabel ?? item.label}</span>\n </NavLink>\n );\n}\n\n/**\n * The upper row of the phone navigation: the pages of the group the reader is in, as\n * a sideways-scrolling row of pills. Rendered only while such a group is current —\n * on a page outside every group the bar stays one row, rather than showing an empty\n * shelf or guessing a group.\n *\n * It slides in over 200ms (fade only under reduced motion) and wraps onto more rows\n * rather than scrolling, so every page of the group is in view at once.\n */\nfunction MobileSubNav({ nav, layout }: { nav: AppShellNavItem[]; layout: \"wrap\" | \"scroll\" }) {\n const { pathname } = useLocation();\n const scroll = layout === \"scroll\";\n const listRef = useRef<HTMLUListElement>(null);\n const fade = useStripFade(listRef);\n // The current page into view — the row only, never the page.\n useLayoutEffect(() => {\n const list = listRef.current;\n const current = list?.querySelector<HTMLElement>('[aria-current=\"page\"]');\n if (scroll && list && current) scrollIntoStrip(list, current);\n }, [scroll, pathname]);\n const group = nav.find(\n (item) =>\n !!item.items?.length &&\n (matchPath({ path: item.to, end: item.end ?? true }, pathname) ||\n item.items.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname))),\n );\n\n if (!group) return null;\n return (\n <nav\n aria-label={group.label}\n // Keyed by group so switching groups replays the entrance.\n key={group.to}\n className=\"animate-subnav border-b border-[var(--border)]\"\n >\n <ul\n ref={listRef}\n // `wrap` (default) takes as many rows as the group needs: a sideways row hides\n // pages past the edge, and every page of the group in view is the point of the\n // row. The bar grows with it — `--app-nav-h` measures it. `scroll` is the app's\n // opt-in trade: one row, the cut edge faded (see mobileSubNavLayout).\n className={cn(\n \"flex gap-1.5 px-3 py-2\",\n scroll ? \"overflow-x-auto overscroll-x-contain [scrollbar-width:none]\" : \"flex-wrap\",\n )}\n data-overflow={scroll ? fade.overflow : undefined}\n style={scroll && fade.mask ? { maskImage: fade.mask, WebkitMaskImage: fade.mask } : undefined}\n >\n {group.items!.map((sub) => (\n <li key={sub.to} className=\"shrink-0\">\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n className={({ isActive }) =>\n cn(\n \"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors\",\n isActive\n ? \"border-[var(--brand)] bg-[var(--brand)] font-medium text-[var(--brand-contrast)]\"\n : \"border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-3.5 shrink-0\" aria-hidden />\n {sub.label}\n </NavLink>\n </li>\n ))}\n </ul>\n </nav>\n );\n}\n\n/** `strong` is the current page; `trail` is the group that CONTAINS the current page\n * while the page itself is shown elsewhere (the inline list below it). */\nconst navLinkClass = (collapsed: boolean, forceActive: boolean, trail = false) =>\n ({ isActive }: { isActive: boolean }) =>\n `flex items-center gap-3 min-h-9 ${collapsed ? \"justify-center px-2\" : \"px-3\"} py-2 rounded-md text-sm transition-colors duration-150 ${\n isActive || (forceActive && !trail)\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : forceActive\n ? \"font-medium text-[var(--text-primary)] hover:bg-[var(--bg-hover)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\"\n }`;\n\n/** Whether any of an entry's sub-items is the current route. NavLink only knows about\n * its OWN `to`, so without this a group whose pages live at sibling paths\n * (`/fields`, `/numbers`, …) lost its highlight the moment the reader left the one\n * page the group linked to — the sidebar then marked nothing at all, and the only\n * \"you are here\" left was inside a flyout that is closed unless hovered. */\nfunction useSubItemActive(item: AppShellNavItem): boolean {\n const { pathname } = useLocation();\n return !!item.items?.some((sub) => matchPath({ path: sub.to, end: sub.end ?? true }, pathname));\n}\n\n/** A single desktop sidebar entry. Plain link (with a tooltip when collapsed) —\n * unless it has sub-items, in which case hovering/focusing opens a flyout, or (with\n * `inline`) a disclosure lists them underneath. */\nfunction SidebarNavItem({\n item,\n collapsed,\n inline,\n toggleGroupLabel,\n openGroup,\n setOpenGroup,\n}: {\n item: AppShellNavItem;\n collapsed: boolean;\n inline: boolean;\n toggleGroupLabel: (groupLabel: string) => string;\n openGroup: string | null;\n setOpenGroup: (to: string | null) => void;\n}) {\n const hasSub = !!item.items?.length;\n const subActive = useSubItemActive(item);\n\n // In the EXPANDED sidebar a group is one component in both styles, so switching\n // between them is a transition of the same element (the page list folding away or\n // unfolding) rather than one tree replaced by another. The icon-only sidebar keeps\n // the plain flyout below: an indented list has no room there.\n if (hasSub && !collapsed) {\n return (\n <SidebarGroup\n item={item}\n subActive={subActive}\n inline={inline}\n toggleGroupLabel={toggleGroupLabel}\n open={openGroup === item.to}\n setOpenGroup={setOpenGroup}\n />\n );\n }\n\n const link = (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n className={navLinkClass(collapsed, subActive)}\n aria-haspopup={hasSub ? \"menu\" : undefined}\n >\n <span className=\"relative shrink-0\">\n <item.icon className=\"size-4\" />\n </span>\n {/* Wraps rather than truncates: in the sidebar a label cut to \"Checkbox, switch\n & sl…\" hides the one word that tells two pages apart, and there is height\n to spare. (The phone bar keeps `truncate` — its cells must stay one row.) */}\n {!collapsed && <span className=\"min-w-0 flex-1 break-words leading-snug\">{item.label}</span>}\n {!collapsed && hasSub && <ChevronRight className=\"size-3.5 shrink-0 opacity-60 rtl:-scale-x-100\" />}\n </NavLink>\n );\n\n if (hasSub) return <SidebarFlyout item={item}>{link}</SidebarFlyout>;\n return collapsed ? (\n <Tooltip label={item.label} side=\"end\" portal className=\"block\">\n {link}\n </Tooltip>\n ) : (\n link\n );\n}\n\n/**\n * An entry with sub-items in the expanded sidebar.\n *\n * - `inline`: the entry's own link (its overview page) plus a chevron button that\n * shows or hides the sub-items indented beneath it. Two controls rather than one\n * because they do two different things — a row that both navigated AND toggled\n * would make \"open the overview\" and \"peek at the pages\" the same click.\n * - flyout: the link alone, with the sub-items in a panel beside the sidebar on\n * hover/focus. The inline list is still rendered, folded shut, so that switching\n * styles animates it rather than swapping it.\n *\n * THE FOLD is a `grid-template-rows` transition from `0fr` to `1fr` — the one way to\n * animate to a content-sized height without measuring it, so a translated label that\n * wraps to two lines unfolds exactly as far as it needs. `visibility` rides the same\n * transition so a shut list drops out of the accessibility tree only once it has\n * finished closing, and `inert` takes its links out of the tab order immediately.\n */\nfunction SidebarGroup({\n item,\n subActive,\n inline,\n toggleGroupLabel,\n open,\n setOpenGroup,\n}: {\n item: AppShellNavItem;\n subActive: boolean;\n inline: boolean;\n toggleGroupLabel: (groupLabel: string) => string;\n /** Whether this is the sidebar's one open group (see AppShell's `openGroup`). */\n open: boolean;\n setOpenGroup: (to: string | null) => void;\n}) {\n const listId = useId();\n const { pathname } = useLocation();\n const onOwnPage = !!matchPath({ path: item.to, end: item.end ?? true }, pathname);\n // Opening follows the reader: arriving on this group's overview or one of its pages\n // (by link, by the pager, by Back) opens it — and, the sidebar holding ONE open\n // group, closes whichever was open before. Both folds run at once, so the eye sees\n // one list hand over to the other. Run in an effect after mount, so the current\n // group UNFOLDS into view on load rather than appearing already open.\n const here = subActive || onOwnPage;\n useEffect(() => {\n if (here) setOpenGroup(item.to);\n }, [here, item.to, setOpenGroup]);\n const expanded = inline && open;\n\n const link = (\n <NavLink\n to={item.to}\n end={item.end ?? true}\n data-tour={item.dataTour}\n aria-haspopup={inline ? undefined : \"menu\"}\n // `trail` (inline only): while a page of the group is current, the group row is\n // the breadcrumb, not the destination — the page's own row below carries the\n // strong marker, and two inverse rows would say \"you are here\" twice. In the\n // flyout style the pages are out of sight, so the group row IS the marker.\n className={(state) => cn(navLinkClass(false, subActive, inline)(state), \"min-w-0 flex-1\")}\n >\n <span className=\"relative shrink-0\">\n <item.icon className=\"size-4\" />\n </span>\n <span className=\"min-w-0 flex-1 break-words leading-snug\">{item.label}</span>\n {!inline && <ChevronRight className=\"size-3.5 shrink-0 opacity-60 rtl:-scale-x-100\" />}\n </NavLink>\n );\n\n return (\n <div>\n {inline ? (\n <div className=\"flex items-center gap-0.5\">\n {link}\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={listId}\n aria-label={toggleGroupLabel(item.label)}\n onClick={() => setOpenGroup(open ? null : item.to)}\n className=\"flex size-9 shrink-0 items-center justify-center rounded-md text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-secondary)]\"\n >\n <ChevronRight\n aria-hidden\n className={cn(\n \"size-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none rtl:-scale-x-100\",\n // Under dir=\"rtl\" the chevron is MIRRORED first (scale applies before rotate), so\n // it starts pointing left and a +90° turn would point it UP — hence −90° there.\n open && \"rotate-90 rtl:-rotate-90\",\n )}\n />\n </button>\n </div>\n ) : (\n <SidebarFlyout item={item}>{link}</SidebarFlyout>\n )}\n <div\n id={listId}\n inert={!expanded}\n className=\"grid transition-[grid-template-rows,visibility] duration-200 ease-out motion-reduce:transition-none\"\n style={{\n gridTemplateRows: expanded ? \"1fr\" : \"0fr\",\n visibility: expanded ? \"visible\" : \"hidden\",\n }}\n >\n <div\n className={cn(\n \"min-h-0 overflow-hidden transition-opacity duration-200 ease-out motion-reduce:transition-none\",\n expanded ? \"opacity-100\" : \"opacity-0\",\n )}\n >\n {/* The rule and indent live on the list INSIDE the clipping box: margin or\n padding on the element whose row is collapsing to 0fr would hold it open\n by that much. */}\n <ul className=\"ms-[1.3rem] mt-0.5 space-y-0.5 border-s border-[var(--border)] ps-2\">\n {item.items!.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n className={({ isActive }) =>\n cn(\n \"flex min-h-8 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors\",\n isActive\n ? \"bg-[var(--bg-inverse)] text-[var(--text-inverse)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-4 shrink-0 opacity-70\" aria-hidden />\n <span className=\"min-w-0 break-words leading-snug\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n </div>\n </div>\n </div>\n );\n}\n\n// Only ONE sidebar flyout should be on screen at a time. Each open flyout parks its\n// \"close now\" here so the next item to open can dismiss it immediately, instead of\n// leaving the previous panel up for the 140ms travel delay while the cursor moves\n// down the nav list — which showed two panels at once.\nlet activeFlyoutClose: (() => void) | null = null;\n\n/**\n * Wraps a nav item that has sub-items: on hover or keyboard focus it opens a\n * portalled flyout to the right of the sidebar (portalled so the sidebar's\n * `overflow-hidden` can't clip it) with the item's label as a header and each\n * sub-item as an icon + title link. A short close delay lets the cursor travel\n * from the item onto the panel; Escape / outside-move close it.\n */\nfunction SidebarFlyout({ item, children }: { item: AppShellNavItem; children: ReactNode }) {\n const wrapperRef = useRef<HTMLDivElement>(null);\n const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n const [open, setOpen] = useState(false);\n // The flyout is portalled out of the subtree whose `dir` it inherited; read on open.\n const [dir, setDir] = useState<Direction>(\"ltr\");\n\n // Anchor the flyout to the item's END edge — its right, or its left in RTL, where\n // the sidebar sits on the right of the window. The hook re-measures on\n // scroll/resize (the nav list can scroll).\n const rect = useAnchoredRect(wrapperRef, open);\n const pos = rect\n ? dir === \"rtl\"\n ? { top: rect.top, right: window.innerWidth - rect.left }\n : { top: rect.top, left: rect.right }\n : null;\n\n const cancelClose = useCallback(() => {\n if (closeTimer.current) {\n clearTimeout(closeTimer.current);\n closeTimer.current = null;\n }\n }, []);\n // Stable identity: the coordinator above holds this so a sibling item can close\n // THIS flyout without waiting out its travel delay.\n const closeNow = useCallback(() => {\n cancelClose();\n setOpen(false);\n }, [cancelClose]);\n const openMenu = useCallback(() => {\n cancelClose();\n if (activeFlyoutClose && activeFlyoutClose !== closeNow) activeFlyoutClose();\n activeFlyoutClose = closeNow;\n setDir(dirOf(wrapperRef.current));\n setOpen(true);\n }, [cancelClose, closeNow]);\n const scheduleClose = useCallback(() => {\n cancelClose();\n closeTimer.current = setTimeout(() => {\n setOpen(false);\n if (activeFlyoutClose === closeNow) activeFlyoutClose = null;\n }, 140);\n }, [cancelClose, closeNow]);\n\n // Close on Escape while open.\n useEscapeKey(() => setOpen(false), open);\n\n useEffect(\n () => () => {\n cancelClose();\n if (activeFlyoutClose === closeNow) activeFlyoutClose = null;\n },\n [cancelClose, closeNow],\n );\n\n return (\n // Not a control: hover and focus-within on the nav link inside. Focus (the\n // keyboard path) opens it exactly as hover does, Escape closes it.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- hover/focus-within wrapper around a real link\n <div\n ref={wrapperRef}\n className=\"relative\"\n onMouseEnter={openMenu}\n onMouseLeave={scheduleClose}\n onFocus={openMenu}\n onBlur={scheduleClose}\n >\n {children}\n {open &&\n pos &&\n createPortal(\n // Keeps the flyout open while the pointer crosses into it; keyboard users\n // reach it by focus, which the wrapper above already handles.\n // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- pointer-only hover bridge\n <div\n dir={dir}\n style={{ position: \"fixed\", ...pos }}\n className=\"z-40 ps-1\"\n onMouseEnter={cancelClose}\n onMouseLeave={scheduleClose}\n >\n <div\n role=\"menu\"\n aria-label={item.label}\n className=\"animate-flyout min-w-52 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg\"\n >\n <div className=\"border-b border-[var(--border)] px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-muted)]\">\n {item.label}\n </div>\n <ul className=\"py-1\">\n {item.items!.map((sub) => (\n <li key={sub.to}>\n <NavLink\n to={sub.to}\n end={sub.end ?? true}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n className={({ isActive }) =>\n cn(\n \"flex items-center gap-2.5 px-3 py-2 text-sm\",\n isActive\n ? \"bg-[var(--bg-active)] font-medium text-[var(--text-primary)]\"\n : \"text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]\",\n )\n }\n >\n <sub.icon className=\"size-4 shrink-0 text-[var(--text-placeholder)]\" />\n <span className=\"min-w-0 break-words leading-snug\">{sub.label}</span>\n </NavLink>\n </li>\n ))}\n </ul>\n </div>\n </div>,\n document.body,\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
|
-
import { aA as KitLinkProps, az as KitLinkComponent } from '../kit-labels-
|
|
3
|
+
import { aA as KitLinkProps, az as KitLinkComponent } from '../kit-labels-BHfRBbHs.js';
|
|
4
4
|
import '../components/data-table-labels.js';
|
|
5
5
|
import '../components/mini-calendar.js';
|
|
6
6
|
import '../components/calendar-heatmap.js';
|
package/dist/shell.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ import './theme/theme-store.js';
|
|
|
13
13
|
import 'zustand/middleware';
|
|
14
14
|
import 'zustand';
|
|
15
15
|
import 'lucide-react';
|
|
16
|
-
import './kit-labels-
|
|
16
|
+
import './kit-labels-BHfRBbHs.js';
|
|
17
17
|
import './components/data-table-labels.js';
|
|
18
18
|
import './components/mini-calendar.js';
|
|
19
19
|
import './components/calendar-heatmap.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode, ReactElement } from 'react';
|
|
3
|
-
import { f as ButtonVariant } from '../kit-labels-
|
|
3
|
+
import { f as ButtonVariant } from '../kit-labels-BHfRBbHs.js';
|
|
4
4
|
import { UseWizardReturn, WizardLabels } from './types.js';
|
|
5
5
|
import '../components/data-table-labels.js';
|
|
6
6
|
import '../components/mini-calendar.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useId, useMemo } from "react";
|
|
3
|
+
import { useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { Check } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn.js";
|
|
6
6
|
import { Button, Spinner } from "../components/ui.js";
|
|
@@ -23,6 +23,18 @@ function StepperNav({
|
|
|
23
23
|
}) {
|
|
24
24
|
const l = useKitLabels("wizard", DEFAULT_WIZARD_LABELS, labels);
|
|
25
25
|
const cancelTitleId = useId();
|
|
26
|
+
const contentRef = useRef(null);
|
|
27
|
+
const [contentFloor, setContentFloor] = useState(0);
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
const el = contentRef.current;
|
|
30
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
31
|
+
const observer = new ResizeObserver(() => {
|
|
32
|
+
const h = el.offsetHeight;
|
|
33
|
+
setContentFloor((floor) => h > floor ? h : floor);
|
|
34
|
+
});
|
|
35
|
+
observer.observe(el);
|
|
36
|
+
return () => observer.disconnect();
|
|
37
|
+
}, []);
|
|
26
38
|
const contextValue = useMemo(
|
|
27
39
|
() => ({
|
|
28
40
|
registerStepValidate: wizard.registerStepValidate,
|
|
@@ -133,7 +145,7 @@ function StepperNav({
|
|
|
133
145
|
wizard.currentStep.label
|
|
134
146
|
] })
|
|
135
147
|
] }),
|
|
136
|
-
/* @__PURE__ */ jsx(WizardContextProvider, { value: contextValue, children: /* @__PURE__ */ jsx("div", {
|
|
148
|
+
/* @__PURE__ */ jsx(WizardContextProvider, { value: contextValue, children: /* @__PURE__ */ jsx("div", { style: contentFloor > 0 ? { minHeight: contentFloor } : void 0, children: /* @__PURE__ */ jsx("div", { ref: contentRef, children }) }) }),
|
|
137
149
|
wizard.error && /* @__PURE__ */ jsx(
|
|
138
150
|
"div",
|
|
139
151
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/wizard/stepper-nav.tsx"],"sourcesContent":["import { useId, useMemo } from \"react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { Check } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Button, Spinner } from \"../components/ui\";\nimport type { ButtonVariant } from \"../components/ui\";\nimport { Modal } from \"../components/modal\";\nimport { alertFrameClass } from \"../components/alert-banner\";\nimport { WizardContextProvider } from \"./wizard-context\";\nimport { DEFAULT_WIZARD_LABELS } from \"./types\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport type { UseWizardReturn, WizardLabels } from \"./types\";\n\n/**\n * The chrome around a wizard: the step indicator, the step content, and the\n * Cancel / Back / Skip / Next / Finish bar — plus the cancel-confirmation\n * dialog, because \"are you sure you want to lose this\" is part of the wizard's\n * contract rather than something each app should re-decide.\n *\n * It is a component and not a set of parts for one reason: which button shows,\n * and whether it is enabled, is a function of `wizard` state that was getting\n * re-derived (and re-derived slightly differently) at every call site. Finish is\n * gated on `canFinish`, Next and Skip on `nextBlocked`, and Skip only appears on\n * an `optional` step that is not the last — that policy lives here now.\n *\n * The `<StepperNav>` is also what publishes {@link WizardContextProvider}, which\n * is how a mounted step registers its own validator. A step's\n * `useRhfWizardStep` / `useWizardStepValidate` therefore only works inside one.\n *\n * Which buttons show is read off `wizard` (`canCancel`, `canGoBack`, `canDone`,\n * `isCommitStep`), so the policy options — `cancellable`, `onExit`, `commits`,\n * `onDone` — are all set on `useWizard`. What is set HERE is how the Finish button\n * looks and what wraps it, because that is presentation and usually depends on\n * state the app holds (a \"replace everything\" toggle, a write lock).\n */\nexport function StepperNav<TData extends Record<string, unknown>>({\n wizard,\n children,\n title,\n description,\n className,\n labels,\n finishVariant = \"brand\",\n finishDisabled = false,\n doneDisabled = false,\n renderFinish,\n}: {\n wizard: UseWizardReturn<TData>;\n children: ReactNode;\n title?: ReactNode;\n description?: ReactNode;\n className?: string;\n labels?: Partial<WizardLabels>;\n /** The Finish button's variant — `\"danger\"` for a destructive commit (an import\n * that replaces what is there). Default `\"brand\"`. Next keeps `\"brand\"`. */\n finishVariant?: ButtonVariant;\n /** Disable Finish for a reason of the app's own, on top of the kit's gates\n * (`canFinish`, submitting, validating). */\n finishDisabled?: boolean;\n /**\n * Disable Done — the button of the last step after a commit — while the step still\n * has work of its own running. keksdose's YNAB import ends on a recurring step whose\n * convert loop writes one rule at a time (ynab-import-panel.tsx): leaving mid-loop\n * abandons the rest. Until now the panel dropped `onDone` for the duration, which\n * takes the button away instead of saying \"not yet\": disabled, it stays where the\n * eye left it and comes back to life when the loop ends.\n */\n doneDisabled?: boolean;\n /**\n * Wrap the Finish button: receives the kit's button element — already labelled,\n * gated and wired to `wizard.finish` — and returns what to render in its place.\n *\n * ```tsx\n * renderFinish={(button) => <SaveGuard lock={lock}>{button}</SaveGuard>}\n * ```\n *\n * A wrapper and not a replacement (`finishButton: (props) => …`) or a hook\n * (`onBeforeFinish`): the app's need is to put something AROUND the commit — a\n * write-lock tooltip, which has to sit on a wrapping element because a disabled\n * button receives no pointer events — while the kit keeps owning what the button\n * says and when it is enabled. A replacement would hand every app the gating to\n * re-derive (the problem this component exists to end); `onBeforeFinish` can veto\n * a click but cannot explain a disabled button. The element is a plain `<Button>`,\n * so a wrapper that clones it with `disabled` works as expected.\n */\n renderFinish?: (button: ReactElement) => ReactNode;\n}) {\n const l = useKitLabels(\"wizard\", DEFAULT_WIZARD_LABELS, labels);\n const cancelTitleId = useId();\n\n const contextValue = useMemo(\n () => ({\n registerStepValidate: wizard.registerStepValidate,\n setNextBlocked: wizard.setNextBlocked,\n }),\n [wizard.registerStepValidate, wizard.setNextBlocked],\n );\n\n // Finish is only valid once every step before the last has been completed or\n // skipped (derived once in useWizard). Prevents a restored last-step index\n // (empty data after a reload) from firing an empty submit.\n const canFinish = wizard.canFinish;\n\n // Pending while the step's validators run (an async server check): a spinner and\n // `aria-busy`, and disabled — the hook's own re-entry guard still refuses a second\n // press, this just stops it looking like the first one did nothing.\n const pending = wizard.isValidating;\n const stepNextLabel = wizard.currentStep.nextLabel;\n\n // Every branch below assigns it; no initial value to overwrite.\n let forwardButton: ReactNode;\n if (wizard.isCommitStep) {\n const finishButton = (\n <Button\n variant={finishVariant}\n data-tour=\"wizard-finish\"\n onClick={wizard.finish}\n disabled={wizard.isSubmitting || pending || !canFinish || finishDisabled}\n aria-busy={wizard.isSubmitting || pending || undefined}\n >\n {/* Decorative: the button's text is what a reader hears. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {wizard.isSubmitting ? l.submitting : (stepNextLabel ?? l.finish)}\n </Button>\n );\n forwardButton = renderFinish ? renderFinish(finishButton) : finishButton;\n } else if (wizard.isLastStep) {\n // The last step after a commit. Nothing to finish; Done only when the app gave\n // an `onDone` — otherwise the step carries its own way out.\n forwardButton = wizard.canDone ? (\n <Button variant=\"brand\" data-tour=\"wizard-done\" onClick={wizard.done} disabled={doneDisabled}>\n {stepNextLabel ?? l.done ?? DEFAULT_WIZARD_LABELS.done}\n </Button>\n ) : null;\n } else {\n forwardButton = (\n <Button\n variant=\"brand\"\n data-tour=\"wizard-next\"\n onClick={wizard.goNext}\n disabled={wizard.nextBlocked || pending}\n aria-busy={pending || undefined}\n >\n {pending && <Spinner label={null} className=\"size-4\" />}\n {stepNextLabel ?? l.next}\n </Button>\n );\n }\n\n return (\n <div className={cn(\"flex flex-col gap-6\", className)}>\n {(title || description) && (\n <div>\n {title && (\n <h1 className=\"text-2xl font-bold tracking-tight text-[var(--text-primary)]\">\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>\n )}\n </div>\n )}\n\n {/* Step indicator */}\n <nav aria-label={l.steps}>\n <ol className=\"flex w-full items-center\">\n {wizard.steps.map((step, index) => {\n const status = wizard.stepStatus(index);\n const isLast = index === wizard.steps.length - 1;\n\n return (\n <li key={step.id} className={cn(\"flex items-center\", !isLast && \"flex-1\")}>\n <button\n type=\"button\"\n onClick={() => wizard.goToStep(index)}\n // `canGoToStep` adds the commit lock: once past a committing step,\n // the steps up to it are done with and cannot be reopened.\n disabled={status === \"upcoming\" || !wizard.canGoToStep(index)}\n aria-current={status === \"active\" ? \"step\" : undefined}\n className={cn(\n \"group flex flex-col items-center gap-1.5\",\n status === \"upcoming\" ? \"cursor-default\" : \"cursor-pointer\",\n )}\n >\n {/* Circle */}\n <span\n className={cn(\n \"flex size-8 shrink-0 items-center justify-center rounded-full border-2 text-xs font-semibold transition-colors\",\n status === \"completed\" &&\n \"border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-contrast)]\",\n status === \"active\" &&\n \"border-[var(--brand)] bg-[var(--bg-surface)] text-[var(--brand)]\",\n status === \"upcoming\" &&\n \"border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-placeholder)]\",\n status === \"skipped\" &&\n \"border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-muted)]\",\n )}\n >\n {status === \"completed\" ? <Check className=\"size-4\" /> : index + 1}\n </span>\n {/* Label — hidden on mobile, where the line below carries it */}\n <span\n className={cn(\n \"hidden text-xs font-medium md:block\",\n status === \"active\" || status === \"completed\"\n ? \"text-[var(--text-primary)]\"\n : \"text-[var(--text-muted)]\",\n )}\n >\n {step.label}\n </span>\n </button>\n\n {/* Connector line */}\n {!isLast && (\n <div\n className={cn(\n \"mx-2 h-0.5 flex-1\",\n status === \"completed\" ? \"bg-[var(--brand)]\" : \"bg-[var(--border)]\",\n )}\n />\n )}\n </li>\n );\n })}\n </ol>\n {/* Mobile: the labels above are hidden, so name the current step here */}\n <p className=\"mt-2 text-center text-sm font-medium text-[var(--text-primary)] md:hidden\">\n {l.step(wizard.currentStepIndex + 1, wizard.steps.length)}\n {\" — \"}\n {wizard.currentStep.label}\n </p>\n </nav>\n\n {/* Step content */}\n <WizardContextProvider value={contextValue}>\n <div className=\"min-h-[300px]\">{children}</div>\n </WizardContextProvider>\n\n {/* Submit error */}\n {wizard.error && (\n <div\n role=\"alert\"\n className={cn(\n \"text-sm text-[var(--danger)]\",\n alertFrameClass(\"danger\"),\n )}\n >\n {wizard.error}\n </div>\n )}\n\n {/* Navigation bar */}\n <div className=\"flex items-center justify-between border-t border-[var(--border)] pt-4\">\n {/* The empty span keeps the right-hand group on the right when Cancel is off. */}\n {wizard.canCancel ? (\n <Button variant=\"secondary\" onClick={wizard.cancel}>\n {l.cancel}\n </Button>\n ) : (\n <span />\n )}\n <div className=\"flex items-center gap-2\">\n {wizard.canGoBack && (\n // Disabled, not hidden, while the commit runs: it comes back if the commit\n // fails, and a button that vanishes mid-click is its own kind of surprise.\n <Button variant=\"secondary\" onClick={wizard.goBack} disabled={wizard.isSubmitting}>\n {l.back}\n </Button>\n )}\n {wizard.currentStep.optional && !wizard.isLastStep && !wizard.isCommitStep && (\n <Button\n variant=\"ghost\"\n onClick={wizard.skip}\n disabled={wizard.nextBlocked || wizard.isValidating}\n >\n {l.skip}\n </Button>\n )}\n {forwardButton}\n </div>\n </div>\n\n {/* Cancel confirmation */}\n {wizard.showCancelDialog && (\n <Modal onClose={wizard.dismissCancel} labelledBy={cancelTitleId} className=\"space-y-4\">\n <div className=\"space-y-1.5\">\n <h2 id={cancelTitleId} className=\"text-lg font-semibold text-[var(--text-primary)]\">\n {l.cancelTitle}\n </h2>\n <p className=\"text-sm text-[var(--text-muted)]\">{l.confirmCancel}</p>\n </div>\n <div className=\"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\">\n <Button variant=\"secondary\" onClick={wizard.dismissCancel}>\n {l.cancelDismissLabel}\n </Button>\n <Button variant=\"danger\" onClick={wizard.confirmCancel}>\n {l.cancelConfirmLabel}\n </Button>\n </div>\n </Modal>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/wizard/stepper-nav.tsx"],"sourcesContent":["import { useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { Check } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { Button, Spinner } from \"../components/ui\";\nimport type { ButtonVariant } from \"../components/ui\";\nimport { Modal } from \"../components/modal\";\nimport { alertFrameClass } from \"../components/alert-banner\";\nimport { WizardContextProvider } from \"./wizard-context\";\nimport { DEFAULT_WIZARD_LABELS } from \"./types\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport type { UseWizardReturn, WizardLabels } from \"./types\";\n\n/**\n * The chrome around a wizard: the step indicator, the step content, and the\n * Cancel / Back / Skip / Next / Finish bar — plus the cancel-confirmation\n * dialog, because \"are you sure you want to lose this\" is part of the wizard's\n * contract rather than something each app should re-decide.\n *\n * It is a component and not a set of parts for one reason: which button shows,\n * and whether it is enabled, is a function of `wizard` state that was getting\n * re-derived (and re-derived slightly differently) at every call site. Finish is\n * gated on `canFinish`, Next and Skip on `nextBlocked`, and Skip only appears on\n * an `optional` step that is not the last — that policy lives here now.\n *\n * The `<StepperNav>` is also what publishes {@link WizardContextProvider}, which\n * is how a mounted step registers its own validator. A step's\n * `useRhfWizardStep` / `useWizardStepValidate` therefore only works inside one.\n *\n * Which buttons show is read off `wizard` (`canCancel`, `canGoBack`, `canDone`,\n * `isCommitStep`), so the policy options — `cancellable`, `onExit`, `commits`,\n * `onDone` — are all set on `useWizard`. What is set HERE is how the Finish button\n * looks and what wraps it, because that is presentation and usually depends on\n * state the app holds (a \"replace everything\" toggle, a write lock).\n */\nexport function StepperNav<TData extends Record<string, unknown>>({\n wizard,\n children,\n title,\n description,\n className,\n labels,\n finishVariant = \"brand\",\n finishDisabled = false,\n doneDisabled = false,\n renderFinish,\n}: {\n wizard: UseWizardReturn<TData>;\n children: ReactNode;\n title?: ReactNode;\n description?: ReactNode;\n className?: string;\n labels?: Partial<WizardLabels>;\n /** The Finish button's variant — `\"danger\"` for a destructive commit (an import\n * that replaces what is there). Default `\"brand\"`. Next keeps `\"brand\"`. */\n finishVariant?: ButtonVariant;\n /** Disable Finish for a reason of the app's own, on top of the kit's gates\n * (`canFinish`, submitting, validating). */\n finishDisabled?: boolean;\n /**\n * Disable Done — the button of the last step after a commit — while the step still\n * has work of its own running. keksdose's YNAB import ends on a recurring step whose\n * convert loop writes one rule at a time (ynab-import-panel.tsx): leaving mid-loop\n * abandons the rest. Until now the panel dropped `onDone` for the duration, which\n * takes the button away instead of saying \"not yet\": disabled, it stays where the\n * eye left it and comes back to life when the loop ends.\n */\n doneDisabled?: boolean;\n /**\n * Wrap the Finish button: receives the kit's button element — already labelled,\n * gated and wired to `wizard.finish` — and returns what to render in its place.\n *\n * ```tsx\n * renderFinish={(button) => <SaveGuard lock={lock}>{button}</SaveGuard>}\n * ```\n *\n * A wrapper and not a replacement (`finishButton: (props) => …`) or a hook\n * (`onBeforeFinish`): the app's need is to put something AROUND the commit — a\n * write-lock tooltip, which has to sit on a wrapping element because a disabled\n * button receives no pointer events — while the kit keeps owning what the button\n * says and when it is enabled. A replacement would hand every app the gating to\n * re-derive (the problem this component exists to end); `onBeforeFinish` can veto\n * a click but cannot explain a disabled button. The element is a plain `<Button>`,\n * so a wrapper that clones it with `disabled` works as expected.\n */\n renderFinish?: (button: ReactElement) => ReactNode;\n}) {\n const l = useKitLabels(\"wizard\", DEFAULT_WIZARD_LABELS, labels);\n const cancelTitleId = useId();\n\n // The step area never shrinks below the tallest step shown so far, so the button\n // bar does not jump up and down as Next and Back move between a long step and a\n // short one. This replaces a fixed `min-h-[300px]` (carried over from Kastlan,\n // whose steps are all long forms), which did the same for steps under 300px but\n // left a short wizard — two fields a step — with a ~200px hole above its buttons.\n // Measured rather than guessed: the floor is whatever the app's own steps need.\n const contentRef = useRef<HTMLDivElement>(null);\n const [contentFloor, setContentFloor] = useState(0);\n useLayoutEffect(() => {\n const el = contentRef.current;\n if (!el || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => {\n const h = el.offsetHeight;\n setContentFloor((floor) => (h > floor ? h : floor));\n });\n observer.observe(el);\n return () => observer.disconnect();\n }, []);\n\n const contextValue = useMemo(\n () => ({\n registerStepValidate: wizard.registerStepValidate,\n setNextBlocked: wizard.setNextBlocked,\n }),\n [wizard.registerStepValidate, wizard.setNextBlocked],\n );\n\n // Finish is only valid once every step before the last has been completed or\n // skipped (derived once in useWizard). Prevents a restored last-step index\n // (empty data after a reload) from firing an empty submit.\n const canFinish = wizard.canFinish;\n\n // Pending while the step's validators run (an async server check): a spinner and\n // `aria-busy`, and disabled — the hook's own re-entry guard still refuses a second\n // press, this just stops it looking like the first one did nothing.\n const pending = wizard.isValidating;\n const stepNextLabel = wizard.currentStep.nextLabel;\n\n // Every branch below assigns it; no initial value to overwrite.\n let forwardButton: ReactNode;\n if (wizard.isCommitStep) {\n const finishButton = (\n <Button\n variant={finishVariant}\n data-tour=\"wizard-finish\"\n onClick={wizard.finish}\n disabled={wizard.isSubmitting || pending || !canFinish || finishDisabled}\n aria-busy={wizard.isSubmitting || pending || undefined}\n >\n {/* Decorative: the button's text is what a reader hears. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {wizard.isSubmitting ? l.submitting : (stepNextLabel ?? l.finish)}\n </Button>\n );\n forwardButton = renderFinish ? renderFinish(finishButton) : finishButton;\n } else if (wizard.isLastStep) {\n // The last step after a commit. Nothing to finish; Done only when the app gave\n // an `onDone` — otherwise the step carries its own way out.\n forwardButton = wizard.canDone ? (\n <Button variant=\"brand\" data-tour=\"wizard-done\" onClick={wizard.done} disabled={doneDisabled}>\n {stepNextLabel ?? l.done ?? DEFAULT_WIZARD_LABELS.done}\n </Button>\n ) : null;\n } else {\n forwardButton = (\n <Button\n variant=\"brand\"\n data-tour=\"wizard-next\"\n onClick={wizard.goNext}\n disabled={wizard.nextBlocked || pending}\n aria-busy={pending || undefined}\n >\n {pending && <Spinner label={null} className=\"size-4\" />}\n {stepNextLabel ?? l.next}\n </Button>\n );\n }\n\n return (\n <div className={cn(\"flex flex-col gap-6\", className)}>\n {(title || description) && (\n <div>\n {title && (\n <h1 className=\"text-2xl font-bold tracking-tight text-[var(--text-primary)]\">\n {title}\n </h1>\n )}\n {description && (\n <p className=\"mt-1 text-sm text-[var(--text-muted)]\">{description}</p>\n )}\n </div>\n )}\n\n {/* Step indicator */}\n <nav aria-label={l.steps}>\n <ol className=\"flex w-full items-center\">\n {wizard.steps.map((step, index) => {\n const status = wizard.stepStatus(index);\n const isLast = index === wizard.steps.length - 1;\n\n return (\n <li key={step.id} className={cn(\"flex items-center\", !isLast && \"flex-1\")}>\n <button\n type=\"button\"\n onClick={() => wizard.goToStep(index)}\n // `canGoToStep` adds the commit lock: once past a committing step,\n // the steps up to it are done with and cannot be reopened.\n disabled={status === \"upcoming\" || !wizard.canGoToStep(index)}\n aria-current={status === \"active\" ? \"step\" : undefined}\n className={cn(\n \"group flex flex-col items-center gap-1.5\",\n status === \"upcoming\" ? \"cursor-default\" : \"cursor-pointer\",\n )}\n >\n {/* Circle */}\n <span\n className={cn(\n \"flex size-8 shrink-0 items-center justify-center rounded-full border-2 text-xs font-semibold transition-colors\",\n status === \"completed\" &&\n \"border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-contrast)]\",\n status === \"active\" &&\n \"border-[var(--brand)] bg-[var(--bg-surface)] text-[var(--brand)]\",\n status === \"upcoming\" &&\n \"border-[var(--border)] bg-[var(--bg-surface)] text-[var(--text-placeholder)]\",\n status === \"skipped\" &&\n \"border-[var(--border)] bg-[var(--bg-surface-2)] text-[var(--text-muted)]\",\n )}\n >\n {status === \"completed\" ? <Check className=\"size-4\" /> : index + 1}\n </span>\n {/* Label — hidden on mobile, where the line below carries it */}\n <span\n className={cn(\n \"hidden text-xs font-medium md:block\",\n status === \"active\" || status === \"completed\"\n ? \"text-[var(--text-primary)]\"\n : \"text-[var(--text-muted)]\",\n )}\n >\n {step.label}\n </span>\n </button>\n\n {/* Connector line */}\n {!isLast && (\n <div\n className={cn(\n \"mx-2 h-0.5 flex-1\",\n status === \"completed\" ? \"bg-[var(--brand)]\" : \"bg-[var(--border)]\",\n )}\n />\n )}\n </li>\n );\n })}\n </ol>\n {/* Mobile: the labels above are hidden, so name the current step here */}\n <p className=\"mt-2 text-center text-sm font-medium text-[var(--text-primary)] md:hidden\">\n {l.step(wizard.currentStepIndex + 1, wizard.steps.length)}\n {\" — \"}\n {wizard.currentStep.label}\n </p>\n </nav>\n\n {/* Step content */}\n <WizardContextProvider value={contextValue}>\n <div style={contentFloor > 0 ? { minHeight: contentFloor } : undefined}>\n <div ref={contentRef}>{children}</div>\n </div>\n </WizardContextProvider>\n\n {/* Submit error */}\n {wizard.error && (\n <div\n role=\"alert\"\n className={cn(\n \"text-sm text-[var(--danger)]\",\n alertFrameClass(\"danger\"),\n )}\n >\n {wizard.error}\n </div>\n )}\n\n {/* Navigation bar */}\n <div className=\"flex items-center justify-between border-t border-[var(--border)] pt-4\">\n {/* The empty span keeps the right-hand group on the right when Cancel is off. */}\n {wizard.canCancel ? (\n <Button variant=\"secondary\" onClick={wizard.cancel}>\n {l.cancel}\n </Button>\n ) : (\n <span />\n )}\n <div className=\"flex items-center gap-2\">\n {wizard.canGoBack && (\n // Disabled, not hidden, while the commit runs: it comes back if the commit\n // fails, and a button that vanishes mid-click is its own kind of surprise.\n <Button variant=\"secondary\" onClick={wizard.goBack} disabled={wizard.isSubmitting}>\n {l.back}\n </Button>\n )}\n {wizard.currentStep.optional && !wizard.isLastStep && !wizard.isCommitStep && (\n <Button\n variant=\"ghost\"\n onClick={wizard.skip}\n disabled={wizard.nextBlocked || wizard.isValidating}\n >\n {l.skip}\n </Button>\n )}\n {forwardButton}\n </div>\n </div>\n\n {/* Cancel confirmation */}\n {wizard.showCancelDialog && (\n <Modal onClose={wizard.dismissCancel} labelledBy={cancelTitleId} className=\"space-y-4\">\n <div className=\"space-y-1.5\">\n <h2 id={cancelTitleId} className=\"text-lg font-semibold text-[var(--text-primary)]\">\n {l.cancelTitle}\n </h2>\n <p className=\"text-sm text-[var(--text-muted)]\">{l.confirmCancel}</p>\n </div>\n <div className=\"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\">\n <Button variant=\"secondary\" onClick={wizard.dismissCancel}>\n {l.cancelDismissLabel}\n </Button>\n <Button variant=\"danger\" onClick={wizard.confirmCancel}>\n {l.cancelConfirmLabel}\n </Button>\n </div>\n </Modal>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
package/dist/wizard.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { WizardStep, WizardStepProps } from './wizard/wizard-step.js';
|
|
|
6
6
|
export { WizardSummary } from './wizard/wizard-summary.js';
|
|
7
7
|
export { StepperNav } from './wizard/stepper-nav.js';
|
|
8
8
|
import 'react';
|
|
9
|
-
import './kit-labels-
|
|
9
|
+
import './kit-labels-BHfRBbHs.js';
|
|
10
10
|
import './components/data-table-labels.js';
|
|
11
11
|
import './components/mini-calendar.js';
|
|
12
12
|
import './components/calendar-heatmap.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
|
|
6
6
|
"_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
"LICENSE"
|
|
69
69
|
],
|
|
70
70
|
"scripts": {
|
|
71
|
-
"typecheck": "tsc --noEmit && tsc -p tsconfig.node.json",
|
|
72
|
-
"lint": "eslint . --max-warnings 0",
|
|
71
|
+
"typecheck": "tsc --noEmit --incremental --tsBuildInfoFile node_modules/.cache/tsc/app.tsbuildinfo && tsc -p tsconfig.node.json --incremental --tsBuildInfoFile node_modules/.cache/tsc/node.tsbuildinfo",
|
|
72
|
+
"lint": "eslint . --max-warnings 0 --cache --cache-location node_modules/.cache/eslint/",
|
|
73
73
|
"check:tokens": "node scripts/check-token-discipline.mjs",
|
|
74
74
|
"test:coverage": "vitest run --coverage",
|
|
75
75
|
"check:graph": "node scripts/check-module-graph.mjs",
|
|
@@ -77,7 +77,10 @@
|
|
|
77
77
|
"check:tailwind": "node scripts/check-tailwind-scan.mjs",
|
|
78
78
|
"check:security": "npm audit --audit-level=high",
|
|
79
79
|
"check:commits": "node scripts/check-commit-range.cjs",
|
|
80
|
-
"check": "npm run typecheck && npm run lint && npm run check:tokens && npm run
|
|
80
|
+
"check:static": "npm run typecheck && npm run lint && npm run check:tokens && npm run check:security && npm run check:commits",
|
|
81
|
+
"check:test": "npm run test:coverage",
|
|
82
|
+
"check:build": "npm run build && npm run check:graph && npm run check:package && npm run build:showcase && npm run check:tailwind",
|
|
83
|
+
"check": "node scripts/check.mjs",
|
|
81
84
|
"test": "vitest run",
|
|
82
85
|
"test:watch": "vitest",
|
|
83
86
|
"build": "node scripts/tsup-quiet.mjs && node scripts/fix-esm-extensions.mjs",
|
|
@@ -271,6 +271,13 @@ export function AlertBanner({
|
|
|
271
271
|
inlineRow ? "inline-flex items-center" : strip ? "flex flex-wrap items-center gap-y-1" : "flex items-start",
|
|
272
272
|
sm ? "gap-1.5 text-xs" : inline ? "gap-1.5 text-sm" : "gap-2 text-sm",
|
|
273
273
|
);
|
|
274
|
+
// The strip's message claims 12rem before anything shrinks. `flex-1` is a 0 basis,
|
|
275
|
+
// which never makes a wrapping row wrap — the action and the × squeezed the message
|
|
276
|
+
// into a three-word column on a phone instead. With a real basis the action drops to
|
|
277
|
+
// a second line once message + action no longer fit, and the message keeps the width.
|
|
278
|
+
const grow = strip ? "flex-[1_1_12rem]" : "flex-1";
|
|
279
|
+
// Inside a whole-row banner's button or link, which then carries `grow` itself.
|
|
280
|
+
const nested = interactive && (onDismiss !== undefined || (action !== undefined && action !== null));
|
|
274
281
|
const frame = inline
|
|
275
282
|
? ""
|
|
276
283
|
: cn(
|
|
@@ -283,7 +290,7 @@ export function AlertBanner({
|
|
|
283
290
|
const content = (
|
|
284
291
|
<>
|
|
285
292
|
{glyph}
|
|
286
|
-
<span className={cn(!inlineRow && "flex-1", "min-w-0 text-start")}>{children}</span>
|
|
293
|
+
<span className={cn(!inlineRow && (nested ? "flex-1" : grow), "min-w-0 text-start")}>{children}</span>
|
|
287
294
|
{interactive && (
|
|
288
295
|
<ChevronRight
|
|
289
296
|
aria-hidden
|
|
@@ -346,7 +353,8 @@ export function AlertBanner({
|
|
|
346
353
|
const split = dismiss !== null || trailing !== null;
|
|
347
354
|
const actionClass = split
|
|
348
355
|
? cn(
|
|
349
|
-
"flex min-w-0
|
|
356
|
+
"flex min-w-0 text-start",
|
|
357
|
+
grow,
|
|
350
358
|
sm ? "gap-1.5" : "gap-2",
|
|
351
359
|
inlineRow || strip ? "items-center" : "items-start",
|
|
352
360
|
"outline-none after:absolute after:inset-0 after:content-['']",
|
|
@@ -172,6 +172,14 @@ interface AmountInputProps {
|
|
|
172
172
|
/** The largest amount the field settles on (a deposit release capped at the
|
|
173
173
|
* balance). A higher figure is lowered to it when it settles. */
|
|
174
174
|
max?: number;
|
|
175
|
+
/**
|
|
176
|
+
* The SETTLED figure, dot-decimal, sign included — fired on blur, on Enter, on the
|
|
177
|
+
* numpad's "=" and on every result the calculator writes back, never on a keystroke
|
|
178
|
+
* ("1200+" is not a figure yet). `onChange` still reports every keystroke; this is
|
|
179
|
+
* for a host that stores a number and must not see drafts (kastlan 52; see
|
|
180
|
+
* {@link MoneyField}, which is built on it).
|
|
181
|
+
*/
|
|
182
|
+
onCommit?: (value: string) => void;
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
/** The currency's minor unit (CHF 2, JPY 0), or `undefined` for no currency or a code
|
|
@@ -295,7 +303,7 @@ function isResultOf(previous: string, text: string, settle: (text: string) => st
|
|
|
295
303
|
}
|
|
296
304
|
|
|
297
305
|
export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
|
|
298
|
-
({ value, onChange, currency, onCurrencyChange, placeholder, label, disabled, invalid: invalidProp, className, id, ariaLabel, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, autoFocus, tone = "neutral", negative = false, onNegativeChange, variant = "field", align = "start", labels, currencyNames, digits: digitsProp, min, max }, ref) => {
|
|
306
|
+
({ value, onChange, currency, onCurrencyChange, placeholder, label, disabled, invalid: invalidProp, className, id, ariaLabel, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, autoFocus, tone = "neutral", negative = false, onNegativeChange, variant = "field", align = "start", labels, currencyNames, digits: digitsProp, min, max, onCommit }, ref) => {
|
|
299
307
|
const generatedId = useId();
|
|
300
308
|
const fieldId = id ?? generatedId;
|
|
301
309
|
const invalid = Boolean(invalidProp) || ariaInvalid === true || ariaInvalid === "true";
|
|
@@ -399,7 +407,9 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
|
|
|
399
407
|
};
|
|
400
408
|
const commit = () => {
|
|
401
409
|
setDraft(null);
|
|
402
|
-
|
|
410
|
+
const settled = settle(commitExpression(shown));
|
|
411
|
+
handleText(settled);
|
|
412
|
+
onCommit?.(settled);
|
|
403
413
|
};
|
|
404
414
|
const typed = (raw: string) => {
|
|
405
415
|
setDraft(raw);
|
|
@@ -533,7 +543,9 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
|
|
|
533
543
|
value={display}
|
|
534
544
|
onChange={(result, expression) => {
|
|
535
545
|
setDraft(null);
|
|
536
|
-
|
|
546
|
+
const settled = settle(result);
|
|
547
|
+
handleText(settled, expression);
|
|
548
|
+
onCommit?.(settled);
|
|
537
549
|
}}
|
|
538
550
|
className="px-1.5"
|
|
539
551
|
ariaLabel={labels?.calculatorTrigger}
|
|
@@ -622,7 +634,9 @@ export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
|
|
|
622
634
|
onChange={typed}
|
|
623
635
|
onResult={(result) => {
|
|
624
636
|
setDraft(null);
|
|
625
|
-
|
|
637
|
+
const settled = settle(result);
|
|
638
|
+
handleText(settled);
|
|
639
|
+
onCommit?.(settled);
|
|
626
640
|
}}
|
|
627
641
|
onDone={() => innerRef.current?.blur()}
|
|
628
642
|
label={label}
|