|
|
|
@@ -0,0 +1,214 @@
|
|
|
|
|
// @vitest-environment jsdom
|
|
|
|
|
/**
|
|
|
|
|
* AttachmentLightbox — shared fullscreen modal for image/PDF/preview.
|
|
|
|
|
*
|
|
|
|
|
* Per RFC #2991 Phase 2, AttachmentLightbox owns:
|
|
|
|
|
* - Backdrop + centered viewport
|
|
|
|
|
* - Esc to close
|
|
|
|
|
* - Click-outside to close (stopPropagation on content)
|
|
|
|
|
* - Focus trap: focus enters close button on open, restores on close
|
|
|
|
|
* - prefers-reduced-motion respect
|
|
|
|
|
*
|
|
|
|
|
* NOTE: No @testing-library/jest-dom import — use textContent / className /
|
|
|
|
|
* getAttribute / checked / value checks to avoid jest-dom dependency errors.
|
|
|
|
|
*
|
|
|
|
|
* Covers:
|
|
|
|
|
* - Does not render when open=false
|
|
|
|
|
* - Renders dialog with role=dialog and aria-modal
|
|
|
|
|
* - Renders with provided aria-label
|
|
|
|
|
* - Close button has aria-label="Close preview"
|
|
|
|
|
* - Clicking backdrop (outside content) calls onClose
|
|
|
|
|
* - Clicking content does NOT call onClose (stopPropagation)
|
|
|
|
|
* - Escape key calls onClose
|
|
|
|
|
* - Focus moves to close button when opened
|
|
|
|
|
* - Focus restores to previous element when closed
|
|
|
|
|
* - Reduced motion: motion-reduce class on backdrop
|
|
|
|
|
* - Renders children inside the modal
|
|
|
|
|
*/
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
|
|
|
import React from "react";
|
|
|
|
|
|
|
|
|
|
import { AttachmentLightbox } from "../AttachmentLightbox";
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
cleanup();
|
|
|
|
|
vi.restoreAllMocks();
|
|
|
|
|
vi.resetModules();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** Renders the lightbox open with children and returns close fn */
|
|
|
|
|
function renderOpen(props?: Partial<React.ComponentProps<typeof AttachmentLightbox>>) {
|
|
|
|
|
const onClose = vi.fn();
|
|
|
|
|
const result = render(
|
|
|
|
|
<AttachmentLightbox
|
|
|
|
|
open={true}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
ariaLabel="Image preview"
|
|
|
|
|
{...props}
|
|
|
|
|
>
|
|
|
|
|
<img alt="test" src="data:image/png;base64,iVBORw0KGgo=" />
|
|
|
|
|
</AttachmentLightbox>,
|
|
|
|
|
);
|
|
|
|
|
return { ...result, onClose };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Render States ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("AttachmentLightbox — render", () => {
|
|
|
|
|
it("does not render when open=false", () => {
|
|
|
|
|
const { container } = render(
|
|
|
|
|
<AttachmentLightbox
|
|
|
|
|
open={false}
|
|
|
|
|
onClose={vi.fn()}
|
|
|
|
|
ariaLabel="Preview"
|
|
|
|
|
>
|
|
|
|
|
<div>content</div>
|
|
|
|
|
</AttachmentLightbox>,
|
|
|
|
|
);
|
|
|
|
|
expect(container.firstChild).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders dialog with role=dialog and aria-modal", () => {
|
|
|
|
|
renderOpen();
|
|
|
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
|
|
|
expect(dialog).toBeTruthy();
|
|
|
|
|
expect(dialog?.getAttribute("aria-modal")).toBe("true");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders with provided aria-label", () => {
|
|
|
|
|
renderOpen({ ariaLabel: "PDF: report-2026.pdf" });
|
|
|
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
|
|
|
expect(dialog?.getAttribute("aria-label")).toBe("PDF: report-2026.pdf");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("close button has aria-label='Close preview'", () => {
|
|
|
|
|
renderOpen();
|
|
|
|
|
const btn = document.querySelector('[aria-label="Close preview"]');
|
|
|
|
|
expect(btn).toBeTruthy();
|
|
|
|
|
expect(btn?.tagName).toBe("BUTTON");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders children inside the modal", () => {
|
|
|
|
|
renderOpen({ ariaLabel: "Preview" });
|
|
|
|
|
// Children are inside the dialog
|
|
|
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
|
|
|
expect(dialog?.querySelector("img")).toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("applies reduced-motion class on backdrop", () => {
|
|
|
|
|
renderOpen();
|
|
|
|
|
// The div[role="dialog"] IS the fixed backdrop — contains motion-reduce:transition-none
|
|
|
|
|
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
|
|
|
|
|
expect(dialog?.className).toContain("motion-reduce");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Interaction ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("AttachmentLightbox — interaction", () => {
|
|
|
|
|
it("calls onClose when close button is clicked", () => {
|
|
|
|
|
const onClose = vi.fn();
|
|
|
|
|
renderOpen({ onClose });
|
|
|
|
|
const btn = document.querySelector('[aria-label="Close preview"]') as HTMLButtonElement;
|
|
|
|
|
btn.click();
|
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("calls onClose when backdrop (outside content) is clicked", () => {
|
|
|
|
|
const onClose = vi.fn();
|
|
|
|
|
renderOpen({ onClose });
|
|
|
|
|
// The div[role="dialog"] IS the backdrop (fixed inset-0).
|
|
|
|
|
// Click on it — e.target === e.currentTarget triggers onBackdropClick.
|
|
|
|
|
const backdrop = document.querySelector('[role="dialog"]') as HTMLElement;
|
|
|
|
|
fireEvent.click(backdrop, { target: backdrop });
|
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does NOT call onClose when content (inside modal) is clicked", () => {
|
|
|
|
|
const onClose = vi.fn();
|
|
|
|
|
renderOpen({ onClose });
|
|
|
|
|
// Click on the img inside the modal
|
|
|
|
|
const img = document.querySelector("img") as HTMLElement;
|
|
|
|
|
fireEvent.click(img);
|
|
|
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("calls onClose on Escape key", () => {
|
|
|
|
|
const onClose = vi.fn();
|
|
|
|
|
renderOpen({ onClose });
|
|
|
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not call onClose for other keys", () => {
|
|
|
|
|
const onClose = vi.fn();
|
|
|
|
|
renderOpen({ onClose });
|
|
|
|
|
fireEvent.keyDown(document, { key: "Enter" });
|
|
|
|
|
fireEvent.keyDown(document, { key: "Tab" });
|
|
|
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ─── Focus Management ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("AttachmentLightbox — focus management", () => {
|
|
|
|
|
it("moves focus to close button when opened", () => {
|
|
|
|
|
renderOpen();
|
|
|
|
|
const btn = document.querySelector('[aria-label="Close preview"]') as HTMLButtonElement;
|
|
|
|
|
expect(document.activeElement).toBe(btn);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("restores focus to previous element when closed", () => {
|
|
|
|
|
// Create a button to hold focus before opening the modal
|
|
|
|
|
const outerBtn = document.createElement("button");
|
|
|
|
|
outerBtn.textContent = "Open modal";
|
|
|
|
|
document.body.appendChild(outerBtn);
|
|
|
|
|
outerBtn.focus();
|
|
|
|
|
expect(document.activeElement).toBe(outerBtn);
|
|
|
|
|
|
|
|
|
|
const onClose = vi.fn();
|
|
|
|
|
const { rerender } = render(
|
|
|
|
|
<AttachmentLightbox
|
|
|
|
|
open={false}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
ariaLabel="Preview"
|
|
|
|
|
>
|
|
|
|
|
<div>content</div>
|
|
|
|
|
</AttachmentLightbox>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Open the modal
|
|
|
|
|
rerender(
|
|
|
|
|
<AttachmentLightbox
|
|
|
|
|
open={true}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
ariaLabel="Preview"
|
|
|
|
|
>
|
|
|
|
|
<div>content</div>
|
|
|
|
|
</AttachmentLightbox>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Focus should now be on close button
|
|
|
|
|
const btn = document.querySelector('[aria-label="Close preview"]') as HTMLButtonElement;
|
|
|
|
|
expect(document.activeElement).toBe(btn);
|
|
|
|
|
|
|
|
|
|
// Close the modal
|
|
|
|
|
rerender(
|
|
|
|
|
<AttachmentLightbox
|
|
|
|
|
open={false}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
ariaLabel="Preview"
|
|
|
|
|
>
|
|
|
|
|
<div>content</div>
|
|
|
|
|
</AttachmentLightbox>,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Focus should be restored to outerBtn
|
|
|
|
|
expect(document.activeElement).toBe(outerBtn);
|
|
|
|
|
|
|
|
|
|
document.body.removeChild(outerBtn);
|
|
|
|
|
});
|
|
|
|
|
});
|