@marktiderman/genesis-ui 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/SKILL.md +137 -0
  2. package/dist/{AppShell-D_E_Hk4g.d.ts → AppShell-CX0M-dfc.d.ts} +24 -6
  3. package/dist/chunk-CDYZRZ6U.js +141 -0
  4. package/dist/chunk-CDYZRZ6U.js.map +1 -0
  5. package/dist/{chunk-UBCYXOXT.js → chunk-DWM7ELFI.js} +28 -7
  6. package/dist/chunk-DWM7ELFI.js.map +1 -0
  7. package/dist/{chunk-GDFNAHI4.js → chunk-IXGAEECT.js} +4 -2
  8. package/dist/chunk-IXGAEECT.js.map +1 -0
  9. package/dist/{chunk-D4JTQYP5.js → chunk-JA3ZKULS.js} +3 -3
  10. package/dist/{chunk-D4JTQYP5.js.map → chunk-JA3ZKULS.js.map} +1 -1
  11. package/dist/chunk-VFO3I7PV.js +24 -0
  12. package/dist/chunk-VFO3I7PV.js.map +1 -0
  13. package/dist/components/data/index.d.ts +42 -9
  14. package/dist/components/data/index.js +208 -76
  15. package/dist/components/data/index.js.map +1 -1
  16. package/dist/components/layout/index.d.ts +2 -2
  17. package/dist/components/layout/index.js +6 -6
  18. package/dist/components/patterns/index.js +3 -3
  19. package/dist/hooks/index.d.ts +2 -2
  20. package/dist/hooks/index.js +4 -4
  21. package/dist/hooks/use-data-filters.d.ts +1 -1
  22. package/dist/hooks/use-data-filters.js +2 -2
  23. package/dist/hooks/use-mobile.d.ts +1 -0
  24. package/dist/hooks/use-mobile.js +1 -1
  25. package/dist/hooks/use-resource-page.d.ts +1 -1
  26. package/dist/hooks/use-resource-page.js +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +12 -12
  29. package/dist/navigation-DBwcZ6vb.d.ts +163 -0
  30. package/dist/styles.css +11 -0
  31. package/dist/{use-resource-page-DTQS011Q.d.ts → use-resource-page-Dd2VUv30.d.ts} +15 -1
  32. package/package.json +8 -6
  33. package/dist/chunk-6NPEMLBA.js +0 -51
  34. package/dist/chunk-6NPEMLBA.js.map +0 -1
  35. package/dist/chunk-GDFNAHI4.js.map +0 -1
  36. package/dist/chunk-M7AU5DDM.js +0 -26
  37. package/dist/chunk-M7AU5DDM.js.map +0 -1
  38. package/dist/chunk-UBCYXOXT.js.map +0 -1
  39. package/dist/navigation-CpOEyUSb.d.ts +0 -94
