Skip to content

Commit 0757f75

Browse files
committed
fix(documentation): make mobile sidebar dismissible
1 parent c8170c3 commit 0757f75

5 files changed

Lines changed: 271 additions & 38 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/** @jest-environment jsdom */
2+
3+
import { setupMobileSidebar } from "./Sidebar-mobile"
4+
5+
const renderSidebar = (backgroundInert = false) => {
6+
document.body.innerHTML = `
7+
<div id="site-root">
8+
<header><a href="/">Home</a></header>
9+
<main>
10+
<section id="doc-layout">
11+
<button
12+
id="small-device-button-sidebar"
13+
aria-controls="sidebar"
14+
aria-expanded="false"
15+
aria-label="Open sidebar navigation"
16+
>Toggle</button>
17+
<div id="sidebar-backdrop" hidden></div>
18+
<nav id="sidebar">
19+
<button id="section-toggle">Section</button>
20+
<a href="/docs/destination">Destination</a>
21+
</nav>
22+
<article id="background"${backgroundInert ? " inert" : ""}>
23+
<button>Background action</button>
24+
</article>
25+
</section>
26+
</main>
27+
<footer><a href="/footer">Footer</a></footer>
28+
</div>
29+
`
30+
}
31+
32+
const setMobileViewport = (matches: boolean) => {
33+
const listeners = new Set<() => void>()
34+
const media = {
35+
matches,
36+
addEventListener: (_event: string, listener: () => void) => listeners.add(listener),
37+
removeEventListener: (_event: string, listener: () => void) => listeners.delete(listener),
38+
}
39+
Object.defineProperty(window, "matchMedia", {
40+
configurable: true,
41+
value: jest.fn(() => media),
42+
})
43+
return { media, listeners }
44+
}
45+
46+
describe("mobile documentation sidebar", () => {
47+
afterEach(() => {
48+
document.body.innerHTML = ""
49+
document.body.className = ""
50+
})
51+
52+
it("opens and closes from the trigger while exposing its state", () => {
53+
renderSidebar()
54+
setMobileViewport(true)
55+
const cleanup = setupMobileSidebar()
56+
const sidebar = document.getElementById("sidebar")!
57+
const toggle = document.getElementById("small-device-button-sidebar")!
58+
const backdrop = document.getElementById("sidebar-backdrop")!
59+
const background = document.getElementById("background")!
60+
61+
expect(sidebar.hasAttribute("inert")).toBe(true)
62+
63+
toggle.click()
64+
65+
expect(sidebar.classList.contains("show")).toBe(true)
66+
expect(sidebar.hasAttribute("inert")).toBe(false)
67+
expect(toggle.getAttribute("aria-controls")).toBe("sidebar")
68+
expect(toggle.getAttribute("aria-expanded")).toBe("true")
69+
expect(toggle.getAttribute("aria-label")).toBe("Close sidebar navigation")
70+
expect(backdrop.hidden).toBe(false)
71+
expect(background.hasAttribute("inert")).toBe(true)
72+
expect(document.body.classList.contains("mobile-sidebar-open")).toBe(true)
73+
74+
toggle.click()
75+
76+
expect(sidebar.classList.contains("show")).toBe(false)
77+
expect(toggle.getAttribute("aria-expanded")).toBe("false")
78+
expect(toggle.getAttribute("aria-label")).toBe("Open sidebar navigation")
79+
expect(backdrop.hidden).toBe(true)
80+
expect(background.hasAttribute("inert")).toBe(false)
81+
expect(document.body.classList.contains("mobile-sidebar-open")).toBe(false)
82+
expect(document.activeElement).toBe(toggle)
83+
cleanup()
84+
})
85+
86+
it("dismisses from the backdrop, Escape, and destination links only", () => {
87+
renderSidebar()
88+
setMobileViewport(true)
89+
const cleanup = setupMobileSidebar()
90+
const sidebar = document.getElementById("sidebar")!
91+
const toggle = document.getElementById("small-device-button-sidebar")!
92+
const backdrop = document.getElementById("sidebar-backdrop")!
93+
94+
toggle.click()
95+
document.getElementById("section-toggle")!.click()
96+
expect(sidebar.classList.contains("show")).toBe(true)
97+
98+
backdrop.click()
99+
expect(sidebar.classList.contains("show")).toBe(false)
100+
101+
toggle.click()
102+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))
103+
expect(sidebar.classList.contains("show")).toBe(false)
104+
expect(document.activeElement).toBe(toggle)
105+
106+
toggle.click()
107+
document.querySelector<HTMLAnchorElement>("#sidebar a")!.click()
108+
expect(sidebar.classList.contains("show")).toBe(false)
109+
cleanup()
110+
})
111+
112+
it("leaves desktop navigation and pre-existing inert state unchanged", () => {
113+
renderSidebar(true)
114+
const { media, listeners } = setMobileViewport(false)
115+
const cleanup = setupMobileSidebar()
116+
const sidebar = document.getElementById("sidebar")!
117+
const toggle = document.getElementById("small-device-button-sidebar")!
118+
const background = document.getElementById("background")!
119+
120+
toggle.click()
121+
expect(sidebar.classList.contains("show")).toBe(false)
122+
expect(sidebar.hasAttribute("inert")).toBe(false)
123+
124+
media.matches = true
125+
listeners.forEach(listener => listener())
126+
expect(sidebar.hasAttribute("inert")).toBe(true)
127+
128+
toggle.click()
129+
cleanup()
130+
expect(background.hasAttribute("inert")).toBe(true)
131+
expect(sidebar.hasAttribute("inert")).toBe(false)
132+
})
133+
})
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
const mobileSidebarQuery = "(max-width: 800px)"
2+
const sidebarId = "sidebar"
3+
const toggleId = "small-device-button-sidebar"
4+
const backdropId = "sidebar-backdrop"
5+
const bodyOpenClass = "mobile-sidebar-open"
6+
7+
const backgroundElementsMadeInert = new Set<HTMLElement>()
8+
9+
type SidebarElements = {
10+
sidebar: HTMLElement
11+
toggle: HTMLButtonElement
12+
backdrop: HTMLElement
13+
}
14+
15+
const getSidebarElements = (): SidebarElements | undefined => {
16+
const sidebar = document.getElementById(sidebarId)
17+
const toggle = document.getElementById(toggleId)
18+
const backdrop = document.getElementById(backdropId)
19+
20+
if (!(sidebar instanceof HTMLElement) || !(toggle instanceof HTMLButtonElement) || !(backdrop instanceof HTMLElement)) {
21+
return undefined
22+
}
23+
24+
return { sidebar, toggle, backdrop }
25+
}
26+
27+
const setBackgroundInert = (elements: SidebarElements, inert: boolean) => {
28+
if (!inert) {
29+
backgroundElementsMadeInert.forEach(element => element.removeAttribute("inert"))
30+
backgroundElementsMadeInert.clear()
31+
return
32+
}
33+
34+
const interactiveElements = [elements.sidebar, elements.toggle, elements.backdrop]
35+
let current: HTMLElement | null = elements.sidebar
36+
37+
while (current?.parentElement) {
38+
const parent: HTMLElement = current.parentElement
39+
Array.from(parent.children).forEach(child => {
40+
if (!(child instanceof HTMLElement)) return
41+
if (interactiveElements.some(element => child === element || child.contains(element))) return
42+
if (child.hasAttribute("inert")) return
43+
44+
child.setAttribute("inert", "")
45+
backgroundElementsMadeInert.add(child)
46+
})
47+
current = parent
48+
}
49+
}
50+
51+
const setOpen = (elements: SidebarElements, open: boolean, restoreFocus = false) => {
52+
elements.sidebar.classList.toggle("show", open)
53+
elements.sidebar.toggleAttribute("inert", !open)
54+
elements.toggle.setAttribute("aria-expanded", String(open))
55+
elements.toggle.setAttribute("aria-label", `${open ? "Close" : "Open"} sidebar navigation`)
56+
elements.backdrop.hidden = !open
57+
document.body.classList.toggle(bodyOpenClass, open)
58+
setBackgroundInert(elements, open)
59+
60+
if (!open && restoreFocus) elements.toggle.focus()
61+
}
62+
63+
/** Connects the independently rendered mobile drawer controls without changing desktop navigation. */
64+
export const setupMobileSidebar = () => {
65+
const elements = getSidebarElements()
66+
if (!elements) return () => {}
67+
68+
const mediaQuery = window.matchMedia(mobileSidebarQuery)
69+
const close = (restoreFocus = true) => setOpen(elements, false, restoreFocus)
70+
71+
const toggle = () => {
72+
if (!mediaQuery.matches) return
73+
setOpen(elements, !elements.sidebar.classList.contains("show"), true)
74+
}
75+
const dismissFromBackdrop = () => close()
76+
const dismissFromKeyboard = (event: KeyboardEvent) => {
77+
if (event.key !== "Escape" || !mediaQuery.matches || !elements.sidebar.classList.contains("show")) return
78+
event.preventDefault()
79+
close()
80+
}
81+
const dismissFromDestination = (event: MouseEvent) => {
82+
if (!mediaQuery.matches || !elements.sidebar.classList.contains("show")) return
83+
if (!(event.target instanceof Element) || !event.target.closest("a")) return
84+
close()
85+
}
86+
const syncToViewport = () => {
87+
close(false)
88+
if (!mediaQuery.matches) elements.sidebar.removeAttribute("inert")
89+
}
90+
91+
elements.toggle.addEventListener("click", toggle)
92+
elements.backdrop.addEventListener("click", dismissFromBackdrop)
93+
elements.sidebar.addEventListener("click", dismissFromDestination)
94+
document.addEventListener("keydown", dismissFromKeyboard)
95+
mediaQuery.addEventListener("change", syncToViewport)
96+
syncToViewport()
97+
98+
return () => {
99+
elements.toggle.removeEventListener("click", toggle)
100+
elements.backdrop.removeEventListener("click", dismissFromBackdrop)
101+
elements.sidebar.removeEventListener("click", dismissFromDestination)
102+
document.removeEventListener("keydown", dismissFromKeyboard)
103+
mediaQuery.removeEventListener("change", syncToViewport)
104+
close(false)
105+
elements.sidebar.removeAttribute("inert")
106+
}
107+
}

