Fix swipe hijacking h-scroll; drag bottom sheets down to dismiss

- onSwipe: before claiming a horizontal page-swipe, walk up from the
  touch target for an overflow-x auto/scroll ancestor that can still
  scroll that way (tab strip, moves list, coverage grid) and bail if
  found — sliding those no longer flips to the next Pokémon.
- segThumb: retry placement until the seg is laid out, and observe it for
  resizes, so the sliding pill is positioned on first paint instead of
  only after the first click. Dropped the :has() selector for a
  JS-applied .seg--thumbed class.
- New lib/sheet-drag.js: drag the game sheet / Pokémon picker down by its
  header to dismiss (past ~110px or a flick), else it springs back.
  Header gets touch-action:none + a grab cursor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-10 09:39:16 -04:00
parent 17e8e30f9b
commit 5bce0f93f3
6 changed files with 108 additions and 27 deletions

View File

@ -7,6 +7,7 @@ import {
} from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js';
import { gameColors } from '../data/game-colors.js';
import { dragToClose } from '../lib/sheet-drag.js';
let openInstance = null;
@ -134,20 +135,18 @@ export async function openGameSheet() {
}
}
panel.append(
const head = el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Choose your game'),
el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Choose your game'),
el(
'button',
{ class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close },
'✕',
),
'button',
{ class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close },
'✕',
),
subdex,
list,
);
dragToClose(panel, head, close);
panel.append(head, subdex, list);
backdrop.append(panel);
document.body.append(backdrop);
document.addEventListener('keydown', onKey);

View File

@ -3,6 +3,7 @@ import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { Sprite } from './Sprite.js';
import { TypeChip } from './TypeChip.js';
import { dragToClose } from '../lib/sheet-drag.js';
let openInstance = null;
@ -83,16 +84,14 @@ export async function openPokemonPicker(onPick, { closeAfterPick = false } = {})
}
}
panel.append(
el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Add a Pokémon'),
el('button', { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, '✕'),
),
input,
results,
const head = el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Add a Pokémon'),
el('button', { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, '✕'),
);
dragToClose(panel, head, close);
panel.append(head, input, results);
backdrop.append(panel);
document.body.append(backdrop);
document.addEventListener('keydown', onKey);

View File

@ -30,19 +30,28 @@ export function countUp(node, to, { from = 0, duration = 550, decimals = 0, pref
* reduced-motion users just get an instant jump.
*/
export function segThumb(seg, _count, active = 0) {
seg.classList.add('seg--thumbed');
const thumb = document.createElement('div');
thumb.className = 'seg__thumb';
seg.prepend(thumb);
let current = active;
const place = (i) => {
const place = (i, tries = 0) => {
current = i;
const b = seg.querySelectorAll('.seg__btn')[i];
if (!b || !b.offsetWidth) return;
thumb.style.width = `${b.offsetWidth}px`;
thumb.style.transform = `translateX(${b.offsetLeft - 3}px)`;
if (b && b.offsetWidth) {
thumb.style.width = `${b.offsetWidth}px`;
thumb.style.transform = `translateX(${b.offsetLeft - 3}px)`;
} else if (tries < 20) {
// Not laid out yet (hidden tab, webfont pending) — try again.
requestAnimationFrame(() => place(i, tries + 1));
}
};
requestAnimationFrame(() => place(active));
if (document.fonts?.ready) document.fonts.ready.then(() => place(current));
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => place(current));
ro.observe(seg);
}
return place;
}

43
src/lib/sheet-drag.js Normal file
View File

@ -0,0 +1,43 @@
/**
* Drag a bottom sheet down by its handle to dismiss it. `panel` is the
* `.gsheet`, `handle` the grab area (its header), `close` the dismiss fn.
* Past ~110px (or a quick flick) it closes; otherwise it springs back.
*/
export function dragToClose(panel, handle, close) {
let y0 = 0;
let dy = 0;
let t0 = 0;
let dragging = false;
const start = (e) => {
if (e.touches.length !== 1) return;
y0 = e.touches[0].clientY;
dy = 0;
t0 = Date.now();
dragging = true;
panel.style.transition = 'none';
};
const move = (e) => {
if (!dragging) return;
dy = e.touches[0].clientY - y0;
// Rubber-band an upward pull; follow a downward one.
const shown = dy < 0 ? dy * 0.25 : dy;
panel.style.transform = `translateY(${shown}px)`;
if (dy > 0) e.preventDefault();
};
const end = () => {
if (!dragging) return;
dragging = false;
panel.style.transition = '';
panel.style.transform = '';
const flick = dy > 40 && Date.now() - t0 < 300;
if (dy > 110 || flick) close();
};
handle.addEventListener('touchstart', start, { passive: true });
handle.addEventListener('touchmove', move, { passive: false });
handle.addEventListener('touchend', end, { passive: true });
handle.addEventListener('touchcancel', end, { passive: true });
}

View File

@ -14,6 +14,24 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
let t0 = 0;
let active = false;
let claimed = false;
let startNode = null;
// Would a horizontal drag from `node` scroll a nested element instead of
// the page? (moves list, tab strip, coverage grid…) `dir` < 0 means the
// finger is moving left — that scrolls such an element to the right.
const scrollsInside = (node, dir) => {
for (let n = node; n && n !== document.body && n !== target.parentElement; n = n.parentElement) {
if (n.scrollWidth - n.clientWidth > 2) {
const ox = getComputedStyle(n).overflowX;
if (ox === 'auto' || ox === 'scroll') {
const max = n.scrollWidth - n.clientWidth;
if (dir < 0 && n.scrollLeft < max - 1) return true;
if (dir > 0 && n.scrollLeft > 1) return true;
}
}
}
return false;
};
const start = (e) => {
if (e.touches.length !== 1) {
@ -25,6 +43,7 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
t0 = Date.now();
active = true;
claimed = false;
startNode = e.target;
};
const move = (e) => {
@ -43,8 +62,15 @@ export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
active = false;
return;
}
// Only claim a horizontal page-swipe once the drag is unambiguous.
if (adx > 20 && adx > ady * 2) claimed = true;
// Only claim a horizontal page-swipe once the drag is unambiguous —
// and not when it should scroll something nested instead.
if (adx > 20 && adx > ady * 2) {
if (scrollsInside(startNode, dx)) {
active = false;
return;
}
claimed = true;
}
}
if (claimed) e.preventDefault();
};

View File

@ -1725,6 +1725,11 @@
justify-content: space-between;
padding: 16px 0 8px;
background: var(--surface);
cursor: grab;
touch-action: none;
}
.gsheet__head:active {
cursor: grabbing;
}
.gsheet__head::before {
content: "";
@ -3347,7 +3352,7 @@
position: relative;
z-index: 1;
}
.seg:has(.seg__thumb) .seg__btn.is-active {
.seg--thumbed .seg__btn.is-active {
background: none;
box-shadow: none;
}