Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { Meta, StoryObj } from '@storybook/react';

import { Text } from '../typography/text';
import { ZoomPanViewer } from './zoom-pan-viewer';

const SAMPLE_IMAGE =
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop';

const meta: Meta<typeof ZoomPanViewer> = {
title: 'Data Display/ZoomPanViewer',
component: ZoomPanViewer,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component: `
An interactive image viewer with zoom, pan, and scroll-wheel support.

## Usage
\`\`\`tsx
import { ZoomPanViewer } from '@/app/components/ui/data-display/zoom-pan-viewer';

<ZoomPanViewer
src="/image.jpg"
alt="Description"
toolbarPosition="inline"
/>
\`\`\`

## Features
- Zoom in/out with buttons or scroll wheel (0.5x–3x)
- Pan by dragging when zoomed in
- Reset zoom button
- Overlay or inline toolbar positioning
- Optional header content alongside toolbar
`,
},
},
},
decorators: [
(Story) => (
<div style={{ width: 600, height: 400 }}>
<Story />
</div>
),
],
argTypes: {
toolbarPosition: {
control: 'radio',
options: ['overlay', 'bottom', 'inline'],
},
},
};

// Storybook requires default export for meta
// oxlint-disable-next-line no-default-export -- Storybook convention
export default meta;
type Story = StoryObj<typeof ZoomPanViewer>;

export const Default: Story = {
args: {
src: SAMPLE_IMAGE,
alt: 'Mountain landscape',
toolbarPosition: 'overlay',
},
parameters: {
docs: {
description: {
story:
'Default overlay toolbar positioned at the top-right. Ideal for dialog/modal use cases.',
},
},
},
};

export const InlineToolbar: Story = {
args: {
src: SAMPLE_IMAGE,
alt: 'Mountain landscape',
toolbarPosition: 'inline',
},
parameters: {
docs: {
description: {
story:
'Inline toolbar rendered above the image in normal document flow. Ideal for embedded previews.',
},
},
},
};

export const WithHeaderContent: Story = {
args: {
src: SAMPLE_IMAGE,
alt: 'Mountain landscape',
toolbarPosition: 'overlay',
headerContent: (
<Text as="span" truncate className="text-foreground/80 max-w-[60%]">
landscape-photo.jpg
</Text>
),
},
parameters: {
docs: {
description: {
story:
'Overlay toolbar with header content (e.g. filename) rendered to the left of controls.',
},
},
},
};

export const BottomToolbar: Story = {
args: {
src: SAMPLE_IMAGE,
alt: 'Mountain landscape',
toolbarPosition: 'bottom',
imageClassName: 'rounded-xl border',
},
parameters: {
docs: {
description: {
story:
'Floating pill toolbar at the bottom center, matching the PDF preview toolbar design.',
},
},
},
};

export const WithImageStyling: Story = {
args: {
src: SAMPLE_IMAGE,
alt: 'Styled image',
toolbarPosition: 'inline',
imageClassName: 'rounded-xl border',
},
parameters: {
docs: {
description: {
story: 'Custom image styling with rounded corners and border.',
},
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen, fireEvent } from '@testing-library/react';
import { afterEach, describe, it, expect, vi } from 'vitest';

import { ZoomPanViewer } from './zoom-pan-viewer';

vi.mock('@/lib/i18n/client', () => ({
useT: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'imagePreview.zoomIn': 'Zoom in',
'imagePreview.zoomOut': 'Zoom out',
'imagePreview.resetZoom': 'Reset zoom',
};
return translations[key] ?? key;
},
}),
}));

afterEach(cleanup);

