Skip to main content

max / makenotwork

4.5 KB · 105 lines History Blame Raw
1 // <div data-showing="one">, click-through carousel over a described gallery.
2 //
3 // The widget tier's browser half, and it is smaller than it was. The server no
4 // longer renders a carousel and no longer renders its chrome either: it renders
5 // an ordered set of pictures that says one of them is showing, and quasi-webview
6 // derives prev/position/next from that fact alone. So this file stopped
7 // injecting buttons and stopped building a dot strip. What is left is the one
8 // thing a description cannot carry: which frame the reader has moved to since
9 // the page arrived.
10 //
11 // NOTHING HERE KNOWS WHAT A CAROUSEL IS. The hook is `data-showing`, not
12 // `data-widget="carousel"`, and that is deliberate: a script that matched on the
13 // widget's name would have to be written again for the next assembly showing one
14 // child at a time. This binds every one of them, including ones that do not
15 // exist yet.
16 //
17 // PROGRESSIVE ENHANCEMENT. The controls are on the page but makeover keeps
18 // `.showing` hidden until `data-ready` is set, so a reader with no script is
19 // never offered a button that does nothing. `no-js.css`, loaded from a
20 // <noscript> in the partial, opens the frame stack out for them instead.
21
22 import { MnwElement, define } from './base.ts';
23 import { wrapIndex } from './carousel.logic.ts';
24
25 class Carousel extends MnwElement {
26 protected init(): void {
27 // The element wraps the described region rather than being it. A custom
28 // element is what gets upgraded automatically on an htmx swap, which is the
29 // whole reason islands are custom elements; the description emits a plain
30 // `<div>` because it has no idea this host prefixes its tags with `mnw-`.
31 // So the template puts one around the other and each half stays honest.
32 const region = this.querySelector<HTMLElement>('[data-showing]');
33 if (!region) return;
34
35 // The wrappers the renderer puts around each child, which are what carry
36 // `current`. Selected by class because this one IS the renderer's own
37 // structural name rather than a piece of makeover's vocabulary, and the
38 // description has no other way to mark which child is which.
39 const frames = Array.from(
40 region.querySelectorAll<HTMLElement>(':scope > .showing-frame'),
41 );
42 if (frames.length < 2) return; // nothing to navigate; leave the stack alone
43
44 let index = Math.max(
45 frames.findIndex((f) => f.classList.contains('current')),
46 0,
47 );
48
49 const position = region.querySelector<HTMLElement>('.showing-position');
50
51 const show = (next: number): void => {
52 index = wrapIndex(next, frames.length);
53 frames.forEach((f, i) => {
54 const active = i === index;
55 f.classList.toggle('current', active);
56 if (active) f.removeAttribute('aria-hidden');
57 else f.setAttribute('aria-hidden', 'true');
58 });
59 if (position) position.textContent = `${index + 1} / ${frames.length}`;
60 };
61
62 // Bind what is already there. `data-shows` is the renderer's word for what
63 // a control does to the region it sits in, and it takes an index as well as
64 // the two steps, so a derived tab strip binds through this same loop.
65 for (const control of region.querySelectorAll<HTMLElement>('[data-shows]')) {
66 const shows = control.dataset.shows ?? '';
67 control.addEventListener('click', () => {
68 if (shows === 'previous') return show(index - 1);
69 if (shows === 'next') return show(index + 1);
70 const at = Number.parseInt(shows, 10);
71 if (Number.isInteger(at)) show(at);
72 });
73 }
74
75 // The group semantics the template used to carry. They belong with the
76 // behaviour: a stack of pictures is not a carousel until this runs, and
77 // announcing one before then describes something the reader cannot use.
78 region.setAttribute('role', 'group');
79 region.setAttribute('aria-roledescription', 'carousel');
80 region.setAttribute('aria-label', 'Screenshots');
81 region.tabIndex = 0;
82
83 frames.forEach((f, i) => {
84 f.setAttribute('role', 'group');
85 f.setAttribute('aria-roledescription', 'slide');
86 f.setAttribute('aria-label', `${i + 1} of ${frames.length}`);
87 });
88
89 region.addEventListener('keydown', (e) => {
90 if (e.key === 'ArrowLeft') {
91 show(index - 1);
92 e.preventDefault();
93 } else if (e.key === 'ArrowRight') {
94 show(index + 1);
95 e.preventDefault();
96 }
97 });
98
99 show(index);
100 region.setAttribute('data-ready', ''); // reveals the row, collapses the stack
101 }
102 }
103
104 define('mnw-carousel', Carousel);
105