Android touch model, phases 2-4: swipe to queue, and the other three lists #201
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
class=${classMap({
|
||||
'swipe-reveal': true,
|
||||
armed: this.swipeArmed,
|
||||
})}
|
||||
aria-hidden="true"
|
||||
data-testid="swipe-reveal"
|
||||
>
|
||||
<wa-icon name=${ICON_QUEUE}></wa-icon>
|
||||
<span
|
||||
>${this.swipeDone
|
||||
? 'Added'
|
||||
: this.swipeArmed
|
||||
? 'Release to add'
|
||||
: `Add ${what}`}</span
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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}
|
||||
<div
|
||||
role="gridcell"
|
||||
class=${classMap({
|
||||
@@ -2354,6 +2648,9 @@ export class TrackList
|
||||
<div class="sr-only" role="status" aria-live="polite">
|
||||
${this.liveStatus(visibleTracks.length)}
|
||||
</div>
|
||||
<div class="sr-only" role="status" aria-live="polite">
|
||||
${this.swipeAnnouncement}
|
||||
</div>
|
||||
${this.tracks.length === 0
|
||||
? this.renderPlaceholder()
|
||||
: html`
|
||||
|
||||
@@ -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<GestureDetail>;
|
||||
export type SwipeEvent = CustomEvent<SwipeDetail>;
|
||||
|
||||
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<SwipeDetail>(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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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', () => {
|
||||
// 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user