describe('ZoomPanViewer', () => {
const defaultProps = {
src: 'https://example.com/image.jpg',
alt: 'Test image',
};

describe('rendering', () => {
it('renders an image with correct src and alt', () => {
render(<ZoomPanViewer {...defaultProps} />);

const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', defaultProps.src);
expect(img).toHaveAttribute('alt', defaultProps.alt);
});

it('renders zoom in, zoom out, and reset buttons', () => {
render(<ZoomPanViewer {...defaultProps} />);

expect(
screen.getByRole('button', { name: 'Zoom in' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Zoom out' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Reset zoom' }),
).toBeInTheDocument();
});

it('shows 100% zoom by default', () => {
render(<ZoomPanViewer {...defaultProps} />);
expect(screen.getByText('100%')).toBeInTheDocument();
});

it('applies imageClassName to the img element', () => {
render(
<ZoomPanViewer {...defaultProps} imageClassName="rounded-xl border" />,
);

const img = screen.getByRole('img');
expect(img.className).toContain('rounded-xl');
expect(img.className).toContain('border');
});

it('applies className to the container', () => {
const { container } = render(
<ZoomPanViewer {...defaultProps} className="custom-class" />,
);

expect(container.firstElementChild?.className).toContain('custom-class');
});

it('sets draggable to false on the image', () => {
render(<ZoomPanViewer {...defaultProps} />);

const img = screen.getByRole('img');
expect(img).toHaveAttribute('draggable', 'false');
});
});

describe('toolbar positions', () => {
it('renders overlay toolbar by default', () => {
const { container } = render(<ZoomPanViewer {...defaultProps} />);

const toolbarWrapper = container.querySelector('.absolute.top-4');
expect(toolbarWrapper).toBeInTheDocument();
});

it('renders inline toolbar when toolbarPosition is inline', () => {
const { container } = render(
<ZoomPanViewer {...defaultProps} toolbarPosition="inline" />,
);

const inlineWrapper = container.querySelector('.justify-end.mb-2');
expect(inlineWrapper).toBeInTheDocument();
});

it('renders headerContent in overlay mode', () => {
render(
<ZoomPanViewer
{...defaultProps}
headerContent={<span data-testid="header">file.jpg</span>}
/>,
);

expect(screen.getByTestId('header')).toBeInTheDocument();
});
});

describe('zoom controls', () => {
it('increments zoom on zoom in click', () => {
render(<ZoomPanViewer {...defaultProps} />);

fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
expect(screen.getByText('125%')).toBeInTheDocument();
});

it('decrements zoom on zoom out click', () => {
render(<ZoomPanViewer {...defaultProps} />);

// First zoom in, then zoom out
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }));
expect(screen.getByText('100%')).toBeInTheDocument();
});

it('resets zoom on reset click', () => {
render(<ZoomPanViewer {...defaultProps} />);

fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
expect(screen.getByText('150%')).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Reset zoom' }));
expect(screen.getByText('100%')).toBeInTheDocument();
});

it('disables reset button when not zoomed', () => {
render(<ZoomPanViewer {...defaultProps} />);

expect(screen.getByRole('button', { name: 'Reset zoom' })).toBeDisabled();
});

it('enables reset button when zoomed', () => {
render(<ZoomPanViewer {...defaultProps} />);

fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
expect(
screen.getByRole('button', { name: 'Reset zoom' }),
).not.toBeDisabled();
});
});

describe('callbacks', () => {
it('calls onLoad when image loads', () => {
const onLoad = vi.fn();
render(<ZoomPanViewer {...defaultProps} onLoad={onLoad} />);

fireEvent.load(screen.getByRole('img'));
expect(onLoad).toHaveBeenCalledOnce();
});

it('calls onError when image fails', () => {
const onError = vi.fn();
render(<ZoomPanViewer {...defaultProps} onError={onError} />);

fireEvent.error(screen.getByRole('img'));
expect(onError).toHaveBeenCalledOnce();
});
});

describe('image transform', () => {
it('applies scale transform to image', () => {
render(<ZoomPanViewer {...defaultProps} />);

const img = screen.getByRole('img');
expect(img.style.transform).toContain('scale(1)');
});

it('updates transform when zoomed', () => {
render(<ZoomPanViewer {...defaultProps} />);

fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));

const img = screen.getByRole('img');
expect(img.style.transform).toContain('scale(1.25)');
});
});
});
Loading
Loading