Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/** @jest-environment jsdom */

import { setupMobileSidebar } from "./Sidebar-mobile"

const renderSidebar = (backgroundInert = false) => {
document.body.innerHTML = `
<div id="site-root">
<header><a href="/">Home</a></header>
<main>
<section id="doc-layout">
<button
id="small-device-button-sidebar"
aria-controls="sidebar"
aria-expanded="false"
aria-label="Open sidebar navigation"
>Toggle</button>
<div id="sidebar-backdrop" hidden></div>
<nav id="sidebar">
<button id="section-toggle">Section</button>
<a href="/docs/destination">Destination</a>
</nav>
<article id="background"${backgroundInert ? " inert" : ""}>
<button>Background action</button>
</article>
</section>
</main>
<footer><a href="/footer">Footer</a></footer>
</div>
`
}

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<HTMLAnchorElement>("#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)
})
})
107 changes: 107 additions & 0 deletions packages/typescriptlang-org/src/components/layout/Sidebar-mobile.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>()

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")
}
}
16 changes: 15 additions & 1 deletion packages/typescriptlang-org/src/components/layout/Sidebar.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@import "../../style/globals.scss";

#small-device-button-sidebar {
#small-device-button-sidebar,
#sidebar-backdrop {
display: none;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -203,6 +216,7 @@ nav#sidebar {
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
overscroll-behavior: contain;

-webkit-overflow-scrolling: touch;

Expand Down
50 changes: 14 additions & 36 deletions packages/typescriptlang-org/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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 (
<button id="small-device-button-sidebar" aria-label="Toggle sidebar navigation" onClick={toggleClick}>
export const SidebarToggleButton = () => (
<>
<button
id="small-device-button-sidebar"
type="button"
aria-controls="sidebar"
aria-expanded="false"
aria-label="Open sidebar navigation"
>
<svg fill="none" height="26" viewBox="0 0 26 26" width="26" xmlns="http://www.w3.org/2000/svg"><g fill="#fff"><path d="m0 1c0-.552285.447715-1 1-1h24c.5523 0 1 .447715 1 1v3h-26z" /><path d="m0 11h13 13v4h-26z" /><path d="m0 22h26v3c0 .5523-.4477 1-1 1h-24c-.552284 0-1-.4477-1-1z" /></g></svg>
</button>
)
}
<div id="sidebar-backdrop" aria-hidden="true" hidden />
</>
)

export const Sidebar = (props: Props) => {
useEffect(() => {
Expand Down Expand Up @@ -131,22 +124,7 @@ export const Sidebar = (props: Props) => {
}
}

useEffect(() => {
const sidebar = document.getElementById("sidebar")
if (!sidebar) return

const mq = window.matchMedia("(max-width: 800px)")
const sync = () => {
if (mq.matches && !sidebar.classList.contains("show")) {
sidebar.setAttribute("inert", "")
} else {
sidebar.removeAttribute("inert")
}
}
sync()
mq.addEventListener("change", sync)
return () => mq.removeEventListener("change", sync)
}, [])
useEffect(() => setupMobileSidebar(), [])

return (
<nav aria-label="sidebar" id="sidebar">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ export const SiteFooter = (props: Props) => {
useEffect(() => {
// Handle escape closing dropdowns etc
document.onkeydown = whenEscape(() => {
document.getElementById("playground-samples-popover")!.style.visibility = "hidden"
const samplesPopover = document.getElementById("playground-samples-popover")
if (samplesPopover) samplesPopover.style.visibility = "hidden"
})
}, [])

Expand Down