@marktiderman/genesis-ui 2.7.0 → 2.9.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 (74) hide show
  1. package/EXTENDING.md +40 -1
  2. package/SKILL.md +198 -9
  3. package/dist/{AppShell-CX0M-dfc.d.ts → AppShell-LXQykEqe.d.ts} +47 -5
  4. package/dist/{chunk-M62CHO3N.js → chunk-4C4ESW6O.js} +9 -4
  5. package/dist/chunk-4C4ESW6O.js.map +1 -0
  6. package/dist/chunk-DIV4BYXX.js +58 -0
  7. package/dist/chunk-DIV4BYXX.js.map +1 -0
  8. package/dist/chunk-FUVWKTPL.js +19 -0
  9. package/dist/chunk-FUVWKTPL.js.map +1 -0
  10. package/dist/{chunk-PLIYF6KI.js → chunk-MPI33I4K.js} +2 -2
  11. package/dist/{chunk-PLIYF6KI.js.map → chunk-MPI33I4K.js.map} +1 -1
  12. package/dist/{chunk-JA3ZKULS.js → chunk-OITWCIG2.js} +3 -3
  13. package/dist/chunk-OITWCIG2.js.map +1 -0
  14. package/dist/chunk-PKFDGGZV.js +65 -0
  15. package/dist/chunk-PKFDGGZV.js.map +1 -0
  16. package/dist/{chunk-IXGAEECT.js → chunk-RKPS7ERS.js} +9 -5
  17. package/dist/chunk-RKPS7ERS.js.map +1 -0
  18. package/dist/{chunk-TXOZMKNW.js → chunk-S7NXD37O.js} +27 -26
  19. package/dist/chunk-S7NXD37O.js.map +1 -0
  20. package/dist/{chunk-ZPPLUGLA.js → chunk-UYI3GLFJ.js} +162 -19
  21. package/dist/chunk-UYI3GLFJ.js.map +1 -0
  22. package/dist/{chunk-CDYZRZ6U.js → chunk-WGMEVX3Y.js} +2 -2
  23. package/dist/chunk-WGMEVX3Y.js.map +1 -0
  24. package/dist/chunk-ZDHWNQTR.js +552 -0
  25. package/dist/chunk-ZDHWNQTR.js.map +1 -0
  26. package/dist/components/data/index.d.ts +306 -19
  27. package/dist/components/data/index.js +1177 -284
  28. package/dist/components/data/index.js.map +1 -1
  29. package/dist/components/layout/form-page.d.ts +1 -1
  30. package/dist/components/layout/form-page.js +1 -1
  31. package/dist/components/layout/index.d.ts +2 -2
  32. package/dist/components/layout/index.js +8 -8
  33. package/dist/components/patterns/index.d.ts +1 -1
  34. package/dist/components/patterns/index.js +5 -5
  35. package/dist/components/patterns/sortable-list.js +1 -1
  36. package/dist/components/rich-text/rich-text-editor.d.ts +194 -0
  37. package/dist/components/rich-text/rich-text-editor.js +833 -0
  38. package/dist/components/rich-text/rich-text-editor.js.map +1 -0
  39. package/dist/components/rich-text/rich-text.d.ts +51 -0
  40. package/dist/components/rich-text/rich-text.js +4 -0
  41. package/dist/components/rich-text/rich-text.js.map +1 -0
  42. package/dist/components/ui/alert-dialog.d.ts +12 -1
  43. package/dist/components/ui/alert-dialog.js +1 -1
  44. package/dist/hooks/index.d.ts +4 -3
  45. package/dist/hooks/index.js +5 -4
  46. package/dist/hooks/use-data-filters.d.ts +5 -3
  47. package/dist/hooks/use-data-filters.js +2 -2
  48. package/dist/hooks/use-resource-page.d.ts +2 -1
  49. package/dist/hooks/use-resource-page.js +3 -2
  50. package/dist/hooks/use-resource-url-state.d.ts +68 -0
  51. package/dist/hooks/use-resource-url-state.js +3 -0
  52. package/dist/hooks/use-resource-url-state.js.map +1 -0
  53. package/dist/hooks/use-view-preference.d.ts +17 -2
  54. package/dist/hooks/use-view-preference.js +2 -1
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +32 -32
  57. package/dist/{navigation-DBwcZ6vb.d.ts → navigation-Bha0R3Fu.d.ts} +6 -3
  58. package/dist/rich-text-doc-C54fME2C.d.ts +180 -0
  59. package/dist/router/react-router.d.ts +47 -0
  60. package/dist/router/react-router.js +53 -0
  61. package/dist/router/react-router.js.map +1 -0
  62. package/dist/router/tanstack-router.d.ts +47 -0
  63. package/dist/router/tanstack-router.js +105 -0
  64. package/dist/router/tanstack-router.js.map +1 -0
  65. package/dist/{use-resource-page-Dd2VUv30.d.ts → use-resource-page-BDnnCssE.d.ts} +83 -5
  66. package/package.json +61 -14
  67. package/dist/chunk-CDYZRZ6U.js.map +0 -1
  68. package/dist/chunk-FTTNGGLA.js +0 -20
  69. package/dist/chunk-FTTNGGLA.js.map +0 -1
  70. package/dist/chunk-IXGAEECT.js.map +0 -1
  71. package/dist/chunk-JA3ZKULS.js.map +0 -1
  72. package/dist/chunk-M62CHO3N.js.map +0 -1
  73. package/dist/chunk-TXOZMKNW.js.map +0 -1
  74. package/dist/chunk-ZPPLUGLA.js.map +0 -1
package/EXTENDING.md CHANGED
@@ -48,7 +48,23 @@ already right, change the one part that was not.
48
48
  Every interior part a pattern composes is named in its slot map
49
49
  (`ResourcePageSlotMap`). That map is the contract: **a part added to the
50
50
  render tree gets added to the map in the same change**, otherwise the next
51
- surface that needs it forks the page instead.
51
+ surface that needs it forks the page instead. (The delete confirmation,
52
+ `deleteDialog`, joined the map with the built-in delete.)
53
+
54
+ Type your slots with the exported types rather than reaching into the props:
55
+
56
+ ```tsx
57
+ import type { ResourcePageSlots, ResourcePageSlotProps } from "@marktiderman/genesis-ui/data";
58
+
59
+ const slots: ResourcePageSlots<Practice> = { filters: null };
60
+ const slotProps: ResourcePageSlotProps<Practice> = { table: { stickyHeader: true } };
61
+ ```
62
+
63
+ **Before writing a slot, check for the prop.** A slot that exists only to
64
+ compute a subtitle, gate Edit per row, confirm a delete, drop the title or
65
+ add an empty-state sentence is a slot genesis now owes you as a prop
66
+ (`getSubtitle`, `canEdit` / `canDelete`, `allowDelete` / `confirmDelete`,
67
+ `header="toolbar"`, `emptyDescription`) — and those exist.
52
68
 
53
69
  ### 3 · The primitives
54
70
 
@@ -102,3 +118,26 @@ from. Reach for rung 2 first.
102
118
  5. **When a downstream app hand-builds something genesis nearly had, that is
103
119
  the bug report.** `ListPage`, a hand-rolled bulk bar and a forked header
104
120
  were each a missing prop away from not existing.
121
+
122
+ ## Navigation: declare the tree, not the chrome
123
+
124
+ Rule 5 in practice. Apps kept re-deriving the same three things by hand: the
125
+ contextual nav for "where am I", the mobile tab bar for it, and a back row
126
+ placed inside page content. `AppShell` now derives all of them from one
127
+ `NavigationNode` tree, so the ladder for app navigation is:
128
+
129
+ 1. **`navigation`** — pass the tree and `activePath`. The root's `items` are
130
+ the global sidebar; the current node's `items` become a contextual section
131
+ (and the mobile tab bar); its logical parent becomes the back link in the
132
+ content header. `$param` segments in paths and item `to`s are filled from
133
+ the URL, and a static segment beats a `$param` sibling.
134
+ 2. **Override one part** — `navItems`, `mobileNavItems` or
135
+ `parentNavigation={{ href, label }}` win over what the tree derives;
136
+ `parentNavigation={null}` hides the back link.
137
+ 3. **`resolveNavigation(tree, pathname)`** — the same resolver, for chrome
138
+ outside AppShell.
139
+
140
+ Do not copy `resolveNavigation` or `NavigationNode` into an app, and do not
141
+ put a back row in page content or call `history.back()`. genesis-lint
142
+ (`scripts/check-navigation-reimplementation.mjs`) fails a local declaration of
143
+ either.
package/SKILL.md CHANGED
@@ -49,6 +49,68 @@ already right; that re-derivation is where forks come from. Reach for rung 2
49
49
  first. When the app hand-builds something Genesis nearly had, that is a bug
