// `
`, 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', '');
}
}