summaryrefslogtreecommitdiffstats
path: root/src/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'src/scripts')
-rw-r--r--src/scripts/lib/dom.ts59
-rw-r--r--src/scripts/lib/keyboardNav.ts272
-rw-r--r--src/scripts/lib/lightbox.ts450
-rw-r--r--src/scripts/lib/siteHeader.ts37
-rw-r--r--src/scripts/lib/tabbar.ts12
-rw-r--r--src/scripts/lib/toc.ts14
-rw-r--r--src/scripts/script.ts17
7 files changed, 861 insertions, 0 deletions
diff --git a/src/scripts/lib/dom.ts b/src/scripts/lib/dom.ts
new file mode 100644
index 0000000..a88686a
--- /dev/null
+++ b/src/scripts/lib/dom.ts
@@ -0,0 +1,59 @@
1function isAbove(a: HTMLElement, b: HTMLElement) {
2 return a.offsetTop + a.clientHeight < b.offsetTop;
3}
4
5export function getVerticalNeighbor(
6 dir: -1 | 1,
7 items: Iterable<HTMLElement>,
8 current: HTMLElement,
9) {
10 let candidates: HTMLElement[] = [];
11
12 if (dir === -1) {
13 let adjacentY = Number.MIN_SAFE_INTEGER;
14
15 for (const item of items) {
16 if (!isAbove(item, current)) {
17 continue;
18 }
19
20 if (item.offsetTop > adjacentY) {
21 adjacentY = item.offsetTop;
22 candidates = [item];
23 } else if (item.offsetTop === adjacentY) {
24 candidates.push(item);
25 }
26 }
27 } else {
28 let adjacentY = Number.MAX_SAFE_INTEGER;
29
30 for (const item of items) {
31 if (!isAbove(current, item)) {
32 continue;
33 }
34
35 if (item.offsetTop < adjacentY) {
36 adjacentY = item.offsetTop;
37 candidates = [item];
38 } else if (item.offsetTop === adjacentY) {
39 candidates.push(item);
40 }
41 }
42 }
43
44 const currentX = current.offsetLeft + 0.5 * current.clientWidth;
45 let match = candidates[0];
46 let bestDist = Number.MAX_SAFE_INTEGER;
47
48 for (const candidate of candidates) {
49 const candidateX = candidate.offsetLeft + 0.5 * candidate.clientWidth;
50 const dist = Math.abs(currentX - candidateX);
51
52 if (dist < bestDist) {
53 bestDist = dist;
54 match = candidate;
55 }
56 }
57
58 return match;
59}
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}
diff --git a/src/scripts/lib/lightbox.ts b/src/scripts/lib/lightbox.ts
new file mode 100644
index 0000000..ac5b581
--- /dev/null
+++ b/src/scripts/lib/lightbox.ts
@@ -0,0 +1,450 @@
1import Alpine from "alpinejs";
2
3export interface LightboxItem {
4 thumbnail?: string;
5 src: string;
6 srcset?: string;
7 alt: string;
8}
9
10function touchParams(e: TouchEvent) {
11 const [t0, t1] = e.touches;
12
13 if (e.touches.length > 2 || !t0) {
14 return;
15 }
16
17 let x = t0.clientX;
18 let y = t0.clientY;
19 let dist = 0;
20
21 if (t1) {
22 dist = Math.hypot(x - t1.clientX, y - t1.clientY);
23 x = (x + t1.clientX) / 2;
24 y = (y + t1.clientY) / 2;
25 }
26
27 return { x, y, dist };
28}
29
30export function mountLightbox() {
31 const minZoom = 0.9;
32
33 Alpine.data("lightbox", () => ({
34 _zoom: minZoom,
35 _offsetX: 0,
36 _offsetY: 0,
37
38 loading: false,
39 inertia: false,
40 items: <LightboxItem[]>[],
41 current: 0,
42 dragging: false,
43 touchStart: {
44 x: 0,
45 y: 0,
46 distance: 0,
47 transformOrigin: { x: 0, y: 0 },
48 zoom: 1,
49 offsetX: 0,
50 offsetY: 0,
51 timestamp: 0,
52 },
53 velocity: {
54 measure: false,
55 prevOffsetX: 0,
56 prevOffsetY: 0,
57 timestamp: 0,
58 x: 0,
59 y: 0,
60 },
61
62 get zoom() {
63 return this._zoom;
64 },
65 set zoom(value) {
66 this._zoom = Math.min(this.maxZoom, Math.max(minZoom, value));
67 /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign
68 this.offsetX = this.offsetX;
69 /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign
70 this.offsetY = this.offsetY;
71 },
72
73 get offsetX() {
74 return this._offsetX;
75 },
76 set offsetX(value) {
77 const limit = this.offsetLimit;
78 this._offsetX = Math.min(limit.x, Math.max(-limit.x, value));
79 },
80
81 get offsetY() {
82 return this._offsetY;
83 },
84 set offsetY(value) {
85 const limit = this.offsetLimit;
86 this._offsetY = Math.min(limit.y, Math.max(-limit.y, value));
87 },
88
89 get offsetLimit(): { x: number; y: number } {
90 if (!this.$refs.img) {
91 return { x: 0, y: 0 };
92 }
93
94 const th = this.$refs.thumbnails?.clientHeight ?? 0;
95 const cw = this.$root.clientWidth;
96 const ch = this.$root.clientHeight - th;
97 const x = 0.5 * Math.max(0, this.$refs.img.clientWidth * this.zoom - cw);
98 const y = 0.5 * Math.max(0, this.$refs.img.clientHeight * this.zoom - ch);
99
100 return { x, y };
101 },
102
103 get open(): boolean {
104 return !!this.$root.hasAttribute("open");
105 },
106
107 get currentItem(): LightboxItem {
108 return this.items[this.current] ?? { src: "", alt: "" };
109 },
110
111 get transformOrigin(): { x: number; y: number } {
112 const img = this.$refs.img;
113
114 if (!img) {
115 return { x: this.offsetX, y: this.offsetY };
116 }
117
118 return {
119 x: img.offsetLeft + img.clientWidth / 2 + this.offsetX,
120 y: img.offsetTop + img.clientHeight / 2 + this.offsetY,
121 };
122 },
123
124 get naturalZoom() {
125 const img = <HTMLImageElement>this.$refs.img;
126 return img ? img.naturalWidth / img.clientWidth : 1;
127 },
128
129 get maxZoom() {
130 return Math.max(this.naturalZoom, 5);
131 },
132
133 get zoomedFull() {
134 return Math.abs(this.zoom - this.naturalZoom) < 0.1;
135 },
136
137 init() {
138 const triggerEls = document.querySelectorAll<HTMLAnchorElement>("a[data-lightbox]");
139 const galleries = new Map<string, LightboxItem[]>();
140
141 for (const triggerEl of triggerEls) {
142 const triggerImgEl = triggerEl.querySelector("img");
143
144 const key = triggerEl.dataset.lightbox ?? "";
145 const gallery = galleries.get(key);
146 const item: LightboxItem = {
147 thumbnail: triggerImgEl?.src,
148 src: triggerEl.href,
149 srcset: triggerEl.dataset.lightboxSrcset,
150 alt: "",
151 };
152
153 if (gallery) {
154 gallery.push(item);
155 } else {
156 galleries.set(key, [item]);
157 }
158
159 triggerEl.style.cursor = "zoom-in";
160 triggerEl.addEventListener("click", (e) => {
161 e.preventDefault();
162 this.show(galleries.get(key) ?? [], item.src);
163 });
164 }
165
166 this.$watch("currentItem.src", () => {
167 this.loading = true;
168 this.reset();
169 });
170 },
171
172 show(items: LightboxItem[], src?: string) {
173 const current = items.findIndex((item) => item.src === src);
174 this.items = items;
175 this.current = current === -1 ? 0 : current;
176 this.reset();
177
178 (<HTMLDialogElement>this.$root).showModal();
179 },
180
181 close() {
182 (<HTMLDialogElement>this.$root).close();
183 },
184
185 reset() {
186 this.stopVelocity();
187 this.stopInertia();
188 this.dragging = false;
189 this._zoom = minZoom;
190 this._offsetX = 0;
191 this._offsetY = 0;
192 },
193
194 prev() {
195 this.current = this.current > 0 ? this.current - 1 : this.items.length - 1;
196 },
197
198 next() {
199 this.current = this.current < this.items.length - 1 ? this.current + 1 : 0;
200 },
201
202 toggleZoom(x?: number, y?: number) {
203 const zoom = this.zoomedFull ? minZoom : this.naturalZoom;
204 const deltaZoom = zoom / this.zoom - 1;
205
206 this.zoom = zoom;
207
208 if (x !== undefined && y !== undefined) {
209 const transformOrigin = this.transformOrigin;
210 const offsetX = transformOrigin.x - x;
211 const offsetY = transformOrigin.y - y;
212
213 this.offsetX += offsetX * deltaZoom;
214 this.offsetY += offsetY * deltaZoom;
215 }
216 },
217
218 measureVelocity(loop?: boolean) {
219 if (!loop) {
220 this.inertia = false;
221
222 if (this.velocity.measure) {
223 return;
224 } else {
225 this.velocity.prevOffsetX = this.offsetX;
226 this.velocity.prevOffsetY = this.offsetY;
227 this.velocity.x = 0;
228 this.velocity.y = 0;
229 this.velocity.measure = true;
230 }
231 }
232 if (!this.velocity.measure) {
233 return;
234 }
235
236 const deltaX = this.offsetX - this.velocity.prevOffsetX;
237 const deltaY = this.offsetY - this.velocity.prevOffsetY;
238 let window = 1;
239
240 if (
241 Math.sqrt(deltaX ** 2 + deltaY ** 2) <
242 Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2)
243 ) {
244 window = 6;
245 }
246
247 this.velocity.x -= this.velocity.x / window;
248 this.velocity.x += deltaX / window;
249 this.velocity.y -= this.velocity.y / window;
250 this.velocity.y += deltaY / window;
251
252 this.velocity.prevOffsetX = this.offsetX;
253 this.velocity.prevOffsetY = this.offsetY;
254
255 requestAnimationFrame(this.measureVelocity.bind(this, true));
256 },
257
258 stopVelocity() {
259 this.velocity.measure = false;
260 },
261
262 runInertia(loop?: boolean, timestamp: DOMHighResTimeStamp = performance.now()) {
263 const len = Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2);
264
265 if (!loop) {
266 this.stopVelocity();
267
268 if (this.inertia) {
269 return;
270 } else if (len > 5) {
271 this.inertia = true;
272 this.velocity.timestamp = timestamp;
273 }
274 }
275
276 if (!this.inertia) {
277 this.stopInertia();
278 return;
279 }
280
281 if (loop) {
282 const norm = { x: this.velocity.x / len, y: this.velocity.y / len };
283 const elapsed = timestamp - this.velocity.timestamp;
284 const friction = 30 * (elapsed / 1000);
285 const decel = { x: norm.x * friction, y: norm.y * friction };
286 const decelLen = Math.sqrt(decel.x ** 2 + decel.y ** 2);
287
288 if (len < decelLen) {
289 this.stopInertia();
290 return;
291 }
292
293 this.offsetX += this.velocity.x;
294 this.offsetY += this.velocity.y;
295 this.velocity.x -= decel.x;
296 this.velocity.y -= decel.y;
297 this.velocity.timestamp = timestamp;
298 }
299
300 requestAnimationFrame(this.runInertia.bind(this, true));
301 },
302
303 stopInertia() {
304 this.inertia = false;
305 },
306
307 handleClose() {
308 this.stopVelocity();
309 this.stopInertia();
310 },
311
312 handleScroll(e: WheelEvent) {
313 this.stopInertia();
314
315 const direction = e.deltaY > 0 ? -1 : 1;
316
317 const transformOrigin = this.transformOrigin;
318 const offsetX = transformOrigin.x - e.clientX;
319 const offsetY = transformOrigin.y - e.clientY;
320
321 const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.zoom * (1 + direction * 0.1)));
322 const deltaZoom = zoom / this.zoom - 1;
323
324 this.zoom = zoom;
325 this.offsetX += offsetX * deltaZoom;
326 this.offsetY += offsetY * deltaZoom;
327 },
328
329 handleMouseDown(e: MouseEvent) {
330 this.measureVelocity();
331
332 if (e.buttons === 1) {
333 this.dragging = true;
334 }
335 },
336
337 handleMouseMove(e: MouseEvent) {
338 if (!this.dragging) {
339 return;
340 }
341
342 this.offsetX += e.movementX;
343 this.offsetY += e.movementY;
344 },
345
346 handleMouseUp() {
347 this.dragging = false;
348 this.runInertia();
349 },
350
351 handleDoubleClick(e: MouseEvent) {
352 this.toggleZoom(e.clientX, e.clientY);
353 },
354
355 handleTouchStart(e: TouchEvent) {
356 const params = touchParams(e);
357
358 if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) {
359 return;
360 }
361
362 e.preventDefault();
363
364 const now = Date.now();
365
366 if (e.touches.length === 1) {
367 if (
368 now - this.touchStart.timestamp < 200 &&
369 Math.sqrt((params.x - this.touchStart.x) ** 2 + (params.y - this.touchStart.y) ** 2) < 20
370 ) {
371 this.toggleZoom(params.x, params.y);
372 }
373 }
374
375 this.touchStart.x = params.x;
376 this.touchStart.y = params.y;
377 this.touchStart.distance = params.dist;
378 this.touchStart.transformOrigin = this.transformOrigin;
379 this.touchStart.zoom = this.zoom;
380 this.touchStart.offsetX = this.offsetX;
381 this.touchStart.offsetY = this.offsetY;
382 this.touchStart.timestamp = now;
383
384 this.dragging = false;
385
386 if (e.touches.length === 1) {
387 this.measureVelocity();
388 } else {
389 this.stopVelocity();
390 }
391 },
392
393 handleTouchMove(e: TouchEvent) {
394 const params = touchParams(e);
395
396 if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) {
397 return;
398 }
399
400 e.preventDefault();
401
402 const deltaX = params.x - this.touchStart.x;
403 const deltaY = params.y - this.touchStart.y;
404 const deltaDist = this.touchStart.distance ? params.dist / this.touchStart.distance : 1;
405
406 const offsetX = this.touchStart.transformOrigin.x - this.touchStart.x;
407 const offsetY = this.touchStart.transformOrigin.y - this.touchStart.y;
408
409 const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.touchStart.zoom * deltaDist));
410 const deltaZoom = zoom / this.touchStart.zoom - 1;
411
412 this.zoom = zoom;
413 this.offsetX = this.touchStart.offsetX + deltaX + offsetX * deltaZoom;
414 this.offsetY = this.touchStart.offsetY + deltaY + offsetY * deltaZoom;
415 },
416
417 handleTouchEnd(e: TouchEvent) {
418 this.handleTouchStart(e);
419
420 if (!e.touches.length) {
421 this.runInertia();
422 }
423 },
424
425 handleKeyDown(e: KeyboardEvent) {
426 if (!this.open || e.defaultPrevented) {
427 return;
428 }
429
430 this.stopInertia();
431
432 switch (e.key) {
433 case "Left":
434 case "ArrowLeft":
435 this.prev();
436 break;
437
438 case "Right":
439 case "ArrowRight":
440 this.next();
441 break;
442
443 default:
444 return;
445 }
446
447 e.preventDefault();
448 },
449 }));
450}
diff --git a/src/scripts/lib/siteHeader.ts b/src/scripts/lib/siteHeader.ts
new file mode 100644
index 0000000..0d9ec43
--- /dev/null
+++ b/src/scripts/lib/siteHeader.ts
@@ -0,0 +1,37 @@
1export function mountSiteHeader() {
2 const headerEl = document.querySelector<HTMLElement>(".c-site-header");
3
4 if (headerEl) {
5 const navbarEl = headerEl?.querySelector(".o-navbar--static-white");
6 const logoEl = headerEl?.querySelector(".c-logo--static-white");
7 const buttonEls = headerEl?.querySelectorAll(".o-button--static-white");
8
9 let settings: IntersectionObserverInit;
10
11 const boundingRect = headerEl.getBoundingClientRect();
12
13 if (boundingRect.top > 0) {
14 settings = {
15 rootMargin: "1px 0px -100% 0px",
16 };
17 } else {
18 settings = {
19 root: document.documentElement,
20 rootMargin: "-1px 0px 0px 0px",
21 threshold: [1],
22 };
23 }
24
25 const observer = new IntersectionObserver(([e]) => {
26 const scroll = e?.isIntersecting;
27 headerEl.classList.toggle("is-scroll", scroll);
28 navbarEl?.classList.toggle("o-navbar--static-white", !scroll);
29 logoEl?.classList.toggle("c-logo--static-white", !scroll);
30 for (const buttonEl of buttonEls) {
31 buttonEl.classList.toggle("o-button--static-white", !scroll);
32 }
33 }, settings);
34
35 observer.observe(headerEl);
36 }
37}
diff --git a/src/scripts/lib/tabbar.ts b/src/scripts/lib/tabbar.ts
new file mode 100644
index 0000000..6a3d3d3
--- /dev/null
+++ b/src/scripts/lib/tabbar.ts
@@ -0,0 +1,12 @@
1export function mountTabbar() {
2 const tabbarEls = document.querySelectorAll<HTMLElement>(".o-tabbar__tabs");
3
4 for (const tabbarEl of tabbarEls) {
5 const selectedItemEl = tabbarEl.querySelector<HTMLElement>(".o-tabbar__tab.is-selected");
6
7 if (selectedItemEl) {
8 tabbarEl.scrollLeft =
9 selectedItemEl.offsetLeft - 0.5 * tabbarEl.clientWidth + 0.5 * selectedItemEl.clientWidth;
10 }
11 }
12}
diff --git a/src/scripts/lib/toc.ts b/src/scripts/lib/toc.ts
new file mode 100644
index 0000000..958f209
--- /dev/null
+++ b/src/scripts/lib/toc.ts
@@ -0,0 +1,14 @@
1export function mountToc() {
2 const observer = new IntersectionObserver((entries) => {
3 for (const entry of entries) {
4 const id = entry.target.getAttribute("id");
5 document
6 .querySelector(`.o-toc__item[href="#${id}"]`)
7 ?.classList.toggle("is-selected", entry.intersectionRatio > 0);
8 }
9 });
10
11 document.querySelectorAll("section[id]").forEach((section) => {
12 observer.observe(section);
13 });
14}
diff --git a/src/scripts/script.ts b/src/scripts/script.ts
new file mode 100644
index 0000000..02fb7ff
--- /dev/null
+++ b/src/scripts/script.ts
@@ -0,0 +1,17 @@
1/// <reference lib="dom" />
2/// <reference lib="dom.iterable" />
3
4import { mountKeyboardNav } from "./lib/keyboardNav.ts";
5import { mountLightbox } from "./lib/lightbox.ts";
6import { mountSiteHeader } from "./lib/siteHeader.ts";
7import { mountTabbar } from "./lib/tabbar.ts";
8import { mountToc } from "./lib/toc.ts";
9
10document.body.classList.remove("t-no-js");
11document.body.classList.add("t-js");
12
13mountKeyboardNav();
14mountLightbox();
15mountTabbar();
16mountSiteHeader();
17mountToc();