@waveso/docs 0.6.0 → 0.7.1

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/dist/next.d.ts CHANGED
@@ -96,27 +96,25 @@ interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter>
96
96
  /**
97
97
  * Props for {@link DocsRoute.Layout}.
98
98
  *
99
- * Four, and the fourth is a boolean. Everything else a docs shell is asked for
100
- * turned out to be reachable already: an announcement banner renders *above*
101
- * `<docs.Layout>` in your own `layout.tsx`, because this does not own `<body>`;
102
- * a content footer goes inside `children`; and sidebar links, social icons and
103
- * separators are `DocNavNode`s authored in `meta.json`. The header bar is the
104
- * one region nothing else can reach, which is what `actions` is for.
99
+ * Three, and one of them is `children`. Everything else a docs shell is asked
100
+ * for turned out to be reachable already: an announcement banner renders
101
+ * *above* `<docs.Layout>` in your own `layout.tsx`, because this does not own
102
+ * `<body>`; a content footer goes inside `children`; and sidebar links, social
103
+ * icons and separators are `DocNavNode`s authored in `meta.json`. A theme
104
+ * toggle and a repository link go in the layout you write around this one — the
105
+ * host wraps `docs.Layout` exactly as it already wraps `<html>` and `<body>`,
106
+ * so there is no region only this package can reach.
105
107
  *
106
- * A `slots` map was the alternative, and it can still be added later — two node
107
- * props can become a slots map, a slots map cannot become two props.
108
+ * The one region a host cannot reach through `docs.Layout` is *inside* the
109
+ * sidebar, and the exported primitives are the answer for that: `DocsSidebar`,
110
+ * `DocsToc`, `DocContent` and `SkipLink` compose into a layout of your own.
111
+ *
112
+ * A `slots` map was the alternative, and shipping none is the reversible half —
113
+ * a map can be added the day something needs one, a map that shipped cannot be
114
+ * taken back.
108
115
  */
