feat(android): tap to play, hold to select
Phase 1 of #63, and the design the issue asks for as one piece is .planning/plans/active/019-android-touch-model.md. **A finger has no second button and no modifier keys**, so the primary action has to be the primary gesture: tap plays the row, and the hold that opened a context menu now enters selection mode with that row selected. Three decisions in it, and two diverge from the report. **The predicate is the pointer, not the platform or the viewport.** `pointerType === 'touch'`, per event, which is already how long-press.ts decided and is the only such test in the frontend. This is #64's rule -- named after the capability -- and it carries #64's warning: keyed on a width, an Android *tablet* at 600px gets click-selects/double-click-plays on a touchscreen, which is the inversion this issue exists to fix, on the platform it exists for. A touchscreen laptop cannot be described by a width at all. Per event, a mouse keeps desktop semantics on the very same row, and there is no second declaration of what a phone does. **There is no double-tap, and the number is why.** The report asks for single tap to play *and* double tap for the menu. Those cannot both be honoured: the first tap of a double tap is indistinguishable from a single tap until the interval expires, so "tap plays" becomes "tap waits". Measured on the device, the play command to TrackChanged is 155/123/85/56/91 ms -- median ~100 -- and the app's own DOUBLE_CLICK_GRACE_MS is 250. That is 3.5x the primary interaction, 250ms of it spent deliberately doing nothing, on every track anyone plays, to reach a menu the hold already reaches. So the menu and the selection action bar are the same surface, which is also the platform's convention and removes a concept rather than adding one. **Tap-to-play and selection mode ship together**, because splitting them is a regression dressed as an increment: a touch user selects by tapping today and acts through the long-press menu, so moving tap to play on its own would leave a window with no way to select forty tracks at all. **What lets this reassign the hold without touching one of the fourteen context menus**: the layer announces `yj-tap` / `yj-long-press` (composed, cancelable) and acts on nothing. A component claims one with preventDefault. An **unclaimed long press still becomes a `contextmenu`**, so the card grids, Explore, the playlist rows and every other menu behave exactly as they did, and only lists that opt in get selection mode. An unclaimed *tap* does nothing at all and the click follows normally, which is what leaves every button in the app alone -- only a claimed tap has its click swallowed, or playing a track would also select it. **And the device found the one thing no browser tier can see.** Chrome 113's Android WebView fires its own `contextmenu` on a long press. long-press.ts stood down when a trusted one arrived, which was right while both paths ended in a context menu; they no longer do, so standing down means the gesture silently does the *old* thing. Measured, before the fix, holding a track row: {"log":["contextmenu isTrusted=true"], "state":{"bar":null,"menuActive":true,"selected":1}} `yj-long-press` was never announced, the menu opened, and all 26 tests passed -- dispatched pointer events do not make a browser synthesise one. So the native event is a **trigger, not a competitor**: the gesture is announced from it and only a claim suppresses it. Unclaimed it propagates untouched, which is the same "browser wins" outcome reached by asking instead of assuming. The tier could not find that and can hold it, because this module has always told its own events apart by identity rather than isTrusted, so an untrusted one from a test takes exactly the browser's path. Verified on the device by *performing* the gestures rather than describing the page -- `adb shell input tap` and `input swipe x y x y 700` reach the WebView as real pointer events, which is new here and is written down in the plan with the pixel mapping. Tap plays; a hold raises the bar with one selected and no menu; a tap toggles to two, back to one, and the mode ends with the last row; an album card still opens its context menu. 29 new tests. The e2e spec is rewritten to assert **both** halves -- the row selects, and a card elsewhere still opens the real menu -- because a spec that only checked the row would pass on a build that had silently broken the other thirteen. Phases 2-4 (swipe to queue, the other three surfaces, and what #67 inherits) are in the plan and not in this commit.
This commit is contained in:
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* The touch gestures (plan 019, #63; long-press from plan 016 B2).
|
||||
*
|
||||
* 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 {
|
||||
installTouchGestures,
|
||||
LONG_PRESS_MS,
|
||||
MOVE_TOLERANCE_PX,
|
||||
} from '@utils/touch-gestures';
|
||||
|
||||
/** 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('an unclaimed long press is still a context menu', () => {
|
||||
beforeEach(() => {
|
||||
uninstall = installTouchGestures();
|
||||
({ 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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* What plan 019 adds on top, and the one property that protects
|
||||
* everything downstream: a long press nobody claims is unchanged.
|
||||
*/
|
||||
describe('a gesture is announced before it is acted on', () => {
|
||||
beforeEach(() => {
|
||||
uninstall = installTouchGestures();
|
||||
({ host, inner } = mountRow());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstall?.();
|
||||
uninstall = null;
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it('does not synthesise a menu when the long press is claimed', async () => {
|
||||
// This is the whole reason #63 could reassign the hold without
|
||||
// touching one of the fourteen context menus: the lists that want
|
||||
// selection mode claim it, and nothing else changes.
|
||||
const menus = recordMenus(inner);
|
||||
const presses: unknown[] = [];
|
||||
|
||||
inner.addEventListener('yj-long-press', (e) => {
|
||||
presses.push(e);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(HELD);
|
||||
|
||||
expect(presses).toHaveLength(1);
|
||||
expect(menus).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('announces a tap on the element touched, at the touch point', async () => {
|
||||
const taps: { target: EventTarget | null; x: number; y: number }[] = [];
|
||||
|
||||
inner.addEventListener('yj-tap', (e) => {
|
||||
taps.push({ target: e.target, x: e.detail.x, y: e.detail.y });
|
||||
});
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
press(inner, 'pointerup');
|
||||
|
||||
expect(taps).toHaveLength(1);
|
||||
// The row, not its shadow host -- the difference between a
|
||||
// delegated handler firing and a per-row one never firing.
|
||||
expect(taps[0]?.target).toBe(inner);
|
||||
expect([taps[0]?.x, taps[0]?.y]).toEqual([40, 60]);
|
||||
});
|
||||
|
||||
it('lets an unclaimed tap through as an ordinary click', async () => {
|
||||
// Every button, link and checkbox in the app depends on this. Only
|
||||
// a *claimed* tap has its click swallowed.
|
||||
let clicks = 0;
|
||||
|
||||
inner.addEventListener('yj-tap', () => {
|
||||
/* seen, not claimed */
|
||||
});
|
||||
inner.addEventListener('click', () => {
|
||||
clicks += 1;
|
||||
});
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
press(inner, 'pointerup');
|
||||
inner.click();
|
||||
|
||||
expect(clicks).toBe(1);
|
||||
});
|
||||
|
||||
it('swallows the click behind a claimed tap', async () => {
|
||||
// Or playing a track would also select it, and the row would end
|
||||
// up in both states at once.
|
||||
let clicks = 0;
|
||||
|
||||
inner.addEventListener('yj-tap', (e) => e.preventDefault());
|
||||
inner.addEventListener('click', () => {
|
||||
clicks += 1;
|
||||
});
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
press(inner, 'pointerup');
|
||||
inner.click();
|
||||
|
||||
expect(clicks).toBe(0);
|
||||
});
|
||||
|
||||
it('does not announce a tap for a press that became a long press', async () => {
|
||||
// A hold is one gesture, not a hold and then a tap on release.
|
||||
const taps: unknown[] = [];
|
||||
|
||||
inner.addEventListener('yj-tap', (e) => taps.push(e));
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(HELD);
|
||||
press(inner, 'pointerup');
|
||||
|
||||
expect(taps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not announce a tap for a press that drifted', async () => {
|
||||
// A drifted press is a scroll, and the virtualizer's -- not a tap
|
||||
// that happened to move. This is the one that would make a list
|
||||
// unscrollable if it were wrong.
|
||||
const taps: unknown[] = [];
|
||||
|
||||
inner.addEventListener('yj-tap', (e) => taps.push(e));
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
press(inner, 'pointermove', {
|
||||
clientX: 40,
|
||||
clientY: 60 + MOVE_TOLERANCE_PX + 20,
|
||||
});
|
||||
press(inner, 'pointerup');
|
||||
|
||||
expect(taps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores a mouse entirely, for both gestures', async () => {
|
||||
// plan 019 decision 1: the predicate is the pointer, per event. A
|
||||
// mouse on a touchscreen keeps click-selects / double-click-plays
|
||||
// on the very same row, which no viewport width can express.
|
||||
const seen: unknown[] = [];
|
||||
|
||||
inner.addEventListener('yj-tap', (e) => seen.push(e));
|
||||
inner.addEventListener('yj-long-press', (e) => seen.push(e));
|
||||
|
||||
press(inner, 'pointerdown', { pointerType: 'mouse' });
|
||||
await wait(BRIEF);
|
||||
press(inner, 'pointerup', { pointerType: 'mouse' });
|
||||
press(inner, 'pointerdown', { pointerType: 'mouse' });
|
||||
await wait(HELD);
|
||||
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The browser's own long press is a trigger, not a competitor.
|
||||
*
|
||||
* This is a device-only defect made checkable here. `long-press.ts`
|
||||
* stood down when a trusted `contextmenu` arrived mid-press, which was
|
||||
* right while both paths ended in a context menu. Once a hold can mean
|
||||
* *selection mode*, standing down means the gesture silently does the
|
||||
* old thing — and Chrome 113's Android WebView does fire its own, so
|
||||
* on the reference device `yj-long-press` was never announced at all
|
||||
* while every test in this tier passed.
|
||||
*
|
||||
* A test cannot dispatch a *trusted* event, which is exactly why the
|
||||
* module tells its own apart by identity rather than by `isTrusted`:
|
||||
* an untrusted one dispatched from here takes the same path the
|
||||
* browser's does.
|
||||
*/
|
||||
describe("the browser's own long press", () => {
|
||||
beforeEach(() => {
|
||||
uninstall = installTouchGestures();
|
||||
({ host, inner } = mountRow());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstall?.();
|
||||
uninstall = null;
|
||||
host.remove();
|
||||
});
|
||||
|
||||
/** Stand in for the browser recognising the hold itself. */
|
||||
function browserContextMenu(el: EventTarget): MouseEvent {
|
||||
const e = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
el.dispatchEvent(e);
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
it('announces the gesture rather than standing down', async () => {
|
||||
const presses: unknown[] = [];
|
||||
|
||||
inner.addEventListener('yj-long-press', (e) => {
|
||||
presses.push(e);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
browserContextMenu(inner);
|
||||
await wait(HELD);
|
||||
|
||||
expect(presses, 'the hold reached the component').toHaveLength(1);
|
||||
});
|
||||
|
||||
it('suppresses its menu when a component claims the gesture', async () => {
|
||||
const menus = recordMenus(inner);
|
||||
|
||||
inner.addEventListener('yj-long-press', (e) => e.preventDefault());
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
browserContextMenu(inner);
|
||||
await wait(HELD);
|
||||
|
||||
// The component wants selection mode, so the menu must not also
|
||||
// open -- otherwise the device shows both at once.
|
||||
expect(menus).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still opens exactly one menu when nobody claims it', async () => {
|
||||
// The old behaviour, reached by asking instead of assuming. This
|
||||
// is what leaves the card grids, Explore and the playlist rows
|
||||
// untouched by #63.
|
||||
const menus = recordMenus(inner);
|
||||
|
||||
press(inner, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
browserContextMenu(inner);
|
||||
await wait(HELD);
|
||||
|
||||
expect(menus).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* What a finger does to a track list (plan 019, #63).
|
||||
*
|
||||
* The desktop semantics being diverged from are real and stay: a click
|
||||
* selects, a double-click plays. A finger has no second button and no
|
||||
* modifier keys, so the primary action has to be the primary gesture —
|
||||
* and the inversion is decided **per event**, off `pointerType`, not
|
||||
* off a viewport width or a platform flag (plan 019, decision 1).
|
||||
*
|
||||
* That is what these assert: the same row, in the same component, at
|
||||
* the same width, answering a mouse one way and a finger the other.
|
||||
*
|
||||
* There is deliberately no double-tap. Measured on the reference
|
||||
* device, playing a track is ~100ms end to end, and a double-tap
|
||||
* discriminator has to hold every tap for the app's own
|
||||
* `DOUBLE_CLICK_GRACE_MS` of 250 before it can act — 3.5x the primary
|
||||
* interaction, to reach a menu a long press already reaches.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import '@components/track-list/track-list';
|
||||
import '@components/selection-bar/selection-bar';
|
||||
|
||||
import { calls, flush, resetHarness, stub } from '@test/support/harness';
|
||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
|
||||
|
||||
const HELD = LONG_PRESS_MS + 120;
|
||||
const BRIEF = Math.round(LONG_PRESS_MS / 4);
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
let uninstall: (() => void) | null = null;
|
||||
|
||||
function track(n: number) {
|
||||
return {
|
||||
FilePath: `/music/track-${n}.mp3`,
|
||||
TrackName: `Track ${n}`,
|
||||
ArtistName: 'An Artist',
|
||||
Album: 'An Album',
|
||||
Duration: 100 + n,
|
||||
ID: n,
|
||||
};
|
||||
}
|
||||
|
||||
const TRACKS = [track(1), track(2), track(3), track(4)];
|
||||
|
||||
/** Dispatch a pointer event as a finger would produce it. */
|
||||
function press(el: EventTarget, type: string, init: PointerEventInit = {}) {
|
||||
el.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
...init,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** A whole finger tap: down, a moment, up. */
|
||||
async function tap(el: EventTarget) {
|
||||
press(el, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
press(el, 'pointerup');
|
||||
await wait(0);
|
||||
}
|
||||
|
||||
/** A finger held still until the gesture resolves. */
|
||||
async function hold(el: EventTarget) {
|
||||
press(el, 'pointerdown');
|
||||
await wait(HELD);
|
||||
press(el, 'pointerup');
|
||||
await wait(0);
|
||||
}
|
||||
|
||||
async function mountList() {
|
||||
const el = await fixture('track-list');
|
||||
|
||||
(el as unknown as { tracks: unknown[] }).tracks = TRACKS;
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await wait(60);
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function rows(el: HTMLElement): HTMLElement[] {
|
||||
return shadowAll<HTMLElement>(el, '.track-row');
|
||||
}
|
||||
|
||||
describe('a finger on a track row', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||
stub('config.Config.GetShortcuts', {});
|
||||
stub('queue.Queue.SetQueue', null);
|
||||
stub('queue.Queue.AddTracksToQueue', null);
|
||||
uninstall = installTouchGestures();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstall?.();
|
||||
uninstall = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('plays the row it taps, rather than selecting it', async () => {
|
||||
const el = await mountList();
|
||||
const row = rows(el)[1];
|
||||
|
||||
expect(row, 'the list rendered rows').toBeTruthy();
|
||||
|
||||
await tap(row!);
|
||||
await flush();
|
||||
|
||||
const queued = calls('queue.Queue.SetQueue');
|
||||
|
||||
expect(queued.length, 'a tap plays').toBe(1);
|
||||
|
||||
// From that row, in the list as displayed -- not a queue of one
|
||||
// that stops when the song ends.
|
||||
expect(queued[0]?.args[1]).toBe(1);
|
||||
expect((queued[0]?.args[0] as string[]).length).toBe(TRACKS.length);
|
||||
});
|
||||
|
||||
it('leaves the same row to a mouse, which still selects', async () => {
|
||||
// The inversion is per event, so one component answers both
|
||||
// pointers at the same width. A viewport rule cannot say this.
|
||||
const el = await mountList();
|
||||
const row = rows(el)[1];
|
||||
|
||||
row!.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(calls('queue.Queue.SetQueue').length, 'a click does not play').toBe(0);
|
||||
expect(rows(el)[1]?.getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('enters selection mode on a long press, with that row selected', async () => {
|
||||
const el = await mountList();
|
||||
|
||||
await hold(rows(el)[2]!);
|
||||
await el.updateComplete;
|
||||
|
||||
const bar = shadow(el, 'selection-bar');
|
||||
|
||||
expect(bar, 'the action bar appears').toBeTruthy();
|
||||
expect((bar as unknown as { count: number }).count).toBe(1);
|
||||
expect(rows(el)[2]?.getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('does not open a context menu when it enters the mode', async () => {
|
||||
// The gesture is claimed, so the layer must not fall through to
|
||||
// the synthetic `contextmenu` that every other surface still gets.
|
||||
const el = await mountList();
|
||||
|
||||
await hold(rows(el)[0]!);
|
||||
await el.updateComplete;
|
||||
|
||||
const menu = shadow(el, 'menu-surface');
|
||||
|
||||
expect((menu as unknown as { active?: boolean } | null)?.active ?? false).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles rows while the mode is on, instead of playing them', async () => {
|
||||
const el = await mountList();
|
||||
|
||||
await hold(rows(el)[0]!);
|
||||
await el.updateComplete;
|
||||
await tap(rows(el)[2]!);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(calls('queue.Queue.SetQueue').length, 'no track was played').toBe(0);
|
||||
expect(
|
||||
(shadow(el, 'selection-bar') as unknown as { count: number }).count,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('leaves the mode when the last row is deselected', async () => {
|
||||
// Android's own lists do this, and here it matters more than
|
||||
// convention: the mode changes what a tap means, so a mode holding
|
||||
// nothing is a list where tapping does nothing and the bar that
|
||||
// would explain it is showing a count of zero.
|
||||
const el = await mountList();
|
||||
|
||||
await hold(rows(el)[0]!);
|
||||
await el.updateComplete;
|
||||
await tap(rows(el)[0]!);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, 'selection-bar')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('keeps the favourite icon a favourite icon', async () => {
|
||||
// It is inside a row whose tap now plays, and it has been a 44px
|
||||
// target since #56 -- so without the "a control inside the row
|
||||
// owns its own tap" rule, that target silently becomes a second
|
||||
// play button.
|
||||
const el = await mountList();
|
||||
const fav = rows(el)[1]?.querySelector('.fav-icon');
|
||||
|
||||
expect(fav, 'a row renders a favourite control').toBeTruthy();
|
||||
|
||||
await tap(fav!);
|
||||
await flush();
|
||||
|
||||
expect(calls('queue.Queue.SetQueue').length, 'tapping it does not play').toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not play a row the finger scrolled from', async () => {
|
||||
// The failure this exists for makes the list unusable rather than
|
||||
// merely wrong: every flick to scroll would start a track.
|
||||
const el = await mountList();
|
||||
const row = rows(el)[1];
|
||||
|
||||
press(row!, 'pointerdown');
|
||||
press(row!, 'pointermove', { clientX: 40, clientY: 200 });
|
||||
press(row!, 'pointerup');
|
||||
await flush();
|
||||
|
||||
expect(calls('queue.Queue.SetQueue').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<selection-bar>', () => {
|
||||
it('renders nothing with nothing selected', async () => {
|
||||
const el = await fixture('selection-bar', { count: 0 });
|
||||
|
||||
expect(shadow(el, '.bar')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('announces the count, which changes under the finger', async () => {
|
||||
const el = await fixture('selection-bar', { count: 3, actions: [] });
|
||||
const live = shadow(el, '[aria-live="polite"]');
|
||||
|
||||
expect(live?.textContent?.trim()).toContain('3 tracks selected');
|
||||
});
|
||||
|
||||
it('names one track in the singular', async () => {
|
||||
const el = await fixture('selection-bar', { count: 1, actions: [] });
|
||||
|
||||
expect(shadow(el, '[aria-live="polite"]')?.textContent?.trim()).toContain(
|
||||
'1 track selected',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps every control at the touch floor', async () => {
|
||||
// #56 and #186. A bar a thumb uses, in the one mode that only a
|
||||
// thumb can enter.
|
||||
const el = await fixture('selection-bar', {
|
||||
count: 2,
|
||||
actions: [{ id: 'play', label: 'Play', icon: 'play' }],
|
||||
});
|
||||
|
||||
const buttons = shadowAll<HTMLElement>(el, 'button');
|
||||
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
|
||||
for (const button of buttons) {
|
||||
const box = button.getBoundingClientRect();
|
||||
|
||||
expect(
|
||||
Math.min(Math.round(box.width), Math.round(box.height)),
|
||||
button.getAttribute('aria-label') ?? '',
|
||||
).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user