@waveso/docs 0.4.0 → 0.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/CHANGELOG.md +173 -0
- package/README.md +160 -39
- package/dist/errors.d.ts +2 -0
- package/dist/highlighter.js +2 -1
- package/dist/meta.js +6 -9
- package/dist/next.d.ts +54 -14
- package/dist/next.js +115 -18
- package/dist/plugins/rehype-code-frame.d.ts +13 -1
- package/dist/plugins/rehype-code-frame.js +2 -1
- package/dist/plugins/remark-doc-links.d.ts +51 -1
- package/dist/plugins/remark-doc-links.js +27 -16
- package/dist/plugins/remark-youtube.d.ts +18 -3
- package/dist/plugins/remark-youtube.js +57 -9
- package/dist/react/callout.d.ts +13 -1
- package/dist/react/callout.js +2 -2
- package/dist/react/code-runtime.d.ts +12 -2
- package/dist/react/code-runtime.js +28 -4
- package/dist/react/doc-content.d.ts +12 -1
- package/dist/react/doc-content.js +2 -2
- package/dist/react/layout.d.ts +27 -10
- package/dist/react/layout.js +6 -3
- package/dist/react/markdown-components.d.ts +29 -1
- package/dist/react/markdown-components.js +69 -67
- package/dist/react/nav.d.ts +5 -1
- package/dist/react/nav.js +5 -2
- package/dist/react/next-nav.d.ts +5 -1
- package/dist/react/next-nav.js +5 -2
- package/dist/react/search-dialog.d.ts +59 -3
- package/dist/react/search-dialog.js +53 -9
- package/dist/react/shell-labels.d.ts +135 -21
- package/dist/react/shell-labels.js +47 -6
- package/dist/react/sidebar.d.ts +18 -1
- package/dist/react/sidebar.js +59 -23
- package/dist/react/youtube.d.ts +22 -1
- package/dist/react/youtube.js +22 -4
- package/dist/render.d.ts +11 -0
- package/dist/render.js +38 -11
- package/dist/route-path.js +7 -2
- package/dist/safe-href.d.ts +47 -0
- package/dist/safe-href.js +73 -0
- package/dist/search-index.js +1 -1
- package/dist/search-options.d.ts +64 -2
- package/dist/search-options.js +25 -1
- package/dist/semaphore.d.ts +46 -0
- package/dist/semaphore.js +60 -0
- package/dist/source.js +80 -10
- package/dist/types.d.ts +30 -0
- package/package.json +1 -1
|
@@ -5,8 +5,15 @@ import { Options } from "minisearch";
|
|
|
5
5
|
//#region src/react/search-dialog.d.ts
|
|
6
6
|
interface SearchDialogProps {
|
|
7
7
|
/**
|
|
8
|
-
* URL of the serialised index, e.g. `/search-index.json`.
|
|
9
|
-
*
|
|
8
|
+
* URL of the serialised index, e.g. `/docs/search-index.json`.
|
|
9
|
+
*
|
|
10
|
+
* `docs.searchIndexUrl` is the value to pass: it is derived from the route's
|
|
11
|
+
* `basePath`, so it is right when the docs are mounted anywhere but the root.
|
|
12
|
+
* `docs.Layout` passes it for you.
|
|
13
|
+
*
|
|
14
|
+
* (This used to say "whatever `writeSearchIndex` wrote". That function was
|
|
15
|
+
* deleted in 0.3.0, along with the build script it needed — the index is a
|
|
16
|
+
* `force-static` route handler now.)
|
|
10
17
|
*/
|
|
11
18
|
indexUrl: string;
|
|
12
19
|
/**
|
|
@@ -61,6 +68,55 @@ interface SearchDialogProps {
|
|
|
61
68
|
minQueryLength?: number | undefined;
|
|
62
69
|
/** Input debounce in milliseconds. Defaults to 120. */
|
|
63
70
|
debounceMs?: number | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Shown before anything is typed. Defaults to
|
|
73
|
+
* `'Start typing to search the documentation.'`
|
|
74
|
+
*/
|
|
75
|
+
hintLabel?: string | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Shown while a query is below {@link SearchDialogProps.minQueryLength}.
|
|
78
|
+
* Defaults to `'Keep typing — {min} characters or more.'`
|
|
79
|
+
*
|
|
80
|
+
* `{min}` is replaced with that number. Said rather than silently done: a
|
|
81
|
+
* dialog that answers nothing and explains nothing reads as broken, and this
|
|
82
|
+
* is the state every reader passes through on the way to their real query.
|
|
83
|
+
*/
|
|
84
|
+
shortQueryLabel?: string | undefined;
|
|
85
|
+
/** Shown while the index is being fetched. Defaults to `'Loading the search index…'`. */
|
|
86
|
+
loadingLabel?: string | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Shown when the index cannot be loaded. Defaults to
|
|
89
|
+
* `'Search is unavailable right now. Try reloading the page.'`
|
|
90
|
+
*/
|
|
91
|
+
errorLabel?: string | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Shown when a query matches nothing. Defaults to `'No results for “{query}”.'`
|
|
94
|
+
*
|
|
95
|
+
* `{query}` is replaced with what the reader typed.
|
|
96
|
+
*/
|
|
97
|
+
emptyLabel?: string | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* The live region's announcement, by plural category. `{count}` is the total.
|
|
100
|
+
*
|
|
101
|
+
* Defaults to `{ one: '{count} result', other: '{count} results' }`.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ KEYED BY CATEGORY RATHER THAN BEING TWO STRINGS, BECAUSE MOST LANGUAGES
|
|
104
|
+
* ARE NOT ENGLISH. Polish takes four forms and Arabic six;
|
|
105
|
+
* `Intl.PluralRules(locale).select(count)` picks, and an unlisted category
|
|
106
|
+
* falls back to `other`. Two props called "singular" and "plural" would have
|
|
107
|
+
* made this package announce a wrong number of results, correctly, in most of
|
|
108
|
+
* the world.
|
|
109
|
+
*/
|
|
110
|
+
resultCountLabels?: Partial<Record<Intl.LDMLPluralRule, string>> | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* Language tag for the plural rules above. Defaults to the document's own
|
|
113
|
+
* `<html lang>`, then to `'en'`.
|
|
114
|
+
*
|
|
115
|
+
* Read at announcement time rather than at render, so it costs nothing on a
|
|
116
|
+
* site that never changes it and needs no prop on a site that sets `lang`
|
|
117
|
+
* correctly — which is every site that should be setting these labels at all.
|
|
118
|
+
*/
|
|
119
|
+
locale?: string | undefined;
|
|
64
120
|
/** Extra class names for the trigger button, e.g. a navbar's own layout. */
|
|
65
121
|
className?: string | undefined;
|
|
66
122
|
/**
|
|
@@ -95,6 +151,6 @@ interface SearchDialogProps {
|
|
|
95
151
|
* portalled to `document.body`, so a navbar's stacking context cannot trap
|
|
96
152
|
* it behind the page.
|
|
97
153
|
*/
|
|
98
|
-
declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, pageSize, minQueryLength, debounceMs, className, miniSearchOptions }: SearchDialogProps): ReactNode;
|
|
154
|
+
declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, pageSize, minQueryLength, debounceMs, className, miniSearchOptions, hintLabel, shortQueryLabel, loadingLabel, errorLabel, emptyLabel, resultCountLabels, locale }: SearchDialogProps): ReactNode;
|
|
99
155
|
//#endregion
|
|
100
156
|
export { SearchDialog, SearchDialogProps };
|
|
@@ -26,7 +26,7 @@ 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", pageSize = 20, minQueryLength = 2, debounceMs = 120, className, miniSearchOptions }) {
|
|
29
|
+
function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", pageSize = 20, minQueryLength = 2, debounceMs = 120, className, miniSearchOptions, hintLabel, shortQueryLabel, loadingLabel, errorLabel, emptyLabel, resultCountLabels, locale }) {
|
|
30
30
|
const [isOpen, setIsOpen] = useState(false);
|
|
31
31
|
const [query, setQuery] = useState("");
|
|
32
32
|
const [hits, setHits] = useState([]);
|
|
@@ -336,7 +336,16 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
336
336
|
status,
|
|
337
337
|
query: query.trim(),
|
|
338
338
|
hitCount: hits.length,
|
|
339
|
-
minQueryLength
|
|
339
|
+
minQueryLength,
|
|
340
|
+
labels: {
|
|
341
|
+
...hintLabel === void 0 ? {} : { hint: hintLabel },
|
|
342
|
+
...shortQueryLabel === void 0 ? {} : { shortQuery: shortQueryLabel },
|
|
343
|
+
...loadingLabel === void 0 ? {} : { loading: loadingLabel },
|
|
344
|
+
...errorLabel === void 0 ? {} : { error: errorLabel },
|
|
345
|
+
...emptyLabel === void 0 ? {} : { empty: emptyLabel }
|
|
346
|
+
},
|
|
347
|
+
resultCountLabels,
|
|
348
|
+
locale
|
|
340
349
|
})
|
|
341
350
|
]
|
|
342
351
|
})
|
|
@@ -382,24 +391,59 @@ function SearchResultOption({ hit, id, isActive, setSize, posInSet, onActivate,
|
|
|
382
391
|
})
|
|
383
392
|
});
|
|
384
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* The wording, so a caller that overrides none of it costs nothing.
|
|
396
|
+
*
|
|
397
|
+
* `Record<keyof …, string>` rather than `as const`: literal types here make
|
|
398
|
+
* every override a type error, and `Required<StatusLabels>` keeps the
|
|
399
|
+
* `| undefined` that `exactOptionalPropertyTypes` needs on the props.
|
|
400
|
+
*/
|
|
401
|
+
const DEFAULT_STATUS_LABELS = {
|
|
402
|
+
hint: "Start typing to search the documentation.",
|
|
403
|
+
shortQuery: "Keep typing — {min} characters or more.",
|
|
404
|
+
loading: "Loading the search index…",
|
|
405
|
+
error: "Search is unavailable right now. Try reloading the page.",
|
|
406
|
+
empty: "No results for “{query}”."
|
|
407
|
+
};
|
|
408
|
+
const DEFAULT_RESULT_COUNT_LABELS = {
|
|
409
|
+
one: "{count} result",
|
|
410
|
+
other: "{count} results"
|
|
411
|
+
};
|
|
412
|
+
/**
|
|
413
|
+
* The announcement for `count` hits, in the document's own language.
|
|
414
|
+
*
|
|
415
|
+
* `Intl.PluralRules` rather than an `=== 1` check: Polish takes four plural
|
|
416
|
+
* forms and Arabic six, and a package that ships an English singular/plural pair
|
|
417
|
+
* announces a wrong number of results — correctly, and confidently — in most of
|
|
418
|
+
* the world. An unlisted category falls back to `other`, which is the one every
|
|
419
|
+
* language has.
|
|
420
|
+
*/
|
|
421
|
+
function announceCount(count, labels, locale) {
|
|
422
|
+
const tag = locale ?? (typeof document === "undefined" ? "" : document.documentElement.lang) ?? "";
|
|
423
|
+
let category = "other";
|
|
424
|
+
try {
|
|
425
|
+
category = new Intl.PluralRules(tag === "" ? "en" : tag).select(count);
|
|
426
|
+
} catch {}
|
|
427
|
+
return (labels[category] ?? labels.other ?? "{count}").replace("{count}", String(count));
|
|
428
|
+
}
|
|
385
429
|
/** Loading, failure and empty states, plus a live region for hit counts. */
|
|
386
|
-
function SearchStatus({ status, query, hitCount, minQueryLength }) {
|
|
430
|
+
function SearchStatus({ status, query, hitCount, minQueryLength, labels, resultCountLabels, locale }) {
|
|
387
431
|
let message = null;
|
|
388
432
|
let modifier = "";
|
|
389
433
|
if (status === "error") {
|
|
390
|
-
message =
|
|
434
|
+
message = labels.error ?? DEFAULT_STATUS_LABELS.error;
|
|
391
435
|
modifier = " wave-docs-search-status-error";
|
|
392
436
|
} else if (query === "") {
|
|
393
|
-
message =
|
|
437
|
+
message = labels.hint ?? DEFAULT_STATUS_LABELS.hint;
|
|
394
438
|
modifier = " wave-docs-search-status-hint";
|
|
395
439
|
} else if (query.length < minQueryLength) {
|
|
396
|
-
message =
|
|
440
|
+
message = (labels.shortQuery ?? DEFAULT_STATUS_LABELS.shortQuery).replace("{min}", String(minQueryLength));
|
|
397
441
|
modifier = " wave-docs-search-status-hint";
|
|
398
442
|
} else if (status !== "ready") {
|
|
399
|
-
message =
|
|
443
|
+
message = labels.loading ?? DEFAULT_STATUS_LABELS.loading;
|
|
400
444
|
modifier = " wave-docs-search-status-loading";
|
|
401
445
|
} else if (hitCount === 0) {
|
|
402
|
-
message =
|
|
446
|
+
message = (labels.empty ?? DEFAULT_STATUS_LABELS.empty).replace("{query}", query);
|
|
403
447
|
modifier = " wave-docs-search-status-empty";
|
|
404
448
|
}
|
|
405
449
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
|
|
@@ -409,7 +453,7 @@ function SearchStatus({ status, query, hitCount, minQueryLength }) {
|
|
|
409
453
|
className: "wave-docs-search-announcer",
|
|
410
454
|
role: "status",
|
|
411
455
|
"aria-live": "polite",
|
|
412
|
-
children: query === "" || status !== "ready" ? "" :
|
|
456
|
+
children: query === "" || status !== "ready" ? "" : announceCount(hitCount, resultCountLabels ?? DEFAULT_RESULT_COUNT_LABELS, locale)
|
|
413
457
|
})] });
|
|
414
458
|
}
|
|
415
459
|
/**
|
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
//#region src/react/shell-labels.d.ts
|
|
2
2
|
/**
|
|
3
|
-
* Every user-visible string
|
|
3
|
+
* Every user-visible string this package renders that is not the reader's
|
|
4
|
+
* content.
|
|
4
5
|
*
|
|
5
|
-
* ⚠️ THERE
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* ⚠️ THERE USED TO BE FOUR, AND THE DOCSTRING CLAIMED THEY WERE ALL OF THEM.
|
|
7
|
+
* They were the four `docs.Layout` renders directly, and the claim — "the whole
|
|
8
|
+
* of what a non-English site has to say" — was false by seventeen strings. A
|
|
9
|
+
* German site built exactly the documented way shipped
|
|
10
|
+
* `<nav aria-label="On this page">`, a visible `Back to top`, `aria-label="Tip"`
|
|
11
|
+
* on every callout, `Copy code` on every fence and `(opens in a new tab)` after
|
|
12
|
+
* every external link. Verified in this repository's own `site/out`, which is
|
|
13
|
+
* how it was found.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* ## Where each one is rendered, because it decides the cost
|
|
16
|
+
*
|
|
17
|
+
* Most are emitted by Server Components or baked into the HTML by a rehype
|
|
18
|
+
* plugin at build time, so overriding them costs nothing at all. Six cross into
|
|
19
|
+
* a Client Component — the two sidebar disclosure verbs, the two table-of-contents
|
|
20
|
+
* strings and the two copy-status messages — and those are forwarded ONLY when
|
|
21
|
+
* set, so a site that overrides nothing carries exactly the payload it did
|
|
22
|
+
* before.
|
|
23
|
+
*
|
|
24
|
+
* ## The defaults do not all live here
|
|
25
|
+
*
|
|
26
|
+
* {@link DEFAULT_DOCS_LABELS} covers the four shell strings and no more, on
|
|
27
|
+
* purpose. The rest default inside the component that renders them, because
|
|
28
|
+
* this module would otherwise have to be imported by `sidebar.tsx` and
|
|
29
|
+
* `code-runtime.tsx` — and an object literal of twenty-one strings does not
|
|
30
|
+
* tree-shake, so every reader would download the German site's English
|
|
31
|
+
* fallbacks. `next.test.ts` asserts that every key here reaches the output, so
|
|
32
|
+
* a key that is declared and never wired fails rather than reading as
|
|
33
|
+
* configuration.
|
|
15
34
|
*
|
|
16
35
|
* Not here: the search dialog's strings, which are reachable through
|
|
17
|
-
* `search={{ … }}
|
|
18
|
-
*
|
|
19
|
-
*
|
|
36
|
+
* `search={{ … }}` on `docs.Layout` — its trigger, its placeholder, its
|
|
37
|
+
* accessible name and its five state messages, plus the plural forms of its
|
|
38
|
+
* live region. They travel with the dialog's own props rather than with these
|
|
39
|
+
* because that channel already existed and already carries `pageSize` and
|
|
40
|
+
* `minQueryLength`; a second route to the same component would be two places to
|
|
41
|
+
* look. `DocsSidebar`'s own `label` is likewise public API, for anyone composing
|
|
42
|
+
* a shell by hand.
|
|
20
43
|
*/
|
|
21
44
|
interface DocsLabels {
|
|
22
45
|
/** The navigation landmark's accessible name. Default `'Documentation'`. */
|
|
@@ -27,17 +50,108 @@ interface DocsLabels {
|
|
|
27
50
|
closeNav?: string | undefined;
|
|
28
51
|
/** The skip link's visible text. Default `'Skip to content'`. */
|
|
29
52
|
skipToContent?: string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Accessible name of a collapsed group's toggle. Default `'Expand {title}'`.
|
|
55
|
+
*
|
|
56
|
+
* `{title}` is replaced with the group's own name. A placeholder rather than a
|
|
57
|
+
* function because this crosses from a Server Component to a Client one, where
|
|
58
|
+
* a function cannot go.
|
|
59
|
+
*/
|
|
60
|
+
expandGroup?: string | undefined;
|
|
61
|
+
/** The same toggle when open. Default `'Collapse {title}'`. */
|
|
62
|
+
collapseGroup?: string | undefined;
|
|
63
|
+
/** The TOC landmark's accessible name. Default `'On this page'`. */
|
|
64
|
+
toc?: string | undefined;
|
|
65
|
+
/** The link at the end of the TOC. Default `'Back to top'`. */
|
|
66
|
+
backToTop?: string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Screen-reader suffix on a link that opens a new tab.
|
|
69
|
+
* Default `'(opens in a new tab)'`.
|
|
70
|
+
*/
|
|
71
|
+
externalLink?: string | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Accessible name of a wide table's scroll region. Default `'Table'`.
|
|
74
|
+
*
|
|
75
|
+
* The region exists so a keyboard user can scroll a table that overflows; an
|
|
76
|
+
* unnamed one is announced as "region", which says nothing about what it
|
|
77
|
+
* holds.
|
|
78
|
+
*/
|
|
79
|
+
table?: string | undefined;
|
|
80
|
+
/** Heading on a `> [!NOTE]` callout. Default `'Note'`. */
|
|
81
|
+
calloutNote?: string | undefined;
|
|
82
|
+
/** Heading on a `> [!TIP]` callout. Default `'Tip'`. */
|
|
83
|
+
calloutTip?: string | undefined;
|
|
84
|
+
/** Heading on a `> [!IMPORTANT]` callout. Default `'Important'`. */
|
|
85
|
+
calloutImportant?: string | undefined;
|
|
86
|
+
/** Heading on a `> [!WARNING]` callout. Default `'Warning'`. */
|
|
87
|
+
calloutWarning?: string | undefined;
|
|
88
|
+
/** Heading on a `> [!CAUTION]` callout. Default `'Caution'`. */
|
|
89
|
+
calloutCaution?: string | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Accessible name of a YouTube embed. Default `'YouTube video player'`.
|
|
92
|
+
*
|
|
93
|
+
* Markdown carries no video title, so this is the name every embed on the site
|
|
94
|
+
* gets unless a `<YouTube title>` overrides it.
|
|
95
|
+
*/
|
|
96
|
+
youtubeTitle?: string | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* The closed facade's control. Default `'Play video: {title}'`.
|
|
99
|
+
*
|
|
100
|
+
* `{title}` is the embed's accessible name.
|
|
101
|
+
*/
|
|
102
|
+
youtubePlay?: string | undefined;
|
|
103
|
+
/** The open facade's control. Default `'Hide video: {title}'`. */
|
|
104
|
+
youtubeHide?: string | undefined;
|
|
105
|
+
/** The copy button on a fence with no title. Default `'Copy code'`. */
|
|
106
|
+
copyCode?: string | undefined;
|
|
107
|
+
/**
|
|
108
|
+
* The copy button on a titled fence. Default `'Copy code from {title}'`.
|
|
109
|
+
*
|
|
110
|
+
* `{title}` is the fence's own `title="…"`. Two controls both called "Copy
|
|
111
|
+
* code" are indistinguishable in a screen reader's element list, which is why
|
|
112
|
+
* the titled form exists at all.
|
|
113
|
+
*/
|
|
114
|
+
copyCodeFrom?: string | undefined;
|
|
115
|
+
/** Announced after a successful copy. Default `'Copied to the clipboard.'` */
|
|
116
|
+
copied?: string | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* Announced after a failed one. Default
|
|
119
|
+
* `'Copy failed. Select the code and press Control or Command + C.'`
|
|
120
|
+
*
|
|
121
|
+
* Says what to do instead, not merely that it failed: the common way to land
|
|
122
|
+
* here is `next dev` on a phone over plain HTTP, where there is no secure
|
|
123
|
+
* context and retrying cannot help.
|
|
124
|
+
*/
|
|
125
|
+
copyFailed?: string | undefined;
|
|
30
126
|
}
|
|
31
127
|
/**
|
|
32
|
-
*
|
|
128
|
+
* Every key of {@link DocsLabels}, for the test that proves each one is wired.
|
|
33
129
|
*
|
|
34
|
-
* A
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `'Documentation'` — two names for one region, depending on the viewport.
|
|
130
|
+
* A list rather than a type-level trick because the assertion has to run: the
|
|
131
|
+
* failure being guarded against is a key that is declared, documented and never
|
|
132
|
+
* read, which type-checks perfectly.
|
|
38
133
|
*/
|
|
39
|
-
declare const
|
|
134
|
+
declare const DOCS_LABEL_KEYS: readonly ["nav", "openNav", "closeNav", "skipToContent", "expandGroup", "collapseGroup", "toc", "backToTop", "externalLink", "table", "calloutNote", "calloutTip", "calloutImportant", "calloutWarning", "calloutCaution", "youtubeTitle", "youtubePlay", "youtubeHide", "copyCode", "copyCodeFrom", "copied", "copyFailed"];
|
|
135
|
+
/** The four the shell renders itself, resolved centrally. */
|
|
136
|
+
type ShellLabelKey = 'nav' | 'openNav' | 'closeNav' | 'skipToContent';
|
|
137
|
+
/**
|
|
138
|
+
* Defaults for the shell's four, in one place.
|
|
139
|
+
*
|
|
140
|
+
* A `Required<…>` rather than four `=` defaults spread across three components:
|
|
141
|
+
* the previous arrangement is how `DocsSidebar` came to default its landmark to
|
|
142
|
+
* `'Docs'` while `DocsNav` defaulted the same landmark to `'Documentation'` —
|
|
143
|
+
* two names for one region, depending on the viewport.
|
|
144
|
+
*/
|
|
145
|
+
declare const DEFAULT_DOCS_LABELS: Required<Pick<DocsLabels, ShellLabelKey>>;
|
|
40
146
|
/** The given labels over the defaults, with `undefined` treated as unset. */
|
|
41
|
-
declare function resolveLabels(labels: DocsLabels | undefined): Required<DocsLabels
|
|
147
|
+
declare function resolveLabels(labels: DocsLabels | undefined): Required<Pick<DocsLabels, ShellLabelKey>>;
|
|
148
|
+
/**
|
|
149
|
+
* `template` with `{title}` replaced.
|
|
150
|
+
*
|
|
151
|
+
* A placeholder, because these strings cross a Server → Client boundary where a
|
|
152
|
+
* function cannot go — and because a translator needs to move the name within
|
|
153
|
+
* the sentence, which string concatenation does not allow.
|
|
154
|
+
*/
|
|
155
|
+
declare function fillTitle(template: string, title: string): string;
|
|
42
156
|
//#endregion
|
|
43
|
-
export { DEFAULT_DOCS_LABELS, DocsLabels, resolveLabels };
|
|
157
|
+
export { DEFAULT_DOCS_LABELS, DOCS_LABEL_KEYS, DocsLabels, ShellLabelKey, fillTitle, resolveLabels };
|
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
//#region src/react/shell-labels.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Every key of {@link DocsLabels}, for the test that proves each one is wired.
|
|
4
4
|
*
|
|
5
|
-
* A
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
5
|
+
* A list rather than a type-level trick because the assertion has to run: the
|
|
6
|
+
* failure being guarded against is a key that is declared, documented and never
|
|
7
|
+
* read, which type-checks perfectly.
|
|
8
|
+
*/
|
|
9
|
+
const DOCS_LABEL_KEYS = [
|
|
10
|
+
"nav",
|
|
11
|
+
"openNav",
|
|
12
|
+
"closeNav",
|
|
13
|
+
"skipToContent",
|
|
14
|
+
"expandGroup",
|
|
15
|
+
"collapseGroup",
|
|
16
|
+
"toc",
|
|
17
|
+
"backToTop",
|
|
18
|
+
"externalLink",
|
|
19
|
+
"table",
|
|
20
|
+
"calloutNote",
|
|
21
|
+
"calloutTip",
|
|
22
|
+
"calloutImportant",
|
|
23
|
+
"calloutWarning",
|
|
24
|
+
"calloutCaution",
|
|
25
|
+
"youtubeTitle",
|
|
26
|
+
"youtubePlay",
|
|
27
|
+
"youtubeHide",
|
|
28
|
+
"copyCode",
|
|
29
|
+
"copyCodeFrom",
|
|
30
|
+
"copied",
|
|
31
|
+
"copyFailed"
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Defaults for the shell's four, in one place.
|
|
35
|
+
*
|
|
36
|
+
* A `Required<…>` rather than four `=` defaults spread across three components:
|
|
37
|
+
* the previous arrangement is how `DocsSidebar` came to default its landmark to
|
|
38
|
+
* `'Docs'` while `DocsNav` defaulted the same landmark to `'Documentation'` —
|
|
39
|
+
* two names for one region, depending on the viewport.
|
|
9
40
|
*/
|
|
10
41
|
const DEFAULT_DOCS_LABELS = {
|
|
11
42
|
nav: "Documentation",
|
|
@@ -23,5 +54,15 @@ function resolveLabels(labels) {
|
|
|
23
54
|
skipToContent: labels.skipToContent ?? DEFAULT_DOCS_LABELS.skipToContent
|
|
24
55
|
};
|
|
25
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* `template` with `{title}` replaced.
|
|
59
|
+
*
|
|
60
|
+
* A placeholder, because these strings cross a Server → Client boundary where a
|
|
61
|
+
* function cannot go — and because a translator needs to move the name within
|
|
62
|
+
* the sentence, which string concatenation does not allow.
|
|
63
|
+
*/
|
|
64
|
+
function fillTitle(template, title) {
|
|
65
|
+
return template.replace("{title}", title);
|
|
66
|
+
}
|
|
26
67
|
//#endregion
|
|
27
|
-
export { DEFAULT_DOCS_LABELS, resolveLabels };
|
|
68
|
+
export { DEFAULT_DOCS_LABELS, DOCS_LABEL_KEYS, fillTitle, resolveLabels };
|
package/dist/react/sidebar.d.ts
CHANGED
|
@@ -15,6 +15,23 @@ interface DocsSidebarProps {
|
|
|
15
15
|
Link?: DocsLinkComponent | undefined;
|
|
16
16
|
/** Accessible name for the landmark. Distinguish multiple navs on a page. */
|
|
17
17
|
label?: string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* A collapsed group's toggle. Default `'Expand {title}'`.
|
|
20
|
+
*
|
|
21
|
+
* `{title}` is replaced with the group's own name. A placeholder rather than
|
|
22
|
+
* a function, because `docs.Layout` sets this from a Server Component and a
|
|
23
|
+
* function cannot cross that boundary — and because a translator has to be
|
|
24
|
+
* able to move the name within the sentence.
|
|
25
|
+
*/
|
|
26
|
+
expandGroup?: string | undefined;
|
|
27
|
+
/** The same toggle when open. Default `'Collapse {title}'`. */
|
|
28
|
+
collapseGroup?: string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Screen-reader suffix on an external link. Default `'(opens in a new tab)'`.
|
|
31
|
+
*
|
|
32
|
+
* The separating space is markup, so this is the sentence and nothing else.
|
|
33
|
+
*/
|
|
34
|
+
externalLink?: string | undefined;
|
|
18
35
|
className?: string | undefined;
|
|
19
36
|
}
|
|
20
37
|
/**
|
|
@@ -59,6 +76,6 @@ interface DocsSidebarProps {
|
|
|
59
76
|
* nothing prefetches locally whatever this says. Do not "fix" it back because
|
|
60
77
|
* the network tab looks the same.
|
|
61
78
|
*/
|
|
62
|
-
declare function DocsSidebar({ nav, pathname, Link, label, className }: DocsSidebarProps): ReactNode;
|
|
79
|
+
declare function DocsSidebar({ nav, pathname, Link, label, expandGroup, collapseGroup, externalLink, className }: DocsSidebarProps): ReactNode;
|
|
63
80
|
//#endregion
|
|
64
81
|
export { DocsSidebar, DocsSidebarProps };
|
package/dist/react/sidebar.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { nearestScrollTop } from "./nearest-scroll-top.js";
|
|
3
|
-
import { useId, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
4
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
//#region src/react/sidebar.tsx
|
|
6
|
+
const DEFAULT_SIDEBAR_LABELS = {
|
|
7
|
+
expandGroup: "Expand {title}",
|
|
8
|
+
collapseGroup: "Collapse {title}",
|
|
9
|
+
externalLink: "(opens in a new tab)"
|
|
10
|
+
};
|
|
6
11
|
/** Trailing slashes are a routing detail, not a difference in identity. */
|
|
7
12
|
function normalizeHref(href) {
|
|
8
13
|
return href.length > 1 ? href.replace(/\/+$/, "") : href;
|
|
@@ -61,7 +66,12 @@ function containsActive(node, pathname) {
|
|
|
61
66
|
* nothing prefetches locally whatever this says. Do not "fix" it back because
|
|
62
67
|
* the network tab looks the same.
|
|
63
68
|
*/
|
|
64
|
-
function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
69
|
+
function DocsSidebar({ nav, pathname, Link, label = "Docs", expandGroup, collapseGroup, externalLink, className }) {
|
|
70
|
+
const text = {
|
|
71
|
+
expandGroup: expandGroup ?? DEFAULT_SIDEBAR_LABELS.expandGroup,
|
|
72
|
+
collapseGroup: collapseGroup ?? DEFAULT_SIDEBAR_LABELS.collapseGroup,
|
|
73
|
+
externalLink: externalLink ?? DEFAULT_SIDEBAR_LABELS.externalLink
|
|
74
|
+
};
|
|
65
75
|
const baseId = useId();
|
|
66
76
|
const [toggled, setToggled] = useState({});
|
|
67
77
|
const lastPathname = useRef(pathname);
|
|
@@ -77,19 +87,19 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
|
77
87
|
};
|
|
78
88
|
const navRef = useRef(null);
|
|
79
89
|
useLayoutEffect(() => {
|
|
80
|
-
|
|
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;
|
|
90
|
+
revealActive(navRef.current);
|
|
92
91
|
}, [pathname]);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const dialog = navRef.current?.closest("dialog");
|
|
94
|
+
if (!(dialog instanceof HTMLDialogElement)) return;
|
|
95
|
+
const onToggle = () => {
|
|
96
|
+
if (dialog.open) revealActive(navRef.current);
|
|
97
|
+
};
|
|
98
|
+
dialog.addEventListener("toggle", onToggle);
|
|
99
|
+
return () => {
|
|
100
|
+
dialog.removeEventListener("toggle", onToggle);
|
|
101
|
+
};
|
|
102
|
+
}, []);
|
|
93
103
|
return /* @__PURE__ */ jsx("nav", {
|
|
94
104
|
ref: navRef,
|
|
95
105
|
"aria-label": label,
|
|
@@ -101,7 +111,8 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
|
101
111
|
pathname,
|
|
102
112
|
Link,
|
|
103
113
|
toggled,
|
|
104
|
-
onToggle: handleToggle
|
|
114
|
+
onToggle: handleToggle,
|
|
115
|
+
text
|
|
105
116
|
})
|
|
106
117
|
});
|
|
107
118
|
}
|
|
@@ -115,6 +126,28 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
|
115
126
|
* one navigation where they know precisely what they asked for.
|
|
116
127
|
* `sidebar.test.tsx` spies on it and asserts it is never called.
|
|
117
128
|
*/
|
|
129
|
+
/**
|
|
130
|
+
* Scroll the item marked `aria-current="page"` into view, if it is not already.
|
|
131
|
+
*
|
|
132
|
+
* A no-op wherever there is nothing to measure — no nav, no active item, no
|
|
133
|
+
* scrollport — which is every server render, every jsdom render, and every
|
|
134
|
+
* layout where the column is shorter than its content. Nothing to do, and
|
|
135
|
+
* nothing to do wrongly.
|
|
136
|
+
*/
|
|
137
|
+
function revealActive(nav) {
|
|
138
|
+
const active = nav?.querySelector("[aria-current=\"page\"]");
|
|
139
|
+
if (!(active instanceof HTMLElement)) return;
|
|
140
|
+
const port = scrollableAncestor(active);
|
|
141
|
+
if (port === null) return;
|
|
142
|
+
const next = nearestScrollTop({
|
|
143
|
+
itemTop: active.getBoundingClientRect().top - port.getBoundingClientRect().top + port.scrollTop,
|
|
144
|
+
itemHeight: active.offsetHeight,
|
|
145
|
+
viewHeight: port.clientHeight,
|
|
146
|
+
scrollTop: port.scrollTop,
|
|
147
|
+
scrollHeight: port.scrollHeight
|
|
148
|
+
});
|
|
149
|
+
if (next !== void 0) port.scrollTop = next;
|
|
150
|
+
}
|
|
118
151
|
function scrollableAncestor(element) {
|
|
119
152
|
let current = element.parentElement;
|
|
120
153
|
while (current !== null) {
|
|
@@ -124,7 +157,7 @@ function scrollableAncestor(element) {
|
|
|
124
157
|
}
|
|
125
158
|
return null;
|
|
126
159
|
}
|
|
127
|
-
function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, id }) {
|
|
160
|
+
function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, text, id }) {
|
|
128
161
|
const holdsActive = nodes.some((node) => (node.type === "page" || node.type === "link" && !node.external) && isActiveHref(pathname, node.href));
|
|
129
162
|
return /* @__PURE__ */ jsx("ul", {
|
|
130
163
|
id,
|
|
@@ -148,6 +181,7 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
|
|
|
148
181
|
isActive: !node.external && isActiveHref(pathname, node.href),
|
|
149
182
|
isNearby: holdsActive,
|
|
150
183
|
Link,
|
|
184
|
+
externalLink: text.externalLink,
|
|
151
185
|
children: node.title
|
|
152
186
|
})
|
|
153
187
|
}, key);
|
|
@@ -169,14 +203,15 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
|
|
|
169
203
|
pathname,
|
|
170
204
|
Link,
|
|
171
205
|
toggled,
|
|
172
|
-
onToggle
|
|
206
|
+
onToggle,
|
|
207
|
+
text
|
|
173
208
|
}, key);
|
|
174
209
|
default: return null;
|
|
175
210
|
}
|
|
176
211
|
})
|
|
177
212
|
});
|
|
178
213
|
}
|
|
179
|
-
function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
|
|
214
|
+
function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle, text }) {
|
|
180
215
|
const listId = `${itemKey}-list`;
|
|
181
216
|
const hasActive = containsActive(node, pathname);
|
|
182
217
|
const isOpen = toggled[itemKey] ?? hasActive;
|
|
@@ -208,7 +243,7 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
|
|
|
208
243
|
className: "wave-docs-sidebar__group-toggle",
|
|
209
244
|
"aria-expanded": isOpen,
|
|
210
245
|
"aria-controls": isOpen ? listId : void 0,
|
|
211
|
-
"aria-label":
|
|
246
|
+
"aria-label": (isOpen ? text.collapseGroup : text.expandGroup).replace("{title}", node.title),
|
|
212
247
|
onClick: () => onToggle(itemKey, !isOpen),
|
|
213
248
|
children: /* @__PURE__ */ jsx(Chevron, { isOpen })
|
|
214
249
|
})] })
|
|
@@ -220,11 +255,12 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
|
|
|
220
255
|
pathname,
|
|
221
256
|
Link,
|
|
222
257
|
toggled,
|
|
223
|
-
onToggle
|
|
258
|
+
onToggle,
|
|
259
|
+
text
|
|
224
260
|
}) : null]
|
|
225
261
|
});
|
|
226
262
|
}
|
|
227
|
-
function NavLink({ href, isExternal, isActive, isNearby = false, Link, children }) {
|
|
263
|
+
function NavLink({ href, isExternal, isActive, isNearby = false, Link, externalLink = DEFAULT_SIDEBAR_LABELS.externalLink, children }) {
|
|
228
264
|
const className = "wave-docs-sidebar__link";
|
|
229
265
|
if (isExternal) return /* @__PURE__ */ jsxs("a", {
|
|
230
266
|
className,
|
|
@@ -247,9 +283,9 @@ function NavLink({ href, isExternal, isActive, isNearby = false, Link, children
|
|
|
247
283
|
strokeLinejoin: "round",
|
|
248
284
|
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
285
|
}),
|
|
250
|
-
/* @__PURE__ */
|
|
286
|
+
/* @__PURE__ */ jsxs("span", {
|
|
251
287
|
className: "wave-docs-sr-only",
|
|
252
|
-
children: "
|
|
288
|
+
children: [" ", externalLink]
|
|
253
289
|
})
|
|
254
290
|
]
|
|
255
291
|
});
|