All files / src/scripts media-gallery.ts

96.42% Statements 81/84
83.33% Branches 40/48
100% Functions 13/13
100% Lines 72/72

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166                        16x 15x                 1x     19x 19x 57x   57x 57x         8x 8x 8x 8x       8x     8x     8x 1x 1x 1x 1x 1x 1x 1x 1x   7x 7x 7x 7x 7x           9x 8x 8x 8x 8x               20x 20x 20x 20x 20x               20x 20x 20x 2x 2x   20x         34x       34x 34x   33x 33x   13x     13x   11x   11x 33x 30x 30x     11x 10x 10x     11x           5x 5x 2x                           1x 6x 3x 1x 1x 2x 2x 2x       1x  
// Shared image-gallery navigation for the desktop project & room detail pages.
//
// One module drives any page exposing the gallery DOM contract:
//   #main-media-container             – hero (LqipPicture <picture>, <img>, or <video>)
//   .media-thumbnail / .thumbnail-btn – buttons with data-src / data-type / data-alt
//
// Navigation: thumbnail click, on-screen prev/next buttons, and Left/Right keys.
// Replaces the two near-duplicate inline scripts that previously only handled
// thumbnail clicks (see git history of projects/[id].astro + rooms/.../[slug].astro).
 
/** Wrap an index into [0, len) — steps off either end roll around. */
export function wrapIndex(i: number, len: number): number {
	if (len <= 0) return 0;
	return ((i % len) + len) % len;
}
 
interface GalleryState {
	container: HTMLElement;
	thumbs: HTMLElement[];
	index: number;
}
 
let state: GalleryState | null = null;
 
function setActive(index: number): void {
	Iif (!state) return;
	state.thumbs.forEach((thumb, i) => {
		const active = i === index;
		// Persistent active ring; the markup keeps its own `hover:` ring separately.
		thumb.classList.toggle("ring-2", active);
		thumb.classList.toggle("ring-primary-500", active);
	});
}
 
function swapHero(thumb: HTMLElement): void {
	Iif (!state) return;
	const { container } = state;
	const src = thumb.dataset.src;
	Iif (!src) return;
 
	// Drop the current hero media but keep the injected nav buttons, so prev/next
	// survive every swap. Covers <picture> (LqipPicture), <img>, and <video>.
	container
		.querySelectorAll(":scope > :not([data-gallery-nav])")
		.forEach((node) => {
			node.remove();
		});
 
	if (thumb.dataset.type === "video") {
		const video = document.createElement("video");
		video.src = src;
		video.controls = true;
		video.autoplay = true;
		video.muted = true;
		video.playsInline = true;
		video.className = "w-full h-full object-contain bg-overlay-darker";
		container.appendChild(video);
	} else {
		const img = document.createElement("img");
		img.src = src;
		img.alt = thumb.dataset.alt || "";
		img.className = "w-full h-full object-contain";
		container.appendChild(img);
	}
}
 
/** Show media at `i` (wrapped): swap the hero and highlight the matching thumb. */
export function show(i: number): void {
	if (!state) return;
	const index = wrapIndex(i, state.thumbs.length);
	state.index = index;
	swapHero(state.thumbs[index]);
	setActive(index);
}
 
function makeNavButton(
	label: string,
	dir: -1 | 1,
	side: "left" | "right",
): HTMLButtonElement {
	const btn = document.createElement("button");
	btn.type = "button";
	btn.dataset.galleryNav = side;
	btn.setAttribute("aria-label", label);
	btn.className = [
		"absolute top-1/2 -translate-y-1/2 z-10",
		side === "left" ? "left-2" : "right-2",
		"w-10 h-10 rounded-full flex items-center justify-center",
		"bg-overlay-dark text-foreground-inverse",
		"hover:bg-overlay-darker transition-colors",
		"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
	].join(" ");
	const d = side === "left" ? "M15 19l-7-7 7-7" : "M9 5l7 7-7 7";
	btn.innerHTML = `<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="${d}" /></svg>`;
	btn.addEventListener("click", (e) => {
		e.preventDefault();
		show((state?.index ?? 0) + dir);
	});
	return btn;
}
 
/** (Re)bind the gallery for the current page. Safe to call on every page load. */
export function init(): void {
	state = null;
 
	// On a project with rooms, ProjectDetailDesktop renders RoomGallery instead of
	// the flat hero gallery — there's nothing to navigate, so bail.
	const dataEl = document.getElementById("project-data");
	if (dataEl?.dataset.useRooms === "true") return;
 
	const container = document.getElementById("main-media-container");
	if (!container) return;
 
	const thumbs = Array.from(
		document.querySelectorAll<HTMLElement>(".media-thumbnail, .thumbnail-btn"),
	);
	if (thumbs.length < 2) return; // single image: nothing to step through
 
	state = { container, thumbs, index: 0 };
 
	thumbs.forEach((thumb, i) => {
		if (thumb.dataset.galleryBound === "true") return;
		thumb.dataset.galleryBound = "true";
		thumb.addEventListener("click", () => show(i));
	});
 
	if (!container.querySelector("[data-gallery-nav]")) {
		container.appendChild(makeNavButton("Previous photo", -1, "left"));
		container.appendChild(makeNavButton("Next photo", 1, "right"));
	}
 
	setActive(0); // highlight the SSR hero (index 0) without re-swapping it
}
 
// True when the focused element should keep the arrow keys (form fields,
// editable text, and media elements that seek with Left/Right).
function ownsArrowKeys(target: EventTarget | null): boolean {
	const el = target as HTMLElement | null;
	if (!el || typeof el.tagName !== "string") return false;
	return (
		el.tagName === "INPUT" ||
		el.tagName === "TEXTAREA" ||
		el.tagName === "SELECT" ||
		// A focused <video controls> / <audio> owns the arrow keys for seeking.
		el.tagName === "VIDEO" ||
		el.tagName === "AUDIO" ||
		el.isContentEditable === true
	);
}
 
// Bound once at module load. Bundled modules are deduped under ClientRouter, so
// the listener never stacks across SPA navigations; `init` re-runs per page via
// astro:page-load (fires on first load AND after every view-transition nav).
document.addEventListener("keydown", (e) => {
	if (!state || ownsArrowKeys(e.target)) return;
	if (e.key === "ArrowLeft") {
		e.preventDefault();
		show(state.index - 1);
	E} else if (e.key === "ArrowRight") {
		e.preventDefault();
		show(state.index + 1);
	}
});
 
document.addEventListener("astro:page-load", init);