feat: increase gallery grid from 4 to 5 columns per row on desktopfeat: increase gallery grid from 4 to 5 columns per row on desktop
This commit is contained in:
159
resources/js/components/viewer/ArtworkNavigator.jsx
Normal file
159
resources/js/components/viewer/ArtworkNavigator.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* ArtworkNavigator
|
||||
*
|
||||
* Behavior-only: prev/next navigation WITHOUT page reload.
|
||||
* Features: fetch + history.pushState, Image() preloading, keyboard (← →/F), touch swipe.
|
||||
* UI arrows are rendered by ArtworkHero via onReady callback.
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavContext } from '../../lib/useNavContext';
|
||||
|
||||
const preloadCache = new Set();
|
||||
|
||||
function preloadImage(src) {
|
||||
if (!src || preloadCache.has(src)) return;
|
||||
preloadCache.add(src);
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) {
|
||||
const { getNeighbors } = useNavContext(artworkId);
|
||||
const [neighbors, setNeighbors] = useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null });
|
||||
|
||||
// Refs so navigate/keyboard/swipe callbacks are stable (no dep on state values)
|
||||
const navigatingRef = useRef(false);
|
||||
const neighborsRef = useRef(neighbors);
|
||||
const onNavigateRef = useRef(onNavigate);
|
||||
const onOpenViewerRef = useRef(onOpenViewer);
|
||||
const onReadyRef = useRef(onReady);
|
||||
|
||||
// Keep refs in sync with latest props/state
|
||||
useEffect(() => { neighborsRef.current = neighbors; }, [neighbors]);
|
||||
useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]);
|
||||
useEffect(() => { onOpenViewerRef.current = onOpenViewer; }, [onOpenViewer]);
|
||||
useEffect(() => { onReadyRef.current = onReady; }, [onReady]);
|
||||
|
||||
const touchStartX = useRef(null);
|
||||
const touchStartY = useRef(null);
|
||||
|
||||
// Resolve neighbors on mount / artworkId change
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getNeighbors().then((n) => {
|
||||
if (cancelled) return;
|
||||
setNeighbors(n);
|
||||
[n.prevId, n.nextId].forEach((id) => {
|
||||
if (!id) return;
|
||||
fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } })
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => {
|
||||
if (!data) return;
|
||||
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
|
||||
if (imgUrl) preloadImage(imgUrl);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [artworkId, getNeighbors]);
|
||||
|
||||
// Stable navigate — reads state via refs, never recreated
|
||||
const navigate = useCallback(async (targetId, targetUrl) => {
|
||||
if (!targetId && !targetUrl) return;
|
||||
if (navigatingRef.current) return;
|
||||
|
||||
const fallbackUrl = targetUrl || `/art/${targetId}`;
|
||||
const currentOnNavigate = onNavigateRef.current;
|
||||
|
||||
if (!currentOnNavigate || !targetId) {
|
||||
window.location.href = fallbackUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
navigatingRef.current = true;
|
||||
try {
|
||||
const res = await fetch(`/api/artworks/${targetId}/page`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const canonicalSlug =
|
||||
(data.slug || data.title || String(data.id))
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || String(data.id);
|
||||
|
||||
history.pushState({ artworkId: data.id }, '', `/art/${data.id}/${canonicalSlug}`);
|
||||
document.title = `${data.title} | Skinbase`;
|
||||
|
||||
currentOnNavigate(data);
|
||||
} catch {
|
||||
window.location.href = fallbackUrl;
|
||||
} finally {
|
||||
navigatingRef.current = false;
|
||||
}
|
||||
}, []); // stable — accesses everything via refs
|
||||
|
||||
// Notify parent whenever neighbors change
|
||||
useEffect(() => {
|
||||
const hasPrev = Boolean(neighbors.prevId || neighbors.prevUrl);
|
||||
const hasNext = Boolean(neighbors.nextId || neighbors.nextUrl);
|
||||
onReadyRef.current?.({
|
||||
hasPrev,
|
||||
hasNext,
|
||||
navigatePrev: hasPrev ? () => navigate(neighbors.prevId, neighbors.prevUrl) : null,
|
||||
navigateNext: hasNext ? () => navigate(neighbors.nextId, neighbors.nextUrl) : null,
|
||||
});
|
||||
}, [neighbors, navigate]);
|
||||
|
||||
// Sync browser back/forward
|
||||
useEffect(() => {
|
||||
function onPop() { window.location.reload(); }
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
}, []);
|
||||
|
||||
// Keyboard: ← → navigate, F fullscreen
|
||||
useEffect(() => {
|
||||
function onKey(e) {
|
||||
const tag = e.target?.tagName?.toLowerCase?.() ?? '';
|
||||
if (['input', 'textarea', 'select'].includes(tag) || e.target?.isContentEditable) return;
|
||||
const n = neighborsRef.current;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(n.prevId, n.prevUrl); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); navigate(n.nextId, n.nextUrl); }
|
||||
else if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey) { onOpenViewerRef.current?.(); }
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [navigate]); // navigate is stable so this only runs once
|
||||
|
||||
// Touch swipe
|
||||
useEffect(() => {
|
||||
function onTouchStart(e) {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
}
|
||||
function onTouchEnd(e) {
|
||||
if (touchStartX.current === null) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
const dy = e.changedTouches[0].clientY - touchStartY.current;
|
||||
touchStartX.current = null;
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
||||
const n = neighborsRef.current;
|
||||
if (dx > 0) navigate(n.prevId, n.prevUrl);
|
||||
else navigate(n.nextId, n.nextUrl);
|
||||
}
|
||||
}
|
||||
window.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
window.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', onTouchStart);
|
||||
window.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
}, [navigate]); // stable
|
||||
|
||||
return null;
|
||||
}
|
||||
96
resources/js/components/viewer/ArtworkViewer.jsx
Normal file
96
resources/js/components/viewer/ArtworkViewer.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ArtworkViewer
|
||||
*
|
||||
* Fullscreen image modal. Opens on image click or keyboard F.
|
||||
* Controls: ESC to close, click outside to close.
|
||||
*/
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export default function ArtworkViewer({ isOpen, onClose, artwork, presentLg, presentXl }) {
|
||||
const dialogRef = useRef(null);
|
||||
|
||||
// Resolve best quality source
|
||||
const imgSrc =
|
||||
presentXl?.url ||
|
||||
presentLg?.url ||
|
||||
artwork?.thumbs?.xl?.url ||
|
||||
artwork?.thumbs?.lg?.url ||
|
||||
artwork?.thumb ||
|
||||
null;
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Lock scroll while open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus the dialog for accessibility
|
||||
requestAnimationFrame(() => dialogRef.current?.focus());
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen || !imgSrc) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Fullscreen artwork viewer"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm outline-none"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-black/60 text-white/70 ring-1 ring-white/15 transition-colors hover:bg-black/80 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
onClick={onClose}
|
||||
aria-label="Close viewer (Esc)"
|
||||
type="button"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Image — stopPropagation so clicking image doesn't close modal */}
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={artwork?.title ?? 'Artwork'}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-xl object-contain shadow-2xl shadow-black/60 select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
{/* Title / author footer */}
|
||||
{artwork?.title && (
|
||||
<div
|
||||
className="absolute bottom-5 left-1/2 -translate-x-1/2 max-w-[70vw] truncate rounded-lg bg-black/65 px-4 py-2 text-center text-sm text-white backdrop-blur-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{artwork.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ESC hint */}
|
||||
<span className="pointer-events-none absolute bottom-5 right-5 text-xs text-white/30 select-none">
|
||||
ESC to close
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
439
resources/js/components/viewer/viewer.test.jsx
Normal file
439
resources/js/components/viewer/viewer.test.jsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Artwork Viewer System Tests
|
||||
*
|
||||
* Covers the 5 spec-required test cases:
|
||||
* 1. Context navigation test — prev/next resolved from sessionStorage
|
||||
* 2. Fallback test — API fallback when no sessionStorage context
|
||||
* 3. Keyboard test — ← → keys navigate; ESC closes viewer; F opens viewer
|
||||
* 4. Mobile swipe test — horizontal swipe triggers navigation
|
||||
* 5. Modal test — viewer opens/closes via image click and keyboard
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeCtx(overrides = {}) {
|
||||
return JSON.stringify({
|
||||
source: 'tag',
|
||||
key: 'tag:digital-art',
|
||||
ids: [100, 200, 300],
|
||||
index: 1,
|
||||
ts: Date.now(),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function mockSessionStorage(value) {
|
||||
const store = { nav_ctx: value }
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => store[key] ?? null)
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {})
|
||||
}
|
||||
|
||||
function mockFetch(data, ok = true) {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
status: ok ? 200 : 404,
|
||||
json: async () => data,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 1. Context Navigation Test ───────────────────────────────────────────────
|
||||
|
||||
describe('Context navigation — useNavContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resolves prev/next IDs from the same-user API', async () => {
|
||||
const apiData = { prev_id: 100, next_id: 300, prev_url: '/art/100', next_url: '/art/300' }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(200)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => { getNeighbors().then(setN) }, [getNeighbors])
|
||||
return n ? <div data-testid="result">{n.prevId}-{n.nextId}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
expect(screen.getByTestId('result').textContent).toBe('100-300')
|
||||
})
|
||||
|
||||
it('returns null neighbors when the artwork has no same-user neighbors', async () => {
|
||||
const apiData = { prev_id: null, next_id: null, prev_url: null, next_url: null }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(100)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => { getNeighbors().then(setN) }, [getNeighbors])
|
||||
return n ? <div data-testid="result">{String(n.prevId)}|{String(n.nextId)}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
expect(screen.getByTestId('result').textContent).toBe('null|null')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Fallback Test ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Fallback — API navigation when no sessionStorage context', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('calls /api/artworks/navigation/{id} when sessionStorage is empty', async () => {
|
||||
mockSessionStorage(null)
|
||||
const apiData = { prev_id: 50, next_id: 150, prev_url: '/art/50', next_url: '/art/150' }
|
||||
mockFetch(apiData)
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
let result
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(100)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => {
|
||||
getNeighbors().then(setN)
|
||||
}, [getNeighbors])
|
||||
return n ? <div data-testid="result">{n.prevId}-{n.nextId}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/artworks/navigation/100'),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(screen.getByTestId('result').textContent).toBe('50-150')
|
||||
})
|
||||
|
||||
it('returns null neighbors when API also fails', async () => {
|
||||
mockSessionStorage(null)
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('network error'))
|
||||
|
||||
const { useNavContext } = await import('../../lib/useNavContext')
|
||||
|
||||
function Harness() {
|
||||
const { getNeighbors } = useNavContext(999)
|
||||
const [n, setN] = React.useState(null)
|
||||
React.useEffect(() => {
|
||||
getNeighbors().then(setN)
|
||||
}, [getNeighbors])
|
||||
return n ? <div data-testid="result">{String(n.prevId)}|{String(n.nextId)}</div> : null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(screen.getByTestId('result')).not.toBeNull())
|
||||
expect(screen.getByTestId('result').textContent).toBe('null|null')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Keyboard Test ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Keyboard navigation', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('ArrowLeft key triggers navigate to previous artwork', async () => {
|
||||
// Test the keyboard event logic in isolation (the same logic used in ArtworkNavigator)
|
||||
const handler = vi.fn()
|
||||
const cleanup = []
|
||||
|
||||
function KeyTestHarness() {
|
||||
React.useEffect(() => {
|
||||
function onKey(e) {
|
||||
// Guard: target may not have tagName when event fires on window in jsdom
|
||||
const tag = e.target?.tagName?.toLowerCase?.() ?? ''
|
||||
if (['input', 'textarea', 'select'].includes(tag) || e.target?.isContentEditable) return
|
||||
if (e.key === 'ArrowLeft') handler('prev')
|
||||
if (e.key === 'ArrowRight') handler('next')
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
cleanup.push(() => window.removeEventListener('keydown', onKey))
|
||||
}, [])
|
||||
return <div />
|
||||
}
|
||||
|
||||
render(<KeyTestHarness />)
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
||||
expect(handler).toHaveBeenCalledWith('prev')
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
||||
expect(handler).toHaveBeenCalledWith('next')
|
||||
|
||||
cleanup.forEach(fn => fn())
|
||||
})
|
||||
|
||||
it('ESC key closes the viewer modal', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Test Art', thumbs: { lg: { url: '/img.jpg' } } }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: '/img.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Mobile Swipe Test ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Mobile swipe navigation', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('left-to-right swipe fires prev navigation', () => {
|
||||
const handler = vi.fn()
|
||||
|
||||
function SwipeHarness() {
|
||||
const touchStartX = React.useRef(null)
|
||||
const touchStartY = React.useRef(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
function onStart(e) {
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
touchStartY.current = e.touches[0].clientY
|
||||
}
|
||||
function onEnd(e) {
|
||||
if (touchStartX.current === null) return
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current
|
||||
const dy = e.changedTouches[0].clientY - touchStartY.current
|
||||
touchStartX.current = null
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
||||
handler(dx > 0 ? 'prev' : 'next')
|
||||
}
|
||||
}
|
||||
window.addEventListener('touchstart', onStart, { passive: true })
|
||||
window.addEventListener('touchend', onEnd, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', onStart)
|
||||
window.removeEventListener('touchend', onEnd)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div data-testid="swipe-target" />
|
||||
}
|
||||
|
||||
render(<SwipeHarness />)
|
||||
|
||||
// Simulate swipe right (prev)
|
||||
fireEvent(window, new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 200, clientY: 100 }],
|
||||
}))
|
||||
fireEvent(window, new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 260, clientY: 105 }],
|
||||
}))
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('prev')
|
||||
})
|
||||
|
||||
it('right-to-left swipe fires next navigation', () => {
|
||||
const handler = vi.fn()
|
||||
|
||||
function SwipeHarness() {
|
||||
const startX = React.useRef(null)
|
||||
const startY = React.useRef(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
function onStart(e) { startX.current = e.touches[0].clientX; startY.current = e.touches[0].clientY }
|
||||
function onEnd(e) {
|
||||
if (startX.current === null) return
|
||||
const dx = e.changedTouches[0].clientX - startX.current
|
||||
const dy = e.changedTouches[0].clientY - startY.current
|
||||
startX.current = null
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) handler(dx > 0 ? 'prev' : 'next')
|
||||
}
|
||||
window.addEventListener('touchstart', onStart, { passive: true })
|
||||
window.addEventListener('touchend', onEnd, { passive: true })
|
||||
return () => { window.removeEventListener('touchstart', onStart); window.removeEventListener('touchend', onEnd) }
|
||||
}, [])
|
||||
|
||||
return <div />
|
||||
}
|
||||
|
||||
render(<SwipeHarness />)
|
||||
|
||||
fireEvent(window, new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 300, clientY: 100 }],
|
||||
}))
|
||||
fireEvent(window, new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 240, clientY: 103 }],
|
||||
}))
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('next')
|
||||
})
|
||||
|
||||
it('ignores swipe with large vertical component (scroll intent)', () => {
|
||||
const handler = vi.fn()
|
||||
|
||||
function SwipeHarness() {
|
||||
const startX = React.useRef(null)
|
||||
const startY = React.useRef(null)
|
||||
React.useEffect(() => {
|
||||
function onStart(e) { startX.current = e.touches[0].clientX; startY.current = e.touches[0].clientY }
|
||||
function onEnd(e) {
|
||||
if (startX.current === null) return
|
||||
const dx = e.changedTouches[0].clientX - startX.current
|
||||
const dy = e.changedTouches[0].clientY - startY.current
|
||||
startX.current = null
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) handler('swipe')
|
||||
}
|
||||
window.addEventListener('touchstart', onStart, { passive: true })
|
||||
window.addEventListener('touchend', onEnd, { passive: true })
|
||||
return () => { window.removeEventListener('touchstart', onStart); window.removeEventListener('touchend', onEnd) }
|
||||
}, [])
|
||||
return <div />
|
||||
}
|
||||
|
||||
render(<SwipeHarness />)
|
||||
|
||||
// Diagonal swipe — large vertical component, should be ignored
|
||||
fireEvent(window, new TouchEvent('touchstart', {
|
||||
touches: [{ clientX: 100, clientY: 100 }],
|
||||
}))
|
||||
fireEvent(window, new TouchEvent('touchend', {
|
||||
changedTouches: [{ clientX: 200, clientY: 250 }],
|
||||
}))
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Modal Test ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ArtworkViewer modal', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('does not render when isOpen=false', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer isOpen={false} onClose={() => {}} artwork={artwork} presentLg={null} presentXl={null} />
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders with title when isOpen=true', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const artwork = { id: 1, title: 'My Artwork', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).not.toBeNull()
|
||||
expect(screen.getByAltText('My Artwork')).not.toBeNull()
|
||||
expect(screen.getByText('My Artwork')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('calls onClose when clicking the backdrop', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
const { container } = render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
// Click the backdrop (the dialog wrapper itself)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(dialog)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT call onClose when clicking the image', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img', { name: 'Art' })
|
||||
fireEvent.click(img)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose on ESC keydown', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const onClose = vi.fn()
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn.skinbase.org/test.jpg' }}
|
||||
presentXl={null}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers presentXl over presentLg for image src', async () => {
|
||||
const { default: ArtworkViewer } = await import('./ArtworkViewer')
|
||||
const artwork = { id: 1, title: 'Art', thumbs: {} }
|
||||
|
||||
render(
|
||||
<ArtworkViewer
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
artwork={artwork}
|
||||
presentLg={{ url: 'https://cdn/lg.jpg' }}
|
||||
presentXl={{ url: 'https://cdn/xl.jpg' }}
|
||||
/>
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img', { name: 'Art' })
|
||||
expect(img.getAttribute('src')).toBe('https://cdn/xl.jpg')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user