// `
`, and the one thing about it a description cannot // carry: which child the reader has moved to since the page arrived. // // The renderer derives every idiom that shows one child at a time from the same // fact -- a carousel, a tab strip and a disclosure are one region -- so the // browser half is one function too. `data-shows` is the renderer's word for what // a control does to the region it sits in, and it takes an index as well as the // two steps, so a tab strip binds through the same loop a gallery does. // // Nothing here knows what a carousel or a tab group is. What differs between // them is only what a reader should be told they are looking at, and that is the // caller's to add. import { wrapIndex } from './showing.logic.ts'; /** Move a region showing one child at a time. */ export interface Showing { /** Show this child, wrapping at both ends. */ show(next: number): void; /** Which child is showing. */ at(): number; /** How many there are. */ count(): number; } /** * Bind the controls inside a region that shows one child at a time. * * Returns null when there is nothing to navigate, which is a region with fewer * than two children: binding one would offer a control that cannot move. */ export function bindShowing(region: HTMLElement): Showing | null { // The wrappers the renderer puts around each child, which are what carry // `current`. Selected by class because this one IS the renderer's own // structural name rather than a piece of makeover's vocabulary, and the // description has no other way to mark which child is which. const frames = Array.from( region.querySelectorAll(':scope > .showing-frame'), ); if (frames.length < 2) return null; let index = Math.max( frames.findIndex((f) => f.classList.contains('current')), 0, ); const position = region.querySelector('.showing-position'); const tabs = Array.from( region.querySelectorAll('[role="tab"][data-shows]'), ); const show = (next: number): void => { index = wrapIndex(next, frames.length); frames.forEach((f, i) => { const active = i === index; f.classList.toggle('current', active); if (active) f.removeAttribute('aria-hidden'); else f.setAttribute('aria-hidden', 'true'); }); if (position) position.textContent = `${index + 1} / ${frames.length}`; // A strip says which tab is up; a prev/next row has nothing to say it with. // The renderer wrote both of these on the way out and they stop being true // the moment the reader moves. tabs.forEach((tab, i) => { const active = i === index; tab.setAttribute('aria-selected', active ? 'true' : 'false'); tab.classList.toggle('chosen', active); }); }; for (const control of region.querySelectorAll('[data-shows]')) { const shows = control.dataset.shows ?? ''; control.addEventListener('click', () => { if (shows === 'previous') return show(index - 1); if (shows === 'next') return show(index + 1); const at = Number.parseInt(shows, 10); if (Number.isInteger(at)) show(at); }); } // Arrow keys move like a press, not like a reveal. On a strip that matters: // the tab carries its panel's address, so calling `show` directly would move // the reader to a panel that had never been fetched. Clicking is what fires // both halves, and it is what the renderer put on the button for exactly this // reason. const move = (next: number): void => { if (tabs.length === 0) return show(next); const tab = tabs[wrapIndex(next, frames.length)]; tab?.click(); tab?.focus(); }; region.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') { move(index - 1); e.preventDefault(); } else if (e.key === 'ArrowRight') { move(index + 1); e.preventDefault(); } }); return { show, at: () => index, count: () => frames.length }; } /** * Bind every described tab strip on the page. * * A strip is not an island: nothing swaps it, so it needs no custom element to * be re-upgraded after one. Its panels are what swap, and they swap into * themselves. `6b24f2df`. * * Disjoint from the carousel island by construction rather than by ordering: a * gallery has no tablist, so nothing is bound twice. */ export function initShowing(): void { const strips = document.querySelectorAll( '[data-showing]:has([role="tablist"])', ); for (const region of strips) { const showing = bindShowing(region); if (!showing) continue; showing.show(showing.at()); region.setAttribute('data-ready', ''); } }