summaryrefslogtreecommitdiffstats
path: root/src/scripts/lib/keyboardNav.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/scripts/lib/keyboardNav.ts')
-rw-r--r--src/scripts/lib/keyboardNav.ts272
1 files changed, 272 insertions, 0 deletions
diff --git a/src/scripts/lib/keyboardNav.ts b/src/scripts/lib/keyboardNav.ts
new file mode 100644
index 0000000..4c79a08
--- /dev/null
+++ b/src/scripts/lib/keyboardNav.ts
@@ -0,0 +1,272 @@
1import { getVerticalNeighbor } from "./dom.ts";
2
3function focusPrev(
4 owner: HTMLElement,
5 itemSelector: string,
6 wrap: boolean,
7 stateful: boolean,
8 current: HTMLElement,
9) {
10 const items = [...owner.querySelectorAll<HTMLElement>(itemSelector)];
11 const i = items.indexOf(current);
12
13 const prev = i > 0 ? items[i - 1] : wrap ? items.at(-1) : undefined;
14
15 if (!prev || prev === current) {
16 return false;
17 }
18
19 if (stateful) {
20 current.setAttribute("tabindex", "-1");
21 prev.removeAttribute("tabindex");
22 }
23 prev.focus();
24 return true;
25}
26
27function focusNext(
28 owner: HTMLElement,
29 itemSelector: string,
30 wrap: boolean,
31 stateful: boolean,
32 current: HTMLElement,
33) {
34 const items = [...owner.querySelectorAll<HTMLElement>(itemSelector)];
35 const i = items.indexOf(current);
36
37 const next = i < items.length - 1 ? items[i + 1] : wrap ? items[0] : undefined;
38
39 if (!next || next === current) {
40 return false;
41 }
42
43 if (stateful) {
44 current.setAttribute("tabindex", "-1");
45 next.removeAttribute("tabindex");
46 }
47 next.focus();
48 return true;
49}
50
51function focusUp(
52 owner: HTMLElement,
53 itemSelector: string,
54 wrap: boolean,
55 stateful: boolean,
56 current: HTMLElement,
57) {
58 const items = [...owner.querySelectorAll<HTMLElement>(itemSelector)];
59 let neighbor: HTMLElement | undefined = getVerticalNeighbor(-1, items, current);
60
61 if (!neighbor && wrap) {
62 neighbor = items.at(-1);
63 }
64
65 if (!neighbor || neighbor === current) {
66 return false;
67 }
68
69 if (stateful) {
70 current.setAttribute("tabindex", "-1");
71 neighbor.removeAttribute("tabindex");
72 }
73 neighbor.focus();
74 return true;
75}
76
77function focusDown(
78 owner: HTMLElement,
79 itemSelector: string,
80 wrap: boolean,
81 stateful: boolean,
82 current: HTMLElement,
83) {
84 const items = owner.querySelectorAll<HTMLElement>(itemSelector);
85 let neighbor = getVerticalNeighbor(1, items, current);
86
87 if (!neighbor && wrap) {
88 neighbor = items[0];
89 }
90
91 if (!neighbor || neighbor === current) {
92 return false;
93 }
94
95 if (stateful) {
96 current.setAttribute("tabindex", "-1");
97 neighbor.removeAttribute("tabindex");
98 }
99 neighbor.focus();
100 return true;
101}
102
103function focusFirst(
104 owner: HTMLElement,
105 itemSelector: string,
106 stateful: boolean,
107 current: HTMLElement,
108) {
109 const cards = [...owner.querySelectorAll<HTMLElement>(itemSelector)];
110 const first = cards[0];
111
112 if (!first || first === current) {
113 return false;
114 }
115
116 if (stateful) {
117 current.setAttribute("tabindex", "-1");
118 first.removeAttribute("tabindex");
119 }
120 first.focus();
121 return true;
122}
123
124function focusLast(
125 owner: HTMLElement,
126 itemSelector: string,
127 stateful: boolean,
128 current: HTMLElement,
129) {
130 const cards = [...owner.querySelectorAll<HTMLElement>(itemSelector)];
131 const last = cards.at(-1);
132
133 if (!last || last === current) {
134 return false;
135 }
136
137 if (stateful) {
138 current.setAttribute("tabindex", "-1");
139 last.removeAttribute("tabindex");
140 }
141 last.focus();
142 return true;
143}
144
145function onKeyDown(
146 owner: HTMLElement,
147 itemSelector: string,
148 horizontal: boolean,
149 vertical: boolean,
150 wrap: boolean,
151 stateful: boolean,
152 e: KeyboardEvent,
153) {
154 let handled = false;
155
156 if (!e.currentTarget) {
157 return;
158 }
159
160 switch (e.key) {
161 case "ArrowLeft": {
162 if (horizontal) {
163 handled = focusPrev(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement);
164 }
165 break;
166 }
167
168 case "ArrowRight": {
169 if (horizontal) {
170 handled = focusNext(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement);
171 }
172 break;
173 }
174
175 case "ArrowUp": {
176 if (vertical) {
177 handled = focusUp(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement);
178 }
179 break;
180 }
181
182 case "ArrowDown": {
183 if (vertical) {
184 handled = focusDown(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement);
185 }
186 break;
187 }
188
189 case "Home": {
190 handled = focusFirst(owner, itemSelector, stateful, e.currentTarget as HTMLElement);
191 break;
192 }
193
194 case "End": {
195 handled = focusLast(owner, itemSelector, stateful, e.currentTarget as HTMLElement);
196 break;
197 }
198 }
199
200 if (handled) {
201 e.stopPropagation();
202 e.preventDefault();
203 }
204}
205
206export function mountKeyboardNav() {
207 const keyboardNavEls = document.querySelectorAll<HTMLElement>(".js-keyboard-nav");
208
209 for (const keyboardNavEl of keyboardNavEls) {
210 const itemSelector = keyboardNavEl.dataset.keyboardNavItem ?? "";
211
212 if (!itemSelector) {
213 console.error("Keyboard nav: No item class provided");
214 continue;
215 }
216
217 let horizontal = false,
218 vertical = false,
219 wrap = false,
220 stateful = false;
221
222 switch (keyboardNavEl.dataset.keyboardNavMode) {
223 case "grid": {
224 horizontal = true;
225 vertical = true;
226 stateful = true;
227 break;
228 }
229
230 case "nav": {
231 horizontal = true;
232 wrap = true;
233 break;
234 }
235
236 case "menu": {
237 vertical = true;
238 wrap = true;
239 break;
240 }
241
242 case "list": {
243 vertical = true;
244 wrap = true;
245 stateful = true;
246 break;
247 }
248 }
249
250 const onKeyDown_ = (e: KeyboardEvent) => {
251 onKeyDown(keyboardNavEl, itemSelector, horizontal, vertical, wrap, stateful, e);
252 };
253
254 const itemEls = [...keyboardNavEl.querySelectorAll<HTMLElement>(itemSelector)].filter((el) =>
255 el.matches("a, button, input"),
256 );
257 let focused = false;
258
259 for (const itemEl of itemEls) {
260 itemEl.addEventListener("keydown", onKeyDown_);
261 if (!itemEl.classList.contains("is-selected")) {
262 itemEl.setAttribute("tabindex", "-1");
263 } else {
264 focused = true;
265 }
266 }
267
268 if (!focused) {
269 itemEls[0]?.removeAttribute("tabindex");
270 }
271 }
272}