packages/typescriptlang-org/src/components/layout/Sidebar.scss

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@import "../../style/globals.scss";
22

3-
#small-device-button-sidebar {
3+
#small-device-button-sidebar,
4+
#sidebar-backdrop {
45
display: none;
56
}
67

@@ -163,6 +164,18 @@ nav#sidebar {
163164
}
164165

165166
@media (max-width: $screen-sm) {
167+
body.mobile-sidebar-open {
168+
overflow: hidden;
169+
}
170+
171+
#sidebar-backdrop:not([hidden]) {
172+
display: block;
173+
position: fixed;
174+
inset: 0;
175+
background-color: rgba(0, 0, 0, 0.4);
176+
z-index: $z-index-for-handbook-nav - 1;
177+
}
178+
166179
// This is a button which will scroll off and on with the nav
167180
button#small-device-button-sidebar {
168181
display: flex;
@@ -203,6 +216,7 @@ nav#sidebar {
203216
height: 100%;
204217
overflow-y: scroll;
205218
overflow-x: hidden;
219+
overscroll-behavior: contain;
206220

207221
-webkit-overflow-scrolling: touch;
208222

packages/typescriptlang-org/src/components/layout/Sidebar.tsx

Lines changed: 14 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Link } from "gatsby"
44
import "./Sidebar.scss"
55
import { onAnchorKeyDown, onButtonKeydown } from "./Sidebar-keyboard"
66
import { SidebarNavItem } from "../../lib/documentationNavigationUtils"
7+
import { setupMobileSidebar } from "./Sidebar-mobile"
78

