Swipe between items like turning pages (#52)

On a phone the reader is fixed over the item list, and the swipe from #49 moved it alone and
faded it to 40%, so the list showed through it and in the strip it uncovered, and again as the
next item slid in from the far side. Now a layer beside the reader, #dpeek, holds what is really
there: the next or previous item, drawn by the same detailHtml the reader uses, with 16px of the
page's background between them; "No more items" past the last; or, swiping right from the
first, a dimmer over the list that lifts as the reader, shadowed along its edge, is drawn off.
On release the swipe carries on from where the finger left it, over 120-250ms by how far is
left, and the neighbour becomes the reader in place; with reduced motion it switches at once.

A touch starting within 14px of the left edge is kept from Safari, which otherwise takes it as
Back and leaves the page mid-swipe. 14px because the back button starts at 16.

Offsets are rounded to whole pixels: at a fractional offset the seam between the reader and its
neighbour drew a stray light line.

The design had the list shift a third of the way across as it is uncovered, as iOS does. #detail
lives inside #shell, and a transform there makes it the containing block for the fixed reader,
so that part is left out; the dimmer and shadow carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 23:46:07 +00:00
parent a573a07ed5
commit f2fde8cc75
5 changed files with 113 additions and 28 deletions

View File

@@ -1211,14 +1211,15 @@ test.describe('touch gestures on a phone', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
async function drag(page, from, to) {
async function drag(page, from, to, lift = true) {
const cdp = await page.context().newCDPSession(page);
const steps = 8;
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
for (let i = 1; i <= steps; i++)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
if (lift) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
return cdp;
}
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
@@ -1234,6 +1235,14 @@ test.describe('touch gestures on a phone', () => {
await drag(page, { x: 300, y: 400 }, { x: 230, y: 410 }); // too short: stays (#49)
await expect(shown).toHaveText(titles[0]);
await expect(page.locator('#detail')).not.toHaveAttribute('style', /translate/); // springs back
// Held partway: the next item is beside the reader, not the list under it (#52), and
// let go short of a swipe, it goes again.
const held = await drag(page, { x: 300, y: 400 }, { x: 230, y: 405 }, false);
await expect(page.locator('#dpeek.next .dt')).toHaveText(titles[1]);
await expect(page.locator('#detail')).toHaveCSS('opacity', '1');
await held.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect(page.locator('#dpeek')).toHaveCount(0);
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
await expect(shown).toHaveText(titles[1]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays