mirror of
https://github.com/Heretek-Games/flatpark.git
synced 2026-10-11 20:59:53 +00:00
fix: correct swipe reversal, install popups, and button cursors (#319)
Keep held drags between their original pair of slides and use the clamped displacement when settling, so reversing past the origin cannot select a third image. Dismiss the copied hint when opening the install menu, synchronize its expanded state, and add pointer cursors to install, show-more, and category controls. Cover swipe reversals with 20 regression cases.
This commit is contained in:
@@ -427,6 +427,7 @@ const searchMeta = {
|
||||
}
|
||||
if (ok && hint) {
|
||||
if (menu) menu.hidden = true; // hint and menu share an anchor
|
||||
root?.querySelector('[data-install-toggle]')?.setAttribute('aria-expanded', 'false');
|
||||
hint.hidden = false;
|
||||
clearTimeout(hintTimer);
|
||||
hintTimer = setTimeout(() => { hint.hidden = true; }, 6000);
|
||||
@@ -437,8 +438,10 @@ const searchMeta = {
|
||||
if (installRoot) {
|
||||
const toggle = installRoot.querySelector('[data-install-toggle]');
|
||||
const menu = installRoot.querySelector('[data-install-menu]');
|
||||
const hint = installRoot.querySelector('[data-install-hint]');
|
||||
toggle?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (hint) hint.hidden = true;
|
||||
const willOpen = menu.hidden;
|
||||
menu.hidden = !willOpen;
|
||||
toggle.setAttribute('aria-expanded', String(willOpen));
|
||||
|
||||
@@ -48,14 +48,16 @@ export function initSwipe(element, getSlides, onChange) {
|
||||
* @param {number} direction
|
||||
* @param {HTMLElement} [target]
|
||||
*/
|
||||
const drag = (dx, direction = dx < 0 ? 1 : -1, target) => {
|
||||
const drag = (dx, direction = preview?.direction ?? (dx < 0 ? 1 : -1), target) => {
|
||||
const slides = getSlides();
|
||||
const active = preview?.active ?? slides.find((slide) => !slide.classList.contains('hidden'));
|
||||
if (!active || slides.length < 2) return;
|
||||
const neighbor = target ?? slides[(slides.indexOf(active) + direction + slides.length) % slides.length];
|
||||
if (preview?.neighbor !== neighbor) hideNeighbor();
|
||||
const width = preview?.width ?? element.clientWidth;
|
||||
dx = Math.max(-width, Math.min(width, dx));
|
||||
// Keep this gesture between its original pair of slides. Reversing past
|
||||
// the starting point returns to the active slide without revealing a third.
|
||||
dx = direction > 0 ? Math.max(-width, Math.min(0, dx)) : Math.max(0, Math.min(width, dx));
|
||||
preview = { active, neighbor, width, dx, direction };
|
||||
neighbor.classList.add('swipe-neighbor');
|
||||
neighbor.classList.remove('hidden');
|
||||
@@ -152,7 +154,7 @@ export function initSwipe(element, getSlides, onChange) {
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
if (gesture.dragging) drag(dx);
|
||||
settle(gesture.dragging && Math.abs(dx) >= 40 && Math.abs(dx) > Math.abs(dy));
|
||||
settle(gesture.dragging && Math.abs(preview?.dx ?? 0) >= 40 && Math.abs(dx) > Math.abs(dy));
|
||||
});
|
||||
element.addEventListener('pointercancel', (event) => {
|
||||
if (event.pointerId === gesture?.id) settle(false);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
[data-more],
|
||||
[data-install-copy],
|
||||
[data-install-toggle],
|
||||
.seg-side {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-swipe] {
|
||||
touch-action: pan-y pinch-zoom;
|
||||
user-select: none;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { initSwipe } from '../site/src/scripts/swipe.js';
|
||||
|
||||
class Element extends EventTarget {
|
||||
dataset = {};
|
||||
classes = new Set();
|
||||
classList = {
|
||||
add: (...names) => names.forEach((name) => this.classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => this.classes.delete(name)),
|
||||
contains: (name) => this.classes.has(name),
|
||||
};
|
||||
style = { removeProperty(name) { delete this[name]; } };
|
||||
clientWidth = 400;
|
||||
inert = false;
|
||||
capture = null;
|
||||
animations = [];
|
||||
animate() {
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
const animation = { finished: promise, finish: resolve, cancel: () => reject(new Error('cancelled')) };
|
||||
this.animations.push(animation);
|
||||
return animation;
|
||||
}
|
||||
closest() { return null; }
|
||||
setPointerCapture(id) { this.capture = id; }
|
||||
hasPointerCapture(id) { return this.capture === id; }
|
||||
releasePointerCapture() { this.capture = null; }
|
||||
}
|
||||
|
||||
function carousel(reducedMotion = true) {
|
||||
globalThis.Element = Element;
|
||||
globalThis.window = new EventTarget();
|
||||
window.matchMedia = () => ({ matches: reducedMotion });
|
||||
const viewport = new Element();
|
||||
const slides = Array.from({ length: 4 }, () => new Element());
|
||||
let current = 1;
|
||||
const show = (index) => {
|
||||
current = index;
|
||||
slides.forEach((slide, i) => {
|
||||
if (i === index) slide.classList.remove('hidden');
|
||||
else slide.classList.add('hidden');
|
||||
});
|
||||
};
|
||||
show(current);
|
||||
const swipe = initSwipe(viewport, () => slides, show);
|
||||
const pointer = (type, x, y = 100) => {
|
||||
const event = new Event(type, { cancelable: true });
|
||||
Object.assign(event, { pointerId: 1, isPrimary: true, button: 0, clientX: x, clientY: y });
|
||||
viewport.dispatchEvent(event);
|
||||
};
|
||||
const finish = async () => {
|
||||
slides.flatMap((slide) => slide.animations).forEach((animation) => animation.finish());
|
||||
await new Promise(setImmediate);
|
||||
};
|
||||
return { slides, pointer, swipe, finish, current: () => current };
|
||||
}
|
||||
|
||||
for (const reducedMotion of [false, true]) {
|
||||
for (const direction of [-1, 1]) {
|
||||
const label = `direction ${direction}, reduced motion ${reducedMotion}`;
|
||||
test(`held drag reverses past its origin: ${label}`, async () => {
|
||||
const car = carousel(reducedMotion);
|
||||
car.pointer('pointerdown', 200);
|
||||
car.pointer('pointermove', 200 + direction * 120);
|
||||
car.pointer('pointermove', 200 - direction * 120);
|
||||
assert.equal(car.slides[1].style.transform, 'translateX(0px)');
|
||||
assert.ok(car.slides[1 + direction].classList.contains('hidden'), 'a third image must stay hidden');
|
||||
car.pointer('pointerup', 200 - direction * 120);
|
||||
await car.finish();
|
||||
assert.equal(car.current(), 1, 'reversing should return to the original image, not a third image');
|
||||
assert.equal(car.slides.filter((slide) => !slide.classList.contains('hidden')).length, 1);
|
||||
});
|
||||
|
||||
test(`reversal first reported on release: ${label}`, async () => {
|
||||
const car = carousel(reducedMotion);
|
||||
car.pointer('pointerdown', 200);
|
||||
car.pointer('pointermove', 200 + direction * 120);
|
||||
car.pointer('pointerup', 200 - direction * 120);
|
||||
await car.finish();
|
||||
assert.equal(car.current(), 1);
|
||||
});
|
||||
|
||||
test(`repeated reversals can still complete the initial swipe: ${label}`, async () => {
|
||||
const car = carousel(reducedMotion);
|
||||
car.pointer('pointerdown', 200);
|
||||
for (const distance of [120, -120, 80, -80, 60]) {
|
||||
car.pointer('pointermove', 200 + direction * distance);
|
||||
}
|
||||
car.pointer('pointerup', 200 + direction * 60);
|
||||
await car.finish();
|
||||
assert.equal(car.current(), 1 - direction);
|
||||
});
|
||||
|
||||
test(`a new drag can swipe back: ${label}`, async () => {
|
||||
const car = carousel(reducedMotion);
|
||||
for (const step of [direction, -direction]) {
|
||||
car.pointer('pointerdown', 200);
|
||||
car.pointer('pointermove', 200 + step * 120);
|
||||
car.pointer('pointerup', 200 + step * 120);
|
||||
await car.finish();
|
||||
assert.equal(car.current(), step === direction ? 1 - direction : 1);
|
||||
}
|
||||
});
|
||||
|
||||
test(`reversing below the swipe threshold cancels: ${label}`, async () => {
|
||||
const car = carousel(reducedMotion);
|
||||
car.pointer('pointerdown', 200);
|
||||
car.pointer('pointermove', 200 + direction * 120);
|
||||
car.pointer('pointermove', 200 + direction * 20);
|
||||
car.pointer('pointerup', 200 + direction * 20);
|
||||
await car.finish();
|
||||
assert.equal(car.current(), 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pointer gestures against the real carousel controller, with a minimal DOM.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
command -v node >/dev/null 2>&1 || { echo "test_site_swipe: SKIP (no node)"; exit 0; }
|
||||
node --test "$ROOT/tests/site_swipe.mjs"
|
||||
Reference in New Issue
Block a user