109
116
  interface DocsLayoutProps {
110
117
  children: ReactNode;
111
- /**
112
- * Brand at the header start. A string, or your own logo component.
113
- *
114
- * `ReactNode`, so it cannot also serve as the `<title>` or as the header's
115
- * accessible name; the landmark carries a fixed label instead.
116
- */
117
- title?: ReactNode;
118
- /** Header end, after search: a theme toggle, a version switcher, a link. */
119
- actions?: ReactNode;
120
118
  /**
121
119
  * The search trigger. Defaults to on, and the URL is always derived.
122
120
  *
@@ -318,24 +316,33 @@ interface DocsRoute<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
318
316
  * export default docs.Layout;
319
317
  * ```
320
318
  *
321
- * Or, with your own chrome in the header:
319
+ * Or, with your own chrome *around* it — the same layout file `<html>` and
320
+ * `<body>` live in, and `SiteHeader` is yours:
322
321
  *
323
322
  * ```tsx
324
323
  * export default function DocsLayout({ children }: { children: ReactNode }) {
325
324
  * return (
326
- * <docs.Layout title={<Logo />} actions={<ThemeToggle />}>
327
- * {children}
328
- * </docs.Layout>
325
+ * <>
326
+ * <SiteHeader />
327
+ * <docs.Layout search={{ placeholder: 'Search the docs' }}>
328
+ * {children}
329
+ * </docs.Layout>
330
+ * </>
329
331
  * );
330
332
  * }
331
333
  * ```
332
334
  *
333
- * It owns the skip link, the header, the sidebar column, the mobile drawer
334
- * and the grid, and it reads `source.nav()` and `searchIndexUrl` itself — so
335
- * there is no nav to fetch and no URL to pass. It does **not** own the table
336
- * of contents: a Next layout receives `{children, params}` and cannot know
337
- * which page is rendering, so `docs.Page` emits the TOC as its second child
338
- * and the grid places it.
335
+ * If that header of yours is sticky, say how tall it is once —
336
+ * `--wave-docs-chrome-offset: 4rem` and our sticky columns start below it.
337
+ *
338
+ * It owns the skip link, the sidebar one shell at every width, holding the
339
+ * navigation and the 44px strip that moves it — the search trigger and the
340
+ * grid.
341
+ * It reads `source.nav()` and `searchIndexUrl` itself, so there is no nav to
342
+ * fetch and no URL to pass. It does **not** own the table of contents: a Next
343
+ * layout receives `{children, params}` and cannot know which page is
344
+ * rendering, so `docs.Page` emits the TOC as its second child and the grid
345
+ * places it.
339
346
  *
340
347
  * Your `layout.tsx` stays a Server Component. The two pieces that need a
341
348
  * client — the nav's `usePathname`, the search dialog — carry their own
package/dist/next.js CHANGED
@@ -6,6 +6,7 @@ import { mapPooled } from "./map-pooled.js";
6
6
  import { findFunctionValuedOptions } from "./search-options.js";
7
7
  import { createMarkdownComponents } from "./react/markdown-components.js";
8
8
  import { DocContent } from "./react/doc-content.js";
9
+ import { DocsHero } from "./react/hero.js";
9
10
  import { DocsToc } from "./react/toc.js";
10
11
  import { wrapNextLink } from "./react/link-adapter.js";
11
12
  import { createDocsRenderer } from "./render.js";
@@ -172,11 +173,15 @@ async function buildNextComponents(labels) {
172
173
  const [linkMod, imageMod] = await Promise.all([importNext(() => import("next/link"), "next/link"), importNext(() => import("next/image"), "next/image")]);
173
174
  const NextLink = readDefaultExport(linkMod, "next/link");
174
175
  const NextImage = readDefaultExport(imageMod, "next/image");
175
- return createMarkdownComponents({
176
- Link: wrapNextLink(NextLink),
177
- Image: wrapNextImage(NextImage),
178
- ...labels === void 0 ? {} : { labels }
179
- });
176
+ const link = wrapNextLink(NextLink);
177
+ return {
178
+ components: createMarkdownComponents({
179
+ Link: link,
180
+ Image: wrapNextImage(NextImage),
181
+ ...labels === void 0 ? {} : { labels }
182
+ }),
183
+ link
184
+ };
180
185
  }
181
186
  /**
182
187
  * The named subset of `labels`, or `undefined` when none of it is set.
@@ -220,7 +225,7 @@ function pickLabels(labels, map) {
220
225
  */
221
226
  function serializableSearchOptions(candidate) {
222
227
  const functions = findFunctionValuedOptions(candidate);
223
- if (functions.length > 0) throw docsError("invalid-config", `the search dialog cannot be given MiniSearch functions from a server component: ${functions.map((name) => `\`miniSearchOptions.${name}\``).join(", ")}. \`docs.Layout\` renders the dialog as a client component, so its props are serialised on the way across and React rejects a function with "Functions cannot be passed directly to Client Components" while prerendering. Keep the function on \`createDocsRoute\` so the index is still built with it, pass \`search={false}\` to \`docs.Layout\`, and render the dialog yourself from a \`'use client'\` module that imports the same function — \`<DocsSearch indexUrl={docs.searchIndexUrl} miniSearchOptions={{ processTerm }} />\` putting that component in \`actions\`. Serialisable overrides (\`storeFields\`, \`boost\`, \`searchOptions.fuzzy\`) need none of this and are forwarded as before.`);
228
+ if (functions.length > 0) throw docsError("invalid-config", `the search dialog cannot be given MiniSearch functions from a server component: ${functions.map((name) => `\`miniSearchOptions.${name}\``).join(", ")}. \`docs.Layout\` renders the dialog as a client component, so its props are serialised on the way across and React rejects a function with "Functions cannot be passed directly to Client Components" while prerendering. Keep the function on \`createDocsRoute\` so the index is still built with it, pass \`search={false}\` to \`docs.Layout\`, and render the dialog yourself from a \`'use client'\` module that imports the same function — \`<DocsSearch indexUrl={docs.searchIndexUrl} miniSearchOptions={{ processTerm }} />\` in your own layout. Serialisable overrides (\`storeFields\`, \`boost\`, \`searchOptions.fuzzy\`) need none of this and are forwarded as before.`);
224
229
  return candidate;
225
230
  }