50
50
  report against Genesis (`packages/ui/EXTENDING.md`), not a reason to fork.
51
51
 
52
+ ## ResourcePage knobs a real screen needs
53
+
54
+ Reach for these before a slot. Each one replaced a slot workaround in a real app.
55
+
56
+ | You need | Prop |
57
+ | --- | --- |
58
+ | Every row reachable in `resource` mode | default `pagination="client"` fetches up to `clientRowLimit` (1000) and says so past it; `pagination="server"` pages through the provider (give it `searchFields`) |
59
+ | Who may create, edit, delete | `can(action, item)` (UI gate only; the server still decides) |
60
+ | A confirmed delete | `allowDelete` (built in, `resource` mode), or `onDelete` + `confirmDelete`; copy via `confirmDelete={{ title: (item) => … }}`, toast via `onDeleted` |
61
+ | A computed peek subtitle | `getSubtitle(item)`; the peek stays open after an edit and shows the saved row |
62
+ | A list inside a tab (no page title) | `header="toolbar"` (also on `DataPageShell`) |
63
+ | Empty-state copy | `emptyMessage`, `emptyDescription`, `emptyIcon` |
64
+ | Cards on phones, table on desktop | `defaultView={{ mobile: "grid", desktop: "table" }}`; only an explicit toggle is stored |
65
+ | Typed slot maps | `ResourcePageSlots<T>`, `ResourcePageSlotProps<T>`, `ResourcePageSlotMap<T>` from `/data` |
66
+
67
+ `ResourceForm` ties labels to inputs (`getByLabelText` works), shows a failed save as an alert,
68
+ honours `maxLength` on text fields, adds no placeholder unless the field sets one (or
69
+ `autoPlaceholder`), and takes `prefillFromUrl={false}` when the page's query string is not form data.
70
+
71
+ `ResourceForm` also:
72
+
73
+ - puts a provider's field-level errors (`DataWriteError.fields`; the Supabase provider maps
74
+ unique, not-null and foreign-key violations) on their inputs, with the summary in the alert;
75
+ - takes `warnWhenUnsaved` (default off): Cancel / Esc / outside click ask before dropping
76
+ changes, and the tab's close or reload prompts;
77
+ - takes `mutationMode="optimistic" | "undoable"` (default `"pessimistic"`): the form closes at
78
+ once and the cached list updates; `undoable` shows an Undo toast and sends the save after
79
+ `undoDelayMs` (5000) unless undone. A failure puts the list back and is toasted. Needs a
80
+ mounted `<Toaster />`. `ResourcePage` passes its own `mutationMode` to its form and deletes.
81
+
82
+ ## Unsaved changes
83
+
84
+ Core never depends on a router. `useResourceForm` returns `isDirty` and `confirmLeave()`
85
+ (true when leaving is fine); `ResourceForm` reports both through `onDirtyChange`. For any
86
+ other state use `useUnsavedChangesGuard({ when })` from core. Wire `confirmLeave` to your
87
+ router's blocker:
88
+
89
+ ```tsx
90
+ // React Router (data router, v6.4+ / v7)
91
+ import { useBlocker } from "react-router"; // "react-router-dom" on v6
92
+
93
+ const { form, isDirty, confirmLeave } = useResourceForm({ …, warnWhenUnsaved: true });
94
+ const blocker = useBlocker(isDirty);
95
+ useEffect(() => {
96
+ if (blocker.state !== "blocked") return;
97
+ if (confirmLeave()) blocker.proceed();
98
+ else blocker.reset();
99
+ }, [blocker, confirmLeave]);
100
+ ```
101
+
102
+ ```tsx
103
+ // TanStack Router (v1)
104
+ import { useBlocker } from "@tanstack/react-router";
105
+
106
+ const { isDirty, confirmLeave } = useResourceForm({ …, warnWhenUnsaved: true });
107
+ // The hook already handles beforeunload, so the router's own is off.
108
+ useBlocker({ shouldBlockFn: () => !confirmLeave(), disabled: !isDirty, enableBeforeUnload: false });
109
+ ```
110
+
111
+ With `ResourceForm`, keep the pair from `onDirtyChange={(dirty, confirmLeave) => …}` in state
112
+ and use it the same way.
113
+
52
114
  ## The layer contract (docs/FRAMEWORK.md)
53
115
 
54
116
  1. Layers: tokens → primitives → patterns → layouts → data-bound; each depends downward only.
@@ -62,6 +124,7 @@ report against Genesis (`packages/ui/EXTENDING.md`), not a reason to fork.
62
124
  | You need | Use | Import from |
63
125
  | ------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
64
126
  | App skeleton: nav, header, content | `AppShell` | `@marktiderman/genesis-ui/layout` |
127
+ | Sidebar, contextual nav, tab bar and back link from one tree | `AppShell navigation={tree}` (`NavigationNode`; `parentNavigation` to override) | `/layout` |
65
128
  | Page title row with count, view toggle, options | `PageHeader` (+ `options.configurable` for the in-header options popover) | `/layout` |
66
129
  | List page chrome: header, filters, loading/empty/error | `DataPageShell` (`stats`, `filters` slots) | `/data` |
67
130
  | A whole CRUD list page over a resource | `ResourcePage` | `/data` |
@@ -76,7 +139,9 @@ report against Genesis (`packages/ui/EXTENDING.md`), not a reason to fork.
76
139
  | Cmd-K palette | `CommandPalette`, `useCommandPaletteHotkey` | `/command-palette` |
77
140
  | Arrow-key list navigation | `useKeyboardNavigation` | `/hooks/use-keyboard-navigation` |
78
141
  | Reorderable list: drag, keyboard, row-menu moves | `SortableList` | `/sortable-list` |
142
+ | Formatted text (lessons, notes): editor and read-only view | `RichTextEditor`, `RichText` (+ `sanitizeRichTextDoc`, `richTextToHtml` on the server) | `/rich-text-editor`, `/rich-text` |
79
143
  | Persisted page size / density | `useViewSettings` | `/hooks/use-view-settings` |
144
+ | Client-side links, navigate, URL filters on your router | `ReactRouterLink` / `TanStackRouterLink` and their `use…Navigate`, `use…SearchParams`, `use…ActivePath` hooks | `/router/react-router`, `/router/tanstack-router` |
80
145
 
81
146
  ## Import rule
82
147
 
@@ -97,13 +162,85 @@ wins over `@layer theme`. `tailwindFromBrand(brand).css` adds brand ramps
97
162
  (`--color-neutral-500` and the like) on top. Components reference only semantic tokens
98
163
  (`bg-primary`, `text-muted-foreground`), never raw colours.
99
164
 
165
+ ## Data views without hand-rolling
166
+
167
+ Reach for `ResourcePage` (with `data` for rows you already have) before composing DataTable,
168
+ DataGrid and DetailPanel yourself. The parts apps used to hand-roll are props:
169
+
170
+ - **Column widths:** `width: 180` is px, through a `<colgroup>`; the other columns share the rest.
171
+ Long headers truncate and keep the full text as a tooltip.
172
+ - **Clickable cards:** with a custom `renderCard`, `openOnCardClick` makes the whole card one
173
+ keyboard-reachable button that opens the detail. On a bare `DataGrid`, use `onItemClick` and
174
+ `getItemLabel`. Never wrap a card in a `div role="button"`.
175
+ - **Permissions:** `can(action, item)` with `"create"`, `"edit"` or `"delete"` hides the New button or
176
+ a row's Edit / Delete (a UI gate; the server still decides). `canEditItem`, `canEdit` and `canDelete`
177
+ are deprecated aliases, still honoured.
178
+ - **Names and empty state:** `getTitle` / `getSubtitle` when a record's name spans fields;
179
+ `emptyIcon`, `emptyMessage`, `emptyDescription` and `emptyState` (its action) for the empty list.
180
+ - **A list inside tabs:** `header="toolbar"` (on `ResourcePage` or `DataPageShell`) drops the title
181
+ block and keeps the count, view toggle, filters and create button.
182
+ - **Shareable state:** `urlState={useReactRouterSearchParams()}` (or the TanStack / browser adapter) keeps
183
+ the open record (`?id=`), search, sort and, with `pagination="server"`, the page in the URL. Back
184
+ closes a record that was opened. A page that owns its own paging uses `useResourceUrlState`.
185
+ - **Non-destructive confirm:** `<AlertDialogAction variant="default">` for publish/send; red stays
186
+ the default for deletes.
187
+
188
+ ## Big tables
189
+
190
+ `DataTable` runs on TanStack Table. Sorting compares numbers, dates and booleans by value. For a long
191
+ list without pages, `pagination={false} virtualize` renders only the rows in view (`virtualize={{ height }}`
192
+ sets the scroll box). `pinnedColumns={{ left: ["name"], right: ["actions"] }}` keeps columns in view
193
+ when scrolling sideways. Users can reorder columns by dragging a header or with Alt+←/→, and the order
194
+ is kept per device with `columnOrderKey`.
195
+
100
196
  ## Full inventory
