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">◀</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">▶</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">×</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, '&').replace(/"/g, '"').replace(/</g, '<'); 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.