Skip to content

Commit be06486

Browse files
committed
Trap focus when navigation is open
1 parent 957fe3b commit be06486

3 files changed

Lines changed: 109 additions & 1 deletion

File tree

src/components/page/header.astro

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import "@styles/header.scss";
33
44
import {
55
headerId,
6+
logoId,
67
logoTitleId,
78
menuButtonId,
89
menuTemplateId,
@@ -18,7 +19,7 @@ import {
1819
---
1920

2021
<header id={headerId} class="header no-decor">
21-
<a class="logo" href="/" aria-labelledby={logoTitleId}>
22+
<a id={logoId} class="logo" href="/" aria-labelledby={logoTitleId}>
2223
<svg
2324
class="logo-svg"
2425
width="124"
@@ -95,6 +96,7 @@ import {
9596
<script>
9697
import {
9798
headerId,
99+
logoId,
98100
menuButtonId,
99101
menuTemplateId,
100102
modeButtonId,
@@ -105,6 +107,7 @@ import {
105107
navId,
106108
navListId
107109
} from "@components/page/header.ids";
110+
import FocusTrap from "@lib/focus-trap";
108111
import Store from "@lib/store.ts";
109112
import { colors } from "@styles/data/colors.json";
110113

@@ -117,9 +120,11 @@ import {
117120
(document.getElementById(menuTemplateId) as HTMLTemplateElement).content
118121
);
119122

123+
const logo = document.getElementById(logoId) as HTMLAnchorElement;
120124
const navList = document.getElementById(navListId) as HTMLUListElement;
121125
const menuButton = document.getElementById(menuButtonId) as HTMLButtonElement;
122126
const modeButton = document.getElementById(modeButtonId) as HTMLButtonElement;
127+
const headerFocusTrap = new FocusTrap(header, logo, modeButton);
123128

124129
menuButton.addEventListener("click", () => {
125130
document.body.classList.toggle("open-nav");
@@ -144,6 +149,8 @@ import {
144149
modeButton.classList.add("vanish");
145150
});
146151
}
152+
153+
headerFocusTrap.toggleLock();
147154
});
148155

149156
// 'navList' and 'modeButton' animations end at the same time so put

src/components/page/header.ids.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export const headerId = "";
22

3+
export const logoId = "";
34
export const logoTitleId = "";
45

56
export const navId = "";

src/lib/focus-trap.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
export default class FocusTrap {
2+
static #isLocked = false;
3+
static #currentKey = "";
4+
5+
#container: HTMLElement;
6+
#key: string;
7+
#listener: (e: KeyboardEvent) => void;
8+
9+
constructor(container: HTMLElement, start: HTMLElement, end: HTMLElement) {
10+
this.#container = container;
11+
this.#key = crypto.getRandomValues(new Uint8Array(10)).join("");
12+
this.#listener = (e) => {
13+
if (e.key.toLowerCase() !== "tab") {
14+
return;
15+
}
16+
17+
if (e.shiftKey && document.activeElement === start) {
18+
e.preventDefault();
19+
end.focus();
20+
return;
21+
}
22+
23+
if (!e.shiftKey && document.activeElement === end) {
24+
e.preventDefault();
25+
start.focus();
26+
}
27+
};
28+
}
29+
30+
get isLocked() {
31+
return FocusTrap.#isLocked;
32+
}
33+
34+
lock() {
35+
if (FocusTrap.#isLocked) {
36+
throw new Error("Focus trap is already locked.");
37+
}
38+
39+
addEventListener("keydown", this.#listener);
40+
FocusTrap.#isLocked = true;
41+
FocusTrap.#currentKey = this.#key;
42+
43+
this.#hideElements(document.body, this.#container);
44+
}
45+
46+
#hideElements(root: Element, exception: Element) {
47+
for (const child of root.children) {
48+
if (child === exception) {
49+
continue;
50+
}
51+
52+
if (child.contains(exception)) {
53+
this.#hideElements(child, exception);
54+
continue;
55+
}
56+
57+
child.setAttribute("aria-hidden", "true");
58+
}
59+
}
60+
61+
unlock() {
62+
if (!FocusTrap.#isLocked) {
63+
return;
64+
}
65+
66+
if (FocusTrap.#currentKey !== this.#key) {
67+
throw new Error("Cannot unlock a lock set by another focus trap.");
68+
}
69+
70+
removeEventListener("keydown", this.#listener);
71+
FocusTrap.#isLocked = false;
72+
FocusTrap.#currentKey = "";
73+
74+
this.#unhideElements(document.body, this.#container);
75+
}
76+
77+
#unhideElements(root: Element, exception: Element) {
78+
for (const child of root.children) {
79+
if (child === exception) {
80+
continue;
81+
}
82+
83+
if (child.contains(exception)) {
84+
this.#hideElements(child, exception);
85+
continue;
86+
}
87+
88+
child.removeAttribute("aria-hidden");
89+
}
90+
}
91+
92+
toggleLock() {
93+
if (FocusTrap.#isLocked) {
94+
this.unlock();
95+
return;
96+
}
97+
98+
this.lock();
99+
}
100+
}

0 commit comments

Comments
 (0)