diff --git a/.planning/plans/active/019-android-touch-model.md b/.planning/plans/active/019-android-touch-model.md
index b1dd0e3..784271a 100644
--- a/.planning/plans/active/019-android-touch-model.md
+++ b/.planning/plans/active/019-android-touch-model.md
@@ -225,7 +225,8 @@ still works. `SelectionController` gains a mode. `track-list` acts on
tap and enters the mode on long press. The action bar.
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
-finding above and a reveal-and-snap affordance.
+finding above and a reveal-and-snap affordance. **Shipped**; what the
+device said about it is the section below.
**Phase 3 — the other three surfaces**, which is mostly wiring, since
they already share the controller.
@@ -237,6 +238,94 @@ beyond making tap-to-play win on touch.
---
+## What phase 2 measured, which was not what phase 2 predicted
+
+The `touch-action` finding above is **half** of the answer, and
+shipping only that half would have been the exact failure it warns
+about. Driving a real finger with `adb shell input swipe` across a
+track row, three values, all three on the device:
+
+```
+touch-action: auto pointerdown, 1 move, pointercancel
+touch-action: pan-y pointerdown, 2 moves, pointercancel
+touch-action: none pointerdown, 2 moves, pointercancel
+```
+
+`touchmove` kept firing in all three. So **Chrome 113's WebView
+cancels the pointer stream ~16px into any drag whatever `touch-action`
+says**, and a swipe recognised from `pointermove` — which is what the
+rest of this module is built on — is a swipe that dies 16px in.
+
+The other half is a **non-passive `touchmove` calling
+`preventDefault()`**: with it, the same swipe ran to 12 moves and a
+`pointerup` at full travel. And both halves are required, which was
+measured rather than assumed — with the `preventDefault` in place and
+`touch-action` back at `auto`, the gesture died after **one** move.
+The reading is that `auto` lets the browser commit to a horizontal pan
+on the first move past slop, before any threshold of ours can have
+been crossed, while `pan-y` leaves it undecided long enough for the
+second move to claim it.
+
+`none` is the one value to avoid: the list stopped scrolling at all.
+With the shipped pair, a vertical drag still scrolls the virtualizer
+81px on the same run that a horizontal one survives.
+
+**`draggable="true"` is not a competitor**, which is the other thing
+the device was asked. No `dragstart` fires from a touch drag on this
+WebView at all, so the drag-to-playlist attribute on every row needs no
+pointer-type gate.
+
+### And it found a phase 1 defect that no tier can see
+
+The native `contextmenu` arrives in **either** order, and phase 1 only
+handled one of them. `nativeSeen` covers the browser's menu arriving
+*during* the hold. The reverse — our 500ms timer firing first, a
+component claiming it, and Chrome delivering its own `contextmenu`
+50–70ms *later* — was suppressed by nothing, so the context menu
+opened on top of the selection bar. Measured over four holds:
+
+```
+hold 1 yj-long-press, then contextmenu isTrusted=true menu open
+hold 2 yj-long-press clean
+hold 3 yj-long-press, then contextmenu isTrusted=true menu open
+hold 4 yj-long-press clean
+```
+
+Two in four, on the one surface #63 exists to have changed, and
+invisible to both browser tiers because neither synthesises a
+`contextmenu` from a dispatched press. A press that has produced its
+outcome now suppresses a late one whichever branch it took; six holds
+on the fixed build, six clean.
+
+### The rules phase 2 settled
+
+- **A swipe is not a selection.** It queues the row it was made on,
+ unless that row is one of several *explicitly* selected — the same
+ rule the context menu answers with, because a bar reading "40
+ selected" beside a gesture that quietly queues one of them is two
+ answers to one question. It never changes the selection, which is
+ where it differs from a right-click.
+- **Rightward only.** Nothing is bound to a leftward swipe and
+ claiming one would take a gesture away to do nothing with it.
+- **The commit threshold is a fraction of the row** (0.3, floor 72px),
+ because the row is 424x52 on this device and a bare pixel count is a
+ fraction of a row height on one screen and a third of the width on
+ the next.
+- **The affordance is not only a colour** (WCAG 1.4.1, the rule the
+ playing-row marker exists for): the pane carries the queue icon and
+ words, the words change at the threshold ("Add to queue" → "Release
+ to add" → "Added"), and the outcome is announced in a live region.
+- **The row does not move; its cells do.** `.track-row` is
+ `contain: strict` with `overflow: hidden`, so a pane held at the
+ row's original position while the row translates is a pane at a
+ negative offset inside a clipping box and is simply not painted.
+ Sliding the cells needs no wrapper element in a row that is already
+ a grid.
+- **The travel is written to the row's own style, not rendered.** One
+ render at the start, one at the threshold, one at the end; a
+ virtualizer re-rendering every visible row per frame of one finger's
+ travel is the thing `perf.m1` is about.
+
## Open questions
1. **Does selection mode have an escape other than the bar's own
diff --git a/CLAUDE.md b/CLAUDE.md
index d50d04e..a917b06 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1449,18 +1449,62 @@ never opens). **The sweep found two of the fourteen**; twelve were
converted by hand.
**And a menu opens from a finger, through the event it already has.**
-`utils/long-press.ts` is one document-capture listener installed once
-from `index.ts`: a touch that holds still for 500 ms dispatches a
-synthetic `contextmenu` at the touch point, so all six components that
-bind one — delegated on a virtualizer, per row, per card — gained the
-gesture without changing. The target is `composedPath()[0]` rather than
+`utils/touch-gestures.ts` is one document-capture listener installed
+once from `index.ts` — `utils/long-press.ts` until #63 replaced it,
+rather than adding a second listener claiming the same 500 ms hold. It
+**announces** rather than acts: `yj-tap`, `yj-long-press` and
+`yj-swipe-start` are composed and cancelable, and a component claims
+one with `preventDefault()`. That is what let #63 reassign the hold
+without touching one of the fourteen context menus: an *unclaimed*
+`yj-long-press` still becomes a synthetic `contextmenu`, so all six
+components that bind one — delegated on a virtualizer, per row, per
+card — behave exactly as they did, and only the lists that opt in get
+selection mode. The target is `composedPath()[0]` rather than
`elementFromPoint`, which stops at the outermost shadow host and so
-reaches a delegated listener and no per-row one; a browser that fires
-its own long-press `contextmenu` (Chromium does, WebKit and the WebView
-vary) wins, ours being told from theirs by **identity** rather than
-`isTrusted`, since no test can dispatch a trusted event; and the click
-that ends the gesture is swallowed, keyed on the gesture rather than on
-a time window so the first tap on the menu it opened is not eaten too.
+reaches a delegated listener and no per-row one; and the click that
+ends a *claimed* gesture is swallowed, keyed on the gesture rather than
+on a time window so the first tap on the menu it opened is not eaten
+too.
+
+Three things about it are load-bearing, and all three were found on the
+device rather than in a tier.
+
+**A browser that fires its own long-press `contextmenu` is a trigger,
+not a competitor.** Chromium does, WebKit and the WebView vary. The old
+rule was to stand down when a trusted one arrived, which was right
+while both paths ended in a context menu and is wrong the moment a hold
+can mean something else — standing down silently does the *old* thing.
+So the gesture is announced from the native event, and only a component
+that claims it suppresses that event. Ours and the browser's are told
+apart by **identity** rather than `isTrusted`, since no test can
+dispatch a trusted event.
+
+**That arrives in either order, and both have to be handled.** The
+native `contextmenu` mid-hold is one case; the other is our own 500 ms
+timer firing first and Chrome delivering its menu **50–70 ms later**,
+which nothing suppressed — measured over four holds on the reference
+phone, two took that order, so the context menu opened over the
+selection bar intermittently, on the one surface #63 changed. A press
+that has produced its outcome therefore suppresses a late
+`contextmenu` whichever branch it took.
+
+**A horizontal swipe runs on touch events, and needs two things that
+look like one.** Chrome 113's WebView cancels the *pointer* stream
+~16 px into any drag — measured at `auto`, `pan-y` and `none` alike,
+one or two `pointermove`s and then `pointercancel`, while `touchmove`
+kept firing throughout. So the recogniser is `touchmove`, the surface
+declares **`touch-action: pan-y`** *and* a claimed swipe calls
+**`preventDefault()`** on a non-passive listener. Neither works alone:
+with the `preventDefault` in place but `touch-action` back at `auto`
+the gesture died after one move, because `auto` lets the browser commit
+to a horizontal pan before any threshold can be crossed. `none` is the
+value to avoid — it takes the list's own vertical scrolling with it.
+**Both are correct in Chromium either way**, which is why this is
+written down rather than tested. The tie breaks toward scrolling, in
+that order: vertical drift past the tolerance vetoes the swipe for the
+rest of the press (a scroll that curves is still a scroll), and a
+gesture that is not *strictly* more horizontal than vertical is the
+scroller's.
**A control revealed by `:hover` is gated on the device having hover,
and which way round depends on whether it is the only route to its
diff --git a/e2e/specs/touch-gestures.spec.ts b/e2e/specs/touch-gestures.spec.ts
index 523f665..6cf1783 100644
--- a/e2e/specs/touch-gestures.spec.ts
+++ b/e2e/specs/touch-gestures.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect } from '../support/fixtures.js';
+import { test, expect, callBinding } from '../support/fixtures.js';
/**
* The touch gestures against the real app (plan 019, #63; long-press
@@ -192,3 +192,120 @@ test.describe('a hold anywhere else still opens the menu', () => {
expect((await panel(app, 'cover-grid'))?.items).toBeGreaterThan(0);
});
});
+
+/**
+ * Swipe right on a track row to queue it (plan 019 phase 2, #63).
+ *
+ * The component tier has the rule this obeys — one row is a position,
+ * several are a choice — against a queue that is a fake. What is only
+ * true here is that the gesture reaches the *real* queue: `AddTracks`
+ * is a Go method, the queue is persisted, and "the row was added"
+ * is a question only the backend can answer.
+ *
+ * **It is Chromium-only, and that is a property of the browser rather
+ * than a gap.** The gesture runs on touch events, because Chrome 113's
+ * WebView cancels the pointer stream ~16px into any drag whatever
+ * `touch-action` says. Desktop WebKit implements no `TouchEvent`
+ * constructor at all — touch events are a mobile-Safari surface — so
+ * the events this needs cannot be built there. Skipping loudly is
+ * better than a spec that quietly asserts nothing on half the matrix,
+ * which is what `layout-overflow.spec.ts` and `back-navigation.spec.ts`
+ * were each doing when they were green on a broken build.
+ */
+test.describe('a swipe right on a track row queues it', () => {
+ test.beforeEach(async ({ app, browserName }) => {
+ test.skip(
+ browserName !== 'chromium',
+ 'desktop WebKit has no TouchEvent constructor to build the gesture from',
+ );
+
+ 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 }) => {
+ await app.setViewportSize({ width: 1440, height: 900 });
+ });
+
+ /**
+ * Drag the first row sideways by a fraction of its own width and
+ * lift. `fraction` is against the row, because the commit threshold
+ * is — a number of pixels here would be a second declaration of it,
+ * right on one viewport and wrong on the next.
+ */
+ const swipeFirstRow = (page: Page, fraction: number) =>
+ page.evaluate((f) => {
+ const row = document
+ .querySelector('track-list')
+ ?.shadowRoot?.querySelector('.track-row');
+
+ if (!row) throw new Error('no track row to swipe');
+
+ const box = row.getBoundingClientRect();
+ const y = box.top + box.height / 2;
+ const at = (x: number) =>
+ new Touch({
+ identifier: 1,
+ target: row,
+ clientX: box.left + x,
+ clientY: y,
+ });
+ const send = (type: string, points: Touch[]) =>
+ row.dispatchEvent(
+ new TouchEvent(type, {
+ bubbles: true,
+ composed: true,
+ cancelable: true,
+ touches: points,
+ changedTouches: points.length > 0 ? points : [at(0)],
+ }),
+ );
+
+ send('touchstart', [at(0)]);
+
+ for (const step of [0.25, 0.5, 0.75, 1]) {
+ send('touchmove', [at(box.width * f * step)]);
+ }
+
+ send('touchend', []);
+ }, fraction);
+
+ /** How many tracks the backend says are in the queue. */
+ const queueLength = async (page: Page) => {
+ const state = await callBinding<{ tracks: unknown[] }>(
+ page,
+ 'queue.Queue.GetState',
+ );
+
+ return state.tracks?.length ?? 0;
+ };
+
+ test('adds exactly one track to the real queue', async ({ app }) => {
+ const before = await queueLength(app);
+
+ await swipeFirstRow(app, 0.6);
+
+ await expect.poll(() => queueLength(app)).toBe(before + 1);
+
+ // Queued, not played: a swipe is not a tap, and the difference is
+ // what is on screen afterwards.
+ expect(
+ await app.getByTestId('main-content').getAttribute('data-active-view'),
+ ).toBe('tracks');
+ });
+
+ test('does nothing when the finger did not get far enough', async ({
+ app,
+ }) => {
+ const before = await queueLength(app);
+
+ await swipeFirstRow(app, 0.1);
+ await app.waitForTimeout(400);
+
+ expect(await queueLength(app)).toBe(before);
+ });
+});
diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts
index 6379ee6..7bbb424 100644
--- a/frontend/src/components/track-list/track-list.ts
+++ b/frontend/src/components/track-list/track-list.ts
@@ -10,7 +10,7 @@ import {
} from 'lit/decorators.js';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
-import type { GestureEvent } from '@utils/touch-gestures';
+import type { GestureEvent, SwipeEvent } from '@utils/touch-gestures';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
@@ -282,6 +282,37 @@ export class TrackList
* path into it at all (H-5). */
@state() private focusedIndex = 0;
+ // --- swipe right to queue (plan 019 phase 2, #63) ----------------
+
+ /** How far along the row a swipe has to reach to mean it. */
+ private static readonly SWIPE_COMMIT_FRACTION = 0.3;
+
+ /** … and a floor, for a narrow list embedded in a detail page. */
+ private static readonly SWIPE_COMMIT_MIN_PX = 72;
+
+ /** How long the reveal holds its confirmation before snapping. */
+ private static readonly SWIPE_CONFIRM_MS = 550;
+
+ /** The snap itself, which the stylesheet also states. */
+ private static readonly SWIPE_SETTLE_MS = 180;
+
+ /** Which row is being swiped, and therefore which draws a reveal. */
+ @state() private swipeIndex: number | null = null;
+
+ /** Past the commit threshold: the reveal says so, in words. */
+ @state() private swipeArmed = false;
+
+ /** Committed, and holding the confirmation. */
+ @state() private swipeDone = false;
+
+ /** What the gesture did, for anyone not watching the row. */
+ @state() private swipeAnnouncement = '';
+
+ private swipeRow: HTMLElement | null = null;
+ private swipeKeys: string[] = [];
+ private swipeCommitPx = 0;
+ private swipeSettleTimer = 0;
+
private handleSelectAll = (): void => {
this.selection.selectAll();
};
@@ -1135,6 +1166,18 @@ export class TrackList
height: 33px;
box-sizing: border-box;
contain: strict;
+ /* Swipe right to queue (plan 019 phase 2, #63). Half of what
+ makes the gesture reach us on the device: auto lets Chrome
+ 113's WebView commit to a horizontal pan on the first move
+ past slop, and the pointer stream is cancelled before any
+ threshold can be crossed. The other half is the non-passive
+ preventDefault in utils/touch-gestures.ts, and neither works
+ alone -- both were measured three ways on the phone.
+ Never none: that takes the list's own vertical scrolling with
+ it. The cost is that a finger starting on a row can no longer
+ pan the shell sideways in the 600-899 band, where the shell
+ can still overflow; anywhere else on the page still can. */
+ touch-action: pan-y;
}
/* A phone row is two lines, and this height must equal
@@ -1213,6 +1256,62 @@ export class TrackList
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
+ /* The reveal behind a swiped row (plan 019 phase 2, #63).
+
+ The row itself does not move -- its *cells* do. Moving the row
+ and counter-translating the pane inside it is the obvious
+ arrangement and does not work here: .track-row is contain:
+ strict with overflow: hidden, so a pane held at the row's
+ original position is a pane at a negative offset inside a
+ clipping box, and it is simply not painted. Sliding the cells
+ instead leaves the pane where it was drawn, clips the cells off
+ the right edge, and needs no wrapper element in a row that is
+ already a grid.
+
+ It is not only a colour (WCAG 1.4.1, the rule the playing-row
+ marker is here for): the pane carries an icon and words, the
+ words change at the commit threshold, and the outcome is
+ announced in the list's live region. */
+ .swipe-reveal {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: var(--yj-swipe-dx, 0px);
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ gap: 0.4em;
+ padding-left: 8px;
+ overflow: hidden;
+ white-space: nowrap;
+ pointer-events: none;
+ font-size: var(--yj-text-xs);
+ background-color: var(--yj-bg-elevated, #343a40);
+ color: var(--yj-text-secondary, #b3b3b3);
+ }
+
+ .swipe-reveal.armed {
+ background-color: var(--yj-success, #2f9e44);
+ color: var(--yj-success-fg, #fff);
+ }
+
+ .track-row.swiping > :not(.swipe-reveal) {
+ transform: translateX(var(--yj-swipe-dx, 0px));
+ }
+
+ .track-row.settling > * {
+ transition:
+ transform 160ms ease-out,
+ width 160ms ease-out;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .track-row.settling > * {
+ transition: none;
+ }
+ }
+
.cell {
overflow: hidden;
text-overflow: ellipsis;
@@ -1311,6 +1410,9 @@ export class TrackList
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
virt.removeEventListener('yj-tap', this.onRowTap);
virt.removeEventListener('yj-long-press', this.onRowLongPress);
+ virt.removeEventListener('yj-swipe-start', this.onRowSwipeStart);
+ virt.removeEventListener('yj-swipe-move', this.onRowSwipeMove);
+ virt.removeEventListener('yj-swipe-end', this.onRowSwipeEnd);
virt.removeEventListener('dragstart', this.onDelegatedDragStart);
virt.removeEventListener('dragend', this.onTrackDragEnd);
}
@@ -1457,6 +1559,9 @@ export class TrackList
// through the same path a real click takes (plan 019).
virt.addEventListener('yj-tap', this.onRowTap);
virt.addEventListener('yj-long-press', this.onRowLongPress);
+ virt.addEventListener('yj-swipe-start', this.onRowSwipeStart);
+ virt.addEventListener('yj-swipe-move', this.onRowSwipeMove);
+ virt.addEventListener('yj-swipe-end', this.onRowSwipeEnd);
this.delegationAttached = true;
}
@@ -1776,6 +1881,193 @@ export class TrackList
this.virtualizer?.requestUpdate();
};
+ // =================================================================
+ // Swipe right to queue (plan 019 phase 2, #63)
+ // =================================================================
+
+ /**
+ * What a swipe on this row would queue.
+ *
+ * The same rule the context menu answers with, and it has to be:
+ * **one row is a position, several rows are an explicit choice.**
+ * A finger that swipes a row which is part of a selection of forty
+ * has not un-made that selection, and queueing the one row it
+ * touched would quietly contradict the bar above saying forty are
+ * selected. A swipe on a row *outside* the selection is a statement
+ * about that row, exactly as a right-click on one is -- and unlike
+ * a right-click it does not move the selection, because a swipe is
+ * not a way of selecting anything.
+ */
+ private swipeTargetKeys(filePath: string): string[] {
+ if (
+ this.selection.selectionCount > 1 &&
+ this.selection.isSelected(filePath)
+ ) {
+ return this.selection.getSelectedKeysOrdered();
+ }
+
+ return [filePath];
+ }
+
+ private onRowSwipeStart = (e: SwipeEvent) => {
+ // Rightward only. Nothing is bound to a leftward swipe, and
+ // claiming one would take a gesture away to do nothing with it.
+ if (e.detail.dx <= 0) return;
+
+ const hit = this.resolveTrackFromEvent(e);
+
+ if (!hit) return;
+
+ const row = (e.target as HTMLElement).closest(
+ '.track-row',
+ ) as HTMLElement | null;
+
+ if (!row) return;
+
+ e.preventDefault();
+
+ this.swipeRow = row;
+ this.swipeKeys = this.swipeTargetKeys(hit.track.FilePath);
+ // A fraction of the row, with a floor: the row is 424x52 on the
+ // reference device, so a threshold in bare pixels is a fraction
+ // of a row height on one screen and a third of the width on
+ // another.
+ this.swipeCommitPx = Math.max(
+ TrackList.SWIPE_COMMIT_MIN_PX,
+ row.getBoundingClientRect().width *
+ TrackList.SWIPE_COMMIT_FRACTION,
+ );
+ this.swipeArmed = false;
+ this.swipeDone = false;
+ this.swipeIndex = hit.index;
+ this.virtualizer?.requestUpdate();
+ this.setSwipeOffset(0);
+ };
+
+ private onRowSwipeMove = (e: SwipeEvent) => {
+ if (this.swipeIndex === null) return;
+
+ const dx = Math.min(
+ Math.max(e.detail.dx, 0),
+ this.swipeCommitPx * 2,
+ );
+ const armed = dx >= this.swipeCommitPx;
+
+ // Crossing the threshold is the only thing here that renders.
+ // The offset itself is written straight to the row's style, or
+ // a virtualized list would re-render every visible row for
+ // every frame of one finger's travel.
+ if (armed !== this.swipeArmed) {
+ this.swipeArmed = armed;
+ this.virtualizer?.requestUpdate();
+ }
+
+ this.setSwipeOffset(dx);
+ };
+
+ private onRowSwipeEnd = (e: SwipeEvent) => {
+ if (this.swipeIndex === null) return;
+
+ const commit =
+ !e.detail.canceled && e.detail.dx >= this.swipeCommitPx;
+
+ if (!commit) {
+ this.settleSwipe(0);
+
+ return;
+ }
+
+ queueStore.addTracksToQueue(this.swipeKeys);
+
+ const count = this.swipeKeys.length;
+ const only =
+ count === 1
+ ? tracksByFilePath(this.tracks).get(this.swipeKeys[0]!)
+ : undefined;
+
+ // The reveal is the only thing on screen that says this
+ // happened -- the queue panel may well be closed -- so it holds
+ // its confirmation for a moment rather than snapping back the
+ // instant the finger lifts. The live region is the same
+ // sentence for anyone not watching it.
+ this.swipeDone = true;
+ this.swipeAnnouncement =
+ count === 1
+ ? `Added ${only?.TrackName ?? 'the track'} to the queue.`
+ : `Added ${count} tracks to the queue.`;
+ this.virtualizer?.requestUpdate();
+ this.settleSwipe(TrackList.SWIPE_CONFIRM_MS);
+ };
+
+ /** Write the travel to the row itself, with no render. */
+ private setSwipeOffset(dx: number) {
+ this.swipeRow?.style.setProperty('--yj-swipe-dx', `${dx}px`);
+ }
+
+ /**
+ * Put the row back, after `delay`, and forget the swipe.
+ *
+ * The row element is held rather than looked up again: a
+ * virtualizer recycles its rows, and by the time this runs the
+ * element may be drawing a different track. Clearing the property
+ * off whatever it holds now is right either way, since
+ * `swipeIndex` is what decides who draws the reveal.
+ */
+ private settleSwipe(delay: number) {
+ const row = this.swipeRow;
+
+ window.clearTimeout(this.swipeSettleTimer);
+
+ this.swipeSettleTimer = window.setTimeout(() => {
+ row?.classList.add('settling');
+ this.setSwipeOffset(0);
+
+ this.swipeSettleTimer = window.setTimeout(() => {
+ row?.classList.remove('settling');
+ row?.style.removeProperty('--yj-swipe-dx');
+ this.swipeRow = null;
+ this.swipeIndex = null;
+ this.swipeArmed = false;
+ this.swipeDone = false;
+ this.virtualizer?.requestUpdate();
+ }, TrackList.SWIPE_SETTLE_MS);
+ }, delay);
+ }
+
+ /**
+ * What is revealed behind the row, in three states.
+ *
+ * One glyph throughout, and the words carry the state. A tick
+ * would read better for the last of them and is `ICON_IN_LIBRARY`
+ * -- it means *you own this* -- and `icon-language.ts` exists
+ * because `plus` came to mean four things that way.
+ */
+ private renderSwipeReveal() {
+ const count = this.swipeKeys.length;
+ const what =
+ count === 1 ? 'to queue' : `${count} tracks to queue`;
+
+ return html`
+
+
+ ${this.swipeDone
+ ? 'Added'
+ : this.swipeArmed
+ ? 'Release to add'
+ : `Add ${what}`}
+
+ `;
+ }
+
private onDelegatedDragStart = (e: DragEvent) => {
const hit = this.resolveTrackFromEvent(e);
@@ -2197,6 +2489,7 @@ export class TrackList
'track-row': true,
active,
selected,
+ swiping: this.swipeIndex === index,
})}
role="row"
aria-rowindex=${index + 1}
@@ -2208,6 +2501,7 @@ export class TrackList
data-testid="track-row"
data-file-path=${track.FilePath}
>
+ ${this.swipeIndex === index ? this.renderSwipeReveal() : nothing}
${this.liveStatus(visibleTracks.length)}
+
+ ${this.swipeAnnouncement}
+
${this.tracks.length === 0
? this.renderPlaceholder()
: html`
diff --git a/frontend/src/utils/touch-gestures.ts b/frontend/src/utils/touch-gestures.ts
index f9e554a..885b604 100644
--- a/frontend/src/utils/touch-gestures.ts
+++ b/frontend/src/utils/touch-gestures.ts
@@ -14,6 +14,13 @@
*
* `yj-tap` a short press that did not drift
* `yj-long-press` a press that held still for LONG_PRESS_MS
+ * `yj-swipe-start` a press that has travelled decisively sideways
+ *
+ * A claimed swipe is then followed by `yj-swipe-move` and one
+ * `yj-swipe-end`, which is guaranteed: a swipe that the browser or a
+ * second finger takes away still ends, with `canceled` set, so the
+ * affordance a component put on screen always has something to snap
+ * back from.
*
* A component that wants the gesture handles it and calls
* `preventDefault()`. Nothing else changes. That shape is what lets
@@ -80,6 +87,62 @@
* **The click swallow is keyed on the gesture**, cleared by the next
* `pointerdown` rather than by a time window, so the first tap on a
* sheet that just opened is not eaten too.
+ *
+ * ## The swipe runs on touch events, and that is not a style choice
+ *
+ * Everything above is Pointer Events. The swipe is not, and the reason
+ * is measured on the reference device rather than reasoned about:
+ * **Chrome 113's Android WebView cancels the pointer stream ~16px into
+ * any drag, whatever `touch-action` says.** Three values were tried on
+ * a track row, driving a real finger with `adb shell input swipe`:
+ *
+ * ```
+ * touch-action: auto pointerdown, 1 move, pointercancel
+ * touch-action: pan-y pointerdown, 2 moves, pointercancel
+ * touch-action: none pointerdown, 2 moves, pointercancel
+ * ```
+ *
+ * `touchmove` kept firing throughout all three. So a swipe recognised
+ * from `pointermove` is a swipe that dies 16px in — plan 019 predicted
+ * the class of failure ("works in Chromium and not on the phone") and
+ * named `touch-action: pan-y` as the fix; it is half of it.
+ *
+ * The other half is that **a non-passive `touchmove` that calls
+ * `preventDefault()` is what keeps the gesture ours**. With it, the
+ * same swipe ran to 12 moves and a `pointerup` at full travel.
+ *
+ * Both halves are required, and that was measured too: with the
+ * `preventDefault` in place but `touch-action` back at `auto`, the
+ * gesture died after **one** move. The reading is that `auto` lets the
+ * browser commit to a horizontal pan on the first move past slop —
+ * before any threshold of ours can have been crossed — while `pan-y`
+ * leaves it undecided long enough for the second move to claim it.
+ *
+ * So a surface that wants a horizontal swipe declares
+ * `touch-action: pan-y` (`track-list`'s `.track-row` does) *and* gets
+ * this module's `preventDefault`. Neither alone works on the device,
+ * and **both work in Chromium either way**, which is exactly why this
+ * paragraph exists rather than a test.
+ *
+ * `touch-action: none` is the one value to avoid: it also takes the
+ * list's vertical scrolling away, which was measured as a list that
+ * would not move.
+ *
+ * Two consequences of the touch listener worth knowing.
+ *
+ * **It is non-passive, which costs the compositor's scroll fast path**
+ * for the first touchmoves of every scroll, until the browser starts
+ * scrolling and stops waiting on us. That is the standard price of a
+ * horizontal gesture in a scroller and it is paid once per gesture,
+ * not per frame; a vertical drag on the device still scrolls the
+ * virtualizer 81px on the same measurement that the horizontal one
+ * survives.
+ *
+ * **The tie breaks toward scrolling**, deliberately and in that order:
+ * vertical drift past the tolerance vetoes the swipe outright, and a
+ * gesture that is not *strictly* more horizontal than vertical is the
+ * scroller's. A list that will not scroll is unusable; a swipe that
+ * needs a second try is not.
*/
/** How long a press must hold still to mean "long press". */
@@ -92,6 +155,18 @@ export const LONG_PRESS_MS = 500;
*/
export const MOVE_TOLERANCE_PX = 10;
+/**
+ * How far a press must travel sideways before it is a swipe.
+ *
+ * It has a ceiling the other constants do not: the browser's own
+ * decision is made a little past this, so a threshold much higher is a
+ * gesture the device never delivers. Measured, the second `touchmove`
+ * of an `adb input swipe` lands at ~19px and the pointer stream dies
+ * just after it, so 12 is inside that window with room for a slower
+ * finger.
+ */
+export const SWIPE_START_PX = 12;
+
/** Detail carried by both gesture events. */
export interface GestureDetail {
/** Where the finger was, in client coordinates — a menu opens here. */
@@ -99,12 +174,30 @@ export interface GestureDetail {
y: number;
}
+/** Detail carried by the three swipe events. */
+export interface SwipeDetail {
+ /** Travel from where the finger landed. Signed: right is positive. */
+ dx: number;
+ dy: number;
+ /**
+ * The gesture was taken away rather than finished — a second
+ * finger, a `touchcancel`, a scroll underneath. Only ever true on
+ * `yj-swipe-end`, and it is the difference between "do the thing"
+ * and "put the row back".
+ */
+ canceled: boolean;
+}
+
export type GestureEvent = CustomEvent;
+export type SwipeEvent = CustomEvent;
declare global {
interface HTMLElementEventMap {
'yj-tap': GestureEvent;
'yj-long-press': GestureEvent;
+ 'yj-swipe-start': SwipeEvent;
+ 'yj-swipe-move': SwipeEvent;
+ 'yj-swipe-end': SwipeEvent;
}
}
@@ -139,10 +232,44 @@ export function installTouchGestures(): () => void {
* anything. */
let swallowClick = false;
- /** We dispatched a `contextmenu`, so a trusted one arriving now is
- * a duplicate. */
+ /**
+ * This press has already produced its outcome, so a trusted
+ * `contextmenu` arriving now is a duplicate of it.
+ *
+ * It covers **both** outcomes, and that is a fix rather than a
+ * tidy-up. `nativeSeen` handles the browser's menu arriving
+ * *during* the hold; the reverse order was never handled, and it
+ * happens: measured on the reference device over four holds, two
+ * of them fired our 500ms timer and then delivered a trusted
+ * `contextmenu` 50-70ms later, which nothing suppressed — so the
+ * context menu opened on top of the selection bar, intermittently,
+ * on exactly the surface #63 exists to have changed. Neither the
+ * component tier nor the e2e tier can see it: no browser they run
+ * in synthesises a `contextmenu` from a dispatched press at all.
+ */
let justFired = false;
+ // --- the swipe, which runs on touch events; see the header ------
+
+ /** Where the finger landed, and what it landed on. */
+ let swipeTarget: EventTarget | null = null;
+ let swipeOriginX = 0;
+ let swipeOriginY = 0;
+
+ /** The last travel, kept so a `touchcancel` — which carries no
+ * coordinates for a touch that is already gone — can still say how
+ * far the row had moved. */
+ let lastDx = 0;
+ let lastDy = 0;
+
+ /** A component claimed the swipe: it is ours until the finger
+ * lifts, and every `touchmove` is prevented. */
+ let swiping = false;
+
+ /** This press can no longer become a swipe — it went vertical, a
+ * second finger arrived, or nobody claimed it. */
+ let swipeVetoed = false;
+
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
@@ -202,6 +329,12 @@ export function installTouchGestures(): () => void {
// selection mode or a card grid let it fall through to a menu.
swallowClick = true;
+ // The press is answered, so a trusted `contextmenu` for it is
+ // late rather than new. `fireContextMenu` sets this too; it is
+ // set here as well so the *claimed* branch is covered, which
+ // is the branch that was showing a menu over the bar.
+ justFired = true;
+
// An unclaimed long press is what it has always been. This is
// the whole reason the fourteen context menus need no change.
if (!announce('yj-long-press', el)) fireContextMenu(el);
@@ -223,6 +356,145 @@ export function installTouchGestures(): () => void {
timer = setTimeout(onLongPress, LONG_PRESS_MS);
};
+ /**
+ * Announce a swipe on the element the finger landed on.
+ * Returns whether a component claimed it (only `start` asks).
+ */
+ const announceSwipe = (
+ name: 'yj-swipe-start' | 'yj-swipe-move' | 'yj-swipe-end',
+ el: EventTarget,
+ canceled = false,
+ ): boolean => {
+ const event: SwipeEvent = new CustomEvent(name, {
+ bubbles: true,
+ cancelable: name === 'yj-swipe-start',
+ composed: true,
+ detail: { dx: lastDx, dy: lastDy, canceled },
+ });
+
+ ours.add(event);
+ el.dispatchEvent(event);
+
+ return event.defaultPrevented;
+ };
+
+ /**
+ * End a claimed swipe, once.
+ *
+ * Every exit from a swipe comes through here so that `yj-swipe-end`
+ * is guaranteed: a component that has put a reveal on screen and a
+ * row half off its own left edge has no other way to learn the
+ * gesture is over.
+ */
+ const endSwipe = (canceled: boolean): void => {
+ const el = swipeTarget;
+
+ swipeTarget = null;
+
+ if (!swiping) return;
+
+ swiping = false;
+
+ if (!el) return;
+
+ // The gesture happened, so the click that ends it is not a
+ // click on the row it ended over.
+ swallowClick = true;
+ announceSwipe('yj-swipe-end', el, canceled);
+ };
+
+ const onTouchStart = (e: TouchEvent): void => {
+ endSwipe(true);
+
+ lastDx = 0;
+ lastDy = 0;
+
+ // A second finger is a pinch or a scroll, never one of ours.
+ swipeVetoed = e.touches.length !== 1;
+
+ if (swipeVetoed) return;
+
+ const touch = e.touches[0];
+
+ if (!touch) return;
+
+ swipeOriginX = touch.clientX;
+ swipeOriginY = touch.clientY;
+ // `composedPath()[0]` for the reason the press path uses it: a
+ // list delegates inside its own shadow root.
+ swipeTarget = e.composedPath()[0] ?? e.target;
+ };
+
+ const onTouchMove = (e: TouchEvent): void => {
+ if (swipeVetoed || !swipeTarget) return;
+
+ if (e.touches.length !== 1) {
+ endSwipe(true);
+ swipeVetoed = true;
+
+ return;
+ }
+
+ const touch = e.touches[0];
+
+ if (!touch) return;
+
+ lastDx = touch.clientX - swipeOriginX;
+ lastDy = touch.clientY - swipeOriginY;
+
+ if (swiping) {
+ // This is what keeps the stream alive on the device. It is
+ // only ever reached for a *claimed* swipe, so nothing that
+ // scrolls is ever prevented.
+ e.preventDefault();
+ announceSwipe('yj-swipe-move', swipeTarget);
+
+ return;
+ }
+
+ // Vertical first: past the tolerance the list has it, and a
+ // gesture that is exactly diagonal is the list's too.
+ if (
+ Math.abs(lastDy) > MOVE_TOLERANCE_PX &&
+ Math.abs(lastDy) >= Math.abs(lastDx)
+ ) {
+ swipeVetoed = true;
+ swipeTarget = null;
+
+ return;
+ }
+
+ if (
+ Math.abs(lastDx) < SWIPE_START_PX ||
+ Math.abs(lastDx) <= Math.abs(lastDy)
+ ) {
+ return;
+ }
+
+ if (!announceSwipe('yj-swipe-start', swipeTarget)) {
+ // Nobody wants it. Leave the gesture to the browser rather
+ // than holding it open for the rest of the press.
+ swipeVetoed = true;
+ swipeTarget = null;
+
+ return;
+ }
+
+ swiping = true;
+
+ // It is not a tap and it is not a hold.
+ cancel();
+ e.preventDefault();
+ };
+
+ const onTouchEnd = (): void => {
+ endSwipe(false);
+ };
+
+ const onTouchCancel = (): void => {
+ endSwipe(true);
+ };
+
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
@@ -301,25 +573,45 @@ export function installTouchGestures(): () => void {
// before anything that would act on the event.
const opts = { capture: true } as const;
+ // Non-passive, because `onTouchMove` has to be able to prevent the
+ // default for a claimed swipe -- see the header. The other three
+ // are passive: they only read.
+ const blocking = { capture: true, passive: false } as const;
+ const listening = { capture: true, passive: true } as const;
+
+ /** A surface moved under the finger: neither gesture survives it. */
+ const abort = (): void => {
+ endSwipe(true);
+ cancel();
+ };
+
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', onPointerUp, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
+ document.addEventListener('touchstart', onTouchStart, listening);
+ document.addEventListener('touchmove', onTouchMove, blocking);
+ document.addEventListener('touchend', onTouchEnd, listening);
+ document.addEventListener('touchcancel', onTouchCancel, listening);
// 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 });
+ document.addEventListener('scroll', abort, listening);
uninstall = () => {
- cancel();
+ abort();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', onPointerUp, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
- document.removeEventListener('scroll', cancel, opts);
+ document.removeEventListener('touchstart', onTouchStart, opts);
+ document.removeEventListener('touchmove', onTouchMove, opts);
+ document.removeEventListener('touchend', onTouchEnd, opts);
+ document.removeEventListener('touchcancel', onTouchCancel, opts);
+ document.removeEventListener('scroll', abort, opts);
uninstall = null;
};
diff --git a/frontend/test/components/touch-gestures.test.ts b/frontend/test/components/touch-gestures.test.ts
index 6e483dc..50d0fdb 100644
--- a/frontend/test/components/touch-gestures.test.ts
+++ b/frontend/test/components/touch-gestures.test.ts
@@ -424,6 +424,24 @@ describe("the browser's own long press", () => {
expect(menus).toHaveLength(0);
});
+ it('suppresses a menu that arrives after the hold was claimed', async () => {
+ // The order the device actually produces, and the one that was
+ // missing: our 500ms timer fires first and a component claims it,
+ // then Chrome delivers its own `contextmenu` 50-70ms later.
+ // Measured over four holds on the reference phone, two took this
+ // order -- so the context menu opened over the selection bar,
+ // intermittently, on the one surface #63 changed.
+ const menus = recordMenus(inner);
+
+ inner.addEventListener('yj-long-press', (e) => e.preventDefault());
+
+ press(inner, 'pointerdown');
+ await wait(HELD);
+ browserContextMenu(inner);
+
+ expect(menus, 'the menu is late, not new').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
@@ -438,3 +456,226 @@ describe("the browser's own long press", () => {
expect(menus).toHaveLength(1);
});
});
+
+/**
+ * The swipe half (plan 019 phase 2, #63).
+ *
+ * It runs on touch events rather than pointer events, and that is the
+ * one thing about it a browser tier cannot check. Measured on the
+ * reference device: Chrome 113's WebView cancels the *pointer* stream
+ * ~16px into any drag whatever `touch-action` says, while `touchmove`
+ * keeps firing — so what these assert is the shape that survives it,
+ * not that it survives.
+ *
+ * What they can hold is everything else: the axis rule, that the tie
+ * breaks toward the scroller, that an unclaimed swipe is left entirely
+ * alone, that a claimed one prevents the default (which is the half of
+ * the device fix that lives in code), and that an end always arrives.
+ */
+describe('a finger dragged sideways', () => {
+ beforeEach(() => {
+ uninstall = installTouchGestures();
+ ({ host, inner } = mountRow());
+ });
+
+ afterEach(() => {
+ uninstall?.();
+ uninstall = null;
+ host.remove();
+ });
+
+ /** One finger, at an offset from where it landed. */
+ function touch(el: EventTarget, type: string, dx = 0, dy = 0): TouchEvent {
+ const point = new Touch({
+ identifier: 1,
+ target: el as EventTarget as Element,
+ clientX: 40 + dx,
+ clientY: 60 + dy,
+ });
+ const event = new TouchEvent(type, {
+ bubbles: true,
+ composed: true,
+ cancelable: true,
+ touches: type === 'touchend' || type === 'touchcancel' ? [] : [point],
+ changedTouches: [point],
+ });
+
+ el.dispatchEvent(event);
+
+ return event;
+ }
+
+ /** Record the swipe events a component would bind. */
+ function recordSwipes(
+ el: EventTarget,
+ opts: { claim?: boolean } = {},
+ ): { type: string; dx: number; canceled: boolean }[] {
+ const seen: { type: string; dx: number; canceled: boolean }[] = [];
+
+ for (const name of ['yj-swipe-start', 'yj-swipe-move', 'yj-swipe-end']) {
+ el.addEventListener(name, (e) => {
+ const detail = (e as CustomEvent<{ dx: number; canceled: boolean }>)
+ .detail;
+
+ if (name === 'yj-swipe-start' && opts.claim !== false) {
+ e.preventDefault();
+ }
+
+ seen.push({ type: name, dx: detail.dx, canceled: detail.canceled });
+ });
+ }
+
+ return seen;
+ }
+
+ it('announces a swipe once it has travelled decisively sideways', () => {
+ const seen = recordSwipes(inner);
+
+ touch(inner, 'touchstart');
+ touch(inner, 'touchmove', 4);
+ expect(seen, 'a wobble is not a swipe').toHaveLength(0);
+
+ touch(inner, 'touchmove', 30);
+ touch(inner, 'touchmove', 60);
+ touch(inner, 'touchend');
+
+ expect(seen.map((s) => s.type)).toEqual([
+ 'yj-swipe-start',
+ 'yj-swipe-move',
+ 'yj-swipe-end',
+ ]);
+ expect(seen.at(-1)?.dx).toBe(60);
+ expect(seen.at(-1)?.canceled).toBe(false);
+ });
+
+ it('prevents the default only for a claimed swipe', () => {
+ // This is the half of the device fix that lives in code: a
+ // non-passive `touchmove` calling `preventDefault` is what keeps
+ // the gesture ours on Chrome 113. Preventing anything else would
+ // be taking the list's scrolling away.
+ recordSwipes(inner);
+
+ touch(inner, 'touchstart');
+
+ const early = touch(inner, 'touchmove', 4);
+
+ expect(early.defaultPrevented, 'a wobble scrolls').toBe(false);
+
+ const claimed = touch(inner, 'touchmove', 30);
+ const after = touch(inner, 'touchmove', 60);
+
+ expect(claimed.defaultPrevented).toBe(true);
+ expect(after.defaultPrevented).toBe(true);
+ });
+
+ it('leaves an unclaimed swipe entirely alone', () => {
+ const seen = recordSwipes(inner, { claim: false });
+
+ touch(inner, 'touchstart');
+ touch(inner, 'touchmove', 30);
+
+ const later = touch(inner, 'touchmove', 60);
+
+ touch(inner, 'touchend');
+
+ // Asked once, refused, and then not asked again for the rest of
+ // the press -- and nothing prevented, so the browser still owns it.
+ expect(seen.map((s) => s.type)).toEqual(['yj-swipe-start']);
+ expect(later.defaultPrevented).toBe(false);
+ });
+
+ it('gives a drag that went vertical to the scroller, and keeps it', () => {
+ // The veto is a *latch*, and that is the whole of it: a scroll
+ // that curves — which is what a thumb does — would otherwise
+ // become a swipe halfway down the list, snatching the list out
+ // from under itself. Without the latch the second move here is
+ // decisively horizontal and would claim the gesture.
+ const seen = recordSwipes(inner);
+
+ touch(inner, 'touchstart');
+ touch(inner, 'touchmove', 4, 30);
+ touch(inner, 'touchmove', 80, 35);
+ touch(inner, 'touchend');
+
+ expect(seen, 'the list has it').toHaveLength(0);
+ });
+
+ it('gives the scroller the tie as well', () => {
+ // Exactly diagonal is not "decisively sideways". A list that will
+ // not scroll is unusable; a swipe that needs a second try is not.
+ const seen = recordSwipes(inner);
+
+ touch(inner, 'touchstart');
+ touch(inner, 'touchmove', 60, 60);
+ touch(inner, 'touchend');
+
+ expect(seen).toHaveLength(0);
+ });
+
+ it('is not a tap and not a hold once it is a swipe', async () => {
+ const menus = recordMenus(inner);
+ const taps: Event[] = [];
+
+ inner.addEventListener('yj-tap', (e) => taps.push(e));
+ recordSwipes(inner);
+
+ press(inner, 'pointerdown');
+ touch(inner, 'touchstart');
+ touch(inner, 'touchmove', 40);
+ await wait(HELD);
+ touch(inner, 'touchend');
+ press(inner, 'pointerup');
+
+ expect(menus, 'the hold did not become a menu').toHaveLength(0);
+ expect(taps, 'the lift did not become a tap').toHaveLength(0);
+ });
+
+ it('always ends, even when the gesture is taken away', () => {
+ // A component that has a row half off its own left edge has no
+ // other way to learn the finger is gone -- so `touchcancel` is an
+ // end with `canceled` set, not a silence.
+ const seen = recordSwipes(inner);
+
+ touch(inner, 'touchstart');
+ touch(inner, 'touchmove', 40);
+ touch(inner, 'touchcancel');
+
+ expect(seen.at(-1)?.type).toBe('yj-swipe-end');
+ expect(seen.at(-1)?.canceled).toBe(true);
+ expect(seen.at(-1)?.dx).toBe(40);
+ });
+
+ it('is never one gesture when there are two fingers', () => {
+ const seen = recordSwipes(inner);
+ const two = new TouchEvent('touchstart', {
+ bubbles: true,
+ composed: true,
+ cancelable: true,
+ touches: [
+ new Touch({ identifier: 1, target: inner, clientX: 40, clientY: 60 }),
+ new Touch({ identifier: 2, target: inner, clientX: 90, clientY: 60 }),
+ ],
+ });
+
+ inner.dispatchEvent(two);
+ touch(inner, 'touchmove', 60);
+
+ expect(seen, 'a pinch is not a swipe').toHaveLength(0);
+ });
+
+ it('swallows the click a claimed swipe ends on', () => {
+ const clicks: Event[] = [];
+
+ recordSwipes(inner);
+ inner.addEventListener('click', (e) => clicks.push(e));
+
+ touch(inner, 'touchstart');
+ touch(inner, 'touchmove', 40);
+ touch(inner, 'touchend');
+ inner.dispatchEvent(
+ new MouseEvent('click', { bubbles: true, composed: true }),
+ );
+
+ expect(clicks, 'the row was not also clicked').toHaveLength(0);
+ });
+});
diff --git a/frontend/test/components/touch-swipe.test.ts b/frontend/test/components/touch-swipe.test.ts
new file mode 100644
index 0000000..16de742
--- /dev/null
+++ b/frontend/test/components/touch-swipe.test.ts
@@ -0,0 +1,291 @@
+/**
+ * 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(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,
+): Promise {
+ 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', () => {
+ // The other half is the module's non-passive `preventDefault`.
+ // Neither works alone on Chrome 113 and both are irrelevant here,
+ // so this reads the stylesheet rather than the rendering — the
+ // regression is someone tidying the declaration away, and nothing
+ // in this browser looks different when they do.
+ 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) => /\.track-row\s*\{/.test(block));
+
+ expect(rule, 'the row rule is still there to read').toBeTruthy();
+ expect(rule).toContain('touch-action: pan-y');
+ expect(css, 'never none: it takes the scrolling too').not.toContain(
+ 'touch-action: none',
+ );
+ });
+});