PageSourceSearch

https://hdgoe.at/items/frontend/lightbox/lightbox.js?ver=1790919104

js hdgoe.at collected 2026-10-02 05:32:31 UTC 16,304 bytes, 430 lines download raw bytes

1/**
2 * hdg ö accessible lightbox
3 * =========================
4 * Main module. Orchestrates the dialog, slide navigation, focus management,
5 * keyboard + swipe input, and screen-reader announcements.
6 *
7 * Public API:
8 *
9 *   import { Lightbox } from '/items/frontend/lightbox/lightbox.js';
10 *   const lb = new Lightbox({ rootSelector: 'body' });
11 *   lb.init();         // attach delegated listeners
12 *   lb.open(groupKey, index); // programmatic open
13 *   lb.close();
14 *   lb.destroy();      // tear everything down (for SPA teardown etc.)
15 *
16 * Auto-init: if this module is loaded as `<script type="module">` and
17 * `window.HDG_LIGHTBOX_AUTOINIT` is not explicitly false, a default
18 * instance is created and initialised on DOMContentLoaded.
19 *
20 * Accessibility decisions (summary — see comments inline for detail):
21 *
22 *   - Modal uses semantic <dialog> (or <div role="dialog" aria-modal="true">
23 *     fallback). Both have role="dialog" + aria-modal="true" + aria-labelledby.
24 *   - All interactive chrome is real <button> elements with type="button" and
25 *     accessible labels via aria-label or visible text.
26 *   - Visible focus indicators are NEVER removed; styles in lightbox.css
27 *     enhance them but never use outline:0/none.
28 *   - On open: focus moves to the first interactive control (the image's
29 *     wrapper, which carries the accessible name). On slide change: focus
30 *     also moves to that wrapper so a screen reader announces the new slide.
31 *   - On close: focus restores to the original trigger element.
32 *   - Background content is rendered inert (native <dialog> top-layer +
33 *     manual inert/aria-hidden on fallback path).
34 *   - Reading order is logical: title → description → controls.
35 *   - prefers-reduced-motion disables slide animations (handled in CSS).
36 *   - aria-live region announces slide position ("Image 2 of 7") on change.
37 *     We use polite + atomic to avoid noisy double-announcements.
38 *   - Long descriptions don't push the image off-screen — desc lives in a
39 *     scrollable column (.hdg-lb__desc) controlled by CSS.
40 */
41
42import { FocusTrap } from './focus-trap.js';
43import { attachSwipe } from './swipe.js';
44import { collectGroups, locateTrigger, TRIGGER_SELECTOR } from './markup-adapter.js';
45import { DialogShim, supportsNativeDialog } from './dialog-fallback.js';
46
47const ROOT_CLASS = 'hdg-lightbox';
48
49/**
50 * Idempotent ID generator for the singleton dialog element.
51 */
52let _idCounter = 0;
53function uid(prefix) { return `${prefix}-${++_idCounter}`; }
54
55/**
56 * Pulls plain text from an HTML description string, suitable for use as a
57 * fallback accessible name. Capped at 160 chars so screen readers don't
58 * announce paragraphs when the user only wanted the image label.
59 */
60function textFromHTML(html) {
61	if (!html) return '';
62	const tmp = document.createElement('div');
63	tmp.innerHTML = String(html);
64	let t = (tmp.textContent || '').replace(/\s+/g, ' ').trim();
65	if (t.length > 160) t = t.substring(0, 157) + '…';
66	return t;
67}
68
69/**
70 * Default labels (German, with English fallback for non-DE pages).
71 */
72function defaultLabels(lang) {
73	const isDe = String(lang || '').toLowerCase().startsWith('de');
74	return isDe
75		? {
76			dialog: 'Bildergalerie',
77			description: 'Bildbeschreibung',
78			prev: 'Vorheriges Bild',
79			next: 'Nächstes Bild',
80			close: 'Schließen',
81			position: (i, n) => `Bild ${i} von ${n}`
82		}
83		: {
84			dialog: 'Image gallery',
85			description: 'Image description',
86			prev: 'Previous image',
87			next: 'Next image',
88			close: 'Close',
89			position: (i, n) => `Image ${i} of ${n}`
90		};
91}
92
93export class Lightbox {
94	constructor(options = {}) {
95		this.options = Object.assign({
96			rootSelector: 'body',
97			lang: document.documentElement.getAttribute('lang') || 'en',
98			preloadAdjacent: true
99		}, options);
100
101		this.labels = Object.assign(defaultLabels(this.options.lang), options.labels || {});
102
103		this.root = null;
104		this.dialog = null;          // raw element (<dialog> or <div>)
105		this.shim = null;            // DialogShim wrapping the element
106		this.focusTrap = null;
107		this.detachSwipe = null;
108
109		this._groups = null;         // Map<groupKey, items[]>
110		this._currentItems = null;   // active gallery items
111		this._currentIndex = 0;
112		this._trigger = null;        // the original trigger button/link
113
114		this._onDocClick = this._onDocClick.bind(this);
115		this._onKeydown = this._onKeydown.bind(this);
116	}
117
118	// ----- lifecycle -------------------------------------------------------
119
120	init() {
121		const host = document.querySelector(this.options.rootSelector) || document.body;
122		this.root = host;
123		this._ensureDialog();
124		// Event delegation — listen at document level so dynamically-injected
125		// triggers work without re-init. We use 'click' (not 'mousedown') so
126		// keyboard Enter/Space activations are caught too — browsers dispatch
127		// click for those on <button> and <a>.
128		document.addEventListener('click', this._onDocClick, false);
129	}
130
131	destroy() {
132		document.removeEventListener('click', this._onDocClick, false);
133		if (this.detachSwipe) this.detachSwipe();
134		if (this.shim && this.shim.isOpen()) this.shim.close();
135		if (this.dialog && this.dialog.parentNode) this.dialog.parentNode.removeChild(this.dialog);
136		this.dialog = null;
137		this.shim = null;
138		this.focusTrap = null;
139	}
140
141	open(groupKey, index = 0) {
142		if (!this._groups) this._groups = collectGroups();
143		const items = this._groups.get(groupKey);
144		if (!items || !items.length) return;
145		this._openWith(items, index);
146	}
147
148	close() {
149		if (this.shim) this.shim.close();
150	}
151
152	// ----- click delegation -----------------------------------------------
153
154	_onDocClick(e) {
155		// Ignore modified clicks (open in new tab etc.)
156		if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
157		if (e.button !== 0 && e.button !== undefined) return;
158		const trigger = e.target && e.target.closest ? e.target.closest(TRIGGER_SELECTOR) : null;
159		if (!trigger) return;
160		// Recollect groups on every click so dynamically-added triggers Just Work.
161		// If perf becomes an issue we can cache + invalidate on DOMMutationObserver.
162		this._groups = collectGroups();
163		const loc = locateTrigger(trigger, this._groups);
164		if (!loc) return;
165		e.preventDefault();
166		this._trigger = trigger;
167		this._openWith(loc.items, loc.index);
168	}
169
170	_openWith(items, index) {
171		this._currentItems = items;
172		this._currentIndex = Math.max(0, Math.min(index, items.length - 1));
173		this._renderSlide();
174		this.shim.showModal();
175		this.focusTrap.activate({ initialFocus: this.dialog.querySelector('.hdg-lb__img') });
176	}
177
178	// ----- dialog construction --------------------------------------------
179
180	_ensureDialog() {
181		if (this.dialog) return;
182
183		const useNative = supportsNativeDialog();
184		const dialogEl = document.createElement(useNative ? 'dialog' : 'div');
185		dialogEl.className = ROOT_CLASS;
186		dialogEl.setAttribute('role', 'dialog');
187		dialogEl.setAttribute('aria-modal', 'true');
188		const titleId = uid('hdg-lb-title');
189		const liveId = uid('hdg-lb-live');
190		dialogEl.setAttribute('aria-labelledby', titleId);
191		dialogEl.setAttribute('aria-describedby', liveId);
192		// dialog itself focusable as a fallback target before any controls render
193		dialogEl.setAttribute('tabindex', '-1');
194
195		dialogEl.innerHTML = `
196			<div class="hdg-lb__shell">
197				<figure class="hdg-lb__figure">
198					<div class="hdg-lb__media">
199						<img class="hdg-lb__img" alt="" tabindex="-1" />
200					</div>
201					<figcaption class="hdg-lb__desc"  aria-label="${this._escAttr(this.labels.description)}">
202						<h2 id="${titleId}" class="hdg-lb__title"></h2>
203						<p class="hdg-lb__credit"></p>
204						<div class="hdg-lb__body"></div>
205					</figcaption>
206				</figure>
207				<nav class="hdg-lb__nav" aria-label="${this._escAttr(this.labels.dialog)}">
208					<button type="button" class="hdg-lb__btn hdg-lb__prev" aria-label="${this._escAttr(this.labels.prev)}">
209						<span aria-hidden="true">&#9664;</span>
210					</button>
211					<button type="button" class="hdg-lb__btn hdg-lb__next" aria-label="${this._escAttr(this.labels.next)}">
212						<span aria-hidden="true">&#9654;</span>
213					</button>
214					<button type="button" class="hdg-lb__btn hdg-lb__close" aria-label="${this._escAttr(this.labels.close)}">
215						<span aria-hidden="true">&times;</span>
216					</button>
217				</nav>
218				<p id="${liveId}" class="hdg-lb__live" aria-live="polite" aria-atomic="true"></p>
219			</div>
220		`;
221
222		document.body.appendChild(dialogEl);
223		this.dialog = dialogEl;
224		this.shim = new DialogShim(dialogEl);
225		this.focusTrap = new FocusTrap(dialogEl);
226
227		// Wire controls
228		dialogEl.querySelector('.hdg-lb__prev').addEventListener('click', () => this.prev());
229		dialogEl.querySelector('.hdg-lb__next').addEventListener('click', () => this.next());
230		dialogEl.querySelector('.hdg-lb__close').addEventListener('click', () => this.close());
231
232		// Backdrop click closes (only when clicking the dialog element itself,
233		// not its content). Native <dialog> emits the click on the dialog
234		// element when the user clicks the backdrop; bubble check disambiguates.
235		dialogEl.addEventListener('click', (e) => {
236			if (e.target === dialogEl) this.close();
237		});
238
239		// Keyboard navigation while open
240		dialogEl.addEventListener('keydown', this._onKeydown);
241
242		// Swipe on the media area only (so swiping on the desc column scrolls)
243		const media = dialogEl.querySelector('.hdg-lb__media');
244		this.detachSwipe = attachSwipe(media, {
245			onSwipeLeft: () => this.next(),
246			onSwipeRight: () => this.prev()
247		});
248
249		// Close hook for focus restore
250		this.shim.on('close', () => {
251			this.focusTrap.release({ restoreFocus: false });
252			this._restoreTrigger();
253		});
254		this.shim.on('cancel', () => { /* ESC handled — close event will follow */ });
255	}
256
257	_onKeydown(e) {
258		if (e.key === 'ArrowLeft')  { e.preventDefault(); this.prev(); }
259		else if (e.key === 'ArrowRight') { e.preventDefault(); this.next(); }
260		else if (e.key === 'Home')       { e.preventDefault(); this.goto(0); }
261		else if (e.key === 'End')        { e.preventDefault(); this.goto(this._currentItems.length - 1); }
262		// ESC: native dialog handles it via 'cancel'. For the fallback path,
263		// DialogShim wires its own keyboard listener. Either way, lightbox.js
264		// doesn't need to handle ESC explicitly.
265	}
266
267	// ----- slide rendering -------------------------------------------------
268
269	prev() {
270		if (!this._currentItems) return;
271		const n = this._currentItems.length;
272		this.goto((this._currentIndex - 1 + n) % n);
273	}
274
275	next() {
276		if (!this._currentItems) return;
277		const n = this._currentItems.length;
278		this.goto((this._currentIndex + 1) % n);
279	}
280
281	goto(index) {
282		if (!this._currentItems) return;
283		this._currentIndex = ((index % this._currentItems.length) + this._currentItems.length) % this._currentItems.length;
284		this._renderSlide();
285		this._announceSlide();
286		this._refocusImage();
287	}
288
289	/**
290	 * Force the <img> to (re-)receive focus so screen readers announce the new
291	 * accessible name. Without the explicit blur step, NVDA + Firefox doesn't
292	 * re-announce when focus is already on the same element across slide changes.
293	 */
294	_refocusImage() {
295		const img = this.dialog.querySelector('.hdg-lb__img');
296		if (!img) return;
297		if (document.activeElement === img) {
298			img.blur();
299		}
300		requestAnimationFrame(() => {
301			try { img.focus({ preventScroll: true }); }
302			catch (e) { img.focus(); }
303		});
304	}
305
306	_renderSlide() {
307		const item = this._currentItems[this._currentIndex];
308		const fig = this.dialog.querySelector('.hdg-lb__figure');
309		const img = this.dialog.querySelector('.hdg-lb__img');
310		const title = this.dialog.querySelector('.hdg-lb__title');
311		const credit = this.dialog.querySelector('.hdg-lb__credit');
312		const body = this.dialog.querySelector('.hdg-lb__body');
313
314		// Update image. Use a temporary loader so a slow image doesn't show
315		// a broken icon during the swap.
316		img.removeAttribute('src');
317		// Fallback chain: explicit alt → caption → plain text from description →
318		// finally the position label. Empty alts on legacy <img> are common so
319		// the description fallback is what actually populates many gallery items.
320		const altText =
321			item.alt
322			|| item.caption
323			|| textFromHTML(item.description)
324			|| this.labels.position(this._currentIndex + 1, this._currentItems.length);
325		img.alt = altText;
326		// alt on the <img> is the authoritative accessible name. The live region
327		// in _announceSlide() provides a redundant announcement so screen readers
328		// that don't reliably announce focused <img> elements (older NVDA + Firefox)
329		// still hear the new slide's name.
330		// Width/height attributes prevent CLS while the new image loads.
331		if (item.width)  img.setAttribute('width', item.width);  else img.removeAttribute('width');
332		if (item.height) img.setAttribute('height', item.height); else img.removeAttribute('height');
333		img.onerror = () => {
334			img.removeAttribute('src');
335			img.alt = (item.alt || 'Image failed to load') + ' — error';
336		};
337		img.src = item.src;
338
339		title.textContent = item.caption || '';
340		title.style.display = item.caption ? '' : 'none';
341
342		credit.textContent = item.credit || '';
343		credit.style.display = item.credit ? '' : 'none';
344
345		// description is already sanitised by markup-adapter.js
346		body.innerHTML = item.description || '';
347		body.style.display = item.description ? '' : 'none';
348
349		// Hide the whole description column when all three slots are empty.
350		// This handles legacy items like detail_glightbox.php line 424 where
351		// data-description="" — we'd otherwise have an empty focusable region
352		// that VO announces with no content inside.
353		const desc = this.dialog.querySelector('.hdg-lb__desc');
354		const hasDescContent = Boolean(item.caption || item.credit || item.description);
355		if (desc) {
356			desc.style.display = hasDescContent ? '' : 'none';
357			if (hasDescContent) {
358				//desc.setAttribute('tabindex', '0');
359				desc.removeAttribute('tabindex');
360			} else {
361				// No content → not a Tab stop, not announced as a region.
362				desc.removeAttribute('tabindex');
363			}
364		}
365
366		// Figure gets a composite accessible label so a single TAB-focus
367		// announces everything the user needs ("caption — credit").
368		const accessibleName = [item.caption, item.credit].filter(Boolean).join(' — ')
369			|| item.alt
370			|| this.labels.position(this._currentIndex + 1, this._currentItems.length);
371		fig.setAttribute('aria-label', accessibleName);
372
373		// Preload neighbours
374		if (this.options.preloadAdjacent && this._currentItems.length > 1) {
375			const n = this._currentItems.length;
376			const prevSrc = this._currentItems[(this._currentIndex - 1 + n) % n].src;
377			const nextSrc = this._currentItems[(this._currentIndex + 1) % n].src;
378			if (prevSrc) new Image().src = prevSrc;
379			if (nextSrc) new Image().src = nextSrc;
380		}
381	}
382
383	_announceSlide() {
384		const live = this.dialog.querySelector('.hdg-lb__live');
385		const item = this._currentItems[this._currentIndex];
386		const position = this.labels.position(this._currentIndex + 1, this._currentItems.length);
387		const alt = item.alt || item.caption || textFromHTML(item.description) || '';
388		const text = alt ? `${position}: ${alt}` : position;
389		// Toggling content forces AT to re-announce even on identical text.
390		live.textContent = '';
391		setTimeout(() => { live.textContent = text; }, 30);
392	}
393
394	_restoreTrigger() {
395		const t = this._trigger;
396		this._trigger = null;
397		if (!t || typeof t.focus !== 'function') return;
398		// Defer one frame past the close so the dialog finishes teardown.
399		requestAnimationFrame(() => {
400			try { t.focus({ preventScroll: false }); }
401			catch (e) { t.focus(); }
402			if (typeof t.scrollIntoView === 'function') {
403				try { t.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
404				catch (e) { t.scrollIntoView(); }
405			}
406		});
407	}
408
409	_escAttr(s) {
410		return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
411	}
412}
413
414// ----- auto-init -----------------------------------------------------------
415
416if (typeof window !== 'undefined' && window.HDG_LIGHTBOX_AUTOINIT !== false) {
417	const start = () => {
418		try {
419			window.hdgLightbox = new Lightbox();
420			window.hdgLightbox.init();
421		} catch (err) {
422			console.error('[hdg-lightbox] init failed:', err);
423		}
424	};
425	if (document.readyState === 'loading') {
426		document.addEventListener('DOMContentLoaded', start, { once: true });
427	} else {
428		start();
429	}
430}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.