Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions src/components/ui/FullscreenViewer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import PropTypes from "prop-types";
import { ChevronLeft, ChevronRight, X } from "lucide-react";

const FullscreenViewer = ({
images,
currentIndex,
scale,
position,
isFullscreen,
isZoomed,
canGoPrev,
canGoNext,
isDragging,
containerRef,
onClose,
onPrev,
onNext,
onWheel,
onImageClick,
onMouseDown,
onMouseMove,
onMouseUp,
onTouchStart,
onTouchMove,
onTouchEnd,
}) => {
return (
<div
ref={containerRef}
aria-label="Visualizador de imagens em ecrã inteiro"
className={`${
isFullscreen ? "flex" : "hidden"
} items-center justify-center bg-black w-full h-full`}
>
{/* Close button */}
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 z-20 p-2 text-white/70 hover:text-white transition-colors"
aria-label="Fechar"
>
<X className="size-8" />
</button>

{/* Previous button */}
<button
type="button"
onClick={onPrev}
className={`absolute left-4 top-1/2 -translate-y-1/2 z-20 p-2 transition-colors ${
canGoPrev
? "text-white/70 hover:text-white"
: "text-white/20 cursor-not-allowed"
}`}
aria-label="Foto anterior"
>
<ChevronLeft className="size-10" />
</button>

{/* Next button */}
<button
type="button"
onClick={onNext}
className={`absolute right-4 top-1/2 -translate-y-1/2 z-20 p-2 transition-colors ${
canGoNext
? "text-white/70 hover:text-white"
: "text-white/20 cursor-not-allowed"
}`}
aria-label="Próxima foto"
>
<ChevronRight className="size-10" />
</button>

{/* Image */}
{isFullscreen && (
<img
src={images[currentIndex].image}
alt={images[currentIndex].alt}
className={`max-h-full max-w-full object-contain select-none ${
isZoomed ? "cursor-grab active:cursor-grabbing" : "cursor-zoom-in"
}`}
style={{
transform: `scale(${scale}) translate(${position.x / scale}px, ${
position.y / scale
}px)`,
transition: isDragging.current
? "none"
: "transform 0.15s ease-out",
}}
draggable={false}
/>
)}

{/* Gesture capture layer - handles all mouse/touch interactions */}
<button
type="button"
aria-label={isZoomed ? "Arraste para mover a imagem" : "Clique para ampliar"}
className={`absolute inset-0 z-10 bg-transparent border-none ${
isZoomed ? "cursor-grab active:cursor-grabbing" : "cursor-zoom-in"
}`}
onClick={onImageClick}
onWheel={onWheel}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>

{/* Image counter */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/70 text-sm">
{currentIndex + 1} / {images.length}
</div>
</div>
);
};

FullscreenViewer.propTypes = {
images: PropTypes.arrayOf(
PropTypes.shape({
image: PropTypes.string.isRequired,
alt: PropTypes.string,
})
).isRequired,
currentIndex: PropTypes.number.isRequired,
scale: PropTypes.number.isRequired,
position: PropTypes.shape({
x: PropTypes.number.isRequired,
y: PropTypes.number.isRequired,
}).isRequired,
isFullscreen: PropTypes.bool.isRequired,
isZoomed: PropTypes.bool.isRequired,
canGoPrev: PropTypes.bool.isRequired,
canGoNext: PropTypes.bool.isRequired,
isDragging: PropTypes.shape({ current: PropTypes.bool }).isRequired,
containerRef: PropTypes.shape({ current: PropTypes.any }).isRequired,
onClose: PropTypes.func.isRequired,
onPrev: PropTypes.func.isRequired,
onNext: PropTypes.func.isRequired,
onWheel: PropTypes.func.isRequired,
onImageClick: PropTypes.func.isRequired,
onMouseDown: PropTypes.func.isRequired,
onMouseMove: PropTypes.func.isRequired,
onMouseUp: PropTypes.func.isRequired,
onTouchStart: PropTypes.func.isRequired,
onTouchMove: PropTypes.func.isRequired,
onTouchEnd: PropTypes.func.isRequired,
};

export default FullscreenViewer;
238 changes: 238 additions & 0 deletions src/components/ui/FullscreenViewer.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import FullscreenViewer from "./FullscreenViewer";

const mockImages = [
{ id: 1, image: "/image1.jpg", alt: "Imagem 1" },
{ id: 2, image: "/image2.jpg", alt: "Imagem 2" },
{ id: 3, image: "/image3.jpg", alt: "Imagem 3" },
];

const defaultProps = {
images: mockImages,
currentIndex: 0,
scale: 1,
position: { x: 0, y: 0 },
isFullscreen: true,
isZoomed: false,
canGoPrev: false,
canGoNext: true,
isDragging: { current: false },
containerRef: { current: null },
onClose: vi.fn(),
onPrev: vi.fn(),
onNext: vi.fn(),
onWheel: vi.fn(),
onImageClick: vi.fn(),
onMouseDown: vi.fn(),
onMouseMove: vi.fn(),
onMouseUp: vi.fn(),
onTouchStart: vi.fn(),
onTouchMove: vi.fn(),
onTouchEnd: vi.fn(),
};

describe("FullscreenViewer", () => {
it("renders when isFullscreen is true", () => {
render(<FullscreenViewer {...defaultProps} />);

expect(
screen.getByAltText("Imagem 1")
).toBeInTheDocument();
expect(screen.getByText("1 / 3")).toBeInTheDocument();
});

it("is hidden when isFullscreen is false", () => {
render(<FullscreenViewer {...defaultProps} isFullscreen={false} />);

expect(
screen.getByLabelText("Visualizador de imagens em ecrã inteiro")
).toHaveClass("hidden");
});

it("renders navigation buttons", () => {
render(<FullscreenViewer {...defaultProps} />);

expect(screen.getByLabelText("Foto anterior")).toBeInTheDocument();
expect(screen.getByLabelText("Próxima foto")).toBeInTheDocument();
expect(screen.getByLabelText("Fechar")).toBeInTheDocument();
});

it("calls onClose when close button is clicked", () => {
const onClose = vi.fn();
render(<FullscreenViewer {...defaultProps} onClose={onClose} />);

fireEvent.click(screen.getByLabelText("Fechar"));
expect(onClose).toHaveBeenCalledTimes(1);
});

it("calls onPrev when previous button is clicked", () => {
const onPrev = vi.fn();
render(<FullscreenViewer {...defaultProps} canGoPrev={true} onPrev={onPrev} />);

fireEvent.click(screen.getByLabelText("Foto anterior"));
expect(onPrev).toHaveBeenCalledTimes(1);
});

it("calls onNext when next button is clicked", () => {
const onNext = vi.fn();
render(<FullscreenViewer {...defaultProps} onNext={onNext} />);

fireEvent.click(screen.getByLabelText("Próxima foto"));
expect(onNext).toHaveBeenCalledTimes(1);
});

it("shows correct image based on currentIndex", () => {
render(<FullscreenViewer {...defaultProps} currentIndex={1} />);

expect(screen.getByAltText("Imagem 2")).toBeInTheDocument();
expect(screen.getByText("2 / 3")).toBeInTheDocument();
});

it("applies zoom-in cursor when not zoomed", () => {
render(<FullscreenViewer {...defaultProps} isZoomed={false} />);

const gestureButton = screen.getByLabelText("Clique para ampliar");
expect(gestureButton).toHaveClass("cursor-zoom-in");
});

it("applies grab cursor when zoomed", () => {
render(<FullscreenViewer {...defaultProps} isZoomed={true} />);

const gestureButton = screen.getByLabelText("Arraste para mover a imagem");
expect(gestureButton).toHaveClass("cursor-grab");
});

it("calls onImageClick when gesture layer is clicked", () => {
const onImageClick = vi.fn();
render(<FullscreenViewer {...defaultProps} onImageClick={onImageClick} />);

fireEvent.click(screen.getByLabelText("Clique para ampliar"));
expect(onImageClick).toHaveBeenCalledTimes(1);
});

it("calls onWheel when scrolling on gesture layer", () => {
const onWheel = vi.fn();
render(<FullscreenViewer {...defaultProps} onWheel={onWheel} />);

fireEvent.wheel(screen.getByLabelText("Clique para ampliar"));
expect(onWheel).toHaveBeenCalledTimes(1);
});

it("calls mouse handlers on gesture layer", () => {
const onMouseDown = vi.fn();
const onMouseMove = vi.fn();
const onMouseUp = vi.fn();
render(
<FullscreenViewer
{...defaultProps}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
/>
);

const gestureLayer = screen.getByLabelText("Clique para ampliar");
fireEvent.mouseDown(gestureLayer);
fireEvent.mouseMove(gestureLayer);
fireEvent.mouseUp(gestureLayer);

expect(onMouseDown).toHaveBeenCalledTimes(1);
expect(onMouseMove).toHaveBeenCalledTimes(1);
expect(onMouseUp).toHaveBeenCalledTimes(1);
});

it("calls onMouseUp on mouse leave", () => {
const onMouseUp = vi.fn();
render(<FullscreenViewer {...defaultProps} onMouseUp={onMouseUp} />);

const gestureLayer = screen.getByLabelText("Clique para ampliar");
fireEvent.mouseLeave(gestureLayer);

expect(onMouseUp).toHaveBeenCalledTimes(1);
});

it("calls touch handlers on gesture layer", () => {
const onTouchStart = vi.fn();
const onTouchMove = vi.fn();
const onTouchEnd = vi.fn();
render(
<FullscreenViewer
{...defaultProps}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
);

const gestureLayer = screen.getByLabelText("Clique para ampliar");
fireEvent.touchStart(gestureLayer);
fireEvent.touchMove(gestureLayer);
fireEvent.touchEnd(gestureLayer);

expect(onTouchStart).toHaveBeenCalledTimes(1);
expect(onTouchMove).toHaveBeenCalledTimes(1);
expect(onTouchEnd).toHaveBeenCalledTimes(1);
});

it("applies transform style based on scale and position", () => {
render(
<FullscreenViewer
{...defaultProps}
scale={2}
position={{ x: 100, y: 50 }}
/>
);

const image = screen.getByAltText("Imagem 1");
expect(image.style.transform).toBe("scale(2) translate(50px, 25px)");
});

it("disables transition when dragging", () => {
render(
<FullscreenViewer
{...defaultProps}
isDragging={{ current: true }}
/>
);

const image = screen.getByAltText("Imagem 1");
expect(image.style.transition).toBe("none");
});

it("enables smooth transition when not dragging", () => {
render(
<FullscreenViewer
{...defaultProps}
isDragging={{ current: false }}
/>
);

const image = screen.getByAltText("Imagem 1");
expect(image.style.transition).toBe("transform 0.15s ease-out");
});

it("shows disabled style for prev button when canGoPrev is false", () => {
render(<FullscreenViewer {...defaultProps} canGoPrev={false} />);

const prevButton = screen.getByLabelText("Foto anterior");
expect(prevButton).toHaveClass("cursor-not-allowed");
expect(prevButton).toHaveClass("text-white/20");
});

it("shows enabled style for prev button when canGoPrev is true", () => {
render(<FullscreenViewer {...defaultProps} canGoPrev={true} />);

const prevButton = screen.getByLabelText("Foto anterior");
expect(prevButton).not.toHaveClass("cursor-not-allowed");
expect(prevButton).toHaveClass("text-white/70");
});

it("shows disabled style for next button when canGoNext is false", () => {
render(<FullscreenViewer {...defaultProps} canGoNext={false} />);

const nextButton = screen.getByLabelText("Próxima foto");
expect(nextButton).toHaveClass("cursor-not-allowed");
expect(nextButton).toHaveClass("text-white/20");
});
});
Loading