feat(android): tap to play, hold to select
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 9m41s

Phase 1 of #63, and the design the issue asks for as one piece is
.planning/plans/active/019-android-touch-model.md.

**A finger has no second button and no modifier keys**, so the primary
action has to be the primary gesture: tap plays the row, and the hold
that opened a context menu now enters selection mode with that row
selected.

Three decisions in it, and two diverge from the report.

**The predicate is the pointer, not the platform or the viewport.**
`pointerType === 'touch'`, per event, which is already how long-press.ts
decided and is the only such test in the frontend. This is #64's rule --
named after the capability -- and it carries #64's warning: keyed on a
width, an Android *tablet* at 600px gets click-selects/double-click-plays
on a touchscreen, which is the inversion this issue exists to fix, on
the platform it exists for. A touchscreen laptop cannot be described by
a width at all. Per event, a mouse keeps desktop semantics on the very
same row, and there is no second declaration of what a phone does.

**There is no double-tap, and the number is why.** The report asks for
single tap to play *and* double tap for the menu. Those cannot both be
honoured: the first tap of a double tap is indistinguishable from a
single tap until the interval expires, so "tap plays" becomes "tap
waits". Measured on the device, the play command to TrackChanged is
155/123/85/56/91 ms -- median ~100 -- and the app's own
DOUBLE_CLICK_GRACE_MS is 250. That is 3.5x the primary interaction,
250ms of it spent deliberately doing nothing, on every track anyone
plays, to reach a menu the hold already reaches. So the menu and the
selection action bar are the same surface, which is also the platform's
convention and removes a concept rather than adding one.

**Tap-to-play and selection mode ship together**, because splitting
them is a regression dressed as an increment: a touch user selects by
tapping today and acts through the long-press menu, so moving tap to
play on its own would leave a window with no way to select forty tracks
at all.

**What lets this reassign the hold without touching one of the fourteen
context menus**: the layer announces `yj-tap` / `yj-long-press`
(composed, cancelable) and acts on nothing. A component claims one with
preventDefault. An **unclaimed long press still becomes a
`contextmenu`**, so the card grids, Explore, the playlist rows and
every other menu behave exactly as they did, and only lists that opt in
get selection mode. An unclaimed *tap* does nothing at all and the
click follows normally, which is what leaves every button in the app
alone -- only a claimed tap has its click swallowed, or playing a track
would also select it.

**And the device found the one thing no browser tier can see.**
Chrome 113's Android WebView fires its own `contextmenu` on a long
press. long-press.ts stood down when a trusted one arrived, which was
right while both paths ended in a context menu; they no longer do, so
standing down means the gesture silently does the *old* thing.
Measured, before the fix, holding a track row:

    {"log":["contextmenu isTrusted=true"],
     "state":{"bar":null,"menuActive":true,"selected":1}}

`yj-long-press` was never announced, the menu opened, and all 26 tests
passed -- dispatched pointer events do not make a browser synthesise
one. So the native event is a **trigger, not a competitor**: the
gesture is announced from it and only a claim suppresses it. Unclaimed
it propagates untouched, which is the same "browser wins" outcome
reached by asking instead of assuming.

The tier could not find that and can hold it, because this module has
always told its own events apart by identity rather than isTrusted, so
an untrusted one from a test takes exactly the browser's path.

Verified on the device by *performing* the gestures rather than
describing the page -- `adb shell input tap` and `input swipe x y x y
700` reach the WebView as real pointer events, which is new here and is
written down in the plan with the pixel mapping. Tap plays; a hold
raises the bar with one selected and no menu; a tap toggles to two,
back to one, and the mode ends with the last row; an album card still
opens its context menu.

29 new tests. The e2e spec is rewritten to assert **both** halves --
the row selects, and a card elsewhere still opens the real menu --
because a spec that only checked the row would pass on a build that had
silently broken the other thirteen.