101
197
 
102
198
  `docs/component-reference.md` lists every export with its props and stability tag. It is
103
199
  generated (`pnpm gen:component-reference`); do not edit it by hand.
104
200
 
201
+ ## Rich text
202
+
203
+ `RichTextEditor` (`/rich-text-editor`, Tiptap 3) needs the optional peers
204
+ `@tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/extensions` (`^3.31`). `RichText` and the
205
+ helpers on `/rich-text` need nothing beyond React and run on a server.
206
+
207
+ - **Store JSON, derive HTML.** `onChange(doc, details)` gives the document JSON (canonical) and lazy
208
+ `details.html` / `details.text`. Persist `doc` (Postgres `jsonb`). If you also need HTML or text (email,
209
+ search, previews), derive them on the server; never trust HTML or JSON a client posts.
210
+ - **Sanitize on the server on every write, with limits:**
211
+ `const doc = sanitizeRichTextDoc(body.doc, { maxCharacters: 50_000, maxNodes: 5_000, onTooDeep: "throw" })`
212
+ keeps only the allow-listed nodes (paragraph, h2-h4, lists, blockquote, bold/italic/underline,
213
+ http(s)/mailto/tel/relative links, YouTube/Vimeo/Loom embeds by id). Over a limit, or nested more than 32
214
+ levels, it throws `RichTextLimitError` (`reason`: `"characters" | "nodes" | "depth"`), so answer 413/422
215
+ instead of storing it. Then `richTextToHtml(doc)` (`{ embedAs: "link" }` for email) and
216
+ `richTextToPlainText(doc)`. Both run without a DOM and escape every value.
217
+ - **Render with `<RichText value={doc} />`**, not `dangerouslySetInnerHTML`. It re-sanitizes and works in
218
+ server components. If you must inject stored HTML, inject only HTML you produced with `richTextToHtml`.
219
+ - The editor adds roughly 135 KB gzipped (ProseMirror + Tiptap), so load it only where people write:
220
+ `const RichTextEditor = lazy(() => import("@marktiderman/genesis-ui/rich-text-editor").then((m) => ({ default: m.RichTextEditor })))`.
221
+ Reading pages use `RichText`, which pulls in no editor code.
222
+ - Embeds are off by default: `<RichTextEditor embeds />`. A CSP needs `frame-src` for
223
+ `https://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com`. The editor injects no
224
+ `<style>` tag (Tiptap's base rules are classes), so `style-src` needs no `'unsafe-inline'` for it.
225
+
226
+ ```tsx
227
+ <RichTextEditor aria-label="Lesson text" value={doc} onChange={setDoc} placeholder="Write the lesson…" embeds maxLength={20000} />
228
+ <RichText value={lesson.body} />
229
+ ```
230
+
105
231
  ## Wiring into an app
106
232
 
233
+ **Data provider and query cache.** If the app already has a TanStack `QueryClientProvider`,
234
+ mount `DataProviderRoot` inside it (or pass `queryClient` to `GenesisProvider`). Plain
235
+ `GenesisProvider` creates its own `QueryClient`, a second cache that app invalidations never
236
+ reach. On Supabase with RLS, configure `createSupabaseProvider(client, { binTables, resources,
237
+ onWrite })` instead of writing a local adapter: zero-row updates/deletes and Postgres error codes
238
+ come back as typed `DataWriteError` outcomes (`forbidden`, `gone`, `invalid`,
239
+ `unauthenticated`), recycle-bin tables count "gone from view" as success, and per-resource
240
+ `prepare` / `create` / `update` / `toFormValues` / `invalidates` cover RPC writes, form shapes and
241
+ related query keys. Branch on `isDataWriteError(e) && e.outcome`, never on the message. Details:
242
+ `packages/core/README.md`.
243
+
107
244
  **Tailwind class scanning.** Tailwind v4 skips git-ignored paths such as `node_modules`, so genesis-ui's classes
108
245
  are missing unless the app points at them. Import the shipped source entry — never hardcode an
109
246
  `@source` path into `node_modules`:
@@ -113,26 +250,78 @@ are missing unless the app points at them. Import the shipped source entry — n
113
250
  @import "@marktiderman/genesis-ui/styles.css"; /* = @source over the package's dist/ */
114
251
  ```
115
252
 
253
+ **Router adapters.** genesis-ui never imports a router from its root or layer subpaths. Links default
254
+ to a plain `<a>` and navigation to a full page load until you inject an adapter. Take the shipped one
255
+ for your router; do not hand-write it. Each lives on its own subpath with its router as an optional
256
+ peer, so the other router is never installed or bundled.
257
+
258
+ ```tsx
259
+ // React Router v7
260
+ import {
261
+ ReactRouterLink,
262
+ useReactRouterActivePath,
263
+ useReactRouterNavigate,
264
+ useReactRouterSearchParams,
265
+ } from "@marktiderman/genesis-ui/router/react-router";
266
+ // TanStack Router: the same four, named TanStackRouterLink / useTanStackRouter…,
267
+ // from "@marktiderman/genesis-ui/router/tanstack-router".
268
+
269
+ <AppShell linkComponent={ReactRouterLink} activePath={useReactRouterActivePath()} … />
270
+ <ResourcePage detail="route" onNavigate={useReactRouterNavigate()} … />
271
+ useDataFilters("orders", defs, { searchParams: useReactRouterSearchParams() });
272
+ ```
273
+
274
+ The link takes Genesis's string `href` (`/admin?tab=users#top`) and keeps its query and hash; the
275
+ TanStack one parses the query with the router's own `parseSearch`, so `validateSearch` sees what a
276
+ typed `<Link search>` would give it. Genesis owns the active state: the adapters never add a
277
+ router's own `aria-current` or `active` class. Absolute URLs leave the app as plain anchors or a
278
+ document navigation. Next.js needs no adapter: pass `next/link` as `linkComponent`.
279
+
116
280
  **Active nav items with `?query` / `#hash`.** Pass the full location as `activePath`
117
- (`pathname + search + hash`). Plain items still match on the path; an item whose `to` carries a
281
+ (`pathname + search + hash`, which `use…ActivePath()` above returns). Plain items still match on the path; an item whose `to` carries a
118
282
  query or hash (`/admin?tab=users`, `/settings#profile`) is active only when those match too.
119
283
  Do not write a custom `isActive` for this. `isPathActive(to, activePath, { end: true })` is the
120
284
  exact-path variant.
121
285
 
122
- **Hierarchical "up" navigation.** Describe the logical hierarchy once as a `NavigationNode` tree
123
- and call `resolveNavigation(tree, pathname)` from the root barrel or `/data`. It returns
124
- `{ current, parent, items, trail, params }` with every path interpolated from the URL's params.
125
- Use `parent` for a back-to-parent link and `items` for contextual nav — never `history.back()`.
286
+ **Hierarchical navigation — the default.** Describe the logical hierarchy once as a
287
+ `NavigationNode<NavItem>` tree and hand it to `AppShell` with the current location. AppShell
288
+ derives the global sidebar (the root's `items`), a contextual section (the current node's
289
+ `items`, `$param`s filled from the URL), the mobile tab bar (contextual items, else global) and
290
+ a back-to-parent link in the content header. `navItems`, `mobileNavItems` and
291
+ `parentNavigation={{ href, label }}` override a derived part; `parentNavigation={null}` hides the
292
+ link. Never put a back row in page content, never `history.back()`, never copy the resolver.
293
+
294
+ ```tsx
295
+ import { AppShell, type NavigationNode, type NavItem } from "@marktiderman/genesis-ui/layout";
296
+
297
+ const navigation: NavigationNode<NavItem> = {
298
+ id: "app", path: "/", label: "Home", items: globalItems, children: [
299
+ { id: "projects", path: "/projects", label: "Projects", items: [], children: [
300
+ { id: "project", path: "/projects/$projectId", label: "Project", items: [
301
+ { to: "/projects/$projectId", icon: FileText, label: "Overview", end: true },
302
+ { to: "/projects/$projectId/tasks", icon: CheckSquare, label: "Tasks" },
303
+ ] },
304
+ ] },
305
+ ],
306
+ };
307
+
308
+ <AppShell appName="Acme" navigation={navigation} activePath={pathname + search + hash}
309
+ linkComponent={RouterLink}>{children}</AppShell>
310
+ ```
311
+
312
+ For chrome outside AppShell, call the same resolver: `resolveNavigation(tree, pathname)` (from
313
+ `/layout`, `/data` or the root barrel) returns `{ current, parent, items, trail, params }` with
314
+ every path interpolated from the URL's params.
126
315
 
127
316
  ```ts
128
317
  const nav = resolveNavigation(
129
318
  { id: "app", path: "/app", label: "Home", items: globalItems, children: [
130
- { id: "clients", path: "/dashboard", label: "Clients", items: [], children: [
131
- { id: "client", path: "/clients/$clientId", label: "Client", items: clientItems },
319
+ { id: "projects", path: "/dashboard", label: "Projects", items: [], children: [
320
+ { id: "project", path: "/projects/$projectId", label: "Project", items: projectItems },
132
321
  ] },
133
322
  ] },
134
323
  pathname,
135
324
  );
136
- // pathname "/clients/c1/tasks" → nav.current.id "client", nav.parent.path "/dashboard",
137
- // nav.params { clientId: "c1" }
325
+ // pathname "/projects/p1/tasks" → nav.current.id "project", nav.parent.path "/dashboard",
326
+ // nav.params { projectId: "p1" }
138
327
  ```
@@ -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-DBwcZ6vb.js';
6
+ import { N as NavigationNode, L as LinkComponent } from './navigation-Bha0R3Fu.js';
7
7
 
8
8
  /**
9
9
  * Declarative chrome for PageHeader — developer defaults + optional
@@ -12,6 +12,13 @@ import { L as LinkComponent } from './navigation-DBwcZ6vb.js';
12
12
  * @stability Beta
13
13
  */
14
14
  interface PageHeaderOptions {
15
+ /**
16
+ * The icon, title and subtitle block. Turn it off where the surrounding
17
+ * page already names the content (a list inside a tab), keeping only the
18
+ * actions row: count, view toggle, filters, create. Default true. A
19
+ * developer setting, not offered in the user's Header options.
20
+ */
21
+ showTitle?: boolean;
15
22
  showIcon?: boolean;
16
23
  showSubtitle?: boolean;
17
24
  showCount?: boolean;
@@ -119,6 +126,16 @@ interface NavItem {
119
126
  */
120
127
  end?: boolean;
121
128
  }
129
+ /**
130
+ * The "up" link AppShell renders above page content: a real link to the
131
+ * logical parent, never `history.back()`.
132
+ *
133
+ * @stability Beta
134
+ */
135
+ interface ParentNavigation {
136
+ href: string;
137
+ label: string;
138
+ }
122
139
  /**
123
140
  * Declarative chrome for AppShell — developer defaults + optional
124
141
  * in-sidebar user toggles (Notion/Linear “customize chrome” pattern).
@@ -171,8 +188,33 @@ interface AppShellProps {
171
188
  appLogo?: string;
172
189
  /** Custom logo node — preferred over `appLogo` letter tile */
173
190
  logo?: ReactNode;
174
- /** Primary navigation items */
175
- navItems: NavItem[];
191
+ /**
192
+ * Primary navigation items. Optional when `navigation` is set: the tree
193
+ * root's `items` are the global nav then.
194
+ */
195
+ navItems?: NavItem[];
196
+ /**
197
+ * The app's logical navigation hierarchy. AppShell resolves it against
198
+ * `activePath` (see `resolveNavigation`) and derives:
199
+ * - the sidebar's global items from the root's `items` (unless `navItems`),
200
+ * - a contextual section from the current node's `items`, labelled with
201
+ * the node's `label` (`$param` segments in `to` filled from the URL),
202
+ * - the mobile tab bar: the contextual items when there are any, else the
203
+ * global top-level items (unless `mobileNavItems`),
204
+ * - a back link to the current node's logical parent (unless
205
+ * `parentNavigation`).
206
+ *
207
+ * @stability Beta
208
+ */
209
+ navigation?: NavigationNode<NavItem>;
210
+ /**
211
+ * Back-to-parent link rendered in the content header, above `children`, on
212
+ * mobile and desktop. Derived from `navigation` when omitted; `null` hides
213
+ * it. Use this instead of placing a back row inside page content.
214
+ *
215
+ * @stability Beta
216
+ */
217
+ parentNavigation?: ParentNavigation | null;
176
218
  /** Secondary nav (e.g. “More”) — rendered below a divider when shown */
177
219
  secondaryNavItems?: NavItem[];
178
220
  /** Label above secondary nav (default “More”) */
@@ -236,6 +278,6 @@ interface AppShellProps {
236
278
  */
237
279
  isActive?: (to: string, activePath?: string) => boolean;
238
280
  }
239
- declare function AppShell({ appName, appLogo, logo, navItems, secondaryNavItems, secondaryNavLabel, mobileNavItems, headerSlot, collapsedHeaderSlot, footer, userSlot, children, showThemeToggle: showThemeToggleProp, options: optionsProp, optionsStorageKey, collapsed: collapsedProp, onCollapsedChange, linkComponent, activePath, isActive, }: AppShellProps): React.JSX.Element;
281
+ declare function AppShell({ appName, appLogo, logo, navItems: navItemsProp, navigation, parentNavigation: parentNavigationProp, secondaryNavItems, secondaryNavLabel, mobileNavItems, headerSlot, collapsedHeaderSlot, footer, userSlot, children, showThemeToggle: showThemeToggleProp, options: optionsProp, optionsStorageKey, collapsed: collapsedProp, onCollapsedChange, linkComponent, activePath, isActive, }: AppShellProps): React.JSX.Element;
240
282
 
241
- export { AppShell as A, type NavItem as N, PageHeader as P, type AppShellOptions as a, type AppShellProps as b, type PageHeaderOptions as c, type PageHeaderProps as d, type PageHeaderAction as e };
283
+ export { AppShell as A, type NavItem as N, PageHeader as P, type AppShellOptions as a, type AppShellProps as b, type PageHeaderOptions as c, type PageHeaderProps as d, type ParentNavigation as e, type PageHeaderAction as f };
@@ -60,12 +60,17 @@ var AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) =>
60
60
  }
