@marktiderman/genesis-ui 2.7.0 → 2.8.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 (69) hide show
  1. package/EXTENDING.md +40 -1
  2. package/SKILL.md +144 -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-IXGAEECT.js → chunk-DWCAC2E7.js} +6 -3
  7. package/dist/chunk-DWCAC2E7.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-TXOZMKNW.js → chunk-S7NXD37O.js} +27 -26
  17. package/dist/chunk-S7NXD37O.js.map +1 -0
  18. package/dist/{chunk-ZPPLUGLA.js → chunk-UYI3GLFJ.js} +162 -19
  19. package/dist/chunk-UYI3GLFJ.js.map +1 -0
  20. package/dist/{chunk-CDYZRZ6U.js → chunk-WGMEVX3Y.js} +2 -2
  21. package/dist/chunk-WGMEVX3Y.js.map +1 -0
  22. package/dist/chunk-ZDHWNQTR.js +552 -0
  23. package/dist/chunk-ZDHWNQTR.js.map +1 -0
  24. package/dist/components/data/index.d.ts +239 -15
  25. package/dist/components/data/index.js +522 -107
  26. package/dist/components/data/index.js.map +1 -1
  27. package/dist/components/layout/form-page.d.ts +1 -1
  28. package/dist/components/layout/form-page.js +1 -1
  29. package/dist/components/layout/index.d.ts +2 -2
  30. package/dist/components/layout/index.js +8 -8
  31. package/dist/components/patterns/index.d.ts +1 -1
  32. package/dist/components/patterns/index.js +5 -5
  33. package/dist/components/patterns/sortable-list.js +1 -1
  34. package/dist/components/rich-text/rich-text-editor.d.ts +194 -0
  35. package/dist/components/rich-text/rich-text-editor.js +833 -0
  36. package/dist/components/rich-text/rich-text-editor.js.map +1 -0
  37. package/dist/components/rich-text/rich-text.d.ts +51 -0
  38. package/dist/components/rich-text/rich-text.js +4 -0
  39. package/dist/components/rich-text/rich-text.js.map +1 -0
  40. package/dist/components/ui/alert-dialog.d.ts +12 -1
  41. package/dist/components/ui/alert-dialog.js +1 -1
  42. package/dist/hooks/index.d.ts +3 -3
  43. package/dist/hooks/index.js +4 -4
  44. package/dist/hooks/use-data-filters.d.ts +5 -3
  45. package/dist/hooks/use-data-filters.js +2 -2
  46. package/dist/hooks/use-resource-page.d.ts +2 -1
  47. package/dist/hooks/use-resource-page.js +3 -2
  48. package/dist/hooks/use-view-preference.d.ts +17 -2
  49. package/dist/hooks/use-view-preference.js +2 -1
  50. package/dist/index.d.ts +2 -2
  51. package/dist/index.js +30 -30
  52. package/dist/{navigation-DBwcZ6vb.d.ts → navigation-Bha0R3Fu.d.ts} +6 -3
  53. package/dist/rich-text-doc-C54fME2C.d.ts +180 -0
  54. package/dist/router/react-router.d.ts +47 -0
  55. package/dist/router/react-router.js +53 -0
  56. package/dist/router/react-router.js.map +1 -0
  57. package/dist/router/tanstack-router.d.ts +47 -0
  58. package/dist/router/tanstack-router.js +105 -0
  59. package/dist/router/tanstack-router.js.map +1 -0
  60. package/dist/{use-resource-page-Dd2VUv30.d.ts → use-resource-page-D4byuwAs.d.ts} +20 -3
  61. package/package.json +61 -10
  62. package/dist/chunk-CDYZRZ6U.js.map +0 -1
  63. package/dist/chunk-FTTNGGLA.js +0 -20
  64. package/dist/chunk-FTTNGGLA.js.map +0 -1
  65. package/dist/chunk-IXGAEECT.js.map +0 -1
  66. package/dist/chunk-JA3ZKULS.js.map +0 -1
  67. package/dist/chunk-M62CHO3N.js.map +0 -1
  68. package/dist/chunk-TXOZMKNW.js.map +0 -1
  69. 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,25 @@ 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