226
231
  /**
@@ -425,12 +430,18 @@ function createDocsRoute(options) {
425
430
  async function renderRoute(segments) {
426
431
  const doc = await getPage(segments);
427
432
  if (doc === void 0) return (await loadNotFound())();
428
- const components = await loadComponents();
433
+ const { components, link } = await loadComponents();
429
434
  return createElement(Fragment, null, createElement("main", {
430
435
  className: "wave-docs-layout__main",
431
436
  id: DOCS_CONTENT_ID,
432
437
  tabIndex: -1
433
- }, createElement(DocContent, {
438
+ }, (doc.frontmatter.actions?.length ?? 0) === 0 ? null : createElement(DocsHero, {
439
+ title: doc.frontmatter.title,
440
+ ...doc.frontmatter.description === void 0 ? {} : { description: doc.frontmatter.description },
441
+ ...doc.frontmatter.actions === void 0 ? {} : { actions: doc.frontmatter.actions },
442
+ Link: link,
443
+ ...routeLabels?.externalLink === void 0 ? {} : { externalLabel: routeLabels.externalLink }
444
+ }), createElement(DocContent, {
434
445
  hast: doc.hast,
435
446
  components: {
436
447
  ...components,
@@ -457,7 +468,7 @@ function createDocsRoute(options) {
457
468
  async IndexPage() {
458
469
  return renderRoute([]);
459
470
  },
460
- async Layout({ children, title, actions, search, labels }) {
471
+ async Layout({ children, search, labels }) {
461
472
  const { DocsLayoutShell } = await import("./react/layout.js");
462
473
  const host = search === true || search === void 0 || search === false ? void 0 : search;
463
474
  const requestedOptions = host?.miniSearchOptions ?? options.miniSearchOptions;
@@ -474,8 +485,6 @@ function createDocsRoute(options) {
474
485
  nav: await requestScopedSource.nav(),
475
486
  searchIndexUrl,
476
487
  search: searchProps,
477
- ...title === void 0 ? {} : { title },
478
- ...actions === void 0 ? {} : { actions },
479
488
  ...shellLabels === void 0 ? {} : { labels: shellLabels }
480
489
  });
481
490
  },
@@ -0,0 +1,19 @@
1
+ import { DocAction } from "../types.js";
2
+ import { DocsLinkComponent } from "./markdown-components.js";
3
+ import { ReactNode } from "react";
4
+ //#region src/react/hero.d.ts
5
+ interface DocsHeroProps {
6
+ /** `frontmatter.title`, rendered as the page's `<h1>`. */
7
+ title: string;
8
+ /** `frontmatter.description`. Omitted rather than rendered empty. */
9
+ description?: string | undefined;
10
+ /** `frontmatter.actions`. An empty list renders no `<nav>` at all. */
11
+ actions?: readonly DocAction[] | undefined;
12
+ /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
13
+ Link?: DocsLinkComponent | undefined;
14
+ /** Screen-reader suffix on a link that opens elsewhere. */
15
+ externalLabel?: string | undefined;
16
+ }
17
+ declare function DocsHero({ title, description, actions, Link, externalLabel }: DocsHeroProps): ReactNode;
18
+ //#endregion
19
+ export { DocsHero, DocsHeroProps };
@@ -0,0 +1,44 @@
1
+ import { opensInNewTab } from "../safe-href.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/react/hero.tsx
4
+ function DocsHero({ title, description, actions, Link, externalLabel = "(opens in a new tab)" }) {
5
+ const Anchor = Link ?? "a";
6
+ return /* @__PURE__ */ jsx("header", {
7
+ className: "wave-docs-hero",
8
+ children: /* @__PURE__ */ jsxs("div", {
9
+ className: "wave-docs-hero__body",
10
+ children: [
11
+ /* @__PURE__ */ jsx("h1", {
12
+ className: "wave-docs-hero__title",
13
+ children: title
14
+ }),
15
+ description === void 0 || description === "" ? null : /* @__PURE__ */ jsx("p", {
16
+ className: "wave-docs-hero__tagline",
17
+ children: description
18
+ }),
19
+ actions === void 0 || actions.length === 0 ? null : /* @__PURE__ */ jsx("div", {
20
+ className: "wave-docs-hero__actions",
21
+ children: actions.map((action, index) => {
22
+ const external = opensInNewTab(action.href);
23
+ const variant = action.variant ?? (index === 0 ? "primary" : "secondary");
24
+ return /* @__PURE__ */ jsxs(external ? "a" : Anchor, {
25
+ href: action.href,
26
+ className: "wave-docs-hero__action",
27
+ "data-variant": variant,
28
+ ...external ? {
29
+ target: "_blank",
30
+ rel: "noreferrer"
31
+ } : {},
32
+ children: [action.label, external ? /* @__PURE__ */ jsx("span", {
33
+ className: "wave-docs-sr-only",
34
+ children: ` ${externalLabel}`
35
+ }) : null]
36
+ }, action.href);
37
+ })
38
+ })
39
+ ]
40
+ })
41
+ });
42
+ }
43
+ //#endregion
44
+ export { DocsHero };
@@ -27,8 +27,6 @@ interface DocsLayoutShellProps {
27
27
  children: ReactNode;
28
28
  nav: DocNavNode[];
29
29
  searchIndexUrl: string;
30
- title?: ReactNode;
31
- actions?: ReactNode;
32
30
  /**
33
31
  * `false` to omit the trigger; an object to configure it.
34
32
  *
@@ -56,6 +54,6 @@ interface DocsLayoutShellProps {
56
54
  */
57
55
  labels?: DocsLabels | undefined;
58
56
  }
