@waveso/docs 0.2.0 → 0.4.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 +138 -0
- package/README.md +490 -75
- 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 +2 -57
- package/dist/docs-error.js +3 -15
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +45 -0
- package/dist/next.d.ts +153 -28
- package/dist/next.js +65 -33
- package/dist/plugins/rehype-capture-toc.js +26 -5
- 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.js +7 -1
- 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/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 +53 -10
- package/dist/react/search-dialog.js +147 -47
- 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 +18 -7
- package/dist/react/youtube.d.ts +31 -5
- package/dist/react/youtube.js +76 -54
- package/dist/render.d.ts +35 -1
- package/dist/render.js +35 -14
- package/dist/route-path.d.ts +46 -0
- package/dist/route-path.js +51 -0
- package/dist/search-index.d.ts +6 -23
- package/dist/search-index.js +6 -51
- package/dist/sitemap-limit.d.ts +34 -0
- package/dist/sitemap-limit.js +37 -0
- package/dist/source.d.ts +1 -23
- package/dist/source.js +40 -43
- package/dist/styles.css +1001 -106
- package/dist/types.d.ts +11 -2
- package/package.json +58 -23
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { wrapNextLink } from "./next-link.js";
|
|
3
|
+
import { DocsNav } from "./nav.js";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
import NextLink from "next/link";
|
|
6
|
+
import { usePathname } from "next/navigation";
|
|
7
|
+
//#region src/react/next-nav.tsx
|
|
8
|
+
/**
|
|
9
|
+
* {@link DocsNav}, wired to Next's router.
|
|
10
|
+
*
|
|
11
|
+
* Private, and the **only** module in `src/react/` that imports
|
|
12
|
+
* `next/navigation` — named so that is obvious from the file list. Everything
|
|
13
|
+
* else in here takes `pathname` and `Link` as props, which is what lets the
|
|
14
|
+
* components be tested without mounting a router and reused outside Next.
|
|
15
|
+
*
|
|
16
|
+
* The split is not ceremony: `usePathname` is why the consumer used to have to
|
|
17
|
+
* hand-write a `'use client'` wrapper of their own, and deleting that file from
|
|
18
|
+
* the README is most of what `docs.Layout` is for.
|
|
19
|
+
*/
|
|
20
|
+
/** Module scope: a fresh identity here remounts every nav link on every render. */
|
|
21
|
+
const Link = wrapNextLink(NextLink);
|
|
22
|
+
function DocsNextNav({ nav, label, closeLabel }) {
|
|
23
|
+
return /* @__PURE__ */ jsx(DocsNav, {
|
|
24
|
+
nav,
|
|
25
|
+
pathname: usePathname(),
|
|
26
|
+
Link,
|
|
27
|
+
...label === void 0 ? {} : { label },
|
|
28
|
+
...closeLabel === void 0 ? {} : { closeLabel }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { DocsNextNav };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SearchDialogProps } from "./search-dialog.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/react/next-search.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Everything `SearchDialog` takes except the two things Next answers for you.
|
|
6
|
+
*
|
|
7
|
+
* `indexUrl` stays required, and `docs.searchIndexUrl` is the only value
|
|
8
|
+
* anyone should pass: defaulting it to `/search-index.json` would be wrong
|
|
9
|
+
* under every non-root `basePath`, and wrong as a 404 the reader hits and the
|
|
10
|
+
* author never sees.
|
|
11
|
+
*/
|
|
12
|
+
type DocsSearchProps = Omit<SearchDialogProps, 'navigate' | 'Link'>;
|
|
13
|
+
/**
|
|
14
|
+
* Search trigger and dialog for a Next application.
|
|
15
|
+
*
|
|
16
|
+
* `next/link` is passed through rather than left to the plain-anchor fallback,
|
|
17
|
+
* so hovering a result prefetches the page it points at — which is most of why
|
|
18
|
+
* a hit feels instant when you press Enter.
|
|
19
|
+
*/
|
|
20
|
+
declare function DocsSearch(props: DocsSearchProps): ReactNode;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { DocsSearch, DocsSearchProps };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { wrapNextLink } from "./next-link.js";
|
|
3
|
+
import { SearchDialog } from "./search-dialog.js";
|
|
4
|
+
import { useCallback } from "react";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
import NextLink from "next/link";
|
|
7
|
+
import { useRouter } from "next/navigation";
|
|
8
|
+
//#region src/react/next-search.tsx
|
|
9
|
+
/**
|
|
10
|
+
* {@link SearchDialog}, wired to Next's router and `next/link`.
|
|
11
|
+
*
|
|
12
|
+
* `SearchDialog` takes `navigate` and `Link` as props so it stays
|
|
13
|
+
* host-agnostic, and that seam is why its own tests are worth having — they
|
|
14
|
+
* assert behaviour against a stub router rather than mounting Next. But this
|
|
15
|
+
* package is a Next adapter, so in practice every consumer wrote the same
|
|
16
|
+
* fifteen-line `'use client'` wrapper around `useRouter().push` and
|
|
17
|
+
* `next/link`, and the ones who skipped `Link` lost hover prefetching on
|
|
18
|
+
* every result without anything telling them.
|
|
19
|
+
*
|
|
20
|
+
* So it ships. The same bargain `createDocsRoute` already strikes on the
|
|
21
|
+
* server, where `next/link` and `next/image` are wired by default and
|
|
22
|
+
* overridable through `components`.
|
|
23
|
+
*
|
|
24
|
+
* ```tsx
|
|
25
|
+
* // app/docs/layout.tsx — a Server Component; this file carries the boundary
|
|
26
|
+
* import { DocsSearch } from '@waveso/docs/react/next-search';
|
|
27
|
+
* import { docs } from '@/lib/docs';
|
|
28
|
+
*
|
|
29
|
+
* <DocsSearch indexUrl={docs.searchIndexUrl} />
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
const Link = wrapNextLink(NextLink);
|
|
33
|
+
/**
|
|
34
|
+
* Search trigger and dialog for a Next application.
|
|
35
|
+
*
|
|
36
|
+
* `next/link` is passed through rather than left to the plain-anchor fallback,
|
|
37
|
+
* so hovering a result prefetches the page it points at — which is most of why
|
|
38
|
+
* a hit feels instant when you press Enter.
|
|
39
|
+
*/
|
|
40
|
+
function DocsSearch(props) {
|
|
41
|
+
const router = useRouter();
|
|
42
|
+
const navigate = useCallback((href) => {
|
|
43
|
+
router.push(href);
|
|
44
|
+
}, [router]);
|
|
45
|
+
return /* @__PURE__ */ jsx(SearchDialog, {
|
|
46
|
+
...props,
|
|
47
|
+
navigate,
|
|
48
|
+
Link
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { DocsSearch };
|
|
@@ -26,8 +26,39 @@ interface SearchDialogProps {
|
|
|
26
26
|
placeholder?: string | undefined;
|
|
27
27
|
/** Accessible name for the dialog. Defaults to `'Search documentation'`. */
|
|
28
28
|
dialogLabel?: string | undefined;
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* How many results to render at a time. Defaults to 20.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ NOT A CAP. Every match is reachable — the list renders this many, then
|
|
33
|
+
* another `pageSize` each time the reader scrolls near the end, so the DOM
|
|
34
|
+
* stays bounded without anything being withheld.
|
|
35
|
+
*
|
|
36
|
+
* This was `maxResults`, and it was a hard ceiling of 8. On a *six-page*
|
|
37
|
+
* site "docs" matches 18, so ten results simply could not be reached, and
|
|
38
|
+
* the live region announced "8 results" — not a smaller truth but a false
|
|
39
|
+
* one. The ceiling was justified by a claim nobody had measured, and the
|
|
40
|
+
* measurement did not support it: on a 300-page corpus (2,100 records) a
|
|
41
|
+
* query costs 1.3–3.0 ms and rendering *every* row costs 40 ms, 128 ms at
|
|
42
|
+
* 4x CPU throttle. Paging exists to keep that worst case from ever being
|
|
43
|
+
* reached, not because the search cannot find things.
|
|
44
|
+
*/
|
|
45
|
+
pageSize?: number | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Shortest query that runs. Defaults to 2.
|
|
48
|
+
*
|
|
49
|
+
* A single character is not a query — measured on this package's own docs,
|
|
50
|
+
* "a" matches 100% of the corpus, "i" 97%, "s" 93%. Answering those wastes a
|
|
51
|
+
* render and, worse, teaches a reader mid-word that search returns noise.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ TWO, NOT THREE, AND THE DIFFERENCE MATTERS ON A DOCS SITE. Three would
|
|
54
|
+
* refuse `ts`, `js`, `id`, `h1`, `px` — every one a real query here, and each
|
|
55
|
+
* one selective: 10%, 17%, 14%, 3%, 0%. The noise is at one character, so
|
|
56
|
+
* that is where the floor goes.
|
|
57
|
+
*
|
|
58
|
+
* A word like `is` still matches 83%; that is a stopword problem rather than
|
|
59
|
+
* a length one, and `miniSearchOptions.processTerm` is the tool for it.
|
|
60
|
+
*/
|
|
61
|
+
minQueryLength?: number | undefined;
|
|
31
62
|
/** Input debounce in milliseconds. Defaults to 120. */
|
|
32
63
|
debounceMs?: number | undefined;
|
|
33
64
|
/** Extra class names for the trigger button, e.g. a navbar's own layout. */
|
|
@@ -35,15 +66,27 @@ interface SearchDialogProps {
|
|
|
35
66
|
/**
|
|
36
67
|
* Overrides applied through `mergeSearchOptions` when the index is
|
|
37
68
|
* deserialised — the escape hatch for tokenisation, `processTerm` and the
|
|
38
|
-
* query defaults
|
|
39
|
-
*
|
|
69
|
+
* query defaults, without waiting on a release of this package.
|
|
70
|
+
*
|
|
71
|
+
* MiniSearch's own name for the query defaults is `searchOptions`, so they
|
|
72
|
+
* nest one level down:
|
|
73
|
+
*
|
|
74
|
+
* ```tsx
|
|
75
|
+
* <DocsSearch miniSearchOptions={{ searchOptions: { fuzzy: 0.1 } }} />
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* That stutter is why this prop is not called `searchOptions` too. It was,
|
|
79
|
+
* and `searchOptions={{ fuzzy: 0.1 }}` reads so naturally that both README
|
|
80
|
+
* examples were written that way — neither compiled, and the flat form is
|
|
81
|
+
* not a runtime error either. It is a `fuzzy` MiniSearch never reads.
|
|
40
82
|
*
|
|
41
|
-
* ⚠️ HAND THE IDENTICAL OVERRIDES TO
|
|
42
|
-
* `
|
|
43
|
-
*
|
|
44
|
-
*
|
|
83
|
+
* ⚠️ HAND THE IDENTICAL OVERRIDES TO THE BUILD — `createDocsRoute`'s
|
|
84
|
+
* `miniSearchOptions`, or `buildSearchIndex`'s second argument. `tokenize`
|
|
85
|
+
* and `processTerm` decide how terms were written into the index; a client
|
|
86
|
+
* that splits differently from the build looks up terms that were never
|
|
87
|
+
* written and finds nothing, silently.
|
|
45
88
|
*/
|
|
46
|
-
|
|
89
|
+
miniSearchOptions?: Partial<Options<SearchRecord>> | undefined;
|
|
47
90
|
}
|
|
48
91
|
/**
|
|
49
92
|
* Search trigger plus its dialog.
|
|
@@ -52,6 +95,6 @@ interface SearchDialogProps {
|
|
|
52
95
|
* portalled to `document.body`, so a navbar's stacking context cannot trap
|
|
53
96
|
* it behind the page.
|
|
54
97
|
*/
|
|
55
|
-
declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel,
|
|
98
|
+
declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, pageSize, minQueryLength, debounceMs, className, miniSearchOptions }: SearchDialogProps): ReactNode;
|
|
56
99
|
//#endregion
|
|
57
100
|
export { SearchDialog, SearchDialogProps };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { docsError } from "../docs-error.js";
|
|
3
3
|
import { mergeSearchOptions } from "../search-options.js";
|
|
4
4
|
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
5
|
-
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
7
|
//#region src/react/search-dialog.tsx
|
|
8
8
|
/**
|
|
@@ -26,10 +26,31 @@ const FOCUSABLE_SELECTOR = [
|
|
|
26
26
|
* portalled to `document.body`, so a navbar's stacking context cannot trap
|
|
27
27
|
* it behind the page.
|
|
28
28
|
*/
|
|
29
|
-
function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation",
|
|
29
|
+
function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", pageSize = 20, minQueryLength = 2, debounceMs = 120, className, miniSearchOptions }) {
|
|
30
30
|
const [isOpen, setIsOpen] = useState(false);
|
|
31
31
|
const [query, setQuery] = useState("");
|
|
32
32
|
const [hits, setHits] = useState([]);
|
|
33
|
+
/**
|
|
34
|
+
* How many of `hits` are rendered.
|
|
35
|
+
*
|
|
36
|
+
* `hits` holds every match; this is the window. It grows by `pageSize` when
|
|
37
|
+
* the reader scrolls near the end, and whenever the keyboard walks past it —
|
|
38
|
+
* so an option always exists for `aria-activedescendant` to point at.
|
|
39
|
+
*/
|
|
40
|
+
const [visibleCount, setVisibleCount] = useState(pageSize);
|
|
41
|
+
/**
|
|
42
|
+
* Whether the active option moved because of a key, rather than a pointer.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ THE SCROLL-INTO-VIEW BELOW MUST NOT RUN FOR A HOVER. Pointing at a row
|
|
45
|
+
* that is half-clipped by the top or bottom edge set the active index, which
|
|
46
|
+
* scrolled that row flush — moving the whole list under the cursor, which
|
|
47
|
+
* then landed on a different row. Measured: hovering the visible sliver of a
|
|
48
|
+
* clipped row jumped the list 28px.
|
|
49
|
+
*
|
|
50
|
+
* A ref rather than state: it records how the *last* change happened and must
|
|
51
|
+
* not itself cause a render.
|
|
52
|
+
*/
|
|
53
|
+
const movedByKeyboard = useRef(false);
|
|
33
54
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
34
55
|
const [status, setStatus] = useState("idle");
|
|
35
56
|
const [shortcutHint, setShortcutHint] = useState("");
|
|
@@ -39,21 +60,21 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
39
60
|
const listRef = useRef(null);
|
|
40
61
|
const returnFocusRef = useRef(null);
|
|
41
62
|
const indexCacheRef = useRef(/* @__PURE__ */ new Map());
|
|
42
|
-
const
|
|
63
|
+
const miniSearchOptionsRef = useRef(miniSearchOptions);
|
|
43
64
|
/** Whether the dialog has ever been open. See the focus effect below. */
|
|
44
65
|
const hasOpenedRef = useRef(false);
|
|
45
66
|
const baseId = useId();
|
|
46
67
|
const listId = `${baseId}-results`;
|
|
47
68
|
const optionId = (index) => `${baseId}-option-${index}`;
|
|
48
69
|
useEffect(() => {
|
|
49
|
-
|
|
50
|
-
}, [
|
|
70
|
+
miniSearchOptionsRef.current = miniSearchOptions;
|
|
71
|
+
}, [miniSearchOptions]);
|
|
51
72
|
/** Load each URL at most once; a failure evicts that key so a retry can. */
|
|
52
73
|
const ensureIndex = useCallback(() => {
|
|
53
74
|
const cache = indexCacheRef.current;
|
|
54
75
|
let pending = cache.get(indexUrl);
|
|
55
76
|
if (pending === void 0) {
|
|
56
|
-
pending = loadIndex(indexUrl,
|
|
77
|
+
pending = loadIndex(indexUrl, miniSearchOptionsRef.current).catch((error) => {
|
|
57
78
|
cache.delete(indexUrl);
|
|
58
79
|
throw error;
|
|
59
80
|
});
|
|
@@ -135,9 +156,10 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
135
156
|
}, [isOpen]);
|
|
136
157
|
useEffect(() => {
|
|
137
158
|
const trimmed = query.trim();
|
|
138
|
-
if (trimmed
|
|
159
|
+
if (trimmed.length < minQueryLength) {
|
|
139
160
|
setHits([]);
|
|
140
161
|
setActiveIndex(0);
|
|
162
|
+
setVisibleCount(pageSize);
|
|
141
163
|
return;
|
|
142
164
|
}
|
|
143
165
|
let isCancelled = false;
|
|
@@ -145,8 +167,9 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
145
167
|
ensureIndex().then((index) => {
|
|
146
168
|
if (isCancelled) return;
|
|
147
169
|
setStatus("ready");
|
|
148
|
-
setHits(index.search(trimmed).
|
|
170
|
+
setHits(index.search(trimmed).map(toSearchHit).filter(isSearchHit));
|
|
149
171
|
setActiveIndex(0);
|
|
172
|
+
setVisibleCount(pageSize);
|
|
150
173
|
}, () => {
|
|
151
174
|
if (!isCancelled) setStatus("error");
|
|
152
175
|
});
|
|
@@ -158,11 +181,55 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
158
181
|
}, [
|
|
159
182
|
query,
|
|
160
183
|
ensureIndex,
|
|
161
|
-
|
|
162
|
-
debounceMs
|
|
184
|
+
pageSize,
|
|
185
|
+
debounceMs,
|
|
186
|
+
minQueryLength
|
|
163
187
|
]);
|
|
188
|
+
/**
|
|
189
|
+
* Reveal another page when the reader nears the end of the list.
|
|
190
|
+
*
|
|
191
|
+
* A scroll handler rather than an `IntersectionObserver` on a sentinel: the
|
|
192
|
+
* scrollport is one element this component already holds a ref to, the test
|
|
193
|
+
* is one subtraction, and an observer would cost bytes on the largest client
|
|
194
|
+
* entry this package ships for no behaviour the reader can tell apart.
|
|
195
|
+
*
|
|
196
|
+
* `passive`, because this never calls `preventDefault` and a non-passive
|
|
197
|
+
* scroll listener blocks the compositor on every wheel event.
|
|
198
|
+
*
|
|
199
|
+
* No `isOpen` dependency, though the list does not exist while the dialog is
|
|
200
|
+
* closed: the effect returns early on a null ref, and `hits.length` going
|
|
201
|
+
* from 0 to N re-runs it — which happens after the list has mounted, because
|
|
202
|
+
* closing resets the query. The listener attaches exactly when there is
|
|
203
|
+
* something to scroll.
|
|
204
|
+
*/
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
const list = listRef.current;
|
|
207
|
+
if (list === null || visibleCount >= hits.length) return;
|
|
208
|
+
const onScroll = () => {
|
|
209
|
+
if (list.scrollHeight - list.scrollTop - list.clientHeight < list.clientHeight) setVisibleCount((count) => Math.min(count + pageSize, hits.length));
|
|
210
|
+
};
|
|
211
|
+
list.addEventListener("scroll", onScroll, { passive: true });
|
|
212
|
+
return () => list.removeEventListener("scroll", onScroll);
|
|
213
|
+
}, [
|
|
214
|
+
visibleCount,
|
|
215
|
+
hits.length,
|
|
216
|
+
pageSize
|
|
217
|
+
]);
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (activeIndex >= visibleCount) setVisibleCount(Math.min(activeIndex + 1, hits.length));
|
|
220
|
+
}, [
|
|
221
|
+
activeIndex,
|
|
222
|
+
visibleCount,
|
|
223
|
+
hits.length
|
|
224
|
+
]);
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
const list = listRef.current;
|
|
227
|
+
if (list !== null && list.scrollTop !== 0) list.scrollTop = 0;
|
|
228
|
+
}, [hits]);
|
|
164
229
|
useEffect(() => {
|
|
165
230
|
if (hits.length === 0) return;
|
|
231
|
+
if (!movedByKeyboard.current) return;
|
|
232
|
+
movedByKeyboard.current = false;
|
|
166
233
|
(listRef.current?.querySelector(`#${CSS.escape(`${baseId}-option-${activeIndex}`)}`))?.scrollIntoView({ block: "nearest" });
|
|
167
234
|
}, [
|
|
168
235
|
activeIndex,
|
|
@@ -179,6 +246,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
179
246
|
if (hits.length === 0) return;
|
|
180
247
|
event.preventDefault();
|
|
181
248
|
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
249
|
+
movedByKeyboard.current = true;
|
|
182
250
|
setActiveIndex((index) => (index + delta + hits.length) % hits.length);
|
|
183
251
|
return;
|
|
184
252
|
}
|
|
@@ -190,7 +258,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
190
258
|
}
|
|
191
259
|
}
|
|
192
260
|
const activeHit = hits[activeIndex];
|
|
193
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
|
|
261
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
|
|
194
262
|
type: "button",
|
|
195
263
|
ref: triggerRef,
|
|
196
264
|
className: ["wave-docs-search-trigger", className].filter(Boolean).join(" "),
|
|
@@ -250,11 +318,16 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
250
318
|
className: "wave-docs-search-results",
|
|
251
319
|
role: "listbox",
|
|
252
320
|
"aria-label": dialogLabel,
|
|
253
|
-
children: hits.map((hit, index) => /* @__PURE__ */ jsx(SearchResultOption, {
|
|
321
|
+
children: hits.slice(0, visibleCount).map((hit, index) => /* @__PURE__ */ jsx(SearchResultOption, {
|
|
254
322
|
hit,
|
|
255
323
|
id: optionId(index),
|
|
256
324
|
isActive: index === activeIndex,
|
|
257
|
-
|
|
325
|
+
setSize: hits.length,
|
|
326
|
+
posInSet: index + 1,
|
|
327
|
+
onActivate: () => {
|
|
328
|
+
movedByKeyboard.current = false;
|
|
329
|
+
setActiveIndex(index);
|
|
330
|
+
},
|
|
258
331
|
onSelect: selectHit,
|
|
259
332
|
...Link === void 0 ? {} : { Link }
|
|
260
333
|
}, hit.id))
|
|
@@ -262,40 +335,36 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
262
335
|
/* @__PURE__ */ jsx(SearchStatus, {
|
|
263
336
|
status,
|
|
264
337
|
query: query.trim(),
|
|
265
|
-
hitCount: hits.length
|
|
338
|
+
hitCount: hits.length,
|
|
339
|
+
minQueryLength
|
|
266
340
|
})
|
|
267
341
|
]
|
|
268
342
|
})
|
|
269
343
|
}), document.body) : null] });
|
|
270
344
|
}
|
|
271
345
|
/** One result row: a real link, so middle-click and "open in new tab" work. */
|
|
272
|
-
function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
|
|
346
|
+
function SearchResultOption({ hit, id, isActive, setSize, posInSet, onActivate, onSelect, Link }) {
|
|
273
347
|
function handleClick(event) {
|
|
274
348
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
275
349
|
event.preventDefault();
|
|
276
350
|
onSelect(hit);
|
|
277
351
|
}
|
|
278
|
-
const
|
|
279
|
-
const body = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
352
|
+
const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
|
|
280
353
|
className: "wave-docs-search-result-heading",
|
|
281
354
|
children: hit.heading
|
|
282
|
-
}),
|
|
283
|
-
className: "wave-docs-search-result-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
children: [index === 0 ? null : /* @__PURE__ */ jsx("span", {
|
|
287
|
-
className: "wave-docs-search-result-crumb-separator",
|
|
288
|
-
"aria-hidden": "true",
|
|
289
|
-
children: "›"
|
|
290
|
-
}), crumb.text]
|
|
291
|
-
}, crumb.key))
|
|
355
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
356
|
+
className: "wave-docs-search-result-location",
|
|
357
|
+
"aria-hidden": "true",
|
|
358
|
+
children: toDisplayPath(hit.href)
|
|
292
359
|
})] });
|
|
293
360
|
return /* @__PURE__ */ jsx("div", {
|
|
294
361
|
id,
|
|
295
362
|
className: isActive ? "wave-docs-search-result wave-docs-search-result-active" : "wave-docs-search-result",
|
|
296
363
|
role: "option",
|
|
297
364
|
"aria-selected": isActive,
|
|
298
|
-
"aria-
|
|
365
|
+
"aria-setsize": setSize,
|
|
366
|
+
"aria-posinset": posInSet,
|
|
367
|
+
"aria-label": spokenName(hit),
|
|
299
368
|
tabIndex: -1,
|
|
300
369
|
onPointerMove: onActivate,
|
|
301
370
|
children: Link === void 0 ? /* @__PURE__ */ jsx("a", {
|
|
@@ -314,7 +383,7 @@ function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
|
|
|
314
383
|
});
|
|
315
384
|
}
|
|
316
385
|
/** Loading, failure and empty states, plus a live region for hit counts. */
|
|
317
|
-
function SearchStatus({ status, query, hitCount }) {
|
|
386
|
+
function SearchStatus({ status, query, hitCount, minQueryLength }) {
|
|
318
387
|
let message = null;
|
|
319
388
|
let modifier = "";
|
|
320
389
|
if (status === "error") {
|
|
@@ -323,6 +392,9 @@ function SearchStatus({ status, query, hitCount }) {
|
|
|
323
392
|
} else if (query === "") {
|
|
324
393
|
message = "Start typing to search the documentation.";
|
|
325
394
|
modifier = " wave-docs-search-status-hint";
|
|
395
|
+
} else if (query.length < minQueryLength) {
|
|
396
|
+
message = `Keep typing — ${minQueryLength} characters or more.`;
|
|
397
|
+
modifier = " wave-docs-search-status-hint";
|
|
326
398
|
} else if (status !== "ready") {
|
|
327
399
|
message = "Loading the search index…";
|
|
328
400
|
modifier = " wave-docs-search-status-loading";
|
|
@@ -330,7 +402,7 @@ function SearchStatus({ status, query, hitCount }) {
|
|
|
330
402
|
message = `No results for “${query}”.`;
|
|
331
403
|
modifier = " wave-docs-search-status-empty";
|
|
332
404
|
}
|
|
333
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
|
|
405
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
|
|
334
406
|
className: `wave-docs-search-status${modifier}`,
|
|
335
407
|
children: message
|
|
336
408
|
}), /* @__PURE__ */ jsx("p", {
|
|
@@ -348,7 +420,12 @@ function SearchStatus({ status, query, hitCount }) {
|
|
|
348
420
|
*/
|
|
349
421
|
async function loadIndex(url, overrides) {
|
|
350
422
|
const [{ default: MiniSearchClass }, response] = await Promise.all([import("minisearch"), fetch(url)]);
|
|
351
|
-
if (!response.ok) throw docsError("search-index-unavailable",
|
|
423
|
+
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';
|
|
424
|
+
|
|
425
|
+
export const GET = docs.searchIndex;
|
|
426
|
+
export const dynamic = 'force-static';
|
|
427
|
+
|
|
428
|
+
Or pass \`search={false}\` to \`docs.Layout\` to hide the trigger.` : `Failed to load the search index from ${url} (HTTP ${response.status}).`);
|
|
352
429
|
return MiniSearchClass.loadJSONAsync(await response.text(), mergeSearchOptions(overrides));
|
|
353
430
|
}
|
|
354
431
|
/**
|
|
@@ -375,24 +452,47 @@ function isSearchHit(hit) {
|
|
|
375
452
|
return hit !== void 0;
|
|
376
453
|
}
|
|
377
454
|
/**
|
|
378
|
-
*
|
|
379
|
-
* the page title so the index does not carry it twice.
|
|
455
|
+
* The route, without its anchor, for display only.
|
|
380
456
|
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
457
|
+
* ⚠️ THE ANCHOR IS NOISE HERE, AND ALMOST ALWAYS A REPEAT. A section's anchor
|
|
458
|
+
* is slugged from its heading, so `/docs/styling#layout-tokens` under a row
|
|
459
|
+
* whose first line already reads "Layout tokens" spends its width restating
|
|
460
|
+
* it — and on a real site it is the part that pushes the line past the
|
|
461
|
+
* ellipsis.
|
|
462
|
+
*
|
|
463
|
+
* What the line is for is "which page does this land on", and the path answers
|
|
464
|
+
* that on its own. Two rows from the same page showing the same path is not
|
|
465
|
+
* ambiguity: they *are* the same page, and their headings above say which part.
|
|
466
|
+
*
|
|
467
|
+
* Display only. `hit.href` keeps the anchor, so the link still deep-links to
|
|
468
|
+
* the section — that is the whole point of section-scoped records.
|
|
385
469
|
*/
|
|
386
|
-
function
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
470
|
+
function toDisplayPath(href) {
|
|
471
|
+
const hash = href.indexOf("#");
|
|
472
|
+
return hash === -1 ? href : href.slice(0, hash);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* What a result is called when it is read aloud.
|
|
476
|
+
*
|
|
477
|
+
* Words, not the route the row displays. `/docs/styling#layout-tokens` is
|
|
478
|
+
* punctuation to a screen reader — spelled out slash by slash — so the visible
|
|
479
|
+
* line and the announced name deliberately carry the same fact in two forms:
|
|
480
|
+
* the route for a sighted reader scanning for where a hit lands, and
|
|
481
|
+
* "Layout tokens, Styling" for a listener.
|
|
482
|
+
*
|
|
483
|
+
* `ancestors` deliberately excludes the page title, so the page comes first
|
|
484
|
+
* here and the enclosing headings follow, outermost first.
|
|
485
|
+
*
|
|
486
|
+
* A page's own record carries `heading === title` and no ancestors, so its name
|
|
487
|
+
* is the heading alone — "Styling, Styling" is not a path, it is a stutter.
|
|
488
|
+
*/
|
|
489
|
+
function spokenName(hit) {
|
|
490
|
+
if (hit.ancestors.length === 0 && hit.heading === hit.title) return hit.heading;
|
|
491
|
+
return [
|
|
492
|
+
hit.heading,
|
|
493
|
+
hit.title,
|
|
494
|
+
...hit.ancestors
|
|
495
|
+
].join(", ");
|
|
396
496
|
}
|
|
397
497
|
function trapFocus(root, event) {
|
|
398
498
|
if (root === null) return;
|
|
@@ -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
|