import { FocusTrap } from '../shared/focus'; import { GALLERY_CHANGE_EVENT, GALLERY_CLOSE_EVENT, GALLERY_OPEN_EVENT, type GalleryChangeDetail, type GalleryOptions, } from './gallery.types'; const SELECTORS = { item: '[data-c42-gallery-item]', lightbox: '[data-c42-gallery-lightbox]', image: '[data-c42-gallery-image]', prev: '[data-c42-gallery-prev]', next: '[data-c42-gallery-next]', close: '[data-c42-gallery-close]', overlay: '[data-c42-gallery-overlay]', } as const; /** * Headless gallery / lightbox. Thumbnails open a navigable overlay with * prev/next, keyboard support, focus trapping and scroll locking. * * Markup: * ```html *
* *
*
* * * * *
*
* ``` */ export class Gallery { private readonly root: HTMLElement; private readonly lightbox: HTMLElement; private readonly image: HTMLImageElement; private readonly items: HTMLElement[]; private readonly loop: boolean; private readonly closeOnEscape: boolean; private readonly lockScroll: boolean; private readonly focusTrap: FocusTrap; private open = false; private currentIndex = 0; private previouslyFocused: HTMLElement | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: GalleryOptions = {}) { const lightbox = root.querySelector(SELECTORS.lightbox); const image = root.querySelector(SELECTORS.image); if (!lightbox || !image) { throw new Error('[42/gallery] Needs a lightbox and an image element.'); } this.root = root; this.lightbox = lightbox; this.image = image; this.items = Array.from(root.querySelectorAll(SELECTORS.item)); this.loop = options.loop ?? true; this.closeOnEscape = options.closeOnEscape ?? true; this.lockScroll = options.lockScroll ?? true; this.focusTrap = new FocusTrap(lightbox); this.init(); } private srcOf(item: HTMLElement): string { return item.dataset.src ?? item.querySelector('img')?.getAttribute('src') ?? ''; } private altOf(item: HTMLElement): string { return item.dataset.alt ?? item.querySelector('img')?.getAttribute('alt') ?? ''; } private init(): void { this.lightbox.setAttribute('role', 'dialog'); this.lightbox.setAttribute('aria-modal', 'true'); this.lightbox.setAttribute('hidden', ''); this.lightbox.dataset.state = 'closed'; this.items.forEach((item, index) => { const onClick = (): void => this.openAt(index); item.addEventListener('click', onClick); this.cleanups.push(() => item.removeEventListener('click', onClick)); }); this.bind(SELECTORS.prev, () => this.prev()); this.bind(SELECTORS.next, () => this.next()); this.bind(SELECTORS.close, () => this.close()); this.bind(SELECTORS.overlay, () => this.close()); } private bind(selector: string, handler: () => void): void { const el = this.lightbox.querySelector(selector); if (!el) { return; } const listener = (): void => handler(); el.addEventListener('click', listener); this.cleanups.push(() => el.removeEventListener('click', listener)); } private readonly onKeydown = (event: KeyboardEvent): void => { if (!this.open) { return; } if (event.key === 'Escape' && this.closeOnEscape) { event.preventDefault(); this.close(); } else if (event.key === 'ArrowRight') { event.preventDefault(); this.next(); } else if (event.key === 'ArrowLeft') { event.preventDefault(); this.prev(); } }; private show(index: number): void { const item = this.items[index]; if (!item) { return; } this.currentIndex = index; this.image.setAttribute('src', this.srcOf(item)); this.image.setAttribute('alt', this.altOf(item)); const detail: GalleryChangeDetail = { index, src: this.srcOf(item) }; this.root.dispatchEvent(new CustomEvent(GALLERY_CHANGE_EVENT, { detail, bubbles: true })); } openAt(index: number): void { if (this.items.length === 0) { return; } this.previouslyFocused = document.activeElement as HTMLElement | null; this.show(index); this.open = true; this.lightbox.removeAttribute('hidden'); this.lightbox.dataset.state = 'open'; if (this.lockScroll) { document.body.style.overflow = 'hidden'; } this.focusTrap.activate(); document.addEventListener('keydown', this.onKeydown, true); this.root.dispatchEvent(new CustomEvent(GALLERY_OPEN_EVENT, { bubbles: true })); } close(): void { if (!this.open) { return; } this.open = false; this.lightbox.setAttribute('hidden', ''); this.lightbox.dataset.state = 'closed'; if (this.lockScroll) { document.body.style.overflow = ''; } this.focusTrap.deactivate(); document.removeEventListener('keydown', this.onKeydown, true); this.previouslyFocused?.focus?.(); this.previouslyFocused = null; this.root.dispatchEvent(new CustomEvent(GALLERY_CLOSE_EVENT, { bubbles: true })); } next(): void { const last = this.items.length - 1; if (this.currentIndex >= last) { if (!this.loop) { return; } this.show(0); } else { this.show(this.currentIndex + 1); } } prev(): void { if (this.currentIndex <= 0) { if (!this.loop) { return; } this.show(this.items.length - 1); } else { this.show(this.currentIndex - 1); } } get index(): number { return this.currentIndex; } get isOpen(): boolean { return this.open; } on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.close(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }