feat(showcase): add wheel, swipe and keyboard navigation to the example gallery

- Wheel on the main viewer: horizontal deltas always switch examples;
  vertical deltas switch only at the modal scroll boundary, then stay in a
  sticky session (down = next, up = prev) until the pointer leaves the area
- Touch/pen horizontal swipe switches examples; the synthesized click after
  a swipe is swallowed so the media viewer does not open
- '[' / ']' switch examples while the gallery is expanded; ArrowLeft/Right
  stay reserved for model-level navigation
- Direction-aware slide transition on every switch for visual feedback
  (respects prefers-reduced-motion)
This commit is contained in:
Will Miao
2026-09-02 11:52:59 +08:00
parent 49704d801c
commit bc33e32c6f
13 changed files with 447 additions and 23 deletions
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "Beispiele anzeigen ({count})", "showCount": "Beispiele anzeigen ({count})",
"hideExamples": "Beispiele ausblenden", "hideExamples": "Beispiele ausblenden",
"addExamples": "Beispiele hinzufügen", "addExamples": "Beispiele hinzufügen",
"previousExample": "Vorheriges Beispiel", "previousExample": "Vorheriges Beispiel ([)",
"nextExample": "Nächstes Beispiel", "nextExample": "Nächstes Beispiel (])",
"noExamples": "Keine Beispielbilder verfügbar", "noExamples": "Keine Beispielbilder verfügbar",
"addMoreExamples": "Weitere Beispiele hinzufügen", "addMoreExamples": "Weitere Beispiele hinzufügen",
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen", "dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "Show examples ({count})", "showCount": "Show examples ({count})",
"hideExamples": "Hide examples", "hideExamples": "Hide examples",
"addExamples": "Add examples", "addExamples": "Add examples",
"previousExample": "Previous example", "previousExample": "Previous example ([)",
"nextExample": "Next example", "nextExample": "Next example (])",
"noExamples": "No example images available", "noExamples": "No example images available",
"addMoreExamples": "Add more examples", "addMoreExamples": "Add more examples",
"dragDrop": "Drag & drop images or videos here", "dragDrop": "Drag & drop images or videos here",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "Mostrar ejemplos ({count})", "showCount": "Mostrar ejemplos ({count})",
"hideExamples": "Ocultar ejemplos", "hideExamples": "Ocultar ejemplos",
"addExamples": "Añadir ejemplos", "addExamples": "Añadir ejemplos",
"previousExample": "Ejemplo anterior", "previousExample": "Ejemplo anterior ([)",
"nextExample": "Ejemplo siguiente", "nextExample": "Ejemplo siguiente (])",
"noExamples": "No hay imágenes de ejemplo disponibles", "noExamples": "No hay imágenes de ejemplo disponibles",
"addMoreExamples": "Añadir más ejemplos", "addMoreExamples": "Añadir más ejemplos",
"dragDrop": "Arrastra y suelta imágenes o videos aquí", "dragDrop": "Arrastra y suelta imágenes o videos aquí",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "Afficher les exemples ({count})", "showCount": "Afficher les exemples ({count})",
"hideExamples": "Masquer les exemples", "hideExamples": "Masquer les exemples",
"addExamples": "Ajouter des exemples", "addExamples": "Ajouter des exemples",
"previousExample": "Exemple précédent", "previousExample": "Exemple précédent ([)",
"nextExample": "Exemple suivant", "nextExample": "Exemple suivant (])",
"noExamples": "Aucune image d'exemple disponible", "noExamples": "Aucune image d'exemple disponible",
"addMoreExamples": "Ajouter d'autres exemples", "addMoreExamples": "Ajouter d'autres exemples",
"dragDrop": "Glissez-déposez des images ou des vidéos ici", "dragDrop": "Glissez-déposez des images ou des vidéos ici",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "הצג דוגמאות ({count})", "showCount": "הצג דוגמאות ({count})",
"hideExamples": "הסתר דוגמאות", "hideExamples": "הסתר דוגמאות",
"addExamples": "הוסף דוגמאות", "addExamples": "הוסף דוגמאות",
"previousExample": "דוגמה קודמת", "previousExample": "דוגמה קודמת ([)",
"nextExample": "דוגמה הבאה", "nextExample": "דוגמה הבאה (])",
"noExamples": "אין תמונות דוגמה זמינות", "noExamples": "אין תמונות דוגמה זמינות",
"addMoreExamples": "הוסף עוד דוגמאות", "addMoreExamples": "הוסף עוד דוגמאות",
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן", "dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "例を表示({count}", "showCount": "例を表示({count}",
"hideExamples": "例を非表示", "hideExamples": "例を非表示",
"addExamples": "例を追加", "addExamples": "例を追加",
"previousExample": "前の例", "previousExample": "前の例[",
"nextExample": "次の例", "nextExample": "次の例]",
"noExamples": "利用可能な例画像がありません", "noExamples": "利用可能な例画像がありません",
"addMoreExamples": "さらに例を追加", "addMoreExamples": "さらに例を追加",
"dragDrop": "画像または動画をここにドラッグ&ドロップ", "dragDrop": "画像または動画をここにドラッグ&ドロップ",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "예시 보기 ({count})", "showCount": "예시 보기 ({count})",
"hideExamples": "예시 숨기기", "hideExamples": "예시 숨기기",
"addExamples": "예시 추가", "addExamples": "예시 추가",
"previousExample": "이전 예시", "previousExample": "이전 예시([)",
"nextExample": "다음 예시", "nextExample": "다음 예시(])",
"noExamples": "사용 가능한 예시 이미지가 없습니다", "noExamples": "사용 가능한 예시 이미지가 없습니다",
"addMoreExamples": "예시 더 추가", "addMoreExamples": "예시 더 추가",
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요", "dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "Показать примеры ({count})", "showCount": "Показать примеры ({count})",
"hideExamples": "Скрыть примеры", "hideExamples": "Скрыть примеры",
"addExamples": "Добавить примеры", "addExamples": "Добавить примеры",
"previousExample": "Предыдущий пример", "previousExample": "Предыдущий пример ([)",
"nextExample": "Следующий пример", "nextExample": "Следующий пример (])",
"noExamples": "Примеры изображений недоступны", "noExamples": "Примеры изображений недоступны",
"addMoreExamples": "Добавить ещё примеры", "addMoreExamples": "Добавить ещё примеры",
"dragDrop": "Перетащите изображения или видео сюда", "dragDrop": "Перетащите изображения или видео сюда",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "显示示例({count}", "showCount": "显示示例({count}",
"hideExamples": "隐藏示例", "hideExamples": "隐藏示例",
"addExamples": "添加示例", "addExamples": "添加示例",
"previousExample": "上一个示例", "previousExample": "上一个示例[",
"nextExample": "下一个示例", "nextExample": "下一个示例]",
"noExamples": "暂无示例图片", "noExamples": "暂无示例图片",
"addMoreExamples": "添加更多示例", "addMoreExamples": "添加更多示例",
"dragDrop": "将图片或视频拖放到此处", "dragDrop": "将图片或视频拖放到此处",
+2 -2
View File
@@ -1664,8 +1664,8 @@
"showCount": "顯示範例({count}", "showCount": "顯示範例({count}",
"hideExamples": "隱藏範例", "hideExamples": "隱藏範例",
"addExamples": "新增範例", "addExamples": "新增範例",
"previousExample": "上一個範例", "previousExample": "上一個範例[",
"nextExample": "下一個範例", "nextExample": "下一個範例]",
"noExamples": "沒有可用的範例圖片", "noExamples": "沒有可用的範例圖片",
"addMoreExamples": "新增更多範例", "addMoreExamples": "新增更多範例",
"dragDrop": "拖放圖片或影片到此處", "dragDrop": "拖放圖片或影片到此處",
@@ -115,6 +115,9 @@
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
/* Horizontal touch pans are claimed for swipe navigation (ShowcaseView);
vertical pans still scroll the modal */
touch-action: pan-y;
} }
.main-media-container { .main-media-container {
@@ -134,6 +137,32 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* Direction-aware slide on example switches (set by updateMainDisplay) */
.main-media-container.slide-from-right .media-wrapper {
animation: gallery-slide-from-right 0.25s ease;
}
.main-media-container.slide-from-left .media-wrapper {
animation: gallery-slide-from-left 0.25s ease;
}
@keyframes gallery-slide-from-right {
from { transform: translateX(32px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes gallery-slide-from-left {
from { transform: translateX(-32px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.main-media-container.slide-from-right .media-wrapper,
.main-media-container.slide-from-left .media-wrapper {
animation: none;
}
}
.main-media-container .media-wrapper img, .main-media-container .media-wrapper img,
.main-media-container .media-wrapper video { .main-media-container .media-wrapper video {
position: absolute; position: absolute;
@@ -23,7 +23,7 @@ import {
import { generateMetadataPanel } from './MetadataPanel.js'; import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js'; import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer } from '../MediaViewer.js'; import { openMediaViewer, isMediaViewerOpen } from '../MediaViewer.js';
import { escapeAttribute } from '../utils.js'; import { escapeAttribute } from '../utils.js';
/** /**
@@ -231,10 +231,10 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
${renderMediaItem(activeImg, galleryState.activeIndex, exampleFiles)} ${renderMediaItem(activeImg, galleryState.activeIndex, exampleFiles)}
${renderPositionBadge(positionText)} ${renderPositionBadge(positionText)}
</div> </div>
${showNav ? `<button class="gallery-nav prev" id="galleryPrevBtn" title="${translate('modals.model.showcase.previousExample', {}, 'Previous example')}"> ${showNav ? `<button class="gallery-nav prev" id="galleryPrevBtn" title="${translate('modals.model.showcase.previousExample', {}, 'Previous example ([)')}">
<i class="fas fa-chevron-left"></i> <i class="fas fa-chevron-left"></i>
</button> </button>
<button class="gallery-nav next" id="galleryNextBtn" title="${translate('modals.model.showcase.nextExample', {}, 'Next example')}"> <button class="gallery-nav next" id="galleryNextBtn" title="${translate('modals.model.showcase.nextExample', {}, 'Next example (])')}">
<i class="fas fa-chevron-right"></i> <i class="fas fa-chevron-right"></i>
</button>` : ''} </button>` : ''}
</div> </div>
@@ -508,6 +508,13 @@ export function updateMainDisplay(index) {
const activeImg = galleryState.images[galleryState.activeIndex]; const activeImg = galleryState.images[galleryState.activeIndex];
container.style.setProperty('--media-aspect', mediaAspectRatio(activeImg)); container.style.setProperty('--media-aspect', mediaAspectRatio(activeImg));
// Direction-aware slide makes every switch (wheel, keys, buttons,
// thumbnails) perceivable instead of an instant, unexplained swap
container.classList.remove('slide-from-left', 'slide-from-right');
if (delta !== 0) {
void container.offsetWidth; // restart the animation on rapid switches
container.classList.add(delta > 0 ? 'slide-from-right' : 'slide-from-left');
}
// The badge lives inside the container, so rebuild it together with the media // The badge lives inside the container, so rebuild it together with the media
container.innerHTML = renderMediaItem( container.innerHTML = renderMediaItem(
activeImg, activeImg,
@@ -680,6 +687,177 @@ function setupScrollToExpand(gallery) {
}, { passive: true }); }, { passive: true });
} }
// Wheel-navigation tuning: one gesture = one step. Trackpads emit a stream
// of small deltas, so deltas accumulate until the threshold; the cooldown
// keeps the tail of the same gesture from stepping again
const WHEEL_STEP_THRESHOLD = 50;
const WHEEL_COOLDOWN_MS = 250;
const WHEEL_ACCUM_RESET_MS = 200;
/**
* Wheel navigation on the main viewer area. Bound to .gallery-main (not the
* media element) so it works wherever the cursor rests within the viewer
* including over the nav buttons and the dead zones beside the media, and
* regardless of whether the hover-triggered metadata panel is showing.
*
* - Horizontal-dominant deltas (trackpad two-finger swipe) always navigate;
* the modal never scrolls horizontally, so nothing is hijacked.
* - Vertical deltas navigate only when the modal content cannot scroll
* further in that direction (same boundary pass-through pattern as the
* metadata panel's wheel handler), so wheel-scrolling the modal through
* the gallery is never trapped mid-way.
* - Once a boundary crossing triggers a vertical switch, a "wheel session"
* starts: while the pointer stays over .gallery-main, vertical wheel in
* BOTH directions switches examples (down = next, up = prev the reverse
* gesture must undo, not scroll the modal away). The session ends when the
* pointer leaves the area, returning vertical scroll to the modal.
* @param {HTMLElement} gallery - The .showcase-gallery element
*/
function initWheelNavigation(gallery) {
const main = gallery.querySelector('.gallery-main');
if (!main || galleryState.images.length < 2) return;
let accumulated = 0;
let lastEventAt = 0;
let lastStepAt = 0;
let verticalSession = false;
// Leaving the viewer area releases the vertical wheel back to the modal
main.addEventListener('pointerleave', () => {
verticalSession = false;
accumulated = 0;
});
main.addEventListener('wheel', (event) => {
// The metadata panel and media controls keep their own behavior;
// the panel passes boundary scrolls through to the modal by itself
if (event.target.closest('.image-metadata-panel, .media-controls')) return;
const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY);
const delta = horizontal ? event.deltaX : event.deltaY;
if (delta === 0) return;
if (!horizontal && !verticalSession) {
const scroller = main.closest('.modal-content');
if (scroller) {
const atTop = scroller.scrollTop <= 0;
const atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= 1;
if ((delta < 0 && !atTop) || (delta > 0 && !atBottom)) return;
}
}
event.preventDefault();
const now = performance.now();
if (now - lastEventAt > WHEEL_ACCUM_RESET_MS) accumulated = 0;
lastEventAt = now;
if (now - lastStepAt < WHEEL_COOLDOWN_MS) return;
accumulated += delta;
if (Math.abs(accumulated) < WHEEL_STEP_THRESHOLD) return;
const direction = accumulated > 0 ? 1 : -1;
accumulated = 0;
lastStepAt = now;
if (!horizontal) verticalSession = true;
updateMainDisplay(galleryState.activeIndex + direction);
}, { passive: false });
}
// Touch/pen swipe tuning. Mouse is excluded: it already has wheel, keys and
// buttons, and mouse-drag would fight the media's click-to-view gesture
const SWIPE_THRESHOLD_PX = 50;
const SWIPE_CLICK_SUPPRESS_MS = 400;
/**
* Horizontal swipe navigation on the main viewer area (touch/pen). Requires
* `touch-action: pan-y` on .gallery-main so horizontal pans reach these
* handlers while vertical pans still scroll the modal.
* @param {HTMLElement} gallery - The .showcase-gallery element
*/
function initSwipeNavigation(gallery) {
const main = gallery.querySelector('.gallery-main');
if (!main || galleryState.images.length < 2) return;
let startX = 0;
let startY = 0;
let tracking = false;
let lastSwipeAt = 0;
main.addEventListener('pointerdown', (event) => {
if (event.pointerType === 'mouse') return;
// Native video controls own their pointer gestures (scrubbing etc.)
if (event.target.closest('video, .image-metadata-panel, .media-controls, .gallery-nav')) return;
startX = event.clientX;
startY = event.clientY;
tracking = true;
});
main.addEventListener('pointercancel', () => { tracking = false; });
main.addEventListener('pointerup', (event) => {
if (!tracking) return;
tracking = false;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (Math.abs(dx) < SWIPE_THRESHOLD_PX || Math.abs(dx) < Math.abs(dy) * 1.5) return;
lastSwipeAt = performance.now();
updateMainDisplay(galleryState.activeIndex + (dx < 0 ? 1 : -1));
});
// A completed swipe still produces a click on the media — swallow it in
// the capture phase (beats the media element's own handler) so the
// full-size viewer does not open
main.addEventListener('click', (event) => {
if (performance.now() - lastSwipeAt < SWIPE_CLICK_SUPPRESS_MS) {
event.stopPropagation();
event.preventDefault();
}
}, true);
}
/**
* True when the showcase tab is the active pane of an open modal
* @returns {boolean}
*/
function isShowcaseTabVisible() {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab || !showcaseTab.classList.contains('active')) return false;
const modalEl = showcaseTab.closest('.modal');
// No .modal ancestor: standalone/test rendering, treat as visible
if (!modalEl) return true;
return modalEl.classList.contains('show') || modalEl.style.display === 'block';
}
/**
* Typing-target guard for the example shortcuts. Unlike the model-level
* navigation guard, buttons are NOT excluded: clicking a thumbnail or nav
* button leaves focus on it, which would make [ ] feel dead right after the
* most common interaction and buttons consume Space/Enter natively, never
* bracket keys.
* @param {EventTarget|null} target - keydown event target
* @returns {boolean}
*/
function isTypingTarget(target) {
if (!target) return false;
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName);
}
// '[' / ']' switch examples while the gallery is expanded. ArrowLeft/Right
// stay reserved for model-level navigation (ModelModal), and the full-size
// media viewer owns its keys while open.
document.addEventListener('keydown', (event) => {
if (event.key !== '[' && event.key !== ']') return;
if (!galleryState.expanded || galleryState.images.length < 2) return;
if (isTypingTarget(event.target)) return;
if (isMediaViewerOpen()) return;
if (!isShowcaseTabVisible()) return;
event.preventDefault();
updateMainDisplay(galleryState.activeIndex + (event.key === ']' ? 1 : -1));
});
/** /**
* Defer metadata fetches for video thumbnails until they scroll into view: * Defer metadata fetches for video thumbnails until they scroll into view:
* with preload="metadata" on every strip video, expanding the gallery would * with preload="metadata" on every strip video, expanding the gallery would
@@ -773,6 +951,8 @@ export function initShowcaseContent(gallery) {
const container = gallery.querySelector('.main-media-container'); const container = gallery.querySelector('.main-media-container');
if (container && galleryState.expanded) { if (container && galleryState.expanded) {
initMainMediaInteractions(container); initMainMediaInteractions(container);
initWheelNavigation(gallery);
initSwipeNavigation(gallery);
// Gallery just (re)rendered expanded: warm the cache for the // Gallery just (re)rendered expanded: warm the cache for the
// examples adjacent to the active one // examples adjacent to the active one
prefetchAdjacentMedia(); prefetchAdjacentMedia();
@@ -16,6 +16,7 @@ vi.mock(MEDIA_UTILS_MODULE, () => ({
vi.mock(MEDIA_VIEWER_MODULE, () => ({ vi.mock(MEDIA_VIEWER_MODULE, () => ({
openMediaViewer: vi.fn(), openMediaViewer: vi.fn(),
isMediaViewerOpen: vi.fn(() => false),
})); }));
const PREVIEW_URL = '/loras_static/preview/abc.png'; const PREVIEW_URL = '/loras_static/preview/abc.png';
@@ -340,4 +341,218 @@ describe('Showcase gallery', () => {
expect(loadSpy).toHaveBeenCalled(); expect(loadSpy).toHaveBeenCalled();
loadSpy.mockRestore(); loadSpy.mockRestore();
}); });
it('switches examples on wheel over the main viewer', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const main = document.querySelector('.gallery-main');
main.dispatchEvent(new WheelEvent('wheel', { deltaX: 120, deltaY: 0, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// The one-step-per-gesture cooldown intentionally blocks an immediate
// second step; a fresh gallery (new listener) accepts the next gesture.
// No .modal-content ancestor → no boundary guard, vertical also navigates
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
document.querySelector('.gallery-main')
.dispatchEvent(new WheelEvent('wheel', { deltaX: 0, deltaY: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
});
it('vertical wheel only hijacks at the modal scroll boundary', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `
<div class="modal-content">
<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>
</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const scroller = document.querySelector('.modal-content');
// Mid-scroll: the modal owns vertical wheel, the gallery must not navigate
Object.defineProperties(scroller, {
scrollTop: { value: 100, configurable: true },
scrollHeight: { value: 1000, configurable: true },
clientHeight: { value: 500, configurable: true },
});
const main = document.querySelector('.gallery-main');
main.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Bottom of the modal: further down-scroll switches to the next example
// (and starts a vertical wheel session — covered by the next test)
Object.defineProperty(scroller, 'scrollTop', { value: 500, configurable: true });
main.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// Leaving the viewer area ends the session: up-scroll away from the top
// belongs to the modal again
main.dispatchEvent(new Event('pointerleave'));
main.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
});
it('keeps vertical wheel in a sticky session once engaged, until pointer leaves', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `
<div class="modal-content">
<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>
</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const scroller = document.querySelector('.modal-content');
// Modal sits at its bottom: down-scroll engages the gallery
Object.defineProperties(scroller, {
scrollTop: { value: 500, configurable: true },
scrollHeight: { value: 1000, configurable: true },
clientHeight: { value: 500, configurable: true },
});
// The one-step-per-gesture cooldown would block consecutive steps; fake
// the clock so each gesture lands after it
let now = 10000;
const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => now);
const main = document.querySelector('.gallery-main');
const wheelUp = () => main.dispatchEvent(
new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }));
const wheelDown = () => main.dispatchEvent(
new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }));
wheelDown();
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// Reverse gesture must undo: up-scroll switches back to the previous
// example even though the modal is not at its top
now += 300;
wheelUp();
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Pointer leaving the viewer area releases vertical wheel to the modal
now += 300;
main.dispatchEvent(new Event('pointerleave'));
wheelUp();
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
nowSpy.mockRestore();
});
it('ignores wheel events coming from the metadata panel', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
// MediaUtils is mocked, so hoist a panel manually (real code appends it
// as a direct child of .gallery-main)
const main = document.querySelector('.gallery-main');
const panel = document.createElement('div');
panel.className = 'image-metadata-panel visible';
main.appendChild(panel);
panel.dispatchEvent(new WheelEvent('wheel', { deltaX: 120, bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
});
it('switches examples with [ and ] while expanded, with guards', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
const { isMediaViewerOpen } = await import(MEDIA_VIEWER_MODULE);
document.body.innerHTML = `<div id="showcase-tab" class="tab-pane active">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
document.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
document.dispatchEvent(new KeyboardEvent('keydown', { key: '[', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Typing in a field: the key belongs to the field
const input = document.createElement('input');
document.body.appendChild(input);
input.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
input.remove();
// Focus resting on a button (e.g. right after clicking a thumbnail or nav
// button) must NOT deaden the keys — buttons consume Space/Enter natively
document.querySelector('#galleryNextBtn')
.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
document.dispatchEvent(new KeyboardEvent('keydown', { key: '[', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Full-size media viewer open: it owns the keys
isMediaViewerOpen.mockReturnValueOnce(true);
document.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
// Another tab active: examples must not change behind the scenes
document.getElementById('showcase-tab').classList.remove('active');
document.dispatchEvent(new KeyboardEvent('keydown', { key: ']', bubbles: true, cancelable: true }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
});
it('switches examples on touch swipe and suppresses the follow-up click', async () => {
const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
const { openMediaViewer } = await import(MEDIA_VIEWER_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
updateMainDisplay(0);
const main = document.querySelector('.gallery-main');
const img = main.querySelector('.media-wrapper img');
// A plain tap still opens the full-size viewer
img.click();
expect(openMediaViewer).toHaveBeenCalledTimes(1);
// jsdom lacks PointerEvent; MouseEvent carries clientX/clientY and its
// undefined pointerType passes the non-mouse guard
main.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 100 }));
main.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 100, clientY: 110 }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
// The click synthesized after the swipe must not open the viewer
document.querySelector('.gallery-main .media-wrapper img').click();
expect(openMediaViewer).toHaveBeenCalledTimes(1);
// A short drag below the threshold neither navigates nor eats the click
main.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300, clientY: 100 }));
main.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 280, clientY: 100 }));
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('1');
});
it('marks the main viewer with a direction-aware slide class on switches', async () => {
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
const container = document.getElementById('mainMediaContainer');
// galleryState.activeIndex leaks across tests → anchor on the actual index
const start = Number(document.querySelector('.gallery-thumb.active')?.dataset.index || 0);
updateMainDisplay(start); // same index: no direction, no slide
expect(container.classList.contains('slide-from-right')).toBe(false);
expect(container.classList.contains('slide-from-left')).toBe(false);
updateMainDisplay(start + 1); // forward
expect(container.classList.contains('slide-from-right')).toBe(true);
expect(container.classList.contains('slide-from-left')).toBe(false);
updateMainDisplay(start); // backward
expect(container.classList.contains('slide-from-left')).toBe(true);
expect(container.classList.contains('slide-from-right')).toBe(false);
});
}); });