@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.
- package/EXTENDING.md +40 -1
- package/SKILL.md +198 -9
- package/dist/{AppShell-CX0M-dfc.d.ts → AppShell-LXQykEqe.d.ts} +47 -5
- package/dist/{chunk-M62CHO3N.js → chunk-4C4ESW6O.js} +9 -4
- package/dist/chunk-4C4ESW6O.js.map +1 -0
- package/dist/chunk-DIV4BYXX.js +58 -0
- package/dist/chunk-DIV4BYXX.js.map +1 -0
- package/dist/chunk-FUVWKTPL.js +19 -0
- package/dist/chunk-FUVWKTPL.js.map +1 -0
- package/dist/{chunk-PLIYF6KI.js → chunk-MPI33I4K.js} +2 -2
- package/dist/{chunk-PLIYF6KI.js.map → chunk-MPI33I4K.js.map} +1 -1
- package/dist/{chunk-JA3ZKULS.js → chunk-OITWCIG2.js} +3 -3
- package/dist/chunk-OITWCIG2.js.map +1 -0
- package/dist/chunk-PKFDGGZV.js +65 -0
- package/dist/chunk-PKFDGGZV.js.map +1 -0
- package/dist/{chunk-IXGAEECT.js → chunk-RKPS7ERS.js} +9 -5
- package/dist/chunk-RKPS7ERS.js.map +1 -0
- package/dist/{chunk-TXOZMKNW.js → chunk-S7NXD37O.js} +27 -26
- package/dist/chunk-S7NXD37O.js.map +1 -0
- package/dist/{chunk-ZPPLUGLA.js → chunk-UYI3GLFJ.js} +162 -19
- package/dist/chunk-UYI3GLFJ.js.map +1 -0
- package/dist/{chunk-CDYZRZ6U.js → chunk-WGMEVX3Y.js} +2 -2
- package/dist/chunk-WGMEVX3Y.js.map +1 -0
- package/dist/chunk-ZDHWNQTR.js +552 -0
- package/dist/chunk-ZDHWNQTR.js.map +1 -0
- package/dist/components/data/index.d.ts +306 -19
- package/dist/components/data/index.js +1177 -284
- package/dist/components/data/index.js.map +1 -1
- package/dist/components/layout/form-page.d.ts +1 -1
- package/dist/components/layout/form-page.js +1 -1
- package/dist/components/layout/index.d.ts +2 -2
- package/dist/components/layout/index.js +8 -8
- package/dist/components/patterns/index.d.ts +1 -1
- package/dist/components/patterns/index.js +5 -5
- package/dist/components/patterns/sortable-list.js +1 -1
- package/dist/components/rich-text/rich-text-editor.d.ts +194 -0
- package/dist/components/rich-text/rich-text-editor.js +833 -0
- package/dist/components/rich-text/rich-text-editor.js.map +1 -0
- package/dist/components/rich-text/rich-text.d.ts +51 -0
- package/dist/components/rich-text/rich-text.js +4 -0
- package/dist/components/rich-text/rich-text.js.map +1 -0
- package/dist/components/ui/alert-dialog.d.ts +12 -1
- package/dist/components/ui/alert-dialog.js +1 -1
- package/dist/hooks/index.d.ts +4 -3
- package/dist/hooks/index.js +5 -4
- package/dist/hooks/use-data-filters.d.ts +5 -3
- package/dist/hooks/use-data-filters.js +2 -2
- package/dist/hooks/use-resource-page.d.ts +2 -1
- package/dist/hooks/use-resource-page.js +3 -2
- package/dist/hooks/use-resource-url-state.d.ts +68 -0
- package/dist/hooks/use-resource-url-state.js +3 -0
- package/dist/hooks/use-resource-url-state.js.map +1 -0
- package/dist/hooks/use-view-preference.d.ts +17 -2
- package/dist/hooks/use-view-preference.js +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +32 -32
- package/dist/{navigation-DBwcZ6vb.d.ts → navigation-Bha0R3Fu.d.ts} +6 -3
- package/dist/rich-text-doc-C54fME2C.d.ts +180 -0
- package/dist/router/react-router.d.ts +47 -0
- package/dist/router/react-router.js +53 -0
- package/dist/router/react-router.js.map +1 -0
- package/dist/router/tanstack-router.d.ts +47 -0
- package/dist/router/tanstack-router.js +105 -0
- package/dist/router/tanstack-router.js.map +1 -0
- package/dist/{use-resource-page-Dd2VUv30.d.ts → use-resource-page-BDnnCssE.d.ts} +83 -5
- package/package.json +61 -14
- package/dist/chunk-CDYZRZ6U.js.map +0 -1
- package/dist/chunk-FTTNGGLA.js +0 -20
- package/dist/chunk-FTTNGGLA.js.map +0 -1
- package/dist/chunk-IXGAEECT.js.map +0 -1
- package/dist/chunk-JA3ZKULS.js.map +0 -1
- package/dist/chunk-M62CHO3N.js.map +0 -1
- package/dist/chunk-TXOZMKNW.js.map +0 -1
- package/dist/chunk-ZPPLUGLA.js.map +0 -1
|
@@ -6,8 +6,11 @@ import { ComponentType, ReactNode, AnchorHTMLAttributes } from 'react';
|
|
|
6
6
|
* genesis-ui does NOT depend on any router. Components that need to render a
|
|
7
7
|
* link or navigate imperatively accept an injectable abstraction defined here,
|
|
8
8
|
* and default to plain browser behavior (`<a>` / `window.location`). Consumers
|
|
9
|
-
* on
|
|
10
|
-
* their own
|
|
9
|
+
* on a router inject an adapter that maps these props to their own `<Link>` /
|
|
10
|
+
* router. Ready-made ones ship on their own subpaths, each with its router as
|
|
11
|
+
* an optional peer: `@marktiderman/genesis-ui/router/react-router` and
|
|
12
|
+
* `@marktiderman/genesis-ui/router/tanstack-router`. Next.js passes
|
|
13
|
+
* `next/link` directly.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -160,4 +163,4 @@ interface SearchParamsAdapter {
|
|
|
160
163
|
*/
|
|
161
164
|
declare function useBrowserSearchParams(): SearchParamsAdapter;
|
|
162
165
|
|
|
163
|
-
export { DefaultLink as D, type GenesisLinkProps as G, type IsPathActiveOptions as I, type LinkComponent as L, type
|
|
166
|
+
export { DefaultLink as D, type GenesisLinkProps as G, type IsPathActiveOptions as I, type LinkComponent as L, type NavigationNode as N, type ResolvedNavigation as R, type SearchParamsAdapter as S, type NavigateFn as a, defaultNavigate as d, isPathActive as i, resolveNavigation as r, useBrowserSearchParams as u };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich text documents: the JSON shape, the allow-list that sanitizes it, and
|
|
3
|
+
* the serializers (HTML, plain text) built on that allow-list.
|
|
4
|
+
*
|
|
5
|
+
* This file has no React and no editor dependency, so a server can import it
|
|
6
|
+
* (through `@marktiderman/genesis-ui/rich-text`) to validate what a client
|
|
7
|
+
* posted and to produce the HTML it stores or sends. Everything here works in
|
|
8
|
+
* Node, edge runtimes and browsers alike: no DOM, no `DOMParser`.
|
|
9
|
+
*
|
|
10
|
+
* WHY JSON IS CANONICAL. The editor's document is a tree (ProseMirror). JSON
|
|
11
|
+
* is that tree written down; HTML is one rendering of it. Storing JSON keeps
|
|
12
|
+
* the structure the editor understands, so a later schema change is a data
|
|
13
|
+
* migration instead of an HTML scraping job. HTML is derived: produce it from
|
|
14
|
+
* the JSON with `richTextToHtml`, on the server, after `sanitizeRichTextDoc`.
|
|
15
|
+
*
|
|
16
|
+
* WHY AN ALLOW-LIST. Anything not named below is dropped: unknown node and
|
|
17
|
+
* mark types, unknown attributes, links whose URL is not http(s), mailto, tel
|
|
18
|
+
* or relative, and embeds whose URL is not a recognised YouTube, Vimeo or Loom
|
|
19
|
+
* video. The serializers only ever emit tags this file writes itself, and
|
|
20
|
+
* every text and attribute value goes through `escapeHtml`, so the output
|
|
21
|
+
* cannot carry a `<script>`, an event handler or a `javascript:` URL no matter
|
|
22
|
+
* what JSON came in.
|
|
23
|
+
*/
|
|
24
|
+
/** A mark on a text node. `link` carries its URL in `attrs.href`. */
|
|
25
|
+
interface RichTextMark {
|
|
26
|
+
type: string;
|
|
27
|
+
attrs?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One node of a rich text document. Structurally the same as Tiptap's
|
|
31
|
+
* `JSONContent`, so editor output can be passed straight in.
|
|
32
|
+
*/
|
|
33
|
+
interface RichTextNode {
|
|
34
|
+
type: string;
|
|
35
|
+
attrs?: Record<string, unknown>;
|
|
36
|
+
content?: RichTextNode[];
|
|
37
|
+
marks?: RichTextMark[];
|
|
38
|
+
text?: string;
|
|
39
|
+
}
|
|
40
|
+
/** The root node of a rich text document. */
|
|
41
|
+
interface RichTextDoc extends RichTextNode {
|
|
42
|
+
type: "doc";
|
|
43
|
+
content: RichTextNode[];
|
|
44
|
+
}
|
|
45
|
+
/** Heading levels a document may contain. `h1` is left to the page title. */
|
|
46
|
+
type RichTextHeadingLevel = 2 | 3 | 4;
|
|
47
|
+
/** Video hosts the `videoEmbed` node accepts. */
|
|
48
|
+
type VideoProvider = "youtube" | "vimeo" | "loom";
|
|
49
|
+
/** A video reference, reduced to what is needed to rebuild its player URL. */
|
|
50
|
+
interface VideoEmbedAttrs {
|
|
51
|
+
provider: VideoProvider;
|
|
52
|
+
/** The host's video id (YouTube 11 chars, Vimeo digits, Loom alphanumeric). */
|
|
53
|
+
id: string;
|
|
54
|
+
/** Vimeo's privacy hash for unlisted videos (`vimeo.com/123/abcdef`). */
|
|
55
|
+
hash?: string;
|
|
56
|
+
/** Accessible name for the player frame. */
|
|
57
|
+
title?: string;
|
|
58
|
+
}
|
|
59
|
+
interface SanitizeRichTextOptions {
|
|
60
|
+
/** Heading levels to keep. Others move to the nearest kept level. Default `[2, 3, 4]`. */
|
|
61
|
+
headingLevels?: readonly RichTextHeadingLevel[];
|
|
62
|
+
/** Keep `videoEmbed` nodes. Default `true`. When false they are dropped. */
|
|
63
|
+
embeds?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Reject documents whose text is longer than this (characters, counted
|
|
66
|
+
* after sanitizing). Throws `RichTextLimitError`. Off by default; set it on
|
|
67
|
+
* the server for every write that accepts rich text from a client.
|
|
68
|
+
*/
|
|
69
|
+
maxCharacters?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Reject documents with more nodes than this (every node except the root,
|
|
72
|
+
* text included, counted after sanitizing). Throws `RichTextLimitError`.
|
|
73
|
+
* Off by default; set it on the server alongside `maxCharacters`.
|
|
74
|
+
*/
|
|
75
|
+
maxNodes?: number;
|
|
76
|
+
/**
|
|
77
|
+
* What to do with content nested deeper than 32 levels. `"drop"` (default)
|
|
78
|
+
* leaves it out, which suits rendering stored documents; `"throw"` rejects
|
|
79
|
+
* the whole document with `RichTextLimitError`, which suits server writes,
|
|
80
|
+
* so nothing an author wrote disappears without them being told.
|
|
81
|
+
*/
|
|
82
|
+
onTooDeep?: "drop" | "throw";
|
|
83
|
+
}
|
|
84
|
+
/** Thrown by `sanitizeRichTextDoc` when a document breaks a limit it was given. */
|
|
85
|
+
declare class RichTextLimitError extends Error {
|
|
86
|
+
readonly reason: "characters" | "nodes" | "depth";
|
|
87
|
+
readonly limit: number;
|
|
88
|
+
/** The measured size; for `"depth"`, the limit plus one. */
|
|
89
|
+
readonly actual: number;
|
|
90
|
+
constructor(reason: "characters" | "nodes" | "depth", limit: number, actual: number);
|
|
91
|
+
}
|
|
92
|
+
interface RichTextToHtmlOptions extends SanitizeRichTextOptions {
|
|
93
|
+
/**
|
|
94
|
+
* How video embeds are written. `"iframe"` (default) emits a player frame
|
|
95
|
+
* pointing at the host's embed URL; `"link"` emits a plain link to the
|
|
96
|
+
* video, for email and other places iframes do not run.
|
|
97
|
+
*/
|
|
98
|
+
embedAs?: "iframe" | "link";
|
|
99
|
+
}
|
|
100
|
+
/** An empty document: one empty paragraph, which is what the editor produces. */
|
|
101
|
+
declare function emptyRichTextDoc(): RichTextDoc;
|
|
102
|
+
/**
|
|
103
|
+
* True when `url` is safe to put in an `href`: an absolute http(s), mailto or
|
|
104
|
+
* tel URL, a root-relative path (`/lessons/1`) or a fragment (`#notes`).
|
|
105
|
+
*
|
|
106
|
+
* Rejects `javascript:`, `data:`, `vbscript:` and every other scheme, as well
|
|
107
|
+
* as protocol-relative `//host` URLs and anything containing whitespace or
|
|
108
|
+
* control characters (browsers strip those, which is how `java\tscript:`
|
|
109
|
+
* slips past naive checks).
|
|
110
|
+
*/
|
|
111
|
+
declare function isSafeUrl(url: unknown): url is string;
|
|
112
|
+
/**
|
|
113
|
+
* Turn what a person typed into the link field into a safe href, or `null`.
|
|
114
|
+
* `example.com/page` becomes `https://example.com/page`; an email address
|
|
115
|
+
* becomes a `mailto:` link.
|
|
116
|
+
*/
|
|
117
|
+
declare function normalizeLinkUrl(input: string): string | null;
|
|
118
|
+
/**
|
|
119
|
+
* Recognise a YouTube, Vimeo or Loom video URL (watch, share, short and embed
|
|
120
|
+
* forms) and reduce it to its provider and id. Returns `null` for anything
|
|
121
|
+
* else, including other hosts that merely look similar.
|
|
122
|
+
*/
|
|
123
|
+
declare function parseVideoUrl(input: string): VideoEmbedAttrs | null;
|
|
124
|
+
/** Validate untrusted embed attributes, returning a clean copy or `null`. */
|
|
125
|
+
declare function sanitizeVideoEmbedAttrs(attrs: unknown): VideoEmbedAttrs | null;
|
|
126
|
+
/** The player URL for an embed. Built from validated parts only. */
|
|
127
|
+
declare function videoEmbedSrc(attrs: VideoEmbedAttrs): string;
|
|
128
|
+
/** The page URL for an embed (what a viewer would open in a new tab). */
|
|
129
|
+
declare function videoWatchUrl(attrs: VideoEmbedAttrs): string;
|
|
130
|
+
/** Accessible name for an embed's frame: its title, or "YouTube video" etc. */
|
|
131
|
+
declare function videoEmbedTitle(attrs: VideoEmbedAttrs): string;
|
|
132
|
+
/**
|
|
133
|
+
* Attributes for an embed's `<iframe>`. Shared by the HTML serializer, the
|
|
134
|
+
* React renderer and the editor so all three emit the same frame.
|
|
135
|
+
*
|
|
136
|
+
* `sandbox` keeps the player away from top-level navigation and forms.
|
|
137
|
+
* `allow-same-origin` is safe here because the frame is always cross-origin
|
|
138
|
+
* (the player host), and the players need it for their own storage.
|
|
139
|
+
*/
|
|
140
|
+
declare const VIDEO_IFRAME_ATTRS: {
|
|
141
|
+
readonly allow: "accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture";
|
|
142
|
+
readonly loading: "lazy";
|
|
143
|
+
readonly referrerPolicy: "strict-origin-when-cross-origin";
|
|
144
|
+
readonly sandbox: "allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox";
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Validate an untrusted value as a rich text document and return a clean
|
|
148
|
+
* copy that contains only allow-listed nodes, marks and attributes.
|
|
149
|
+
*
|
|
150
|
+
* `null`, a string or a malformed object becomes an empty document. Run
|
|
151
|
+
* this on the server on every write, before storing the JSON or deriving
|
|
152
|
+
* HTML from it, with limits so a client cannot store an oversized or
|
|
153
|
+
* pathologically nested document:
|
|
154
|
+
*
|
|
155
|
+
* ```ts
|
|
156
|
+
* sanitizeRichTextDoc(body, { maxCharacters: 50_000, maxNodes: 5_000, onTooDeep: "throw" });
|
|
157
|
+
* ```
|
|
158
|
+
*
|
|
159
|
+
* Without limits it never throws; with them it throws `RichTextLimitError`.
|
|
160
|
+
*/
|
|
161
|
+
declare function sanitizeRichTextDoc(input: unknown, options?: SanitizeRichTextOptions): RichTextDoc;
|
|
162
|
+
/** True when `value` is shaped like a rich text document (`{ type: "doc" }`). */
|
|
163
|
+
declare function isRichTextDoc(value: unknown): value is RichTextDoc;
|
|
164
|
+
/** Escape text for use in HTML element content and quoted attributes. */
|
|
165
|
+
declare function escapeHtml(value: string): string;
|
|
166
|
+
/**
|
|
167
|
+
* Serialize a document to HTML. The input is sanitized first, so this is
|
|
168
|
+
* safe to call on untrusted JSON; the output contains only the tags listed
|
|
169
|
+
* in this file's header, with every value escaped.
|
|
170
|
+
*/
|
|
171
|
+
declare function richTextToHtml(doc: unknown, options?: RichTextToHtmlOptions): string;
|
|
172
|
+
/**
|
|
173
|
+
* Plain text for search indexes, previews and notifications. Blocks are
|
|
174
|
+
* separated by blank lines; embeds become "Title (url)".
|
|
175
|
+
*/
|
|
176
|
+
declare function richTextToPlainText(doc: unknown): string;
|
|
177
|
+
/** True when the document has no visible text and no embeds. */
|
|
178
|
+
declare function isRichTextEmpty(doc: unknown): boolean;
|
|
179
|
+
|
|
180
|
+
export { type RichTextDoc as R, type SanitizeRichTextOptions as S, VIDEO_IFRAME_ATTRS as V, type RichTextHeadingLevel as a, RichTextLimitError as b, type RichTextMark as c, type RichTextNode as d, type RichTextToHtmlOptions as e, type VideoEmbedAttrs as f, type VideoProvider as g, emptyRichTextDoc as h, escapeHtml as i, isRichTextDoc as j, isRichTextEmpty as k, isSafeUrl as l, richTextToPlainText as m, normalizeLinkUrl as n, sanitizeVideoEmbedAttrs as o, parseVideoUrl as p, videoEmbedTitle as q, richTextToHtml as r, sanitizeRichTextDoc as s, videoWatchUrl as t, videoEmbedSrc as v };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { L as LinkComponent, a as NavigateFn, S as SearchParamsAdapter } from '../navigation-Bha0R3Fu.js';
|
|
2
|
+
import 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* {@link LinkComponent} for React Router. Pass it as `linkComponent` to
|
|
6
|
+
* `AppShell`, `StatCard` and anything else that takes one. `href` maps to
|
|
7
|
+
* React Router's `to` (query and hash included), and `testID` is forwarded as
|
|
8
|
+
* `data-testid`, as `DefaultLink` does.
|
|
9
|
+
*
|
|
10
|
+
* Genesis decides the active state and passes `aria-current` itself. This is
|
|
11
|
+
* a plain `Link`, not a `NavLink`, so the router never adds a second opinion.
|
|
12
|
+
* It forwards `ref` and unknown props, so it works as the child of a Radix
|
|
13
|
+
* `asChild` (`<Button asChild>`).
|
|
14
|
+
*
|
|
15
|
+
* @stability Beta
|
|
16
|
+
*/
|
|
17
|
+
declare const ReactRouterLink: LinkComponent;
|
|
18
|
+
/**
|
|
19
|
+
* {@link NavigateFn} backed by React Router's `useNavigate()`. Pass it as
|
|
20
|
+
* `ResourcePage`'s `onNavigate` (or anywhere a `NavigateFn` is accepted) for
|
|
21
|
+
* client-side navigation instead of a full page load. Absolute URLs
|
|
22
|
+
* (`https://…`, `mailto:…`, `//host/…`) leave the app with a document
|
|
23
|
+
* navigation, as `defaultNavigate` does.
|
|
24
|
+
*
|
|
25
|
+
* @stability Beta
|
|
26
|
+
*/
|
|
27
|
+
declare function useReactRouterNavigate(): NavigateFn;
|
|
28
|
+
/**
|
|
29
|
+
* {@link SearchParamsAdapter} backed by React Router's location. Pass it as
|
|
30
|
+
* `useDataFilters(key, defs, { searchParams })` so URL-synced filters go
|
|
31
|
+
* through the router (loaders re-run, back/forward stays in sync) instead of
|
|
32
|
+
* the raw History API. Like the default browser adapter, an update keeps the
|
|
33
|
+
* current `#hash`.
|
|
34
|
+
*
|
|
35
|
+
* @stability Beta
|
|
36
|
+
*/
|
|
37
|
+
declare function useReactRouterSearchParams(): SearchParamsAdapter;
|
|
38
|
+
/**
|
|
39
|
+
* The current location as `pathname + search + hash`, the shape `AppShell`'s
|
|
40
|
+
* `activePath` expects, so nav items whose `to` names a query or hash
|
|
41
|
+
* (`/admin?tab=users`, `/settings#profile`) highlight correctly.
|
|
42
|
+
*
|
|
43
|
+
* @stability Beta
|
|
44
|
+
*/
|
|
45
|
+
declare function useReactRouterActivePath(): string;
|
|
46
|
+
|
|
47
|
+
export { ReactRouterLink, useReactRouterActivePath, useReactRouterNavigate, useReactRouterSearchParams };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { isExternalHref } from '../chunk-FUVWKTPL.js';
|
|
3
|
+
import { useCallback, useMemo } from 'react';
|
|
4
|
+
import { Link, useNavigate, useSearchParams, useLocation } from 'react-router';
|
|
5
|
+
import { jsx } from 'react/jsx-runtime';
|
|
6
|
+
|
|
7
|
+
var ReactRouterLink = ({ href, testID, ...props }) => (
|
|
8
|
+
// `props` carries `ref` and anything a Radix `asChild` parent merges in;
|
|
9
|
+
// React Router's `Link` forwards both to the anchor. `testID` wins over a
|
|
10
|
+
// `data-testid` in `props` (a parent's may be `undefined`), but only when
|
|
11
|
+
// given, so a parent's real one survives.
|
|
12
|
+
/* @__PURE__ */ jsx(Link, { to: href, ...props, ...testID !== void 0 ? { "data-testid": testID } : null })
|
|
13
|
+
);
|
|
14
|
+
function useReactRouterNavigate() {
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
return useCallback(
|
|
17
|
+
(path) => {
|
|
18
|
+
if (isExternalHref(path)) {
|
|
19
|
+
if (typeof window !== "undefined") window.location.assign(path);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
void navigate(path);
|
|
23
|
+
},
|
|
24
|
+
[navigate]
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
function useReactRouterSearchParams() {
|
|
28
|
+
const [searchParams] = useSearchParams();
|
|
29
|
+
const { hash } = useLocation();
|
|
30
|
+
const navigate = useNavigate();
|
|
31
|
+
const setSearchParams = useCallback(
|
|
32
|
+
(next, options) => {
|
|
33
|
+
const qs = next.toString();
|
|
34
|
+
void navigate(
|
|
35
|
+
{ search: qs ? `?${qs}` : "", hash },
|
|
36
|
+
{ replace: options?.replace }
|
|
37
|
+
);
|
|
38
|
+
},
|
|
39
|
+
[navigate, hash]
|
|
40
|
+
);
|
|
41
|
+
return useMemo(
|
|
42
|
+
() => ({ searchParams, setSearchParams }),
|
|
43
|
+
[searchParams, setSearchParams]
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
function useReactRouterActivePath() {
|
|
47
|
+
const { pathname, search, hash } = useLocation();
|
|
48
|
+
return `${pathname}${search}${hash}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { ReactRouterLink, useReactRouterActivePath, useReactRouterNavigate, useReactRouterSearchParams };
|
|
52
|
+
//# sourceMappingURL=react-router.js.map
|
|
53
|
+
//# sourceMappingURL=react-router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/router/react-router.tsx"],"names":[],"mappings":";;;;;AA2CO,IAAM,kBAAiC,CAAC,EAAE,IAAA,EAAM,MAAA,EAAQ,GAAG,KAAA,EAAM;AAAA;AAAA;AAAA;AAAA;AAAA,kBAKtE,GAAA,CAAC,IAAA,EAAA,EAAK,EAAA,EAAI,IAAA,EAAO,GAAG,KAAA,EAAQ,GAAI,MAAA,KAAW,MAAA,GAAY,EAAE,aAAA,EAAe,MAAA,KAAW,IAAA,EAAO;AAAA;AAYrF,SAAS,sBAAA,GAAqC;AACnD,EAAA,MAAM,WAAW,WAAA,EAAY;AAC7B,EAAA,OAAO,WAAA;AAAA,IACL,CAAC,IAAA,KAAS;AACR,MAAA,IAAI,cAAA,CAAe,IAAI,CAAA,EAAG;AACxB,QAAA,IAAI,OAAO,MAAA,KAAW,WAAA,EAAa,MAAA,CAAO,QAAA,CAAS,OAAO,IAAI,CAAA;AAC9D,QAAA;AAAA,MACF;AACA,MAAA,KAAK,SAAS,IAAI,CAAA;AAAA,IACpB,CAAA;AAAA,IACA,CAAC,QAAQ;AAAA,GACX;AACF;AAWO,SAAS,0BAAA,GAAkD;AAChE,EAAA,MAAM,CAAC,YAAY,CAAA,GAAI,eAAA,EAAgB;AACvC,EAAA,MAAM,EAAE,IAAA,EAAK,GAAI,WAAA,EAAY;AAC7B,EAAA,MAAM,WAAW,WAAA,EAAY;AAE7B,EAAA,MAAM,eAAA,GAAkB,WAAA;AAAA,IACtB,CAAC,MAAM,OAAA,KAAY;AACjB,MAAA,MAAM,EAAA,GAAK,KAAK,QAAA,EAAS;AACzB,MAAA,KAAK,QAAA;AAAA,QACH,EAAE,MAAA,EAAQ,EAAA,GAAK,IAAI,EAAE,CAAA,CAAA,GAAK,IAAI,IAAA,EAAK;AAAA,QACnC,EAAE,OAAA,EAAS,OAAA,EAAS,OAAA;AAAQ,OAC9B;AAAA,IACF,CAAA;AAAA,IACA,CAAC,UAAU,IAAI;AAAA,GACjB;AAEA,EAAA,OAAO,OAAA;AAAA,IACL,OAAO,EAAE,YAAA,EAAc,eAAA,EAAgB,CAAA;AAAA,IACvC,CAAC,cAAc,eAAe;AAAA,GAChC;AACF;AASO,SAAS,wBAAA,GAAmC;AACjD,EAAA,MAAM,EAAE,QAAA,EAAU,MAAA,EAAQ,IAAA,KAAS,WAAA,EAAY;AAC/C,EAAA,OAAO,CAAA,EAAG,QAAQ,CAAA,EAAG,MAAM,GAAG,IAAI,CAAA,CAAA;AACpC","file":"react-router.js","sourcesContent":["\"use client\";\n\n/**\n * React Router (v7, library or framework mode) adapters for genesis-ui's\n * router-agnostic navigation contract.\n *\n * Import from `@marktiderman/genesis-ui/router/react-router`. This is the only\n * genesis-ui entry that imports `react-router`, and `react-router` is an\n * OPTIONAL peer: apps on another router never install it, and apps that do\n * not import this subpath never load it.\n *\n * ```tsx\n * import { AppShell } from \"@marktiderman/genesis-ui/layout\";\n * import {\n * ReactRouterLink,\n * useReactRouterActivePath,\n * } from \"@marktiderman/genesis-ui/router/react-router\";\n *\n * <AppShell linkComponent={ReactRouterLink} activePath={useReactRouterActivePath()} … />\n * ```\n */\nimport { useCallback, useMemo } from \"react\";\nimport { Link, useLocation, useNavigate, useSearchParams } from \"react-router\";\nimport type {\n LinkComponent,\n NavigateFn,\n SearchParamsAdapter,\n} from \"../navigation\";\nimport { isExternalHref } from \"./utils\";\n\n/**\n * {@link LinkComponent} for React Router. Pass it as `linkComponent` to\n * `AppShell`, `StatCard` and anything else that takes one. `href` maps to\n * React Router's `to` (query and hash included), and `testID` is forwarded as\n * `data-testid`, as `DefaultLink` does.\n *\n * Genesis decides the active state and passes `aria-current` itself. This is\n * a plain `Link`, not a `NavLink`, so the router never adds a second opinion.\n * It forwards `ref` and unknown props, so it works as the child of a Radix\n * `asChild` (`<Button asChild>`).\n *\n * @stability Beta\n */\nexport const ReactRouterLink: LinkComponent = ({ href, testID, ...props }) => (\n // `props` carries `ref` and anything a Radix `asChild` parent merges in;\n // React Router's `Link` forwards both to the anchor. `testID` wins over a\n // `data-testid` in `props` (a parent's may be `undefined`), but only when\n // given, so a parent's real one survives.\n <Link to={href} {...props} {...(testID !== undefined ? { \"data-testid\": testID } : null)} />\n);\n\n/**\n * {@link NavigateFn} backed by React Router's `useNavigate()`. Pass it as\n * `ResourcePage`'s `onNavigate` (or anywhere a `NavigateFn` is accepted) for\n * client-side navigation instead of a full page load. Absolute URLs\n * (`https://…`, `mailto:…`, `//host/…`) leave the app with a document\n * navigation, as `defaultNavigate` does.\n *\n * @stability Beta\n */\nexport function useReactRouterNavigate(): NavigateFn {\n const navigate = useNavigate();\n return useCallback<NavigateFn>(\n (path) => {\n if (isExternalHref(path)) {\n if (typeof window !== \"undefined\") window.location.assign(path);\n return;\n }\n void navigate(path);\n },\n [navigate],\n );\n}\n\n/**\n * {@link SearchParamsAdapter} backed by React Router's location. Pass it as\n * `useDataFilters(key, defs, { searchParams })` so URL-synced filters go\n * through the router (loaders re-run, back/forward stays in sync) instead of\n * the raw History API. Like the default browser adapter, an update keeps the\n * current `#hash`.\n *\n * @stability Beta\n */\nexport function useReactRouterSearchParams(): SearchParamsAdapter {\n const [searchParams] = useSearchParams();\n const { hash } = useLocation();\n const navigate = useNavigate();\n\n const setSearchParams = useCallback<SearchParamsAdapter[\"setSearchParams\"]>(\n (next, options) => {\n const qs = next.toString();\n void navigate(\n { search: qs ? `?${qs}` : \"\", hash },\n { replace: options?.replace },\n );\n },\n [navigate, hash],\n );\n\n return useMemo(\n () => ({ searchParams, setSearchParams }),\n [searchParams, setSearchParams],\n );\n}\n\n/**\n * The current location as `pathname + search + hash`, the shape `AppShell`'s\n * `activePath` expects, so nav items whose `to` names a query or hash\n * (`/admin?tab=users`, `/settings#profile`) highlight correctly.\n *\n * @stability Beta\n */\nexport function useReactRouterActivePath(): string {\n const { pathname, search, hash } = useLocation();\n return `${pathname}${search}${hash}`;\n}\n"]}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { L as LinkComponent, a as NavigateFn, S as SearchParamsAdapter } from '../navigation-Bha0R3Fu.js';
|
|
2
|
+
import 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* {@link LinkComponent} for TanStack Router. Pass it as `linkComponent` to
|
|
6
|
+
* `AppShell`, `StatCard` and anything else that takes one. In-app hrefs
|
|
7
|
+
* navigate client-side (query and hash included, parsed by the router's
|
|
8
|
+
* `parseSearch`); absolute URLs render a plain anchor. `testID` is forwarded
|
|
9
|
+
* as `data-testid`, as `DefaultLink` does, and the active state stays the one
|
|
10
|
+
* Genesis passes: TanStack's own `aria-current` / `active` class are not
|
|
11
|
+
* applied. It forwards `ref` and unknown props, so it works as the child of a
|
|
12
|
+
* Radix `asChild` (`<Button asChild>`).
|
|
13
|
+
*
|
|
14
|
+
* @stability Beta
|
|
15
|
+
*/
|
|
16
|
+
declare const TanStackRouterLink: LinkComponent;
|
|
17
|
+
/**
|
|
18
|
+
* {@link NavigateFn} backed by the TanStack router. Pass it as
|
|
19
|
+
* `ResourcePage`'s `onNavigate` (or anywhere a `NavigateFn` is accepted) for
|
|
20
|
+
* client-side navigation instead of a full page load. Absolute URLs
|
|
21
|
+
* (`https://…`, `mailto:…`, `//host/…`) leave the app with a document
|
|
22
|
+
* navigation, as `defaultNavigate` does.
|
|
23
|
+
*
|
|
24
|
+
* @stability Beta
|
|
25
|
+
*/
|
|
26
|
+
declare function useTanStackRouterNavigate(): NavigateFn;
|
|
27
|
+
/**
|
|
28
|
+
* {@link SearchParamsAdapter} backed by the TanStack router's location. Pass
|
|
29
|
+
* it as `useDataFilters(key, defs, { searchParams })` so URL-synced filters go
|
|
30
|
+
* through the router (loaders and `validateSearch` run, back/forward stays in
|
|
31
|
+
* sync) instead of the raw History API. Like the default browser adapter, an
|
|
32
|
+
* update keeps the current path and `#hash`.
|
|
33
|
+
*
|
|
34
|
+
* @stability Beta
|
|
35
|
+
*/
|
|
36
|
+
declare function useTanStackRouterSearchParams(): SearchParamsAdapter;
|
|
37
|
+
/**
|
|
38
|
+
* The current location as `pathname + search + hash`, the shape `AppShell`'s
|
|
39
|
+
* `activePath` expects, so nav items whose `to` names a query or hash
|
|
40
|
+
* (`/admin?tab=users`, `/settings#profile`) highlight correctly. Paths are the
|
|
41
|
+
* router's (basepath removed), matching the `to` your nav items use.
|
|
42
|
+
*
|
|
43
|
+
* @stability Beta
|
|
44
|
+
*/
|
|
45
|
+
declare function useTanStackRouterActivePath(): string;
|
|
46
|
+
|
|
47
|
+
export { TanStackRouterLink, useTanStackRouterActivePath, useTanStackRouterNavigate, useTanStackRouterSearchParams };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { isExternalHref, splitNavigationHref } from '../chunk-FUVWKTPL.js';
|
|
3
|
+
import { DefaultLink } from '../chunk-WGMEVX3Y.js';
|
|
4
|
+
import { useCallback, useMemo } from 'react';
|
|
5
|
+
import { useRouter, useRouterState, useLinkProps } from '@tanstack/react-router';
|
|
6
|
+
import { jsx } from 'react/jsx-runtime';
|
|
7
|
+
|
|
8
|
+
var NO_ACTIVE_PROPS = {};
|
|
9
|
+
function toLocation(router, href) {
|
|
10
|
+
const { pathname, search, hash } = splitNavigationHref(href);
|
|
11
|
+
return {
|
|
12
|
+
// `""` (a bare `?q=1` or `#x`) means "this page" — TanStack's `.`.
|
|
13
|
+
to: pathname || ".",
|
|
14
|
+
search: search ? router.options.parseSearch(`?${search}`) : void 0,
|
|
15
|
+
hash: hash || void 0
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function InAppLink({
|
|
19
|
+
href,
|
|
20
|
+
testID,
|
|
21
|
+
children,
|
|
22
|
+
"aria-current": ariaCurrent,
|
|
23
|
+
ref,
|
|
24
|
+
...rest
|
|
25
|
+
}) {
|
|
26
|
+
const router = useRouter();
|
|
27
|
+
const target = useMemo(() => toLocation(router, href), [router, href]);
|
|
28
|
+
const linkProps = useLinkProps(
|
|
29
|
+
{
|
|
30
|
+
...target,
|
|
31
|
+
...rest,
|
|
32
|
+
activeProps: NO_ACTIVE_PROPS
|
|
33
|
+
// The adapter is route-tree agnostic, so it cannot satisfy the generated
|
|
34
|
+
// route types; the target is checked at runtime by the router instead.
|
|
35
|
+
},
|
|
36
|
+
ref
|
|
37
|
+
);
|
|
38
|
+
const { "data-status": _status, ...anchorProps } = linkProps;
|
|
39
|
+
return /* @__PURE__ */ jsx(
|
|
40
|
+
"a",
|
|
41
|
+
{
|
|
42
|
+
...anchorProps,
|
|
43
|
+
"aria-current": ariaCurrent,
|
|
44
|
+
...testID !== void 0 ? { "data-testid": testID } : null,
|
|
45
|
+
children
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
var TanStackRouterLink = (props) => isExternalHref(props.href) ? /* @__PURE__ */ jsx(DefaultLink, { ...props }) : /* @__PURE__ */ jsx(InAppLink, { ...props });
|
|
50
|
+
function useTanStackRouterNavigate() {
|
|
51
|
+
const router = useRouter();
|
|
52
|
+
return useCallback(
|
|
53
|
+
(path) => {
|
|
54
|
+
if (isExternalHref(path)) {
|
|
55
|
+
if (typeof window !== "undefined") window.location.assign(path);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
void router.navigate(
|
|
59
|
+
toLocation(router, path)
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
[router]
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
function useTanStackRouterSearchParams() {
|
|
66
|
+
const router = useRouter();
|
|
67
|
+
const searchStr = useRouterState({
|
|
68
|
+
select: (state) => state.location.searchStr
|
|
69
|
+
});
|
|
70
|
+
const searchParams = useMemo(
|
|
71
|
+
() => new URLSearchParams(searchStr),
|
|
72
|
+
[searchStr]
|
|
73
|
+
);
|
|
74
|
+
const setSearchParams = useCallback(
|
|
75
|
+
(next, options) => {
|
|
76
|
+
const qs = next.toString();
|
|
77
|
+
void router.navigate({
|
|
78
|
+
// The current URL path, not `.`: `.` resolves against the matched
|
|
79
|
+
// route's template, which is not this URL under a splat or
|
|
80
|
+
// not-found route.
|
|
81
|
+
to: router.state.location.pathname,
|
|
82
|
+
search: router.options.parseSearch(qs ? `?${qs}` : ""),
|
|
83
|
+
hash: true,
|
|
84
|
+
replace: options?.replace
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
[router]
|
|
88
|
+
);
|
|
89
|
+
return useMemo(
|
|
90
|
+
() => ({ searchParams, setSearchParams }),
|
|
91
|
+
[searchParams, setSearchParams]
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
function useTanStackRouterActivePath() {
|
|
95
|
+
return useRouterState({
|
|
96
|
+
select: ({ location }) => {
|
|
97
|
+
const search = location.searchStr && !location.searchStr.startsWith("?") ? `?${location.searchStr}` : location.searchStr;
|
|
98
|
+
return `${location.pathname}${search}${location.hash ? `#${location.hash}` : ""}`;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { TanStackRouterLink, useTanStackRouterActivePath, useTanStackRouterNavigate, useTanStackRouterSearchParams };
|
|
104
|
+
//# sourceMappingURL=tanstack-router.js.map
|
|
105
|
+
//# sourceMappingURL=tanstack-router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/router/tanstack-router.tsx"],"names":[],"mappings":"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","file":"tanstack-router.js","sourcesContent":["\"use client\";\n\n/**\n * TanStack Router adapters for genesis-ui's router-agnostic navigation\n * contract.\n *\n * Import from `@marktiderman/genesis-ui/router/tanstack-router`. This is the\n * only genesis-ui entry that imports `@tanstack/react-router`, and that\n * package is an OPTIONAL peer: apps on another router never install it, and\n * apps that do not import this subpath never load it.\n *\n * Genesis hands links a plain string `href` (`/admin?tab=users#top`), while\n * TanStack Router wants `to`, `search` and `hash` separately. These adapters\n * do that split, and they parse the query with the router's OWN\n * `parseSearch`, so a custom search serializer and `validateSearch` see the\n * same values a typed `<Link search={…}>` would produce.\n *\n * ```tsx\n * import { AppShell } from \"@marktiderman/genesis-ui/layout\";\n * import {\n * TanStackRouterLink,\n * useTanStackRouterActivePath,\n * } from \"@marktiderman/genesis-ui/router/tanstack-router\";\n *\n * <AppShell linkComponent={TanStackRouterLink} activePath={useTanStackRouterActivePath()} … />\n * ```\n */\nimport { useCallback, useMemo, type AnchorHTMLAttributes, type Ref } from \"react\";\nimport {\n useLinkProps,\n useRouter,\n useRouterState,\n type AnyRouter,\n} from \"@tanstack/react-router\";\nimport {\n DefaultLink,\n type GenesisLinkProps,\n type LinkComponent,\n type NavigateFn,\n type SearchParamsAdapter,\n} from \"../navigation\";\nimport { isExternalHref, splitNavigationHref } from \"./utils\";\n\n// Genesis owns the active state (it passes `aria-current` itself, computed by\n// `isPathActive` or the app's `isActive`). TanStack's Link adds its own —\n// `aria-current=\"page\"`, `data-status=\"active\"` and an `active` class —\n// whenever ITS prefix match passes, which can disagree with Genesis (an\n// exact-match `/app` item, or a `?tab=` item) and would leave two items\n// announced as the current page. An empty `activeProps` stops the class;\n// the attributes are overridden below.\nconst NO_ACTIVE_PROPS = {};\n\n/**\n * The navigation target for a Genesis href, with the query parsed by the\n * router's own `parseSearch` (what `<Link search>` expects).\n */\nfunction toLocation(router: AnyRouter, href: string) {\n const { pathname, search, hash } = splitNavigationHref(href);\n return {\n // `\"\"` (a bare `?q=1` or `#x`) means \"this page\" — TanStack's `.`.\n to: pathname || \".\",\n search: search ? router.options.parseSearch(`?${search}`) : undefined,\n hash: hash || undefined,\n };\n}\n\nfunction InAppLink({\n href,\n testID,\n children,\n \"aria-current\": ariaCurrent,\n ref,\n ...rest\n}: GenesisLinkProps & { ref?: Ref<HTMLAnchorElement> }) {\n const router = useRouter();\n const target = useMemo(() => toLocation(router, href), [router, href]);\n // `useLinkProps` is the hook behind TanStack's `<Link>`: client-side\n // navigation on click, `href` built through the router (basepath, rewrites,\n // masks), preloading per the router's `defaultPreload`. Props it does not\n // know (anything a Radix `asChild` parent merges in) pass through to the\n // anchor, and `ref` is merged with the one it needs for preloading.\n const linkProps = useLinkProps(\n {\n ...target,\n ...rest,\n activeProps: NO_ACTIVE_PROPS,\n // The adapter is route-tree agnostic, so it cannot satisfy the generated\n // route types; the target is checked at runtime by the router instead.\n } as Parameters<typeof useLinkProps>[0],\n ref as Parameters<typeof useLinkProps>[1],\n ) as AnchorHTMLAttributes<HTMLAnchorElement> & {\n \"data-status\"?: string;\n ref?: Ref<HTMLAnchorElement>;\n };\n const { \"data-status\": _status, ...anchorProps } = linkProps;\n return (\n <a\n {...anchorProps}\n aria-current={ariaCurrent}\n // Only when given, so a `data-testid` merged in by an `asChild` parent survives.\n {...(testID !== undefined ? { \"data-testid\": testID } : null)}\n >\n {children}\n </a>\n );\n}\n\n/**\n * {@link LinkComponent} for TanStack Router. Pass it as `linkComponent` to\n * `AppShell`, `StatCard` and anything else that takes one. In-app hrefs\n * navigate client-side (query and hash included, parsed by the router's\n * `parseSearch`); absolute URLs render a plain anchor. `testID` is forwarded\n * as `data-testid`, as `DefaultLink` does, and the active state stays the one\n * Genesis passes: TanStack's own `aria-current` / `active` class are not\n * applied. It forwards `ref` and unknown props, so it works as the child of a\n * Radix `asChild` (`<Button asChild>`).\n *\n * @stability Beta\n */\nexport const TanStackRouterLink: LinkComponent = (props) =>\n isExternalHref(props.href) ? <DefaultLink {...props} /> : <InAppLink {...props} />;\n\n/**\n * {@link NavigateFn} backed by the TanStack router. Pass it as\n * `ResourcePage`'s `onNavigate` (or anywhere a `NavigateFn` is accepted) for\n * client-side navigation instead of a full page load. Absolute URLs\n * (`https://…`, `mailto:…`, `//host/…`) leave the app with a document\n * navigation, as `defaultNavigate` does.\n *\n * @stability Beta\n */\nexport function useTanStackRouterNavigate(): NavigateFn {\n const router = useRouter();\n return useCallback<NavigateFn>(\n (path) => {\n if (isExternalHref(path)) {\n if (typeof window !== \"undefined\") window.location.assign(path);\n return;\n }\n void router.navigate(\n toLocation(router, path) as Parameters<AnyRouter[\"navigate\"]>[0],\n );\n },\n [router],\n );\n}\n\n/**\n * {@link SearchParamsAdapter} backed by the TanStack router's location. Pass\n * it as `useDataFilters(key, defs, { searchParams })` so URL-synced filters go\n * through the router (loaders and `validateSearch` run, back/forward stays in\n * sync) instead of the raw History API. Like the default browser adapter, an\n * update keeps the current path and `#hash`.\n *\n * @stability Beta\n */\nexport function useTanStackRouterSearchParams(): SearchParamsAdapter {\n const router = useRouter();\n const searchStr = useRouterState({\n select: (state) => state.location.searchStr,\n });\n const searchParams = useMemo(\n () => new URLSearchParams(searchStr),\n [searchStr],\n );\n\n const setSearchParams = useCallback<SearchParamsAdapter[\"setSearchParams\"]>(\n (next, options) => {\n const qs = next.toString();\n void router.navigate({\n // The current URL path, not `.`: `.` resolves against the matched\n // route's template, which is not this URL under a splat or\n // not-found route.\n to: router.state.location.pathname,\n search: router.options.parseSearch(qs ? `?${qs}` : \"\"),\n hash: true,\n replace: options?.replace,\n } as Parameters<AnyRouter[\"navigate\"]>[0]);\n },\n [router],\n );\n\n return useMemo(\n () => ({ searchParams, setSearchParams }),\n [searchParams, setSearchParams],\n );\n}\n\n/**\n * The current location as `pathname + search + hash`, the shape `AppShell`'s\n * `activePath` expects, so nav items whose `to` names a query or hash\n * (`/admin?tab=users`, `/settings#profile`) highlight correctly. Paths are the\n * router's (basepath removed), matching the `to` your nav items use.\n *\n * @stability Beta\n */\nexport function useTanStackRouterActivePath(): string {\n return useRouterState({\n select: ({ location }) => {\n const search =\n location.searchStr && !location.searchStr.startsWith(\"?\")\n ? `?${location.searchStr}`\n : location.searchStr;\n return `${location.pathname}${search}${location.hash ? `#${location.hash}` : \"\"}`;\n },\n });\n}\n"]}
|
|
@@ -3,11 +3,18 @@ import { ReactNode } from 'react';
|
|
|
3
3
|
import { Density } from './hooks/use-view-settings.js';
|
|
4
4
|
import { V as ViewMode } from './view-toggle-CahQ87PW.js';
|
|
5
5
|
import { Identifier, FilterParam, SortParam } from '@marktiderman/genesis-core/provider';
|
|
6
|
+
import { ViewModeDefault } from './hooks/use-view-preference.js';
|
|
6
7
|
|
|
7
8
|
interface DataTableColumn<T> {
|
|
8
9
|
key: string;
|
|
9
10
|
header: string;
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Starting width. A number is pixels: the column gets exactly that width
|
|
13
|
+
* (until the user resizes it) and the rest share what is left. A string is
|
|
14
|
+
* a class name for the cell, as before (`"w-48"`); a bare px string
|
|
15
|
+
* (`"180"`, `"180px"`) also sets the width in pixels.
|
|
16
|
+
*/
|
|
17
|
+
width?: string | number;
|
|
11
18
|
hideBelow?: "sm" | "md" | "lg" | "xl";
|
|
12
19
|
sortable?: boolean;
|
|
13
20
|
render?: (item: T, value: unknown) => ReactNode;
|
|
@@ -65,8 +72,63 @@ interface DataTableProps<T> {
|
|
|
65
72
|
* - spacious: taller (py-3)
|
|
66
73
|
*/
|
|
67
74
|
density?: Density;
|
|
75
|
+
/**
|
|
76
|
+
* Keyboard use of rows when `onRowClick` is set. Each row's open column
|
|
77
|
+
* (see `openColumn`) is a button named by its content, and the table is
|
|
78
|
+
* one tab stop: Tab reaches the current row's button, Enter or Space
|
|
79
|
+
* opens it, ↑/↓ (or j/k) and Home/End move between rows, and x toggles
|
|
80
|
+
* the row's selection when `selectable`. Default true.
|
|
81
|
+
*/
|
|
82
|
+
keyboardNavigation?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* The column whose cell opens the row (as a button), by key. Default: the
|
|
85
|
+
* first visible column that isn't `editable`. A plain-text cell is wrapped
|
|
86
|
+
* by the button; an `editable` or `render` cell keeps its own content (a
|
|
87
|
+
* link or input stays usable) with the open button beside it, shown when
|
|
88
|
+
* focused. `null` makes no cell a button (mouse only).
|
|
89
|
+
*/
|
|
90
|
+
openColumn?: string | null;
|
|
91
|
+
/**
|
|
92
|
+
* Render only the rows in view, for a long table without pagination
|
|
93
|
+
* (`pagination={false}`). The table scrolls inside a box of `height` px
|
|
94
|
+
* (default 600). Ignored while paginated.
|
|
95
|
+
*/
|
|
96
|
+
virtualize?: boolean | {
|
|
97
|
+
height?: number;
|
|
98
|
+
estimateRowHeight?: number;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Columns that stay in view when the table scrolls sideways, by key, on
|
|
102
|
+
* the left and right. `stickyFirstColumn` is shorthand for pinning the
|
|
103
|
+
* first visible column left.
|
|
104
|
+
*/
|
|
105
|
+
pinnedColumns?: {
|
|
106
|
+
left?: string[];
|
|
107
|
+
right?: string[];
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Let users reorder columns: drag a header, or press Alt+←/→ on its sort
|
|
111
|
+
* button. Default true; the order is kept per device under
|
|
112
|
+
* `columnOrderKey`, and "Reset to defaults" in the Columns menu restores it.
|
|
113
|
+
*/
|
|
114
|
+
reorderableColumns?: boolean;
|
|
115
|
+
/** localStorage key for the user's column order. */
|
|
116
|
+
columnOrderKey?: string;
|
|
117
|
+
/**
|
|
118
|
+
* The header sort, controlled: the first entry is the primary sort, later
|
|
119
|
+
* ones break ties (Shift-click adds one). Leave it unset and the table
|
|
120
|
+
* keeps its own sort, starting unsorted.
|
|
121
|
+
*/
|
|
122
|
+
sort?: readonly DataTableSort[];
|
|
123
|
+
/** Called with the next header sort whenever a header is clicked. */
|
|
124
|
+
onSortChange?: (sort: DataTableSort[]) => void;
|
|
125
|
+
}
|
|
126
|
+
/** One column of a `DataTable` header sort. */
|
|
127
|
+
interface DataTableSort {
|
|
128
|
+
key: string;
|
|
129
|
+
dir: "asc" | "desc";
|
|
68
130
|
}
|
|
69
|
-
declare function DataTable<T>({ items, columns, getKey, onRowClick, selectable, selected, onToggleSelect, onToggleAll, renderActions, emptyMessage, columnVisibility, columnVisibilityKey, resizableColumns, pagination, pageSize: initialPageSize, pageSizeOptions, stickyHeader, resizeKey, stickyFirstColumn, density, }: DataTableProps<T>): React.JSX.Element;
|
|
131
|
+
declare function DataTable<T>({ items, columns, getKey, onRowClick, selectable, selected, onToggleSelect, onToggleAll, renderActions, emptyMessage, columnVisibility, columnVisibilityKey, resizableColumns, pagination, pageSize: initialPageSize, pageSizeOptions, stickyHeader, resizeKey, stickyFirstColumn, density, keyboardNavigation, openColumn, virtualize, pinnedColumns, reorderableColumns, columnOrderKey, sort: sortProp, onSortChange, }: DataTableProps<T>): React.JSX.Element;
|
|
70
132
|
declare function ThumbnailCell({ src, alt, }: {
|
|
71
133
|
src?: string | null;
|
|
72
134
|
alt: string;
|
|
@@ -88,6 +150,12 @@ interface ResourceColumnDef<T> {
|
|
|
88
150
|
header?: string;
|
|
89
151
|
sortable?: boolean;
|
|
90
152
|
hideBelow?: "sm" | "md" | "lg" | "xl";
|
|
153
|
+
/** Starting width, passed straight through to `DataTable` (a number is px). */
|
|
154
|
+
width?: DataTableColumn<T>["width"];
|
|
155
|
+
/** Smallest width the user can resize to, in px. Passed through to `DataTable`. */
|
|
156
|
+
minWidth?: number;
|
|
157
|
+
/** Whether the column shows before the user picks columns. Default true. */
|
|
158
|
+
defaultVisible?: boolean;
|
|
91
159
|
render?: (item: T, value: unknown, actions: ResourceActions<T>) => ReactNode;
|
|
92
160
|
/** In-place cell editing, passed straight through to `DataTable`. */
|
|
93
161
|
editable?: DataTableColumn<T>["editable"];
|
|
@@ -117,8 +185,18 @@ interface UseResourcePageProps<T> {
|
|
|
117
185
|
initialFilters?: FilterParam[];
|
|
118
186
|
/** Callback when filters change. */
|
|
119
187
|
onFiltersChange?: (filters: FilterParam[]) => void;
|
|
120
|
-
/**
|
|
121
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Default view mode, used until the user picks one. Pass
|
|
190
|
+
* `{ mobile, desktop }` for a viewport-dependent default (cards on phones,
|
|
191
|
+
* a table on desktops); only an explicit pick is persisted.
|
|
192
|
+
*/
|
|
193
|
+
defaultView?: ViewModeDefault;
|
|
194
|
+
/**
|
|
195
|
+
* Where the table/cards/list choice is remembered. Default: `resource`,
|
|
196
|
+
* else one key shared by every keyless page — so pages without a
|
|
197
|
+
* `resource` (rows passed in) should each set their own.
|
|
198
|
+
*/
|
|
199
|
+
viewKey?: string;
|
|
122
200
|
/** Detail interaction mode. */
|
|
123
201
|
detail?: "panel" | "modal" | "route" | "none";
|
|
124
202
|
/** Field used for the detail title. Defaults to first column key. */
|
|
@@ -190,4 +268,4 @@ interface UseResourcePageReturn<T> {
|
|
|
190
268
|
declare function titleCase(str: string): string;
|
|
191
269
|
declare function useResourcePage<T = Record<string, unknown>>(props: UseResourcePageProps<T>): UseResourcePageReturn<T>;
|
|
192
270
|
|
|
193
|
-
export { DataTable as D, LinkCell as L, type ResourceActions as R, ThumbnailCell as T, type UseResourcePageProps as U, type ResourceColumnDef as a, type UseResourcePageReturn as b, type DataTableColumn as c, type DataTableProps as d, titleCase as t, useResourcePage as u };
|
|
271
|
+
export { DataTable as D, LinkCell as L, type ResourceActions as R, ThumbnailCell as T, type UseResourcePageProps as U, type ResourceColumnDef as a, type UseResourcePageReturn as b, type DataTableColumn as c, type DataTableProps as d, type DataTableSort as e, titleCase as t, useResourcePage as u };
|