diff --git a/e2e/specs/long-press.spec.ts b/e2e/specs/long-press.spec.ts new file mode 100644 index 0000000..33a8d0b --- /dev/null +++ b/e2e/specs/long-press.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from '../support/fixtures.js'; + +/** + * Long-press is the touch route to a context menu (plan 016 B2 phase 3). + * + * The component tier proves the gesture in isolation, against markup it + * built itself. What it cannot prove is the half that made this one + * listener instead of six: that the synthetic event reaches the handler + * a *real* component bound — `track-list` delegates its `contextmenu` + * on the `lit-virtualizer` rather than binding one per row — and that + * the real `wa-popup` menu opens from it, which is a path with its own + * history of opening and then refusing to work (see + * `menu-keyboard.spec.ts`). + * + * The pointer events are dispatched rather than performed: this project + * runs Desktop Chrome and Desktop Safari, neither of which has touch, + * and a device tier does not exist. So this is honest about what it + * checks — the app's own listeners, on the app's own DOM, from the + * events a touch would produce — and not about a real finger. + */ + +/** A common small phone, as in `phone-shell.spec.ts`. */ +const PHONE = { width: 390, height: 844 }; + +/** Comfortably past the module's 500ms hold. */ +const HELD = 900; + +type Page = import('@playwright/test').Page; + +/** The track list's menu panel, or null while it is not rendered. */ +const panel = (page: Page) => + page.evaluate(() => { + const el = document + .querySelector('track-list') + ?.shadowRoot?.querySelector('.context-menu-panel'); + + if (!el) return null; + + return { + role: el.getAttribute('role'), + label: el.getAttribute('aria-label'), + items: el.querySelectorAll('[role="menuitem"]').length, + }; + }); + +/** + * Press the first track row, optionally dragging partway through — the + * shape of a scroll that begins on a row, which must not open a menu. + */ +async function pressFirstRow( + page: Page, + opts: { driftY?: number } = {}, +): Promise { + await page.evaluate((drift) => { + // `.track-row`, not `[role="row"]`: the column header is a row too, + // and it is the *first* one -- a press on it is correctly ignored, + // which reads exactly like the gesture not working. + const row = document + .querySelector('track-list') + ?.shadowRoot?.querySelector('.track-row'); + + if (!row) throw new Error('no track row to press'); + + const box = row.getBoundingClientRect(); + const x = Math.round(box.left + box.width / 2); + const y = Math.round(box.top + box.height / 2); + const send = (type: string, dy = 0) => + row.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + composed: true, + cancelable: true, + pointerType: 'touch', + isPrimary: true, + clientX: x, + clientY: y + dy, + }), + ); + + send('pointerdown'); + + if (drift) send('pointermove', drift); + }, opts.driftY ?? 0); +} + +test.describe('long-press opens the track menu', () => { + test.beforeEach(async ({ app }) => { + await app.setViewportSize(PHONE); + await app.getByTestId('tab-tracks').click(); + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'tracks', + ); + }); + + test.afterEach(async ({ app }) => { + // Every other spec file runs against a desktop, and the viewport + // belongs to the shared context rather than to this file. + await app.setViewportSize({ width: 1440, height: 900 }); + }); + + test('reaches the delegated handler and opens the real menu', async ({ + app, + }) => { + await expect.poll(() => panel(app)).toBeNull(); + + await pressFirstRow(app); + + await expect + .poll(() => panel(app), { timeout: HELD + 2000 }) + .toMatchObject({ role: 'menu', label: 'Track actions' }); + + // The same panel Shift+F10 opens, items and all -- not an empty + // popup that happened to become visible. + expect((await panel(app))?.items).toBeGreaterThan(0); + }); + + test('does not open one for a press that turns into a scroll', async ({ + app, + }) => { + await pressFirstRow(app, { driftY: 40 }); + + await app.waitForTimeout(HELD); + + expect(await panel(app)).toBeNull(); + }); +}); diff --git a/frontend/index.ts b/frontend/index.ts index a64db71..0d2e523 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -50,6 +50,7 @@ import '@store/theme-store'; // registers the document keydown listener for global shortcuts. import './src/services/keyboard-shortcut-service'; import { activateView, deactivateView } from '@utils/view-lifecycle'; +import { installLongPressContextMenu } from '@utils/long-press'; import { hasTrackPayload, getDragPayload, @@ -64,6 +65,11 @@ setBasePath('/dist/webawesome'); // the session. registerBundledIcons(); +// The touch equivalent of a right-click, installed once for every menu +// in the app rather than per component. Harmless on a desktop: it acts +// on `pointerType === 'touch'` only. +installLongPressContextMenu(); + // --------------------------------------------------------------------------- // View caching navigation system // --------------------------------------------------------------------------- diff --git a/frontend/src/utils/long-press.ts b/frontend/src/utils/long-press.ts new file mode 100644 index 0000000..1f52ff1 --- /dev/null +++ b/frontend/src/utils/long-press.ts @@ -0,0 +1,201 @@ +/** + * Long-press as the touch equivalent of a right-click (plan 016 B2, + * phase 3). + * + * Every context menu in the app opens from a `contextmenu` event — + * `track-list` and `queue-panel` delegate one on their virtualizer, + * the card grids and both playlist detail views bind one per row, and + * `explore-artist-details` binds three. A phone has no right-click, so + * a phone reached none of them. + * + * **This is one document listener, not six components' worth of touch + * handling.** A press that stays still for `LONG_PRESS_MS` dispatches a + * synthetic `contextmenu` at the touch point on the element the touch + * actually landed on, and every existing handler — delegated or + * per-row, in any shadow root — runs unchanged. Six implementations of + * a gesture is exactly the fault `ContextMenuController` exists to + * prevent, and a seam that needs no component to opt in cannot be + * forgotten by the next component. + * + * Three things about it are load-bearing. + * + * **The target comes from `composedPath()[0]`, not from + * `elementFromPoint`**, which stops at the outermost shadow host: every + * menu in this app is bound inside one, so a synthetic event dispatched + * on the host reaches a delegated listener and no per-row one. + * + * **A browser that already does this must win.** Chromium fires a + * `contextmenu` on long-press itself; WebKitGTK and the Android WebView + * vary. So one arriving during the press cancels ours, and one arriving + * just after ours is swallowed at document capture — where nothing else + * has seen it yet. The two are told apart by **identity** (a `WeakSet` + * of the events this module made) rather than by `isTrusted`, so the + * suppressor cannot eat the event it exists to deliver, the rule holds + * for anything else in the app that synthesises one, and a test can + * stand in for a browser that fires its own. + * + * **The click that ends the gesture is swallowed.** A row's click + * selects, and a card's plays; without this, opening a menu also + * activates the thing under it. It is keyed on the gesture (cleared by + * the next `pointerdown`) rather than on a time window, so a quick tap + * on the menu that just opened is not eaten too. + */ + +/** How long a press must hold still to mean "menu". */ +export const LONG_PRESS_MS = 500; + +/** + * How far a press may drift and still count. Below a finger's own + * jitter is a gesture nobody can perform; above ~12px it starts + * stealing the first frames of a scroll. + */ +export const MOVE_TOLERANCE_PX = 10; + +/** The active installation, so a second call is a no-op rather than a + * second listener set. */ +let uninstall: (() => void) | null = null; + +/** The events this module dispatched. Identity, not `isTrusted`: see + * the note above. */ +const ours = new WeakSet(); + +/** + * Install the gesture. Idempotent; returns the uninstaller (which the + * tests use — the app installs once and never removes it). + */ +export function installLongPressContextMenu(): () => void { + if (uninstall) return uninstall; + + let timer: ReturnType | null = null; + let originX = 0; + let originY = 0; + let target: EventTarget | null = null; + + /** A trusted `contextmenu` arrived for this press: the browser has + * it covered. */ + let nativeSeen = false; + + /** We opened a menu, and the click ending that gesture is not a + * click on anything. */ + let swallowClick = false; + + /** We dispatched one, so a trusted one arriving now is a duplicate. */ + let justFired = false; + + const cancel = (): void => { + if (timer !== null) clearTimeout(timer); + + timer = null; + target = null; + }; + + const fire = (): void => { + timer = null; + + const el = target; + + target = null; + + if (nativeSeen || !el) return; + + justFired = true; + swallowClick = true; + + const menu = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + // Or it stops at the shadow root the row lives in, and the + // delegated listeners never see it. + composed: true, + clientX: originX, + clientY: originY, + button: 2, + }); + + ours.add(menu); + el.dispatchEvent(menu); + }; + + const onPointerDown = (e: PointerEvent): void => { + // A new gesture: whatever the last one left behind is stale. + swallowClick = false; + justFired = false; + nativeSeen = false; + cancel(); + + if (e.pointerType !== 'touch' || !e.isPrimary) return; + + originX = e.clientX; + originY = e.clientY; + target = e.composedPath()[0] ?? e.target; + timer = setTimeout(fire, LONG_PRESS_MS); + }; + + const onPointerMove = (e: PointerEvent): void => { + if (timer === null) return; + + const drifted = + Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX || + Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX; + + if (drifted) cancel(); + }; + + const onContextMenu = (e: Event): void => { + // Ours. Everything below is about somebody else's. + if (ours.has(e)) return; + + if (timer !== null) { + // The browser got there first, so stand down rather than + // opening the same menu twice. + nativeSeen = true; + cancel(); + + return; + } + + if (justFired) { + justFired = false; + e.preventDefault(); + e.stopImmediatePropagation(); + } + }; + + const onClick = (e: Event): void => { + if (!swallowClick) return; + + swallowClick = false; + e.preventDefault(); + e.stopImmediatePropagation(); + }; + + // Capture throughout: a component handler that stops propagation + // (every context-menu handler in the app does) must not be able to + // hide the gesture from this, and the suppressors have to run + // before anything that would act on the event. + const opts = { capture: true } as const; + + document.addEventListener('pointerdown', onPointerDown, opts); + document.addEventListener('pointermove', onPointerMove, opts); + document.addEventListener('pointerup', cancel, opts); + document.addEventListener('pointercancel', cancel, opts); + document.addEventListener('contextmenu', onContextMenu, opts); + document.addEventListener('click', onClick, opts); + // A scroll started by something other than the finger (momentum, a + // programmatic reveal) still means the press was not a press. + document.addEventListener('scroll', cancel, { capture: true, passive: true }); + + uninstall = () => { + cancel(); + document.removeEventListener('pointerdown', onPointerDown, opts); + document.removeEventListener('pointermove', onPointerMove, opts); + document.removeEventListener('pointerup', cancel, opts); + document.removeEventListener('pointercancel', cancel, opts); + document.removeEventListener('contextmenu', onContextMenu, opts); + document.removeEventListener('click', onClick, opts); + document.removeEventListener('scroll', cancel, opts); + uninstall = null; + }; + + return uninstall; +} diff --git a/frontend/test/components/long-press.test.ts b/frontend/test/components/long-press.test.ts new file mode 100644 index 0000000..66f2755 --- /dev/null +++ b/frontend/test/components/long-press.test.ts @@ -0,0 +1,212 @@ +/** + * Long-press as the touch route to a context menu (plan 016 B2 phase 3). + * + * These run in a real browser with real event dispatch, which is the + * only place the two things that make this hard are true: the synthetic + * event has to cross a shadow boundary to reach the listener a + * component actually bound, and the suppressors have to tell a trusted + * event from ours at document capture without eating the one they exist + * to deliver. + * + * The timings are real rather than faked, because the thing under test + * *is* a timing, and 600 ms twice is cheaper than a fake-timer harness + * that would also have to fake the pointer events. + */ +import { describe, expect, it, afterEach, beforeEach } from 'vitest'; + +import { + installLongPressContextMenu, + LONG_PRESS_MS, + MOVE_TOLERANCE_PX, +} from '@utils/long-press'; + +/** A press that has certainly resolved, either way. */ +const HELD = LONG_PRESS_MS + 120; + +/** A press that has certainly not. */ +const BRIEF = Math.round(LONG_PRESS_MS / 4); + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +let uninstall: (() => void) | null = null; +let host: HTMLElement; +let inner: HTMLElement; + +/** A row inside a shadow root, which is where every menu in this app + * is bound — an element in the light DOM would pass a weaker test. */ +function mountRow(): { host: HTMLElement; inner: HTMLElement } { + const el = document.createElement('div'); + const root = el.attachShadow({ mode: 'open' }); + const row = document.createElement('div'); + + row.textContent = 'a track'; + root.append(row); + document.body.append(el); + + return { host: el, inner: row }; +} + +function press( + el: EventTarget, + type: string, + init: PointerEventInit = {}, +): void { + el.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + composed: true, + cancelable: true, + pointerType: 'touch', + isPrimary: true, + clientX: 40, + clientY: 60, + ...init, + }), + ); +} + +/** + * Record every `contextmenu` that reaches the listener, *as the + * listener sees it*. + * + * `target` is retargeted for the scope reading it, so an assertion made + * after dispatch has finished reports the shadow host however the event + * was dispatched - which is the same answer a broken implementation + * gives. It has to be read from inside the handler, where the component + * reads it. + */ +function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] { + const seen: { event: MouseEvent; target: EventTarget | null }[] = []; + + el.addEventListener('contextmenu', (e) => { + e.preventDefault(); + // Every real handler does this; the gesture must work anyway. + e.stopPropagation(); + seen.push({ event: e as MouseEvent, target: e.target }); + }); + + return seen; +} + +describe('long-press opens a context menu', () => { + beforeEach(() => { + uninstall = installLongPressContextMenu(); + ({ host, inner } = mountRow()); + }); + + afterEach(() => { + uninstall?.(); + uninstall = null; + host.remove(); + }); + + it('dispatches one at the touch point, on the element touched', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown'); + await wait(HELD); + + expect(seen).toHaveLength(1); + expect(seen[0]?.event.clientX).toBe(40); + expect(seen[0]?.event.clientY).toBe(60); + // Dispatched on the row itself, not on its shadow host - which is + // the difference between a per-row handler firing and only a + // delegated one firing. + expect(seen[0]?.target).toBe(inner); + }); + + it('is cancelled by a press that moves', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown'); + press(inner, 'pointermove', { + clientX: 40 + MOVE_TOLERANCE_PX + 5, + clientY: 60, + }); + await wait(HELD); + + expect(seen).toHaveLength(0); + }); + + it('tolerates the jitter a finger cannot help', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown'); + press(inner, 'pointermove', { clientX: 43, clientY: 62 }); + await wait(HELD); + + expect(seen).toHaveLength(1); + }); + + it('is cancelled by lifting early, and by a scroll', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown'); + await wait(BRIEF); + press(inner, 'pointerup'); + await wait(HELD); + + expect(seen).toHaveLength(0); + + press(inner, 'pointerdown'); + press(inner, 'pointercancel'); + await wait(HELD); + + expect(seen).toHaveLength(0); + }); + + it('ignores a mouse, which has a right button of its own', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown', { pointerType: 'mouse' }); + await wait(HELD); + + expect(seen).toHaveLength(0); + }); + + it('swallows the click that ends the gesture, and only that one', async () => { + let clicks = 0; + + inner.addEventListener('click', () => { + clicks += 1; + }); + + press(inner, 'pointerdown'); + await wait(HELD); + press(inner, 'pointerup'); + inner.click(); + + expect(clicks).toBe(0); + + // The next tap is a tap: on a phone that is the user choosing an + // item in the menu that just opened, so eating it would make the + // gesture useless. + press(inner, 'pointerdown'); + press(inner, 'pointerup'); + inner.click(); + + expect(clicks).toBe(1); + }); + + it('stands down where the browser fires its own', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown'); + await wait(BRIEF); + // Chromium does this itself on touch; WebKit and the Android + // WebView vary, which is the whole reason both halves exist. A + // test cannot dispatch a *trusted* event, which is why the module + // tells its own apart by identity rather than by `isTrusted`. + inner.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + composed: true, + cancelable: true, + }), + ); + await wait(HELD); + + // One menu: the browser's. Not two. + expect(seen).toHaveLength(1); + }); +});