diff --git a/src/components/GameSheet.js b/src/components/GameSheet.js index 56e49f7..37e5d54 100644 --- a/src/components/GameSheet.js +++ b/src/components/GameSheet.js @@ -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); diff --git a/src/components/PokemonPicker.js b/src/components/PokemonPicker.js index ecd3a65..c41162a 100644 --- a/src/components/PokemonPicker.js +++ b/src/components/PokemonPicker.js @@ -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); diff --git a/src/lib/anim.js b/src/lib/anim.js index 9f87614..c304825 100644 --- a/src/lib/anim.js +++ b/src/lib/anim.js @@ -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; } diff --git a/src/lib/sheet-drag.js b/src/lib/sheet-drag.js new file mode 100644 index 0000000..d0851cb --- /dev/null +++ b/src/lib/sheet-drag.js @@ -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 }); +} diff --git a/src/lib/swipe.js b/src/lib/swipe.js index 3037c5f..a899f8a 100644 --- a/src/lib/swipe.js +++ b/src/lib/swipe.js @@ -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(); }; diff --git a/src/styles/layout.css b/src/styles/layout.css index a5dee69..7d96f37 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; }