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 = ` +
+
Home
+
+
+ + + +
+ +
+
+
+ +
+ ` +} + +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 ( - - ) -} +