Skip to main content

max / makenotwork

4.6 KB · 127 lines History Blame Raw
1 // `<div data-showing="one">`, and the one thing about it a description cannot
2 // carry: which child the reader has moved to since the page arrived.
3 //
4 // The renderer derives every idiom that shows one child at a time from the same
5 // fact -- a carousel, a tab strip and a disclosure are one region -- so the
6 // browser half is one function too. `data-shows` is the renderer's word for what
7 // a control does to the region it sits in, and it takes an index as well as the
8 // two steps, so a tab strip binds through the same loop a gallery does.
9 //
10 // Nothing here knows what a carousel or a tab group is. What differs between
11 // them is only what a reader should be told they are looking at, and that is the
12 // caller's to add.
13
14 import { wrapIndex } from './showing.logic.ts';
15
16 /** Move a region showing one child at a time. */
17 export interface Showing {
18 /** Show this child, wrapping at both ends. */
19 show(next: number): void;
20 /** Which child is showing. */
21 at(): number;
22 /** How many there are. */
23 count(): number;
24 }
25
26 /**
27 * Bind the controls inside a region that shows one child at a time.
28 *
29 * Returns null when there is nothing to navigate, which is a region with fewer
30 * than two children: binding one would offer a control that cannot move.
31 */
32 export function bindShowing(region: HTMLElement): Showing | null {
33 // The wrappers the renderer puts around each child, which are what carry
34 // `current`. Selected by class because this one IS the renderer's own
35 // structural name rather than a piece of makeover's vocabulary, and the
36 // description has no other way to mark which child is which.
37 const frames = Array.from(
38 region.querySelectorAll<HTMLElement>(':scope > .showing-frame'),
39 );
40 if (frames.length < 2) return null;
41
42 let index = Math.max(
43 frames.findIndex((f) => f.classList.contains('current')),
44 0,
45 );
46
47 const position = region.querySelector<HTMLElement>('.showing-position');
48 const tabs = Array.from(
49 region.querySelectorAll<HTMLElement>('[role="tab"][data-shows]'),
50 );
51
52 const show = (next: number): void => {
53 index = wrapIndex(next, frames.length);
54 frames.forEach((f, i) => {
55 const active = i === index;
56 f.classList.toggle('current', active);
57 if (active) f.removeAttribute('aria-hidden');
58 else f.setAttribute('aria-hidden', 'true');
59 });
60 if (position) position.textContent = `${index + 1} / ${frames.length}`;
61 // A strip says which tab is up; a prev/next row has nothing to say it with.
62 // The renderer wrote both of these on the way out and they stop being true
63 // the moment the reader moves.
64 tabs.forEach((tab, i) => {
65 const active = i === index;
66 tab.setAttribute('aria-selected', active ? 'true' : 'false');
67 tab.classList.toggle('chosen', active);
68 });
69 };
70
71 for (const control of region.querySelectorAll<HTMLElement>('[data-shows]')) {
72 const shows = control.dataset.shows ?? '';
73 control.addEventListener('click', () => {
74 if (shows === 'previous') return show(index - 1);
75 if (shows === 'next') return show(index + 1);
76 const at = Number.parseInt(shows, 10);
77 if (Number.isInteger(at)) show(at);
78 });
79 }
80
81 // Arrow keys move like a press, not like a reveal. On a strip that matters:
82 // the tab carries its panel's address, so calling `show` directly would move
83 // the reader to a panel that had never been fetched. Clicking is what fires
84 // both halves, and it is what the renderer put on the button for exactly this
85 // reason.
86 const move = (next: number): void => {
87 if (tabs.length === 0) return show(next);
88 const tab = tabs[wrapIndex(next, frames.length)];
89 tab?.click();
90 tab?.focus();
91 };
92
93 region.addEventListener('keydown', (e) => {
94 if (e.key === 'ArrowLeft') {
95 move(index - 1);
96 e.preventDefault();
97 } else if (e.key === 'ArrowRight') {
98 move(index + 1);
99 e.preventDefault();
100 }
101 });
102
103 return { show, at: () => index, count: () => frames.length };
104 }
105
106 /**
107 * Bind every described tab strip on the page.
108 *
109 * A strip is not an island: nothing swaps it, so it needs no custom element to
110 * be re-upgraded after one. Its panels are what swap, and they swap into
111 * themselves. `6b24f2df`.
112 *
113 * Disjoint from the carousel island by construction rather than by ordering: a
114 * gallery has no tablist, so nothing is bound twice.
115 */
116 export function initShowing(): void {
117 const strips = document.querySelectorAll<HTMLElement>(
118 '[data-showing]:has([role="tablist"])',
119 );
120 for (const region of strips) {
121 const showing = bindShowing(region);
122 if (!showing) continue;
123 showing.show(showing.at());
124 region.setAttribute('data-ready', '');
125 }
126 }
127