+ | Per-row rights in the peek | `canEditItem(item)` for both, or `canEdit(item)` / `canDelete(item)` separately (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
+
52
71
  ## The layer contract (docs/FRAMEWORK.md)
53
72
 
54
73
  1. Layers: tokens → primitives → patterns → layouts → data-bound; each depends downward only.
@@ -62,6 +81,7 @@ report against Genesis (`packages/ui/EXTENDING.md`), not a reason to fork.
62
81
  | You need | Use | Import from |
63
82
  | ------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
64
83
  | App skeleton: nav, header, content | `AppShell` | `@marktiderman/genesis-ui/layout` |
84
+ | Sidebar, contextual nav, tab bar and back link from one tree | `AppShell navigation={tree}` (`NavigationNode`; `parentNavigation` to override) | `/layout` |
65
85
  | Page title row with count, view toggle, options | `PageHeader` (+ `options.configurable` for the in-header options popover) | `/layout` |
66
86
  | List page chrome: header, filters, loading/empty/error | `DataPageShell` (`stats`, `filters` slots) | `/data` |
67
87
  | A whole CRUD list page over a resource | `ResourcePage` | `/data` |
@@ -76,7 +96,9 @@ report against Genesis (`packages/ui/EXTENDING.md`), not a reason to fork.
76
96
  | Cmd-K palette | `CommandPalette`, `useCommandPaletteHotkey` | `/command-palette` |
77
97
  | Arrow-key list navigation | `useKeyboardNavigation` | `/hooks/use-keyboard-navigation` |
78
98
  | Reorderable list: drag, keyboard, row-menu moves | `SortableList` | `/sortable-list` |
99
+ | Formatted text (lessons, notes): editor and read-only view | `RichTextEditor`, `RichText` (+ `sanitizeRichTextDoc`, `richTextToHtml` on the server) | `/rich-text-editor`, `/rich-text` |
79
100
  | Persisted page size / density | `useViewSettings` | `/hooks/use-view-settings` |
101
+ | 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
102
 
81
103
  ## Import rule
82
104
 
@@ -97,13 +119,74 @@ wins over `@layer theme`. `tailwindFromBrand(brand).css` adds brand ramps
97
119
  (`--color-neutral-500` and the like) on top. Components reference only semantic tokens
98
120
  (`bg-primary`, `text-muted-foreground`), never raw colours.
99
121
 
122
+ ## Data views without hand-rolling
123
+
124
+ Reach for `ResourcePage` (with `data` for rows you already have) before composing DataTable,
125
+ DataGrid and DetailPanel yourself. The parts apps used to hand-roll are props:
126
+
127
+ - **Column widths:** `width: 180` is px, through a `<colgroup>`; the other columns share the rest.
128
+ Long headers truncate and keep the full text as a tooltip.
129
+ - **Clickable cards:** with a custom `renderCard`, `openOnCardClick` makes the whole card one
130
+ keyboard-reachable button that opens the detail. On a bare `DataGrid`, use `onItemClick` and
131
+ `getItemLabel`. Never wrap a card in a `div role="button"`.
132
+ - **Per-row rights:** `canEditItem(item)` hides Edit and Delete for rows the user can't change;
133
+ `canEdit(item)` / `canDelete(item)` gate each one separately. `canEditItem` is checked first: a row it
134
+ refuses gets neither button, whatever the pair says.
135
+ - **Names and empty state:** `getTitle` / `getSubtitle` when a record's name spans fields;
136
+ `emptyIcon`, `emptyMessage`, `emptyDescription` and `emptyState` (its action) for the empty list.
137
+ - **A list inside tabs:** `header="toolbar"` (on `ResourcePage` or `DataPageShell`) drops the title
138
+ block and keeps the count, view toggle, filters and create button.
139
+ - **Non-destructive confirm:** `<AlertDialogAction variant="default">` for publish/send; red stays
140
+ the default for deletes.
141
+
100
142
  ## Full inventory
101
143
 
102
144
  `docs/component-reference.md` lists every export with its props and stability tag. It is
103
145
  generated (`pnpm gen:component-reference`); do not edit it by hand.
104
146
 
147
+ ## Rich text
148
+
149
+ `RichTextEditor` (`/rich-text-editor`, Tiptap 3) needs the optional peers
150
+ `@tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/extensions` (`^3.31`). `RichText` and the
151
+ helpers on `/rich-text` need nothing beyond React and run on a server.
152
+
153
+ - **Store JSON, derive HTML.** `onChange(doc, details)` gives the document JSON (canonical) and lazy
154
+ `details.html` / `details.text`. Persist `doc` (Postgres `jsonb`). If you also need HTML or text (email,
155
+ search, previews), derive them on the server; never trust HTML or JSON a client posts.
156
+ - **Sanitize on the server on every write, with limits:**
157
+ `const doc = sanitizeRichTextDoc(body.doc, { maxCharacters: 50_000, maxNodes: 5_000, onTooDeep: "throw" })`
158
+ keeps only the allow-listed nodes (paragraph, h2-h4, lists, blockquote, bold/italic/underline,
159
+ http(s)/mailto/tel/relative links, YouTube/Vimeo/Loom embeds by id). Over a limit, or nested more than 32
160
+ levels, it throws `RichTextLimitError` (`reason`: `"characters" | "nodes" | "depth"`), so answer 413/422
161
+ instead of storing it. Then `richTextToHtml(doc)` (`{ embedAs: "link" }` for email) and
162
+ `richTextToPlainText(doc)`. Both run without a DOM and escape every value.
163
+ - **Render with `<RichText value={doc} />`**, not `dangerouslySetInnerHTML`. It re-sanitizes and works in
164
+ server components. If you must inject stored HTML, inject only HTML you produced with `richTextToHtml`.
165
+ - The editor adds roughly 135 KB gzipped (ProseMirror + Tiptap), so load it only where people write:
166
+ `const RichTextEditor = lazy(() => import("@marktiderman/genesis-ui/rich-text-editor").then((m) => ({ default: m.RichTextEditor })))`.
167
+ Reading pages use `RichText`, which pulls in no editor code.
168
+ - Embeds are off by default: `<RichTextEditor embeds />`. A CSP needs `frame-src` for
169
+ `https://www.youtube-nocookie.com https://player.vimeo.com https://www.loom.com`. The editor injects no
170
+ `<style>` tag (Tiptap's base rules are classes), so `style-src` needs no `'unsafe-inline'` for it.
171
+
172
+ ```tsx
173
+ <RichTextEditor aria-label="Lesson text" value={doc} onChange={setDoc} placeholder="Write the lesson…" embeds maxLength={20000} />
174
+ <RichText value={lesson.body} />
175
+ ```
176
+
105
177
  ## Wiring into an app
106
178
 
179
+ **Data provider and query cache.** If the app already has a TanStack `QueryClientProvider`,
180
+ mount `DataProviderRoot` inside it (or pass `queryClient` to `GenesisProvider`). Plain
181
+ `GenesisProvider` creates its own `QueryClient`, a second cache that app invalidations never
182
+ reach. On Supabase with RLS, configure `createSupabaseProvider(client, { binTables, resources,
183
+ onWrite })` instead of writing a local adapter: zero-row updates/deletes and Postgres error codes
184
+ come back as typed `DataWriteError` outcomes (`forbidden`, `gone`, `invalid`,
185
+ `unauthenticated`), recycle-bin tables count "gone from view" as success, and per-resource
186
+ `prepare` / `create` / `update` / `toFormValues` / `invalidates` cover RPC writes, form shapes and
187
+ related query keys. Branch on `isDataWriteError(e) && e.outcome`, never on the message. Details:
188
+ `packages/core/README.md`.
189
+
107
190
  **Tailwind class scanning.** Tailwind v4 skips git-ignored paths such as `node_modules`, so genesis-ui's classes
108
191
  are missing unless the app points at them. Import the shipped source entry — never hardcode an
109
192
  `@source` path into `node_modules`:
@@ -113,26 +196,78 @@ are missing unless the app points at them. Import the shipped source entry — n
113
196
  @import "@marktiderman/genesis-ui/styles.css"; /* = @source over the package's dist/ */
114
197
  ```
115
198
 
199
+ **Router adapters.** genesis-ui never imports a router from its root or layer subpaths. Links default
200
+ to a plain `<a>` and navigation to a full page load until you inject an adapter. Take the shipped one
201
+ for your router; do not hand-write it. Each lives on its own subpath with its router as an optional
202
+ peer, so the other router is never installed or bundled.
203
+
204
+ ```tsx
205
+ // React Router v7
206
+ import {
207
+ ReactRouterLink,
208
+ useReactRouterActivePath,
209
+ useReactRouterNavigate,
210
+ useReactRouterSearchParams,
211
+ } from "@marktiderman/genesis-ui/router/react-router";
212
+ // TanStack Router: the same four, named TanStackRouterLink / useTanStackRouter…,
213
+ // from "@marktiderman/genesis-ui/router/tanstack-router".
214
+
215
+ <AppShell linkComponent={ReactRouterLink} activePath={useReactRouterActivePath()} … />
216
+ <ResourcePage detail="route" onNavigate={useReactRouterNavigate()} … />
217
+ useDataFilters("orders", defs, { searchParams: useReactRouterSearchParams() });
218
+ ```
219
+
220
+ The link takes Genesis's string `href` (`/admin?tab=users#top`) and keeps its query and hash; the
221
+ TanStack one parses the query with the router's own `parseSearch`, so `validateSearch` sees what a
222
+ typed `<Link search>` would give it. Genesis owns the active state: the adapters never add a
223
+ router's own `aria-current` or `active` class. Absolute URLs leave the app as plain anchors or a
224
+ document navigation. Next.js needs no adapter: pass `next/link` as `linkComponent`.
225
+
116
226
  **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
227
+ (`pathname + search + hash`, which `use…ActivePath()` above returns). Plain items still match on the path; an item whose `to` carries a
118
228
  query or hash (`/admin?tab=users`, `/settings#profile`) is active only when those match too.
119
229
  Do not write a custom `isActive` for this. `isPathActive(to, activePath, { end: true })` is the
120
230
  exact-path variant.
121
231
 
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()`.
232
+ **Hierarchical navigation — the default.** Describe the logical hierarchy once as a
233
+ `NavigationNode<NavItem>` tree and hand it to `AppShell` with the current location. AppShell
234
+ derives the global sidebar (the root's `items`), a contextual section (the current node's
235
+ `items`, `$param`s filled from the URL), the mobile tab bar (contextual items, else global) and
236
+ a back-to-parent link in the content header. `navItems`, `mobileNavItems` and
237
+ `parentNavigation={{ href, label }}` override a derived part; `parentNavigation={null}` hides the
238
+ link. Never put a back row in page content, never `history.back()`, never copy the resolver.
239
+
240
+ ```tsx
241
+ import { AppShell, type NavigationNode, type NavItem } from "@marktiderman/genesis-ui/layout";
242
+
243
+ const navigation: NavigationNode<NavItem> = {
244
+ id: "app", path: "/", label: "Home", items: globalItems, children: [
245
+ { id: "projects", path: "/projects", label: "Projects", items: [], children: [
246
+ { id: "project", path: "/projects/$projectId", label: "Project", items: [
247
+ { to: "/projects/$projectId", icon: FileText, label: "Overview", end: true },
248
+ { to: "/projects/$projectId/tasks", icon: CheckSquare, label: "Tasks" },
249
+ ] },
250
+ ] },
251
+ ],
252
+ };
253
+
254
+ <AppShell appName="Acme" navigation={navigation} activePath={pathname + search + hash}
255
+ linkComponent={RouterLink}>{children}</AppShell>
256
+ ```
257
+
258
+ For chrome outside AppShell, call the same resolver: `resolveNavigation(tree, pathname)` (from
259
+ `/layout`, `/data` or the root barrel) returns `{ current, parent, items, trail, params }` with
260
+ every path interpolated from the URL's params.
126
261
 
127
262
  ```ts
128
263
  const nav = resolveNavigation(
129
264
  { 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 },
265
+ { id: "projects", path: "/dashboard", label: "Projects", items: [], children: [
266
+ { id: "project", path: "/projects/$projectId", label: "Project", items: projectItems },
132
267
  ] },
133
268
  ] },
134
269
  pathname,
135
270
  );
