diff --git a/.planning/plans/active/019-android-touch-model.md b/.planning/plans/active/019-android-touch-model.md new file mode 100644 index 0000000..b1dd0e3 --- /dev/null +++ b/.planning/plans/active/019-android-touch-model.md @@ -0,0 +1,253 @@ +# 019 — The Android touch model + +**Issue:** #63 (`Area/Library-UI`, `Kind/Feature`, `Priority/High`) +**Depends on:** #60 (bottom-sheet menus) — closed, merged as PR #176 +**Relates:** #67 (inline links into the menu), #71 ("More" nav), #54 +(native feel), #5/#8 (selection, drag to queue — the desktop semantics +being diverged from) +**Status:** in flight. + +#73 puts #60 first in Phase 4 because it is "the presentation every +other item needs", and this is the next one. The Direction on #63 asks +for the interaction model to be designed as one piece before any of it +is built, because it *reassigns an existing gesture* rather than adding +one — `utils/long-press.ts` currently owns the 500ms hold, and every +context menu in the app is downstream of it. + +This document is that design. Everything below is a measurement, or an +argument for one of the choices #63 leaves open. + +--- + +## The mapping + +| gesture | pointer is a finger | pointer is a mouse | +|---|---|---| +| single tap / click | **play the row** | select the row | +| double | — | play the row | +| long press (500ms) | **enter selection mode** | — | +| right-click | — | context menu | +| swipe right | **add to queue** | — | +| drag | reorder / drag to playlist | reorder / drag to playlist | + +Three of those are #63's report unchanged. Two are decisions it left +open, and one is a deliberate divergence. + +--- + +## Decision 1 — the predicate is the pointer, not the platform + +#63 says "the row component needs a platform-aware interaction layer +rather than shared handlers". It needs an interaction layer; it should +not be platform-aware. + +**The question a row has to answer is not "am I on Android" or "is the +viewport under 600px" but "what made this event".** `pointerType === +'touch'`, read off the event that is being handled, which is already +how `long-press.ts` decides (`if (e.pointerType !== 'touch') return`) +and is the only such test in the frontend today. + +This is #64's rule — the predicate is named after the capability, not +the platform — and it carries #64's warning with it. Keyed on a width: + +- an Android **tablet** at 600px or more gets click-selects / + double-click-plays on a touchscreen, which is the exact inversion + this issue exists to fix, on the platform it exists for; +- a **touchscreen laptop** cannot be described at all, because both + pointers are live in the same session on the same row; +- and a narrow desktop window gets phone semantics with a mouse. + +Per event, all three are right for free, and there is no second +declaration of what a phone does — the thing CLAUDE.md declines to add +every time it comes up. + +**Measured, so this is not an assumption about the WebView.** On the +reference device (TLP301, Android 14, WebView Chrome 113, 424x439), +driving a real tap with `adb shell input tap`: + +``` +[["down","touch",78,94],["touchstart","touchstart",0,0],["up","touch",78,94]] +``` + +`PointerEvent` exists, `pointerType` is `"touch"`, `maxTouchPoints` is +5, and `(pointer: coarse)` / `(hover: none)` both match. + +--- + +## Decision 2 — there is no double-tap, and the number is why + +#63 asks for *single tap → play* **and** *double tap → context menu*. +Those two cannot both be honoured. The first tap of a double tap is +indistinguishable from a single tap until the interval expires, so +"tap plays" necessarily becomes "tap waits to find out whether you +meant something else, then plays". The app already owns that constant: +`utils/explore-link.ts` holds a navigation for `DOUBLE_CLICK_GRACE_MS += 250` for precisely this reason. + +**What it would be added to, measured on the device.** Six runs, from +the play command to the backend's `TrackChanged`: + +``` +155, 123, 85, 56, 91 ms median ~100 +``` + +So the app's primary interaction is ~100ms, and a double-tap +discriminator makes it ~350 — **3.5x, of which 250ms is spent +deliberately doing nothing** — paid on every track anyone ever plays, +in order to reach a menu. + +It is also against the platform's convention, which counts for more +than usual here because this is the phone build and nothing else: +long-press is *how you select* on Android (Gmail, Files, Photos), +double-tap is zoom or nothing, and a list's menu is either the +long-press sheet or a per-row overflow. + +**So the menu and the selection action bar become the same surface**, +which is the convention and removes a concept rather than adding one. +Long-press selects the row it was made on and raises the action bar; +the bar's actions *are* the context menu's actions, contextualised to +whatever is selected — one row or forty. #60's bottom sheet stays +behind it as the overflow, so `contextMenuStyles`, `MenuKeyboard` and +`menu-surface` are reused rather than reimplemented. + +--- + +## Decision 3 — tap-to-play and selection mode ship together + +The obvious phase order is "tap plays first, it is the smallest +change". It is wrong, and the reason is a capability that exists today +and is easy to miss. + +**A touch user can already multi-select**: tap selects (the desktop +semantics, which a finger currently gets), and the long-press menu then +acts on the selection. Move tap to play without shipping selection mode +in the same change and there is a window — a release, if it lands — in +which selecting forty tracks to add to a playlist is impossible on a +phone. That is a regression dressed as an increment. + +So phase 1 is both, or neither. + +--- + +## What the code looks like now + +| surface | how it binds | selection | +|---|---|---| +| `track-list` | delegated on the virtualizer: `click`, `dblclick`, `contextmenu`, `dragstart` | `SelectionController` | +| `queue-panel` | delegated, same shape | `SelectionController` | +| `playlist-details` | per row | `SelectionController` | +| `smart-playlist-details` | per row | `SelectionController` | + +All four already share `SelectionController`, and all four resolve a +row from an event by `data-index` / `data-file-path` on the row. So the +gesture layer has one shape to talk to, and "selection mode" is a flag +on the controller they already have rather than a fifth concept. + +`utils/long-press.ts` is one document-capture listener that synthesises +a `contextmenu` — the seam that needed no component to opt in. **This +plan keeps that shape and changes what the gesture means**, which is +why it is a rewrite of that file rather than a second listener set: two +document listeners both claiming the 500ms hold is the fault the file's +own header warns about. + +--- + +## Two measurements that decide the implementation + +**`touch-action` is `auto` on both the virtualizer and the rows.** With +`auto` the browser owns panning on both axes, so a horizontal drag can +be claimed as a scroll and our gesture ends in `pointercancel` +mid-swipe. A row that wants a horizontal swipe has to declare +`touch-action: pan-y`: the browser keeps the vertical pan (which is the +virtualizer's scroll, and must stay native or the list stutters) and +hands us the horizontal axis. This is the single most likely way for +swipe-to-queue to "work in Chromium and not on the phone". + +**The row is 424x52 on the device**, so a swipe threshold in px is a +fraction of a row height, not of a screen. + +**And the third one was found by building phase 1 and then running it** +— it is not something any browser tier can report. 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 the same place; once a hold can mean selection mode they end +in different places, and standing down means the gesture silently does +the *old* thing. Measured, before the fix: + +``` +{"log":["contextmenu isTrusted=true"], + "state":{"bar":null,"menuActive":true,"selected":1}} +``` + +`yj-long-press` was never announced at all, the context menu opened, +and all 26 tests in the component tier passed — dispatched pointer +events do not make a browser synthesise a `contextmenu`. + +So the browser's event is a **trigger, not a competitor**: the gesture +is announced from it, and only a component that claims it suppresses +the native menu. Unclaimed, it propagates untouched. That is the same +"browser wins" outcome, reached by asking instead of assuming — and +verified both ways on the device, a track row entering selection mode +and an album card still opening its menu. + +The tier could not *find* it and can *hold* it: a test cannot dispatch +a trusted event, but this module has always told its own apart by +identity rather than `isTrusted`, so an untrusted one from a test takes +exactly the browser's path. + +--- + +## A tier note: this one can be driven, not only measured + +`adb shell input tap|swipe` reaches the WebView as real pointer events, +which the log above is evidence of. So for the first time the Android +tier can *perform* the thing under test rather than describe the page +afterwards — a long press is `input swipe X Y X Y 600`, a swipe right +is `input swipe X Y X+N Y 120`. + +Device CSS pixels from device pixels, on this phone: +`css = (device - 59) / 2.564` vertically, `css = device / 2.564` +horizontally (measured from the tap above: 200,300 arrived as 78,94). + +This does not make the device a spec tier — it does not run in CI and +`make ui-test` still has to carry the assertions. It makes "does the +gesture actually fire on Chrome 113" answerable in seconds. + +--- + +## Phases + +**Phase 1 — the seam, tap-to-play, selection mode.** `utils/ +touch-gestures.ts` replacing `long-press.ts`: pointer-typed +recognition of tap / long-press / horizontal swipe, dispatched as +composed custom events so a delegated listener in any shadow root +still works. `SelectionController` gains a mode. `track-list` acts on +tap and enters the mode on long press. The action bar. + +**Phase 2 — swipe right to queue**, with the `touch-action: pan-y` +finding above and a reveal-and-snap affordance. + +**Phase 3 — the other three surfaces**, which is mostly wiring, since +they already share the controller. + +**Phase 4 — what this leaves behind.** The inline `explore-link`s in a +row are a single-click target inside a row whose single tap now plays; +that conflict is #67's, and this plan should not pre-empt its answer +beyond making tap-to-play win on touch. + +--- + +## Open questions + +1. **Does selection mode have an escape other than the bar's own + close?** Back is the platform's answer and the shell already owns + the history stack (#6/#55). Pushing an entry for a *mode* rather + than a place is the same argument #55 settled for the overlaid + queue, and it should probably be settled the same way — but the + queue is a screen and a selection mode is not, so it wants its own + paragraph rather than an assumption. +2. **Does a tap on a row's favourite icon still toggle it in normal + mode?** It is inside the row and the row now plays. It has to keep + working — it is a 44px target since #56 — so the gesture layer needs + the same "a control inside the row wins" rule the keyboard service + has for a focused control that owns a key. diff --git a/e2e/specs/long-press.spec.ts b/e2e/specs/long-press.spec.ts deleted file mode 100644 index 33a8d0b..0000000 --- a/e2e/specs/long-press.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { test, expect } from '../support/fixtures.js'; - -/** - * Long-press is the touch route to a context menu (plan 016 B2 phase 3). - * - * The component tier proves the gesture in isolation, against markup it - * built itself. What it cannot prove is the half that made this one - * listener instead of six: that the synthetic event reaches the handler - * a *real* component bound — `track-list` delegates its `contextmenu` - * on the `lit-virtualizer` rather than binding one per row — and that - * the real `wa-popup` menu opens from it, which is a path with its own - * history of opening and then refusing to work (see - * `menu-keyboard.spec.ts`). - * - * The pointer events are dispatched rather than performed: this project - * runs Desktop Chrome and Desktop Safari, neither of which has touch, - * and a device tier does not exist. So this is honest about what it - * checks — the app's own listeners, on the app's own DOM, from the - * events a touch would produce — and not about a real finger. - */ - -/** A common small phone, as in `phone-shell.spec.ts`. */ -const PHONE = { width: 390, height: 844 }; - -/** Comfortably past the module's 500ms hold. */ -const HELD = 900; - -type Page = import('@playwright/test').Page; - -/** The track list's menu panel, or null while it is not rendered. */ -const panel = (page: Page) => - page.evaluate(() => { - const el = document - .querySelector('track-list') - ?.shadowRoot?.querySelector('.context-menu-panel'); - - if (!el) return null; - - return { - role: el.getAttribute('role'), - label: el.getAttribute('aria-label'), - items: el.querySelectorAll('[role="menuitem"]').length, - }; - }); - -/** - * Press the first track row, optionally dragging partway through — the - * shape of a scroll that begins on a row, which must not open a menu. - */ -async function pressFirstRow( - page: Page, - opts: { driftY?: number } = {}, -): Promise { - await page.evaluate((drift) => { - // `.track-row`, not `[role="row"]`: the column header is a row too, - // and it is the *first* one -- a press on it is correctly ignored, - // which reads exactly like the gesture not working. - const row = document - .querySelector('track-list') - ?.shadowRoot?.querySelector('.track-row'); - - if (!row) throw new Error('no track row to press'); - - const box = row.getBoundingClientRect(); - const x = Math.round(box.left + box.width / 2); - const y = Math.round(box.top + box.height / 2); - const send = (type: string, dy = 0) => - row.dispatchEvent( - new PointerEvent(type, { - bubbles: true, - composed: true, - cancelable: true, - pointerType: 'touch', - isPrimary: true, - clientX: x, - clientY: y + dy, - }), - ); - - send('pointerdown'); - - if (drift) send('pointermove', drift); - }, opts.driftY ?? 0); -} - -test.describe('long-press opens the track menu', () => { - test.beforeEach(async ({ app }) => { - 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 }) => { - // Every other spec file runs against a desktop, and the viewport - // belongs to the shared context rather than to this file. - await app.setViewportSize({ width: 1440, height: 900 }); - }); - - test('reaches the delegated handler and opens the real menu', async ({ - app, - }) => { - await expect.poll(() => panel(app)).toBeNull(); - - await pressFirstRow(app); - - await expect - .poll(() => panel(app), { timeout: HELD + 2000 }) - .toMatchObject({ role: 'menu', label: 'Track actions' }); - - // The same panel Shift+F10 opens, items and all -- not an empty - // popup that happened to become visible. - expect((await panel(app))?.items).toBeGreaterThan(0); - }); - - test('does not open one for a press that turns into a scroll', async ({ - app, - }) => { - await pressFirstRow(app, { driftY: 40 }); - - await app.waitForTimeout(HELD); - - expect(await panel(app)).toBeNull(); - }); -}); diff --git a/e2e/specs/touch-gestures.spec.ts b/e2e/specs/touch-gestures.spec.ts new file mode 100644 index 0000000..523f665 --- /dev/null +++ b/e2e/specs/touch-gestures.spec.ts @@ -0,0 +1,194 @@ +import { test, expect } from '../support/fixtures.js'; + +/** + * The touch gestures against the real app (plan 019, #63; long-press + * from plan 016 B2). + * + * The component tier proves the gestures in isolation, against markup + * it built itself. What it cannot prove is the half that made this one + * document listener instead of six: that the announced gesture reaches + * the handler a *real* component bound — `track-list` delegates on the + * `lit-virtualizer` rather than binding per row — and that the real + * menu opens from it, a path with its own history of opening and then + * refusing to work (see `menu-keyboard.spec.ts`). + * + * **Both halves of the reassignment are here, and the second is the + * one that matters.** #63 makes a hold on a *track row* mean selection + * mode; every other surface in the app keeps the context menu it has + * had, because an unclaimed `yj-long-press` still becomes a + * `contextmenu`. A spec that only checked the row would pass on a + * build that had silently broken the other thirteen menus. + * + * The pointer events are dispatched rather than performed: this + * project runs Desktop Chrome and Desktop Safari, neither of which has + * touch. So this is honest about what it checks — the app's own + * listeners, on the app's own DOM, from the events a touch would + * produce — and not about a real finger. The finger is the Android + * tier, and it found something this cannot see: Chrome 113's WebView + * fires its own `contextmenu` on a long press, which is why the module + * announces the gesture from a native event rather than standing down. + */ + +/** A common small phone, as in `phone-shell.spec.ts`. */ +const PHONE = { width: 390, height: 844 }; + +/** Comfortably past the module's 500ms hold. */ +const HELD = 900; + +type Page = import('@playwright/test').Page; + +/** A component's menu panel, or null while it is not rendered. */ +const panel = (page: Page, host: string) => + page.evaluate((tag) => { + const el = document + .querySelector(tag) + ?.shadowRoot?.querySelector('.context-menu-panel'); + + if (!el) return null; + + return { + role: el.getAttribute('role'), + label: el.getAttribute('aria-label'), + items: el.querySelectorAll('[role="menuitem"]').length, + }; + }, host); + +/** How many tracks the selection bar says are selected, or null. */ +const selectionCount = (page: Page) => + page.evaluate(() => { + const bar = document + .querySelector('track-list') + ?.shadowRoot?.querySelector('selection-bar'); + + return bar ? (bar as unknown as { count: number }).count : null; + }); + +/** + * Press an element, optionally dragging partway through — the shape of + * a scroll that begins on a row, which must be neither gesture — and + * optionally lifting, which is what makes it a tap rather than a hold. + */ +async function press( + page: Page, + selector: { host: string; inner: string }, + opts: { driftY?: number; lift?: boolean } = {}, +): Promise { + await page.evaluate( + ({ host, inner, drift, lift }) => { + const el = document + .querySelector(host) + ?.shadowRoot?.querySelector(inner); + + if (!el) throw new Error(`no ${inner} in ${host} to press`); + + const box = el.getBoundingClientRect(); + const x = Math.round(box.left + box.width / 2); + const y = Math.round(box.top + box.height / 2); + const send = (type: string, dy = 0) => + el.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + composed: true, + cancelable: true, + pointerType: 'touch', + isPrimary: true, + clientX: x, + clientY: y + dy, + }), + ); + + send('pointerdown'); + + if (drift) send('pointermove', drift); + if (lift) send('pointerup'); + }, + { + host: selector.host, + inner: selector.inner, + drift: opts.driftY ?? 0, + lift: opts.lift ?? false, + }, + ); +} + +// `.track-row`, not `[role="row"]`: the column header is a row too, and +// it is the *first* one — a press on it is correctly ignored, which +// reads exactly like the gesture not working. +const TRACK_ROW = { host: 'track-list', inner: '.track-row' }; + +test.describe('a hold on a track row selects it', () => { + test.beforeEach(async ({ app }) => { + 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 }) => { + // Every other spec file runs against a desktop, and the viewport + // belongs to the shared context rather than to this file. + await app.setViewportSize({ width: 1440, height: 900 }); + }); + + test('raises the selection bar rather than the context menu', async ({ + app, + }) => { + await expect.poll(() => selectionCount(app)).toBeNull(); + + await press(app, TRACK_ROW); + + await expect + .poll(() => selectionCount(app), { timeout: HELD + 2000 }) + .toBe(1); + + // The gesture is claimed, so the menu this hold used to open must + // not also be up -- on a phone that would be a sheet over the bar. + expect(await panel(app, 'track-list')).toBeNull(); + }); + + test('is neither gesture when the press turns into a scroll', async ({ + app, + }) => { + await press(app, TRACK_ROW, { driftY: 40 }); + await app.waitForTimeout(HELD); + + expect(await selectionCount(app)).toBeNull(); + expect(await panel(app, 'track-list')).toBeNull(); + }); +}); + +test.describe('a hold anywhere else still opens the menu', () => { + test.beforeEach(async ({ app }) => { + await app.setViewportSize(PHONE); + await app.getByTestId('tab-albums').click(); + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'albums', + ); + }); + + test.afterEach(async ({ app }) => { + await app.setViewportSize({ width: 1440, height: 900 }); + }); + + test('reaches the delegated handler and opens the real menu', async ({ + app, + }) => { + // The property that let #63 reassign the hold without touching one + // of the fourteen context menus: unclaimed, it is what it was. + // Without this half, breaking all of them passes the suite. + await expect.poll(() => panel(app, 'cover-grid')).toBeNull(); + + await press(app, { host: 'cover-grid', inner: '[role="option"]' }); + + await expect + .poll(() => panel(app, 'cover-grid'), { timeout: HELD + 2000 }) + .toMatchObject({ role: 'menu' }); + + // The same panel Shift+F10 opens, items and all -- not an empty + // popup that happened to become visible. + expect((await panel(app, 'cover-grid'))?.items).toBeGreaterThan(0); + }); +}); diff --git a/frontend/index.ts b/frontend/index.ts index f69b74b..d97d80a 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -70,7 +70,7 @@ import '@store/theme-store'; // registers the document keydown listener for global shortcuts. import './src/services/keyboard-shortcut-service'; import { activateView, deactivateView } from '@utils/view-lifecycle'; -import { installLongPressContextMenu } from '@utils/long-press'; +import { installTouchGestures } from '@utils/touch-gestures'; import { openQueue, queuePanelElement } from '@utils/open-queue'; import { installTopBarFit } from './src/services/top-bar-fit'; import { @@ -87,10 +87,13 @@ setBasePath('/dist/webawesome'); // the session. registerBundledIcons(); -// The touch equivalent of a right-click, installed once for every menu -// in the app rather than per component. Harmless on a desktop: it acts -// on `pointerType === 'touch'` only. -installLongPressContextMenu(); +// Every touch gesture in the app, installed once rather than per +// component (plan 019). Harmless on a desktop: it acts on +// `pointerType === 'touch'` only, per event, so a mouse on a +// touchscreen keeps click-selects / double-click-plays on the very +// same row. An unclaimed long press still becomes a `contextmenu`, +// which is what leaves all fourteen menus untouched by #63. +installTouchGestures(); // The top bar decides what it can afford to show (#143). Here rather // than in a component because the bar is light DOM in index.html and diff --git a/frontend/src/components/home-view/home-view.ts b/frontend/src/components/home-view/home-view.ts index 73b9c53..aa31d96 100644 --- a/frontend/src/components/home-view/home-view.ts +++ b/frontend/src/components/home-view/home-view.ts @@ -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. diff --git a/frontend/src/components/selection-bar/selection-bar.ts b/frontend/src/components/selection-bar/selection-bar.ts new file mode 100644 index 0000000..f6dbb21 --- /dev/null +++ b/frontend/src/components/selection-bar/selection-bar.ts @@ -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` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'selection-bar': SelectionBar; + } +} diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index db23e33..6379ee6 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -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` + ) => + this.onContextMenuAction(e.detail.id)} + @selection-more=${(e: CustomEvent<{ x: number; y: number }>) => + this.ctxMenu.openAt(e.detail.x, e.detail.y)} + > + `; + } + + 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 )} + ${this.renderSelectionBar()} `} void) | null = null; - -/** The events this module dispatched. Identity, not `isTrusted`: see - * the note above. */ -const ours = new WeakSet(); - -/** - * 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 | 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; -} diff --git a/frontend/src/utils/selection-controller.ts b/frontend/src/utils/selection-controller.ts index 658a176..1f66cce 100644 --- a/frontend/src/utils/selection-controller.ts +++ b/frontend/src/utils/selection-controller.ts @@ -19,6 +19,29 @@ export class SelectionController implements ReactiveController { private host: SelectionHost; private _selectedItems: Set = 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?.(); } diff --git a/frontend/src/utils/touch-gestures.ts b/frontend/src/utils/touch-gestures.ts new file mode 100644 index 0000000..f9e554a --- /dev/null +++ b/frontend/src/utils/touch-gestures.ts @@ -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; + +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(); + +/** + * 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 | 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(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; +} diff --git a/frontend/test/components/long-press.test.ts b/frontend/test/components/long-press.test.ts deleted file mode 100644 index 66f2755..0000000 --- a/frontend/test/components/long-press.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Long-press as the touch route to a context menu (plan 016 B2 phase 3). - * - * These run in a real browser with real event dispatch, which is the - * only place the two things that make this hard are true: the synthetic - * event has to cross a shadow boundary to reach the listener a - * component actually bound, and the suppressors have to tell a trusted - * event from ours at document capture without eating the one they exist - * to deliver. - * - * The timings are real rather than faked, because the thing under test - * *is* a timing, and 600 ms twice is cheaper than a fake-timer harness - * that would also have to fake the pointer events. - */ -import { describe, expect, it, afterEach, beforeEach } from 'vitest'; - -import { - installLongPressContextMenu, - LONG_PRESS_MS, - MOVE_TOLERANCE_PX, -} from '@utils/long-press'; - -/** A press that has certainly resolved, either way. */ -const HELD = LONG_PRESS_MS + 120; - -/** A press that has certainly not. */ -const BRIEF = Math.round(LONG_PRESS_MS / 4); - -const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -let uninstall: (() => void) | null = null; -let host: HTMLElement; -let inner: HTMLElement; - -/** A row inside a shadow root, which is where every menu in this app - * is bound — an element in the light DOM would pass a weaker test. */ -function mountRow(): { host: HTMLElement; inner: HTMLElement } { - const el = document.createElement('div'); - const root = el.attachShadow({ mode: 'open' }); - const row = document.createElement('div'); - - row.textContent = 'a track'; - root.append(row); - document.body.append(el); - - return { host: el, inner: row }; -} - -function press( - el: EventTarget, - type: string, - init: PointerEventInit = {}, -): void { - el.dispatchEvent( - new PointerEvent(type, { - bubbles: true, - composed: true, - cancelable: true, - pointerType: 'touch', - isPrimary: true, - clientX: 40, - clientY: 60, - ...init, - }), - ); -} - -/** - * Record every `contextmenu` that reaches the listener, *as the - * listener sees it*. - * - * `target` is retargeted for the scope reading it, so an assertion made - * after dispatch has finished reports the shadow host however the event - * was dispatched - which is the same answer a broken implementation - * gives. It has to be read from inside the handler, where the component - * reads it. - */ -function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] { - const seen: { event: MouseEvent; target: EventTarget | null }[] = []; - - el.addEventListener('contextmenu', (e) => { - e.preventDefault(); - // Every real handler does this; the gesture must work anyway. - e.stopPropagation(); - seen.push({ event: e as MouseEvent, target: e.target }); - }); - - return seen; -} - -describe('long-press opens a context menu', () => { - beforeEach(() => { - uninstall = installLongPressContextMenu(); - ({ host, inner } = mountRow()); - }); - - afterEach(() => { - uninstall?.(); - uninstall = null; - host.remove(); - }); - - it('dispatches one at the touch point, on the element touched', async () => { - const seen = recordMenus(inner); - - press(inner, 'pointerdown'); - await wait(HELD); - - expect(seen).toHaveLength(1); - expect(seen[0]?.event.clientX).toBe(40); - expect(seen[0]?.event.clientY).toBe(60); - // Dispatched on the row itself, not on its shadow host - which is - // the difference between a per-row handler firing and only a - // delegated one firing. - expect(seen[0]?.target).toBe(inner); - }); - - it('is cancelled by a press that moves', async () => { - const seen = recordMenus(inner); - - press(inner, 'pointerdown'); - press(inner, 'pointermove', { - clientX: 40 + MOVE_TOLERANCE_PX + 5, - clientY: 60, - }); - await wait(HELD); - - expect(seen).toHaveLength(0); - }); - - it('tolerates the jitter a finger cannot help', async () => { - const seen = recordMenus(inner); - - press(inner, 'pointerdown'); - press(inner, 'pointermove', { clientX: 43, clientY: 62 }); - await wait(HELD); - - expect(seen).toHaveLength(1); - }); - - it('is cancelled by lifting early, and by a scroll', async () => { - const seen = recordMenus(inner); - - press(inner, 'pointerdown'); - await wait(BRIEF); - press(inner, 'pointerup'); - await wait(HELD); - - expect(seen).toHaveLength(0); - - press(inner, 'pointerdown'); - press(inner, 'pointercancel'); - await wait(HELD); - - expect(seen).toHaveLength(0); - }); - - it('ignores a mouse, which has a right button of its own', async () => { - const seen = recordMenus(inner); - - press(inner, 'pointerdown', { pointerType: 'mouse' }); - await wait(HELD); - - expect(seen).toHaveLength(0); - }); - - it('swallows the click that ends the gesture, and only that one', async () => { - let clicks = 0; - - inner.addEventListener('click', () => { - clicks += 1; - }); - - press(inner, 'pointerdown'); - await wait(HELD); - press(inner, 'pointerup'); - inner.click(); - - expect(clicks).toBe(0); - - // The next tap is a tap: on a phone that is the user choosing an - // item in the menu that just opened, so eating it would make the - // gesture useless. - press(inner, 'pointerdown'); - press(inner, 'pointerup'); - inner.click(); - - expect(clicks).toBe(1); - }); - - it('stands down where the browser fires its own', async () => { - const seen = recordMenus(inner); - - press(inner, 'pointerdown'); - await wait(BRIEF); - // Chromium does this itself on touch; WebKit and the Android - // WebView vary, which is the whole reason both halves exist. A - // test cannot dispatch a *trusted* event, which is why the module - // tells its own apart by identity rather than by `isTrusted`. - inner.dispatchEvent( - new MouseEvent('contextmenu', { - bubbles: true, - composed: true, - cancelable: true, - }), - ); - await wait(HELD); - - // One menu: the browser's. Not two. - expect(seen).toHaveLength(1); - }); -}); diff --git a/frontend/test/components/touch-gestures.test.ts b/frontend/test/components/touch-gestures.test.ts new file mode 100644 index 0000000..6e483dc --- /dev/null +++ b/frontend/test/components/touch-gestures.test.ts @@ -0,0 +1,440 @@ +/** + * The touch gestures (plan 019, #63; long-press from plan 016 B2). + * + * These run in a real browser with real event dispatch, which is the + * only place the two things that make this hard are true: the synthetic + * event has to cross a shadow boundary to reach the listener a + * component actually bound, and the suppressors have to tell a trusted + * event from ours at document capture without eating the one they exist + * to deliver. + * + * The timings are real rather than faked, because the thing under test + * *is* a timing, and 600 ms twice is cheaper than a fake-timer harness + * that would also have to fake the pointer events. + */ +import { describe, expect, it, afterEach, beforeEach } from 'vitest'; + +import { + installTouchGestures, + LONG_PRESS_MS, + MOVE_TOLERANCE_PX, +} from '@utils/touch-gestures'; + +/** A press that has certainly resolved, either way. */ +const HELD = LONG_PRESS_MS + 120; + +/** A press that has certainly not. */ +const BRIEF = Math.round(LONG_PRESS_MS / 4); + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +let uninstall: (() => void) | null = null; +let host: HTMLElement; +let inner: HTMLElement; + +/** A row inside a shadow root, which is where every menu in this app + * is bound — an element in the light DOM would pass a weaker test. */ +function mountRow(): { host: HTMLElement; inner: HTMLElement } { + const el = document.createElement('div'); + const root = el.attachShadow({ mode: 'open' }); + const row = document.createElement('div'); + + row.textContent = 'a track'; + root.append(row); + document.body.append(el); + + return { host: el, inner: row }; +} + +function press( + el: EventTarget, + type: string, + init: PointerEventInit = {}, +): void { + el.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + composed: true, + cancelable: true, + pointerType: 'touch', + isPrimary: true, + clientX: 40, + clientY: 60, + ...init, + }), + ); +} + +/** + * Record every `contextmenu` that reaches the listener, *as the + * listener sees it*. + * + * `target` is retargeted for the scope reading it, so an assertion made + * after dispatch has finished reports the shadow host however the event + * was dispatched - which is the same answer a broken implementation + * gives. It has to be read from inside the handler, where the component + * reads it. + */ +function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] { + const seen: { event: MouseEvent; target: EventTarget | null }[] = []; + + el.addEventListener('contextmenu', (e) => { + e.preventDefault(); + // Every real handler does this; the gesture must work anyway. + e.stopPropagation(); + seen.push({ event: e as MouseEvent, target: e.target }); + }); + + return seen; +} + +describe('an unclaimed long press is still a context menu', () => { + beforeEach(() => { + uninstall = installTouchGestures(); + ({ host, inner } = mountRow()); + }); + + afterEach(() => { + uninstall?.(); + uninstall = null; + host.remove(); + }); + + it('dispatches one at the touch point, on the element touched', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown'); + await wait(HELD); + + expect(seen).toHaveLength(1); + expect(seen[0]?.event.clientX).toBe(40); + expect(seen[0]?.event.clientY).toBe(60); + // Dispatched on the row itself, not on its shadow host - which is + // the difference between a per-row handler firing and only a + // delegated one firing. + expect(seen[0]?.target).toBe(inner); + }); + + it('is cancelled by a press that moves', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown'); + press(inner, 'pointermove', { + clientX: 40 + MOVE_TOLERANCE_PX + 5, + clientY: 60, + }); + await wait(HELD); + + expect(seen).toHaveLength(0); + }); + + it('tolerates the jitter a finger cannot help', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown'); + press(inner, 'pointermove', { clientX: 43, clientY: 62 }); + await wait(HELD); + + expect(seen).toHaveLength(1); + }); + + it('is cancelled by lifting early, and by a scroll', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown'); + await wait(BRIEF); + press(inner, 'pointerup'); + await wait(HELD); + + expect(seen).toHaveLength(0); + + press(inner, 'pointerdown'); + press(inner, 'pointercancel'); + await wait(HELD); + + expect(seen).toHaveLength(0); + }); + + it('ignores a mouse, which has a right button of its own', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown', { pointerType: 'mouse' }); + await wait(HELD); + + expect(seen).toHaveLength(0); + }); + + it('swallows the click that ends the gesture, and only that one', async () => { + let clicks = 0; + + inner.addEventListener('click', () => { + clicks += 1; + }); + + press(inner, 'pointerdown'); + await wait(HELD); + press(inner, 'pointerup'); + inner.click(); + + expect(clicks).toBe(0); + + // The next tap is a tap: on a phone that is the user choosing an + // item in the menu that just opened, so eating it would make the + // gesture useless. + press(inner, 'pointerdown'); + press(inner, 'pointerup'); + inner.click(); + + expect(clicks).toBe(1); + }); + + it('stands down where the browser fires its own', async () => { + const seen = recordMenus(inner); + + press(inner, 'pointerdown'); + await wait(BRIEF); + // Chromium does this itself on touch; WebKit and the Android + // WebView vary, which is the whole reason both halves exist. A + // test cannot dispatch a *trusted* event, which is why the module + // tells its own apart by identity rather than by `isTrusted`. + inner.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + composed: true, + cancelable: true, + }), + ); + await wait(HELD); + + // One menu: the browser's. Not two. + expect(seen).toHaveLength(1); + }); +}); + +/** + * What plan 019 adds on top, and the one property that protects + * everything downstream: a long press nobody claims is unchanged. + */ +describe('a gesture is announced before it is acted on', () => { + beforeEach(() => { + uninstall = installTouchGestures(); + ({ host, inner } = mountRow()); + }); + + afterEach(() => { + uninstall?.(); + uninstall = null; + host.remove(); + }); + + it('does not synthesise a menu when the long press is claimed', async () => { + // This is the whole reason #63 could reassign the hold without + // touching one of the fourteen context menus: the lists that want + // selection mode claim it, and nothing else changes. + const menus = recordMenus(inner); + const presses: unknown[] = []; + + inner.addEventListener('yj-long-press', (e) => { + presses.push(e); + e.preventDefault(); + }); + + press(inner, 'pointerdown'); + await wait(HELD); + + expect(presses).toHaveLength(1); + expect(menus).toHaveLength(0); + }); + + it('announces a tap on the element touched, at the touch point', async () => { + const taps: { target: EventTarget | null; x: number; y: number }[] = []; + + inner.addEventListener('yj-tap', (e) => { + taps.push({ target: e.target, x: e.detail.x, y: e.detail.y }); + }); + + press(inner, 'pointerdown'); + await wait(BRIEF); + press(inner, 'pointerup'); + + expect(taps).toHaveLength(1); + // The row, not its shadow host -- the difference between a + // delegated handler firing and a per-row one never firing. + expect(taps[0]?.target).toBe(inner); + expect([taps[0]?.x, taps[0]?.y]).toEqual([40, 60]); + }); + + it('lets an unclaimed tap through as an ordinary click', async () => { + // Every button, link and checkbox in the app depends on this. Only + // a *claimed* tap has its click swallowed. + let clicks = 0; + + inner.addEventListener('yj-tap', () => { + /* seen, not claimed */ + }); + inner.addEventListener('click', () => { + clicks += 1; + }); + + press(inner, 'pointerdown'); + await wait(BRIEF); + press(inner, 'pointerup'); + inner.click(); + + expect(clicks).toBe(1); + }); + + it('swallows the click behind a claimed tap', async () => { + // Or playing a track would also select it, and the row would end + // up in both states at once. + let clicks = 0; + + inner.addEventListener('yj-tap', (e) => e.preventDefault()); + inner.addEventListener('click', () => { + clicks += 1; + }); + + press(inner, 'pointerdown'); + await wait(BRIEF); + press(inner, 'pointerup'); + inner.click(); + + expect(clicks).toBe(0); + }); + + it('does not announce a tap for a press that became a long press', async () => { + // A hold is one gesture, not a hold and then a tap on release. + const taps: unknown[] = []; + + inner.addEventListener('yj-tap', (e) => taps.push(e)); + + press(inner, 'pointerdown'); + await wait(HELD); + press(inner, 'pointerup'); + + expect(taps).toHaveLength(0); + }); + + it('does not announce a tap for a press that drifted', async () => { + // A drifted press is a scroll, and the virtualizer's -- not a tap + // that happened to move. This is the one that would make a list + // unscrollable if it were wrong. + const taps: unknown[] = []; + + inner.addEventListener('yj-tap', (e) => taps.push(e)); + + press(inner, 'pointerdown'); + press(inner, 'pointermove', { + clientX: 40, + clientY: 60 + MOVE_TOLERANCE_PX + 20, + }); + press(inner, 'pointerup'); + + expect(taps).toHaveLength(0); + }); + + it('ignores a mouse entirely, for both gestures', async () => { + // plan 019 decision 1: the predicate is the pointer, per event. A + // mouse on a touchscreen keeps click-selects / double-click-plays + // on the very same row, which no viewport width can express. + const seen: unknown[] = []; + + inner.addEventListener('yj-tap', (e) => seen.push(e)); + inner.addEventListener('yj-long-press', (e) => seen.push(e)); + + press(inner, 'pointerdown', { pointerType: 'mouse' }); + await wait(BRIEF); + press(inner, 'pointerup', { pointerType: 'mouse' }); + press(inner, 'pointerdown', { pointerType: 'mouse' }); + await wait(HELD); + + expect(seen).toHaveLength(0); + }); +}); + +/** + * The browser's own long press is a trigger, not a competitor. + * + * This is a device-only defect made checkable here. `long-press.ts` + * stood down when a trusted `contextmenu` arrived mid-press, which was + * right while both paths ended in a context menu. Once a hold can mean + * *selection mode*, standing down means the gesture silently does the + * old thing — and Chrome 113's Android WebView does fire its own, so + * on the reference device `yj-long-press` was never announced at all + * while every test in this tier passed. + * + * A test cannot dispatch a *trusted* event, which is exactly why the + * module tells its own apart by identity rather than by `isTrusted`: + * an untrusted one dispatched from here takes the same path the + * browser's does. + */ +describe("the browser's own long press", () => { + beforeEach(() => { + uninstall = installTouchGestures(); + ({ host, inner } = mountRow()); + }); + + afterEach(() => { + uninstall?.(); + uninstall = null; + host.remove(); + }); + + /** Stand in for the browser recognising the hold itself. */ + function browserContextMenu(el: EventTarget): MouseEvent { + const e = new MouseEvent('contextmenu', { + bubbles: true, + composed: true, + cancelable: true, + }); + + el.dispatchEvent(e); + + return e; + } + + it('announces the gesture rather than standing down', async () => { + const presses: unknown[] = []; + + inner.addEventListener('yj-long-press', (e) => { + presses.push(e); + e.preventDefault(); + }); + + press(inner, 'pointerdown'); + await wait(BRIEF); + browserContextMenu(inner); + await wait(HELD); + + expect(presses, 'the hold reached the component').toHaveLength(1); + }); + + it('suppresses its menu when a component claims the gesture', async () => { + const menus = recordMenus(inner); + + inner.addEventListener('yj-long-press', (e) => e.preventDefault()); + + press(inner, 'pointerdown'); + await wait(BRIEF); + browserContextMenu(inner); + await wait(HELD); + + // The component wants selection mode, so the menu must not also + // open -- otherwise the device shows both at once. + expect(menus).toHaveLength(0); + }); + + it('still opens exactly one menu when nobody claims it', async () => { + // The old behaviour, reached by asking instead of assuming. This + // is what leaves the card grids, Explore and the playlist rows + // untouched by #63. + const menus = recordMenus(inner); + + press(inner, 'pointerdown'); + await wait(BRIEF); + browserContextMenu(inner); + await wait(HELD); + + expect(menus).toHaveLength(1); + }); +}); diff --git a/frontend/test/components/touch-selection.test.ts b/frontend/test/components/touch-selection.test.ts new file mode 100644 index 0000000..29351df --- /dev/null +++ b/frontend/test/components/touch-selection.test.ts @@ -0,0 +1,279 @@ +/** + * What a finger does to a track list (plan 019, #63). + * + * The desktop semantics being diverged from are real and stay: a click + * selects, a double-click plays. A finger has no second button and no + * modifier keys, so the primary action has to be the primary gesture — + * and the inversion is decided **per event**, off `pointerType`, not + * off a viewport width or a platform flag (plan 019, decision 1). + * + * That is what these assert: the same row, in the same component, at + * the same width, answering a mouse one way and a finger the other. + * + * There is deliberately no double-tap. Measured on the reference + * device, playing a track is ~100ms end to end, and a double-tap + * discriminator has to hold every tap for the app's own + * `DOUBLE_CLICK_GRACE_MS` of 250 before it can act — 3.5x the primary + * interaction, to reach a menu a long press already reaches. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import '@components/track-list/track-list'; +import '@components/selection-bar/selection-bar'; + +import { calls, flush, resetHarness, stub } from '@test/support/harness'; +import { fixture, shadow, shadowAll } from '@test/support/render'; +import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures'; + +const HELD = LONG_PRESS_MS + 120; +const BRIEF = Math.round(LONG_PRESS_MS / 4); +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)]; + +/** Dispatch a pointer event as a finger would produce it. */ +function press(el: EventTarget, type: string, init: PointerEventInit = {}) { + el.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + composed: true, + cancelable: true, + pointerType: 'touch', + isPrimary: true, + clientX: 40, + clientY: 60, + ...init, + }), + ); +} + +/** A whole finger tap: down, a moment, up. */ +async function tap(el: EventTarget) { + press(el, 'pointerdown'); + await wait(BRIEF); + press(el, 'pointerup'); + await wait(0); +} + +/** A finger held still until the gesture resolves. */ +async function hold(el: EventTarget) { + press(el, 'pointerdown'); + await wait(HELD); + press(el, 'pointerup'); + await wait(0); +} + +async function mountList() { + const el = await fixture('track-list'); + + (el as unknown as { tracks: unknown[] }).tracks = TRACKS; + await flush(); + await el.updateComplete; + await wait(60); + await el.updateComplete; + + return el; +} + +function rows(el: HTMLElement): HTMLElement[] { + return shadowAll(el, '.track-row'); +} + +describe('a finger on 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.AddTracksToQueue', null); + uninstall = installTouchGestures(); + }); + + afterEach(() => { + uninstall?.(); + uninstall = null; + vi.restoreAllMocks(); + }); + + it('plays the row it taps, rather than selecting it', async () => { + const el = await mountList(); + const row = rows(el)[1]; + + expect(row, 'the list rendered rows').toBeTruthy(); + + await tap(row!); + await flush(); + + const queued = calls('queue.Queue.SetQueue'); + + expect(queued.length, 'a tap plays').toBe(1); + + // From that row, in the list as displayed -- not a queue of one + // that stops when the song ends. + expect(queued[0]?.args[1]).toBe(1); + expect((queued[0]?.args[0] as string[]).length).toBe(TRACKS.length); + }); + + it('leaves the same row to a mouse, which still selects', async () => { + // The inversion is per event, so one component answers both + // pointers at the same width. A viewport rule cannot say this. + const el = await mountList(); + const row = rows(el)[1]; + + row!.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }), + ); + await el.updateComplete; + + expect(calls('queue.Queue.SetQueue').length, 'a click does not play').toBe(0); + expect(rows(el)[1]?.getAttribute('aria-selected')).toBe('true'); + }); + + it('enters selection mode on a long press, with that row selected', async () => { + const el = await mountList(); + + await hold(rows(el)[2]!); + await el.updateComplete; + + const bar = shadow(el, 'selection-bar'); + + expect(bar, 'the action bar appears').toBeTruthy(); + expect((bar as unknown as { count: number }).count).toBe(1); + expect(rows(el)[2]?.getAttribute('aria-selected')).toBe('true'); + }); + + it('does not open a context menu when it enters the mode', async () => { + // The gesture is claimed, so the layer must not fall through to + // the synthetic `contextmenu` that every other surface still gets. + const el = await mountList(); + + await hold(rows(el)[0]!); + await el.updateComplete; + + const menu = shadow(el, 'menu-surface'); + + expect((menu as unknown as { active?: boolean } | null)?.active ?? false).toBe( + false, + ); + }); + + it('toggles rows while the mode is on, instead of playing them', async () => { + const el = await mountList(); + + await hold(rows(el)[0]!); + await el.updateComplete; + await tap(rows(el)[2]!); + await el.updateComplete; + + expect(calls('queue.Queue.SetQueue').length, 'no track was played').toBe(0); + expect( + (shadow(el, 'selection-bar') as unknown as { count: number }).count, + ).toBe(2); + }); + + it('leaves the mode when the last row is deselected', async () => { + // Android's own lists do this, and here it matters more than + // convention: the mode changes what a tap means, so a mode holding + // nothing is a list where tapping does nothing and the bar that + // would explain it is showing a count of zero. + const el = await mountList(); + + await hold(rows(el)[0]!); + await el.updateComplete; + await tap(rows(el)[0]!); + await el.updateComplete; + + expect(shadow(el, 'selection-bar')).toBeFalsy(); + }); + + it('keeps the favourite icon a favourite icon', async () => { + // It is inside a row whose tap now plays, and it has been a 44px + // target since #56 -- so without the "a control inside the row + // owns its own tap" rule, that target silently becomes a second + // play button. + const el = await mountList(); + const fav = rows(el)[1]?.querySelector('.fav-icon'); + + expect(fav, 'a row renders a favourite control').toBeTruthy(); + + await tap(fav!); + await flush(); + + expect(calls('queue.Queue.SetQueue').length, 'tapping it does not play').toBe( + 0, + ); + }); + + it('does not play a row the finger scrolled from', async () => { + // The failure this exists for makes the list unusable rather than + // merely wrong: every flick to scroll would start a track. + const el = await mountList(); + const row = rows(el)[1]; + + press(row!, 'pointerdown'); + press(row!, 'pointermove', { clientX: 40, clientY: 200 }); + press(row!, 'pointerup'); + await flush(); + + expect(calls('queue.Queue.SetQueue').length).toBe(0); + }); +}); + +describe('', () => { + it('renders nothing with nothing selected', async () => { + const el = await fixture('selection-bar', { count: 0 }); + + expect(shadow(el, '.bar')).toBeFalsy(); + }); + + it('announces the count, which changes under the finger', async () => { + const el = await fixture('selection-bar', { count: 3, actions: [] }); + const live = shadow(el, '[aria-live="polite"]'); + + expect(live?.textContent?.trim()).toContain('3 tracks selected'); + }); + + it('names one track in the singular', async () => { + const el = await fixture('selection-bar', { count: 1, actions: [] }); + + expect(shadow(el, '[aria-live="polite"]')?.textContent?.trim()).toContain( + '1 track selected', + ); + }); + + it('keeps every control at the touch floor', async () => { + // #56 and #186. A bar a thumb uses, in the one mode that only a + // thumb can enter. + const el = await fixture('selection-bar', { + count: 2, + actions: [{ id: 'play', label: 'Play', icon: 'play' }], + }); + + const buttons = shadowAll(el, 'button'); + + expect(buttons.length).toBeGreaterThan(0); + + for (const button of buttons) { + const box = button.getBoundingClientRect(); + + expect( + Math.min(Math.round(box.width), Math.round(box.height)), + button.getAttribute('aria-label') ?? '', + ).toBeGreaterThanOrEqual(44); + } + }); +});