package/SKILL.md ADDED
@@ -0,0 +1,137 @@
1
+ ---
2
+ name: genesis-ui
3
+ description: Build web UI with the Genesis design system (@marktiderman/genesis-ui) — pick the right rung (pattern, slots, primitives, hooks), import by subpath, theme from a brand. Load before writing any page, list, form or dashboard on Genesis.
4
+ ---
5
+
6
+ # genesis-ui
7
+
8
+ Genesis is a starting point, not a ceiling. **Reuse > Extend > Create**: before
9
+ writing a component, find the export that already answers the question, then
10
+ step down the ladder one rung — never off it.
11
+
12
+ ## The ladder
13
+
14
+ Every pattern offers four rungs. Take the highest that fits.
15
+
16
+ **1 · The pattern** — the whole page, wired.
17
+
18
+ ```tsx
19
+ <ResourcePage title="Questions" resource="questions" columns={["title"]} />
20
+ ```
21
+
22
+ **2 · `slots` / `slotProps`** — keep the wiring, change one part. `null` ejects a part.
23
+
24
+ ```tsx
25
+ <ResourcePage
26
+ title="Questions"
27
+ slots={{ filters: MyFilters, bulkBar: null }} // replace a part, or drop it
28
+ slotProps={{ table: { className: "text-xs" } }} // or feed it more props — wins over ours
29
+ />
30
+ ```
31
+
32
+ **3 · The primitives** — compose the page yourself from Genesis parts.
33
+
34
+ ```tsx
35
+ <DataPageShell title="Questions" filters={<DataFilters … />}>
36
+ <DataTable … />
37
+ </DataPageShell>
38
+ ```
39
+
40
+ **4 · The hook** — all of the logic, none of the markup.
41
+
42
+ ```tsx
43
+ const page = useResourcePage({ … }); // state, paging, selection, sorting
44
+ return <WhateverYouWant {...page} />;
45
+ ```
46
+
47
+ Dropping to rung 3 because one part was wrong re-derives wiring that was
48
+ already right; that re-derivation is where forks come from. Reach for rung 2
49
+ first. When the app hand-builds something Genesis nearly had, that is a bug
50
+ report against Genesis (`packages/ui/EXTENDING.md`), not a reason to fork.
51
+
52
+ ## The layer contract (docs/FRAMEWORK.md)
53
+
54
+ 1. Layers: tokens → primitives → patterns → layouts → data-bound; each depends downward only.
55
+ 2. A primitive does one interaction and knows no domain words; a pattern composes primitives.
56
+ 3. A layout positions things without knowing what they are; it carries no domain and no data.
57
+ 4. Data-bound components need a resource contract (`DataProvider`); nothing else fetches.
58
+ 5. Capability ships additively; removals ship as a deprecated alias until the next major.
59
+
60
+ ## Reach for this first
61
+
62
+ | You need | Use | Import from |
63
+ | ------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
64
+ | App skeleton: nav, header, content | `AppShell` | `@marktiderman/genesis-ui/layout` |
65
+ | Page title row with count, view toggle, options | `PageHeader` (+ `options.configurable` for the in-header options popover) | `/layout` |
66
+ | List page chrome: header, filters, loading/empty/error | `DataPageShell` (`stats`, `filters` slots) | `/data` |
67
+ | A whole CRUD list page over a resource | `ResourcePage` | `/data` |
68
+ | Record detail side panel | `DetailPanel` | `/data` |
69
+ | Dashboard / settings / form / detail page shape | `DashboardPage`, `SettingsPage`, `FormPage`, `DetailPage` | `/dashboard-page`, `/settings-page`, `/form-page`, `/detail-page` |
70
+ | KPI tile | `StatCard` | `/data` |
71
+ | Table cells: dates, numbers, money, badges, links, thumbnails | `DateCell`, `NumberCell`, `CurrencyCell`, `BadgeCell`, `LinkCell`, `ThumbnailCell` | `/data` |
72
+ | Search, sort, filters, status chips/tabs, saved views | `DataFilters` | `/data` |
73
+ | Create/edit form bound to a resource | `ResourceForm` / `useResourceForm` | `/data` / `@marktiderman/genesis-core/hooks` |
74
+ | Nothing-here state with optional clear-filters | `EmptyState` | `/empty-state` |
75
+ | Settings list rows | `SettingsRow`, `ToggleRow` | `/settings-row`, `/toggle-row` |
76
+ | Cmd-K palette | `CommandPalette`, `useCommandPaletteHotkey` | `/command-palette` |
77
+ | Arrow-key list navigation | `useKeyboardNavigation` | `/hooks/use-keyboard-navigation` |
78
+ | Persisted page size / density | `useViewSettings` | `/hooks/use-view-settings` |
79
+
80
+ ## Import rule
81
+
82
+ Import by subpath, never the root barrel in app code: `@marktiderman/genesis-ui/button`,
83
+ `/card`, `/data`, `/layout`. The root barrel drags every component and peer dependency into
84
+ the bundle. Hooks are `@marktiderman/genesis-ui/hooks/<file>` (e.g. `/hooks/use-view-settings`),
85
+ not `/hooks`. Never import from the legacy `@genesis/*` scope.
86
+
87
+ ## Theming
88
+
89
+ Tokens come from `@marktiderman/genesis-design-system`. A Tailwind v4 web app imports, in
90
+ order: `tailwindcss`, then `@marktiderman/genesis-design-system/themes/genesis.css` (the
91
+ semantic variables), then `.../themes/genesis-tailwind4.css` (the `@theme inline` bridge that
92
+ turns them into `bg-primary`, `font-sans` and the rest, plus the `.dark` class variant). Do not
93
+ hand-write that bridge, and do not use `presets/tailwind`: it is a v3-shaped JS preset. Override
94
+ fonts or colours in an unlayered `:root` after the imports, not in `@theme`, since genesis.css
95
+ wins over `@layer theme`. `tailwindFromBrand(brand).css` adds brand ramps
96
+ (`--color-neutral-500` and the like) on top. Components reference only semantic tokens
97
+ (`bg-primary`, `text-muted-foreground`), never raw colours.
98
+
99
+ ## Full inventory
100
+
101
+ `docs/component-reference.md` lists every export with its props and stability tag. It is
102
+ generated (`pnpm gen:component-reference`); do not edit it by hand.
103
+
104
+ ## Wiring into an app
105
+
106
+ **Tailwind class scanning.** Tailwind v4 skips git-ignored paths such as `node_modules`, so genesis-ui's classes
107
+ are missing unless the app points at them. Import the shipped source entry — never hardcode an
108
+ `@source` path into `node_modules`:
109
+
110
+ ```css
111
+ @import "tailwindcss";
112
+ @import "@marktiderman/genesis-ui/styles.css"; /* = @source over the package's dist/ */
113
+ ```
114
+
115
+ **Active nav items with `?query` / `#hash`.** Pass the full location as `activePath`
116
+ (`pathname + search + hash`). Plain items still match on the path; an item whose `to` carries a
117
+ query or hash (`/admin?tab=users`, `/settings#profile`) is active only when those match too.
118
+ Do not write a custom `isActive` for this. `isPathActive(to, activePath, { end: true })` is the
119
+ exact-path variant.
120
+
121
+ **Hierarchical "up" navigation.** Describe the logical hierarchy once as a `NavigationNode` tree
122
+ and call `resolveNavigation(tree, pathname)` from the root barrel or `/data`. It returns
123
+ `{ current, parent, items, trail, params }` with every path interpolated from the URL's params.
124
+ Use `parent` for a back-to-parent link and `items` for contextual nav — never `history.back()`.
125
+
126
+ ```ts
127
+ const nav = resolveNavigation(
128
+ { id: "app", path: "/app", label: "Home", items: globalItems, children: [
129
+ { id: "clients", path: "/dashboard", label: "Clients", items: [], children: [
130
+ { id: "client", path: "/clients/$clientId", label: "Client", items: clientItems },
131
+ ] },
132
+ ] },
133
+ pathname,
134
+ );
135
+ // pathname "/clients/c1/tasks" → nav.current.id "client", nav.parent.path "/dashboard",
136
+ // nav.params { clientId: "c1" }
137
+ ```
@@ -3,7 +3,7 @@ import { ReactNode } from 'react';
3
3
  import { LucideIcon } from 'lucide-react';
