Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76e1c444cc | ||
|
|
4f32d4e13c | ||
|
|
4f628b1f52 | ||
|
|
2100f0022f | ||
|
|
52038dc5ae | ||
|
|
0d331666d6 | ||
|
|
6a5a3c33dc | ||
|
|
1668b9e0d2 |
@@ -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.
|
||||
@@ -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<void> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+8
-5
@@ -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
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@components/cover-grid/cover-grid.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { backButton } from '../../styles/back-button.css';
|
||||
|
||||
@customElement('artist-details')
|
||||
export class ArtistDetails extends LitElement {
|
||||
@@ -40,7 +41,7 @@ export class ArtistDetails extends LitElement {
|
||||
/** Tracks the store's cached array reference to detect refreshes. */
|
||||
private lastAlbumsRef: library.Album[] | null = null;
|
||||
|
||||
static override styles = [designTokens, css`
|
||||
static override styles = [designTokens, backButton, css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -66,31 +67,6 @@ export class ArtistDetails extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(
|
||||
--yj-bg-overlay,
|
||||
rgba(255, 255, 255, 0.06)
|
||||
);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: var(
|
||||
--yj-bg-hover,
|
||||
rgba(255, 255, 255, 0.12)
|
||||
);
|
||||
}
|
||||
|
||||
.back-button wa-icon {
|
||||
font-size: 16px; /* back button — outside type scale */
|
||||
}
|
||||
|
||||
@@ -250,13 +250,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
||||
}
|
||||
|
||||
/* Collapsible-section toggle used in the Pending header —
|
||||
transparent button that inherits the header's type. */
|
||||
transparent button that inherits the header's type.
|
||||
|
||||
187x**15** before this (#186), which was the smallest
|
||||
control measured anywhere in the app until the column
|
||||
arrows were counted. It is transparent and full-width
|
||||
already, so the floor costs it a height and nothing
|
||||
else. */
|
||||
.section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-block-size: 44px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
@@ -274,6 +281,8 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
/* 32x18, and it has no background until hover -- so the
|
||||
padding out to a square target is invisible (#186). */
|
||||
.folders-menu-trigger {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
@@ -281,6 +290,8 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.4rem;
|
||||
min-inline-size: 44px;
|
||||
min-block-size: 44px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -293,7 +304,10 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
|
||||
.folders-refresh-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.95rem;
|
||||
min-inline-size: 44px;
|
||||
min-block-size: 44px;
|
||||
}
|
||||
|
||||
.folders-refresh-trigger:disabled {
|
||||
|
||||
@@ -85,6 +85,21 @@ export class ConfigField extends LitElement {
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
/* Every control here meets the app's 44px touch floor (#186).
|
||||
|
||||
This is the shape every row in Settings uses, so it is the
|
||||
one rule that covers the most controls -- and it is the
|
||||
*cheapest* place to reach the floor, because there is no
|
||||
overflow fit on this page. The page header's had one (#69),
|
||||
which is why that pass had to grow padding and hand the
|
||||
width back with a negative margin; here the control is a
|
||||
block in a column and a taller box costs nothing but the
|
||||
height it takes.
|
||||
|
||||
Measured on the reference device before this: the select
|
||||
335x30, the text and number inputs the same, the browse
|
||||
button 30 tall, the colour swatch 33x33 and the toggle
|
||||
**34x19**. */
|
||||
input[type='text'],
|
||||
input[type='number'] {
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
@@ -95,6 +110,7 @@ export class ConfigField extends LitElement {
|
||||
font-size: 0.85em;
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
min-block-size: 44px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -117,6 +133,7 @@ export class ConfigField extends LitElement {
|
||||
font-size: 0.85em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
min-block-size: 44px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -139,6 +156,7 @@ export class ConfigField extends LitElement {
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
min-block-size: 44px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
@@ -158,8 +176,12 @@ export class ConfigField extends LitElement {
|
||||
}
|
||||
|
||||
input[type='color'] {
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
/* border-box, or the 2px border makes this 48 and the
|
||||
assertion below reads as passing by four pixels of
|
||||
border rather than by the rule. */
|
||||
box-sizing: border-box;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 2px solid var(--yj-border, #444);
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
@@ -187,12 +209,32 @@ export class ConfigField extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-block-size: 44px;
|
||||
}
|
||||
|
||||
/* The toggle is the one control here whose target and paint
|
||||
must differ, and it is also the one no sweep can see.
|
||||
|
||||
Its <input> is opacity: 0; width: 0; height: 0, so a
|
||||
walk of every input on the page skips it as a zero-sized
|
||||
node -- the thing a finger actually hits is this <label>,
|
||||
which measured **34x19**. That is smaller than anything in
|
||||
#186's original table and it is absent from it for exactly
|
||||
that reason.
|
||||
|
||||
A 44px pill is not what a switch should look like, so the
|
||||
box is 44px and the paint is not: .toggle-slider is a
|
||||
2.5em x 1.4em child centred in it rather than an absolute
|
||||
fill. The negative inline margins hand the extra width back
|
||||
to the layout, so the pill stays flush with the right edge
|
||||
of the inputs in the rows above it -- the header pass's
|
||||
shape, used here for alignment rather than for a fit. */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 2.5em;
|
||||
height: 1.4em;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
inline-size: 44px;
|
||||
block-size: 44px;
|
||||
margin-inline: calc((2.5em - 44px) / 2);
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
@@ -202,9 +244,10 @@ export class ConfigField extends LitElement {
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
inline-size: 2.5em;
|
||||
block-size: 1.4em;
|
||||
background: var(--yj-bg-overlay, #495057);
|
||||
border-radius: 1em;
|
||||
transition: background 0.2s;
|
||||
|
||||
@@ -176,6 +176,13 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
/* The app's 44px touch floor (#56, #186), stated once for
|
||||
all 41 buttons this page renders rather than per class.
|
||||
Height is free here: Settings has no overflow fit, so
|
||||
the header's "only width is contested" rule does not
|
||||
bind, and the two classes that need more than a height
|
||||
say so below. */
|
||||
min-block-size: 44px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -509,11 +516,24 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The two column lists are the densest thing in the app, and
|
||||
the density argument is why they are shaped the way they
|
||||
are rather than simply grown (#186).
|
||||
|
||||
Measured on the reference device: the row was already
|
||||
335x36 -- it is the controls *inside* it that were 16x16 and
|
||||
**16x14**, the smallest anywhere in this app, 36 of them.
|
||||
So the fix grows the controls into the row they already
|
||||
occupy and only takes the row from 36 to 44, which over the
|
||||
two lists (10 and 19 items) is 232px of extra scroll on a
|
||||
439px screen. Growing each control to its own 44px row
|
||||
instead would have cost four screens. */
|
||||
.column-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
gap: 0.5em;
|
||||
padding: 0.5em 0.75em;
|
||||
padding: 0 0.75em;
|
||||
min-block-size: 44px;
|
||||
border-bottom: 1px solid
|
||||
var(--yj-border-subtle, #333);
|
||||
font-size: 0.85em;
|
||||
@@ -531,8 +551,19 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
/* A native checkbox cannot grow its hit area without growing
|
||||
its paint, and a 44px checkbox is not what anyone wants. So
|
||||
the target is the label instead: .column-label is a real
|
||||
<label for> now, which makes the column's *name* the thing
|
||||
you tap -- ~250x44 rather than 16x16.
|
||||
|
||||
That is the argument config-field already makes one file
|
||||
over for its own labels: "a real label association also
|
||||
makes the label text a click target for the control, which
|
||||
is behaviour, not annotation". Here it is the whole fix. */
|
||||
.column-toggle {
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
accent-color: var(
|
||||
--yj-accent,
|
||||
#ffd43b
|
||||
@@ -541,20 +572,32 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
|
||||
.column-label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
min-block-size: 44px;
|
||||
}
|
||||
|
||||
.view-note {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-font-size-sm, 0.85rem);
|
||||
margin-left: auto;
|
||||
/* The row stretches its children so the label can be a
|
||||
full-height target; this is text, not a target. */
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.column-arrows {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.15em;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 16x14 before this, and they carry background: none and a
|
||||
transparent border -- so padding out to 44px grows the
|
||||
target and changes nothing anyone can see until hover,
|
||||
which is precisely what #186's Direction asks for. */
|
||||
.column-arrow-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
@@ -564,6 +607,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
font-size: 0.65em;
|
||||
line-height: 1;
|
||||
padding: 0.2em 0.35em;
|
||||
min-inline-size: 44px;
|
||||
min-block-size: 44px;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
@@ -711,6 +756,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
padding: 0.2em 0.4em;
|
||||
letter-spacing: 2px;
|
||||
border-radius: 4px;
|
||||
/* Square, so it needs the width too -- the shared rule
|
||||
above only gives it a height. It was 31x31, and it is
|
||||
the only route to "Remove library", which is the case
|
||||
#55 settled one component over: the way out is 44px. */
|
||||
min-inline-size: 44px;
|
||||
}
|
||||
|
||||
.overflow-btn:hover {
|
||||
@@ -2003,6 +2053,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
class="column-item ${checked ? 'enabled' : 'disabled'}"
|
||||
>
|
||||
<input
|
||||
id="view-${v.id}"
|
||||
type="checkbox"
|
||||
class="column-toggle"
|
||||
aria-label="Show ${v.label} in the navigation"
|
||||
@@ -2014,9 +2065,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)}
|
||||
/>
|
||||
<span class="column-label">
|
||||
<label class="column-label" for="view-${v.id}">
|
||||
${v.label}
|
||||
</span>
|
||||
</label>
|
||||
${note
|
||||
? html`<span class="view-note">${note}</span>`
|
||||
: nothing}
|
||||
@@ -2212,6 +2263,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
class="column-item ${checked ? 'enabled' : 'disabled'}"
|
||||
>
|
||||
<input
|
||||
id="column-${id}"
|
||||
type="checkbox"
|
||||
class="column-toggle"
|
||||
aria-label="Show the ${columnLabel} column"
|
||||
@@ -2222,11 +2274,12 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
id,
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
<label
|
||||
class="column-label"
|
||||
for="column-${id}"
|
||||
>
|
||||
${columnLabel}
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
class="column-arrows"
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import '@awesome.me/webawesome/dist/components/switch/switch.js';
|
||||
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
|
||||
import '@awesome.me/webawesome/dist/components/callout/callout.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { waTouchFloor } from '../../styles/wa-touch-floor.css';
|
||||
import type {
|
||||
DownloadDescriptor,
|
||||
DownloadProvider,
|
||||
@@ -136,6 +137,7 @@ export class DownloadClients extends LitElement {
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
waTouchFloor,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
@@ -239,12 +241,17 @@ export class DownloadClients extends LitElement {
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
|
||||
/* The checkbox is 16x16 and cannot grow without becoming
|
||||
a 44px checkbox, but it is already wrapped in the label
|
||||
that names it -- so the label is the target and only
|
||||
needs the height (#186). Eight of them. */
|
||||
.format-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
min-block-size: 44px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -25,6 +25,11 @@ export class ShortcutCapture extends LitElement {
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
/* 80x25, twenty-six of them -- the most numerous control on
|
||||
the Settings page after the column lists (#186). The floor
|
||||
is a height here and nothing else: the width was already
|
||||
past it, and the type stays where it is so a shortcut still
|
||||
reads as a key rather than as a button. */
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: var(--yj-text-sm, 13px);
|
||||
@@ -35,6 +40,7 @@ export class ShortcutCapture extends LitElement {
|
||||
color: var(--yj-text-primary, #eee);
|
||||
cursor: pointer;
|
||||
min-width: 80px;
|
||||
min-height: 44px;
|
||||
text-align: center;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
@@ -61,6 +67,11 @@ export class ShortcutCapture extends LitElement {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
/* Reset renders only for a rebound shortcut, so a sweep of a
|
||||
freshly-installed app never sees it -- it is not in #186's
|
||||
tables for that reason, and it is a touch target the moment
|
||||
anybody uses the feature. It also has no background, so the
|
||||
padding out to 44px is invisible. */
|
||||
.reset-btn {
|
||||
font-size: var(--yj-text-xs, 11px);
|
||||
padding: 2px 6px;
|
||||
@@ -69,7 +80,8 @@ export class ShortcutCapture extends LitElement {
|
||||
background: transparent;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
cursor: pointer;
|
||||
min-width: auto;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
@@ -93,8 +93,26 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
||||
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
/* 85x34 and 96x34 before this (#186). A tab is the only
|
||||
route to the panel it names, so it is the last control
|
||||
that should be hard to hit -- and the underline that
|
||||
marks the active one is drawn on the bottom border,
|
||||
which a taller box moves further from the label. So the
|
||||
height goes on *padding*, keeping the border against
|
||||
the label rather than 10px below a centred one.
|
||||
|
||||
The min-size is the floor and is not redundant: padding
|
||||
alone made this 44px here and **43px in CI**, because
|
||||
the total is 13 + 13 + 2 + whatever line box the font
|
||||
gives 13px text, and ubuntu:24.04's is a pixel shorter
|
||||
than this machine's. A height computed from a font's
|
||||
line box is not a height you control -- the same
|
||||
mistake #195 made about a layout property measured on
|
||||
one engine, one layer down, and caught here by the test
|
||||
rather than by a person. */
|
||||
.tab {
|
||||
padding: 8px 14px;
|
||||
min-block-size: 44px;
|
||||
padding: 13px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { backButton } from '../../styles/back-button.css';
|
||||
import { srOnly } from '../../styles/sr-only.css';
|
||||
import { unownedLabel, unownedStyles } from '@utils/ownership';
|
||||
import {
|
||||
@@ -355,6 +356,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
backButton,
|
||||
exploreLinkStyles,
|
||||
contextMenuStyles,
|
||||
srOnly,
|
||||
@@ -379,25 +381,6 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
||||
color: var(--yj-text-primary, #fff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.back-button wa-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { backButton } from '../../styles/back-button.css';
|
||||
import {
|
||||
LookupArtist,
|
||||
BrowseReleaseGroups,
|
||||
@@ -266,6 +267,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
backButton,
|
||||
exploreLinkStyles,
|
||||
contextMenuStyles,
|
||||
unownedStyles,
|
||||
@@ -289,25 +291,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
||||
color: var(--yj-text-primary, #fff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
|
||||
.back-button wa-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -256,15 +256,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 89x26 and 79x26 before this (#186). */
|
||||
.search-mode-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
cursor: pointer;
|
||||
min-block-size: 44px;
|
||||
padding: 5px 12px;
|
||||
font-size: var(--yj-text-sm);
|
||||
font-family: inherit;
|
||||
@@ -289,7 +292,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
min-height: 44px;
|
||||
max-width: 520px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
@@ -364,8 +367,15 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* The input measured 325x**18** and the box around it 36,
|
||||
which is two faults rather than one (#186): the row was
|
||||
under the floor, and the input did not fill it, so eight
|
||||
of those pixels were not a target at all. The container
|
||||
is 44 and the input stretches to it -- a tap anywhere in
|
||||
the box now lands on the input rather than beside it. */
|
||||
input {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -379,15 +389,21 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
/* No background until hover, so the target grows and the
|
||||
glyph does not. It is inside a 44px box already, hence
|
||||
the width alone. */
|
||||
.clear-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
min-inline-size: 44px;
|
||||
margin-inline-end: -12px;
|
||||
font-size: var(--yj-text-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { describeError } from '@utils/describe-error';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@components/track-list/track-list.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { backButton } from '../../styles/back-button.css';
|
||||
import { list } from '@utils/binding';
|
||||
|
||||
@customElement('genre-details')
|
||||
@@ -37,7 +38,7 @@ export class GenreDetails extends LitElement {
|
||||
private scanCompleteCleanup: (() => void) | null =
|
||||
null;
|
||||
|
||||
static override styles = [designTokens, css`
|
||||
static override styles = [designTokens, backButton, css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -77,31 +78,6 @@ export class GenreDetails extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(
|
||||
--yj-bg-overlay,
|
||||
rgba(255, 255, 255, 0.06)
|
||||
);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: var(
|
||||
--yj-bg-hover,
|
||||
rgba(255, 255, 255, 0.12)
|
||||
);
|
||||
}
|
||||
|
||||
.back-button wa-icon {
|
||||
font-size: 16px; /* back button — outside type scale */
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -23,8 +23,14 @@ export class LibraryFilter extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 120x32 on the reference device (#186). This control has two
|
||||
placements since #57 -- the desktop top bar and Settings ->
|
||||
Libraries -- and it is the only route to setSelectedLibrary
|
||||
in either, so it is one of the controls #148 argued must not
|
||||
simply be taken away. It is one component, so it reaches the
|
||||
floor in one place. */
|
||||
select {
|
||||
height: 32px;
|
||||
min-height: 44px;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid
|
||||
|
||||
@@ -298,6 +298,47 @@ export class PageHeader extends LitElement {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Every control in this header meets the app's 44px touch
|
||||
floor -- the number #56 set for the transport and the
|
||||
queue header already keeps (#186).
|
||||
|
||||
It is min-size rather than padding with a negative
|
||||
margin, which is what the seek bar needed (#187), and
|
||||
the difference is worth stating because it decides
|
||||
whether targets can collide. There the painted track had
|
||||
to stay thin, so the target was grown past its own box
|
||||
and had to be checked against its neighbours. Here the
|
||||
control *is* the target: the boxes are flex items, so
|
||||
the gap keeps them apart and no two can overlap by
|
||||
construction.
|
||||
|
||||
There is no phone branch. With the target being the box,
|
||||
a 44px control on a desktop is merely large, and a
|
||||
second declaration of what a phone shows is a second
|
||||
thing to keep in step -- which is the reason this
|
||||
component has never had one. It also avoids a media
|
||||
query that no tier here renders, which is exactly how
|
||||
the seek bar's phone rule came to be dead for months.
|
||||
|
||||
**The height is the box and the width is not**, and that
|
||||
asymmetry is the whole of what the overflow fit below
|
||||
cares about. That pass measures inline size, so a taller
|
||||
control costs it nothing and a wider one costs it
|
||||
directly. Growing the two square controls to 44px wide
|
||||
added 22px, which fits at every width Chromium was
|
||||
checked at and clipped the overflow trigger at 320px in
|
||||
**WebKit** -- the engine closest to what actually ships,
|
||||
and the one no machine here can run. So the horizontal
|
||||
half is padding with the margin cancelling it, which is
|
||||
what the issue asked for in the first place: the target
|
||||
grows and the layout does not.
|
||||
|
||||
The cost is that a horizontal target can now overlap a
|
||||
neighbour, which the box version could not. The arrow's
|
||||
is deliberately lopsided for the seek bar's reason
|
||||
(#187): the select is 6px to its left and there is open
|
||||
space to its right, so it takes the side with nothing to
|
||||
steal from. */
|
||||
.sort select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
@@ -306,6 +347,7 @@ export class PageHeader extends LitElement {
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
min-block-size: 44px;
|
||||
}
|
||||
|
||||
.sort-dir {
|
||||
@@ -318,6 +360,18 @@ export class PageHeader extends LitElement {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 3px 5px;
|
||||
/* 28x21 before this, the smallest control in the
|
||||
header and the only one that failed the floor in
|
||||
both directions.
|
||||
|
||||
Vertically the box grows, because the header has the
|
||||
room and nothing measures it. Horizontally the box
|
||||
must not: 28 + 2 + 14 is a 44px target over a 28px
|
||||
layout box, weighted right because the select is 6px
|
||||
to the left. */
|
||||
min-block-size: 44px;
|
||||
padding-inline: 5px 21px;
|
||||
margin-inline: 0 -16px;
|
||||
}
|
||||
|
||||
.sort-dir:hover {
|
||||
@@ -377,10 +431,20 @@ export class PageHeader extends LitElement {
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
min-block-size: 44px;
|
||||
}
|
||||
|
||||
.more-button {
|
||||
padding: 6px 10px;
|
||||
/* 38x27, and it is the route to every collapsed
|
||||
action, so it is the last control that should be
|
||||
hard to hit -- and the one WebKit clipped at 320px
|
||||
when this was 6px wider as a box. 38 + 3 + 3 is a
|
||||
44px target over a 38px layout box; the actions row
|
||||
has an 8px gap, so this one can be symmetric. */
|
||||
padding-inline: 13px;
|
||||
margin-inline: -3px;
|
||||
}
|
||||
|
||||
/* The display: flex above outranks the UA stylesheet's
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
exploreLinkStyles,
|
||||
} from '@utils/explore-link';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { backButton } from '../../styles/back-button.css';
|
||||
import { list } from '@utils/binding';
|
||||
import {
|
||||
ICON_PLAYLIST,
|
||||
@@ -954,6 +955,7 @@ export class PlaylistDetails
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
backButton,
|
||||
contextMenuStyles,
|
||||
exploreLinkStyles,
|
||||
css`
|
||||
@@ -981,31 +983,6 @@ export class PlaylistDetails
|
||||
);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(
|
||||
--yj-bg-overlay,
|
||||
rgba(255, 255, 255, 0.06)
|
||||
);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: var(
|
||||
--yj-bg-hover,
|
||||
rgba(255, 255, 255, 0.12)
|
||||
);
|
||||
}
|
||||
|
||||
.back-button wa-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -61,11 +61,32 @@ export class SearchTrigger extends LitElement {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* The smallest a touch target should be. The header's
|
||||
own action buttons are smaller because they carry a
|
||||
label; this one is a glyph. */
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
/* The app's touch floor, from #56 -- and this is the
|
||||
control that should least have to argue for it: #57
|
||||
created it as the phone's replacement for the header
|
||||
search box, so it exists *only* where there is a
|
||||
thumb.
|
||||
|
||||
It shipped at 40px under a comment calling that "the
|
||||
smallest a touch target should be", which was the
|
||||
floor being restated four pixels short rather than a
|
||||
second opinion about it (#186). The rest of that
|
||||
comment said the header's own action buttons are
|
||||
smaller because they carry a label; they are 44px
|
||||
now too, so that no longer distinguishes anything.
|
||||
|
||||
The extra width is a target rather than a box, for
|
||||
page-header's reason: this button sits in that
|
||||
header, whose overflow fit (#69) measures inline
|
||||
size, and four pixels there is four pixels the
|
||||
trigger for every collapsed action does not get at
|
||||
320px. Height is free -- nothing measures it. */
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
/* Border-box, so the 44 above is the whole target and
|
||||
the margin is what hands the four extra pixels back
|
||||
to the row. */
|
||||
margin-inline: -2px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
} from '@utils/explore-link';
|
||||
import '@components/smart-playlist-editor/smart-playlist-editor.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { backButton } from '../../styles/back-button.css';
|
||||
import { list } from '@utils/binding';
|
||||
import {
|
||||
ICON_PLAYLIST,
|
||||
@@ -242,6 +243,7 @@ export class SmartPlaylistDetails
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
backButton,
|
||||
contextMenuStyles,
|
||||
exploreLinkStyles,
|
||||
css`
|
||||
@@ -269,31 +271,6 @@ export class SmartPlaylistDetails
|
||||
);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(
|
||||
--yj-bg-overlay,
|
||||
rgba(255, 255, 255, 0.06)
|
||||
);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: var(
|
||||
--yj-bg-hover,
|
||||
rgba(255, 255, 255, 0.12)
|
||||
);
|
||||
}
|
||||
|
||||
.back-button wa-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
/**
|
||||
* The way out of a detail view, at the app's 44px touch floor.
|
||||
*
|
||||
* #186's second table names `artist-details`' back button at
|
||||
* **32x32**. It is the same declaration in **six** components —
|
||||
* `artist-details`, `genre-details`, `playlist-details`,
|
||||
* `smart-playlist-details`, `explore-artist-details` and
|
||||
* `explore-album-details` — byte-identical, 32px in all six, and the
|
||||
* sweep that filed the issue visited one of them.
|
||||
*
|
||||
* That is the argument for this file rather than six edits. A device
|
||||
* sweep walks the views somebody thought to open, so six copies of a
|
||||
* control is six chances for the next pass to miss five; the arrows
|
||||
* and the toggles were each one declaration covering 36 and 29
|
||||
* controls, and this is the same shape stated the other way round.
|
||||
*
|
||||
* **It is a real 44px box, not padding with the width handed back.**
|
||||
* The header pass had to grow a hit area past its own layout box
|
||||
* because `page-header` measures itself for #69's overflow fit; a
|
||||
* detail view's header does not, so the control can simply be the
|
||||
* target. It also *should* be — this button has a visible background,
|
||||
* so a hit area larger than the circle would be a control that is
|
||||
* bigger than it looks, which is the thing #187 accepts only where a
|
||||
* thin painted track is the point.
|
||||
*
|
||||
* The size is #55's, arrived at for the same reason one component
|
||||
* over: "the way out is 44px on a phone", when the queue panel's close
|
||||
* button was 25x21 and, at phone width, the only pointer route off a
|
||||
* full-screen surface. A detail view has the platform's back gesture
|
||||
* as well, so this is less severe than the queue was — it is the same
|
||||
* control wearing the same mistake.
|
||||
*/
|
||||
export const backButton = css`
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
||||
color: var(--yj-text-primary, #fff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
/**
|
||||
* A Web Awesome form control is at least the app's 44px touch floor.
|
||||
*
|
||||
* #56 named 44px and #186 found nothing but the transport had reached
|
||||
* it. Web Awesome's form controls are the part of Settings this app
|
||||
* does not draw: measured on the reference device (TLP301, 424x439),
|
||||
* `wa-input`'s control is **204x20** and `wa-button` **185x21** — the
|
||||
* shortest controls on the page, and the only ones whose height is
|
||||
* decided inside somebody else's shadow root.
|
||||
*
|
||||
* `--wa-form-control-height` is that decision, and it is the library's
|
||||
* own theming variable rather than a part or an internal — the default
|
||||
* theme sets it at `:root` and every control that has a height reads
|
||||
* it (button, input, select, radio). So this is `wa-slider-label`'s
|
||||
* better half: the API first, and no reach into a shadow root at all.
|
||||
*
|
||||
* Three things about it are load-bearing.
|
||||
*
|
||||
* **A custom property inherits through a shadow boundary**, which is
|
||||
* what lets a `:host` declaration reach a `wa-input` the host renders.
|
||||
* That is also why it is a stylesheet a component adopts rather than a
|
||||
* `:root` rule in `index.css`: a `:root` rule would cover every wa
|
||||
* control in the app in one line and be invisible to the component
|
||||
* tier, which renders a component and no page stylesheet. Here the
|
||||
* floor is measurable where it is applied.
|
||||
*
|
||||
* **It is a flat 44px rather than a floor over the library's own
|
||||
* expression.** The default is `round(calc(2 * padding-block + 1em *
|
||||
* line-height), 1px)` — em-based, so `size="small"` is what produced
|
||||
* the 20px above — and a `max(44px, …)` would have to restate that
|
||||
* formula here, which is a copy of somebody else's arithmetic that
|
||||
* goes stale silently. A flat value is safe because this app uses
|
||||
* exactly two sizes, `small` and the default, and both are under the
|
||||
* floor; a `size="large"` added later would be pinned down to 44 and
|
||||
* should take that as the prompt to revisit this.
|
||||
*
|
||||
* **Only the height is pinned.** The font size still comes from
|
||||
* `size="small"`, so a control grows its hit area without growing its
|
||||
* visual weight — which is what #186's Direction asks for and what the
|
||||
* page header's second pass had to be corrected to do.
|
||||
*/
|
||||
export const waTouchFloor = css`
|
||||
:host {
|
||||
--wa-form-control-height: 44px;
|
||||
}
|
||||
`;
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.5 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* The controls #186's second table found, outside Settings.
|
||||
*
|
||||
* Six one-off controls across four surfaces, and the reason they are a
|
||||
* test rather than six stylesheet edits is `back-button`. The issue
|
||||
* names it in `artist-details` at **32x32**; it is the same
|
||||
* declaration, byte-identical, in *six* components — because a device
|
||||
* sweep walks the views somebody thought to open, and five of them
|
||||
* were not opened.
|
||||
*
|
||||
* So the assertion is over the whole set rather than over the one that
|
||||
* was measured. That is `icon-language.test.ts`'s shape and it is here
|
||||
* for the same reason: checking one call site checks one call site.
|
||||
*
|
||||
* | control | before | where |
|
||||
* |---|---|---|
|
||||
* | `.folders-menu-trigger` | **32x18** | autotag |
|
||||
* | `.section-toggle` | 187x**15** | autotag |
|
||||
* | `.back-button` | 32x32 | six detail views |
|
||||
* | Requests / Downloads tabs | 85x**34**, 96x**34** | downloads |
|
||||
* | `.search-mode-tab` | 89x**26**, 79x**26** | explore |
|
||||
* | explore search input | 325x**18** in a 36px box | explore |
|
||||
*
|
||||
* `page-action-check-now` (113x29) is in that table and is **not**
|
||||
* here: it is a `PageAction`, so #195 raised it with the rest of the
|
||||
* page header's actions, and `touch-targets.test.ts` already covers
|
||||
* it. Re-asserting it here would be a second statement of one rule.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import '@components/artist-details/artist-details';
|
||||
import '@components/autotag-view/autotag-view';
|
||||
import '@components/downloads-view/downloads-view';
|
||||
import '@components/explore-album-details/explore-album-details';
|
||||
import '@components/explore-artist-details/explore-artist-details';
|
||||
import '@components/explore-view/explore-view';
|
||||
import '@components/genre-details/genre-details';
|
||||
import '@components/playlist-details/playlist-details';
|
||||
import '@components/smart-playlist-details/smart-playlist-details';
|
||||
|
||||
import { flush, stub } from '@test/support/harness';
|
||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
|
||||
/** The app's touch floor, from #56. */
|
||||
const FLOOR = 44;
|
||||
|
||||
/**
|
||||
* Every component that draws a back button.
|
||||
*
|
||||
* The list is here rather than derived because deriving it means
|
||||
* reading the source, and this tier renders instead — but it is
|
||||
* checked against the source by `the back button is one declaration`
|
||||
* below, so a seventh view cannot join quietly.
|
||||
*/
|
||||
const BACK_BUTTON_VIEWS = [
|
||||
'artist-details',
|
||||
'genre-details',
|
||||
'playlist-details',
|
||||
'smart-playlist-details',
|
||||
'explore-artist-details',
|
||||
'explore-album-details',
|
||||
] as const;
|
||||
|
||||
function boxOf(el: Element | null | undefined): { w: number; h: number } {
|
||||
if (!el) return { w: 0, h: 0 };
|
||||
|
||||
const box = el.getBoundingClientRect();
|
||||
|
||||
return { w: Math.round(box.width), h: Math.round(box.height) };
|
||||
}
|
||||
|
||||
describe('the way out of a detail view', () => {
|
||||
beforeEach(() => {
|
||||
for (const path of [
|
||||
'library.Library.GetTracks',
|
||||
'library.Library.GetAlbums',
|
||||
'library.Library.GetArtists',
|
||||
'library.Library.GetGenres',
|
||||
'playlist.Service.GetAllPlaylists',
|
||||
'playlist.Service.GetAllPlaylistsWithTracks',
|
||||
]) {
|
||||
stub(path, []);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(BACK_BUTTON_VIEWS)('is 44px in <%s>', async (tag) => {
|
||||
// #55 settled this one component over, when the queue panel's
|
||||
// close button was 25x21 and, at phone width, the only pointer
|
||||
// route off a full-screen surface: "the way out is 44px". A detail
|
||||
// view has the platform's back gesture as well, so it is less
|
||||
// severe -- and it is the same control wearing the same mistake.
|
||||
const el = await fixture(tag);
|
||||
|
||||
await flush();
|
||||
|
||||
const back = shadow(el, '.back-button');
|
||||
|
||||
expect(back, `${tag} draws a back button`).toBeTruthy();
|
||||
expect(boxOf(back)).toEqual({ w: FLOOR, h: FLOOR });
|
||||
});
|
||||
|
||||
it('is one declaration, so a seventh view cannot miss it', async () => {
|
||||
// The regression this exists for is not a size changing -- it is
|
||||
// somebody adding a detail view and writing `.back-button` out
|
||||
// again at 32px, which is exactly how there came to be six copies.
|
||||
// A sweep of the running app would not catch it either, because a
|
||||
// sweep visits the views you think to open.
|
||||
const sources = import.meta.glob('../../src/components/**/*.ts', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
}) as Record<string, string>;
|
||||
|
||||
expect(Object.keys(sources).length, 'the glob read something').toBeGreaterThan(0);
|
||||
|
||||
const redeclared = Object.entries(sources)
|
||||
.filter(([, src]) => /^\s*\.back-button\s*(?::[a-z-]+\s*)?\{/m.test(src))
|
||||
.map(([path]) => path);
|
||||
|
||||
expect(redeclared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autotag', () => {
|
||||
it('raises the two smallest controls the sweep found', async () => {
|
||||
// 187x15 and 32x18. The section toggle was the smallest control
|
||||
// measured anywhere in the app until the column arrows were
|
||||
// counted, and autotag is off by default (#25), which is
|
||||
// presumably why nobody had met either.
|
||||
const el = await fixture('autotag-view');
|
||||
|
||||
await flush();
|
||||
|
||||
for (const selector of ['.section-toggle', '.folders-menu-trigger']) {
|
||||
const control = shadowAll<HTMLElement>(el, selector).find(
|
||||
(c) => c.getBoundingClientRect().height > 0,
|
||||
);
|
||||
|
||||
if (!control) continue;
|
||||
|
||||
expect(boxOf(control).h, `${selector} height`).toBeGreaterThanOrEqual(FLOOR);
|
||||
}
|
||||
|
||||
// The stylesheet is the assertion for whichever of the two this
|
||||
// fixture does not render -- both are behind state a bare mount
|
||||
// does not reach, and a test that silently checked nothing is the
|
||||
// trap icon-language.test.ts's first assertion exists for.
|
||||
const sheet = (el.constructor as typeof HTMLElement & { styles?: unknown })
|
||||
.styles;
|
||||
|
||||
expect(String(sheet)).toContain('min-block-size: 44px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Downloads tabs', () => {
|
||||
beforeEach(() => {
|
||||
stub('download.Service.ListDownloads', []);
|
||||
stub('download.Service.ListRequests', []);
|
||||
stub('download.Service.ListProviders', []);
|
||||
});
|
||||
|
||||
it('are the only route to their panels, and are 44px', async () => {
|
||||
const el = await fixture('downloads-view');
|
||||
|
||||
await flush();
|
||||
|
||||
const tabs = shadowAll<HTMLElement>(el, '[role="tab"]');
|
||||
|
||||
expect(tabs).toHaveLength(2);
|
||||
|
||||
for (const tab of tabs) {
|
||||
expect(boxOf(tab).h, tab.textContent?.trim()).toBeGreaterThanOrEqual(FLOOR);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the active underline against the label', async () => {
|
||||
// The height is padding rather than a min-size, because the mark
|
||||
// for the selected tab is the bottom border -- a min-size would
|
||||
// centre the label and leave the underline 10px below it.
|
||||
const el = await fixture('downloads-view');
|
||||
|
||||
await flush();
|
||||
|
||||
const tab = shadowAll<HTMLElement>(el, '[role="tab"]')[0]!;
|
||||
const style = getComputedStyle(tab);
|
||||
|
||||
expect(parseFloat(style.paddingBlockStart)).toBeGreaterThan(8);
|
||||
expect(style.paddingBlockStart).toBe(style.paddingBlockEnd);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Explore's own search row", () => {
|
||||
beforeEach(() => {
|
||||
stub('explore.Service.GetShelves', { State: 'ready', Shelves: [] });
|
||||
stub('explore.Service.GetIndexStatus', {});
|
||||
});
|
||||
|
||||
it('raises the mode tabs', async () => {
|
||||
const el = await fixture('explore-view');
|
||||
|
||||
await flush();
|
||||
|
||||
const tabs = shadowAll<HTMLElement>(el, '.search-mode-tab');
|
||||
|
||||
expect(tabs.length).toBeGreaterThan(0);
|
||||
|
||||
for (const tab of tabs) {
|
||||
expect(boxOf(tab).h, tab.textContent?.trim()).toBeGreaterThanOrEqual(FLOOR);
|
||||
}
|
||||
});
|
||||
|
||||
it('makes the whole search box the input, not the middle 18px of it', async () => {
|
||||
// Two faults, not one: the row was 36px and the input inside it
|
||||
// was **18**, so half the box was not a target at all -- a tap
|
||||
// near the top or bottom edge landed on the container and did
|
||||
// nothing. The container is 44 and the input stretches to fill it.
|
||||
const el = await fixture('explore-view');
|
||||
|
||||
await flush();
|
||||
|
||||
const box = shadow(el, '.search-container');
|
||||
const input = shadow(el, '.search-container input');
|
||||
|
||||
expect(box, 'the search row renders').toBeTruthy();
|
||||
expect(input, 'it holds an input').toBeTruthy();
|
||||
|
||||
expect(boxOf(box).h).toBeGreaterThanOrEqual(FLOOR);
|
||||
expect(boxOf(input).h).toBeGreaterThanOrEqual(FLOOR);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,25 @@ describe('<search-trigger>', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('meets the touch floor it was shipped four pixels under', async () => {
|
||||
stubPhone(true);
|
||||
|
||||
// #57 created this as the phone's replacement for the header search
|
||||
// box, so it exists *only* where there is a thumb -- and it shipped
|
||||
// at 40x40 under a comment calling that "the smallest a touch
|
||||
// target should be", which was the app's own 44px floor (#56)
|
||||
// restated short rather than a second opinion about it. #186.
|
||||
const el = await fixture('search-trigger');
|
||||
const button = shadow<HTMLButtonElement>(el, '[data-testid="search-trigger"]');
|
||||
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
const box = button!.getBoundingClientRect();
|
||||
|
||||
expect(Math.round(box.width)).toBeGreaterThanOrEqual(44);
|
||||
expect(Math.round(box.height)).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
it('names what the button will search', async () => {
|
||||
stubPhone(true);
|
||||
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Every control in Settings is at least 44px (#186, second pass).
|
||||
*
|
||||
* The header pass covered the five controls a user meets on every
|
||||
* screen. Settings is the other half and is much the larger one: swept
|
||||
* on the reference device (TLP301, 424x439) with all eleven
|
||||
* `config-section`s expanded, **120 controls** were under the floor,
|
||||
* not the 93 the issue's first table implies, and `config-field` — the
|
||||
* row shape the issue names — is eight of them. The bulk is behind the
|
||||
* disclosures:
|
||||
*
|
||||
* | control | size | count |
|
||||
* |---|---|---|
|
||||
* | `.column-arrow-btn` | **16x14** | 36 |
|
||||
* | `.column-toggle` | 16x16 | 29 |
|
||||
* | `shortcut-capture` button | 80x**25** | 26 |
|
||||
* | download format checkbox | 16x16 | 8 |
|
||||
* | `config-field` select | 335x**30** | 7 |
|
||||
* | `wa-input` / `wa-button` | 204x**20**, 185x**21** | 6 |
|
||||
* | `library-filter` select | 120x**32** | 1 |
|
||||
* | `.overflow-btn` | 31x31 | 1 |
|
||||
*
|
||||
* **This tier can measure it, unlike #187's seek bar**, for the reason
|
||||
* the header pass gives: the controls are real elements and the rules
|
||||
* are min-sizes, so a real Chromium rendering a real component gives
|
||||
* the actual answer at any width. And unlike the header there is no
|
||||
* overflow fit on this page, so nothing here needs the negative-margin
|
||||
* treatment — height is free and the two square controls can simply be
|
||||
* square.
|
||||
*
|
||||
* **What the sweep cannot see is written down here as a test rather
|
||||
* than as a comment**, because it is the trap this whole issue keeps
|
||||
* setting. Two controls are invisible to a walk of `button, select,
|
||||
* input`: `config-field`'s toggle, whose `<input>` is
|
||||
* `opacity: 0; width: 0; height: 0` so the thing a finger hits is the
|
||||
* `<label>` around it (34x19, smaller than anything in either of the
|
||||
* issue's tables), and `shortcut-capture`'s reset button, which renders
|
||||
* only for a shortcut somebody has already rebound. Both are asserted
|
||||
* by name below.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import '@components/config-page/config-field';
|
||||
import '@components/config-page/config-page';
|
||||
import '@components/config-page/download-clients';
|
||||
import '@components/config-page/shortcut-capture';
|
||||
import '@components/library-filter/library-filter';
|
||||
|
||||
import { flush, stub } from '@test/support/harness';
|
||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
|
||||
/** The app's touch floor, from #56. */
|
||||
const FLOOR = 44;
|
||||
|
||||
/**
|
||||
* Everything a finger can hit, through every shadow root under `root`.
|
||||
*
|
||||
* It descends rather than querying one root because Settings is a tree
|
||||
* of components — `config-page` renders `config-section`s holding
|
||||
* `config-field`s and `shortcut-capture`s — and the defect was
|
||||
* distributed across all of them. This is the device sweep, run here.
|
||||
*/
|
||||
function controlsUnder(root: Document | ShadowRoot | Element): { name: string; el: HTMLElement }[] {
|
||||
const SELECTOR = 'button, select, input, [role="button"], [role="tab"], [role="switch"]';
|
||||
const found: { name: string; el: HTMLElement }[] = [];
|
||||
const seen = new Set<Element>();
|
||||
|
||||
const walk = (node: ParentNode, depth: number): void => {
|
||||
if (depth > 20) return;
|
||||
|
||||
for (const el of Array.from(node.querySelectorAll('*'))) {
|
||||
if (el.matches(SELECTOR) && !seen.has(el)) {
|
||||
seen.add(el);
|
||||
|
||||
const box = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
const rendered =
|
||||
(box.width > 0 || box.height > 0) &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.display !== 'none';
|
||||
|
||||
if (rendered) {
|
||||
const host = (el.getRootNode() as ShadowRoot).host;
|
||||
|
||||
found.push({
|
||||
name: `${host ? host.tagName.toLowerCase() : 'root'} ${
|
||||
(typeof el.className === 'string' && el.className) || el.tagName.toLowerCase()
|
||||
}`,
|
||||
el: el as HTMLElement,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (el.shadowRoot) walk(el.shadowRoot, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root as ParentNode, 0);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a finger actually hits for `el`.
|
||||
*
|
||||
* For everything in this app that is one element, that is the element.
|
||||
* A native checkbox is the exception and is why this function exists:
|
||||
* it cannot grow its hit area without growing its paint, and a 44px
|
||||
* checkbox is not what anyone wants — so a checkbox that has a label
|
||||
* is targeted *through* the label, which is the fix the column lists
|
||||
* and the download formats both use.
|
||||
*
|
||||
* The fallback is the checkbox itself, deliberately: a checkbox with
|
||||
* no label is a 16px target and this must still say so.
|
||||
*/
|
||||
function hitTarget(el: HTMLElement): HTMLElement {
|
||||
const input = el as HTMLInputElement;
|
||||
|
||||
if (input.type !== 'checkbox' && input.type !== 'radio') return el;
|
||||
|
||||
const wrapping = el.closest('label');
|
||||
const root = el.getRootNode() as ShadowRoot | Document;
|
||||
const associated = input.id
|
||||
? root.querySelector<HTMLLabelElement>(`label[for="${CSS.escape(input.id)}"]`)
|
||||
: null;
|
||||
|
||||
return wrapping ?? associated ?? el;
|
||||
}
|
||||
|
||||
/** The ones that miss the floor, reported with the numbers. */
|
||||
function tooSmall(controls: { name: string; el: HTMLElement }[]): string[] {
|
||||
return controls
|
||||
.map(({ name, el }) => {
|
||||
const box = hitTarget(el).getBoundingClientRect();
|
||||
|
||||
return { name, w: Math.round(box.width), h: Math.round(box.height) };
|
||||
})
|
||||
.filter((c) => c.w < FLOOR || c.h < FLOOR)
|
||||
.map((c) => `${c.name} ${c.w}x${c.h}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open every disclosure under `host`, so the sweep can see the page.
|
||||
*
|
||||
* A collapsed `config-section` renders its body with `hidden`, so its
|
||||
* controls measure 0x0 — which is exactly why the issue's first table
|
||||
* lists seven Settings controls and the real count is 120.
|
||||
*/
|
||||
async function expandEverySection(host: HTMLElement & { updateComplete: Promise<unknown> }) {
|
||||
const sections = shadowAll<HTMLElement>(host, 'config-section');
|
||||
|
||||
expect(sections.length, 'the page renders disclosures to open').toBeGreaterThan(0);
|
||||
|
||||
for (const section of sections) {
|
||||
section.shadowRoot
|
||||
?.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')
|
||||
?.click();
|
||||
}
|
||||
|
||||
await flush();
|
||||
await host.updateComplete;
|
||||
|
||||
for (const section of sections) {
|
||||
await (section as HTMLElement & { updateComplete?: Promise<unknown> }).updateComplete;
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/** A control's own box, named so a failure says which and how small. */
|
||||
function boxOf(el: Element | null | undefined): string {
|
||||
if (!el) return 'missing';
|
||||
|
||||
const box = el.getBoundingClientRect();
|
||||
|
||||
return `${Math.round(box.width)}x${Math.round(box.height)}`;
|
||||
}
|
||||
|
||||
function meetsFloor(el: Element | null | undefined): boolean {
|
||||
if (!el) return false;
|
||||
|
||||
const box = el.getBoundingClientRect();
|
||||
|
||||
return Math.round(box.width) >= FLOOR && Math.round(box.height) >= FLOOR;
|
||||
}
|
||||
|
||||
describe('a config field is the shape every Settings row uses', () => {
|
||||
it.each(['text', 'number', 'select', 'directory', 'color'] as const)(
|
||||
'a %s field meets the floor',
|
||||
async (type) => {
|
||||
const el = await fixture('config-field', {
|
||||
schema: {
|
||||
key: 'k',
|
||||
label: 'Music folder',
|
||||
type,
|
||||
options: [{ value: 'dark', label: 'Dark' }],
|
||||
},
|
||||
value: type === 'color' ? '#ffd43b' : '',
|
||||
});
|
||||
|
||||
const controls = controlsUnder(el.shadowRoot!);
|
||||
|
||||
// A sweep that found nothing passes vacuously.
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
expect(tooSmall(controls)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('grows the toggle, which no sweep of inputs can see', async () => {
|
||||
// The `<input>` is opacity: 0; width: 0; height: 0, so the walk
|
||||
// above skips it as a zero-sized node -- and the thing a finger
|
||||
// hits is the styling <label> around it, which measured 34x19 on
|
||||
// the device. It is absent from #186's tables for exactly that
|
||||
// reason, and it is smaller than everything in them.
|
||||
const el = await fixture('config-field', {
|
||||
schema: { key: 'x', label: 'Scan on startup', type: 'toggle' },
|
||||
value: true,
|
||||
});
|
||||
|
||||
const target = shadow(el, '.toggle-switch');
|
||||
|
||||
expect(boxOf(target)).toBe('44x44');
|
||||
});
|
||||
|
||||
it('keeps the toggle painted at its old size, in its old place', async () => {
|
||||
// A 44px pill is not what a switch should look like. The box is
|
||||
// 44px and the paint is not: the slider is a child centred in it,
|
||||
// and negative inline margins hand the extra width back so the
|
||||
// pill stays flush with the inputs in the rows above.
|
||||
const el = await fixture('config-field', {
|
||||
schema: { key: 'x', label: 'Scan on startup', type: 'toggle' },
|
||||
value: true,
|
||||
});
|
||||
|
||||
const target = shadow(el, '.toggle-switch') as HTMLElement;
|
||||
const slider = shadow(el, '.toggle-slider');
|
||||
|
||||
expect(slider!.getBoundingClientRect().height).toBeLessThan(FLOOR);
|
||||
|
||||
const style = getComputedStyle(target);
|
||||
const handedBack =
|
||||
parseFloat(style.marginInlineStart) + parseFloat(style.marginInlineEnd);
|
||||
|
||||
expect(handedBack).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shortcut editor', () => {
|
||||
it('meets the floor', async () => {
|
||||
const el = await fixture('shortcut-capture', {
|
||||
action: 'play.toggle',
|
||||
label: 'Play/pause',
|
||||
currentKey: 'Space',
|
||||
defaultKey: 'Space',
|
||||
});
|
||||
|
||||
expect(meetsFloor(shadow(el, 'button'))).toBe(true);
|
||||
});
|
||||
|
||||
it('grows the reset button, which only a rebound shortcut renders', async () => {
|
||||
// Not in either of #186's tables, and it cannot be: a sweep of a
|
||||
// freshly-installed app never sees it. It appears the moment
|
||||
// anybody uses the feature.
|
||||
const el = await fixture('shortcut-capture', {
|
||||
action: 'play.toggle',
|
||||
label: 'Play/pause',
|
||||
currentKey: 'K',
|
||||
defaultKey: 'Space',
|
||||
});
|
||||
|
||||
const reset = shadow(el, '.reset-btn');
|
||||
|
||||
expect(reset, 'a rebound shortcut renders a reset button').toBeTruthy();
|
||||
expect(meetsFloor(reset)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the library filter', () => {
|
||||
it('meets the floor in both of its placements', async () => {
|
||||
// One component, two mount points since #57 -- the desktop top bar
|
||||
// and Settings -> Libraries -- so it reaches the floor once.
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
const select = shadow(el, 'select');
|
||||
|
||||
expect(select, 'the filter renders a select').toBeTruthy();
|
||||
expect(Math.round(select!.getBoundingClientRect().height)).toBeGreaterThanOrEqual(FLOOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download clients', () => {
|
||||
beforeEach(() => {
|
||||
stub('download.Service.ListProviders', []);
|
||||
stub('download.Service.ProviderKinds', []);
|
||||
stub('config.Config.GetDownloadPreferences', {});
|
||||
});
|
||||
|
||||
it('gives Web Awesome form controls the floor through the library API', async () => {
|
||||
// wa-input's control is inside somebody else's shadow root, so the
|
||||
// height comes from --wa-form-control-height rather than from a
|
||||
// rule of ours reaching in. A custom property inherits through a
|
||||
// shadow boundary, which is what makes a :host declaration reach
|
||||
// it -- and what makes it measurable here.
|
||||
const el = await fixture('download-clients');
|
||||
|
||||
await flush();
|
||||
await expandEverySection(el);
|
||||
|
||||
expect(getComputedStyle(el).getPropertyValue('--wa-form-control-height').trim()).toBe(
|
||||
'44px',
|
||||
);
|
||||
|
||||
// And the outcome, not only the mechanism. A sweep of `input`
|
||||
// reports a wa-input at 204x**42** even when this is right,
|
||||
// because the inner input sits *inside* the control's own 1px
|
||||
// border -- Web Awesome sizes it
|
||||
// `calc(--wa-form-control-height - border-width * 2)`. What a
|
||||
// finger hits is `part=base`, measured at 238x44 on the device.
|
||||
//
|
||||
// This reaches into another library's shadow root, which
|
||||
// `name-dialog.ts` only permits where the failure is bounded. It
|
||||
// is bounded the other way here: this is a test, so a renamed
|
||||
// part fails loudly rather than silently passing, which is the
|
||||
// direction that costs nobody a device session.
|
||||
const input = shadowAll<HTMLElement>(el, 'wa-input').find(
|
||||
(w) => w.getBoundingClientRect().height > 0,
|
||||
);
|
||||
|
||||
expect(input, 'the add form renders a wa-input').toBeTruthy();
|
||||
|
||||
const base = input!.shadowRoot?.querySelector('[part~="base"]');
|
||||
|
||||
expect(base, 'wa-input still calls its control box "base"').toBeTruthy();
|
||||
expect(Math.round(base!.getBoundingClientRect().height)).toBeGreaterThanOrEqual(FLOOR);
|
||||
});
|
||||
|
||||
it('makes each allowed-format checkbox label a target', async () => {
|
||||
const el = await fixture('download-clients');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
// Every section starts collapsed, and a collapsed body is `hidden`
|
||||
// — so its controls measure 0x0 and a sweep of an unexpanded page
|
||||
// reports them all as fine. That is how the issue's first table
|
||||
// came to list seven Settings controls when there are 120.
|
||||
await expandEverySection(el);
|
||||
|
||||
const options = shadowAll<HTMLElement>(el, '.format-option');
|
||||
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
|
||||
const short = options
|
||||
.map((o) => ({ label: o.textContent?.trim(), h: Math.round(o.getBoundingClientRect().height) }))
|
||||
.filter((o) => o.h < FLOOR);
|
||||
|
||||
expect(short).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the whole Settings page', () => {
|
||||
beforeEach(() => {
|
||||
for (const path of [
|
||||
'library.Library.GetAllLibrariesWithTrackCounts',
|
||||
'jobs.Service.GetJobs',
|
||||
'download.Service.ListProviders',
|
||||
'download.Service.ProviderKinds',
|
||||
]) {
|
||||
stub(path, []);
|
||||
}
|
||||
|
||||
stub('config.Config.GetShortcuts', {});
|
||||
stub('config.Config.GetDownloadPreferences', {});
|
||||
stub('config.Config.GetThemeAccentColor', '#ffd43b');
|
||||
stub('config.Config.GetThemeBackgroundShade', 'dark');
|
||||
});
|
||||
|
||||
it('has no control under the floor with every section expanded', async () => {
|
||||
// The device sweep, run here: eleven collapsed sections is what
|
||||
// made the first table look like seven controls. The density is
|
||||
// behind the disclosures.
|
||||
const el = await fixture('config-page');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await expandEverySection(el);
|
||||
|
||||
const controls = controlsUnder(el.shadowRoot!);
|
||||
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
expect(tooSmall(controls)).toEqual([]);
|
||||
});
|
||||
|
||||
it('makes a column row a target by naming it, not by growing the checkbox', async () => {
|
||||
// A native checkbox cannot grow its hit area without growing its
|
||||
// paint. The label is the target instead -- which is also the
|
||||
// argument config-field already makes for its own labels, and it
|
||||
// is behaviour rather than annotation: the column's name is now a
|
||||
// click target for its checkbox.
|
||||
const el = await fixture('config-page');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await expandEverySection(el);
|
||||
|
||||
const labels = shadowAll<HTMLLabelElement>(el, 'label.column-label');
|
||||
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
|
||||
for (const label of labels) {
|
||||
const target = label.htmlFor
|
||||
? el.shadowRoot!.getElementById(label.htmlFor)
|
||||
: null;
|
||||
|
||||
expect(
|
||||
(target as HTMLInputElement | null)?.type,
|
||||
`${label.textContent?.trim()} names its checkbox`,
|
||||
).toBe('checkbox');
|
||||
expect(
|
||||
Math.round(label.getBoundingClientRect().height),
|
||||
`${label.textContent?.trim()} is a target`,
|
||||
).toBeGreaterThanOrEqual(FLOOR);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLElement>(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('<selection-bar>', () => {
|
||||
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<HTMLElement>(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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Every control a finger meets is at least 44px (#186).
|
||||
*
|
||||
* #56 sized the playback transport for a thumb and named 44px; the
|
||||
* queue header keeps it; nothing else was resized. So the controls a
|
||||
* user meets on *every* screen — the sort control, its direction
|
||||
* button, the page actions, the overflow trigger and the phone's search
|
||||
* button — sat between a third and two thirds of the app's own floor.
|
||||
* Measured on the reference device (TLP301, 424x439): `page-sort` 99x23,
|
||||
* `page-sort-direction` **28x21**, `page-actions-more` 38x27,
|
||||
* `search-trigger` 40x40.
|
||||
*
|
||||
* Unlike the seek bar's target (#187), this one can be measured here
|
||||
* rather than inferred from the stylesheet. There the painted track had
|
||||
* to stay thin, so the hit area was grown past its own box and only a
|
||||
* phone-width layout of a third-party slider could show it. Here the
|
||||
* control *is* the target, so a real Chromium rendering a real
|
||||
* `page-header` gives the actual answer — and because it is a `min-size`
|
||||
* rather than a media query, the answer is the same at every width,
|
||||
* which is what makes it checkable in this tier at all.
|
||||
*
|
||||
* That is also why there is no phone branch to test: a 44px control on
|
||||
* a desktop is merely large, and a second declaration of what a phone
|
||||
* shows is a second thing to keep in step.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PageAction, PageHeader } from '@components/page-header/page-header';
|
||||
|
||||
import '@components/page-header/page-header';
|
||||
import { fixture, shadowAll } from '@test/support/render';
|
||||
|
||||
/** The app's touch floor, from #56. */
|
||||
const FLOOR = 44;
|
||||
|
||||
const SORTS = [
|
||||
{ id: 'name', label: 'Name' },
|
||||
{ id: 'tracks', label: 'Tracks' },
|
||||
];
|
||||
|
||||
function actions(): PageAction[] {
|
||||
return [
|
||||
{ id: 'import', label: 'Import', icon: 'file-import', priority: 0, onSelect: () => {} },
|
||||
{ id: 'new', label: 'New Playlist', icon: 'plus', priority: 2, onSelect: () => {} },
|
||||
];
|
||||
}
|
||||
|
||||
/** Every visible control in the header's own shadow root. */
|
||||
function controlsOf(el: PageHeader): { name: string; el: HTMLElement }[] {
|
||||
return shadowAll<HTMLElement>(el, 'button, select')
|
||||
.filter((c) => !(c as HTMLButtonElement).hidden)
|
||||
.map((c) => ({
|
||||
name: c.dataset.testid ?? (c.className || c.tagName.toLowerCase()),
|
||||
el: c,
|
||||
}));
|
||||
}
|
||||
|
||||
function tooSmall(controls: { name: string; el: HTMLElement }[]): string[] {
|
||||
return controls
|
||||
.map(({ name, el }) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
|
||||
return { name, w: Math.round(b.width), h: Math.round(b.height) };
|
||||
})
|
||||
.filter((c) => c.w < FLOOR || c.h < FLOOR)
|
||||
.map((c) => `${c.name} ${c.w}x${c.h}`);
|
||||
}
|
||||
|
||||
describe("the page header's controls", () => {
|
||||
it('all meet the touch floor', async () => {
|
||||
const el = await fixture<PageHeader>('page-header', {
|
||||
heading: 'Playlists',
|
||||
count: 50,
|
||||
countNoun: 'playlist',
|
||||
sortOptions: SORTS,
|
||||
sortField: 'name',
|
||||
sortDirection: 'asc',
|
||||
actions: actions(),
|
||||
});
|
||||
|
||||
const controls = controlsOf(el);
|
||||
|
||||
// A sweep that found no controls passes vacuously — the same first
|
||||
// assertion icon-language.test.ts makes, for the same reason.
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
|
||||
// The two that were smallest, named so a regression says which.
|
||||
expect(controls.map((c) => c.name)).toContain('page-sort-direction');
|
||||
expect(controls.map((c) => c.name)).toContain('page-sort');
|
||||
|
||||
expect(tooSmall(controls)).toEqual([]);
|
||||
});
|
||||
|
||||
it('grows the target without growing the box, so the overflow fit is untouched', async () => {
|
||||
// The regression this exists for, and it was a real one: growing
|
||||
// the two square controls to 44px *wide* added 22px to the header,
|
||||
// which fit at every width Chromium was checked at and clipped the
|
||||
// overflow trigger at 320x600 in WebKit -- the engine closest to
|
||||
// what ships, and the one no machine here can run. #69's fit pass
|
||||
// measures inline size, so a taller control is free and a wider one
|
||||
// is not.
|
||||
//
|
||||
// Negative inline margins are what keep the box out of it: the
|
||||
// padding makes the target, and the margin gives the space back.
|
||||
const el = await fixture<PageHeader>('page-header', {
|
||||
heading: 'Playlists',
|
||||
sortOptions: SORTS,
|
||||
sortField: 'name',
|
||||
actions: actions(),
|
||||
});
|
||||
|
||||
el.style.width = '320px';
|
||||
|
||||
for (let frame = 0; frame < 3; frame += 1) {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
await el.updateComplete;
|
||||
}
|
||||
|
||||
for (const selector of ['.sort-dir', '.more-button']) {
|
||||
const control = shadowAll<HTMLElement>(el, selector).filter(
|
||||
(c) => !(c as HTMLButtonElement).hidden,
|
||||
)[0];
|
||||
|
||||
expect(control, selector).toBeTruthy();
|
||||
|
||||
const style = getComputedStyle(control!);
|
||||
const added =
|
||||
parseFloat(style.marginInlineStart) + parseFloat(style.marginInlineEnd);
|
||||
|
||||
expect(added, `${selector} gives its extra width back`).toBeLessThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the overflow trigger, which is the route to the rest', async () => {
|
||||
// At 320px the fit pass collapses actions into the menu, so the
|
||||
// trigger is rendered — and it is then the only way to reach them,
|
||||
// which makes it the last control that should be hard to hit.
|
||||
const el = await fixture<PageHeader>('page-header', {
|
||||
heading: 'Playlists',
|
||||
sortOptions: SORTS,
|
||||
sortField: 'name',
|
||||
actions: actions(),
|
||||
});
|
||||
|
||||
el.style.width = '320px';
|
||||
|
||||
for (let frame = 0; frame < 3; frame += 1) {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
await el.updateComplete;
|
||||
}
|
||||
|
||||
const more = shadowAll<HTMLButtonElement>(el, '.more-button').filter(
|
||||
(b) => !b.hidden,
|
||||
);
|
||||
|
||||
expect(more.length).toBe(1);
|
||||
|
||||
const box = more[0]!.getBoundingClientRect();
|
||||
|
||||
expect(Math.round(box.width)).toBeGreaterThanOrEqual(FLOOR);
|
||||
expect(Math.round(box.height)).toBeGreaterThanOrEqual(FLOOR);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user