136
- // pathname "/clients/c1/tasks" → nav.current.id "client", nav.parent.path "/dashboard",
137
- // nav.params { clientId: "c1" }
271
+ // pathname "/projects/p1/tasks" → nav.current.id "project", nav.parent.path "/dashboard",
272
+ // nav.params { projectId: "p1" }
138
273
  ```
@@ -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"]}
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { useViewPreference } from './chunk-FTTNGGLA.js';
2
+ import { useViewPreference } from './chunk-PKFDGGZV.js';
3
3
  import { useMemo, useState, useCallback, useRef, useEffect } from 'react';
4
4
 
5
5
  function titleCase(str) {
@@ -186,6 +186,9 @@ function useResourcePage(props) {
186
186
  header: col.header ?? titleCase(col.key),
187
187
  sortable: col.sortable ?? true,
188
188
  hideBelow: col.hideBelow,
189
+ width: col.width,
190
+ minWidth: col.minWidth,
191
+ defaultVisible: col.defaultVisible,
189
192
  editable: col.editable,
190
193
  accessor: col.accessor,
191
194
  render: col.render ? (item, value) => col.render(item, value, {
@@ -290,5 +293,5 @@ function useResourcePage(props) {
290
293
  }
291
294
 
292
295
  export { titleCase, useResourcePage };
293
- //# sourceMappingURL=chunk-IXGAEECT.js.map
294
- //# sourceMappingURL=chunk-IXGAEECT.js.map
296
+ //# sourceMappingURL=chunk-DWCAC2E7.js.map
297
+ //# sourceMappingURL=chunk-DWCAC2E7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/use-resource-page.ts"],"names":[],"mappings":"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","file":"chunk-DWCAC2E7.js","sourcesContent":["\"use client\";\n\nimport { useState, useMemo, useCallback, useEffect, useRef } from \"react\";\nimport type { ReactNode } from \"react\";\nimport type { DataTableColumn } from \"../components/data/DataTable\";\nimport type { DataFilterConfig } from \"../components/data/DataFilters\";\nimport type { ViewMode } from \"../components/patterns/view-toggle\";\nimport type { Identifier, FilterParam, SortParam } from \"@marktiderman/genesis-core/provider\";\nimport { useViewPreference, type ViewModeDefault } from \"./use-view-preference\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/** Actions available inside column render functions. */\nexport interface ResourceActions<T> {\n update: (id: Identifier, data: Partial<T>) => Promise<T>;\n remove: (id: Identifier) => Promise<void>;\n refetch: () => void;\n}\n\n/** Column definition — full config form. */\nexport interface ResourceColumnDef<T> {\n key: string;\n header?: string;\n sortable?: boolean;\n hideBelow?: \"sm\" | \"md\" | \"lg\" | \"xl\";\n /** Starting width, passed straight through to `DataTable` (a number is px). */\n width?: DataTableColumn<T>[\"width\"];\n /** Smallest width the user can resize to, in px. Passed through to `DataTable`. */\n minWidth?: number;\n /** Whether the column shows before the user picks columns. Default true. */\n defaultVisible?: boolean;\n render?: (\n item: T,\n value: unknown,\n actions: ResourceActions<T>,\n ) => ReactNode;\n /** In-place cell editing, passed straight through to `DataTable`. */\n editable?: DataTableColumn<T>[\"editable\"];\n /**\n * The cell's value, when it isn't `item[key]`. Passed straight through to\n * `DataTable`, which also starts an edit from `String(accessor(item))`.\n */\n accessor?: (item: T) => unknown;\n}\n\nexport interface UseResourcePageProps<T> {\n /** Resource name — used for view-preference storage key. */\n resource?: string;\n /** The data to display (pre-fetched or from useResource). */\n data: T[];\n /** Total count of records (before client-side filtering). */\n total: number;\n /** Column definitions. Strings are expanded to `{ key, header, sortable }`. Omit to auto-generate from data keys. */\n columns?: Array<string | ResourceColumnDef<T>>;\n /** Status filter config. String = field name (options auto-derived). Object = explicit field + options. */\n statusFilter?: string | { field: string; options?: string[] };\n /** Fields to search across. Defaults to all string-valued fields in data. */\n searchFields?: string[];\n /** Initial filter params applied on mount. */\n initialFilters?: FilterParam[];\n /** Callback when filters change. */\n onFiltersChange?: (filters: FilterParam[]) => void;\n /**\n * Default view mode, used until the user picks one. Pass\n * `{ mobile, desktop }` for a viewport-dependent default (cards on phones,\n * a table on desktops); only an explicit pick is persisted.\n */\n defaultView?: ViewModeDefault;\n /** Detail interaction mode. */\n detail?: \"panel\" | \"modal\" | \"route\" | \"none\";\n /** Field used for the detail title. Defaults to first column key. */\n titleField?: string;\n /** Field used for the detail subtitle. Defaults to second column key. */\n subtitleField?: string;\n /**\n * Server-controlled mode. The `data` handed in has ALREADY been searched,\n * sorted and paginated by the server, so this hook must not do any of it a\n * second time — `data` is returned untouched and `total` is the server's\n * `total` rather than the length of a client-filtered slice.\n *\n * Search / sort / status state is still tracked (the filter UI stays\n * controlled and `onFiltersChange` still fires) — only the client-side\n * transform of `data` is skipped. Defaults to `false`, so every existing\n * caller keeps its current client-side behavior.\n */\n controlled?: boolean;\n}\n\nexport interface UseResourcePageReturn<T> {\n /** Filtered and sorted data for display. */\n data: T[];\n /** Total count (of the filtered set; the server's `total` when `controlled`). */\n total: number;\n /** Resolved column definitions. */\n columns: ResourceColumnDef<T>[];\n /** Columns mapped to DataTable format. */\n tableColumns: DataTableColumn<T>[];\n /** Ready-to-spread filter config for DataFilters. */\n filterConfig: {\n search: string;\n onSearchChange: (value: string) => void;\n sort: string;\n onSortChange: (sort: string) => void;\n sortOptions: Array<{ value: string; label: string }>;\n statusChips?: {\n options: string[];\n selected: string[];\n onChange: (values: string[]) => void;\n };\n };\n viewMode: ViewMode;\n setViewMode: (mode: ViewMode) => void;\n search: string;\n setSearch: (value: string) => void;\n sort: SortParam | null;\n setSort: (sort: SortParam | null) => void;\n statusValue: string[];\n setStatusValue: (values: string[]) => void;\n selectedItem: T | null;\n setSelectedItem: (item: T | null) => void;\n detailOpen: boolean;\n setDetailOpen: (open: boolean) => void;\n handleRowClick: (item: T) => void;\n titleField: string;\n subtitleField: string;\n statusOptions: string[];\n hasActiveFilters: boolean;\n clearFilters: () => void;\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Convert a key string to title case.\n * \"created_at\" → \"Created At\"\n * \"firstName\" → \"First Name\"\n */\nexport function titleCase(str: string): string {\n // Split on underscores, hyphens, and camelCase boundaries\n const words = str\n .replace(/([a-z])([A-Z])/g, \"$1 $2\") // camelCase → camel Case\n .replace(/[_-]/g, \" \") // snake_case / kebab-case → spaces\n .split(/\\s+/)\n .filter(Boolean);\n\n return words\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())\n .join(\" \");\n}\n\n/**\n * Resolve column shorthand into full ResourceColumnDef objects.\n * - undefined + data present → auto-generate from Object.keys(data[0]), skip \"id\"\n * - undefined + empty data → empty array\n * - string items → { key, header: titleCase(key), sortable: true }\n * - object items → defaults applied\n */\nfunction resolveColumns<T>(\n columns: Array<string | ResourceColumnDef<T>> | undefined,\n data: T[],\n): ResourceColumnDef<T>[] {\n if (columns === undefined) {\n if (data.length === 0) return [];\n const keys = Object.keys(data[0] as Record<string, unknown>).filter(\n (k) => k !== \"id\",\n );\n return keys.map((key) => ({\n key,\n header: titleCase(key),\n sortable: true,\n }));\n }\n\n return columns.map((col) => {\n if (typeof col === \"string\") {\n return { key: col, header: titleCase(col), sortable: true };\n }\n return {\n ...col,\n header: col.header ?? titleCase(col.key),\n sortable: col.sortable ?? true,\n };\n });\n}\n\n/**\n * Derive the list of string fields in a data set (for default search).\n */\nfunction deriveStringFields<T>(data: T[]): string[] {\n if (data.length === 0) return [];\n const first = data[0] as Record<string, unknown>;\n return Object.keys(first).filter((k) => typeof first[k] === \"string\");\n}\n\n/**\n * Derive unique status options from a data array.\n */\nfunction deriveStatusOptions<T>(data: T[], field: string): string[] {\n const values = new Set<string>();\n for (const item of data) {\n const val = (item as Record<string, unknown>)[field];\n if (val != null) values.add(String(val));\n }\n return Array.from(values).sort();\n}\n\n/**\n * Client-side text search across multiple fields.\n */\nfunction matchesSearch<T>(\n item: T,\n query: string,\n fields: string[],\n): boolean {\n if (!query) return true;\n const lower = query.toLowerCase();\n const record = item as Record<string, unknown>;\n return fields.some((f) => {\n const val = record[f];\n return val != null && String(val).toLowerCase().includes(lower);\n });\n}\n\n/**\n * Client-side sort comparator.\n */\nfunction compareItems<T>(a: T, b: T, sort: SortParam): number {\n const ra = a as Record<string, unknown>;\n const rb = b as Record<string, unknown>;\n const va = ra[sort.field];\n const vb = rb[sort.field];\n const sa = va == null ? \"\" : String(va);\n const sb = vb == null ? \"\" : String(vb);\n const cmp = sa.localeCompare(sb, undefined, {\n numeric: true,\n sensitivity: \"base\",\n });\n return sort.order === \"asc\" ? cmp : -cmp;\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\nexport function useResourcePage<T = Record<string, unknown>>(\n props: UseResourcePageProps<T>,\n): UseResourcePageReturn<T> {\n const {\n resource,\n data: rawData,\n total: rawTotal,\n columns: columnsProp,\n statusFilter: statusFilterProp,\n searchFields: searchFieldsProp,\n defaultView = \"table\",\n detail = \"panel\",\n titleField: titleFieldProp,\n subtitleField: subtitleFieldProp,\n initialFilters,\n onFiltersChange,\n controlled = false,\n } = props;\n\n // -- View mode --\n const [viewMode, setViewMode] = useViewPreference(\n resource ?? \"resource-page\",\n defaultView,\n );\n\n // -- Derive initial values from initialFilters --\n const initialSearch = useMemo(() => {\n if (!initialFilters) return \"\";\n const searchFilter = initialFilters.find(\n (f) => f.operator === \"contains\" && f.field === \"__search__\",\n );\n return searchFilter && \"value\" in searchFilter ? String(searchFilter.value ?? \"\") : \"\";\n }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount\n\n const initialSort = useMemo(() => {\n if (!initialFilters) return null;\n const sortFilter = initialFilters.find(\n (f) => f.field === \"__sort__\",\n );\n if (!sortFilter || !(\"value\" in sortFilter) || typeof sortFilter.value !== \"string\") return null;\n const [field, order] = sortFilter.value.split(\":\");\n if (!field || (order !== \"asc\" && order !== \"desc\")) return null;\n return { field, order } as SortParam;\n }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount\n\n const initialStatus = useMemo(() => {\n if (!initialFilters) return [];\n const statusFilter = initialFilters.find(\n (f) => f.operator === \"in\" && f.field === \"__status__\",\n );\n if (!statusFilter || statusFilter.operator !== \"in\") return [];\n return statusFilter.value.map((v) => String(v));\n }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount\n\n // -- Search --\n const [search, setSearch] = useState(initialSearch);\n\n // -- Sort --\n const [sort, setSort] = useState<SortParam | null>(initialSort);\n\n // -- Status filter --\n const [statusValue, setStatusValue] = useState<string[]>(initialStatus);\n\n // -- Detail panel --\n const [selectedItem, setSelectedItem] = useState<T | null>(null);\n const [detailOpen, setDetailOpen] = useState(false);\n\n // -- Resolve columns --\n const resolvedColumns = useMemo(\n () => resolveColumns(columnsProp, rawData),\n [columnsProp, rawData],\n );\n\n // -- Title / subtitle fields --\n const titleField =\n titleFieldProp ?? resolvedColumns[0]?.key ?? \"name\";\n const subtitleField =\n subtitleFieldProp ?? resolvedColumns[1]?.key ?? \"\";\n\n // -- Status filter config --\n const statusFieldName = useMemo(() => {\n if (!statusFilterProp) return null;\n return typeof statusFilterProp === \"string\"\n ? statusFilterProp\n : statusFilterProp.field;\n }, [statusFilterProp]);\n\n const statusOptions = useMemo(() => {\n if (!statusFilterProp) return [];\n if (typeof statusFilterProp === \"object\" && statusFilterProp.options) {\n return statusFilterProp.options;\n }\n if (!statusFieldName) return [];\n return deriveStatusOptions(rawData, statusFieldName);\n }, [statusFilterProp, statusFieldName, rawData]);\n\n // -- Search fields --\n const searchFields = useMemo(\n () => searchFieldsProp ?? deriveStringFields(rawData),\n [searchFieldsProp, rawData],\n );\n\n // -- Client-side filtering + sorting --\n // Skipped entirely in controlled mode: the server already applied the\n // search, the sort and the page window, so re-running them here would\n // filter the current page against a query the server has already honoured\n // (and re-sort a slice of rows that is not the full result set).\n const filteredData = useMemo(() => {\n if (controlled) return rawData;\n\n let result = rawData;\n\n // Text search\n if (search) {\n result = result.filter((item) =>\n matchesSearch(item, search, searchFields),\n );\n }\n\n // Status filter\n if (statusFieldName && statusValue.length > 0) {\n result = result.filter((item) => {\n const val = (item as Record<string, unknown>)[statusFieldName];\n return val != null && statusValue.includes(String(val));\n });\n }\n\n // Sort\n if (sort) {\n result = [...result].sort((a, b) => compareItems(a, b, sort));\n }\n\n return result;\n }, [controlled, rawData, search, searchFields, statusFieldName, statusValue, sort]);\n\n // -- Sort options (built from resolved columns) --\n const sortOptions = useMemo(() => {\n const opts: Array<{ value: string; label: string }> = [];\n for (const col of resolvedColumns) {\n if (col.sortable !== false) {\n opts.push({\n value: `${col.key}:asc`,\n label: `${col.header ?? titleCase(col.key)} (A-Z)`,\n });\n opts.push({\n value: `${col.key}:desc`,\n label: `${col.header ?? titleCase(col.key)} (Z-A)`,\n });\n }\n }\n return opts;\n }, [resolvedColumns]);\n\n // -- Sort string for DataFilters select --\n const sortString = sort ? `${sort.field}:${sort.order}` : \"\";\n\n const handleSortChange = useCallback((value: string) => {\n if (!value) {\n setSort(null);\n return;\n }\n const [field, order] = value.split(\":\");\n setSort({ field, order: order as \"asc\" | \"desc\" });\n }, []);\n\n // -- Map to DataTableColumn --\n const tableColumns: DataTableColumn<T>[] = useMemo(\n () =>\n resolvedColumns.map((col) => ({\n key: col.key,\n header: col.header ?? titleCase(col.key),\n sortable: col.sortable ?? true,\n hideBelow: col.hideBelow,\n width: col.width,\n minWidth: col.minWidth,\n defaultVisible: col.defaultVisible,\n editable: col.editable,\n accessor: col.accessor,\n render: col.render\n ? (item: T, value: unknown) =>\n col.render!(item, value, {\n update: async () => {\n throw new Error(\n \"ResourceActions.update not available in server-data mode\",\n );\n },\n remove: async () => {\n throw new Error(\n \"ResourceActions.remove not available in server-data mode\",\n );\n },\n refetch: () => {\n /* no-op in server-data mode */\n },\n })\n : undefined,\n })),\n [resolvedColumns],\n );\n\n // -- Active filters check --\n const hasActiveFilters =\n search !== \"\" || statusValue.length > 0 || sort !== null;\n\n const clearFilters = useCallback(() => {\n setSearch(\"\");\n setStatusValue([]);\n setSort(null);\n }, []);\n\n // -- Notify parent of filter changes --\n const onFiltersChangeRef = useRef(onFiltersChange);\n onFiltersChangeRef.current = onFiltersChange;\n\n useEffect(() => {\n if (!onFiltersChangeRef.current) return;\n const filters: FilterParam[] = [];\n if (search) {\n filters.push({ field: \"__search__\", operator: \"contains\", value: search });\n }\n if (sort) {\n filters.push({ field: \"__sort__\", operator: \"eq\", value: `${sort.field}:${sort.order}` });\n }\n if (statusValue.length > 0) {\n filters.push({ field: \"__status__\", operator: \"in\", value: statusValue });\n }\n onFiltersChangeRef.current(filters);\n }, [search, sort, statusValue]);\n\n // -- Row click --\n const handleRowClick = useCallback(\n (item: T) => {\n if (detail === \"none\") return;\n setSelectedItem(item);\n setDetailOpen(true);\n },\n [detail],\n );\n\n // -- Filter config (ready to spread into DataFilters) --\n const filterConfig = useMemo(\n () => ({\n search,\n onSearchChange: setSearch,\n sort: sortString,\n onSortChange: handleSortChange,\n sortOptions,\n ...(statusFieldName && statusOptions.length > 0\n ? {\n statusChips: {\n options: statusOptions,\n selected: statusValue,\n onChange: setStatusValue,\n },\n }\n : {}),\n }),\n [\n search,\n sortString,\n handleSortChange,\n sortOptions,\n statusFieldName,\n statusOptions,\n statusValue,\n ],\n );\n\n return {\n data: filteredData,\n // Controlled mode: `filteredData` is one server page, so its length is\n // not the record count — the server's total is.\n total: controlled ? rawTotal : filteredData.length,\n columns: resolvedColumns,\n tableColumns,\n filterConfig,\n viewMode,\n setViewMode,\n search,\n setSearch,\n sort,\n setSort,\n statusValue,\n setStatusValue,\n selectedItem,\n setSelectedItem,\n detailOpen,\n setDetailOpen,\n handleRowClick,\n titleField,\n subtitleField,\n statusOptions,\n hasActiveFilters,\n clearFilters,\n };\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
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/use-data-filters.ts"],"names":[],"mappings":"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","file":"chunk-OITWCIG2.js","sourcesContent":["\"use client\";\n\nimport { useState, useCallback, useEffect, useRef } from \"react\";\nimport { useStorage } from \"@marktiderman/genesis-core\";\nimport {\n useBrowserSearchParams,\n type SearchParamsAdapter,\n} from \"../navigation\";\n\ntype FilterType = \"string\" | \"multi\";\n\nexport interface FilterDef {\n type: FilterType;\n default: string | string[];\n}\n\nexport type FilterDefs = Record<string, FilterDef>;\n\nexport type FilterValues<T extends FilterDefs> = {\n [K in keyof T]: T[K][\"type\"] extends \"multi\" ? string[] : string;\n};\n\nfunction parseArray(val: string | null): string[] {\n if (!val) return [];\n return val.split(\",\").filter(Boolean);\n}\n\n/**\n * @stability Beta\n */\nexport interface UseDataFiltersOptions {\n /**\n * Injectable URL search-params adapter. Defaults to a browser History-API\n * implementation (no router). Router consumers pass their router's adapter\n * for full SPA integration: `useReactRouterSearchParams()` from\n * `@marktiderman/genesis-ui/router/react-router` or\n * `useTanStackRouterSearchParams()` from `/router/tanstack-router`.\n */\n searchParams?: SearchParamsAdapter;\n}\n\nexport function useDataFilters<T extends FilterDefs>(\n storageKey: string,\n defs: T,\n options?: UseDataFiltersOptions\n) {\n // Always call the default hook (rules-of-hooks); use the override if provided.\n const browserAdapter = useBrowserSearchParams();\n const { searchParams, setSearchParams } =\n options?.searchParams ?? browserAdapter;\n const storage = useStorage();\n const initialized = useRef(false);\n\n // Build initial state from URL -> storage -> defaults\n const getInitial = useCallback((): FilterValues<T> => {\n const result = {} as Record<string, unknown>;\n const localKey = `${storageKey}-filters`;\n let stored: Record<string, unknown> = {};\n\n try {\n const raw = storage.getItem(localKey);\n if (raw) stored = JSON.parse(raw);\n } catch {\n /* ignore */\n }\n\n // Check if URL has any of our params\n const hasUrlParams = Object.keys(defs).some((k) => searchParams.has(k));\n\n for (const [key, def] of Object.entries(defs)) {\n if (def.type === \"multi\") {\n const urlVal = hasUrlParams ? searchParams.get(key) : null;\n if (urlVal !== null) {\n result[key] = parseArray(urlVal);\n } else if (Array.isArray(stored[key])) {\n result[key] = stored[key];\n } else {\n result[key] = def.default;\n }\n } else {\n const urlVal = hasUrlParams ? searchParams.get(key) : null;\n if (urlVal !== null) {\n result[key] = urlVal;\n } else if (typeof stored[key] === \"string\") {\n result[key] = stored[key];\n } else {\n result[key] = def.default;\n }\n }\n }\n\n return result as FilterValues<T>;\n }, [defs, searchParams, storageKey, storage]);\n\n const [filters, setFilters] = useState<FilterValues<T>>(() => getInitial());\n\n // Mark as initialized after first render\n useEffect(() => {\n if (!initialized.current) {\n initialized.current = true;\n }\n }, []);\n\n // Persist to storage + URL whenever filters change\n useEffect(() => {\n if (!initialized.current) return;\n\n const localKey = `${storageKey}-filters`;\n storage.setItem(localKey, JSON.stringify(filters));\n\n // Update URL params. When using the DEFAULT browser adapter, seed from the\n // LIVE URL (not the adapter's possibly stale snapshot) so a second\n // useDataFilters instance on the same page can't merge against an old query\n // string and drop params another instance just wrote. When a caller\n // explicitly injects a SearchParamsAdapter (custom router / test double\n // whose URL state may not be synced to the DOM), trust ITS snapshot instead\n // of reading the real DOM location. Also falls back to the adapter snapshot\n // in non-browser envs.\n const newParams = new URLSearchParams(\n !options?.searchParams && typeof window !== \"undefined\"\n ? window.location.search\n : searchParams.toString(),\n );\n for (const [key, def] of Object.entries(defs)) {\n const val = (filters as Record<string, unknown>)[key];\n const isDefault =\n def.type === \"multi\"\n ? (val as string[]).length === 0\n : val === def.default;\n\n if (isDefault) {\n newParams.delete(key);\n } else if (def.type === \"multi\") {\n newParams.set(key, (val as string[]).join(\",\"));\n } else {\n newParams.set(key, val as string);\n }\n }\n setSearchParams(newParams, { replace: true });\n }, [filters, defs, storageKey, storage]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const setFilter = useCallback(\n <K extends keyof T>(\n key: K,\n value: T[K][\"type\"] extends \"multi\" ? string[] : string\n ) => {\n setFilters((prev) => ({ ...prev, [key]: value }));\n },\n []\n );\n\n const resetFilters = useCallback(() => {\n const result = {} as Record<string, unknown>;\n for (const [key, def] of Object.entries(defs)) {\n result[key] = def.default;\n }\n setFilters(result as FilterValues<T>);\n }, [defs]);\n\n const loadView = useCallback(\n (viewFilters: Record<string, unknown>) => {\n const result = {} as Record<string, unknown>;\n for (const [key, def] of Object.entries(defs)) {\n const v = viewFilters[key];\n if (def.type === \"multi\" && Array.isArray(v)) {\n result[key] = v;\n } else if (def.type === \"string\" && typeof v === \"string\") {\n result[key] = v;\n } else {\n result[key] = def.default;\n }\n }\n setFilters(result as FilterValues<T>);\n },\n [defs]\n );\n\n const hasActiveFilters = Object.entries(defs).some(([key, def]) => {\n const val = (filters as Record<string, unknown>)[key];\n if (def.type === \"multi\") return (val as string[]).length > 0;\n return val !== def.default;\n });\n\n return {\n filters,\n setFilter,\n resetFilters,\n loadView,\n hasActiveFilters,\n };\n}\n"]}