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 = [];
}
}