feat(android): the touch model reaches the other three lists
Plan 019 phases 3 and 4, which finish #63. The queue panel and both playlist detail views get tap-to-play and hold-to-select; the playlist views get swipe-to-queue as well. Phase 3 was not the pure wiring the plan expected, in two places. A tap on a queue row plays that position. Copying track-list's tap -- which sets the queue to the list the row is in -- would rebuild the queue from the queue, discarding its source, its shuffle order and anything inserted by hand. It reads as a no-op and is not one. And the queue panel has no swipe, deliberately. A right swipe means add to the queue everywhere else it exists, and a queue row is already in the queue; the only thing it could mean there is remove, which is the same gesture with the opposite effect one screen away. Removing a queue row is on the row, on its sheet since #60, and now on its selection bar. The assertion is that its rows do not opt in. The reveal became utils/swipe-to-queue.ts rather than being copied into three lists, keyed on a data-swipe attribute so one stylesheet carries the touch-action half of the device fix to rows that are called two different things. Phase 4 was already true and is now asserted: a claimed tap has its click swallowed, so an explore-link inside a row never sees one and tap-to-play wins with no rule of its own. Its test was vacuous when written -- the tap helper sent no click, so there was nothing to swallow -- which also weakened phase 1's. It sends one now. Escape leaves selection mode, from selection-bar rather than from each of the four hosts, since that element exists only while the mode does. The platform's back gesture deliberately does not reach it: the shell owns the history stack and four lists reaching for history is four stacks. That is #200. Verified on the reference phone: a queue row taps to its own index and refuses a swipe, a playlist row queues on a swipe and plays its playlist on a tap, and a hold raises the bar without the menu. Closes #63
This commit is contained in:
@@ -26,6 +26,9 @@ import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
|
||||
|
||||
const HELD = LONG_PRESS_MS + 120;
|
||||
|
||||
/** Comfortably past `explore-link`'s own DOUBLE_CLICK_GRACE_MS of 250. */
|
||||
const EXPLORE_LINK_GRACE = 400;
|
||||
const BRIEF = Math.round(LONG_PRESS_MS / 4);
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -39,6 +42,12 @@ function track(n: number) {
|
||||
Album: 'An Album',
|
||||
Duration: 100 + n,
|
||||
ID: n,
|
||||
// Tagged, so the title's `explore-link` can actually navigate.
|
||||
// Without an MBID it asks the backend for a local album first and
|
||||
// gives up when nothing answers -- which makes "the tap did not
|
||||
// navigate" true of every build, working or not.
|
||||
ReleaseGroupMBID: 'e8f4b1d2-0000-4000-8000-00000000000' + n,
|
||||
RecordingMBID: 'a1b2c3d4-0000-4000-8000-00000000000' + n,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,11 +69,24 @@ function press(el: EventTarget, type: string, init: PointerEventInit = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A whole finger tap: down, a moment, up. */
|
||||
/**
|
||||
* A whole finger tap: down, a moment, up, and **the click a browser
|
||||
* fires afterwards**.
|
||||
*
|
||||
* That last event is not decoration. A tap the component claims has
|
||||
* its click swallowed at document capture, and the click is the only
|
||||
* thing that would otherwise select the row, follow the `explore-link`
|
||||
* in its title, or press whatever the finger landed on. A helper that
|
||||
* stops at `pointerup` asserts none of that and passes on a build with
|
||||
* the swallow deleted.
|
||||
*/
|
||||
async function tap(el: EventTarget) {
|
||||
press(el, 'pointerdown');
|
||||
await wait(BRIEF);
|
||||
press(el, 'pointerup');
|
||||
el.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }),
|
||||
);
|
||||
await wait(0);
|
||||
}
|
||||
|
||||
@@ -277,3 +299,98 @@ describe('<selection-bar>', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* What the gestures leave behind (plan 019 phase 4, #63).
|
||||
*
|
||||
* Every track, album and artist name in a row is an `explore-link`,
|
||||
* which navigates on a genuine single click — and a row's single
|
||||
* *tap* now plays. That conflict is #67's to answer properly; what
|
||||
* this plan committed to is the narrower half of it, that **tap-to-play
|
||||
* wins on touch**, and it falls out of phase 1's design rather than
|
||||
* needing a rule: a claimed tap has its click swallowed at document
|
||||
* capture, so the link's own handler never runs.
|
||||
*
|
||||
* It falls out, which is exactly why it is asserted. Nothing else in
|
||||
* the suite would fail if the swallow stopped covering the link, and
|
||||
* the symptom — a tap on a track's title navigating to its album
|
||||
* instead of playing it — is one a phone user meets constantly and a
|
||||
* mouse user never does.
|
||||
*/
|
||||
describe('a tap on a name inside a row', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||
stub('config.Config.GetShortcuts', {});
|
||||
stub('queue.Queue.SetQueue', null);
|
||||
uninstall = installTouchGestures();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
uninstall?.();
|
||||
uninstall = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Where the row's title is rendered, which is a link. */
|
||||
function titleLink(el: HTMLElement, row: number): HTMLElement {
|
||||
const link = rows(el)[row]?.querySelector('.explore-link');
|
||||
|
||||
expect(link, 'the row renders its title as a link').toBeTruthy();
|
||||
|
||||
return link as HTMLElement;
|
||||
}
|
||||
|
||||
it('plays the row rather than navigating', async () => {
|
||||
const el = await mountList();
|
||||
const navigations: Event[] = [];
|
||||
|
||||
document.addEventListener('navigate', (e) => navigations.push(e));
|
||||
|
||||
await tap(titleLink(el, 1));
|
||||
await flush();
|
||||
// `explore-link` holds a navigation for DOUBLE_CLICK_GRACE_MS, so
|
||||
// asserting sooner passes on a build that is about to navigate.
|
||||
await wait(EXPLORE_LINK_GRACE);
|
||||
|
||||
expect(calls('queue.Queue.SetQueue').length, 'the row played').toBe(1);
|
||||
expect(navigations, 'and nothing navigated').toHaveLength(0);
|
||||
});
|
||||
|
||||
it('toggles the row while selection mode is on', async () => {
|
||||
const el = await mountList();
|
||||
const navigations: Event[] = [];
|
||||
|
||||
document.addEventListener('navigate', (e) => navigations.push(e));
|
||||
|
||||
await hold(rows(el)[0]!);
|
||||
await el.updateComplete;
|
||||
await tap(titleLink(el, 2));
|
||||
await el.updateComplete;
|
||||
await wait(EXPLORE_LINK_GRACE);
|
||||
|
||||
expect(
|
||||
(shadow(el, 'selection-bar') as unknown as { count: number }).count,
|
||||
).toBe(2);
|
||||
expect(navigations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves the mode on Escape', async () => {
|
||||
// A mode changes what a tap means, so it needs an exit that is not
|
||||
// "find the x". It is a dismissal rather than a shortcut, which is
|
||||
// why it is not a panel-scoped binding.
|
||||
const el = await mountList();
|
||||
|
||||
await hold(rows(el)[0]!);
|
||||
await el.updateComplete;
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, 'selection-bar')).toBeFalsy();
|
||||
expect(rows(el)[0]?.getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user