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:
Nico
2026-09-07 21:00:11 +08:00
committed by GitHub
parent dd19b653db
commit a9832680d3
5 changed files with 136 additions and 3 deletions
+3
View File
@@ -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));
+5 -3
View File
@@ -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);
+7
View File
@@ -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;
+115
View File
@@ -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);
});
}
}
+6
View File
@@ -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"