Android touch model, phases 2-4: swipe to queue, and the other three lists #201
No files matched your search
@@ -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.
|
tap and enters the mode on long press. The action bar.
|
||||||
|
|
||||||
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
|
**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
|
**Phase 3 — the other three surfaces**, which is mostly wiring, since
|
||||||
they already share the controller.
|
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
|
## Open questions
|
||||||
|
|
||||||
1. **Does selection mode have an escape other than the bar's own
|
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.
|
converted by hand.
|
||||||
|
|
||||||
**And a menu opens from a finger, through the event it already has.**
|
**And a menu opens from a finger, through the event it already has.**
|
||||||
`utils/long-press.ts` is one document-capture listener installed once
|
`utils/touch-gestures.ts` is one document-capture listener installed
|
||||||
from `index.ts`: a touch that holds still for 500 ms dispatches a
|
once from `index.ts` — `utils/long-press.ts` until #63 replaced it,
|
||||||
synthetic `contextmenu` at the touch point, so all six components that
|
rather than adding a second listener claiming the same 500 ms hold. It
|
||||||
bind one — delegated on a virtualizer, per row, per card — gained the
|
**announces** rather than acts: `yj-tap`, `yj-long-press` and
|
||||||
gesture without changing. The target is `composedPath()[0]` rather than
|
`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
|
`elementFromPoint`, which stops at the outermost shadow host and so
|
||||||
reaches a delegated listener and no per-row one; a browser that fires
|
reaches a delegated listener and no per-row one; and the click that
|
||||||
its own long-press `contextmenu` (Chromium does, WebKit and the WebView
|
ends a *claimed* gesture is swallowed, keyed on the gesture rather than
|
||||||
vary) wins, ours being told from theirs by **identity** rather than
|
on a time window so the first tap on the menu it opened is not eaten
|
||||||
`isTrusted`, since no test can dispatch a trusted event; and the click
|
too.
|
||||||
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.
|
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,
|
**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
|
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
|
* 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);
|
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';
|
} from 'lit/decorators.js';
|
||||||
import { SelectionController } from '@utils/selection-controller';
|
import { SelectionController } from '@utils/selection-controller';
|
||||||
import type { SelectionHost } 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 '@components/selection-bar/selection-bar';
|
||||||
import type { SelectionAction } from '@components/selection-bar/selection-bar';
|
import type { SelectionAction } from '@components/selection-bar/selection-bar';
|
||||||
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
|
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
|
||||||
@@ -282,6 +282,37 @@ export class TrackList
|
|||||||
* path into it at all (H-5). */
|
* path into it at all (H-5). */
|
||||||
@state() private focusedIndex = 0;
|
@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 => {
|
private handleSelectAll = (): void => {
|
||||||
this.selection.selectAll();
|
this.selection.selectAll();
|
||||||
};
|
};
|
||||||
@@ -1135,6 +1166,18 @@ export class TrackList
|
|||||||
height: 33px;
|
height: 33px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
contain: strict;
|
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
|
/* 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));
|
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 {
|
.cell {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1311,6 +1410,9 @@ export class TrackList
|
|||||||
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
|
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
|
||||||
virt.removeEventListener('yj-tap', this.onRowTap);
|
virt.removeEventListener('yj-tap', this.onRowTap);
|
||||||
virt.removeEventListener('yj-long-press', this.onRowLongPress);
|
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('dragstart', this.onDelegatedDragStart);
|
||||||
virt.removeEventListener('dragend', this.onTrackDragEnd);
|
virt.removeEventListener('dragend', this.onTrackDragEnd);
|
||||||
}
|
}
|
||||||
@@ -1457,6 +1559,9 @@ export class TrackList
|
|||||||
// through the same path a real click takes (plan 019).
|
// through the same path a real click takes (plan 019).
|
||||||
virt.addEventListener('yj-tap', this.onRowTap);
|
virt.addEventListener('yj-tap', this.onRowTap);
|
||||||
virt.addEventListener('yj-long-press', this.onRowLongPress);
|
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;
|
this.delegationAttached = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1776,6 +1881,193 @@ export class TrackList
|
|||||||
this.virtualizer?.requestUpdate();
|
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) => {
|
private onDelegatedDragStart = (e: DragEvent) => {
|
||||||
const hit = this.resolveTrackFromEvent(e);
|
const hit = this.resolveTrackFromEvent(e);
|
||||||
|
|
||||||
@@ -2197,6 +2489,7 @@ export class TrackList
|
|||||||
'track-row': true,
|
'track-row': true,
|
||||||
active,
|
active,
|
||||||
selected,
|
selected,
|
||||||
|
swiping: this.swipeIndex === index,
|
||||||
})}
|
})}
|
||||||
role="row"
|
role="row"
|
||||||
aria-rowindex=${index + 1}
|
aria-rowindex=${index + 1}
|
||||||
@@ -2208,6 +2501,7 @@ export class TrackList
|
|||||||
data-testid="track-row"
|
data-testid="track-row"
|
||||||
data-file-path=${track.FilePath}
|
data-file-path=${track.FilePath}
|
||||||
>
|
>
|
||||||
|
${this.swipeIndex === index ? this.renderSwipeReveal() : nothing}
|
||||||
<div
|
<div
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
class=${classMap({
|
class=${classMap({
|
||||||
@@ -2354,6 +2648,9 @@ export class TrackList
|
|||||||
<div class="sr-only" role="status" aria-live="polite">
|
<div class="sr-only" role="status" aria-live="polite">
|
||||||
${this.liveStatus(visibleTracks.length)}
|
${this.liveStatus(visibleTracks.length)}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sr-only" role="status" aria-live="polite">
|
||||||
|
${this.swipeAnnouncement}
|
||||||
|
</div>
|
||||||
${this.tracks.length === 0
|
${this.tracks.length === 0
|
||||||
? this.renderPlaceholder()
|
? this.renderPlaceholder()
|
||||||
: html`
|
: html`
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
*
|
*
|
||||||
* `yj-tap` a short press that did not drift
|
* `yj-tap` a short press that did not drift
|
||||||
* `yj-long-press` a press that held still for LONG_PRESS_MS
|
* `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
|
* A component that wants the gesture handles it and calls
|
||||||
* `preventDefault()`. Nothing else changes. That shape is what lets
|
* `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
|
* **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
|
* `pointerdown` rather than by a time window, so the first tap on a
|
||||||
* sheet that just opened is not eaten too.
|
* 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". */
|
/** 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;
|
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. */
|
/** Detail carried by both gesture events. */
|
||||||
export interface GestureDetail {
|
export interface GestureDetail {
|
||||||
/** Where the finger was, in client coordinates — a menu opens here. */
|
/** Where the finger was, in client coordinates — a menu opens here. */
|
||||||
@@ -99,12 +174,30 @@ export interface GestureDetail {
|
|||||||
y: number;
|
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 GestureEvent = CustomEvent<GestureDetail>;
|
||||||
|
export type SwipeEvent = CustomEvent<SwipeDetail>;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementEventMap {
|
interface HTMLElementEventMap {
|
||||||
'yj-tap': GestureEvent;
|
'yj-tap': GestureEvent;
|
||||||
'yj-long-press': 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. */
|
* anything. */
|
||||||
let swallowClick = false;
|
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;
|
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 => {
|
const cancel = (): void => {
|
||||||
if (timer !== null) clearTimeout(timer);
|
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.
|
// selection mode or a card grid let it fall through to a menu.
|
||||||
swallowClick = true;
|
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
|
// An unclaimed long press is what it has always been. This is
|
||||||
// the whole reason the fourteen context menus need no change.
|
// the whole reason the fourteen context menus need no change.
|
||||||
if (!announce('yj-long-press', el)) fireContextMenu(el);
|
if (!announce('yj-long-press', el)) fireContextMenu(el);
|
||||||
@@ -223,6 +356,145 @@ export function installTouchGestures(): () => void {
|
|||||||
timer = setTimeout(onLongPress, LONG_PRESS_MS);
|
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 => {
|
const onPointerMove = (e: PointerEvent): void => {
|
||||||
if (timer === null) return;
|
if (timer === null) return;
|
||||||
|
|
||||||
@@ -301,25 +573,45 @@ export function installTouchGestures(): () => void {
|
|||||||
// before anything that would act on the event.
|
// before anything that would act on the event.
|
||||||
const opts = { capture: true } as const;
|
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('pointerdown', onPointerDown, opts);
|
||||||
document.addEventListener('pointermove', onPointerMove, opts);
|
document.addEventListener('pointermove', onPointerMove, opts);
|
||||||
document.addEventListener('pointerup', onPointerUp, opts);
|
document.addEventListener('pointerup', onPointerUp, opts);
|
||||||
document.addEventListener('pointercancel', cancel, opts);
|
document.addEventListener('pointercancel', cancel, opts);
|
||||||
document.addEventListener('contextmenu', onContextMenu, opts);
|
document.addEventListener('contextmenu', onContextMenu, opts);
|
||||||
document.addEventListener('click', onClick, 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
|
// A scroll started by something other than the finger (momentum, a
|
||||||
// programmatic reveal) still means the press was not a press.
|
// programmatic reveal) still means the press was not a press.
|
||||||
document.addEventListener('scroll', cancel, { capture: true, passive: true });
|
document.addEventListener('scroll', abort, listening);
|
||||||
|
|
||||||
uninstall = () => {
|
uninstall = () => {
|
||||||
cancel();
|
abort();
|
||||||
document.removeEventListener('pointerdown', onPointerDown, opts);
|
document.removeEventListener('pointerdown', onPointerDown, opts);
|
||||||
document.removeEventListener('pointermove', onPointerMove, opts);
|
document.removeEventListener('pointermove', onPointerMove, opts);
|
||||||
document.removeEventListener('pointerup', onPointerUp, opts);
|
document.removeEventListener('pointerup', onPointerUp, opts);
|
||||||
document.removeEventListener('pointercancel', cancel, opts);
|
document.removeEventListener('pointercancel', cancel, opts);
|
||||||
document.removeEventListener('contextmenu', onContextMenu, opts);
|
document.removeEventListener('contextmenu', onContextMenu, opts);
|
||||||
document.removeEventListener('click', onClick, 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;
|
uninstall = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -424,6 +424,24 @@ describe("the browser's own long press", () => {
|
|||||||
expect(menus).toHaveLength(0);
|
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 () => {
|
it('still opens exactly one menu when nobody claims it', async () => {
|
||||||
// The old behaviour, reached by asking instead of assuming. This
|
// The old behaviour, reached by asking instead of assuming. This
|
||||||
// is what leaves the card grids, Explore and the playlist rows
|
// 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);
|
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