59
- declare function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search, labels }: DocsLayoutShellProps): ReactNode;
57
+ declare function DocsLayoutShell({ children, nav, searchIndexUrl, search, labels }: DocsLayoutShellProps): ReactNode;
60
58
  //#endregion
61
59
  export { DocsLayoutSearchProps, DocsLayoutShell, DocsLayoutShellProps };
@@ -1,68 +1,31 @@
1
1
  import { DocsSearch } from "./next-search.js";
2
2
  import { resolveLabels } from "./shell-labels.js";
3
- import { DOCS_NAV_ID } from "./nav.js";
4
3
  import { DocsNextNav } from "./next-nav.js";
5
4
  import { SkipLink } from "./skip-link.js";
6
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
6
  //#region src/react/layout.tsx
8
- function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search = true, labels }) {
7
+ function DocsLayoutShell({ children, nav, searchIndexUrl, search = true, labels }) {
9
8
  const text = resolveLabels(labels);
10
- return /* @__PURE__ */ jsxs(Fragment, { children: [
11
- /* @__PURE__ */ jsx(SkipLink, { children: text.skipToContent }),
12
- /* @__PURE__ */ jsx("header", {
13
- className: "wave-docs-layout__header",
14
- children: /* @__PURE__ */ jsxs("div", {
15
- className: "wave-docs-layout__header-inner",
16
- children: [
17
- /* @__PURE__ */ jsx("button", {
18
- type: "button",
19
- className: "wave-docs-layout__nav-trigger",
20
- "aria-label": text.openNav,
21
- command: "show-modal",
22
- commandfor: DOCS_NAV_ID,
23
- children: /* @__PURE__ */ jsx("svg", {
24
- "aria-hidden": "true",
25
- viewBox: "0 0 16 16",
26
- width: "18",
27
- height: "18",
28
- fill: "none",
29
- stroke: "currentColor",
30
- strokeWidth: "1.5",
31
- strokeLinecap: "round",
32
- children: /* @__PURE__ */ jsx("path", { d: "M2.5 4h11M2.5 8h11M2.5 12h11" })
33
- })
34
- }),
35
- title === void 0 ? null : /* @__PURE__ */ jsx("div", {
36
- className: "wave-docs-layout__title",
37
- children: title
38
- }),
39
- search === false ? null : /* @__PURE__ */ jsx(DocsSearch, {
40
- indexUrl: searchIndexUrl,
41
- ...search === true ? {} : search,
42
- className: ["wave-docs-layout__search", search === true ? void 0 : search?.className].filter(Boolean).join(" ")
43
- }),
44
- actions === void 0 ? null : /* @__PURE__ */ jsx("div", {
45
- className: "wave-docs-layout__actions",
46
- children: actions
47
- })
48
- ]
49
- })
50
- }),
51
- /* @__PURE__ */ jsxs("div", {
9
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(SkipLink, { children: text.skipToContent }), /* @__PURE__ */ jsx("div", {
10
+ className: "wave-docs-shell",
11
+ children: /* @__PURE__ */ jsxs("div", {
52
12
  className: "wave-docs-layout",
53
- children: [/* @__PURE__ */ jsx("div", {
54
- className: "wave-docs-layout__sidebar",
55
- children: /* @__PURE__ */ jsx(DocsNextNav, {
56
- nav,
57
- label: text.nav,
58
- closeLabel: text.closeNav,
59
- ...labels?.expandGroup === void 0 ? {} : { expandGroup: labels.expandGroup },
60
- ...labels?.collapseGroup === void 0 ? {} : { collapseGroup: labels.collapseGroup },
61
- ...labels?.externalLink === void 0 ? {} : { externalLink: labels.externalLink }
13
+ children: [/* @__PURE__ */ jsx(DocsNextNav, {
14
+ nav,
15
+ label: text.nav,
16
+ closeLabel: text.closeNav,
17
+ openLabel: text.openNav,
18
+ ...labels?.expandGroup === void 0 ? {} : { expandGroup: labels.expandGroup },
19
+ ...labels?.collapseGroup === void 0 ? {} : { collapseGroup: labels.collapseGroup },
20
+ ...labels?.externalLink === void 0 ? {} : { externalLink: labels.externalLink },
21
+ children: search === false ? null : /* @__PURE__ */ jsx(DocsSearch, {
22
+ indexUrl: searchIndexUrl,
23
+ ...search === true ? {} : search,
24
+ className: ["wave-docs-layout__search", search === true ? void 0 : search?.className].filter(Boolean).join(" ")
62
25
  })
63
26
  }), children]
64
27
  })
65
- ] });
28
+ })] });
66
29
  }
67
30
  //#endregion
68
31
  export { DocsLayoutShell };
@@ -2,14 +2,7 @@ import { DocNavNode } from "../types.js";
2
2
  import { DocsLinkComponent } from "./markdown-components.js";
3
3
  import { ReactNode } from "react";
4
4
  //#region src/react/nav.d.ts
5
- /**
6
- * The drawer's `id`, and the header trigger's `commandfor`.
7
- *
8
- * A constant rather than a `useId`, for two reasons that both matter: the
9
- * trigger is rendered on the server in a different subtree and cannot see a
10
- * hook's value, and `command`/`commandfor` must agree before React hydrates or
11
- * the button does nothing on the first tap.
12
- */
5
+ /** The navigation's `id`, and the trigger's `aria-controls`. */
13
6
  declare const DOCS_NAV_ID = "wave-docs-nav";
14
7
  interface DocsNavProps {
15
8
  /** The tree from `docs.source.nav()`. */
@@ -18,15 +11,22 @@ interface DocsNavProps {
18
11
  pathname: string;
19
12
  /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
20
13
  Link?: DocsLinkComponent | undefined;
21
- /** Accessible name for the nav landmark and the drawer. */
14
+ /** Accessible name for the nav landmark. */
22
15
  label?: string | undefined;
23
- /** Accessible name for the close button. */
16
+ /** Accessible name for the trigger while the sidebar is open. */
24
17
  closeLabel?: string | undefined;
18
+ /** Accessible name for the trigger while the sidebar is closed. */
19
+ openLabel?: string | undefined;
20
+ /**
21
+ * Rendered above the tree. The search trigger goes here — it belongs to the
22
+ * navigation, so it moves with it and is never a second thing to place.
23
+ */
24
+ children?: ReactNode;
25
25
  /** Passed through to the tree. See `DocsSidebarProps.expandGroup`. */
26
26
  expandGroup?: string | undefined;
27
27
  collapseGroup?: string | undefined;
28
28
  externalLink?: string | undefined;
29
29
  }
30
- declare function DocsNav({ nav, pathname, Link, label, closeLabel, expandGroup, collapseGroup, externalLink }: DocsNavProps): ReactNode;
30
+ declare function DocsNav({ nav, pathname, Link, label, children, closeLabel, openLabel, expandGroup, collapseGroup, externalLink }: DocsNavProps): ReactNode;
31
31
  //#endregion
32
32
  export { DOCS_NAV_ID, DocsNav, DocsNavProps };
package/dist/react/nav.js CHANGED
@@ -1,73 +1,121 @@
1
1
  "use client";
2
2
  import { DocsSidebar } from "./sidebar.js";
3
- import { useEffect, useRef } from "react";
4
- import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/react/nav.tsx
6
- /**
7
- * The drawer's `id`, and the header trigger's `commandfor`.
8
- *
9
- * A constant rather than a `useId`, for two reasons that both matter: the
10
- * trigger is rendered on the server in a different subtree and cannot see a
11
- * hook's value, and `command`/`commandfor` must agree before React hydrates or
12
- * the button does nothing on the first tap.
13
- */
6
+ /** The navigation's `id`, and the trigger's `aria-controls`. */
14
7
  const DOCS_NAV_ID = "wave-docs-nav";
15
- function DocsNav({ nav, pathname, Link, label = "Documentation", closeLabel = "Close navigation", expandGroup, collapseGroup, externalLink }) {
16
- const ref = useRef(null);
8
+ function DocsNav({ nav, pathname, Link, label = "Documentation", children, closeLabel = "Close navigation", openLabel = "Open navigation", expandGroup, collapseGroup, externalLink }) {
9
+ const shellRef = useRef(null);
10
+ const navRef = useRef(null);
11
+ const returnFocusRef = useRef(null);
12
+ /**
13
+ * ⚠️ THREE STATES, AND THE THIRD ONE IS WHY THERE IS NO FLASH.
14
+ *
15
+ * `null` is "nobody has chosen yet". The server renders it, no `data-state`
16
+ * reaches the DOM, and the stylesheet decides per mode — closed where the
17
+ * navigation would cover the article, open where it would sit beside it. So
18
+ * the first paint is already right at both shapes, with no JavaScript and
19
+ * nothing to correct afterwards.
20
+ *
21
+ * A boolean would have to be picked before the container's width is known,
22
+ * which is either a flash on a phone or a flash on a desktop.
23
+ */
24
+ const [choice, setChoice] = useState(null);
25
+ const [mode, setMode] = useState(null);
26
+ const [ready, setReady] = useState(false);
27
+ const open = choice ?? mode === "push";
28
+ const covering = mode === "cover" && open;
17
29
  useEffect(() => {
18
- ref.current?.close?.();
30
+ const shell = shellRef.current;
31
+ const layout = shell?.parentElement;
32
+ if (shell == null || layout == null) return;
33
+ const read = () => {
34
+ const value = getComputedStyle(shell).getPropertyValue("--wave-docs-sidebar-mode").trim();
35
+ setMode(value === "push" ? "push" : "cover");
36
+ };
37
+ read();
38
+ const frame = requestAnimationFrame(() => {
39
+ setReady(true);
40
+ });
41
+ const observer = new ResizeObserver(read);
42
+ observer.observe(layout);
43
+ return () => {
44
+ cancelAnimationFrame(frame);
45
+ observer.disconnect();
46
+ };
47
+ }, []);
48
+ const close = useCallback(() => {
49
+ setChoice(false);
50
+ }, []);
51
+ useEffect(() => {
52
+ if (covering) setChoice(false);
19
53
  }, [pathname]);
20
54
  useEffect(() => {
21
- if ("command" in HTMLButtonElement.prototype) return;
22
- const onClick = (event) => {
23
- const target = event.target;
24
- if (!(target instanceof Element)) return;
25
- const button = target.closest("button[commandfor]");
26
- if (button === null) return;
27
- if (button.getAttribute("commandfor") !== "wave-docs-nav") return;
28
- const dialog = ref.current;
29
- if (dialog === null) return;
30
- if (button.getAttribute("command") === "close") dialog.close();
31
- else dialog.showModal();
55
+ if (!covering) return;
56
+ const onKeyDown = (event) => {
57
+ if (event.key !== "Escape") return;
58
+ event.stopPropagation();
59
+ close();
32
60
  };
33
- document.addEventListener("click", onClick);
61
+ document.addEventListener("keydown", onKeyDown);
34
62
  return () => {
35
- document.removeEventListener("click", onClick);
63
+ document.removeEventListener("keydown", onKeyDown);
36
64
  };
37
- }, []);
38
- return /* @__PURE__ */ jsxs("dialog", {
39
- ref,
40
- id: DOCS_NAV_ID,
41
- className: "wave-docs-layout__drawer",
42
- closedby: "any",
43
- "aria-label": label,
44
- children: [/* @__PURE__ */ jsx("button", {
65
+ }, [covering, close]);
66
+ useEffect(() => {
67
+ const shell = shellRef.current;
68
+ const layout = shell?.parentElement;
69
+ if (shell == null || layout == null) return;
70
+ const outside = [...layout.children].filter((child) => child !== shell && !child.classList.contains(SCRIM_CLASS));
71
+ if (!covering) {
72
+ for (const element of outside) element.removeAttribute("inert");
73
+ return;
74
+ }
75
+ returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
76
+ for (const element of outside) element.setAttribute("inert", "");
77
+ navRef.current?.focus();
78
+ return () => {
79
+ for (const element of outside) element.removeAttribute("inert");
80
+ returnFocusRef.current?.focus({ preventScroll: true });
81
+ };
82
+ }, [covering]);
83
+ const state = choice === null && mode === null ? void 0 : open;
84
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
85
+ ref: shellRef,
86
+ className: "wave-docs-layout__sidebar",
87
+ ...state === void 0 ? {} : { "data-state": state ? "open" : "closed" },
88
+ ...ready ? { "data-ready": "" } : {},
89
+ children: [/* @__PURE__ */ jsxs("div", {
90
+ ref: navRef,
91
+ id: DOCS_NAV_ID,
92
+ className: "wave-docs-layout__sidebar-nav",
93
+ tabIndex: -1,
94
+ children: [children, /* @__PURE__ */ jsx(DocsSidebar, {
95
+ nav,
96
+ pathname,
97
+ label,
98
+ Link,
99
+ ...expandGroup === void 0 ? {} : { expandGroup },
100
+ ...collapseGroup === void 0 ? {} : { collapseGroup },
101
+ ...externalLink === void 0 ? {} : { externalLink }
102
+ })]
103
+ }), /* @__PURE__ */ jsx("button", {
45
104
  type: "button",
46
- className: "wave-docs-layout__drawer-close",
47
- "aria-label": closeLabel,
48
- command: "close",
49
- commandfor: DOCS_NAV_ID,
50
- children: /* @__PURE__ */ jsx("svg", {
51
- "aria-hidden": "true",
52
- viewBox: "0 0 16 16",
53
- width: "16",
54
- height: "16",
55
- fill: "none",
56
- stroke: "currentColor",
57
- strokeWidth: "1.5",
58
- strokeLinecap: "round",
59
- children: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" })
60
- })
61
- }), /* @__PURE__ */ jsx(DocsSidebar, {
62
- nav,
63
- pathname,
64
- label,
65
- Link,
66
- ...expandGroup === void 0 ? {} : { expandGroup },
67
- ...collapseGroup === void 0 ? {} : { collapseGroup },
68
- ...externalLink === void 0 ? {} : { externalLink }
105
+ className: "wave-docs-layout__sidebar-trigger",
106
+ "aria-controls": DOCS_NAV_ID,
107
+ "aria-label": open ? closeLabel : openLabel,
108
+ ...state === void 0 ? {} : { "aria-expanded": state },
109
+ onClick: () => {
110
+ setChoice(!open);
111
+ }
69
112
  })]
70
- });
113
+ }), /* @__PURE__ */ jsx("div", {
114
+ className: SCRIM_CLASS,
115
+ "aria-hidden": "true",
116
+ onClick: close
117
+ })] });
71
118
  }
119
+ const SCRIM_CLASS = "wave-docs-layout__sidebar-scrim";
72
120
  //#endregion
73
121
  export { DOCS_NAV_ID, DocsNav };
@@ -5,11 +5,15 @@ interface DocsNextNavProps {
5
5
  nav: DocNavNode[];
6
6
  label?: string | undefined;
7
7
  closeLabel?: string | undefined;
8
+ /** Accessible name for the trigger while the sidebar is closed. */
9
+ openLabel?: string | undefined;
10
+ /** Rendered above the tree. See `DocsNavProps.children`. */
11
+ children?: ReactNode;
8
12
  /** Passed through to the tree. See `DocsSidebarProps.expandGroup`. */
9
13
  expandGroup?: string | undefined;
10
14
  collapseGroup?: string | undefined;
11
15
  externalLink?: string | undefined;
12
16
  }
13
- declare function DocsNextNav({ nav, label, closeLabel, expandGroup, collapseGroup, externalLink }: DocsNextNavProps): ReactNode;
17
+ declare function DocsNextNav({ nav, label, closeLabel, openLabel, children, expandGroup, collapseGroup, externalLink }: DocsNextNavProps): ReactNode;
14
18
  //#endregion
15
19
  export { DocsNextNav, DocsNextNavProps };
@@ -19,16 +19,18 @@ import { usePathname } from "next/navigation";
19
19
  */
20
20
  /** Module scope: a fresh identity here remounts every nav link on every render. */
21
21
  const Link = wrapNextLink(NextLink);
22
- function DocsNextNav({ nav, label, closeLabel, expandGroup, collapseGroup, externalLink }) {
22
+ function DocsNextNav({ nav, label, closeLabel, openLabel, children, expandGroup, collapseGroup, externalLink }) {
23
23
  return /* @__PURE__ */ jsx(DocsNav, {
24
24
  nav,
25
25
  pathname: usePathname(),
26
26
  Link,
27
27
  ...label === void 0 ? {} : { label },
28
28
  ...closeLabel === void 0 ? {} : { closeLabel },
29
+ ...openLabel === void 0 ? {} : { openLabel },
29
30
  ...expandGroup === void 0 ? {} : { expandGroup },
30
31
  ...collapseGroup === void 0 ? {} : { collapseGroup },
31
- ...externalLink === void 0 ? {} : { externalLink }
32
+ ...externalLink === void 0 ? {} : { externalLink },
33
+ children
32
34
  });
33
35
  }
34
36
  //#endregion
@@ -44,9 +44,15 @@
44
44
  interface DocsLabels {
45
45
  /** The navigation landmark's accessible name. Default `'Documentation'`. */
46
46
  nav?: string | undefined;
47
- /** The header button that opens the drawer. Default `'Open navigation'`. */
47
+ /**
48
+ * The sidebar's trigger, named for what pressing it does next. One button
49
+ * carries both strings: `openNav` while the sidebar is closed, `closeNav`
50
+ * while it is open.
51
+ *
52
+ * Default `'Open navigation'`.
53
+ */
48
54
  openNav?: string | undefined;
49
- /** The button that closes the drawer. Default `'Close navigation'`. */
55
+ /** The same button, while the sidebar is open. Default `'Close navigation'`. */
50
56
  closeNav?: string | undefined;
51
57
  /** The skip link's visible text. Default `'Skip to content'`. */
52
58
  skipToContent?: string | undefined;
@@ -8,9 +8,19 @@ interface DocsTocProps {
8
8
  label?: string | undefined;
9
9
  /**
10
10
  * Region of the viewport that counts as "current", as an
11
- * `IntersectionObserver` root margin. The default reserves 80px for a sticky
12
- * header and ignores the bottom 60% of the screen, so the active entry
13
- * tracks what you are reading rather than what has scrolled into view.
11
+ * `IntersectionObserver` root margin. The default ignores the bottom 60% of
12
+ * the screen, so the active entry tracks what you are reading rather than
13
+ * what has scrolled into view.
14
+ *
15
+ * ⚠️ IT USED TO RESERVE 80px FOR A STICKY HEADER, AND TWO THINGS WERE WRONG
16
+ * WITH THAT. There is no header — above 80rem, where this component
17
+ * is rendered at all, nothing of ours overlays the content. And 80px never
18
+ * matched the header anyway: the token was `3.5rem`, which is 56px, so the
19
+ * figure was never derived from the thing it claimed to reserve for.
20
+ *
21
+ * This component is exported standalone, so its default cannot assume our
22
+ * shell. It assumes nothing overlays the content; a host whose own chrome
23
+ * does sets this prop, which is what the prop is for.
14
24
  *
15
25
  * Only `px` and `%` are legal here — `IntersectionObserver` throws on any
16
26
  * other unit, `rem` included.