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
300 lines
9.4 KiB
TypeScript
300 lines
9.4 KiB
TypeScript
/**
|
|
* Swipe right on a track row to queue it (plan 019 phase 2, #63).
|
|
*
|
|
* `touch-gestures.test.ts` holds the recogniser — the axis rule, the
|
|
* claim, the guaranteed end. What is here is what the *list* does with
|
|
* it, and the two things that are only true of a list:
|
|
*
|
|
* **A swipe is not a selection.** It acts on the row it was made on,
|
|
* unless that row is one of several the user has explicitly chosen, in
|
|
* which case it acts on all of them — the same rule the context menu
|
|
* answers with, because a bar saying "40 selected" and a gesture that
|
|
* quietly queues one of them is two answers to the same question.
|
|
*
|
|
* **A short swipe is a no-op**, and that is the only thing standing
|
|
* between "add to queue" and a scroll that drifted sideways. The
|
|
* threshold is a fraction of the row, so it is measured from the row
|
|
* here rather than written down twice.
|
|
*
|
|
* What this tier cannot see is the device, and the reason is in the
|
|
* module's own header: Chrome 113's WebView cancels the pointer stream
|
|
* ~16px into any drag whatever `touch-action` says, so the gesture
|
|
* runs on touch events and needs `touch-action: pan-y` *and* a
|
|
* non-passive `preventDefault`. Both are correct in Chromium either
|
|
* way. The stylesheet half is asserted below for the same reason
|
|
* `hover-affordance.test.ts` reads a parsed stylesheet: the regression
|
|
* is someone tidying the declaration away, and nothing here renders
|
|
* differently when they do.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import '@components/track-list/track-list';
|
|
|
|
import { calls, flush, resetHarness, stub } from '@test/support/harness';
|
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
|
import { installTouchGestures } from '@utils/touch-gestures';
|
|
|
|
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)];
|
|
|
|
async function mountList() {
|
|
const el = await fixture('track-list');
|
|
|
|
// A definite width, because the commit threshold is a fraction of
|
|
// the row and a list that has not been given one is not a list.
|
|
el.style.display = 'block';
|
|
el.style.width = '400px';
|
|
|
|
(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');
|
|
}
|
|
|
|
/**
|
|
* Drag a row sideways by `dx` and lift, as one finger.
|
|
*
|
|
* `onStep` runs after each move and is awaited, which is how the
|
|
* reveal is observed: the component writes the travel straight to the
|
|
* row's style but renders the pane through Lit, so it exists a frame
|
|
* after the move that asked for it, not during it.
|
|
*/
|
|
async function swipe(
|
|
el: EventTarget,
|
|
dx: number,
|
|
dy = 0,
|
|
onStep?: () => Promise<void> | void,
|
|
): Promise<void> {
|
|
const at = (x: number, y: number) =>
|
|
new Touch({
|
|
identifier: 1,
|
|
target: el as Element,
|
|
clientX: x,
|
|
clientY: y,
|
|
});
|
|
const send = (type: string, points: Touch[]) =>
|
|
el.dispatchEvent(
|
|
new TouchEvent(type, {
|
|
bubbles: true,
|
|
composed: true,
|
|
cancelable: true,
|
|
touches: points,
|
|
changedTouches: points.length > 0 ? points : [at(0, 0)],
|
|
}),
|
|
);
|
|
|
|
send('touchstart', [at(0, 100)]);
|
|
|
|
// Several steps, because the recogniser claims the gesture on the
|
|
// move that crosses its threshold and the component reads every one
|
|
// after it.
|
|
for (const step of [0.25, 0.5, 0.75, 1]) {
|
|
send('touchmove', [at(dx * step, 100 + dy * step)]);
|
|
if (onStep) await onStep();
|
|
}
|
|
|
|
send('touchend', []);
|
|
}
|
|
|
|
/** Where the commit threshold falls for the row as rendered. */
|
|
function threshold(row: HTMLElement): number {
|
|
return Math.max(72, row.getBoundingClientRect().width * 0.3);
|
|
}
|
|
|
|
describe('a finger swiped right across 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.AddTracks', null);
|
|
uninstall = installTouchGestures();
|
|
});
|
|
|
|
afterEach(() => {
|
|
uninstall?.();
|
|
uninstall = null;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('adds that row to the queue', async () => {
|
|
const el = await mountList();
|
|
const row = rows(el)[1];
|
|
|
|
expect(row, 'the list rendered rows').toBeTruthy();
|
|
|
|
await swipe(row!, threshold(row!) + 40);
|
|
await flush();
|
|
|
|
const queued = calls('queue.Queue.AddTracks');
|
|
|
|
expect(queued.length, 'one swipe, one call').toBe(1);
|
|
expect(queued[0]?.args[0]).toEqual(['/music/track-2.mp3']);
|
|
|
|
// It queues; it does not play. The row that was playing keeps
|
|
// playing, which is the difference from a tap.
|
|
expect(calls('queue.Queue.SetQueue').length).toBe(0);
|
|
});
|
|
|
|
it('does nothing when the finger did not get far enough', async () => {
|
|
const el = await mountList();
|
|
const row = rows(el)[1];
|
|
|
|
await swipe(row!, Math.round(threshold(row!)) - 10);
|
|
await flush();
|
|
|
|
expect(calls('queue.Queue.AddTracks').length).toBe(0);
|
|
});
|
|
|
|
it('leaves a scroll that began on a row to the list', async () => {
|
|
// The same shape as `touch-selection.test.ts`'s "does not play a
|
|
// row the finger scrolled from", one gesture over: the failure
|
|
// this guards against makes the list unusable rather than wrong.
|
|
const el = await mountList();
|
|
const row = rows(el)[1];
|
|
|
|
await swipe(row!, 30, 200);
|
|
await flush();
|
|
|
|
expect(calls('queue.Queue.AddTracks').length).toBe(0);
|
|
});
|
|
|
|
it('reveals what it will do, in words, while the finger is down', async () => {
|
|
const el = await mountList();
|
|
const row = rows(el)[1];
|
|
const reveals: (string | undefined)[] = [];
|
|
|
|
await swipe(row!, threshold(row!) + 40, 0, async () => {
|
|
await el.updateComplete;
|
|
reveals.push(
|
|
shadow(el, '[data-testid="swipe-reveal"]')?.textContent?.trim(),
|
|
);
|
|
});
|
|
|
|
// Not only a colour (WCAG 1.4.1): the pane says what it is for,
|
|
// and says something different once the gesture would commit.
|
|
expect(reveals.some((t) => t?.includes('Add to queue'))).toBe(true);
|
|
expect(reveals.some((t) => t?.includes('Release to add'))).toBe(true);
|
|
});
|
|
|
|
it('says what it did, for anyone not watching the row', async () => {
|
|
const el = await mountList();
|
|
const row = rows(el)[1];
|
|
|
|
await swipe(row!, threshold(row!) + 40);
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
const said = shadowAll(el, '[role="status"]')
|
|
.map((r) => r.textContent?.trim())
|
|
.join(' ');
|
|
|
|
expect(said).toContain('Track 2');
|
|
expect(said).toContain('queue');
|
|
});
|
|
|
|
it('queues the whole selection when the row is part of one', async () => {
|
|
// One row is a position; several rows are an explicit choice. A
|
|
// gesture that quietly queued the one row touched would contradict
|
|
// the bar above it saying how many are selected.
|
|
const el = await mountList();
|
|
|
|
rows(el)[1]?.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true, composed: true }),
|
|
);
|
|
rows(el)[3]?.dispatchEvent(
|
|
new MouseEvent('click', {
|
|
bubbles: true,
|
|
composed: true,
|
|
ctrlKey: true,
|
|
}),
|
|
);
|
|
await el.updateComplete;
|
|
|
|
const row = rows(el)[1];
|
|
|
|
await swipe(row!, threshold(row!) + 40);
|
|
await flush();
|
|
|
|
expect(calls('queue.Queue.AddTracks')[0]?.args[0]).toEqual([
|
|
'/music/track-2.mp3',
|
|
'/music/track-4.mp3',
|
|
]);
|
|
});
|
|
|
|
it('queues only the row it touched when that row is outside the selection', async () => {
|
|
const el = await mountList();
|
|
|
|
rows(el)[3]?.dispatchEvent(
|
|
new MouseEvent('click', { bubbles: true, composed: true }),
|
|
);
|
|
await el.updateComplete;
|
|
|
|
const row = rows(el)[0];
|
|
|
|
await swipe(row!, threshold(row!) + 40);
|
|
await flush();
|
|
|
|
expect(calls('queue.Queue.AddTracks')[0]?.args[0]).toEqual([
|
|
'/music/track-1.mp3',
|
|
]);
|
|
|
|
// And it did not become a way of selecting anything.
|
|
expect(rows(el)[3]?.getAttribute('aria-selected')).toBe('true');
|
|
expect(rows(el)[0]?.getAttribute('aria-selected')).toBe('false');
|
|
});
|
|
|
|
it('declares pan-y on the row, which is half of what makes it work', async () => {
|
|
// The other half is the gesture module's non-passive
|
|
// `preventDefault`. Neither works alone on Chrome 113 and both are
|
|
// irrelevant here, so this reads the stylesheet and the attribute
|
|
// rather than the rendering — the regression is someone tidying
|
|
// one of them away, and nothing in this browser looks different
|
|
// when they do.
|
|
const el = await mountList();
|
|
|
|
expect(
|
|
rows(el)[0]?.hasAttribute('data-swipe'),
|
|
'the row opts into the shared rule',
|
|
).toBe(true);
|
|
|
|
const sheets = (
|
|
customElements.get('track-list') as unknown as {
|
|
styles: { cssText: string }[];
|
|
}
|
|
).styles;
|
|
const css = sheets.map((s) => s.cssText).join('\n');
|
|
const rule = css
|
|
.split('}')
|
|
.find((block) => /\[data-swipe\]\s*\{/.test(block));
|
|
|
|
expect(rule, 'the shared rule is in this component').toBeTruthy();
|
|
expect(rule).toContain('touch-action: pan-y');
|
|
expect(css, 'never none: it takes the scrolling too').not.toContain(
|
|
'touch-action: none',
|
|
);
|
|
});
|
|
});
|