I'm working on my portfolio site, and learning how to write TypeScript and test scripts. I'm stuck resolving these errors:
RUN v3.2.4 /Users/sigma/Documents/Websites/Site/Site 5
❯ tests/lightbox.test.ts (3 tests | 3 failed) 3087ms
× lightbox dialog > opens and focuses the Close button 1050ms
→ expected false to be true // Object.is equality
× lightbox dialog > updates caption and description from dataset 1016ms
→ expected false to be true // Object.is equality
× lightbox dialog > prev/next disabled at the ends 1020ms
→ expected false to be true // Object.is equality
FAIL tests/lightbox.test.ts > lightbox dialog > opens and focuses the Close button
AssertionError: expected false to be true // Object.is equality
❯ tests/lightbox.test.ts:72:67
70|
71| const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
72| await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
| ^
73| const closeBtn = screen.getByRole('button', { name: /close/i });
74| expect(closeBtn).toHaveFocus();
❯ runWithExpensiveErrorDiagnosticsDisabled node_modules/@testing-library/dom/dist/config.js:47:12
❯ checkCallback node_modules/@testing-library/dom/dist/wait-for.js:124:77
❯ Timeout.checkRealTimersCallback node_modules/@testing-library/dom/dist/wait-for.js:118:16
FAIL tests/lightbox.test.ts > lightbox dialog > updates caption and description from dataset
AssertionError: expected false to be true // Object.is equality
❯ tests/lightbox.test.ts:87:67
85|
86| const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
87| await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
| ^
88| expect(screen.getByText('Two')).toBeInTheDocument();
89| expect(screen.getByText('Desc 2')).toBeInTheDocument();
❯ runWithExpensiveErrorDiagnosticsDisabled node_modules/@testing-library/dom/dist/config.js:47:12
❯ checkCallback node_modules/@testing-library/dom/dist/wait-for.js:124:77
❯ Timeout.checkRealTimersCallback node_modules/@testing-library/dom/dist/wait-for.js:118:16
FAIL tests/lightbox.test.ts > lightbox dialog > prev/next disabled at the ends
AssertionError: expected false to be true // Object.is equality
❯ tests/lightbox.test.ts:106:67
104|
105| const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
106| await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
| ^
107|
108| await waitFor(() => {
❯ runWithExpensiveErrorDiagnosticsDisabled node_modules/@testing-library/dom/dist/config.js:47:12
❯ checkCallback node_modules/@testing-library/dom/dist/wait-for.js:124:77
❯ Timeout.checkRealTimersCallback node_modules/@testing-library/dom/dist/wait-for.js:118:16
Test Files 1 failed (1)
Tests 3 failed (3)
Start at 10:21:44
Duration 3.77s (transform 25ms, setup 123ms, collect 39ms, tests 3.09s, environment 281ms, prepare 75ms)
So my dialogs work when I run the code in the browser. Just for reference here are the snippets of the code:
...
<li class="card">
<h3>Import from LinkedIn</h3>
<p>One-click import of saved jobs from LinkedIn to reduce manual data entry.</p>
<hr />
<figure class="shot">
<a href="assets/img/full/image_full.png"
data-lightbox
data-full="assets/img/full/image_full.png"
data-title="Wireframe: Importing saved jobs from LinkedIn"
data-description="One-click import of saved jobs from LinkedIn to reduce manual data entry."
data-lightbox-group="ga-wireframes"
aria-label="View full-size wireframe: Importing saved jobs from LinkedIn">
<img src="assets/img/thumb/image_thumb.png"
alt="Wireframe: Importing saved jobs from LinkedIn"
width="751" height="1158" loading="lazy" decoding="async">
</a>
<figcaption>Wireframe — Import from LinkedIn</figcaption>
</figure>
</li>
<!-- Lightbox dialog (shared, keep once per page) -->
<dialog class="lb-dialog" aria-modal="true" aria-label="Image Viewer" tabindex="-1">
<div class="lb-header">
<h2 class="lb-title" id="lb-title">Image Viewer</h2>
<button class="btn lb-btn" id="lb-close-btn" aria-label="Close" type="button">
<i class="fa-solid fa-xmark"></i>
<span class="sr-only">Close</span>
</button>
</div>
<div class="lb-nav">
<button type="button" class="lb-prev lb-arrow" aria-label="Previous image" aria-disabled="true" disabled>
<i class="fa-solid fa-chevron-left"></i>
</button>
<button type="button" class="lb-next lb-arrow" aria-label="Next image" aria-disabled="false">
<i class="fa-solid fa-chevron-right"></i>
</button>
</div>
<div class="lb-body">
<img class="lb-media" alt="">
</div>
<div class="lb-bar">
<p class="lb-caption" id="lb-caption"></p>
<p class="lb-desc" id="lb-desc"></p>
<div class="lb-actions">
<button class="btn lb-btn" value="close" aria-label="Close" id="lb-bar-close-btn" type="button">
<i class="fa-solid fa-xmark"></i> Close
</button>
</div>
</div>
</dialog>
And the lightbox-dialog.ts file:
type Trigger = HTMLElement & {
dataset: {
lightbox?: string;
full?: string;
title?: string;
lightboxGroup?: string;
caption?: string;
description?: string;
};
};
export class DialogLightbox {
private readonly dialog!: HTMLDialogElement;
private readonly bodyEl!: HTMLDivElement;
private readonly img!: HTMLImageElement;
private readonly captionEl!: HTMLParagraphElement;
private readonly descEl!: HTMLParagraphElement;
private readonly titleEl!: HTMLElement;
private readonly prevBtn!: HTMLButtonElement;
private readonly nextBtn!: HTMLButtonElement;
private readonly closeBtn!: HTMLButtonElement;
public readonly triggers: Trigger[] = [];
private group: Trigger[] = [];
private index = 0;
private lastActive: HTMLElement | null = null;
constructor() {
// For tests, select *all* elements with data-lightbox
// This allows easy swap between <a> and <button> in test markup
this.triggers = Array.from(document.querySelectorAll("[data-lightbox]")) as Trigger[];
this.dialog = document.querySelector("dialog.lb-dialog") as HTMLDialogElement;
if (!this.dialog) return;
this.bodyEl = this.dialog.querySelector(".lb-body") as HTMLDivElement;
this.img = this.dialog.querySelector(".lb-media") as HTMLImageElement;
this.captionEl = this.dialog.querySelector(".lb-caption") as HTMLParagraphElement;
this.descEl = this.dialog.querySelector('.lb-desc') as HTMLParagraphElement;
this.titleEl = this.dialog.querySelector(".lb-title") as HTMLElement;
this.prevBtn = this.dialog.querySelector(".lb-prev") as HTMLButtonElement;
this.nextBtn = this.dialog.querySelector(".lb-next") as HTMLButtonElement;
this.closeBtn = this.dialog.querySelector(".lb-header .lb-btn") as HTMLButtonElement
|| this.dialog.querySelector(".lb-bar .lb-btn") as HTMLButtonElement;
this.prevBtn.type = "button";
this.nextBtn.type = "button";
if (this.closeBtn) this.closeBtn.type = "button";
if (this.captionEl) this.captionEl.setAttribute("aria-live", "polite");
if (this.descEl) this.descEl.setAttribute("aria-live", "polite");
if (this.bodyEl) this.bodyEl.setAttribute("role", "document");
if (this.img) {
this.img.setAttribute("decoding", "async");
this.img.setAttribute("loading", "eager");
(this.img as any).fetchPriority = "high";
this.img.addEventListener("load", () => {
const nW = this.img.naturalWidth || 0;
const nH = this.img.naturalHeight || 0;
this.img.style.setProperty("--media-natural-w", String(nW));
this.img.style.setProperty("--media-natural-h", String(nH));
if (nH > nW) {
this.img.classList.add("is-portrait");
this.img.classList.remove("is-landscape");
} else {
this.img.classList.add("is-landscape");
this.img.classList.remove("is-portrait");
}
const anyBodyEl = this.bodyEl as any;
if (anyBodyEl && typeof anyBodyEl.scrollTo === "function") {
anyBodyEl.scrollTo({ top: 0 });
}
});
}
this.bind();
this.updateArrowStates();
}
private bind() {
this.triggers.forEach(t => {
t.addEventListener("click", ev => {
// DEBUG: log for test
console.log("[Lightbox TEST] click fired for", t.dataset.title || t.textContent);
ev.preventDefault();
this.openFromTrigger(t);
});
});
this.prevBtn.addEventListener("click", () => { if (!this.prevBtn.disabled) this.prev(); });
this.nextBtn.addEventListener("click", () => { if (!this.nextBtn.disabled) this.next(); });
if (this.closeBtn) {
this.closeBtn.addEventListener("click", e => {
e.preventDefault();
this.close();
});
}
this.dialog.addEventListener("keydown", (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowLeft":
if (!this.prevBtn.disabled) { e.preventDefault(); this.prev(); }
break;
case "ArrowRight":
if (!this.nextBtn.disabled) { e.preventDefault(); this.next(); }
break;
case "Home":
if (this.group.length > 1 && this.index > 0) {
e.preventDefault();
this.index = 0; this.render();
}
break;
case "End":
if (this.group.length > 1 && this.index < this.group.length - 1) {
e.preventDefault();
this.index = this.group.length - 1; this.render();
}
break;
}
});
this.dialog.addEventListener("cancel", e => {
e.preventDefault();
this.close();
});
this.dialog.addEventListener("click", (e: MouseEvent) => {
const rect = this.dialog.getBoundingClientRect();
const inside =
e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom;
if (!inside) this.close();
});
this.dialog.addEventListener("close", () => {
document.body.classList.remove("lb-no-scroll");
setTimeout(() => {
this.lastActive?.focus();
}, 0);
});
}
private setButtonDisabled(btn: HTMLButtonElement | null, disabled: boolean) {
if (!btn) return;
btn.disabled = disabled;
btn.setAttribute("aria-disabled", disabled ? "true" : "false");
}
private updateArrowStates() {
const isGallery = this.group.length > 1;
const isFirst = this.index === 0;
const isLast = this.index === this.group.length - 1;
this.prevBtn.style.display = isGallery ? "" : "none";
this.nextBtn.style.display = isGallery ? "" : "none";
this.setButtonDisabled(this.prevBtn, !isGallery || isFirst);
this.setButtonDisabled(this.nextBtn, !isGallery || isLast);
}
private openFromTrigger(t: Trigger) {
const g = t.dataset.lightboxGroup?.trim();
const all = Array.from(document.querySelectorAll("[data-lightbox]")) as Trigger[];
this.group = g && g.length
? all.filter(x => (x.dataset.lightboxGroup || "").trim() === g)
: all;
this.index = Math.max(0, this.group.findIndex(x => x === t));
this.open();
}
private open() {
this.lastActive = (document.activeElement as HTMLElement) || null;
this.dialog.setAttribute("open", "");
try { this.render(); } catch {}
document.body.classList.add("lb-no-scroll");
const hasShowModal = typeof (this.dialog as any).showModal === "function";
if (hasShowModal) {
try { (this.dialog as any).showModal(); } catch {}
}
document.body.setAttribute("data-lb-opened", "true");
if (this.closeBtn) this.closeBtn.focus();
else this.dialog.focus();
}
private close() {
if (typeof this.dialog.close === "function") {
this.dialog.close();
} else {
this.dialog.removeAttribute("open");
document.body.classList.remove("lb-no-scroll");
setTimeout(() => {
this.lastActive?.focus();
}, 0);
}
}
private prev() {
if (this.group.length <= 1) return;
if (this.index > 0) { this.index--; this.render(); }
}
private next() {
if (this.group.length <= 1) return;
if (this.index < this.group.length - 1) { this.index++; this.render(); }
}
private render() {
const cur = this.group[this.index];
const src = cur.dataset.full || cur.getAttribute("href") || "";
const title = cur.dataset.title || cur.getAttribute("title") || "";
const caption = cur.dataset.caption || title;
const desc = cur.dataset.description || "";
if (this.img.getAttribute("src") !== src) {
this.img.removeAttribute("src");
this.img.setAttribute("src", src);
}
this.titleEl.textContent = title || "Image Viewer";
this.captionEl.textContent = caption;
this.descEl.textContent = desc;
this.img.alt = title || "Expanded image";
this.dialog.setAttribute("aria-label", title ? `Image viewer — ${title}` : "Image viewer");
this.updateArrowStates();
(this.bodyEl as any)?.scrollTo?.({ top: 0 });
}
}
export function initLightbox(): DialogLightbox | null {
const dlg = document.querySelector("dialog.lb-dialog") as HTMLDialogElement | null;
if (!dlg) return null;
const instance = new DialogLightbox();
try {
(globalThis as any).__lb = instance;
(globalThis as any).__lbOpen = (idx: number) => {
const all = Array.from(document.querySelectorAll("[data-lightbox]")) as Trigger[];
(instance as any).group = all;
(instance as any).index = Math.max(0, Math.min(idx | 0, all.length - 1));
(instance as any).open();
};
} catch {}
return instance;
}
and the test script:
/// <reference types="vitest/globals" />
import { fireEvent, screen, waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { initLightbox } from '../scripts/lightbox-dialog';
// Polyfill for <dialog> methods in jsdom tests
beforeAll(() => {
const proto = HTMLDialogElement.prototype as any;
if (typeof proto.showModal !== 'function') {
proto.showModal = function showModal(this: HTMLDialogElement) {
this.setAttribute('open', '');
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '-1');
(this as unknown as HTMLElement).focus?.();
};
}
if (typeof proto.close !== 'function') {
proto.close = function close(this: HTMLDialogElement) {
this.removeAttribute('open');
};
}
});
// IMPORTANT: Update test triggers so they have visible content!
function buildMarkup(): void {
document.body.innerHTML = `
<!-- Triggers (with visible text, like your real UI) -->
<a data-lightbox data-title="One" data-description="Desc 1" href="#one">One</a>
<a data-lightbox data-title="Two" data-description="Desc 2" href="#two">Two</a>
<a data-lightbox data-title="Three" data-description="Desc 3" href="#three">Three</a>
<div id="one" hidden></div>
<div id="two" hidden></div>
<div id="three" hidden></div>
<dialog class="lb-dialog" aria-modal="true"
aria-labelledby="lb-title"
aria-describedby="lb-caption lb-desc">
<div class="lb-header">
<h2 class="lb-title" id="lb-title">Image viewer</h2>
<button class="lb-btn">Close</button>
</div>
<div class="lb-body">
<img class="lb-media" alt="Preview" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==">
</div>
<div class="lb-nav">
<button class="lb-prev lb-arrow" aria-disabled="true"><i></i></button>
<button class="lb-next lb-arrow"><i></i></button>
</div>
<div class="lb-bar">
<p class="lb-caption" id="lb-caption"></p>
<p class="lb-desc" id="lb-desc"></p>
<div class="lb-actions"></div>
</div>
</dialog>
`;
}
describe('lightbox dialog', () => {
beforeEach(() => {
buildMarkup();
initLightbox();
});
it('opens and focuses the Close button', async () => {
const __lbOpen = (globalThis as any).__lbOpen as undefined | ((i: number) => void);
if (typeof __lbOpen === 'function') {
__lbOpen(0);
} else {
const first = screen.getAllByRole('link')[0] as HTMLAnchorElement;
await userEvent.click(first);
}
const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
const closeBtn = screen.getByRole('button', { name: /close/i });
expect(closeBtn).toHaveFocus();
});
it('updates caption and description from dataset', async () => {
const __lbOpen = (globalThis as any).__lbOpen as undefined | ((i: number) => void);
if (typeof __lbOpen === 'function') {
__lbOpen(1);
} else {
const second = screen.getAllByRole('link')[1] as HTMLAnchorElement;
await userEvent.click(second);
}
const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
expect(screen.getByText('Two')).toBeInTheDocument();
expect(screen.getByText('Desc 2')).toBeInTheDocument();
});
it('prev/next disabled at the ends', async () => {
const links = screen.getAllByRole('link') as HTMLAnchorElement[];
const firstLink = links[0];
const thirdLink = links[2];
const __lbOpen = (globalThis as any).__lbOpen as undefined | ((i: number) => void);
if (typeof __lbOpen === 'function') {
__lbOpen(0);
} else {
await userEvent.click(firstLink);
}
const dialogEl = document.querySelector('.lb-dialog') as HTMLDialogElement;
await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
await waitFor(() => {
const prev = document.querySelector<HTMLButtonElement>('.lb-prev')!;
const next = document.querySelector<HTMLButtonElement>('.lb-next')!;
expect(prev).toBeDisabled();
expect(next).not.toBeDisabled();
});
if (typeof __lbOpen === 'function') {
__lbOpen(2);
} else {
await userEvent.click(thirdLink);
}
await waitFor(() => expect(dialogEl.hasAttribute('open')).toBe(true));
await waitFor(() => {
const prev = document.querySelector<HTMLButtonElement>('.lb-prev')!;
const next = document.querySelector<HTMLButtonElement>('.lb-next')!;
expect(prev).not.toBeDisabled();
expect(next).toBeDisabled();
});
fireEvent.keyDown(dialogEl, { key: 'Home' });
await waitFor(() => {
const prev = document.querySelector<HTMLButtonElement>('.lb-prev')!;
expect(prev).toBeDisabled();
});
fireEvent.keyDown(dialogEl, { key: 'End' });
await waitFor(() => {
const next = document.querySelector<HTMLButtonElement>('.lb-next')!;
expect(next).toBeDisabled();
});
});
});
any help will be greatly appreciated.