89
export type Props = {
910
navItems: SidebarNavItem[]
@@ -41,28 +42,20 @@ const toggleNavigationSection: MouseEventHandler = (event) => {
4142
}
4243
}
4344

44-
export const SidebarToggleButton = () => {
45-
const toggleClick = () => {
46-
const navSidebar = document.getElementById("sidebar")
47-
const toggleButton = document.getElementById("small-device-button-sidebar")
48-
const isOpen = navSidebar?.classList.contains("show")
49-
if (isOpen) {
50-
navSidebar?.classList.remove("show")
51-
navSidebar?.setAttribute("inert", "")
52-
toggleButton?.focus()
53-
} else {
54-
navSidebar?.classList.add("show")
55-
navSidebar?.removeAttribute("inert")
56-
}
57-
}
58-
59-
60-
return (
61-
<button id="small-device-button-sidebar" aria-label="Toggle sidebar navigation" onClick={toggleClick}>
45+
export const SidebarToggleButton = () => (
46+
<>
47+
<button
48+
id="small-device-button-sidebar"
49+
type="button"
50+
aria-controls="sidebar"
51+
aria-expanded="false"
52+
aria-label="Open sidebar navigation"
53+
>
6254
<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>
6355
</button>
64-
)
65-
}
56+
<div id="sidebar-backdrop" aria-hidden="true" hidden />
57+
</>
58+
)
6659

6760
export const Sidebar = (props: Props) => {
6861
useEffect(() => {
@@ -131,22 +124,7 @@ export const Sidebar = (props: Props) => {
131124
}
132125
}
133126

134-
useEffect(() => {
135-
const sidebar = document.getElementById("sidebar")
136-
if (!sidebar) return
137-
138-
const mq = window.matchMedia("(max-width: 800px)")
139-
const sync = () => {
140-
if (mq.matches && !sidebar.classList.contains("show")) {
141-
sidebar.setAttribute("inert", "")
142-
} else {
143-
sidebar.removeAttribute("inert")
144-
}
145-
}
146-
sync()
147-
mq.addEventListener("change", sync)
148-
return () => mq.removeEventListener("change", sync)
149-
}, [])
127+
useEffect(() => setupMobileSidebar(), [])
150128

151129
return (
152130
<nav aria-label="sidebar" id="sidebar">

packages/typescriptlang-org/src/components/layout/SiteFooter.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,8 @@ export const SiteFooter = (props: Props) => {
220220
useEffect(() => {
221221
// Handle escape closing dropdowns etc
222222
document.onkeydown = whenEscape(() => {
223-
document.getElementById("playground-samples-popover")!.style.visibility = "hidden"
223+
const samplesPopover = document.getElementById("playground-samples-popover")
224+
if (samplesPopover) samplesPopover.style.visibility = "hidden"
224225
})
225226
}, [])
226227

0 commit comments

Comments
 (0)