From e4f7bfed18b5a9b5532cfbcab7f02df338e40545 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Thu, 2 Jul 2026 17:17:25 +0200 Subject: [PATCH] pkgs/nixos-render-docs: add sidebar-open and popover for mobile support --- doc/doc-support/package.nix | 2 + doc/nav.json | 3 + doc/style.css | 59 ++++++++++++++++++- .../src/nixos_render_docs/manual.py | 35 ++++++++++- .../src/tests/test_manual.py | 22 ++++--- 5 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 doc/nav.json diff --git a/doc/doc-support/package.nix b/doc/doc-support/package.nix index 466beb204425..f2c37df39132 100644 --- a/doc/doc-support/package.nix +++ b/doc/doc-support/package.nix @@ -73,6 +73,7 @@ stdenvNoCC.mkDerivation ( ../anchor.min.js ../manpage-urls.json ../redirects.json + ../nav.json ] ); }; @@ -117,6 +118,7 @@ stdenvNoCC.mkDerivation ( --script ./anchor.min.js \ --script ./anchor-use.js \ --sidebar-depth 3 \ + --nav ./nav.json \ manual.md \ out/index.html diff --git a/doc/nav.json b/doc/nav.json new file mode 100644 index 000000000000..15d4926421cf --- /dev/null +++ b/doc/nav.json @@ -0,0 +1,3 @@ +{ + "open": [] +} diff --git a/doc/style.css b/doc/style.css index 90517caf4948..ac052c81511c 100644 --- a/doc/style.css +++ b/doc/style.css @@ -131,6 +131,32 @@ body { padding-left: 1rem; padding-right: 1rem; } + + /* + See: https://developer.mozilla.org/en-US/docs/Web/API/Popover_API + + - :popover-open pseudo-class matches a popover element when it is in the showing state + - ::backdrop full-screen element placed directly behind popover + */ + nav.toc-sidebar:popover-open { + position: fixed; + inset: 0 auto 0 0; + width: min(20rem, 85vw); + height: 100dvh; + max-height: none; + margin: 0; + padding: 1rem; + overflow-y: auto; + overscroll-behavior: contain; + background: var(--background); + border: 0; + border-right: 0.0625rem solid #d8d8d8; + box-shadow: 0 0 1.5rem rgb(0 0 0 / 0.35); + } + + nav.toc-sidebar::backdrop { + background: rgb(0 0 0 / 0.5); + } } a { @@ -486,9 +512,25 @@ div.appendix .variablelist .term { nav.toc-sidebar { height: 100%; - overflow-y: none; padding: 0 1rem 2rem; - border-bottom: 0.0625rem solid #d8d8d8; +} + +/* menu button, shown on mobile, hidden on desktop */ +.toc-toggle { + display: inline-flex; + align-items: center; + margin: 0.75rem 0 0; + padding: 0.4rem 0.8rem; + border: 0.0625rem solid #d8d8d8; + border-radius: 0.25rem; + background: var(--background); + color: var(--main-text-color); + font: inherit; + cursor: pointer; + position: fixed; + top: 0.5rem; + left: 0.8rem; + z-index: 2; } nav.toc-sidebar .toc { @@ -533,14 +575,25 @@ nav.toc-sidebar summary { } nav.toc-sidebar { + /* un-pop the drawer */ + display: block; + position: static; + inset: auto; + /* */ + margin: 0; grid-column: 1; grid-row: 2; max-height: none; overflow-y: auto; - border-bottom: none; + border: none; border-right: 0.0625rem solid #d8d8d8; } + /* Hide the toggle button on desktop */ + .toc-toggle { + display: none; + } + main.content { grid-column: 1 / -1; grid-row: 2; diff --git a/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/manual.py b/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/manual.py index 902b365d8b5f..11d247320d68 100644 --- a/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/manual.py +++ b/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/manual.py @@ -264,6 +264,7 @@ class HTMLParameters(NamedTuple): # structural depth of the navigation sidebar tree sidebar_depth: int media_dir: Path + sidebar_open: frozenset[str] = frozenset() class ManualHTMLRenderer(RendererMixin, HTMLRenderer): _base_path: Path @@ -367,6 +368,20 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer): file.write(self._redirects.get_redirect_script(toc.target.path)) scripts.append(f'./{redirects_name}') + # Register a close handler + # Without this the popover can still be closed by clicking outside of it + # It handles auto-closing when the user clicks a href. + close_menu_js = """ + document.addEventListener("DOMContentLoaded", () => { + const nav = document.getElementById("manual-toc"); + nav?.addEventListener("click", (e) => { + if (e.target.closest("a[href]") && nav.matches(":popover-open")) { + nav.hidePopover(); + } + }); + }); + """ + return "\n".join([ '', '' for script in scripts)), + f"", f' ', f' ' if home.target.href() else "", f' {up_link}{prev_link}{next_link}', ' ', ' ', + # See: https://developer.mozilla.org/en-US/docs/Web/API/Popover_API + # Supported by most browsers since 2023, full support since Jan 2025 + (' ') if sidebar else "", nav_html, - f' ' if sidebar else "", + f' ' if sidebar else "", '
', ]) @@ -450,9 +471,10 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer): link = f'{e.target.toc_html}' cls = html.escape(e.kind, True) if children: + open_attr = " open" if e.target.id in self._html_params.sidebar_open else "" items.append( f'
  • ' - f'
    {link}{children}
    ' + f'{link}{children}' '
  • ' ) else: @@ -729,6 +751,9 @@ def _build_cli_html(p: argparse.ArgumentParser) -> None: p.add_argument('--media-dir', default="media", type=Path) p.add_argument('--redirects', type=Path) p.add_argument('--sidebar-depth', default=2, type=int) + # nav metadata (JSON): {"open": ["anchor-id", ...]} selects which sidebar + # entries render expanded; omitted or absent means everything is collapsed. + p.add_argument('--nav', type=Path) # Deprecated flags, p.add_argument('--toc-depth', nargs='?', action=_DeprecatedDepthFlag, default=None) p.add_argument('--chunk-toc-depth', nargs='?', action=_DeprecatedDepthFlag, default=None) @@ -738,6 +763,10 @@ def _build_cli_html(p: argparse.ArgumentParser) -> None: p.add_argument('outfile', type=Path) def _run_cli_html(args: argparse.Namespace) -> None: + sidebar_open: frozenset[str] = frozenset() + if args.nav: + with open(args.nav) as nav_file: + sidebar_open = frozenset(json.load(nav_file).get("open", [])) with open(args.manpage_urls) as manpage_urls, open(Path(__file__).parent / "redirects.js") as redirects_script: redirects = None if args.redirects: @@ -747,7 +776,7 @@ def _run_cli_html(args: argparse.Namespace) -> None: md = HTMLConverter( args.revision, HTMLParameters(args.generator, args.stylesheet, args.script, - args.sidebar_depth, args.media_dir), + args.sidebar_depth, args.media_dir, sidebar_open), json.load(manpage_urls), redirects) md.convert(args.infile, args.outfile) diff --git a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manual.py b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manual.py index 339bf58b2462..14f7024ce186 100644 --- a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manual.py +++ b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manual.py @@ -3,7 +3,7 @@ from pathlib import Path from nixos_render_docs.manual import HTMLConverter, HTMLParameters -def _build(tmp_path: Path, sidebar_depth: int = 2) -> str: +def _build(tmp_path: Path, sidebar_depth: int = 2, sidebar_open: frozenset[str] = frozenset()) -> str: (tmp_path / "part.md").write_text( "# Build helpers {#part-builders}\n\n" # -> h2 "```{=include=} chapters\nchapter.md\n```\n" @@ -29,7 +29,7 @@ def _build(tmp_path: Path, sidebar_depth: int = 2) -> str: out.mkdir(exist_ok=True) conv = HTMLConverter( "1.0.0", - HTMLParameters("test-gen", [], [], sidebar_depth, Path("media")), + HTMLParameters("test-gen", [], [], sidebar_depth, Path("media"), sidebar_open), {}, ) conv.convert(tmp_path / "index.md", out / "index.html") @@ -53,11 +53,12 @@ def test_single_h1_and_flat_heading_levels(tmp_path: Path) -> None: def test_sidebar_is_collapsible_tree(tmp_path: Path) -> None: html = _build(tmp_path) - assert '