61
61
  ));
62
62
  AlertDialogDescription.displayName = AlertDialog$1.Description.displayName;
63
- var AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
63
+ var ALERT_ACTION_VARIANT = {
64
+ destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
65
+ default: "bg-primary text-primary-foreground hover:bg-primary/90"
66
+ };
67
+ var AlertDialogAction = React.forwardRef(({ className, variant = "destructive", ...props }, ref) => /* @__PURE__ */ jsx(
64
68
  AlertDialog$1.Action,
65
69
  {
66
70
  ref,
67
71
  className: cn(
68
- "inline-flex h-10 items-center justify-center rounded-lg bg-destructive px-4 py-2 text-sm font-semibold text-destructive-foreground transition-colors hover:bg-destructive/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
72
+ "inline-flex h-10 items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
73
+ ALERT_ACTION_VARIANT[variant],
69
74
  className
70
75
  ),
71
76
  ...props
@@ -86,5 +91,5 @@ var AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => /* @_
86
91
  AlertDialogCancel.displayName = AlertDialog$1.Cancel.displayName;
87
92
 
88
93
  export { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger };
89
- //# sourceMappingURL=chunk-M62CHO3N.js.map
90
- //# sourceMappingURL=chunk-M62CHO3N.js.map
94
+ //# sourceMappingURL=chunk-4C4ESW6O.js.map
95
+ //# sourceMappingURL=chunk-4C4ESW6O.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/components/ui/alert-dialog.tsx"],"names":["AlertDialogPrimitive"],"mappings":"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","file":"chunk-4C4ESW6O.js","sourcesContent":["\"use client\";\n\nimport * as React from \"react\";\nimport { AlertDialog as AlertDialogPrimitive } from \"radix-ui\";\nimport { cn } from \"../../utils\";\n\nconst AlertDialog = AlertDialogPrimitive.Root;\nconst AlertDialogTrigger = AlertDialogPrimitive.Trigger;\nconst AlertDialogPortal = AlertDialogPrimitive.Portal;\n\nconst AlertDialogOverlay = React.forwardRef<\n React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n <AlertDialogPrimitive.Overlay\n className={cn(\n \"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n className\n )}\n {...props}\n ref={ref}\n />\n));\nAlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;\n\nconst AlertDialogContent = React.forwardRef<\n React.ComponentRef<typeof AlertDialogPrimitive.Content>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>\n>(({ className, ...props }, ref) => (\n <AlertDialogPortal>\n <AlertDialogOverlay />\n <AlertDialogPrimitive.Content\n ref={ref}\n className={cn(\n \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n className\n )}\n {...props}\n />\n </AlertDialogPortal>\n));\nAlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;\n\nconst AlertDialogHeader = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n <div className={cn(\"flex flex-col space-y-2 text-center sm:text-left\", className)} {...props} />\n);\n\nconst AlertDialogFooter = ({\n className,\n ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n <div className={cn(\"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\", className)} {...props} />\n);\n\nconst AlertDialogTitle = React.forwardRef<\n React.ComponentRef<typeof AlertDialogPrimitive.Title>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n <AlertDialogPrimitive.Title\n ref={ref}\n className={cn(\"text-lg font-semibold\", className)}\n {...props}\n />\n));\nAlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;\n\nconst AlertDialogDescription = React.forwardRef<\n React.ComponentRef<typeof AlertDialogPrimitive.Description>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n <AlertDialogPrimitive.Description\n ref={ref}\n className={cn(\"text-sm text-muted-foreground\", className)}\n {...props}\n />\n));\nAlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;\n\nconst ALERT_ACTION_VARIANT = {\n destructive: \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n} as const;\n\n/**\n * The confirming button. `variant` defaults to `\"destructive\"` (red), the\n * right colour for deleting; pass `\"default\"` when the action is not\n * dangerous (publish, send, continue).\n */\nconst AlertDialogAction = React.forwardRef<\n React.ComponentRef<typeof AlertDialogPrimitive.Action>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> & {\n variant?: keyof typeof ALERT_ACTION_VARIANT | undefined;\n }\n>(({ className, variant = \"destructive\", ...props }, ref) => (\n <AlertDialogPrimitive.Action\n ref={ref}\n className={cn(\n \"inline-flex h-10 items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\",\n ALERT_ACTION_VARIANT[variant],\n className\n )}\n {...props}\n />\n));\nAlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;\n\nconst AlertDialogCancel = React.forwardRef<\n React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,\n React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>\n>(({ className, ...props }, ref) => (\n <AlertDialogPrimitive.Cancel\n ref={ref}\n className={cn(\n \"mt-2 inline-flex h-10 items-center justify-center rounded-lg border border-border bg-card px-4 py-2 text-sm font-medium transition-colors hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:mt-0\",\n className\n )}\n {...props}\n />\n));\nAlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;\n\nexport {\n AlertDialog,\n AlertDialogPortal,\n AlertDialogOverlay,\n AlertDialogTrigger,\n AlertDialogContent,\n AlertDialogHeader,\n AlertDialogFooter,\n AlertDialogTitle,\n AlertDialogDescription,\n AlertDialogAction,\n AlertDialogCancel,\n};\n"]}
@@ -0,0 +1,58 @@
1
+ "use client";
2
+ import { useCallback, useMemo } from 'react';
3
+
4
+ // src/hooks/use-resource-url-state.ts
5
+ var DEFAULTS = { detail: "id", page: "page", sort: "sort", q: "q" };
6
+ function parseSort(raw) {
7
+ if (!raw) return null;
8
+ const at = raw.lastIndexOf(":");
9
+ const field = at > 0 ? raw.slice(0, at) : "";
10
+ const order = raw.slice(at + 1);
11
+ return field && (order === "asc" || order === "desc") ? { field, order } : null;
12
+ }
13
+ function useResourceUrlState(options) {
14
+ const adapter = options?.searchParams;
15
+ const names = { ...DEFAULTS, ...options?.params };
16
+ const sp = adapter?.searchParams;
17
+ const setSearchParams = adapter?.setSearchParams;
18
+ const detailId = sp?.get(names.detail) || null;
19
+ const rawPage = sp?.get(names.page);
20
+ const pageNum = rawPage && /^\d+$/.test(rawPage) ? Number(rawPage) : null;
21
+ const page = pageNum !== null && Number.isSafeInteger(pageNum) && pageNum > 0 ? pageNum : null;
22
+ const sortRaw = sp?.get(names.sort) ?? null;
23
+ const q = sp?.get(names.q) ?? "";
24
+ const setDetailId = useCallback(
25
+ (id) => {
26
+ if (!sp || !setSearchParams) return;
27
+ const next = new URLSearchParams(sp);
28
+ if (id) next.set(names.detail, id);
29
+ else next.delete(names.detail);
30
+ if (next.toString() === sp.toString()) return;
31
+ setSearchParams(next, { replace: !id });
32
+ },
33
+ [sp, setSearchParams, names.detail]
34
+ );
35
+ const write = useCallback(
36
+ (values) => {
37
+ if (!sp || !setSearchParams) return;
38
+ const next = new URLSearchParams(sp);
39
+ const put = (key, value) => {
40
+ if (value) next.set(key, value);
41
+ else next.delete(key);
42
+ };
43
+ if (values.page !== void 0) put(names.page, values.page && values.page > 1 ? String(values.page) : null);
44
+ if (values.sort !== void 0) put(names.sort, values.sort ? `${values.sort.field}:${values.sort.order}` : null);
45
+ if (values.q !== void 0) put(names.q, values.q || null);
46
+ if (next.toString() === sp.toString()) return;
47
+ setSearchParams(next, { replace: true });
48
+ },
49
+ [sp, setSearchParams, names.page, names.sort, names.q]
50
+ );
51
+ const sort = useMemo(() => parseSort(sortRaw), [sortRaw]);
52
+ if (!adapter) return void 0;
53
+ return { detailId, setDetailId, page, sort, q, write };
54
+ }
55
+
56
+ export { useResourceUrlState };
57
+ //# sourceMappingURL=chunk-DIV4BYXX.js.map
58
+ //# sourceMappingURL=chunk-DIV4BYXX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/use-resource-url-state.ts"],"names":[],"mappings":"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","file":"chunk-DIV4BYXX.js","sourcesContent":["\"use client\";\n\nimport { useCallback, useMemo } from \"react\";\nimport type { SearchParamsAdapter } from \"../navigation\";\nimport type { SortParam } from \"@marktiderman/genesis-core/provider\";\n\n/**\n * The query-string names a list keeps its state under. Rename one when two\n * lists share a page, or when `id` already means something in your routes.\n *\n * @stability Beta\n */\nexport interface ResourceUrlParams {\n /** The open record's key. Default `\"id\"`. */\n detail?: string;\n /** The page number (from 1). Default `\"page\"`. */\n page?: string;\n /** `field:asc` or `field:desc`. Default `\"sort\"`. */\n sort?: string;\n /** The search text. Default `\"q\"`. */\n q?: string;\n}\n\n/**\n * Where a list's state lives in the URL: the router's search params, plus\n * optional names for each value.\n *\n * @stability Beta\n */\nexport interface ResourceUrlStateOptions {\n searchParams: SearchParamsAdapter;\n params?: ResourceUrlParams;\n}\n\n/** @stability Beta */\nexport interface ResourceUrlState {\n /** The open record's key, or null when no record is open. */\n detailId: string | null;\n /** Open a record (a new history entry, so Back closes it) or close it (null; replaces). */\n setDetailId: (id: string | null) => void;\n /** The page from the URL, or null when absent or not a positive integer. */\n page: number | null;\n /** The sort from the URL, or null. */\n sort: SortParam | null;\n /** The search text from the URL (\"\" when absent). */\n q: string;\n /**\n * Write page, sort and search. Replaces the history entry (typing a search\n * shouldn't fill Back with every keystroke). A default value (page 1, no\n * sort, empty search) is dropped from the URL; `undefined` leaves a value as is.\n */\n write: (next: { page?: number | null; sort?: SortParam | null; q?: string }) => void;\n}\n\nconst DEFAULTS: Required<ResourceUrlParams> = { detail: \"id\", page: \"page\", sort: \"sort\", q: \"q\" };\n\nfunction parseSort(raw: string | null): SortParam | null {\n if (!raw) return null;\n const at = raw.lastIndexOf(\":\");\n const field = at > 0 ? raw.slice(0, at) : \"\";\n const order = raw.slice(at + 1);\n return field && (order === \"asc\" || order === \"desc\") ? { field, order } : null;\n}\n\n/**\n * A list's peek, page, sort and search, kept in the URL through any\n * {@link SearchParamsAdapter} (the browser's, React Router's or TanStack\n * Router's). The URL is the source of truth: a copied link reopens the same\n * record on the same page, and Back closes a record that was opened.\n *\n * `ResourcePage` uses it when given `urlState`; a server-controlled page\n * that owns its own page/sort/search can call it directly.\n *\n * @stability Beta\n */\nexport function useResourceUrlState(options: ResourceUrlStateOptions): ResourceUrlState;\nexport function useResourceUrlState(options: ResourceUrlStateOptions | undefined): ResourceUrlState | undefined;\nexport function useResourceUrlState(options: ResourceUrlStateOptions | undefined): ResourceUrlState | undefined {\n const adapter = options?.searchParams;\n const names = { ...DEFAULTS, ...options?.params };\n const sp = adapter?.searchParams;\n const setSearchParams = adapter?.setSearchParams;\n\n const detailId = sp?.get(names.detail) || null;\n const rawPage = sp?.get(names.page);\n const pageNum = rawPage && /^\\d+$/.test(rawPage) ? Number(rawPage) : null;\n // A positive safe integer only: hundreds of digits parse to Infinity.\n const page = pageNum !== null && Number.isSafeInteger(pageNum) && pageNum > 0 ? pageNum : null;\n const sortRaw = sp?.get(names.sort) ?? null;\n const q = sp?.get(names.q) ?? \"\";\n\n const setDetailId = useCallback(\n (id: string | null) => {\n if (!sp || !setSearchParams) return;\n const next = new URLSearchParams(sp);\n if (id) next.set(names.detail, id);\n else next.delete(names.detail);\n if (next.toString() === sp.toString()) return;\n // Opening adds an entry so Back closes the record; closing replaces.\n setSearchParams(next, { replace: !id });\n },\n [sp, setSearchParams, names.detail],\n );\n\n const write = useCallback<ResourceUrlState[\"write\"]>(\n (values) => {\n if (!sp || !setSearchParams) return;\n const next = new URLSearchParams(sp);\n const put = (key: string, value: string | null) => {\n if (value) next.set(key, value);\n else next.delete(key);\n };\n if (values.page !== undefined) put(names.page, values.page && values.page > 1 ? String(values.page) : null);\n if (values.sort !== undefined) put(names.sort, values.sort ? `${values.sort.field}:${values.sort.order}` : null);\n if (values.q !== undefined) put(names.q, values.q || null);\n if (next.toString() === sp.toString()) return;\n setSearchParams(next, { replace: true });\n },\n [sp, setSearchParams, names.page, names.sort, names.q],\n );\n\n const sort = useMemo(() => parseSort(sortRaw), [sortRaw]);\n\n if (!adapter) return undefined;\n return { detailId, setDetailId, page, sort, q, write };\n}\n"]}
@@ -0,0 +1,19 @@
1
+ // src/router/utils.ts
2
+ function isExternalHref(href) {
3
+ return /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("//");
4
+ }
5
+ function splitNavigationHref(href) {
6
+ const hashAt = href.indexOf("#");
7
+ const hash = hashAt === -1 ? "" : href.slice(hashAt + 1);
8
+ const beforeHash = hashAt === -1 ? href : href.slice(0, hashAt);
9
+ const searchAt = beforeHash.indexOf("?");
10
+ return {
11
+ pathname: searchAt === -1 ? beforeHash : beforeHash.slice(0, searchAt),
12
+ search: searchAt === -1 ? "" : beforeHash.slice(searchAt + 1),
13
+ hash
14
+ };
15
+ }
16
+
17
+ export { isExternalHref, splitNavigationHref };
18
+ //# sourceMappingURL=chunk-FUVWKTPL.js.map
19
+ //# sourceMappingURL=chunk-FUVWKTPL.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/router/utils.ts"],"names":[],"mappings":";AAWO,SAAS,eAAe,IAAA,EAAuB;AACpD,EAAA,OAAO,sBAAsB,IAAA,CAAK,IAAI,CAAA,IAAK,IAAA,CAAK,WAAW,IAAI,CAAA;AACjE;AAQO,SAAS,oBAAoB,IAAA,EAIlC;AACA,EAAA,MAAM,MAAA,GAAS,IAAA,CAAK,OAAA,CAAQ,GAAG,CAAA;AAC/B,EAAA,MAAM,OAAO,MAAA,KAAW,EAAA,GAAK,KAAK,IAAA,CAAK,KAAA,CAAM,SAAS,CAAC,CAAA;AACvD,EAAA,MAAM,aAAa,MAAA,KAAW,EAAA,GAAK,OAAO,IAAA,CAAK,KAAA,CAAM,GAAG,MAAM,CAAA;AAC9D,EAAA,MAAM,QAAA,GAAW,UAAA,CAAW,OAAA,CAAQ,GAAG,CAAA;AACvC,EAAA,OAAO;AAAA,IACL,UAAU,QAAA,KAAa,EAAA,GAAK,aAAa,UAAA,CAAW,KAAA,CAAM,GAAG,QAAQ,CAAA;AAAA,IACrE,QAAQ,QAAA,KAAa,EAAA,GAAK,KAAK,UAAA,CAAW,KAAA,CAAM,WAAW,CAAC,CAAA;AAAA,IAC5D;AAAA,GACF;AACF","file":"chunk-FUVWKTPL.js","sourcesContent":["/**\n * Helpers shared by the router adapters. Router-free on purpose: nothing here\n * may import a router, so either adapter can use it without pulling in the\n * other's peer.\n */\n\n/**\n * True for a href that leaves the app: a scheme (`https:`, `mailto:`, `tel:`)\n * or a protocol-relative `//host/…`. Router adapters hand these to the browser\n * instead of resolving them as an in-app route.\n */\nexport function isExternalHref(href: string): boolean {\n return /^[a-z][a-z\\d+.-]*:/i.test(href) || href.startsWith(\"//\");\n}\n\n/**\n * Split an in-app href into its path, query string (no `?`) and hash (no `#`).\n * Unlike the active-path matcher's splitter this keeps the path exactly as\n * given (no trailing-slash normalisation): it is a navigation target, not a\n * comparison key.\n */\nexport function splitNavigationHref(href: string): {\n pathname: string;\n search: string;\n hash: string;\n} {\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 return {\n pathname: searchAt === -1 ? beforeHash : beforeHash.slice(0, searchAt),\n search: searchAt === -1 ? \"\" : beforeHash.slice(searchAt + 1),\n hash,\n };\n}\n"]}
@@ -48,5 +48,5 @@ var FormPage = React.forwardRef(
48
48
  FormPage.displayName = "FormPage";
49
49
 
50
50
  export { FormPage };
51
- //# sourceMappingURL=chunk-PLIYF6KI.js.map
52
- //# sourceMappingURL=chunk-PLIYF6KI.js.map
51
+ //# sourceMappingURL=chunk-MPI33I4K.js.map
52
+ //# sourceMappingURL=chunk-MPI33I4K.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/components/layout/form-page.tsx"],"names":[],"mappings":";;;;;AAqFO,IAAM,QAAA,GAAiB,KAAA,CAAA,UAAA;AAAA,EAC5B,CACE,EAAE,SAAA,EAAW,KAAA,EAAO,WAAA,EAAa,MAAA,EAAQ,OAAA,EAAS,MAAA,EAAQ,QAAA,EAAU,GAAG,KAAA,EAAM,EAC7E,GAAA,qBAEA,IAAA;AAAA,IAAC,MAAA;AAAA,IAAA;AAAA,MACC,GAAA;AAAA,MACA,WAAA,EAAU,WAAA;AAAA,MACV,aAAA,EAAa,MAAA;AAAA,MACb,SAAA,EAAW,EAAA,CAAG,qBAAA,EAAuB,SAAS,CAAA;AAAA,MAC7C,GAAG,KAAA;AAAA,MAEJ,QAAA,EAAA;AAAA,wBAAA,IAAA,CAAC,QAAA,EAAA,EAAO,WAAA,EAAU,kBAAA,EAAmB,SAAA,EAAU,6BAAA,EAC7C,QAAA,EAAA;AAAA,0BAAA,GAAA;AAAA,YAAC,IAAA;AAAA,YAAA;AAAA,cACC,WAAA,EAAU,iBAAA;AAAA,cACV,SAAA,EAAU,uDAAA;AAAA,cAET,QAAA,EAAA;AAAA;AAAA,WACH;AAAA,UACC,WAAA,mBACC,GAAA;AAAA,YAAC,GAAA;AAAA,YAAA;AAAA,cACC,WAAA,EAAU,uBAAA;AAAA,cACV,SAAA,EAAU,+BAAA;AAAA,cAET,QAAA,EAAA;AAAA;AAAA,WACH,GACE;AAAA,SAAA,EACN,CAAA;AAAA,QAEC,MAAA,mBACC,GAAA,CAAC,KAAA,EAAA,EAAM,OAAA,EAAQ,aAAA,EAAc,WAAA,EAAU,kBAAA,EACrC,QAAA,kBAAA,GAAA,CAAC,gBAAA,EAAA,EAAkB,QAAA,EAAA,MAAA,EAAO,CAAA,EAC5B,CAAA,GACE,IAAA;AAAA,4BAEH,KAAA,EAAA,EAAI,WAAA,EAAU,gBAAA,EAAiB,SAAA,EAAU,+BACvC,QAAA,EACH,CAAA;AAAA,QAEC,OAAA,mBACC,GAAA;AAAA,UAAC,KAAA;AAAA,UAAA;AAAA,YACC,WAAA,EAAU,mBAAA;AAAA,YACV,SAAA,EAAU,8GAAA;AAAA,YAET,QAAA,EAAA;AAAA;AAAA,SACH,GACE;AAAA;AAAA;AAAA;AAGV;AACA,QAAA,CAAS,WAAA,GAAc,UAAA","file":"chunk-PLIYF6KI.js","sourcesContent":["import * as React from \"react\";\nimport { cn } from \"../../utils\";\nimport { Alert, AlertDescription } from \"../ui/alert\";\n\nexport interface FormPageProps\n // `FormHTMLAttributes` declares `title?: string` (the native tooltip\n // attribute); omit it before redeclaring the page title as a ReactNode.\n extends Omit<React.FormHTMLAttributes<HTMLFormElement>, \"title\"> {\n /** Page title. Rendered as the page's single `<h1>`. */\n title: React.ReactNode;\n /** Supporting copy under the title — what this form is for. */\n description?: React.ReactNode;\n /**\n * Validation summary. Rendered in a destructive `Alert` above the fields\n * when set, so the summary announces itself (`role=\"alert\"`) and sits\n * where a keyboard user lands after a failed submit. Pass a list, a\n * sentence, or your own markup — the copy is yours.\n */\n errors?: React.ReactNode;\n /**\n * Submit / cancel controls. Rendered in a bar that sticks to the bottom of\n * the scroll container, so the primary action stays reachable on a form\n * longer than the viewport.\n */\n actions?: React.ReactNode;\n /** Stable test/automation selector (emitted as `data-testid`). */\n testID?: string;\n}\n\n/**\n * FormPage — a titled form: header, validation summary, a body of sections,\n * and a sticky action bar.\n *\n * It renders a real `<form>` and forwards `onSubmit` (and every other form\n * attribute), so a plain `type=\"submit\"` button in `actions` submits it with\n * no `form=` wiring. That is what keeps it usable for a HAND-WRITTEN form —\n * `react-hook-form`, `useActionState`, or an uncontrolled `<form action>` —\n * rather than only for one of them.\n *\n * **Do not put a `ResourceForm` inside it.** `ResourceForm` renders its own\n * `<form>`, and nesting one form in another is invalid HTML: the browser\n * drops the inner element, SSR and hydration disagree about the tree, and a\n * submit fires both handlers. `ResourceForm` is the data-bound *alternative*\n * to this template — it already answers \"bind a form to a resource\", with a\n * `layout=\"page\"` mode of its own — not something this one wraps.\n *\n * ```tsx\n * <FormPage\n * title=\"New engagement\"\n * description=\"Both the coach and the client will be notified.\"\n * errors={submitFailed ? \"Fix the highlighted fields.\" : undefined}\n * onSubmit={handleSubmit(onValid)}\n * actions={\n * <>\n * <Button variant=\"ghost\" type=\"button\" testID=\"cancel\">Cancel</Button>\n * <Button type=\"submit\" testID=\"save\">Save</Button>\n * </>\n * }\n * >\n * <Section title=\"Basics\">\n * <FormField label=\"Name\">…</FormField>\n * </Section>\n * </FormPage>\n * ```\n *\n * Sections are just children — `Section` is the natural one, but any node\n * works. The body spaces them at `gap-8`, wider than the page's own `gap-6`,\n * so section boundaries read as boundaries rather than as more fields.\n *\n * The sticky bar needs a scrolling ancestor to stick to, which `AppShell`'s\n * `<main>` provides. It renders only when `actions` is set: an empty sticky\n * strip is a border floating over the content for no reason.\n *\n * This is a **layout** — it positions a form, it does not submit one. Binding\n * fields to a resource is `ResourceForm`'s job\n * (`@marktiderman/genesis-ui/data`), which is already the one home for that\n * question. The header row is markup, not a component, and deliberately not\n * `PageHeader` — see `./detail-page` for the measured reasons composing it\n * was rejected across all four templates (`title: string`, and a 6x jump in\n * the emitted per-component closure plus a new `radix-ui` runtime dependency\n * for chrome none of them render). Width is left to `Container` for the\n * one-home reason — wrap, don't duplicate.\n *\n * @stability Beta\n */\nexport const FormPage = React.forwardRef<HTMLFormElement, FormPageProps>(\n (\n { className, title, description, errors, actions, testID, children, ...props },\n ref\n ) => (\n <form\n ref={ref}\n data-slot=\"form-page\"\n data-testid={testID}\n className={cn(\"flex flex-col gap-6\", className)}\n {...props}\n >\n <header data-slot=\"form-page-header\" className=\"flex min-w-0 flex-col gap-1\">\n <h1\n data-slot=\"form-page-title\"\n className=\"truncate text-xl font-bold tracking-tight sm:text-2xl\"\n >\n {title}\n </h1>\n {description ? (\n <p\n data-slot=\"form-page-description\"\n className=\"text-sm text-muted-foreground\"\n >\n {description}\n </p>\n ) : null}\n </header>\n\n {errors ? (\n <Alert variant=\"destructive\" data-slot=\"form-page-errors\">\n <AlertDescription>{errors}</AlertDescription>\n </Alert>\n ) : null}\n\n <div data-slot=\"form-page-body\" className=\"flex min-w-0 flex-col gap-8\">\n {children}\n </div>\n\n {actions ? (\n <div\n data-slot=\"form-page-actions\"\n className=\"sticky bottom-0 z-10 flex flex-wrap items-center justify-end gap-2 border-t border-border bg-background py-3\"\n >\n {actions}\n </div>\n ) : null}\n </form>\n )\n);\nFormPage.displayName = \"FormPage\";\n"]}
1
+ {"version":3,"sources":["../src/components/layout/form-page.tsx"],"names":[],"mappings":";;;;;AAqFO,IAAM,QAAA,GAAiB,KAAA,CAAA,UAAA;AAAA,EAC5B,CACE,EAAE,SAAA,EAAW,KAAA,EAAO,WAAA,EAAa,MAAA,EAAQ,OAAA,EAAS,MAAA,EAAQ,QAAA,EAAU,GAAG,KAAA,EAAM,EAC7E,GAAA,qBAEA,IAAA;AAAA,IAAC,MAAA;AAAA,IAAA;AAAA,MACC,GAAA;AAAA,MACA,WAAA,EAAU,WAAA;AAAA,MACV,aAAA,EAAa,MAAA;AAAA,MACb,SAAA,EAAW,EAAA,CAAG,qBAAA,EAAuB,SAAS,CAAA;AAAA,MAC7C,GAAG,KAAA;AAAA,MAEJ,QAAA,EAAA;AAAA,wBAAA,IAAA,CAAC,QAAA,EAAA,EAAO,WAAA,EAAU,kBAAA,EAAmB,SAAA,EAAU,6BAAA,EAC7C,QAAA,EAAA;AAAA,0BAAA,GAAA;AAAA,YAAC,IAAA;AAAA,YAAA;AAAA,cACC,WAAA,EAAU,iBAAA;AAAA,cACV,SAAA,EAAU,uDAAA;AAAA,cAET,QAAA,EAAA;AAAA;AAAA,WACH;AAAA,UACC,WAAA,mBACC,GAAA;AAAA,YAAC,GAAA;AAAA,YAAA;AAAA,cACC,WAAA,EAAU,uBAAA;AAAA,cACV,SAAA,EAAU,+BAAA;AAAA,cAET,QAAA,EAAA;AAAA;AAAA,WACH,GACE;AAAA,SAAA,EACN,CAAA;AAAA,QAEC,MAAA,mBACC,GAAA,CAAC,KAAA,EAAA,EAAM,OAAA,EAAQ,aAAA,EAAc,WAAA,EAAU,kBAAA,EACrC,QAAA,kBAAA,GAAA,CAAC,gBAAA,EAAA,EAAkB,QAAA,EAAA,MAAA,EAAO,CAAA,EAC5B,CAAA,GACE,IAAA;AAAA,4BAEH,KAAA,EAAA,EAAI,WAAA,EAAU,gBAAA,EAAiB,SAAA,EAAU,+BACvC,QAAA,EACH,CAAA;AAAA,QAEC,OAAA,mBACC,GAAA;AAAA,UAAC,KAAA;AAAA,UAAA;AAAA,YACC,WAAA,EAAU,mBAAA;AAAA,YACV,SAAA,EAAU,8GAAA;AAAA,YAET,QAAA,EAAA;AAAA;AAAA,SACH,GACE;AAAA;AAAA;AAAA;AAGV;AACA,QAAA,CAAS,WAAA,GAAc,UAAA","file":"chunk-MPI33I4K.js","sourcesContent":["import * as React from \"react\";\nimport { cn } from \"../../utils\";\nimport { Alert, AlertDescription } from \"../ui/alert\";\n\nexport interface FormPageProps\n // `FormHTMLAttributes` declares `title?: string` (the native tooltip\n // attribute); omit it before redeclaring the page title as a ReactNode.\n extends Omit<React.FormHTMLAttributes<HTMLFormElement>, \"title\"> {\n /** Page title. Rendered as the page's single `<h1>`. */\n title: React.ReactNode;\n /** Supporting copy under the title — what this form is for. */\n description?: React.ReactNode;\n /**\n * Validation summary. Rendered in a destructive `Alert` above the fields\n * when set, so the summary announces itself (`role=\"alert\"`) and sits\n * where a keyboard user lands after a failed submit. Pass a list, a\n * sentence, or your own markup — the copy is yours.\n */\n errors?: React.ReactNode;\n /**\n * Submit / cancel controls. Rendered in a bar that sticks to the bottom of\n * the scroll container, so the primary action stays reachable on a form\n * longer than the viewport.\n */\n actions?: React.ReactNode;\n /** Stable test/automation selector (emitted as `data-testid`). */\n testID?: string;\n}\n\n/**\n * FormPage — a titled form: header, validation summary, a body of sections,\n * and a sticky action bar.\n *\n * It renders a real `<form>` and forwards `onSubmit` (and every other form\n * attribute), so a plain `type=\"submit\"` button in `actions` submits it with\n * no `form=` wiring. That is what keeps it usable for a HAND-WRITTEN form —\n * `react-hook-form`, `useActionState`, or an uncontrolled `<form action>` —\n * rather than only for one of them.\n *\n * **Do not put a `ResourceForm` inside it.** `ResourceForm` renders its own\n * `<form>`, and nesting one form in another is invalid HTML: the browser\n * drops the inner element, SSR and hydration disagree about the tree, and a\n * submit fires both handlers. `ResourceForm` is the data-bound *alternative*\n * to this template — it already answers \"bind a form to a resource\", with a\n * `layout=\"page\"` mode of its own — not something this one wraps.\n *\n * ```tsx\n * <FormPage\n * title=\"New engagement\"\n * description=\"Both parties will be notified.\"\n * errors={submitFailed ? \"Fix the highlighted fields.\" : undefined}\n * onSubmit={handleSubmit(onValid)}\n * actions={\n * <>\n * <Button variant=\"ghost\" type=\"button\" testID=\"cancel\">Cancel</Button>\n * <Button type=\"submit\" testID=\"save\">Save</Button>\n * </>\n * }\n * >\n * <Section title=\"Basics\">\n * <FormField label=\"Name\">…</FormField>\n * </Section>\n * </FormPage>\n * ```\n *\n * Sections are just children — `Section` is the natural one, but any node\n * works. The body spaces them at `gap-8`, wider than the page's own `gap-6`,\n * so section boundaries read as boundaries rather than as more fields.\n *\n * The sticky bar needs a scrolling ancestor to stick to, which `AppShell`'s\n * `<main>` provides. It renders only when `actions` is set: an empty sticky\n * strip is a border floating over the content for no reason.\n *\n * This is a **layout** — it positions a form, it does not submit one. Binding\n * fields to a resource is `ResourceForm`'s job\n * (`@marktiderman/genesis-ui/data`), which is already the one home for that\n * question. The header row is markup, not a component, and deliberately not\n * `PageHeader` — see `./detail-page` for the measured reasons composing it\n * was rejected across all four templates (`title: string`, and a 6x jump in\n * the emitted per-component closure plus a new `radix-ui` runtime dependency\n * for chrome none of them render). Width is left to `Container` for the\n * one-home reason — wrap, don't duplicate.\n *\n * @stability Beta\n */\nexport const FormPage = React.forwardRef<HTMLFormElement, FormPageProps>(\n (\n { className, title, description, errors, actions, testID, children, ...props },\n ref\n ) => (\n <form\n ref={ref}\n data-slot=\"form-page\"\n data-testid={testID}\n className={cn(\"flex flex-col gap-6\", className)}\n {...props}\n >\n <header data-slot=\"form-page-header\" className=\"flex min-w-0 flex-col gap-1\">\n <h1\n data-slot=\"form-page-title\"\n className=\"truncate text-xl font-bold tracking-tight sm:text-2xl\"\n >\n {title}\n </h1>\n {description ? (\n <p\n data-slot=\"form-page-description\"\n className=\"text-sm text-muted-foreground\"\n >\n {description}\n </p>\n ) : null}\n </header>\n\n {errors ? (\n <Alert variant=\"destructive\" data-slot=\"form-page-errors\">\n <AlertDescription>{errors}</AlertDescription>\n </Alert>\n ) : null}\n\n <div data-slot=\"form-page-body\" className=\"flex min-w-0 flex-col gap-8\">\n {children}\n </div>\n\n {actions ? (\n <div\n data-slot=\"form-page-actions\"\n className=\"sticky bottom-0 z-10 flex flex-wrap items-center justify-end gap-2 border-t border-border bg-background py-3\"\n >\n {actions}\n </div>\n ) : null}\n </form>\n )\n);\nFormPage.displayName = \"FormPage\";\n"]}
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { useBrowserSearchParams } from './chunk-CDYZRZ6U.js';
2
+ import { useBrowserSearchParams } from './chunk-WGMEVX3Y.js';
3
3
  import { useRef, useCallback, useState, useEffect } from 'react';
4
4
  import { useStorage } from '@marktiderman/genesis-core';
5
5
 
@@ -116,5 +116,5 @@ function useDataFilters(storageKey, defs, options) {
116
116
  }
117
117
 
118
118
  export { useDataFilters };
119
- //# sourceMappingURL=chunk-JA3ZKULS.js.map
120
- //# sourceMappingURL=chunk-JA3ZKULS.js.map
119
+ //# sourceMappingURL=chunk-OITWCIG2.js.map
120
+ //# sourceMappingURL=chunk-OITWCIG2.js.map