cortena-ui 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/README.md +149 -1
- package/dist/a2ui/views.js +2 -2
- package/dist/agent-chat/a2ui-block.d.ts +60 -0
- package/dist/agent-chat/a2ui-block.js +69 -0
- package/dist/agent-chat/a2ui-block.js.map +1 -0
- package/dist/agent-chat/agui-client.d.ts +40 -0
- package/dist/agent-chat/agui-client.js +251 -0
- package/dist/agent-chat/agui-client.js.map +1 -0
- package/dist/agent-chat/bridge.d.ts +109 -0
- package/dist/agent-chat/bridge.js +349 -0
- package/dist/agent-chat/bridge.js.map +1 -0
- package/dist/agent-chat/session.d.ts +58 -0
- package/dist/agent-chat/session.js +249 -0
- package/dist/agent-chat/session.js.map +1 -0
- package/dist/agent-chat/store.d.ts +67 -0
- package/dist/agent-chat/store.js +548 -0
- package/dist/agent-chat/store.js.map +1 -0
- package/dist/agent-chat/types.d.ts +187 -0
- package/dist/agent-chat/types.js +17 -0
- package/dist/agent-chat/types.js.map +1 -0
- package/dist/agent-chat.d.ts +10 -0
- package/dist/agent-chat.js +10 -0
- package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
- package/dist/components/admin-permissions/admin-permissions.js +101 -0
- package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
- package/dist/components/admin-permissions/context.d.ts +70 -0
- package/dist/components/admin-permissions/context.js +258 -0
- package/dist/components/admin-permissions/context.js.map +1 -0
- package/dist/components/admin-permissions/index.d.ts +10 -0
- package/dist/components/admin-permissions/licence.d.ts +15 -0
- package/dist/components/admin-permissions/licence.js +78 -0
- package/dist/components/admin-permissions/licence.js.map +1 -0
- package/dist/components/admin-permissions/matrix.d.ts +20 -0
- package/dist/components/admin-permissions/matrix.js +191 -0
- package/dist/components/admin-permissions/matrix.js.map +1 -0
- package/dist/components/admin-permissions/members.d.ts +18 -0
- package/dist/components/admin-permissions/members.js +185 -0
- package/dist/components/admin-permissions/members.js.map +1 -0
- package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
- package/dist/components/admin-permissions/role-assignment.js +174 -0
- package/dist/components/admin-permissions/role-assignment.js.map +1 -0
- package/dist/components/admin-permissions/roles.d.ts +25 -0
- package/dist/components/admin-permissions/roles.js +168 -0
- package/dist/components/admin-permissions/roles.js.map +1 -0
- package/dist/components/admin-permissions/types.d.ts +152 -0
- package/dist/components/admin-permissions/types.js +63 -0
- package/dist/components/admin-permissions/types.js.map +1 -0
- package/dist/components/agent-chat-popup.d.ts +29 -0
- package/dist/components/agent-chat-popup.js +188 -0
- package/dist/components/agent-chat-popup.js.map +1 -0
- package/dist/components/agent-chat.d.ts +143 -0
- package/dist/components/agent-chat.js +578 -0
- package/dist/components/agent-chat.js.map +1 -0
- package/dist/components/app-shell.d.ts +126 -0
- package/dist/components/app-shell.js +297 -0
- package/dist/components/app-shell.js.map +1 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button-link.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/checkbox.d.ts +1 -1
- package/dist/components/combobox.d.ts +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/consent-screen.d.ts +65 -0
- package/dist/components/consent-screen.js +123 -0
- package/dist/components/consent-screen.js.map +1 -0
- package/dist/components/data-table/data-table.d.ts +15 -1
- package/dist/components/data-table/data-table.js +18 -4
- package/dist/components/data-table/data-table.js.map +1 -1
- package/dist/components/data-table/index.d.ts +4 -4
- package/dist/components/data-table/parts.d.ts +27 -3
- package/dist/components/data-table/parts.js +175 -55
- package/dist/components/data-table/parts.js.map +1 -1
- package/dist/components/data-table/types.d.ts +61 -0
- package/dist/components/data-table/use-data-table.js +91 -6
- package/dist/components/data-table/use-data-table.js.map +1 -1
- package/dist/components/data-table/use-server-source.js +119 -28
- package/dist/components/data-table/use-server-source.js.map +1 -1
- package/dist/components/help-panel.d.ts +131 -0
- package/dist/components/help-panel.js +545 -0
- package/dist/components/help-panel.js.map +1 -0
- package/dist/components/login-screen.d.ts +127 -0
- package/dist/components/login-screen.js +339 -0
- package/dist/components/login-screen.js.map +1 -0
- package/dist/components/session-guard.d.ts +268 -0
- package/dist/components/session-guard.js +632 -0
- package/dist/components/session-guard.js.map +1 -0
- package/dist/components/toast.d.ts +1 -1
- package/dist/core.d.ts +5 -1
- package/dist/core.js +11 -7
- package/dist/data-table.d.ts +13 -4
- package/dist/data-table.js +10 -2
- package/dist/hooks/use-cortena-theme.js +49 -3
- package/dist/hooks/use-cortena-theme.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +21 -8
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.js +2 -1
- package/package.json +16 -4
- package/src/agent-chat/a2ui-block.ts +118 -0
- package/src/agent-chat/agui-client.ts +405 -0
- package/src/agent-chat/bridge.ts +433 -0
- package/src/agent-chat/session.ts +392 -0
- package/src/agent-chat/store.ts +738 -0
- package/src/agent-chat/types.ts +213 -0
- package/src/components/admin-permissions/admin-permissions.tsx +130 -0
- package/src/components/admin-permissions/context.tsx +376 -0
- package/src/components/admin-permissions/index.tsx +32 -0
- package/src/components/admin-permissions/licence.tsx +84 -0
- package/src/components/admin-permissions/matrix.tsx +257 -0
- package/src/components/admin-permissions/members.tsx +204 -0
- package/src/components/admin-permissions/role-assignment.tsx +239 -0
- package/src/components/admin-permissions/roles.tsx +169 -0
- package/src/components/admin-permissions/types.ts +231 -0
- package/src/components/agent-chat-popup.tsx +289 -0
- package/src/components/agent-chat.tsx +843 -0
- package/src/components/app-shell.tsx +502 -0
- package/src/components/consent-screen.tsx +239 -0
- package/src/components/data-table/data-table.tsx +36 -0
- package/src/components/data-table/index.tsx +6 -1
- package/src/components/data-table/parts.tsx +223 -47
- package/src/components/data-table/types.ts +68 -0
- package/src/components/data-table/use-data-table.ts +152 -4
- package/src/components/data-table/use-server-source.ts +150 -12
- package/src/components/help-panel.tsx +765 -0
- package/src/components/login-screen.tsx +479 -0
- package/src/components/session-guard.tsx +1071 -0
- package/src/entries/agent-chat.ts +113 -0
- package/src/entries/core.ts +8 -0
- package/src/entries/data-table.ts +41 -0
- package/src/entries/markdown.ts +25 -0
- package/src/hooks/use-cortena-theme.ts +63 -4
- package/src/index.ts +6 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HelpPanel — the extension's own help, answered from its own functional
|
|
5
|
+
* document.
|
|
6
|
+
*
|
|
7
|
+
* §12 of how-to-create-a-cortena-extension, audit rule P-13. Every extension
|
|
8
|
+
* with a UI ships one: anchored bottom-right, collapsed by default, expanding
|
|
9
|
+
* into a right-hand vertical panel. It fills `AppShell`'s help slot, so the
|
|
10
|
+
* shell keeps the button, the open state and the corner geometry (the panel
|
|
11
|
+
* stops 12px above the cluster so the button that opened it stays clickable).
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <AppShell
|
|
15
|
+
* extension={{ id: "tasks", name: "Tasks" }}
|
|
16
|
+
* user={user}
|
|
17
|
+
* help={{ source: "/docs/functional.md" }}
|
|
18
|
+
* helpPanel={helpPanelSlot({
|
|
19
|
+
* // lazy: the document is fetched the first time help is opened
|
|
20
|
+
* source: () => fetch("/docs/functional.md").then((r) => r.text()),
|
|
21
|
+
* documentUrl: "/docs/functional.md",
|
|
22
|
+
* context: location.pathname,
|
|
23
|
+
* })}
|
|
24
|
+
* >
|
|
25
|
+
* <HelpHotkeys />
|
|
26
|
+
* …
|
|
27
|
+
* </AppShell>
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* It is NOT the agent pop-up (§17) and the two must not be merged. Help
|
|
31
|
+
* answers *about* the extension from a document; the agent *acts* in the
|
|
32
|
+
* extension through its tools. Same corner, different affordances.
|
|
33
|
+
*
|
|
34
|
+
* Giving the functional document a runtime consumer is the whole point: a
|
|
35
|
+
* stale document becomes a visibly wrong help panel rather than a file nobody
|
|
36
|
+
* opens. So the panel renders the document itself — outline from its headings,
|
|
37
|
+
* search over its text, `#heading` deep links, and "open in new tab" for the
|
|
38
|
+
* whole thing — rather than a hand-written copy of it that can drift.
|
|
39
|
+
*
|
|
40
|
+
* `context` is the current route. On open the panel jumps to the section that
|
|
41
|
+
* route is about, matched against the front matter's `routes:` map first and
|
|
42
|
+
* against the headings second, so help opens on the part of the document the
|
|
43
|
+
* user is looking at rather than at the top of it.
|
|
44
|
+
*
|
|
45
|
+
* Lives on the `cortena-ui/markdown` entry: it renders markdown, so it carries
|
|
46
|
+
* react-markdown, and no extension that only draws a Button may pay for that.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { ExternalLink, Search, X } from "lucide-react";
|
|
50
|
+
import * as React from "react";
|
|
51
|
+
import { Markdown, markdownSanitizeSchema } from "@/components/markdown";
|
|
52
|
+
import { Spinner } from "@/components/spinner";
|
|
53
|
+
import { cn } from "@/lib/cn";
|
|
54
|
+
|
|
55
|
+
/* ── the document ────────────────────────────────────────────────────────── */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The markdown, or a loader for it. A function is called the first time the
|
|
59
|
+
* panel is opened, which is what keeps a long document out of the initial
|
|
60
|
+
* payload; keep its identity stable (module scope, or `useCallback`) or it is
|
|
61
|
+
* called again on every parent render.
|
|
62
|
+
*/
|
|
63
|
+
export type HelpSource = string | (() => Promise<string>);
|
|
64
|
+
|
|
65
|
+
export interface HelpSection {
|
|
66
|
+
/** Slug of the heading — the `#anchor` the outline links to. `""` for the preamble. */
|
|
67
|
+
id: string;
|
|
68
|
+
/** 1–6 for a heading; 0 for the text before the first heading. */
|
|
69
|
+
depth: number;
|
|
70
|
+
/** Heading text with inline markdown removed. */
|
|
71
|
+
title: string;
|
|
72
|
+
/** The heading and everything under it, verbatim, so it can be re-rendered alone. */
|
|
73
|
+
markdown: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HelpRoute {
|
|
77
|
+
/** A route the extension serves, e.g. `/board`. */
|
|
78
|
+
route: string;
|
|
79
|
+
/** The section it is about: a heading, or that heading's slug. */
|
|
80
|
+
target: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface HelpDocument {
|
|
84
|
+
/** `title:` from the front matter, else the first `#` heading. */
|
|
85
|
+
title?: string;
|
|
86
|
+
/** Every scalar key in the front matter. */
|
|
87
|
+
frontMatter: Record<string, string>;
|
|
88
|
+
/** The `route:` / `routes:` block, longest route first. */
|
|
89
|
+
routes: HelpRoute[];
|
|
90
|
+
/** The document without its front matter. */
|
|
91
|
+
body: string;
|
|
92
|
+
sections: HelpSection[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Lowercase, punctuation to hyphens. The same slug the rendered heading gets. */
|
|
96
|
+
function slugify(text: string): string {
|
|
97
|
+
const slug = text
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.normalize("NFKD")
|
|
100
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
101
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
102
|
+
.replace(/^-+|-+$/g, "");
|
|
103
|
+
return slug || "section";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Slugs in document order, with `-1`, `-2` for repeats, as GitHub numbers them. */
|
|
107
|
+
function createSlugger(): (text: string) => string {
|
|
108
|
+
const seen = new Map<string, number>();
|
|
109
|
+
return (text) => {
|
|
110
|
+
const base = slugify(text);
|
|
111
|
+
const n = seen.get(base) ?? 0;
|
|
112
|
+
seen.set(base, n + 1);
|
|
113
|
+
return n === 0 ? base : `${base}-${n}`;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Heading text with the inline markdown taken off, so the outline entry and
|
|
119
|
+
* the slug match what the rendered heading actually says. Without this a
|
|
120
|
+
* `## [Reports](/reports)` heading slugs to `reports-reports` in the outline
|
|
121
|
+
* and to `reports` in the document, and the deep link lands nowhere.
|
|
122
|
+
*/
|
|
123
|
+
function plainText(md: string): string {
|
|
124
|
+
return md
|
|
125
|
+
.replace(/`([^`]*)`/g, "$1")
|
|
126
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
127
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
128
|
+
.replace(/<[^>]*>/g, "")
|
|
129
|
+
.replace(/[*_~]+/g, "")
|
|
130
|
+
.replace(/\s+/g, " ")
|
|
131
|
+
.trim();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
135
|
+
const HEADING = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/;
|
|
136
|
+
const FENCE = /^[ \t]*(```|~~~)/;
|
|
137
|
+
|
|
138
|
+
function unquote(value: string): string {
|
|
139
|
+
const trimmed = value.trim();
|
|
140
|
+
const quoted = /^(['"])([\s\S]*)\1$/.exec(trimmed);
|
|
141
|
+
return quoted ? (quoted[2] ?? "") : trimmed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse the document once: front matter, then sections split at ATX headings.
|
|
146
|
+
*
|
|
147
|
+
* Exported because the route map and the outline are worth asserting on
|
|
148
|
+
* without a browser, and because an extension that wants its own navigation
|
|
149
|
+
* over the same document should read it the same way rather than write a
|
|
150
|
+
* second parser.
|
|
151
|
+
*/
|
|
152
|
+
export function parseHelpDocument(source: string): HelpDocument {
|
|
153
|
+
const matter = FRONT_MATTER.exec(source);
|
|
154
|
+
const body = matter ? source.slice(matter[0].length) : source;
|
|
155
|
+
|
|
156
|
+
const frontMatter: Record<string, string> = {};
|
|
157
|
+
const routes: HelpRoute[] = [];
|
|
158
|
+
if (matter) {
|
|
159
|
+
let inRoutes = false;
|
|
160
|
+
for (const line of (matter[1] ?? "").split(/\r?\n/)) {
|
|
161
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
162
|
+
const indented = /^[ \t]+/.test(line);
|
|
163
|
+
const pair = /^[ \t]*([^:]+):[ \t]*(.*)$/.exec(line);
|
|
164
|
+
if (!pair) continue;
|
|
165
|
+
const key = unquote(pair[1] ?? "");
|
|
166
|
+
const value = unquote(pair[2] ?? "");
|
|
167
|
+
if (inRoutes && indented) {
|
|
168
|
+
// ` /board: Working the board` — a route, and the section it is about.
|
|
169
|
+
if (key && value) routes.push({ route: key, target: value });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
inRoutes = !indented && (key === "route" || key === "routes") && value === "";
|
|
173
|
+
if (!inRoutes && !indented && key) frontMatter[key] = value;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Longest route first, so `/reports/exports` wins over `/reports`.
|
|
177
|
+
routes.sort((a, b) => b.route.length - a.route.length);
|
|
178
|
+
|
|
179
|
+
const slug = createSlugger();
|
|
180
|
+
const sections: HelpSection[] = [];
|
|
181
|
+
let current: HelpSection | undefined;
|
|
182
|
+
let fence: string | undefined;
|
|
183
|
+
for (const line of body.split(/\r?\n/)) {
|
|
184
|
+
const fenced = FENCE.exec(line);
|
|
185
|
+
if (fenced) {
|
|
186
|
+
const mark = fenced[1] ?? "";
|
|
187
|
+
fence = fence === undefined ? mark : fence === mark ? undefined : fence;
|
|
188
|
+
}
|
|
189
|
+
const heading = fence === undefined ? HEADING.exec(line) : null;
|
|
190
|
+
if (heading) {
|
|
191
|
+
const title = plainText(heading[2] ?? "");
|
|
192
|
+
current = { id: slug(title), depth: (heading[1] ?? "").length, title, markdown: "" };
|
|
193
|
+
sections.push(current);
|
|
194
|
+
} else if (!current) {
|
|
195
|
+
// Everything before the first heading is one unnamed section, so a
|
|
196
|
+
// search that matches the opening paragraph still shows it.
|
|
197
|
+
current = { id: "", depth: 0, title: "", markdown: "" };
|
|
198
|
+
sections.push(current);
|
|
199
|
+
}
|
|
200
|
+
current.markdown += `${line}\n`;
|
|
201
|
+
}
|
|
202
|
+
const preamble = sections[0];
|
|
203
|
+
if (preamble && preamble.depth === 0 && !preamble.markdown.trim()) sections.shift();
|
|
204
|
+
|
|
205
|
+
const title = frontMatter.title ?? sections.find((s) => s.depth === 1)?.title;
|
|
206
|
+
return { title, frontMatter, routes, body, sections };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalizeRoute(route: string): string {
|
|
210
|
+
const path = (route.split(/[?#]/)[0] ?? "").trim().toLowerCase();
|
|
211
|
+
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function findSection(doc: HelpDocument, target: string): HelpSection | undefined {
|
|
215
|
+
const wanted = target.trim().toLowerCase();
|
|
216
|
+
return doc.sections.find(
|
|
217
|
+
(s) =>
|
|
218
|
+
s.id !== "" &&
|
|
219
|
+
(s.id === wanted || s.id === slugify(wanted) || s.title.toLowerCase() === wanted),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The section a route is about: the front matter's map first (exact, then the
|
|
225
|
+
* longest matching prefix, so `/tasks/123` finds `/tasks`), then a heading
|
|
226
|
+
* whose slug is the route or its last segment.
|
|
227
|
+
*/
|
|
228
|
+
export function resolveHelpTarget(doc: HelpDocument, context?: string): string | undefined {
|
|
229
|
+
if (!context) return undefined;
|
|
230
|
+
const path = normalizeRoute(context);
|
|
231
|
+
if (!path) return undefined;
|
|
232
|
+
|
|
233
|
+
for (const { route, target } of doc.routes) {
|
|
234
|
+
const candidate = normalizeRoute(route);
|
|
235
|
+
const prefix = candidate === "/" ? "/" : `${candidate}/`;
|
|
236
|
+
if (candidate === path || path.startsWith(prefix)) {
|
|
237
|
+
const section = findSection(doc, target);
|
|
238
|
+
if (section) return section.id;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const segments = path.split("/").filter(Boolean);
|
|
242
|
+
for (const candidate of [path, segments.at(-1)]) {
|
|
243
|
+
if (!candidate) continue;
|
|
244
|
+
const section = findSection(doc, candidate);
|
|
245
|
+
if (section) return section.id;
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* ── heading anchors ─────────────────────────────────────────────────────── */
|
|
251
|
+
|
|
252
|
+
interface HastNode {
|
|
253
|
+
type?: string;
|
|
254
|
+
tagName?: string;
|
|
255
|
+
value?: string;
|
|
256
|
+
properties?: Record<string, unknown>;
|
|
257
|
+
children?: HastNode[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function nodeText(node: HastNode): string {
|
|
261
|
+
if (node.type === "text") return node.value ?? "";
|
|
262
|
+
return (node.children ?? []).map(nodeText).join("");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Give every heading the slug the outline links to. It runs AFTER sanitize —
|
|
267
|
+
* the ids are ours, not the document's, so they are added to trusted nodes
|
|
268
|
+
* rather than smuggled through the sanitizer.
|
|
269
|
+
*/
|
|
270
|
+
function rehypeHelpAnchors() {
|
|
271
|
+
return (tree: HastNode) => {
|
|
272
|
+
const slug = createSlugger();
|
|
273
|
+
const walk = (node: HastNode) => {
|
|
274
|
+
const tag = node.tagName ?? "";
|
|
275
|
+
if (/^h[1-6]$/.test(tag)) {
|
|
276
|
+
node.properties = { ...node.properties, id: slug(plainText(nodeText(node))) };
|
|
277
|
+
}
|
|
278
|
+
for (const child of node.children ?? []) walk(child);
|
|
279
|
+
};
|
|
280
|
+
walk(tree);
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ── keyboard ────────────────────────────────────────────────────────────── */
|
|
285
|
+
|
|
286
|
+
function isTypingTarget(target: EventTarget | null): boolean {
|
|
287
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
288
|
+
if (target.isContentEditable) return true;
|
|
289
|
+
const tag = target.tagName;
|
|
290
|
+
if (tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
291
|
+
if (tag !== "INPUT") return target.closest('[role="textbox"], [role="searchbox"]') !== null;
|
|
292
|
+
const type = (target as HTMLInputElement).type;
|
|
293
|
+
return type !== "checkbox" && type !== "radio" && type !== "button" && type !== "submit";
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The shell owns the open state and exposes only its button, so with no
|
|
298
|
+
* `onOpen` the shortcut presses exactly the control a pointer would. Already
|
|
299
|
+
* open (the button says so) means `?` is a no-op rather than a close.
|
|
300
|
+
*/
|
|
301
|
+
function pressHelpButton(): void {
|
|
302
|
+
const button = document.querySelector<HTMLElement>('[data-slot="help-button"]');
|
|
303
|
+
if (!button || button.getAttribute("aria-expanded") === "true") return;
|
|
304
|
+
button.click();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface HelpHotkeysOptions {
|
|
308
|
+
/** Whether the panel is open. Escape only closes when it is. */
|
|
309
|
+
open?: boolean;
|
|
310
|
+
/** Defaults to pressing the shell's help button. */
|
|
311
|
+
onOpen?: () => void;
|
|
312
|
+
onClose?: () => void;
|
|
313
|
+
/** Turn the shortcuts off without unmounting. */
|
|
314
|
+
disabled?: boolean;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Escape closes, `?` opens — the second only when focus is not in an input,
|
|
319
|
+
* because `?` is a character before it is a shortcut and stealing it from
|
|
320
|
+
* someone typing a question into a filter box is worse than having no
|
|
321
|
+
* shortcut at all.
|
|
322
|
+
*
|
|
323
|
+
* `HelpPanel` calls this itself for Escape. Mount `<HelpHotkeys />` in the
|
|
324
|
+
* shell's children for `?`, which has to be listening while the panel is
|
|
325
|
+
* closed and therefore unmounted.
|
|
326
|
+
*/
|
|
327
|
+
export function useHelpHotkeys({ open, onOpen, onClose, disabled }: HelpHotkeysOptions = {}): void {
|
|
328
|
+
const state = React.useRef({ open, onOpen, onClose });
|
|
329
|
+
state.current = { open, onOpen, onClose };
|
|
330
|
+
React.useEffect(() => {
|
|
331
|
+
if (disabled) return;
|
|
332
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
333
|
+
const { open: isOpen, onOpen: openIt, onClose: closeIt } = state.current;
|
|
334
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
335
|
+
if (event.key === "Escape") {
|
|
336
|
+
if (isOpen && closeIt) closeIt();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (event.key !== "?" || isOpen || isTypingTarget(event.target)) return;
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
(openIt ?? pressHelpButton)();
|
|
342
|
+
};
|
|
343
|
+
document.addEventListener("keydown", onKeyDown);
|
|
344
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
345
|
+
}, [disabled]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface HelpHotkeysProps extends HelpHotkeysOptions {}
|
|
349
|
+
|
|
350
|
+
/** `useHelpHotkeys` as a component, for a shell whose children are markup. */
|
|
351
|
+
export function HelpHotkeys(props: HelpHotkeysProps): null {
|
|
352
|
+
useHelpHotkeys(props);
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* `documentUrl` when it is safe to hang off an `href`, `undefined` otherwise.
|
|
358
|
+
*
|
|
359
|
+
* A relative URL is resolved against the current page first, so "/docs/x.md"
|
|
360
|
+
* — the ordinary case — passes on any http(s) origin and is not special-cased
|
|
361
|
+
* into a hole. `javascript:`, `data:`, `blob:` and `vbscript:` all fall out of
|
|
362
|
+
* the same allow-list rather than each needing to be thought of.
|
|
363
|
+
*/
|
|
364
|
+
export function httpDocumentUrl(documentUrl: string | undefined): string | undefined {
|
|
365
|
+
if (!documentUrl) return undefined;
|
|
366
|
+
try {
|
|
367
|
+
const base = typeof location === "undefined" ? undefined : location.href;
|
|
368
|
+
const { protocol } = base === undefined ? new URL(documentUrl) : new URL(documentUrl, base);
|
|
369
|
+
return protocol === "http:" || protocol === "https:" ? documentUrl : undefined;
|
|
370
|
+
} catch {
|
|
371
|
+
// Not a URL. No anchor at all beats an anchor that goes nowhere.
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/* ── the panel ───────────────────────────────────────────────────────────── */
|
|
377
|
+
|
|
378
|
+
export interface HelpPanelProps extends Omit<React.ComponentProps<"div">, "title" | "onSelect"> {
|
|
379
|
+
/** The functional document, as markdown. A function is loaded on first open. */
|
|
380
|
+
source: HelpSource;
|
|
381
|
+
/** From the shell's help slot. `false` renders nothing and loads nothing. */
|
|
382
|
+
open?: boolean;
|
|
383
|
+
/** Closes the panel: the header button and Escape. */
|
|
384
|
+
onClose?: () => void;
|
|
385
|
+
/** The current route. Opens the panel on the section that route is about. */
|
|
386
|
+
context?: string;
|
|
387
|
+
/** Header title. Defaults to the front matter's `title:`, then the first `#`. */
|
|
388
|
+
title?: string;
|
|
389
|
+
/**
|
|
390
|
+
* Where "open in new tab" goes — the document's own URL, so the tab can be
|
|
391
|
+
* shared. Without one the panel opens the text it already has.
|
|
392
|
+
*
|
|
393
|
+
* **http(s) only.** The value reaches an `href` the user clicks, and the
|
|
394
|
+
* panel's source is the extension's functional document, which an extension
|
|
395
|
+
* may well be fetching from somewhere configurable. A `javascript:` URL in
|
|
396
|
+
* that slot is script execution one click away, and `data:` opens a document
|
|
397
|
+
* of the author's choosing on a tab the user believes is the help page.
|
|
398
|
+
*/
|
|
399
|
+
documentUrl?: string;
|
|
400
|
+
/** Deepest heading level in the outline. 3 by default; 6 shows every heading. */
|
|
401
|
+
outlineDepth?: number;
|
|
402
|
+
/** Start with the search box filled. For the guide and for tests. */
|
|
403
|
+
defaultQuery?: string;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const headerButton = [
|
|
407
|
+
"inline-flex size-8 shrink-0 items-center justify-center rounded-[var(--ds-radius-md)]",
|
|
408
|
+
"text-[color:var(--ds-muted-foreground)] outline-none",
|
|
409
|
+
"transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
|
|
410
|
+
"hover:bg-[var(--ds-hover)] hover:text-[color:var(--ds-foreground)]",
|
|
411
|
+
"focus-visible:ring-2 focus-visible:ring-[var(--ds-ring)]",
|
|
412
|
+
"[&_svg]:size-4",
|
|
413
|
+
].join(" ");
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Documents already loaded, by the loader that produced them.
|
|
417
|
+
*
|
|
418
|
+
* `helpPanelSlot` returns `null` while closed, so closing the panel UNMOUNTS
|
|
419
|
+
* it and takes its state with it. Every reopen therefore re-ran the loader:
|
|
420
|
+
* on a `fetch` that is a network round trip per open, and on a slow one the
|
|
421
|
+
* panel shows a skeleton for a document the user read a minute ago. The cache
|
|
422
|
+
* is keyed by the loader itself and held weakly, so a caller that memoises its
|
|
423
|
+
* loader — the documented shape — gets the document back instantly, and one
|
|
424
|
+
* that builds a new closure each render is simply not cached rather than
|
|
425
|
+
* leaking.
|
|
426
|
+
*/
|
|
427
|
+
const loadedDocuments = /* @__PURE__ */ new WeakMap<object, string>();
|
|
428
|
+
|
|
429
|
+
function HelpPanel({
|
|
430
|
+
source,
|
|
431
|
+
open = true,
|
|
432
|
+
onClose,
|
|
433
|
+
context,
|
|
434
|
+
title,
|
|
435
|
+
documentUrl,
|
|
436
|
+
outlineDepth = 3,
|
|
437
|
+
defaultQuery = "",
|
|
438
|
+
className,
|
|
439
|
+
...props
|
|
440
|
+
}: HelpPanelProps) {
|
|
441
|
+
const [text, setText] = React.useState<string | undefined>(
|
|
442
|
+
typeof source === "string" ? source : loadedDocuments.get(source),
|
|
443
|
+
);
|
|
444
|
+
const [failed, setFailed] = React.useState(false);
|
|
445
|
+
const [query, setQuery] = React.useState(defaultQuery);
|
|
446
|
+
const [activeId, setActiveId] = React.useState<string>();
|
|
447
|
+
const bodyRef = React.useRef<HTMLDivElement>(null);
|
|
448
|
+
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
449
|
+
|
|
450
|
+
useHelpHotkeys({ open, onClose });
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Focus moves into the panel when it opens.
|
|
454
|
+
*
|
|
455
|
+
* The shell's help button stays where it is, so without this, focus is
|
|
456
|
+
* still on the button: Escape works (the handler is on the document) but
|
|
457
|
+
* Tab walks the page BEHIND the panel, and a screen reader announces
|
|
458
|
+
* nothing at all — the panel is simply not where the user is. The search
|
|
459
|
+
* box is the target, because searching is what the panel is for.
|
|
460
|
+
*/
|
|
461
|
+
React.useEffect(() => {
|
|
462
|
+
if (!open) return;
|
|
463
|
+
// After paint, so the element exists and Base UI's own focus work — the
|
|
464
|
+
// pop-up, a dialog the panel is rendered beside — has settled.
|
|
465
|
+
const frame = requestAnimationFrame(() => {
|
|
466
|
+
const root = panelRef.current;
|
|
467
|
+
if (!root || root.contains(document.activeElement)) return;
|
|
468
|
+
// `preventScroll`: focusing scrolls an element into view by default, and
|
|
469
|
+
// the panel has just opened on the section the current route is about.
|
|
470
|
+
// Scrolling it back to the top would undo exactly that.
|
|
471
|
+
root
|
|
472
|
+
.querySelector<HTMLElement>('[data-slot="help-panel-search-input"]')
|
|
473
|
+
?.focus({ preventScroll: true });
|
|
474
|
+
});
|
|
475
|
+
return () => cancelAnimationFrame(frame);
|
|
476
|
+
}, [open]);
|
|
477
|
+
|
|
478
|
+
React.useEffect(() => {
|
|
479
|
+
if (!open) return;
|
|
480
|
+
if (typeof source === "string") {
|
|
481
|
+
setText(source);
|
|
482
|
+
setFailed(false);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const cached = loadedDocuments.get(source);
|
|
486
|
+
if (cached !== undefined) {
|
|
487
|
+
setText(cached);
|
|
488
|
+
setFailed(false);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
let cancelled = false;
|
|
492
|
+
setFailed(false);
|
|
493
|
+
source().then(
|
|
494
|
+
(loaded) => {
|
|
495
|
+
loadedDocuments.set(source, loaded);
|
|
496
|
+
if (!cancelled) setText(loaded);
|
|
497
|
+
},
|
|
498
|
+
() => {
|
|
499
|
+
if (!cancelled) setFailed(true);
|
|
500
|
+
},
|
|
501
|
+
);
|
|
502
|
+
return () => {
|
|
503
|
+
cancelled = true;
|
|
504
|
+
};
|
|
505
|
+
}, [open, source]);
|
|
506
|
+
|
|
507
|
+
const doc = React.useMemo(() => parseHelpDocument(text ?? ""), [text]);
|
|
508
|
+
const needle = query.trim().toLowerCase();
|
|
509
|
+
const matches = React.useMemo(
|
|
510
|
+
() =>
|
|
511
|
+
needle
|
|
512
|
+
? doc.sections.filter((section) => section.markdown.toLowerCase().includes(needle))
|
|
513
|
+
: doc.sections,
|
|
514
|
+
[doc, needle],
|
|
515
|
+
);
|
|
516
|
+
const markdown = needle ? matches.map((section) => section.markdown).join("\n") : doc.body;
|
|
517
|
+
const outline = matches.filter((s) => s.depth >= 1 && s.depth <= outlineDepth);
|
|
518
|
+
|
|
519
|
+
// Where the last programmatic scroll left the panel, so a re-align after the
|
|
520
|
+
// document reflows can tell "nobody has scrolled since" from "the user has".
|
|
521
|
+
const scrolledTo = React.useRef<number | undefined>(undefined);
|
|
522
|
+
const scrollTo = React.useCallback((id: string, onlyIfUntouched = false) => {
|
|
523
|
+
const scroller = bodyRef.current;
|
|
524
|
+
const heading = scroller?.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
|
|
525
|
+
if (!scroller || !heading) return false;
|
|
526
|
+
if (onlyIfUntouched && scrolledTo.current !== scroller.scrollTop) return false;
|
|
527
|
+
// Not `scrollIntoView`: the panel is a scroller inside a fixed aside, and
|
|
528
|
+
// scrollIntoView would scroll the page behind it as well.
|
|
529
|
+
scroller.scrollTop += heading.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
530
|
+
scrolledTo.current = scroller.scrollTop;
|
|
531
|
+
setActiveId(id);
|
|
532
|
+
return true;
|
|
533
|
+
}, []);
|
|
534
|
+
|
|
535
|
+
// Open on the section the current route is about, once the document is here.
|
|
536
|
+
const target = React.useMemo(() => resolveHelpTarget(doc, context), [doc, context]);
|
|
537
|
+
React.useEffect(() => {
|
|
538
|
+
if (!open || !target || needle) return;
|
|
539
|
+
let cancelled = false;
|
|
540
|
+
scrollTo(target);
|
|
541
|
+
// Twice more, and only while the user has not scrolled since: the markdown
|
|
542
|
+
// mounts in the same commit, and the display face lands after that. A
|
|
543
|
+
// heading that grows 4px once the font arrives leaves the jump short of
|
|
544
|
+
// the section it was supposed to open on.
|
|
545
|
+
const again = () => {
|
|
546
|
+
if (!cancelled) scrollTo(target, scrolledTo.current !== undefined);
|
|
547
|
+
};
|
|
548
|
+
const frame = requestAnimationFrame(again);
|
|
549
|
+
void document.fonts?.ready.then(again);
|
|
550
|
+
return () => {
|
|
551
|
+
cancelled = true;
|
|
552
|
+
cancelAnimationFrame(frame);
|
|
553
|
+
};
|
|
554
|
+
}, [open, target, needle, scrollTo]);
|
|
555
|
+
|
|
556
|
+
// "Open in new tab" without a URL still has the text, so it opens that. A
|
|
557
|
+
// `documentUrl` this panel refused counts as not having one.
|
|
558
|
+
const safeDocumentUrl = httpDocumentUrl(documentUrl);
|
|
559
|
+
const [fallbackUrl, setFallbackUrl] = React.useState<string>();
|
|
560
|
+
React.useEffect(() => {
|
|
561
|
+
if (safeDocumentUrl || text === undefined) return;
|
|
562
|
+
const url = URL.createObjectURL(new Blob([text], { type: "text/markdown;charset=utf-8" }));
|
|
563
|
+
setFallbackUrl(url);
|
|
564
|
+
return () => URL.revokeObjectURL(url);
|
|
565
|
+
}, [safeDocumentUrl, text]);
|
|
566
|
+
// The blob: fallback is ours — made here from text already in the panel —
|
|
567
|
+
// so it is not put through the same check.
|
|
568
|
+
const href = safeDocumentUrl ?? fallbackUrl;
|
|
569
|
+
|
|
570
|
+
if (!open) return null;
|
|
571
|
+
|
|
572
|
+
const heading = title ?? doc.title ?? "Help";
|
|
573
|
+
const loading = text === undefined && !failed;
|
|
574
|
+
|
|
575
|
+
return (
|
|
576
|
+
<div
|
|
577
|
+
ref={panelRef}
|
|
578
|
+
data-slot="help-panel"
|
|
579
|
+
data-loading={loading || undefined}
|
|
580
|
+
className={cn(
|
|
581
|
+
"flex h-full min-h-0 w-full flex-col overflow-hidden",
|
|
582
|
+
"bg-[var(--ds-card)] text-[color:var(--ds-foreground)]",
|
|
583
|
+
className,
|
|
584
|
+
)}
|
|
585
|
+
{...props}
|
|
586
|
+
>
|
|
587
|
+
<header
|
|
588
|
+
data-slot="help-panel-header"
|
|
589
|
+
className="flex shrink-0 items-center gap-2 border-b border-[var(--ds-border-subtle)] px-4 py-3"
|
|
590
|
+
>
|
|
591
|
+
<h2
|
|
592
|
+
data-slot="help-panel-title"
|
|
593
|
+
className={cn(
|
|
594
|
+
"min-w-0 flex-1 truncate font-display font-bold",
|
|
595
|
+
"text-[length:var(--ds-text-body-sm)] leading-[var(--ds-text-body-sm--line-height)]",
|
|
596
|
+
"text-[color:var(--ds-foreground)]",
|
|
597
|
+
)}
|
|
598
|
+
>
|
|
599
|
+
{heading}
|
|
600
|
+
</h2>
|
|
601
|
+
{href ? (
|
|
602
|
+
<a
|
|
603
|
+
data-slot="help-panel-open-full"
|
|
604
|
+
href={href}
|
|
605
|
+
target="_blank"
|
|
606
|
+
rel="noreferrer"
|
|
607
|
+
aria-label="Open the full document in a new tab"
|
|
608
|
+
className={headerButton}
|
|
609
|
+
>
|
|
610
|
+
<ExternalLink aria-hidden />
|
|
611
|
+
</a>
|
|
612
|
+
) : null}
|
|
613
|
+
{onClose ? (
|
|
614
|
+
<button
|
|
615
|
+
type="button"
|
|
616
|
+
data-slot="help-panel-close"
|
|
617
|
+
aria-label="Close help"
|
|
618
|
+
onClick={onClose}
|
|
619
|
+
className={headerButton}
|
|
620
|
+
>
|
|
621
|
+
<X aria-hidden />
|
|
622
|
+
</button>
|
|
623
|
+
) : null}
|
|
624
|
+
</header>
|
|
625
|
+
|
|
626
|
+
<div
|
|
627
|
+
data-slot="help-panel-search"
|
|
628
|
+
className="shrink-0 border-b border-[var(--ds-border-subtle)] px-4 py-3"
|
|
629
|
+
>
|
|
630
|
+
<div className="relative">
|
|
631
|
+
<Search
|
|
632
|
+
aria-hidden
|
|
633
|
+
className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-[color:var(--ds-text-tertiary)]"
|
|
634
|
+
/>
|
|
635
|
+
<input
|
|
636
|
+
type="search"
|
|
637
|
+
data-slot="help-panel-search-input"
|
|
638
|
+
aria-label={`Search ${heading}`}
|
|
639
|
+
placeholder="Search this document"
|
|
640
|
+
value={query}
|
|
641
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
642
|
+
className={cn(
|
|
643
|
+
"h-9 w-full rounded-[var(--ds-radius-md)] border border-[var(--ds-border)]",
|
|
644
|
+
"bg-[var(--ds-muted)] py-2 pr-3 pl-8",
|
|
645
|
+
"text-[length:var(--ds-text-caption-lg)] leading-[var(--ds-text-caption-lg--line-height)]",
|
|
646
|
+
"text-[color:var(--ds-foreground)] placeholder:text-[color:var(--ds-text-tertiary)]",
|
|
647
|
+
"transition-[border-color,box-shadow] duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
|
|
648
|
+
"outline-none hover:border-[var(--ds-border-strong)]",
|
|
649
|
+
"focus-visible:border-[var(--ds-ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ds-ring)]/40",
|
|
650
|
+
"[&::-webkit-search-cancel-button]:hidden",
|
|
651
|
+
)}
|
|
652
|
+
/>
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
{outline.length > 0 ? (
|
|
657
|
+
<nav
|
|
658
|
+
data-slot="help-panel-outline"
|
|
659
|
+
aria-label={`${heading} sections`}
|
|
660
|
+
className="max-h-40 shrink-0 overflow-y-auto border-b border-[var(--ds-border-subtle)] px-2 py-2"
|
|
661
|
+
>
|
|
662
|
+
<ol className="flex flex-col">
|
|
663
|
+
{outline.map((section) => (
|
|
664
|
+
<li key={section.id}>
|
|
665
|
+
<a
|
|
666
|
+
data-slot="help-panel-outline-item"
|
|
667
|
+
data-depth={section.depth}
|
|
668
|
+
data-active={section.id === activeId || undefined}
|
|
669
|
+
href={`#${section.id}`}
|
|
670
|
+
onClick={(event) => {
|
|
671
|
+
// The anchor is a real deep link — copyable, and it works
|
|
672
|
+
// in a new tab — but inside an SPA the panel scrolls
|
|
673
|
+
// itself rather than handing the route a hash.
|
|
674
|
+
event.preventDefault();
|
|
675
|
+
scrollTo(section.id);
|
|
676
|
+
}}
|
|
677
|
+
className={cn(
|
|
678
|
+
"block truncate rounded-[var(--ds-radius-sm)] px-2 py-1 no-underline",
|
|
679
|
+
"text-[length:var(--ds-text-caption)] leading-[var(--ds-text-caption--line-height)]",
|
|
680
|
+
"text-[color:var(--ds-muted-foreground)] outline-none",
|
|
681
|
+
"transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
|
|
682
|
+
"hover:bg-[var(--ds-hover)] hover:text-[color:var(--ds-foreground)]",
|
|
683
|
+
"focus-visible:ring-2 focus-visible:ring-[var(--ds-ring)]",
|
|
684
|
+
"data-[active]:bg-[var(--ds-primary-soft)] data-[active]:text-[color:var(--ds-primary-soft-foreground)]",
|
|
685
|
+
section.depth === 2 && "pl-5",
|
|
686
|
+
section.depth >= 3 && "pl-8",
|
|
687
|
+
)}
|
|
688
|
+
>
|
|
689
|
+
{section.title}
|
|
690
|
+
</a>
|
|
691
|
+
</li>
|
|
692
|
+
))}
|
|
693
|
+
</ol>
|
|
694
|
+
</nav>
|
|
695
|
+
) : null}
|
|
696
|
+
|
|
697
|
+
<div
|
|
698
|
+
ref={bodyRef}
|
|
699
|
+
data-slot="help-panel-body"
|
|
700
|
+
className="min-h-0 flex-1 overflow-y-auto px-4 py-3"
|
|
701
|
+
>
|
|
702
|
+
{loading ? (
|
|
703
|
+
<div data-slot="help-panel-loading" className="flex items-center gap-2 py-6">
|
|
704
|
+
<Spinner />
|
|
705
|
+
<span className="text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]">
|
|
706
|
+
Loading help…
|
|
707
|
+
</span>
|
|
708
|
+
</div>
|
|
709
|
+
) : failed ? (
|
|
710
|
+
<p
|
|
711
|
+
data-slot="help-panel-error"
|
|
712
|
+
className="py-6 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-destructive)]"
|
|
713
|
+
>
|
|
714
|
+
The help document could not be loaded.
|
|
715
|
+
</p>
|
|
716
|
+
) : needle && matches.length === 0 ? (
|
|
717
|
+
<p
|
|
718
|
+
data-slot="help-panel-empty"
|
|
719
|
+
className="py-6 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]"
|
|
720
|
+
>
|
|
721
|
+
No section matches “{query.trim()}”.
|
|
722
|
+
</p>
|
|
723
|
+
) : (
|
|
724
|
+
<Markdown
|
|
725
|
+
compact
|
|
726
|
+
sanitizeSchema={markdownSanitizeSchema}
|
|
727
|
+
rehypePlugins={[rehypeHelpAnchors]}
|
|
728
|
+
>
|
|
729
|
+
{markdown}
|
|
730
|
+
</Markdown>
|
|
731
|
+
)}
|
|
732
|
+
</div>
|
|
733
|
+
</div>
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/* ── the shell slot ──────────────────────────────────────────────────────── */
|
|
738
|
+
|
|
739
|
+
export interface HelpPanelSlotConfig extends Omit<HelpPanelProps, "open" | "onClose"> {}
|
|
740
|
+
|
|
741
|
+
/** What `AppShell` hands its `helpPanel` render function. */
|
|
742
|
+
interface ShellHelpSlot {
|
|
743
|
+
open: boolean;
|
|
744
|
+
source?: string;
|
|
745
|
+
onClose: () => void;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* `AppShell`'s `helpPanel` slot, filled.
|
|
750
|
+
*
|
|
751
|
+
* The shell renders whatever the function returns, open or closed, so this
|
|
752
|
+
* returns `null` while closed — a panel that rendered an empty box would draw
|
|
753
|
+
* the shell's bordered aside over the corner with nothing in it. `help.source`
|
|
754
|
+
* from the shell is used when no `source` is passed here.
|
|
755
|
+
*/
|
|
756
|
+
export function helpPanelSlot(
|
|
757
|
+
config: HelpPanelSlotConfig,
|
|
758
|
+
): (slot: ShellHelpSlot) => React.ReactNode {
|
|
759
|
+
return ({ open, source, onClose }) =>
|
|
760
|
+
open ? (
|
|
761
|
+
<HelpPanel {...config} source={config.source ?? source ?? ""} open onClose={onClose} />
|
|
762
|
+
) : null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export { HelpPanel };
|