/*
RFC-0932: Client-side QR code modal for external links. Attaches click/keyboard
handlers to [data-qr-trigger] elements, lazy-loads the qrcode library on first activation,
and manages the modal lifecycle (open, close, focus trap, body scroll lock).
- Do not generate QR codes for internal links or non-HTTP links.
- Do not manage entitlement state — the orchestrator only calls initExternalLinkQr() when entitled.
- Do not create the modal DOM — the modal element is rendered by layout-component.astro.
- RFC-0932: initial implementation — QR modal lifecycle, dynamic qrcode import, focus trap, body scroll lock.
- RFC-0932: honor the data-external-link-qr="off" opt-out in the delegated click/keydown handlers — opted-out external links (footer review QR, nachweis links) navigate directly instead of opening the QR modal.
- RFC-1248: VITE-CLIENT-DEP-02 lazy-import gate + SCAN-02 validator reads-root existence check (RFC-1248)
*/
export interface ExternalLinkQrOptions {
/** CSS selector for the modal container element. Default: "[data-external-link-qr-modal]" */
modalSelector?: string;
/** QR code error correction level. Default: "M" */
errorCorrectionLevel?: "L" | "M" | "Q" | "H";
}
const DEFAULT_MODAL_SELECTOR = "[data-external-link-qr-modal]";
const DEFAULT_ERROR_CORRECTION = "M" as const;
let initialized = false;
export function initExternalLinkQr(options?: ExternalLinkQrOptions): void {
if (initialized) return;
initialized = true;
const modalSelector = options?.modalSelector ?? DEFAULT_MODAL_SELECTOR;
const errorCorrectionLevel = options?.errorCorrectionLevel ?? DEFAULT_ERROR_CORRECTION;
const modalEl = document.querySelector(modalSelector);
if (!modalEl) return;
const modal: HTMLElement = modalEl;
const overlay = modal.querySelector("[data-qr-overlay]");
const canvas = modal.querySelector("[data-qr-canvas]");
const closeBtnEl = modal.querySelector("[data-qr-close]");
const openLinkBtn = modal.querySelector("[data-qr-open-link]");
const titleEl = modal.querySelector("[data-qr-title]");
const errorEl = modal.querySelector("[data-qr-error]");
if (!overlay || !closeBtnEl) return;
const closeBtn: HTMLButtonElement = closeBtnEl;
let lastFocused: HTMLElement | null = null;
function openModal(href: string, trigger: HTMLElement): void {
lastFocused = trigger;
if (titleEl) {
try {
const url = new URL(href);
titleEl.textContent = url.hostname.replace(/^www\./, "");
} catch {
titleEl.textContent = href;
}
}
if (openLinkBtn) {
openLinkBtn.setAttribute("href", href);
}
if (errorEl) {
errorEl.hidden = true;
}
modal.hidden = false;
document.body.style.overflow = "hidden";
if (canvas) {
generateQr(href).catch((err) => {
console.error(err);
if (errorEl) {
errorEl.hidden = false;
}
});
}
closeBtn.focus();
}
async function generateQr(href: string): Promise {
if (!canvas) return;
const { toCanvas } = await import("qrcode/lib/browser.js");
await toCanvas(canvas, href, {
width: 256,
margin: 2,
errorCorrectionLevel,
});
}
function closeModal(): void {
modal.hidden = true;
document.body.style.overflow = "";
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
if (lastFocused) {
lastFocused.focus();
lastFocused = null;
}
}
function isModalOpen(): boolean {
return !modal.hidden;
}
// Anchors marked data-external-link-qr="off" opt out of the QR modal entirely
// (e.g. the footer review QR code, which is already a QR — it must navigate
// directly). external-links.ts honors the same flag for trigger injection.
function isQrOptedOut(anchor: Element): boolean {
return anchor.getAttribute("data-external-link-qr") === "off";
}
function handleDelegatedClick(e: Event): void {
const anchor = (e.target as HTMLElement)?.closest?.("a[data-external-link]");
if (!anchor || isQrOptedOut(anchor)) return;
const href = anchor.getAttribute("href");
if (!href) return;
e.preventDefault();
openModal(href, anchor as HTMLElement);
}
function handleDelegatedKeydown(e: KeyboardEvent): void {
if (e.key !== "Enter" && e.key !== " ") return;
const anchor = (e.target as HTMLElement)?.closest?.("a[data-external-link]");
if (!anchor || isQrOptedOut(anchor)) return;
const href = anchor.getAttribute("href");
if (!href) return;
e.preventDefault();
openModal(href, anchor as HTMLElement);
}
function handleKeydown(e: KeyboardEvent): void {
if (!isModalOpen()) return;
if (e.key === "Escape") {
e.preventDefault();
closeModal();
return;
}
if (e.key === "Tab") {
const focusable = Array.from(
modal.querySelectorAll('button, [href], [tabindex]:not([tabindex="-1"])'),
).filter((el) => !el.hasAttribute("disabled") && el.offsetParent !== null);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
}
function attachTriggers(): void {
document.addEventListener("click", handleDelegatedClick);
document.addEventListener("keydown", handleDelegatedKeydown);
}
overlay.addEventListener("click", closeModal);
closeBtn.addEventListener("click", closeModal);
document.addEventListener("keydown", handleKeydown);
if (canvas) {
canvas.style.cursor = "pointer";
canvas.addEventListener("click", () => {
const link = openLinkBtn?.getAttribute("href");
if (link) window.open(link, "_blank", "noopener,noreferrer");
closeModal();
});
}
if (openLinkBtn) {
openLinkBtn.addEventListener("click", () => {
closeModal();
});
}
attachTriggers();
modal.setAttribute("data-external-link-qr-modal-initialized", "true");
}