diff --git a/docs/assets/javascripts/sidebar-toggle.js b/docs/assets/javascripts/sidebar-toggle.js new file mode 100644 index 00000000..9e12d149 --- /dev/null +++ b/docs/assets/javascripts/sidebar-toggle.js @@ -0,0 +1,101 @@ +(function () { + "use strict"; + + const storageKey = "dg-sidebar-collapsed"; + const desktopQuery = "(min-width: 1220px)"; + + function readStoredState(storage) { + if (!storage) return false; + try { + return storage.getItem(storageKey) === "true"; + } catch { + return false; + } + } + + function writeStoredState(storage, collapsed) { + if (!storage) return; + try { + storage.setItem(storageKey, collapsed ? "true" : "false"); + } catch { + // Ignore storage failures (private mode / strict policies). + } + } + + function getStorage(windowRef) { + try { + return windowRef.localStorage || null; + } catch { + return null; + } + } + + function applyState(root, button, storage, collapsed) { + root.dataset.dgSidebarCollapsed = collapsed ? "true" : "false"; + button.setAttribute("aria-expanded", collapsed ? "false" : "true"); + button.setAttribute("aria-label", "왼쪽 메뉴"); + writeStoredState(storage, collapsed); + } + + function mountSidebarToggle(documentRef, windowRef) { + if (!documentRef || !windowRef) return null; + const root = documentRef.documentElement; + if (!root || root.dataset.dgSidebarToggleReady === "true") return null; + const headerInner = documentRef.querySelector(".md-header__inner"); + if (!headerInner) return null; + + root.dataset.dgSidebarToggleReady = "true"; + + const button = documentRef.createElement("button"); + button.type = "button"; + button.className = "md-header__button md-icon dg-sidebar-toggle"; + button.setAttribute("aria-expanded", "true"); + button.setAttribute("aria-label", "왼쪽 메뉴"); + button.title = "왼쪽 메뉴 접기/펼치기"; + button.innerHTML = + ''; + + const mediaQuery = typeof windowRef.matchMedia === "function" + ? windowRef.matchMedia(desktopQuery) + : null; + const storage = getStorage(windowRef); + + const syncState = () => { + const collapsed = readStoredState(storage); + applyState(root, button, storage, collapsed); + }; + + button.addEventListener("click", () => { + const collapsed = root.dataset.dgSidebarCollapsed === "true"; + applyState(root, button, storage, !collapsed); + }); + + if (mediaQuery) { + const onMediaChange = event => { + if (event.matches) syncState(); + }; + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", onMediaChange); + } else if (typeof mediaQuery.addListener === "function") { + mediaQuery.addListener(onMediaChange); + } + } + + syncState(); + headerInner.insertBefore(button, headerInner.firstChild); + return button; + } + + if (typeof module !== "undefined" && module.exports) { + module.exports = {mountSidebarToggle, readStoredState, applyState, getStorage}; + } + + if (typeof document !== "undefined") { + const start = () => mountSidebarToggle(document, window); + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start); + } else { + start(); + } + } +})(); diff --git a/docs/assets/stylesheets/extra.css b/docs/assets/stylesheets/extra.css index 36dae0db..66e8a6b9 100644 --- a/docs/assets/stylesheets/extra.css +++ b/docs/assets/stylesheets/extra.css @@ -286,7 +286,37 @@ body { box-shadow: inset 2px 0 var(--dg-accent); } +.dg-sidebar-toggle { + display: none; +} + +@media (max-width: 1219px) { + .md-header .dg-sidebar-toggle { + display: none !important; + } +} + @media (min-width: 1220px) { + .md-header .dg-sidebar-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + } + + .dg-sidebar-toggle svg { + width: 1.1rem; + height: 1.1rem; + fill: currentColor; + } + + [data-dg-sidebar-collapsed="true"] .md-sidebar--primary { + display: none; + } + + [data-dg-sidebar-collapsed="true"] .dg-sidebar-toggle { + color: var(--dg-accent); + } + .md-nav--primary .md-nav[data-md-level="1"], .md-nav--primary .md-nav[data-md-level="2"] { border-left: 1px solid var(--dg-border); diff --git a/mkdocs.yml b/mkdocs.yml index 73df1196..ecf9af16 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,5 +82,6 @@ extra_css: extra_javascript: - assets/javascripts/explore.js + - assets/javascripts/sidebar-toggle.js copyright: Copyright © Azure Engineering Notes contributors diff --git a/tests/docs/sidebar-toggle.test.cjs b/tests/docs/sidebar-toggle.test.cjs new file mode 100644 index 00000000..f20c0e2e --- /dev/null +++ b/tests/docs/sidebar-toggle.test.cjs @@ -0,0 +1,114 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const toggle = require("../../docs/assets/javascripts/sidebar-toggle.js"); + +function createFixture(stored = "false") { + const root = {dataset: {}}; + const buttonListeners = {}; + const inserted = []; + + const button = { + attributes: {}, + type: "", + className: "", + title: "", + innerHTML: "", + setAttribute(name, value) { + this.attributes[name] = value; + }, + addEventListener(type, callback) { + buttonListeners[type] = callback; + }, + }; + + const headerInner = { + firstChild: null, + insertBefore(node) { + inserted.push(node); + }, + }; + + const localStorage = { + value: stored, + getItem() { + return this.value; + }, + setItem(_key, value) { + this.value = value; + }, + }; + + const media = { + matches: true, + addEventListener() {}, + }; + + const documentRef = { + documentElement: root, + querySelector(selector) { + return selector === ".md-header__inner" ? headerInner : null; + }, + createElement(tag) { + assert.equal(tag, "button"); + return button; + }, + }; + + const windowRef = { + localStorage, + matchMedia() { + return media; + }, + }; + + return { + root, + button, + buttonListeners, + inserted, + documentRef, + windowRef, + localStorage, + media, + }; +} + +test("mountSidebarToggle inserts a button and applies stored collapsed state", () => { + const fixture = createFixture("true"); + const mounted = toggle.mountSidebarToggle(fixture.documentRef, fixture.windowRef); + assert.equal(mounted, fixture.button); + assert.equal(fixture.inserted.length, 1); + assert.equal(fixture.root.dataset.dgSidebarCollapsed, "true"); + assert.equal(fixture.button.attributes["aria-expanded"], "false"); + assert.equal(fixture.button.attributes["aria-label"], "왼쪽 메뉴"); + assert.equal(fixture.button.attributes["aria-pressed"], undefined); + assert.equal(fixture.root.dataset.dgSidebarToggleReady, "true"); +}); + +test("button click toggles collapsed state and persists to storage", () => { + const fixture = createFixture("false"); + toggle.mountSidebarToggle(fixture.documentRef, fixture.windowRef); + fixture.buttonListeners.click(); + assert.equal(fixture.root.dataset.dgSidebarCollapsed, "true"); + assert.equal(fixture.button.attributes["aria-expanded"], "false"); + assert.equal(fixture.localStorage.value, "true"); + fixture.buttonListeners.click(); + assert.equal(fixture.root.dataset.dgSidebarCollapsed, "false"); + assert.equal(fixture.button.attributes["aria-expanded"], "true"); + assert.equal(fixture.localStorage.value, "false"); +}); + +test("blocked localStorage still mounts without persistence", () => { + const fixture = createFixture("true"); + Object.defineProperty(fixture.windowRef, "localStorage", { + get() { + throw new Error("storage blocked"); + }, + }); + const mounted = toggle.mountSidebarToggle(fixture.documentRef, fixture.windowRef); + assert.equal(mounted, fixture.button); + assert.equal(fixture.inserted.length, 1); + assert.equal(fixture.root.dataset.dgSidebarCollapsed, "false"); + fixture.buttonListeners.click(); + assert.equal(fixture.root.dataset.dgSidebarCollapsed, "true"); +}); diff --git a/tests/docs/test_faceted_discovery.py b/tests/docs/test_faceted_discovery.py index 549c95a1..df1565e1 100644 --- a/tests/docs/test_faceted_discovery.py +++ b/tests/docs/test_faceted_discovery.py @@ -261,11 +261,12 @@ def test_service_discovery_reports_topics_and_documents(taxonomy, catalog): assert "개 주제 · " in pages[PurePosixPath(path)] -def test_javascript_behavior_with_available_node(): +@pytest.mark.parametrize("script_name", ("explore.test.cjs", "sidebar-toggle.test.cjs")) +def test_javascript_behavior_with_available_node(script_name): node = shutil.which("node") if not node: pytest.skip("Node unavailable: parent browser verification is required") - result = subprocess.run([node, "--test", str(ROOT / "tests/docs/explore.test.cjs")], + result = subprocess.run([node, "--test", str(ROOT / "tests/docs" / script_name)], cwd=ROOT, capture_output=True, text=True) assert result.returncode == 0, result.stdout + result.stderr