@marktiderman/genesis-ui 2.3.0 → 2.4.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/SKILL.md +137 -0
- package/dist/{AppShell-D_E_Hk4g.d.ts → AppShell-CX0M-dfc.d.ts} +24 -6
- package/dist/chunk-CDYZRZ6U.js +141 -0
- package/dist/chunk-CDYZRZ6U.js.map +1 -0
- package/dist/{chunk-UBCYXOXT.js → chunk-DWM7ELFI.js} +28 -7
- package/dist/chunk-DWM7ELFI.js.map +1 -0
- package/dist/{chunk-D4JTQYP5.js → chunk-JA3ZKULS.js} +3 -3
- package/dist/{chunk-D4JTQYP5.js.map → chunk-JA3ZKULS.js.map} +1 -1
- package/dist/chunk-VFO3I7PV.js +24 -0
- package/dist/chunk-VFO3I7PV.js.map +1 -0
- package/dist/components/data/index.d.ts +46 -8
- package/dist/components/data/index.js +66 -22
- package/dist/components/data/index.js.map +1 -1
- package/dist/components/layout/index.d.ts +2 -2
- package/dist/components/layout/index.js +6 -6
- package/dist/components/patterns/index.js +3 -3
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.js +3 -3
- package/dist/hooks/use-data-filters.d.ts +1 -1
- package/dist/hooks/use-data-filters.js +2 -2
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +12 -12
- package/dist/navigation-DBwcZ6vb.d.ts +163 -0
- package/dist/styles.css +11 -0
- package/package.json +7 -5
- package/dist/chunk-6NPEMLBA.js +0 -51
- package/dist/chunk-6NPEMLBA.js.map +0 -1
- package/dist/chunk-M7AU5DDM.js +0 -26
- package/dist/chunk-M7AU5DDM.js.map +0 -1
- package/dist/chunk-UBCYXOXT.js.map +0 -1
- 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-
|
|
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
|
-
/**
|
|
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
|
|
211
|
-
* `
|
|
212
|
-
*
|
|
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-
|
|
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
|
|
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
|
|
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(
|
|
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-
|
|
1132
|
-
//# sourceMappingURL=chunk-
|
|
1152
|
+
//# sourceMappingURL=chunk-DWM7ELFI.js.map
|
|
1153
|
+
//# sourceMappingURL=chunk-DWM7ELFI.js.map
|