@waveso/docs 0.1.0 → 0.3.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/CHANGELOG.md +194 -0
- package/README.md +592 -88
- package/dist/code-frame.d.ts +29 -0
- package/dist/code-frame.js +41 -0
- package/dist/code-meta.d.ts +48 -0
- package/dist/code-meta.js +72 -0
- package/dist/docs-content-id.d.ts +19 -0
- package/dist/docs-content-id.js +19 -0
- package/dist/docs-error.d.ts +19 -0
- package/dist/docs-error.js +28 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +45 -0
- package/dist/frontmatter.d.ts +39 -7
- package/dist/frontmatter.js +51 -24
- package/dist/highlighter.d.ts +2 -2
- package/dist/highlighter.js +3 -2
- package/dist/map-pooled.d.ts +26 -0
- package/dist/map-pooled.js +45 -0
- package/dist/meta.d.ts +7 -3
- package/dist/meta.js +61 -15
- package/dist/next.d.ts +182 -35
- package/dist/next.js +177 -49
- package/dist/plugins/rehype-capture-toc.js +52 -20
- package/dist/plugins/rehype-code-frame.d.ts +10 -0
- package/dist/plugins/rehype-code-frame.js +88 -0
- package/dist/plugins/rehype-code-language.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +54 -0
- package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
- package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
- package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
- package/dist/plugins/rehype-flatten-roots.js +39 -0
- package/dist/plugins/remark-doc-links.d.ts +12 -1
- package/dist/plugins/remark-doc-links.js +147 -20
- package/dist/react/code-runtime.d.ts +14 -0
- package/dist/react/code-runtime.js +161 -0
- package/dist/react/doc-content.d.ts +39 -2
- package/dist/react/doc-content.js +42 -10
- package/dist/react/layout.d.ts +44 -0
- package/dist/react/layout.js +65 -0
- package/dist/react/markdown-components.js +71 -6
- package/dist/react/nav.d.ts +28 -0
- package/dist/react/nav.js +70 -0
- package/dist/react/nearest-scroll-top.d.ts +45 -0
- package/dist/react/nearest-scroll-top.js +44 -0
- package/dist/react/next-link.d.ts +34 -0
- package/dist/react/next-link.js +30 -0
- package/dist/react/next-nav.d.ts +11 -0
- package/dist/react/next-nav.js +32 -0
- package/dist/react/next-search.d.ts +22 -0
- package/dist/react/next-search.js +52 -0
- package/dist/react/search-dialog.d.ts +35 -7
- package/dist/react/search-dialog.js +55 -33
- package/dist/react/shell-labels.d.ts +43 -0
- package/dist/react/shell-labels.js +27 -0
- package/dist/react/sidebar.d.ts +38 -3
- package/dist/react/sidebar.js +104 -12
- package/dist/react/skip-link.d.ts +1 -9
- package/dist/react/skip-link.js +6 -5
- package/dist/react/toc.d.ts +12 -4
- package/dist/react/toc.js +46 -12
- package/dist/react/youtube.d.ts +31 -5
- package/dist/react/youtube.js +76 -52
- package/dist/render.d.ts +78 -10
- package/dist/render.js +137 -54
- package/dist/route-path.d.ts +46 -0
- package/dist/route-path.js +51 -0
- package/dist/search-index.d.ts +22 -21
- package/dist/search-index.js +27 -78
- package/dist/search-options.d.ts +32 -1
- package/dist/search-options.js +66 -3
- package/dist/section-boundary.d.ts +17 -0
- package/dist/section-boundary.js +43 -0
- package/dist/sitemap-limit.d.ts +34 -0
- package/dist/sitemap-limit.js +37 -0
- package/dist/source.d.ts +12 -22
- package/dist/source.js +165 -72
- package/dist/styles.css +1117 -125
- package/dist/types.d.ts +52 -29
- package/package.json +70 -34
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
2
|
+
import { docsError } from "../docs-error.js";
|
|
3
|
+
import { mergeSearchOptions } from "../search-options.js";
|
|
3
4
|
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
4
|
-
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
6
|
import { createPortal } from "react-dom";
|
|
6
7
|
//#region src/react/search-dialog.tsx
|
|
7
8
|
/**
|
|
@@ -25,7 +26,7 @@ const FOCUSABLE_SELECTOR = [
|
|
|
25
26
|
* portalled to `document.body`, so a navbar's stacking context cannot trap
|
|
26
27
|
* it behind the page.
|
|
27
28
|
*/
|
|
28
|
-
function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", maxResults = 8, debounceMs = 120 }) {
|
|
29
|
+
function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", maxResults = 8, debounceMs = 120, className, miniSearchOptions }) {
|
|
29
30
|
const [isOpen, setIsOpen] = useState(false);
|
|
30
31
|
const [query, setQuery] = useState("");
|
|
31
32
|
const [hits, setHits] = useState([]);
|
|
@@ -37,29 +38,34 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
37
38
|
const inputRef = useRef(null);
|
|
38
39
|
const listRef = useRef(null);
|
|
39
40
|
const returnFocusRef = useRef(null);
|
|
40
|
-
const
|
|
41
|
+
const indexCacheRef = useRef(/* @__PURE__ */ new Map());
|
|
42
|
+
const miniSearchOptionsRef = useRef(miniSearchOptions);
|
|
41
43
|
/** Whether the dialog has ever been open. See the focus effect below. */
|
|
42
44
|
const hasOpenedRef = useRef(false);
|
|
43
45
|
const baseId = useId();
|
|
44
46
|
const listId = `${baseId}-results`;
|
|
45
47
|
const optionId = (index) => `${baseId}-option-${index}`;
|
|
46
|
-
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
miniSearchOptionsRef.current = miniSearchOptions;
|
|
50
|
+
}, [miniSearchOptions]);
|
|
51
|
+
/** Load each URL at most once; a failure evicts that key so a retry can. */
|
|
47
52
|
const ensureIndex = useCallback(() => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
const cache = indexCacheRef.current;
|
|
54
|
+
let pending = cache.get(indexUrl);
|
|
55
|
+
if (pending === void 0) {
|
|
56
|
+
pending = loadIndex(indexUrl, miniSearchOptionsRef.current).catch((error) => {
|
|
57
|
+
cache.delete(indexUrl);
|
|
52
58
|
throw error;
|
|
53
59
|
});
|
|
54
|
-
|
|
60
|
+
cache.set(indexUrl, pending);
|
|
55
61
|
}
|
|
56
62
|
return pending;
|
|
57
63
|
}, [indexUrl]);
|
|
58
64
|
const warmIndex = useCallback(() => {
|
|
59
|
-
if (
|
|
65
|
+
if (indexCacheRef.current.has(indexUrl)) return;
|
|
60
66
|
setStatus("loading");
|
|
61
67
|
ensureIndex().then(() => setStatus("ready"), () => setStatus("error"));
|
|
62
|
-
}, [ensureIndex]);
|
|
68
|
+
}, [ensureIndex, indexUrl]);
|
|
63
69
|
const openDialog = useCallback(() => {
|
|
64
70
|
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
65
71
|
setIsOpen(true);
|
|
@@ -83,6 +89,20 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
83
89
|
openDialog,
|
|
84
90
|
closeDialog
|
|
85
91
|
]);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!isOpen) return;
|
|
94
|
+
function handleKeyDown(event) {
|
|
95
|
+
if (event.key === "Escape") {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
event.stopPropagation();
|
|
98
|
+
closeDialog();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (event.key === "Tab") trapFocus(dialogRef.current, event);
|
|
102
|
+
}
|
|
103
|
+
document.addEventListener("keydown", handleKeyDown, true);
|
|
104
|
+
return () => document.removeEventListener("keydown", handleKeyDown, true);
|
|
105
|
+
}, [isOpen, closeDialog]);
|
|
86
106
|
useEffect(() => {
|
|
87
107
|
const isApple = /mac|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
88
108
|
setShortcutHint(isApple ? "⌘K" : "Ctrl K");
|
|
@@ -153,17 +173,8 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
153
173
|
closeDialog();
|
|
154
174
|
navigate(hit.href);
|
|
155
175
|
}, [closeDialog, navigate]);
|
|
156
|
-
function
|
|
157
|
-
if (event.
|
|
158
|
-
event.preventDefault();
|
|
159
|
-
event.stopPropagation();
|
|
160
|
-
closeDialog();
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
if (event.key === "Tab") {
|
|
164
|
-
trapFocus(dialogRef.current, event);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
176
|
+
function handleInputKeyDown(event) {
|
|
177
|
+
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
167
178
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
168
179
|
if (hits.length === 0) return;
|
|
169
180
|
event.preventDefault();
|
|
@@ -179,10 +190,10 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
179
190
|
}
|
|
180
191
|
}
|
|
181
192
|
const activeHit = hits[activeIndex];
|
|
182
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
|
|
193
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
|
|
183
194
|
type: "button",
|
|
184
195
|
ref: triggerRef,
|
|
185
|
-
className: "wave-docs-search-trigger",
|
|
196
|
+
className: ["wave-docs-search-trigger", className].filter(Boolean).join(" "),
|
|
186
197
|
"aria-label": triggerLabel,
|
|
187
198
|
"aria-keyshortcuts": "Meta+K Control+K",
|
|
188
199
|
onClick: openDialog,
|
|
@@ -206,7 +217,6 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
206
217
|
role: "dialog",
|
|
207
218
|
"aria-modal": "true",
|
|
208
219
|
"aria-label": dialogLabel,
|
|
209
|
-
onKeyDown: handleDialogKeyDown,
|
|
210
220
|
children: [
|
|
211
221
|
/* @__PURE__ */ jsxs("div", {
|
|
212
222
|
className: "wave-docs-search-input-row",
|
|
@@ -223,6 +233,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
223
233
|
placeholder,
|
|
224
234
|
value: query,
|
|
225
235
|
onChange: (event) => setQuery(event.target.value),
|
|
236
|
+
onKeyDown: handleInputKeyDown,
|
|
226
237
|
autoComplete: "off",
|
|
227
238
|
autoCorrect: "off",
|
|
228
239
|
spellCheck: false
|
|
@@ -265,7 +276,7 @@ function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
|
|
|
265
276
|
onSelect(hit);
|
|
266
277
|
}
|
|
267
278
|
const trail = toBreadcrumbs(hit);
|
|
268
|
-
const body = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
279
|
+
const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
|
|
269
280
|
className: "wave-docs-search-result-heading",
|
|
270
281
|
children: hit.heading
|
|
271
282
|
}), trail.length === 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
@@ -319,7 +330,7 @@ function SearchStatus({ status, query, hitCount }) {
|
|
|
319
330
|
message = `No results for “${query}”.`;
|
|
320
331
|
modifier = " wave-docs-search-status-empty";
|
|
321
332
|
}
|
|
322
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
|
|
333
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
|
|
323
334
|
className: `wave-docs-search-status${modifier}`,
|
|
324
335
|
children: message
|
|
325
336
|
}), /* @__PURE__ */ jsx("p", {
|
|
@@ -335,10 +346,15 @@ function SearchStatus({ status, query, hitCount }) {
|
|
|
335
346
|
* `loadJSONAsync` yields between chunks so deserialising a large index does
|
|
336
347
|
* not freeze the frame the dialog just opened in.
|
|
337
348
|
*/
|
|
338
|
-
async function loadIndex(url) {
|
|
349
|
+
async function loadIndex(url, overrides) {
|
|
339
350
|
const [{ default: MiniSearchClass }, response] = await Promise.all([import("minisearch"), fetch(url)]);
|
|
340
|
-
if (!response.ok) throw
|
|
341
|
-
|
|
351
|
+
if (!response.ok) throw docsError("search-index-unavailable", response.status === 404 ? `No search index at ${url}. Create the route that serves it:\n\n // app${url}/route.ts — the whole file\n import { docs } from '@/lib/docs';
|
|
352
|
+
|
|
353
|
+
export const GET = docs.searchIndex;
|
|
354
|
+
export const dynamic = 'force-static';
|
|
355
|
+
|
|
356
|
+
Or pass \`search={false}\` to \`docs.Layout\` to hide the trigger.` : `Failed to load the search index from ${url} (HTTP ${response.status}).`);
|
|
357
|
+
return MiniSearchClass.loadJSONAsync(await response.text(), mergeSearchOptions(overrides));
|
|
342
358
|
}
|
|
343
359
|
/**
|
|
344
360
|
* Narrow one MiniSearch result. Its stored fields are untyped by design, and
|
|
@@ -392,10 +408,16 @@ function trapFocus(root, event) {
|
|
|
392
408
|
event.preventDefault();
|
|
393
409
|
return;
|
|
394
410
|
}
|
|
395
|
-
|
|
411
|
+
const active = document.activeElement;
|
|
412
|
+
if (!(active instanceof HTMLElement) || !root.contains(active)) {
|
|
413
|
+
event.preventDefault();
|
|
414
|
+
(event.shiftKey ? last : first).focus();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (event.shiftKey && active === first) {
|
|
396
418
|
event.preventDefault();
|
|
397
419
|
last.focus();
|
|
398
|
-
} else if (!event.shiftKey &&
|
|
420
|
+
} else if (!event.shiftKey && active === last) {
|
|
399
421
|
event.preventDefault();
|
|
400
422
|
first.focus();
|
|
401
423
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/react/shell-labels.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Every user-visible string the shell renders that is not the reader's content.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ THERE ARE ONLY FIVE, AND THAT IS THE POINT. `docs.Layout` renders the
|
|
6
|
+
* whole chrome of a documentation site, and until this existed all five were
|
|
7
|
+
* hardcoded English with no way to reach them: `DocsNav` declared `label` and
|
|
8
|
+
* `closeLabel` props, documented them, defaulted them — and the layout that is
|
|
9
|
+
* the only thing rendering `DocsNav` never passed either, while `DocsLayoutProps`
|
|
10
|
+
* had no way to say them. Dead options that read as configuration.
|
|
11
|
+
*
|
|
12
|
+
* Private module, public type: `next.ts` re-exports {@link DocsLabels} as part
|
|
13
|
+
* of `DocsLayoutProps`, and this file exists so the Node adapter can name the
|
|
14
|
+
* type without importing a `'use client'` module for it.
|
|
15
|
+
*
|
|
16
|
+
* Not here: the search dialog's strings, which are reachable through
|
|
17
|
+
* `search={{ … }}`; and the sidebar's own `label`, which is public API on
|
|
18
|
+
* `DocsSidebar` for anyone composing a shell by hand. This is the set that had
|
|
19
|
+
* no route at all.
|
|
20
|
+
*/
|
|
21
|
+
interface DocsLabels {
|
|
22
|
+
/** The navigation landmark's accessible name. Default `'Documentation'`. */
|
|
23
|
+
nav?: string | undefined;
|
|
24
|
+
/** The header button that opens the drawer. Default `'Open navigation'`. */
|
|
25
|
+
openNav?: string | undefined;
|
|
26
|
+
/** The button that closes the drawer. Default `'Close navigation'`. */
|
|
27
|
+
closeNav?: string | undefined;
|
|
28
|
+
/** The skip link's visible text. Default `'Skip to content'`. */
|
|
29
|
+
skipToContent?: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The defaults, in one place.
|
|
33
|
+
*
|
|
34
|
+
* A `Required<DocsLabels>` rather than four `=` defaults spread across three
|
|
35
|
+
* components: the previous arrangement is how `DocsSidebar` came to default its
|
|
36
|
+
* landmark to `'Docs'` while `DocsNav` defaulted the same landmark to
|
|
37
|
+
* `'Documentation'` — two names for one region, depending on the viewport.
|
|
38
|
+
*/
|
|
39
|
+
declare const DEFAULT_DOCS_LABELS: Required<DocsLabels>;
|
|
40
|
+
/** The given labels over the defaults, with `undefined` treated as unset. */
|
|
41
|
+
declare function resolveLabels(labels: DocsLabels | undefined): Required<DocsLabels>;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { DEFAULT_DOCS_LABELS, DocsLabels, resolveLabels };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/react/shell-labels.ts
|
|
2
|
+
/**
|
|
3
|
+
* The defaults, in one place.
|
|
4
|
+
*
|
|
5
|
+
* A `Required<DocsLabels>` rather than four `=` defaults spread across three
|
|
6
|
+
* components: the previous arrangement is how `DocsSidebar` came to default its
|
|
7
|
+
* landmark to `'Docs'` while `DocsNav` defaulted the same landmark to
|
|
8
|
+
* `'Documentation'` — two names for one region, depending on the viewport.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_DOCS_LABELS = {
|
|
11
|
+
nav: "Documentation",
|
|
12
|
+
openNav: "Open navigation",
|
|
13
|
+
closeNav: "Close navigation",
|
|
14
|
+
skipToContent: "Skip to content"
|
|
15
|
+
};
|
|
16
|
+
/** The given labels over the defaults, with `undefined` treated as unset. */
|
|
17
|
+
function resolveLabels(labels) {
|
|
18
|
+
if (labels === void 0) return DEFAULT_DOCS_LABELS;
|
|
19
|
+
return {
|
|
20
|
+
nav: labels.nav ?? DEFAULT_DOCS_LABELS.nav,
|
|
21
|
+
openNav: labels.openNav ?? DEFAULT_DOCS_LABELS.openNav,
|
|
22
|
+
closeNav: labels.closeNav ?? DEFAULT_DOCS_LABELS.closeNav,
|
|
23
|
+
skipToContent: labels.skipToContent ?? DEFAULT_DOCS_LABELS.skipToContent
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { DEFAULT_DOCS_LABELS, resolveLabels };
|
package/dist/react/sidebar.d.ts
CHANGED
|
@@ -20,9 +20,44 @@ interface DocsSidebarProps {
|
|
|
20
20
|
/**
|
|
21
21
|
* The docs navigation tree.
|
|
22
22
|
*
|
|
23
|
-
* Prefetch
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* ## Prefetch
|
|
24
|
+
*
|
|
25
|
+
* Nearby links prefetch; the rest do not. Nearby means the list that directly
|
|
26
|
+
* contains the current page, plus the heading link of the group the reader is
|
|
27
|
+
* inside — 5 to 15 warm links on a real sidebar rather than 400 or none.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ THE PREVIOUS NOTE HERE WAS WRONG, AND THE RETRACTION IS THE POINT. It
|
|
30
|
+
* said prefetch was off because a full-tree sidebar otherwise asks Next to
|
|
31
|
+
* prefetch every route in it, ~1.8 KB brotli each. That reasoning is correct
|
|
32
|
+
* for the Pages Router and wrong for the App Router:
|
|
33
|
+
* `next/dist/client/app-dir/link.js` computes
|
|
34
|
+
* `const prefetchEnabled = prefetchProp !== false`, and BOTH the hover path
|
|
35
|
+
* and the touch path bail on it, while the IntersectionObserver is only
|
|
36
|
+
* registered when it is true. So `prefetch={false}` did not trade viewport
|
|
37
|
+
* prefetching for hover prefetching — it turned off both, and made every
|
|
38
|
+
* navigation from the most-clicked control in a docs site a cold RSC
|
|
39
|
+
* round-trip.
|
|
40
|
+
*
|
|
41
|
+
* ## The keyboard model, and why there is no `role="tree"`
|
|
42
|
+
*
|
|
43
|
+
* This is the **APG Disclosure Navigation** pattern: a list of links, with a
|
|
44
|
+
* button per collapsible group. Every link is an ordinary tab stop, Enter
|
|
45
|
+
* follows it, and the browser does all of it. There is no `tabindex`
|
|
46
|
+
* anywhere in here and no roving focus, deliberately.
|
|
47
|
+
*
|
|
48
|
+
* `role="tree"` is the tempting alternative and it is refused. It removes
|
|
49
|
+
* every link from the tab order in favour of a single roving tabstop, so a
|
|
50
|
+
* reader who tabs into the navigation can no longer tab through it; and it
|
|
51
|
+
* makes a screen reader announce "tree item, level 3" for what is, in every
|
|
52
|
+
* way that matters to the person hearing it, a link to a page. A docs sidebar
|
|
53
|
+
* is not a file explorer. `sidebar.test.tsx` asserts the absence of both, so
|
|
54
|
+
* the decision survives someone reaching for the aria pattern that sounds
|
|
55
|
+
* closest to "collapsible tree".
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ AND IT IS UNOBSERVABLE IN `next dev`. The same file guards the hover path
|
|
58
|
+
* with `if (!prefetchEnabled || process.env.NODE_ENV === 'development')`, so
|
|
59
|
+
* nothing prefetches locally whatever this says. Do not "fix" it back because
|
|
60
|
+
* the network tab looks the same.
|
|
26
61
|
*/
|
|
27
62
|
declare function DocsSidebar({ nav, pathname, Link, label, className }: DocsSidebarProps): ReactNode;
|
|
28
63
|
//#endregion
|
package/dist/react/sidebar.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { nearestScrollTop } from "./nearest-scroll-top.js";
|
|
3
|
+
import { useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
4
5
|
//#region src/react/sidebar.tsx
|
|
5
6
|
/** Trailing slashes are a routing detail, not a difference in identity. */
|
|
6
7
|
function normalizeHref(href) {
|
|
@@ -21,9 +22,44 @@ function containsActive(node, pathname) {
|
|
|
21
22
|
/**
|
|
22
23
|
* The docs navigation tree.
|
|
23
24
|
*
|
|
24
|
-
* Prefetch
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* ## Prefetch
|
|
26
|
+
*
|
|
27
|
+
* Nearby links prefetch; the rest do not. Nearby means the list that directly
|
|
28
|
+
* contains the current page, plus the heading link of the group the reader is
|
|
29
|
+
* inside — 5 to 15 warm links on a real sidebar rather than 400 or none.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ THE PREVIOUS NOTE HERE WAS WRONG, AND THE RETRACTION IS THE POINT. It
|
|
32
|
+
* said prefetch was off because a full-tree sidebar otherwise asks Next to
|
|
33
|
+
* prefetch every route in it, ~1.8 KB brotli each. That reasoning is correct
|
|
34
|
+
* for the Pages Router and wrong for the App Router:
|
|
35
|
+
* `next/dist/client/app-dir/link.js` computes
|
|
36
|
+
* `const prefetchEnabled = prefetchProp !== false`, and BOTH the hover path
|
|
37
|
+
* and the touch path bail on it, while the IntersectionObserver is only
|
|
38
|
+
* registered when it is true. So `prefetch={false}` did not trade viewport
|
|
39
|
+
* prefetching for hover prefetching — it turned off both, and made every
|
|
40
|
+
* navigation from the most-clicked control in a docs site a cold RSC
|
|
41
|
+
* round-trip.
|
|
42
|
+
*
|
|
43
|
+
* ## The keyboard model, and why there is no `role="tree"`
|
|
44
|
+
*
|
|
45
|
+
* This is the **APG Disclosure Navigation** pattern: a list of links, with a
|
|
46
|
+
* button per collapsible group. Every link is an ordinary tab stop, Enter
|
|
47
|
+
* follows it, and the browser does all of it. There is no `tabindex`
|
|
48
|
+
* anywhere in here and no roving focus, deliberately.
|
|
49
|
+
*
|
|
50
|
+
* `role="tree"` is the tempting alternative and it is refused. It removes
|
|
51
|
+
* every link from the tab order in favour of a single roving tabstop, so a
|
|
52
|
+
* reader who tabs into the navigation can no longer tab through it; and it
|
|
53
|
+
* makes a screen reader announce "tree item, level 3" for what is, in every
|
|
54
|
+
* way that matters to the person hearing it, a link to a page. A docs sidebar
|
|
55
|
+
* is not a file explorer. `sidebar.test.tsx` asserts the absence of both, so
|
|
56
|
+
* the decision survives someone reaching for the aria pattern that sounds
|
|
57
|
+
* closest to "collapsible tree".
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ AND IT IS UNOBSERVABLE IN `next dev`. The same file guards the hover path
|
|
60
|
+
* with `if (!prefetchEnabled || process.env.NODE_ENV === 'development')`, so
|
|
61
|
+
* nothing prefetches locally whatever this says. Do not "fix" it back because
|
|
62
|
+
* the network tab looks the same.
|
|
27
63
|
*/
|
|
28
64
|
function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
29
65
|
const baseId = useId();
|
|
@@ -39,7 +75,23 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
|
39
75
|
[key]: isOpen
|
|
40
76
|
}));
|
|
41
77
|
};
|
|
78
|
+
const navRef = useRef(null);
|
|
79
|
+
useLayoutEffect(() => {
|
|
80
|
+
const active = navRef.current?.querySelector("[aria-current=\"page\"]");
|
|
81
|
+
if (!(active instanceof HTMLElement)) return;
|
|
82
|
+
const port = scrollableAncestor(active);
|
|
83
|
+
if (port === null) return;
|
|
84
|
+
const next = nearestScrollTop({
|
|
85
|
+
itemTop: active.getBoundingClientRect().top - port.getBoundingClientRect().top + port.scrollTop,
|
|
86
|
+
itemHeight: active.offsetHeight,
|
|
87
|
+
viewHeight: port.clientHeight,
|
|
88
|
+
scrollTop: port.scrollTop,
|
|
89
|
+
scrollHeight: port.scrollHeight
|
|
90
|
+
});
|
|
91
|
+
if (next !== void 0) port.scrollTop = next;
|
|
92
|
+
}, [pathname]);
|
|
42
93
|
return /* @__PURE__ */ jsx("nav", {
|
|
94
|
+
ref: navRef,
|
|
43
95
|
"aria-label": label,
|
|
44
96
|
className: ["wave-docs-sidebar", className].filter(Boolean).join(" "),
|
|
45
97
|
children: /* @__PURE__ */ jsx(NavList, {
|
|
@@ -53,7 +105,27 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
|
53
105
|
})
|
|
54
106
|
});
|
|
55
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The nearest ancestor that actually scrolls, or `null`.
|
|
110
|
+
*
|
|
111
|
+
* ⚠️ THIS EXISTS SO `scrollIntoView` DOES NOT HAVE TO. `scrollIntoView({ block:
|
|
112
|
+
* 'nearest' })` reads as exactly the right call and scrolls **every**
|
|
113
|
+
* scrollable ancestor including the document — so on a docs page it brings the
|
|
114
|
+
* sidebar item into view and jumps the article the reader came to read, on the
|
|
115
|
+
* one navigation where they know precisely what they asked for.
|
|
116
|
+
* `sidebar.test.tsx` spies on it and asserts it is never called.
|
|
117
|
+
*/
|
|
118
|
+
function scrollableAncestor(element) {
|
|
119
|
+
let current = element.parentElement;
|
|
120
|
+
while (current !== null) {
|
|
121
|
+
const overflow = getComputedStyle(current).overflowY;
|
|
122
|
+
if ((overflow === "auto" || overflow === "scroll") && current.scrollHeight > current.clientHeight) return current;
|
|
123
|
+
current = current.parentElement;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
56
127
|
function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, id }) {
|
|
128
|
+
const holdsActive = nodes.some((node) => (node.type === "page" || node.type === "link" && !node.external) && isActiveHref(pathname, node.href));
|
|
57
129
|
return /* @__PURE__ */ jsx("ul", {
|
|
58
130
|
id,
|
|
59
131
|
className: "wave-docs-sidebar__list",
|
|
@@ -74,6 +146,7 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
|
|
|
74
146
|
href: node.href,
|
|
75
147
|
isExternal: node.external,
|
|
76
148
|
isActive: !node.external && isActiveHref(pathname, node.href),
|
|
149
|
+
isNearby: holdsActive,
|
|
77
150
|
Link,
|
|
78
151
|
children: node.title
|
|
79
152
|
})
|
|
@@ -84,6 +157,7 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
|
|
|
84
157
|
href: node.href,
|
|
85
158
|
isExternal: false,
|
|
86
159
|
isActive: isActiveHref(pathname, node.href),
|
|
160
|
+
isNearby: holdsActive,
|
|
87
161
|
Link,
|
|
88
162
|
children: node.title
|
|
89
163
|
})
|
|
@@ -122,10 +196,11 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
|
|
|
122
196
|
className: "wave-docs-sidebar__group-title",
|
|
123
197
|
children: node.title
|
|
124
198
|
}), /* @__PURE__ */ jsx(Chevron, { isOpen })]
|
|
125
|
-
}) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(NavLink, {
|
|
199
|
+
}) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(NavLink, {
|
|
126
200
|
href: node.href,
|
|
127
201
|
isExternal: false,
|
|
128
202
|
isActive: isGroupActive,
|
|
203
|
+
isNearby: hasActive,
|
|
129
204
|
Link,
|
|
130
205
|
children: node.title
|
|
131
206
|
}), /* @__PURE__ */ jsx("button", {
|
|
@@ -149,17 +224,34 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
|
|
|
149
224
|
}) : null]
|
|
150
225
|
});
|
|
151
226
|
}
|
|
152
|
-
function NavLink({ href, isExternal, isActive, Link, children }) {
|
|
227
|
+
function NavLink({ href, isExternal, isActive, isNearby = false, Link, children }) {
|
|
153
228
|
const className = "wave-docs-sidebar__link";
|
|
154
229
|
if (isExternal) return /* @__PURE__ */ jsxs("a", {
|
|
155
230
|
className,
|
|
156
231
|
href,
|
|
157
232
|
target: "_blank",
|
|
158
233
|
rel: "noopener noreferrer",
|
|
159
|
-
children: [
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
234
|
+
children: [
|
|
235
|
+
children,
|
|
236
|
+
/* @__PURE__ */ jsx("svg", {
|
|
237
|
+
className: "wave-docs-sidebar__external",
|
|
238
|
+
"aria-hidden": "true",
|
|
239
|
+
focusable: "false",
|
|
240
|
+
viewBox: "0 0 24 24",
|
|
241
|
+
width: "12",
|
|
242
|
+
height: "12",
|
|
243
|
+
fill: "none",
|
|
244
|
+
stroke: "currentColor",
|
|
245
|
+
strokeWidth: "2",
|
|
246
|
+
strokeLinecap: "round",
|
|
247
|
+
strokeLinejoin: "round",
|
|
248
|
+
children: /* @__PURE__ */ jsx("path", { d: "M14 4h6v6M20 4l-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" })
|
|
249
|
+
}),
|
|
250
|
+
/* @__PURE__ */ jsx("span", {
|
|
251
|
+
className: "wave-docs-sr-only",
|
|
252
|
+
children: " (opens in a new tab)"
|
|
253
|
+
})
|
|
254
|
+
]
|
|
163
255
|
});
|
|
164
256
|
if (Link === void 0) return /* @__PURE__ */ jsx("a", {
|
|
165
257
|
className,
|
|
@@ -170,7 +262,7 @@ function NavLink({ href, isExternal, isActive, Link, children }) {
|
|
|
170
262
|
return /* @__PURE__ */ jsx(Link, {
|
|
171
263
|
className,
|
|
172
264
|
href,
|
|
173
|
-
prefetch: false,
|
|
265
|
+
prefetch: isNearby ? void 0 : false,
|
|
174
266
|
"aria-current": isActive ? "page" : void 0,
|
|
175
267
|
children
|
|
176
268
|
});
|
|
@@ -1,14 +1,6 @@
|
|
|
1
|
+
import { DOCS_CONTENT_ID } from "../docs-content-id.js";
|
|
1
2
|
import { ReactNode } from "react";
|
|
2
3
|
//#region src/react/skip-link.d.ts
|
|
3
|
-
/**
|
|
4
|
-
* The `id` this link targets, and the one `createDocsRoute` puts on its
|
|
5
|
-
* `<article>`.
|
|
6
|
-
*
|
|
7
|
-
* One constant for both halves: the two files spelled the string independently,
|
|
8
|
-
* and a skip link pointing at an id nothing carries scrolls nowhere and focuses
|
|
9
|
-
* nothing — a failure with no symptom until a keyboard user hits it.
|
|
10
|
-
*/
|
|
11
|
-
declare const DOCS_CONTENT_ID = "docs-content";
|
|
12
4
|
interface SkipLinkProps {
|
|
13
5
|
/** Fragment id of the main content region. */
|
|
14
6
|
href?: string | undefined;
|
package/dist/react/skip-link.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import { DOCS_CONTENT_ID } from "../docs-content-id.js";
|
|
1
2
|
import { jsx } from "react/jsx-runtime";
|
|
2
3
|
//#region src/react/skip-link.tsx
|
|
3
4
|
/**
|
|
4
5
|
* The `id` this link targets, and the one `createDocsRoute` puts on its
|
|
5
|
-
* `<
|
|
6
|
+
* `<main>`. Defined in a private module so the two halves cannot spell it
|
|
7
|
+
* differently; re-exported here because this is the documented import path.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
* and
|
|
9
|
-
*
|
|
9
|
+
* (This comment used to call the file a `'use client'` module. It is not one
|
|
10
|
+
* and never was — `SkipLink` is an anchor with no state, so it is a Server
|
|
11
|
+
* Component like everything else here that does not need a browser.)
|
|
10
12
|
*/
|
|
11
|
-
const DOCS_CONTENT_ID = "docs-content";
|
|
12
13
|
/**
|
|
13
14
|
* Skip-to-content link: invisible until focused, first in the tab order.
|
|
14
15
|
*
|
package/dist/react/toc.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ interface DocsTocProps {
|
|
|
16
16
|
* other unit, `rem` included.
|
|
17
17
|
*/
|
|
18
18
|
rootMargin?: string | undefined;
|
|
19
|
+
/** Text for the back-to-top link. */
|
|
20
|
+
topLabel?: string | undefined;
|
|
19
21
|
className?: string | undefined;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
@@ -26,10 +28,16 @@ interface DocsTocProps {
|
|
|
26
28
|
* out of sync on duplicate headings.
|
|
27
29
|
*
|
|
28
30
|
* Scrolling itself is left to the browser: the links are real anchors, and
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
31
|
+
* nothing here calls `scrollTo`. Doing it in JavaScript means reimplementing
|
|
32
|
+
* the `prefers-reduced-motion` check, and getting that wrong makes people ill.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ AND THE STYLESHEET SETS NO `scroll-behavior: smooth` EITHER, deliberately
|
|
35
|
+
* — this comment used to say it did. Next 16 suppresses smooth scrolling
|
|
36
|
+
* across a route change only when `<html>` carries
|
|
37
|
+
* `data-scroll-behavior="smooth"`, an attribute only the host can set, so a
|
|
38
|
+
* package-level rule would smooth-scroll every navigation and no reader could
|
|
39
|
+
* turn it off. `styles.css` says the same at greater length.
|
|
32
40
|
*/
|
|
33
|
-
declare function DocsToc({ entries, label, rootMargin, className }: DocsTocProps): ReactNode;
|
|
41
|
+
declare function DocsToc({ entries, label, rootMargin, className, topLabel }: DocsTocProps): ReactNode;
|
|
34
42
|
//#endregion
|
|
35
43
|
export { DocsToc, DocsTocProps };
|
package/dist/react/toc.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { DOCS_CONTENT_ID } from "../docs-content-id.js";
|
|
2
3
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
5
|
//#region src/react/toc.tsx
|
|
5
6
|
const DEFAULT_ROOT_MARGIN = "-80px 0px -60% 0px";
|
|
7
|
+
/**
|
|
8
|
+
* How many frames to keep looking for headings that are not in the document
|
|
9
|
+
* yet. ~1s at 60Hz, which covers a `<Suspense>` boundary resolving or a
|
|
10
|
+
* tabs/accordion wrapper revealing its panel. Bounded because the loop must
|
|
11
|
+
* also terminate on a page whose headings genuinely never arrive.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_ATTACH_FRAMES = 60;
|
|
6
14
|
function flattenTocIds(entries) {
|
|
7
15
|
const ids = [];
|
|
8
16
|
const walk = (list) => {
|
|
@@ -22,11 +30,17 @@ function flattenTocIds(entries) {
|
|
|
22
30
|
* out of sync on duplicate headings.
|
|
23
31
|
*
|
|
24
32
|
* Scrolling itself is left to the browser: the links are real anchors, and
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
33
|
+
* nothing here calls `scrollTo`. Doing it in JavaScript means reimplementing
|
|
34
|
+
* the `prefers-reduced-motion` check, and getting that wrong makes people ill.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ AND THE STYLESHEET SETS NO `scroll-behavior: smooth` EITHER, deliberately
|
|
37
|
+
* — this comment used to say it did. Next 16 suppresses smooth scrolling
|
|
38
|
+
* across a route change only when `<html>` carries
|
|
39
|
+
* `data-scroll-behavior="smooth"`, an attribute only the host can set, so a
|
|
40
|
+
* package-level rule would smooth-scroll every navigation and no reader could
|
|
41
|
+
* turn it off. `styles.css` says the same at greater length.
|
|
28
42
|
*/
|
|
29
|
-
function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MARGIN, className }) {
|
|
43
|
+
function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MARGIN, className, topLabel = "Back to top" }) {
|
|
30
44
|
const ids = useMemo(() => flattenTocIds(entries), [entries]);
|
|
31
45
|
const [activeId, setActiveId] = useState(void 0);
|
|
32
46
|
const lastIds = useRef(ids);
|
|
@@ -46,21 +60,41 @@ function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MA
|
|
|
46
60
|
rootMargin,
|
|
47
61
|
threshold: 0
|
|
48
62
|
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
let frame;
|
|
64
|
+
let framesLeft = MAX_ATTACH_FRAMES;
|
|
65
|
+
const attach = () => {
|
|
66
|
+
let attached = 0;
|
|
67
|
+
for (const id of ids) {
|
|
68
|
+
const element = document.getElementById(id);
|
|
69
|
+
if (element !== null) {
|
|
70
|
+
observer.observe(element);
|
|
71
|
+
attached += 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (attached === 0 && framesLeft > 0 && typeof requestAnimationFrame === "function") {
|
|
75
|
+
framesLeft -= 1;
|
|
76
|
+
frame = requestAnimationFrame(attach);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
attach();
|
|
80
|
+
return () => {
|
|
81
|
+
if (frame !== void 0) cancelAnimationFrame(frame);
|
|
82
|
+
observer.disconnect();
|
|
83
|
+
};
|
|
54
84
|
}, [ids, rootMargin]);
|
|
55
85
|
if (entries.length === 0) return null;
|
|
56
|
-
return /* @__PURE__ */
|
|
86
|
+
return /* @__PURE__ */ jsxs("nav", {
|
|
57
87
|
"aria-label": label,
|
|
58
88
|
className: ["wave-docs-toc", className].filter(Boolean).join(" "),
|
|
59
|
-
children: /* @__PURE__ */ jsx(TocList, {
|
|
89
|
+
children: [/* @__PURE__ */ jsx(TocList, {
|
|
60
90
|
entries,
|
|
61
91
|
activeId,
|
|
62
92
|
onSelect: setActiveId
|
|
63
|
-
})
|
|
93
|
+
}), /* @__PURE__ */ jsx("a", {
|
|
94
|
+
className: "wave-docs-toc__top",
|
|
95
|
+
href: `#${DOCS_CONTENT_ID}`,
|
|
96
|
+
children: topLabel
|
|
97
|
+
})]
|
|
64
98
|
});
|
|
65
99
|
}
|
|
66
100
|
function TocList({ entries, activeId, onSelect }) {
|