From 0757f75a16f1df485a2f242b67f1238255db607a Mon Sep 17 00:00:00 2001
From: Praffi <167605652+praffiii@users.noreply.github.com>
Date: Tue, 28 Jul 2026 16:14:43 +0700
Subject: [PATCH] fix(documentation): make mobile sidebar dismissible
---
.../components/layout/Sidebar-mobile.test.ts | 133 ++++++++++++++++++
.../src/components/layout/Sidebar-mobile.ts | 107 ++++++++++++++
.../src/components/layout/Sidebar.scss | 16 ++-
.../src/components/layout/Sidebar.tsx | 50 ++-----
.../src/components/layout/SiteFooter.tsx | 3 +-
5 files changed, 271 insertions(+), 38 deletions(-)
create mode 100644 packages/typescriptlang-org/src/components/layout/Sidebar-mobile.test.ts
create mode 100644 packages/typescriptlang-org/src/components/layout/Sidebar-mobile.ts
diff --git a/packages/typescriptlang-org/src/components/layout/Sidebar-mobile.test.ts b/packages/typescriptlang-org/src/components/layout/Sidebar-mobile.test.ts
new file mode 100644
index 000000000000..bbf3c5719f24
--- /dev/null
+++ b/packages/typescriptlang-org/src/components/layout/Sidebar-mobile.test.ts
@@ -0,0 +1,133 @@
+/** @jest-environment jsdom */
+
+import { setupMobileSidebar } from "./Sidebar-mobile"
+
+const renderSidebar = (backgroundInert = false) => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+}
+
+const setMobileViewport = (matches: boolean) => {
+ const listeners = new Set<() => void>()
+ const media = {
+ matches,
+ addEventListener: (_event: string, listener: () => void) => listeners.add(listener),
+ removeEventListener: (_event: string, listener: () => void) => listeners.delete(listener),
+ }
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: jest.fn(() => media),
+ })
+ return { media, listeners }
+}
+
+describe("mobile documentation sidebar", () => {
+ afterEach(() => {
+ document.body.innerHTML = ""
+ document.body.className = ""
+ })
+
+ it("opens and closes from the trigger while exposing its state", () => {
+ renderSidebar()
+ setMobileViewport(true)
+ const cleanup = setupMobileSidebar()
+ const sidebar = document.getElementById("sidebar")!
+ const toggle = document.getElementById("small-device-button-sidebar")!
+ const backdrop = document.getElementById("sidebar-backdrop")!
+ const background = document.getElementById("background")!
+
+ expect(sidebar.hasAttribute("inert")).toBe(true)
+
+ toggle.click()
+
+ expect(sidebar.classList.contains("show")).toBe(true)
+ expect(sidebar.hasAttribute("inert")).toBe(false)
+ expect(toggle.getAttribute("aria-controls")).toBe("sidebar")
+ expect(toggle.getAttribute("aria-expanded")).toBe("true")
+ expect(toggle.getAttribute("aria-label")).toBe("Close sidebar navigation")
+ expect(backdrop.hidden).toBe(false)
+ expect(background.hasAttribute("inert")).toBe(true)
+ expect(document.body.classList.contains("mobile-sidebar-open")).toBe(true)
+
+ toggle.click()
+
+ expect(sidebar.classList.contains("show")).toBe(false)
+ expect(toggle.getAttribute("aria-expanded")).toBe("false")
+ expect(toggle.getAttribute("aria-label")).toBe("Open sidebar navigation")
+ expect(backdrop.hidden).toBe(true)
+ expect(background.hasAttribute("inert")).toBe(false)
+ expect(document.body.classList.contains("mobile-sidebar-open")).toBe(false)
+ expect(document.activeElement).toBe(toggle)
+ cleanup()
+ })
+
+ it("dismisses from the backdrop, Escape, and destination links only", () => {
+ renderSidebar()
+ setMobileViewport(true)
+ const cleanup = setupMobileSidebar()
+ const sidebar = document.getElementById("sidebar")!
+ const toggle = document.getElementById("small-device-button-sidebar")!
+ const backdrop = document.getElementById("sidebar-backdrop")!
+
+ toggle.click()
+ document.getElementById("section-toggle")!.click()
+ expect(sidebar.classList.contains("show")).toBe(true)
+
+ backdrop.click()
+ expect(sidebar.classList.contains("show")).toBe(false)
+
+ toggle.click()
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))
+ expect(sidebar.classList.contains("show")).toBe(false)
+ expect(document.activeElement).toBe(toggle)
+
+ toggle.click()
+ document.querySelector("#sidebar a")!.click()
+ expect(sidebar.classList.contains("show")).toBe(false)
+ cleanup()
+ })
+
+ it("leaves desktop navigation and pre-existing inert state unchanged", () => {
+ renderSidebar(true)
+ const { media, listeners } = setMobileViewport(false)
+ const cleanup = setupMobileSidebar()
+ const sidebar = document.getElementById("sidebar")!
+ const toggle = document.getElementById("small-device-button-sidebar")!
+ const background = document.getElementById("background")!
+
+ toggle.click()
+ expect(sidebar.classList.contains("show")).toBe(false)
+ expect(sidebar.hasAttribute("inert")).toBe(false)
+
+ media.matches = true
+ listeners.forEach(listener => listener())
+ expect(sidebar.hasAttribute("inert")).toBe(true)
+
+ toggle.click()
+ cleanup()
+ expect(background.hasAttribute("inert")).toBe(true)
+ expect(sidebar.hasAttribute("inert")).toBe(false)
+ })
+})
diff --git a/packages/typescriptlang-org/src/components/layout/Sidebar-mobile.ts b/packages/typescriptlang-org/src/components/layout/Sidebar-mobile.ts
new file mode 100644
index 000000000000..ef6108744c27
--- /dev/null
+++ b/packages/typescriptlang-org/src/components/layout/Sidebar-mobile.ts
@@ -0,0 +1,107 @@
+const mobileSidebarQuery = "(max-width: 800px)"
+const sidebarId = "sidebar"
+const toggleId = "small-device-button-sidebar"
+const backdropId = "sidebar-backdrop"
+const bodyOpenClass = "mobile-sidebar-open"
+
+const backgroundElementsMadeInert = new Set()
+
+type SidebarElements = {
+ sidebar: HTMLElement
+ toggle: HTMLButtonElement
+ backdrop: HTMLElement
+}
+
+const getSidebarElements = (): SidebarElements | undefined => {
+ const sidebar = document.getElementById(sidebarId)
+ const toggle = document.getElementById(toggleId)
+ const backdrop = document.getElementById(backdropId)
+
+ if (!(sidebar instanceof HTMLElement) || !(toggle instanceof HTMLButtonElement) || !(backdrop instanceof HTMLElement)) {
+ return undefined
+ }
+
+ return { sidebar, toggle, backdrop }
+}
+
+const setBackgroundInert = (elements: SidebarElements, inert: boolean) => {
+ if (!inert) {
+ backgroundElementsMadeInert.forEach(element => element.removeAttribute("inert"))
+ backgroundElementsMadeInert.clear()
+ return
+ }
+
+ const interactiveElements = [elements.sidebar, elements.toggle, elements.backdrop]
+ let current: HTMLElement | null = elements.sidebar
+
+ while (current?.parentElement) {
+ const parent: HTMLElement = current.parentElement
+ Array.from(parent.children).forEach(child => {
+ if (!(child instanceof HTMLElement)) return
+ if (interactiveElements.some(element => child === element || child.contains(element))) return
+ if (child.hasAttribute("inert")) return
+
+ child.setAttribute("inert", "")
+ backgroundElementsMadeInert.add(child)
+ })
+ current = parent
+ }
+}
+
+const setOpen = (elements: SidebarElements, open: boolean, restoreFocus = false) => {
+ elements.sidebar.classList.toggle("show", open)
+ elements.sidebar.toggleAttribute("inert", !open)
+ elements.toggle.setAttribute("aria-expanded", String(open))
+ elements.toggle.setAttribute("aria-label", `${open ? "Close" : "Open"} sidebar navigation`)
+ elements.backdrop.hidden = !open
+ document.body.classList.toggle(bodyOpenClass, open)
+ setBackgroundInert(elements, open)
+
+ if (!open && restoreFocus) elements.toggle.focus()
+}
+
+/** Connects the independently rendered mobile drawer controls without changing desktop navigation. */
+export const setupMobileSidebar = () => {
+ const elements = getSidebarElements()
+ if (!elements) return () => {}
+
+ const mediaQuery = window.matchMedia(mobileSidebarQuery)
+ const close = (restoreFocus = true) => setOpen(elements, false, restoreFocus)
+
+ const toggle = () => {
+ if (!mediaQuery.matches) return
+ setOpen(elements, !elements.sidebar.classList.contains("show"), true)
+ }
+ const dismissFromBackdrop = () => close()
+ const dismissFromKeyboard = (event: KeyboardEvent) => {
+ if (event.key !== "Escape" || !mediaQuery.matches || !elements.sidebar.classList.contains("show")) return
+ event.preventDefault()
+ close()
+ }
+ const dismissFromDestination = (event: MouseEvent) => {
+ if (!mediaQuery.matches || !elements.sidebar.classList.contains("show")) return
+ if (!(event.target instanceof Element) || !event.target.closest("a")) return
+ close()
+ }
+ const syncToViewport = () => {
+ close(false)
+ if (!mediaQuery.matches) elements.sidebar.removeAttribute("inert")
+ }
+
+ elements.toggle.addEventListener("click", toggle)
+ elements.backdrop.addEventListener("click", dismissFromBackdrop)
+ elements.sidebar.addEventListener("click", dismissFromDestination)
+ document.addEventListener("keydown", dismissFromKeyboard)
+ mediaQuery.addEventListener("change", syncToViewport)
+ syncToViewport()
+
+ return () => {
+ elements.toggle.removeEventListener("click", toggle)
+ elements.backdrop.removeEventListener("click", dismissFromBackdrop)
+ elements.sidebar.removeEventListener("click", dismissFromDestination)
+ document.removeEventListener("keydown", dismissFromKeyboard)
+ mediaQuery.removeEventListener("change", syncToViewport)
+ close(false)
+ elements.sidebar.removeAttribute("inert")
+ }
+}
diff --git a/packages/typescriptlang-org/src/components/layout/Sidebar.scss b/packages/typescriptlang-org/src/components/layout/Sidebar.scss
index 24ac4b986f24..c38abad8d89f 100644
--- a/packages/typescriptlang-org/src/components/layout/Sidebar.scss
+++ b/packages/typescriptlang-org/src/components/layout/Sidebar.scss
@@ -1,6 +1,7 @@
@import "../../style/globals.scss";
-#small-device-button-sidebar {
+#small-device-button-sidebar,
+#sidebar-backdrop {
display: none;
}
@@ -163,6 +164,18 @@ nav#sidebar {
}
@media (max-width: $screen-sm) {
+ body.mobile-sidebar-open {
+ overflow: hidden;
+ }
+
+ #sidebar-backdrop:not([hidden]) {
+ display: block;
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.4);
+ z-index: $z-index-for-handbook-nav - 1;
+ }
+
// This is a button which will scroll off and on with the nav
button#small-device-button-sidebar {
display: flex;
@@ -203,6 +216,7 @@ nav#sidebar {
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
+ overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
diff --git a/packages/typescriptlang-org/src/components/layout/Sidebar.tsx b/packages/typescriptlang-org/src/components/layout/Sidebar.tsx
index 59af224f8bd0..eb6534f5bb25 100644
--- a/packages/typescriptlang-org/src/components/layout/Sidebar.tsx
+++ b/packages/typescriptlang-org/src/components/layout/Sidebar.tsx
@@ -4,6 +4,7 @@ import { Link } from "gatsby"
import "./Sidebar.scss"
import { onAnchorKeyDown, onButtonKeydown } from "./Sidebar-keyboard"
import { SidebarNavItem } from "../../lib/documentationNavigationUtils"
+import { setupMobileSidebar } from "./Sidebar-mobile"
export type Props = {
navItems: SidebarNavItem[]
@@ -41,28 +42,20 @@ const toggleNavigationSection: MouseEventHandler = (event) => {
}
}
-export const SidebarToggleButton = () => {
- const toggleClick = () => {
- const navSidebar = document.getElementById("sidebar")
- const toggleButton = document.getElementById("small-device-button-sidebar")
- const isOpen = navSidebar?.classList.contains("show")
- if (isOpen) {
- navSidebar?.classList.remove("show")
- navSidebar?.setAttribute("inert", "")
- toggleButton?.focus()
- } else {
- navSidebar?.classList.add("show")
- navSidebar?.removeAttribute("inert")
- }
- }
-
-
- return (
-