Phases 2-4 (swipe to queue, the other three surfaces, and what #67
inherits) are in the plan and not in this commit.
This commit is contained in:
2026-08-22 00:23:33 -04:00
parent 4f32d4e13c
commit 76e1c444cc
13 changed files with 1872 additions and 546 deletions
@@ -166,7 +166,7 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
* gated on the device having hover rather than on width. A
* touch long-press synthesises a hover state in the WebView,
* so on a phone it flashed into view during the 500ms hold
* that utils/long-press.ts is measuring for a context menu
* that utils/touch-gestures.ts is measuring for a long press
* a control appearing because you were reaching for a
* different one. A phone user taps the album and plays from
* the detail view, so there is nothing to replace it with.
@@ -0,0 +1,176 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { ICON_MORE_ACTIONS } from '@utils/icon-language';
/**
* What a selection can have done to it, while a finger is holding one.
*
* This is the context menu (plan 019, #63). Not a second surface
* beside it — the same actions, contextualised to whatever is
* selected, in the shape Android puts them in.
*
* #63 asked for a double tap to open the menu instead. That mapping
* costs the app's primary interaction 250ms on every play, because the
* first tap of a double tap is indistinguishable from a single tap
* until the interval expires, and playing a track is ~100ms end to end
* on the reference device. So there is no double tap: a long press
* selects, this says what can be done, and the sheet behind "More" is
* the same `menu-surface` every other menu in the app opens.
*
* Four things about it are load-bearing.
*
* **It is presentational.** It takes a count and a list of actions and
* emits `selection-action` / `selection-exit`; it holds no selection
* and calls no store. The host already owns a `SelectionController`
* and an action handler, and a bar that reached for either would be a
* second definition of what "play the selection" means — the fault
* `utils/library-status.ts` exists to have fixed one feature over.
*
* **The bar is only what fits, and "More" is the rest.** A context
* menu can be nine items because it is a sheet; a bar is one row on a
* 424px screen. So the host passes the two or three worth a thumb and
* the overflow opens the menu it already renders, which is what keeps
* every action reachable at every size — plan 018's promise, and the
* reason this cannot simply drop the long tail.
*
* **Its controls are 44px** (#56, #186), and the count is a live
* region: the number changes under the user's finger as they tap rows,
* and nothing else on screen announces it.
*
* **It renders nothing at zero.** The mode ends when the last row is
* deselected — `SelectionController.toggleInMode` is where that is
* decided — so a bar with a count of none is a state this should never
* be asked to draw, and drawing it anyway would hide the fact that it
* has been.
*/
export interface SelectionAction {
id: string;
label: string;
icon: string;
danger?: boolean;
}
@customElement('selection-bar')
export class SelectionBar extends LitElement {
static override styles = [
designTokens,
css`
:host {
display: block;
}
.bar {
display: flex;
align-items: center;
gap: 0.25em;
padding: 0.25em 0.5em;
background: var(--yj-bg-elevated, #343a40);
border-top: 1px solid var(--yj-border-subtle, #333);
}
.count {
flex: 1;
min-width: 0;
font-size: var(--yj-text-md);
font-weight: 600;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 44px, from #56 and #186 -- this is a bar a thumb uses. */
button {
display: flex;
align-items: center;
justify-content: center;
min-inline-size: 44px;
min-block-size: 44px;
padding: 0 0.5em;
border: none;
border-radius: 4px;
background: none;
color: var(--yj-text-primary, #fff);
font-family: inherit;
font-size: var(--yj-text-md);
cursor: pointer;
}
button:hover {
background: var(--yj-bg-overlay, #495057);
}
button.danger {
color: var(--yj-error-text, #ff8787);
}
`,
];
/** How many items are selected. Zero renders nothing. */
@property({ type: Number }) count = 0;
/** The actions worth a thumb. The rest live behind "More". */
@property({ attribute: false }) actions: SelectionAction[] = [];
private emit(name: string, detail?: unknown) {
this.dispatchEvent(
new CustomEvent(name, { detail, bubbles: true, composed: true }),
);
}
override render() {
if (this.count <= 0) return nothing;
const noun = this.count === 1 ? 'track' : 'tracks';
return html`
<div class="bar" role="toolbar" aria-label="Selection actions">
<button
aria-label="Leave selection"
@click=${() => this.emit('selection-exit')}
>
<wa-icon name="xmark"></wa-icon>
</button>
<span class="count" role="status" aria-live="polite">
${this.count.toLocaleString()} ${noun} selected
</span>
${this.actions.map(
(action) => html`
<button
class=${action.danger ? 'danger' : ''}
aria-label=${action.label}
title=${action.label}
@click=${() =>
this.emit('selection-action', { id: action.id })}
>
<wa-icon name=${action.icon}></wa-icon>
</button>
`,
)}
<button
aria-label="More actions"
@click=${(e: MouseEvent) => {
const box = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
this.emit('selection-more', {
x: box.left,
y: box.top,
});
}}
>
<wa-icon name=${ICON_MORE_ACTIONS}></wa-icon>
</button>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'selection-bar': SelectionBar;
}
}
@@ -10,6 +10,9 @@ 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 '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { PHONE_QUERY } from '@utils/breakpoints';
import {
@@ -77,7 +80,9 @@ import '@components/playlist-picker/playlist-picker.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import {
ICON_PLAY,
ICON_PLAYLIST,
ICON_PLAY_NEXT,
ICON_QUEUE,
} from '@utils/icon-language';
@@ -1304,6 +1309,8 @@ export class TrackList
virt.removeEventListener('click', this.onDelegatedClick);
virt.removeEventListener('dblclick', this.onDelegatedDblClick);
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
virt.removeEventListener('yj-tap', this.onRowTap);
virt.removeEventListener('yj-long-press', this.onRowLongPress);
virt.removeEventListener('dragstart', this.onDelegatedDragStart);
virt.removeEventListener('dragend', this.onTrackDragEnd);
}
@@ -1445,6 +1452,11 @@ export class TrackList
virt.addEventListener('contextmenu', this.onDelegatedContextMenu);
virt.addEventListener('dragstart', this.onDelegatedDragStart);
virt.addEventListener('dragend', this.onTrackDragEnd);
// Delegated like the rest: the gesture layer dispatches on the
// element the finger landed on, composed, so it arrives here
// through the same path a real click takes (plan 019).
virt.addEventListener('yj-tap', this.onRowTap);
virt.addEventListener('yj-long-press', this.onRowLongPress);
this.delegationAttached = true;
}
@@ -1708,6 +1720,62 @@ export class TrackList
if (hit) this.onTrackContextMenu(e, hit.track);
};
/**
* A finger tapped a row (plan 019, #63).
*
* On a desktop a click selects and a double-click plays; a finger
* inverts that, because there is no second button and no modifier
* key, so the primary action has to be the primary gesture.
*
* Claiming the gesture (`preventDefault`) is what tells the layer
* to swallow the click behind it -- otherwise playing a track
* would also select it, and the row would end up in both states.
* A tap this does *not* claim falls through as an ordinary click,
* which is what keeps the favourite icon working.
*/
private onRowTap = (e: GestureEvent) => {
const hit = this.resolveTrackFromEvent(e);
if (!hit) return;
// A control inside the row owns its own tap. The same rule the
// shortcut service has for a focused control that owns a key,
// and without it the 44px favourite target (#56) becomes a
// 44px play target.
if ((e.target as HTMLElement).closest('.fav-icon')) return;
e.preventDefault();
this.focusedIndex = hit.index;
if (this.selection.selectionMode) {
this.selection.toggleInMode(hit.track.FilePath, hit.index);
this.virtualizer?.requestUpdate();
return;
}
this.playFromRow(hit.index);
};
/**
* A finger held a row still for half a second.
*
* Claiming this is what makes it *selection mode* rather than the
* context menu it has been since plan 016 -- an unclaimed
* `yj-long-press` still becomes a `contextmenu`, which is how the
* card grids and Explore keep the behaviour they have.
*/
private onRowLongPress = (e: GestureEvent) => {
const hit = this.resolveTrackFromEvent(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(hit.track.FilePath, hit.index);
this.virtualizer?.requestUpdate();
};
private onDelegatedDragStart = (e: DragEvent) => {
const hit = this.resolveTrackFromEvent(e);
@@ -2238,6 +2306,45 @@ export class TrackList
this.saveSortPreferences();
};
/**
* The three worth a thumb. Everything else is behind "More",
* which opens the context menu this list already renders.
*
* A bar is one row on a 424px screen and the menu is nine items,
* so this is a subset by necessity rather than a second opinion
* about what matters -- and plan 018's promise that no action is
* unreachable is kept by the overflow, not by this list.
*/
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'add-to-queue', label: 'Add to queue', icon: ICON_QUEUE },
{ id: 'play-next', label: 'Play next', icon: ICON_PLAY_NEXT },
];
private renderSelectionBar() {
// Only in selection mode: a mouse selection is modeless and
// shows its actions on right-click, which is where a desktop
// user looks for them.
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${TrackList.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
override render() {
const visibleTracks = this.cachedSortedTracks;
const cols = this.activeColumns;
@@ -2322,6 +2429,7 @@ export class TrackList
)}
</div>
</div>
${this.renderSelectionBar()}
`}
<menu-surface
-201
View File
@@ -1,201 +0,0 @@
/**
* Long-press as the touch equivalent of a right-click (plan 016 B2,
* phase 3).
*
* Every context menu in the app opens from a `contextmenu` event —
* `track-list` and `queue-panel` delegate one on their virtualizer,
* the card grids and both playlist detail views bind one per row, and
* `explore-artist-details` binds three. A phone has no right-click, so
* a phone reached none of them.
*
* **This is one document listener, not six components' worth of touch
* handling.** A press that stays still for `LONG_PRESS_MS` dispatches a
* synthetic `contextmenu` at the touch point on the element the touch
* actually landed on, and every existing handler — delegated or
* per-row, in any shadow root — runs unchanged. Six implementations of
* a gesture is exactly the fault `ContextMenuController` exists to
* prevent, and a seam that needs no component to opt in cannot be
* forgotten by the next component.
*
* Three things about it are load-bearing.
*
* **The target comes from `composedPath()[0]`, not from
* `elementFromPoint`**, which stops at the outermost shadow host: every
* menu in this app is bound inside one, so a synthetic event dispatched
* on the host reaches a delegated listener and no per-row one.
*
* **A browser that already does this must win.** Chromium fires a
* `contextmenu` on long-press itself; WebKitGTK and the Android WebView
* vary. So one arriving during the press cancels ours, and one arriving
* just after ours is swallowed at document capture — where nothing else
* has seen it yet. The two are told apart by **identity** (a `WeakSet`
* of the events this module made) rather than by `isTrusted`, so the
* suppressor cannot eat the event it exists to deliver, the rule holds
* for anything else in the app that synthesises one, and a test can
* stand in for a browser that fires its own.
*
* **The click that ends the gesture is swallowed.** A row's click
* selects, and a card's plays; without this, opening a menu also
* activates the thing under it. It is keyed on the gesture (cleared by
* the next `pointerdown`) rather than on a time window, so a quick tap
* on the menu that just opened is not eaten too.
*/
/** How long a press must hold still to mean "menu". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`: see
* the note above. */
const ours = new WeakSet<Event>();
/**
* Install the gesture. Idempotent; returns the uninstaller (which the
* tests use — the app installs once and never removes it).
*/
export function installLongPressContextMenu(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** We opened a menu, and the click ending that gesture is not a
* click on anything. */
let swallowClick = false;
/** We dispatched one, so a trusted one arriving now is a duplicate. */
let justFired = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
};
const fire = (): void => {
timer = null;
const el = target;
target = null;
if (nativeSeen || !el) return;
justFired = true;
swallowClick = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
timer = setTimeout(fire, LONG_PRESS_MS);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
if (drifted) cancel();
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser got there first, so stand down rather than
// opening the same menu twice.
nativeSeen = true;
cancel();
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', cancel, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
// 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 });
uninstall = () => {
cancel();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', cancel, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
document.removeEventListener('scroll', cancel, opts);
uninstall = null;
};
return uninstall;
}
@@ -19,6 +19,29 @@ export class SelectionController implements ReactiveController {
private host: SelectionHost;
private _selectedItems: Set<string> = new Set();
private lastSelectedIndex: number | null = null;
private _mode = false;
/**
* Whether the list is in *selection mode* (plan 019, #63).
*
* A finger has no modifier keys, so the ctrl/shift semantics this
* controller was written for cannot be expressed by touch at all.
* Selection mode is the platform's answer: a long press enters it,
* and while it is on, a tap toggles a row instead of playing it.
*
* It is a flag *here* rather than a fifth concept beside the
* controller because all four surfaces that select
* (`track-list`, `queue-panel` and both playlist detail views)
* already share this class -- so "is this list selecting" has one
* answer per list, in the object that already owns the selection
* it would otherwise contradict.
*
* A mouse never sets it. Desktop selection is unchanged and stays
* modeless, which is what `handleItemClick` still implements.
*/
get selectionMode(): boolean {
return this._mode;
}
constructor(host: SelectionHost) {
this.host = host;
@@ -136,8 +159,64 @@ export class SelectionController implements ReactiveController {
return true;
}
/**
* Enter selection mode with `key` selected.
*
* The row the gesture was made on is selected, rather than the
* mode opening empty: a long press is a statement about *that*
* row, and an action bar with nothing in it is a mode the user has
* to make a second gesture to escape.
*/
enterSelectionMode(key: string, index: number): void {
this._mode = true;
this._selectedItems = new Set([key]);
this.lastSelectedIndex = index;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/**
* Toggle one row, and leave the mode when the last one goes.
*
* Deselecting everything is how Android's own list surfaces exit
* selection mode, and it matters more here than convention: the
* mode changes what a tap *means*, so a mode with an empty
* selection is a list where tapping does nothing and nothing on
* screen says why.
*/
toggleInMode(key: string, index: number): void {
const next = new Set(this._selectedItems);
if (next.has(key)) next.delete(key);
else next.add(key);
this._selectedItems = next;
this.lastSelectedIndex = index;
if (next.size === 0) this._mode = false;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Leave selection mode, dropping the selection with it. */
exitSelectionMode(): void {
if (!this._mode && this._selectedItems.size === 0) return;
this._mode = false;
this._selectedItems = new Set();
this.lastSelectedIndex = null;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Clear the entire selection. */
clear(): void {
// The mode goes with it: every caller of this means "the
// selection is no longer meaningful", and a mode outliving the
// selection it was showing is the empty-mode trap above.
this._mode = false;
if (this._selectedItems.size === 0) return;
this._selectedItems = new Set();
@@ -176,6 +255,13 @@ export class SelectionController implements ReactiveController {
if (next.size === this._selectedItems.size) return;
this._selectedItems = next;
// A refetch that emptied the selection also ends the mode --
// otherwise removing the last selected track from the library
// leaves the list in a state where a tap selects and the bar
// is gone.
if (next.size === 0) this._mode = false;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
+327
View File
@@ -0,0 +1,327 @@
/**
* The touch gestures, as one document listener (plan 019, #63).
*
* This replaces `utils/long-press.ts` rather than sitting beside it,
* and that is the point: two document listeners both claiming the
* 500ms hold is exactly the fault that file's own header warns about.
* What it did — one capture listener, the target from
* `composedPath()[0]`, the browser's own gesture winning, the trailing
* click swallowed — is kept whole. What changes is what the gesture
* *means*.
*
* **A gesture is announced, not acted on.** Two composed, cancelable
* events are dispatched on the element the finger actually landed on:
*
* `yj-tap` a short press that did not drift
* `yj-long-press` a press that held still for LONG_PRESS_MS
*
* A component that wants the gesture handles it and calls
* `preventDefault()`. Nothing else changes. That shape is what lets
* this reassign the hold without touching a single one of the fourteen
* context menus downstream of it: **an unclaimed `yj-long-press` still
* becomes a synthetic `contextmenu`**, so a card grid, an Explore
* result or a playlist row behaves exactly as it did, and only the
* lists that opt in get selection mode.
*
* The same rule keeps taps honest. An unclaimed `yj-tap` does nothing
* at all and the browser's click follows normally, so every button,
* link and checkbox in the app is untouched by this file. Only a
* claimed tap has its click swallowed — otherwise playing a track
* would also select it.
*
* Five things are load-bearing.
*
* **The predicate is the pointer, not the platform** (plan 019,
* decision 1). `pointerType === 'touch'`, per event — so an Android
* tablet over 600px, a touchscreen laptop with a mouse also plugged
* in, and a narrow desktop window are all right for free, and there is
* no second declaration of what a phone does. Keyed on a viewport
* width, the first of those three gets desktop semantics on a
* touchscreen, which is the inversion #63 exists to fix, on the
* platform it exists for.
*
* **There is no double-tap**, and it is not an omission — see plan
* 019, decision 2. Measured on the reference device, the play command
* to `TrackChanged` is ~100ms; a double-tap discriminator has to hold
* every tap for the app's own `DOUBLE_CLICK_GRACE_MS` of 250 before it
* can act, which is 3.5x the primary interaction in the app to reach a
* menu that long-press already reaches.
*
* **The target comes from `composedPath()[0]`**, not
* `elementFromPoint`, which stops at the outermost shadow host: every
* list in this app delegates inside one, so an event dispatched on the
* host reaches a delegated listener and no per-row one.
*
* **A browser that fires its own `contextmenu` is a trigger, not a
* competitor**, and that is a change from `long-press.ts` rather than
* an inherited rule. It used to stand down when a trusted
* `contextmenu` arrived, because both paths ended in the same place: a
* context menu. They no longer do — ours may end in selection mode —
* so standing down means the gesture silently does the *old* thing.
*
* Measured on the reference device, which is the only tier that can
* see this: Chrome 113's WebView fires its own `contextmenu` on a long
* press, so a hold on a track row opened the context menu and
* `yj-long-press` was never announced at all. Every test in the
* component tier passed, because dispatched pointer events do not make
* a browser synthesise one.
*
* So a trusted `contextmenu` arriving mid-press *becomes* the long
* press: `yj-long-press` is announced from it, and only if a component
* claims it is the native event suppressed. Unclaimed, it propagates
* untouched and opens the menu it always did — which is the same
* "browser wins" outcome, now reached by asking rather than assuming.
*
* Ours and the browser's are still told apart by identity rather than
* `isTrusted` — a `WeakSet` of the events this module made — so the
* suppressor cannot eat the event it exists to deliver, and a test can
* stand in for a browser that fires one.
*
* **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.
*/
/** How long a press must hold still to mean "long press". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/** Detail carried by both gesture events. */
export interface GestureDetail {
/** Where the finger was, in client coordinates — a menu opens here. */
x: number;
y: number;
}
export type GestureEvent = CustomEvent<GestureDetail>;
declare global {
interface HTMLElementEventMap {
'yj-tap': GestureEvent;
'yj-long-press': GestureEvent;
}
}
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`. */
const ours = new WeakSet<Event>();
/**
* Install the gestures. Idempotent; returns the uninstaller (which the
* tests use — the app installs once and never removes it).
*/
export function installTouchGestures(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** The press is still a candidate for a tap: it has neither
* drifted nor become a long press. */
let tapCandidate = false;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** A gesture was claimed, and the click ending it is not a click on
* anything. */
let swallowClick = false;
/** We dispatched a `contextmenu`, so a trusted one arriving now is
* a duplicate. */
let justFired = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
tapCandidate = false;
};
/**
* Announce a gesture on the element the finger landed on.
* Returns whether a component claimed it.
*/
const announce = (name: 'yj-tap' | 'yj-long-press', el: EventTarget): boolean => {
const event: GestureEvent = new CustomEvent<GestureDetail>(name, {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
detail: { x: originX, y: originY },
});
ours.add(event);
el.dispatchEvent(event);
return event.defaultPrevented;
};
const fireContextMenu = (el: EventTarget): void => {
justFired = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onLongPress = (): void => {
timer = null;
tapCandidate = false;
const el = target;
target = null;
if (nativeSeen || !el) return;
// The gesture happened either way, so the click that ends it is
// never a click on anything -- whether a list claimed it for
// selection mode or a card grid let it fall through to a menu.
swallowClick = 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);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
tapCandidate = true;
timer = setTimeout(onLongPress, LONG_PRESS_MS);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
// A drifted press is neither gesture -- it is a scroll, and the
// virtualizer's, not ours.
if (drifted) cancel();
};
const onPointerUp = (): void => {
const el = target;
const wasTap = tapCandidate && timer !== null;
// Clears the long-press timer, so a tap cannot also become one.
cancel();
if (!wasTap || !el) return;
// Only a *claimed* tap swallows its click. An unclaimed one has
// to fall through untouched, or every button in the app stops
// working.
if (announce('yj-tap', el)) swallowClick = true;
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser recognised the same hold this was timing.
// Use its event as the trigger rather than racing it --
// and rather than standing down, which is what the old
// rule did and which now silently means "do the thing this
// gesture used to do".
const el = e.composedPath()[0] ?? e.target;
nativeSeen = true;
cancel();
if (!el) return;
swallowClick = true;
// Claimed: the component wants selection mode, so the
// browser's menu must not also open. Unclaimed: let it
// through exactly as before.
if (announce('yj-long-press', el)) {
e.preventDefault();
e.stopImmediatePropagation();
}
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
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);
// 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 });
uninstall = () => {
cancel();
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);
uninstall = null;
};
return uninstall;
}