4
4
  import { V as ViewMode } from './view-toggle-CahQ87PW.js';
5
5
  import { Density } from './hooks/use-view-settings.js';
6
- import { L as LinkComponent } from './navigation-CpOEyUSb.js';
6
+ import { L as LinkComponent } from './navigation-DBwcZ6vb.js';
7
7
 
8
8
  /**
9
9
  * Declarative chrome for PageHeader — developer defaults + optional
@@ -18,6 +18,12 @@ interface PageHeaderOptions {
18
18
  showViewToggle?: boolean;
19
19
  showViewSettings?: boolean;
20
20
  showCreate?: boolean;
21
+ /**
22
+ * Stat cards row under the title block. Off by default — a header that
23
+ * receives `stats` still hides them until the developer or the user turns
24
+ * this on, so an existing page gains no new chrome by upgrading.
25
+ */
26
+ showStats?: boolean;
21
27
  /** Ghost ListFilter control in the actions cluster */
22
28
  showDetailsToggle?: boolean;
23
29
  /** Quiet hint beside actions while details are collapsed */
@@ -56,6 +62,13 @@ interface PageHeaderProps {
56
62
  optionsStorageKey?: string;
57
63
  /** Extra trailing actions (after create); prefer first-class slots below */
58
64
  actions?: ReactNode;
65
+ /**
66
+ * Stat cards row, full width, directly under the title block and above
67
+ * `details`. Layout-only: pass `<StatCard>`s (or anything else) and the
68
+ * header positions them. Rendered only when `options.showStats` resolves
69
+ * true — see {@link PageHeaderOptions.showStats}.
70
+ */
71
+ stats?: ReactNode;
59
72
  /** Collapsible details region below the title row */
60
73
  details?: ReactNode;
61
74
  detailsOpen?: boolean;
@@ -90,7 +103,7 @@ interface PageHeaderProps {
90
103
  */
91
104
  secondaryActions?: PageHeaderAction[];
92
105
  }
93
- declare function PageHeader({ title, subtitle, icon: Icon, options: optionsProp, optionsStorageKey, actions, details, detailsOpen, onDetailsOpenChange, detailsActive, detailsHint, count, totalCount, entityName, countActive, viewMode, onViewModeChange, pageSize, onPageSizeChange, density, onDensityChange, createAction, secondaryActions, }: PageHeaderProps): React.JSX.Element;
106
+ declare function PageHeader({ title, subtitle, icon: Icon, options: optionsProp, optionsStorageKey, actions, stats, details, detailsOpen, onDetailsOpenChange, detailsActive, detailsHint, count, totalCount, entityName, countActive, viewMode, onViewModeChange, pageSize, onPageSizeChange, density, onDensityChange, createAction, secondaryActions, }: PageHeaderProps): React.JSX.Element;
94
107
 
95
108
  interface NavItem {
96
109
  to: string;
@@ -100,7 +113,10 @@ interface NavItem {
100
113
  children?: NavItem[];
101
114
  badge?: string | number;
102
115
  disabled?: boolean;
103
- /** Exact-match active state (e.g. overview `/`) */
116
+ /**
117
+ * Exact-match active state (e.g. overview `/`). Exact on the path only, so
118
+ * an `activePath` carrying `?search#hash` still matches.
119
+ */
104
120
  end?: boolean;
105
121
  }
106
122
  /**
@@ -207,9 +223,11 @@ interface AppShellProps {
207
223
  */
208
224
  linkComponent?: LinkComponent;
209
225
  /**
210
- * Current path used to highlight the active nav item. Router consumers pass
211
- * `useLocation().pathname` (RR) or `usePathname()` (Next). Omit to disable
212
- * active highlighting.
226
+ * Current location used to highlight the active nav item. Pass the full
227
+ * location, `pathname + search + hash` (React Router: `useLocation()`), so
228
+ * items whose `to` names a query or hash (`/admin?tab=users`) can become
229
+ * active. A pathname alone still works for path-only items, `end` included.
230
+ * Omit to disable active highlighting.
213
231
  */
214
232
  activePath?: string;
215
233
  /**
@@ -0,0 +1,141 @@
1
+ "use client";
2
+ import { useState, useEffect, useCallback } from 'react';
3
+ import { jsx } from 'react/jsx-runtime';
4
+
5
+ // src/navigation.tsx
6
+ var DefaultLink = ({
7
+ href,
8
+ children,
9
+ testID,
10
+ ...rest
11
+ }) => /* @__PURE__ */ jsx("a", { href, "data-testid": testID, ...rest, children });
12
+ function splitHref(href) {
13
+ const hashAt = href.indexOf("#");
14
+ const hash = hashAt === -1 ? "" : href.slice(hashAt + 1);
15
+ const beforeHash = hashAt === -1 ? href : href.slice(0, hashAt);
16
+ const searchAt = beforeHash.indexOf("?");
17
+ const rawPath = searchAt === -1 ? beforeHash : beforeHash.slice(0, searchAt);
18
+ return {
19
+ path: rawPath.length > 1 ? rawPath.replace(/\/+$/, "") || "/" : rawPath,
20
+ search: searchAt === -1 ? "" : beforeHash.slice(searchAt + 1),
21
+ hash
22
+ };
23
+ }
24
+ function isPathActive(to, activePath, options) {
25
+ if (activePath == null) return false;
26
+ if (to === activePath) return true;
27
+ const target = splitHref(to);
28
+ const current = splitHref(activePath);
29
+ if (!target.search && !target.hash) {
30
+ if (target.path === current.path) return true;
31
+ if (options?.end) return false;
32
+ return target.path !== "/" && current.path.startsWith(target.path + "/");
33
+ }
34
+ if (target.path !== current.path) return false;
35
+ if (target.hash && target.hash !== current.hash) return false;
36
+ if (target.search) {
37
+ const want = new URLSearchParams(target.search);
38
+ const have = new URLSearchParams(current.search);
39
+ for (const key of new Set(want.keys())) {
40
+ const wanted = want.getAll(key);
41
+ const had = have.getAll(key);
42
+ if (!wanted.every((value) => had.includes(value))) return false;
43
+ }
44
+ }
45
+ return true;
46
+ }
47
+ function pathSegments(path) {
48
+ return splitHref(path).path.split("/").filter(Boolean);
49
+ }
50
+ function decodeSegment(segment) {
51
+ try {
52
+ return decodeURIComponent(segment);
53
+ } catch {
54
+ return segment;
55
+ }
56
+ }
57
+ function resolveNavigation(tree, pathname) {
58
+ const segments = pathSegments(pathname).map(decodeSegment);
59
+ const matches = (pattern) => pattern.length <= segments.length && pattern.every(
60
+ (part, i) => part.startsWith("$") || decodeSegment(part) === segments[i]
61
+ );
62
+ const compare = (a, b) => {
63
+ const shared = Math.min(a.pattern.length, b.pattern.length);
64
+ for (let i = 0; i < shared; i++) {
65
+ const aParam = a.pattern[i].startsWith("$");
66
+ const bParam = b.pattern[i].startsWith("$");
67
+ if (aParam !== bParam) return aParam ? -1 : 1;
68
+ }
69
+ if (a.pattern.length !== b.pattern.length) {
70
+ return a.pattern.length - b.pattern.length;
71
+ }
72
+ return a.trail.length - b.trail.length;
73
+ };
74
+ let best;
75
+ const visit = (node, ancestors) => {
76
+ const trail2 = [...ancestors, node];
77
+ const pattern = pathSegments(node.path);
78
+ if (matches(pattern)) {
79
+ const candidate = { trail: trail2, pattern };
80
+ if (!best || compare(candidate, best) > 0) best = candidate;
81
+ }
82
+ for (const child of node.children ?? []) visit(child, trail2);
83
+ };
84
+ visit(tree, []);
85
+ const winner = best ?? { trail: [tree], pattern: [] };
86
+ const params = {};
87
+ winner.pattern.forEach((part, i) => {
88
+ if (part.startsWith("$")) params[part.slice(1)] = segments[i];
89
+ });
90
+ const interpolate = (node) => ({
91
+ ...node,
92
+ path: node.path.split("/").map((part) => {
93
+ if (!part.startsWith("$")) return part;
94
+ const value = params[part.slice(1)];
95
+ return value == null ? part : encodeURIComponent(value);
96
+ }).join("/")
97
+ });
98
+ const trail = winner.trail.map(interpolate);
99
+ const current = trail[trail.length - 1];
100
+ return {
101
+ current,
102
+ parent: trail.length > 1 ? trail[trail.length - 2] : void 0,
103
+ items: current.items,
104
+ trail,
105
+ params
106
+ };
107
+ }
108
+ var defaultNavigate = (path) => {
109
+ if (typeof window !== "undefined") window.location.assign(path);
110
+ };
111
+ function useBrowserSearchParams() {
112
+ const read = () => new URLSearchParams(
113
+ typeof window !== "undefined" ? window.location.search : ""
114
+ );
115
+ const [searchParams, setLocal] = useState(read);
116
+ useEffect(() => {
117
+ if (typeof window === "undefined") return;
118
+ const onPop = () => setLocal(read());
119
+ window.addEventListener("popstate", onPop);
120
+ return () => window.removeEventListener("popstate", onPop);
121
+ }, []);
122
+ const setSearchParams = useCallback(
123
+ (next, options) => {
124
+ if (typeof window === "undefined") return;
125
+ const qs = next.toString();
126
+ const url = window.location.pathname + (qs ? `?${qs}` : "") + window.location.hash;
127
+ if (options?.replace) {
128
+ window.history.replaceState(window.history.state, "", url);
129
+ } else {
130
+ window.history.pushState(window.history.state, "", url);
131
+ }
132
+ setLocal(new URLSearchParams(next));
133
+ },
134
+ []
135
+ );
136
+ return { searchParams, setSearchParams };
137
+ }
138
+
139
+ export { DefaultLink, defaultNavigate, isPathActive, resolveNavigation, useBrowserSearchParams };
140
+ //# sourceMappingURL=chunk-CDYZRZ6U.js.map
141
+ //# sourceMappingURL=chunk-CDYZRZ6U.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/navigation.tsx"],"names":["trail"],"mappings":"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","file":"chunk-CDYZRZ6U.js","sourcesContent":["\"use client\";\n\n/**\n * Router-agnostic navigation primitives.\n *\n * genesis-ui does NOT depend on any router. Components that need to render a\n * link or navigate imperatively accept an injectable abstraction defined here,\n * and default to plain browser behavior (`<a>` / `window.location`). Consumers\n * on React Router or Next.js inject a thin adapter that maps these props to\n * their own `<Link>` / router.\n */\nimport {\n useCallback,\n useEffect,\n useState,\n type AnchorHTMLAttributes,\n type ComponentType,\n type ReactNode,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// Link abstraction\n// ---------------------------------------------------------------------------\n\n/**\n * Props passed to an injectable link component. The contract is a plain anchor\n * (`href`), so the default works with NO router. A React Router consumer maps\n * `href` → `to`; a Next.js consumer passes `next/link` directly (it already\n * accepts `href`, `className`, `children`).\n *\n * @stability Beta\n */\nexport interface GenesisLinkProps {\n /** Destination path or URL. */\n href: string;\n className?: string;\n onClick?: () => void;\n children?: ReactNode;\n /** Set to \"page\" when the link points at the active route. */\n \"aria-current\"?: AnchorHTMLAttributes<HTMLAnchorElement>[\"aria-current\"];\n /** Accessible name — useful when the visible label is hidden (icon rail). */\n \"aria-label\"?: string;\n /** Native tooltip / title attribute. */\n title?: string;\n /**\n * Stable test/automation selector. {@link DefaultLink} renders it as\n * `data-testid`; injected router adapters (`next/link`, RR `Link`) forward it\n * to the underlying `<a>` too.\n */\n testID?: string;\n}\n\n/**\n * A component that renders a navigable link from {@link GenesisLinkProps}.\n *\n * @stability Beta\n */\nexport type LinkComponent = ComponentType<GenesisLinkProps>;\n\n/**\n * Default link — a plain `<a>`. No router required.\n *\n * @stability Beta\n */\nexport const DefaultLink: LinkComponent = ({\n href,\n children,\n testID,\n ...rest\n}) => (\n <a href={href} data-testid={testID} {...rest}>\n {children}\n </a>\n);\n\n/**\n * Split a href into its path (trailing slash dropped, except for `/`), query\n * (no `?`) and hash (no `#`).\n */\nfunction splitHref(href: string): { path: string; search: string; hash: string } {\n const hashAt = href.indexOf(\"#\");\n const hash = hashAt === -1 ? \"\" : href.slice(hashAt + 1);\n const beforeHash = hashAt === -1 ? href : href.slice(0, hashAt);\n const searchAt = beforeHash.indexOf(\"?\");\n const rawPath = searchAt === -1 ? beforeHash : beforeHash.slice(0, searchAt);\n return {\n path: rawPath.length > 1 ? rawPath.replace(/\\/+$/, \"\") || \"/\" : rawPath,\n search: searchAt === -1 ? \"\" : beforeHash.slice(searchAt + 1),\n hash,\n };\n}\n\n/**\n * Options for {@link isPathActive}.\n *\n * @stability Beta\n */\nexport interface IsPathActiveOptions {\n /** Require an exact path match instead of a prefix match (like `NavLink end`). */\n end?: boolean;\n}\n\n/**\n * Default active-path matcher, mirroring React Router's `NavLink` (prefix\n * match, with `/` treated as exact). Consumers can override via an `isActive`\n * prop where components accept one.\n *\n * `activePath` may carry a query and hash (`pathname + search + hash`). An\n * item whose `to` has neither compares against the path part only, exactly as\n * before. An item that opts in by putting a query or hash in its `to`\n * (`/admin?tab=users`, `/settings#profile`) is active only when the path\n * matches exactly AND every query param it names has the same value in\n * `activePath` (extra params such as `&page=2` are ignored) AND, if it names a\n * hash, the hash is equal.\n *\n * @stability Beta\n */\nexport function isPathActive(\n to: string,\n activePath?: string,\n options?: IsPathActiveOptions,\n): boolean {\n if (activePath == null) return false;\n if (to === activePath) return true;\n const target = splitHref(to);\n const current = splitHref(activePath);\n\n if (!target.search && !target.hash) {\n if (target.path === current.path) return true;\n if (options?.end) return false;\n return target.path !== \"/\" && current.path.startsWith(target.path + \"/\");\n }\n\n if (target.path !== current.path) return false;\n if (target.hash && target.hash !== current.hash) return false;\n if (target.search) {\n const want = new URLSearchParams(target.search);\n const have = new URLSearchParams(current.search);\n for (const key of new Set(want.keys())) {\n const wanted = want.getAll(key);\n const had = have.getAll(key);\n if (!wanted.every((value) => had.includes(value))) return false;\n }\n }\n return true;\n}\n\n// ---------------------------------------------------------------------------\n// Hierarchical navigation resolver\n// ---------------------------------------------------------------------------\n\n/**\n * One node of a logical navigation hierarchy. `path` is a route pattern whose\n * segments are either static (`clients`) or named params (`$clientId`). A\n * node's place in the tree is its LOGICAL parent, which need not share its URL\n * prefix — `/clients/$clientId` can sit under `/dashboard`.\n *\n * @stability Beta\n */\nexport interface NavigationNode<T> {\n id: string;\n path: string;\n label: string;\n /** Contextual nav items to show while this node is current. */\n items: T[];\n children?: NavigationNode<T>[];\n}\n\n/**\n * Result of {@link resolveNavigation}. `current`, `parent` and every `trail`\n * entry are shallow copies of their tree nodes with `path` interpolated from\n * `params` (a param the URL did not supply stays as `$name`).\n *\n * @stability Beta\n */\nexport interface ResolvedNavigation<T> {\n /** The best-matching node — the root when nothing else matches. */\n current: NavigationNode<T>;\n /** `current`'s logical parent, for an \"up\" / back-to-parent link. */\n parent?: NavigationNode<T>;\n /** `current.items`. */\n items: T[];\n /** Root → `current`, inclusive. */\n trail: NavigationNode<T>[];\n /** Params captured by `current.path`, URL-decoded. */\n params: Record<string, string>;\n}\n\nfunction pathSegments(path: string): string[] {\n return splitHref(path).path.split(\"/\").filter(Boolean);\n}\n\nfunction decodeSegment(segment: string): string {\n try {\n return decodeURIComponent(segment);\n } catch {\n return segment;\n }\n}\n\n/**\n * Resolve where `pathname` sits in a logical navigation tree. Pure: it never\n * reads browser history or `window`.\n *\n * A node matches when its pattern matches a leading run of WHOLE path\n * segments (`/my` matches `/my/tasks`, never `/my-other`). Among matches the\n * winner is decided by positional specificity: at the first position where\n * one pattern is static and the other a `$param`, the static one wins (so\n * `/clients/new` beats `/clients/$clientId` whatever the sibling order, and\n * `/my` beats `/$space/tasks` for `/my/tasks`). Otherwise the pattern matching\n * more segments wins, then the deeper tree node.\n * Non-matching nodes are still searched, so a match keeps its logical\n * ancestors even when their URLs differ.\n *\n * @stability Beta\n */\nexport function resolveNavigation<T>(\n tree: NavigationNode<T>,\n pathname: string,\n): ResolvedNavigation<T> {\n // Compare decoded forms on both sides, so `/caf%C3%A9` matches a `/café`\n // node (and an encoded pattern matches a decoded pathname).\n const segments = pathSegments(pathname).map(decodeSegment);\n\n type Match = { trail: NavigationNode<T>[]; pattern: string[] };\n const matches = (pattern: string[]) =>\n pattern.length <= segments.length &&\n pattern.every(\n (part, i) => part.startsWith(\"$\") || decodeSegment(part) === segments[i],\n );\n\n // > 0 when `a` should win over `b`.\n // Positional specificity, as TanStack Router and React Router rank routes:\n // walking left to right, the first position where one pattern is static and\n // the other a `$param` decides. Only then does the longer match win.\n const compare = (a: Match, b: Match): number => {\n const shared = Math.min(a.pattern.length, b.pattern.length);\n for (let i = 0; i < shared; i++) {\n const aParam = a.pattern[i]!.startsWith(\"$\");\n const bParam = b.pattern[i]!.startsWith(\"$\");\n if (aParam !== bParam) return aParam ? -1 : 1;\n }\n if (a.pattern.length !== b.pattern.length) {\n return a.pattern.length - b.pattern.length;\n }\n return a.trail.length - b.trail.length;\n };\n\n let best: Match | undefined;\n const visit = (node: NavigationNode<T>, ancestors: NavigationNode<T>[]) => {\n const trail = [...ancestors, node];\n const pattern = pathSegments(node.path);\n if (matches(pattern)) {\n const candidate = { trail, pattern };\n if (!best || compare(candidate, best) > 0) best = candidate;\n }\n for (const child of node.children ?? []) visit(child, trail);\n };\n visit(tree, []);\n\n const winner: Match = best ?? { trail: [tree], pattern: [] };\n const params: Record<string, string> = {};\n winner.pattern.forEach((part, i) => {\n if (part.startsWith(\"$\")) params[part.slice(1)] = segments[i]!;\n });\n\n const interpolate = (node: NavigationNode<T>): NavigationNode<T> => ({\n ...node,\n path: node.path\n .split(\"/\")\n .map((part) => {\n if (!part.startsWith(\"$\")) return part;\n const value = params[part.slice(1)];\n return value == null ? part : encodeURIComponent(value);\n })\n .join(\"/\"),\n });\n\n const trail = winner.trail.map(interpolate);\n const current = trail[trail.length - 1]!;\n return {\n current,\n parent: trail.length > 1 ? trail[trail.length - 2] : undefined,\n items: current.items,\n trail,\n params,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Imperative navigation abstraction\n// ---------------------------------------------------------------------------\n\n/**\n * Injectable imperative navigation callback.\n *\n * @stability Beta\n */\nexport type NavigateFn = (path: string) => void;\n\n/**\n * Default navigate — a full-page navigation. No router required.\n *\n * @stability Beta\n */\nexport const defaultNavigate: NavigateFn = (path) => {\n if (typeof window !== \"undefined\") window.location.assign(path);\n};\n\n// ---------------------------------------------------------------------------\n// Search-params abstraction (for URL-synced hooks)\n// ---------------------------------------------------------------------------\n\n/**\n * Minimal, router-agnostic replacement for React Router's `useSearchParams`\n * return shape. Consumers may inject their router's implementation for full\n * SPA integration; otherwise the browser History API is used.\n *\n * @stability Beta\n */\nexport interface SearchParamsAdapter {\n searchParams: URLSearchParams;\n setSearchParams: (\n next: URLSearchParams,\n options?: { replace?: boolean },\n ) => void;\n}\n\n/**\n * Browser-native {@link SearchParamsAdapter} built on the History API. Reads\n * the current query string and writes updates via `pushState`/`replaceState`,\n * staying reactive to `popstate` events. No router required.\n *\n * @stability Beta\n */\nexport function useBrowserSearchParams(): SearchParamsAdapter {\n const read = () =>\n new URLSearchParams(\n typeof window !== \"undefined\" ? window.location.search : \"\",\n );\n\n const [searchParams, setLocal] = useState<URLSearchParams>(read);\n\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n const onPop = () => setLocal(read());\n window.addEventListener(\"popstate\", onPop);\n return () => window.removeEventListener(\"popstate\", onPop);\n }, []);\n\n const setSearchParams = useCallback<SearchParamsAdapter[\"setSearchParams\"]>(\n (next, options) => {\n if (typeof window === \"undefined\") return;\n const qs = next.toString();\n const url =\n window.location.pathname + (qs ? `?${qs}` : \"\") + window.location.hash;\n if (options?.replace) {\n window.history.replaceState(window.history.state, \"\", url);\n } else {\n window.history.pushState(window.history.state, \"\", url);\n }\n setLocal(new URLSearchParams(next));\n },\n [],\n );\n\n return { searchParams, setSearchParams };\n}\n"]}
@@ -8,7 +8,7 @@ import { Switch } from './chunk-27ELYTGE.js';
8
8
  import { Button } from './chunk-NWSCYLPT.js';
9
9
  import { Label } from './chunk-F47U5KNW.js';
10
10
  import { cn } from './chunk-NZ5WBA4R.js';
11
- import { DefaultLink, isPathActive } from './chunk-6NPEMLBA.js';
11
+ import { DefaultLink, isPathActive } from './chunk-CDYZRZ6U.js';
12
12
  import { useId, useState, useEffect, useMemo, useCallback } from 'react';
13
13
  import { ListFilter, MoreHorizontal, SlidersHorizontal, Menu, X, PanelLeft, PanelLeftClose, Sun, Moon, ChevronDown } from 'lucide-react';
14
14
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
@@ -44,6 +44,7 @@ var OPTION_DEFAULTS = {
44
44
  showViewToggle: true,
45
45
  showViewSettings: true,
46
46
  showCreate: true,
47
+ showStats: false,
47
48
  showDetailsToggle: true,
48
49
  showDetailsHint: true,
49
50
  subtitleInDetails: void 0,
@@ -58,6 +59,7 @@ var BOOLEAN_OPTION_KEYS = [
58
59
  "showViewToggle",
59
60
  "showViewSettings",
60
61
  "showCreate",
62
+ "showStats",
61
63
  "showDetailsToggle",
62
64
  "showDetailsHint",
63
65
  "subtitleInDetails",
@@ -89,6 +91,7 @@ function PageHeader({
89
91
  options: optionsProp,
90
92
  optionsStorageKey,
91
93
  actions,
94
+ stats,
92
95
  details,
93
96
  detailsOpen,
94
97
  onDetailsOpenChange,
@@ -109,6 +112,7 @@ function PageHeader({
109
112
  }) {
110
113
  const detailsRegionId = useId();
111
114
  const hasDetails = details != null;
115
+ const hasStats = stats != null;
112
116
  const hasCount = count !== void 0 && totalCount !== void 0;
113
117
  const hasViewToggle = Boolean(viewMode && onViewModeChange);
114
118
  const hasViewSettings = Boolean(onPageSizeChange && onDensityChange);
@@ -143,13 +147,14 @@ function PageHeader({
143
147
  merged.subtitleInDetails = hasDetails;
144
148
  }
145
149
  if (merged.configurable === void 0) {
146
- merged.configurable = hasDetails || hasCount || hasViewSettings || hasViewToggle;
150
+ merged.configurable = hasDetails || hasStats || hasCount || hasViewSettings || hasViewToggle;
147
151
  }
148
152
  return merged;
149
153
  }, [
150
154
  optionsProp,
151
155
  userOverrides,
152
156
  hasDetails,
157
+ hasStats,
153
158
  hasCount,
154
159
  hasViewSettings,
155
160
  hasViewToggle
@@ -185,6 +190,7 @@ function PageHeader({
185
190
  available: hasViewSettings
186
191
  },
187
192
  { key: "showCreate", label: "Create action", available: hasCreate },
193
+ { key: "showStats", label: "Stat cards", available: hasStats },
188
194
  {
189
195
  key: "showDetailsToggle",
190
196
  label: "Filters / details",
@@ -197,6 +203,7 @@ function PageHeader({
197
203
  }
198
204
  ].filter((t) => t.available);
199
205
  const showDetailsToggle = hasDetails && Boolean(resolved.showDetailsToggle);
206
+ const showStats = hasStats && Boolean(resolved.showStats);
200
207
  const showHint = hasDetails && !open && Boolean(resolved.showDetailsHint) && detailsHint != null;
201
208
  const actionsCluster = /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5 shrink-0", children: [
202
209
  showHint ? /* @__PURE__ */ jsx("span", { className: "hidden max-w-[14rem] truncate text-xs text-muted-foreground sm:inline", children: detailsHint }) : null,
@@ -353,11 +360,23 @@ function PageHeader({
353
360
  ] }),
354
361
  actionsCluster
355
362
  ] });
363
+ const statsRow = showStats ? /* @__PURE__ */ jsx(
364
+ "div",
365
+ {
366
+ "data-testid": "page-header-stats",
367
+ className: "mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4",
368
+ children: stats
369
+ }
370
+ ) : null;
356
371
  if (!hasDetails) {
357
- return titleRow;
372
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
373
+ titleRow,
374
+ statsRow
375
+ ] });
358
376
  }
359
377
  return /* @__PURE__ */ jsxs(Collapsible, { open, onOpenChange: setOpen, children: [
360
378
  titleRow,
379
+ statsRow,
361
380
  /* @__PURE__ */ jsx(CollapsibleContent, { id: detailsRegionId, className: "mt-3", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 rounded-lg border border-border bg-card/40 p-3 sm:p-4", children: [
362
381
  moveSubtitle && subtitle && resolved.showSubtitle !== false ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: subtitle }) : null,
363
382
  details
@@ -431,7 +450,7 @@ function ThemeToggle({ testId }) {
431
450
  }
432
451
  function itemIsActive(item, activePath, isActive) {
433
452
  if (item.end) {
434
- return Boolean(activePath && activePath === item.to);
453
+ return isPathActive(item.to, activePath, { end: true });
435
454
  }
436
455
  if (isActive(item.to, activePath)) return true;
437
456
  return Boolean(
@@ -537,7 +556,9 @@ function NavGroup({
537
556
  }
538
557
  );
539
558
  }
540
- const selfExact = Boolean(item.to && activePath && activePath === item.to);
559
+ const selfExact = Boolean(
560
+ item.to && isPathActive(item.to, activePath, { end: true })
561
+ );
541
562
  const selfActive = item.to ? item.end ? selfExact : isActive(item.to, activePath) : false;
542
563
  const groupHighlight = selfActive || childActive;
543
564
  const pad = density === "compact" ? "px-2.5 py-1.5" : "px-3 py-2";
@@ -1128,5 +1149,5 @@ function AppShell({
1128
1149
  }
1129
1150
 
1130
1151
  export { AppShell, PageHeader };
1131
- //# sourceMappingURL=chunk-UBCYXOXT.js.map
1132
- //# sourceMappingURL=chunk-UBCYXOXT.js.map
1152
+ //# sourceMappingURL=chunk-DWM7ELFI.js.map
1153
+ //# sourceMappingURL=chunk-DWM7ELFI.js.map