Files
indiekit-blog/js/lightbox.js
Ricardo 1026d728af a11y: fix all remaining WCAG 2.1 AA issues from audit round 2
- Focus traps for fediverse modal and lightbox dialogs (C3, C4)
- Search widget input label (C5)
- Blogroll widget tab ARIA semantics (C6)
- Footer social links "opens in new tab" warning (S5)
- Reply context aria-label on aside (S8)
- Photo alt text fallback includes post title (S10)
- Post categories use list markup (M3)
- Funkwhale now-playing bars aria-hidden (M7)
- TOC uses static Tailwind classes instead of dynamic (M9)
- Footer headings use proper aria heading roles (M15)
- Header anchor opacity increased to 1 for contrast (M18)
- Custom HTML widgets labeled as regions (M19)
- Empty collection placeholder role=status (M22)
- GitHub widget loading state announced (N5)
- Subscribe icon contrast improved (m1)
- All Permalink links have aria-label with post context (m3)
- Podroll audio element aria-label (m4)
- Obfuscated email link aria-label (m6)
- Fediverse follow button uses aria-label (M10)

Score: 53.6% → 92.9% (26/28 WCAG criteria passing)

Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596
2026-03-07 19:34:25 +01:00

119 lines
3.6 KiB
JavaScript

/**
* Alpine.js lightbox component for article images.
* Registers via alpine:init so it's available before Alpine starts.
* Click any image inside .e-content to view fullscreen.
* Navigate with arrow keys, close with Escape or click outside.
*/
document.addEventListener("alpine:init", () => {
Alpine.data("lightbox", () => ({
open: false,
src: "",
alt: "",
images: [],
currentIndex: 0,
triggerElement: null,
init() {
const container = this.$root;
const imgs = container.querySelectorAll(
".e-content img:not(.u-photo), .photo-gallery img.u-photo"
);
this.images = Array.from(imgs);
this.images.forEach((img, i) => {
img.style.cursor = "zoom-in";
img.setAttribute("tabindex", "0");
img.setAttribute("role", "button");
img.setAttribute("aria-label", (img.alt || "Image") + " — click to enlarge");
img.addEventListener("click", (e) => {
e.preventDefault();
this.show(i);
});
img.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.show(i);
}
});
});
},
show(index) {
this.triggerElement = this.images[index];
this.currentIndex = index;
const img = this.images[index];
// Use the largest source available
const picture = img.closest("picture");
if (picture) {
const source = picture.querySelector("source");
if (source) {
// Extract the URL from srcset (strip width descriptor)
const srcset = source.getAttribute("srcset") || "";
this.src = srcset.split(/\s+/)[0] || img.src;
} else {
this.src = img.src;
}
} else {
this.src = img.src;
}
this.alt = img.alt || "";
this.open = true;
document.body.style.overflow = "hidden";
// Move focus to close button for keyboard users
this.$nextTick(() => {
const closeBtn = document.querySelector('[x-ref="closeBtn"]');
if (closeBtn) closeBtn.focus();
});
},
close() {
this.open = false;
this.src = "";
document.body.style.overflow = "";
// Return focus to the image that triggered the lightbox
if (this.triggerElement) {
this.triggerElement.focus();
this.triggerElement = null;
}
},
next() {
if (this.images.length > 1) {
this.show((this.currentIndex + 1) % this.images.length);
}
},
prev() {
if (this.images.length > 1) {
this.show(
(this.currentIndex - 1 + this.images.length) % this.images.length
);
}
},
onKeydown(e) {
if (!this.open) return;
if (e.key === "Escape") this.close();
if (e.key === "ArrowRight") this.next();
if (e.key === "ArrowLeft") this.prev();
if (e.key === "Tab") {
const dialog = document.querySelector('[role="dialog"][aria-modal="true"]');
if (!dialog) return;
const focusable = Array.from(
dialog.querySelectorAll('button, [tabindex]:not([tabindex="-1"])')
).filter((el) => el.offsetParent !== null);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
},
}));
});