Compare commits
4
Commits
7e0be8fa30
...
b1cdef8769
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1cdef8769 | ||
|
|
d661836347 | ||
|
|
28eecf0a97 | ||
|
|
e8690476bd |
@@ -286,3 +286,27 @@ Related, and it will bite once: the launcher activity is
|
||||
resolves the leading dot against the *applicationId* and fails with a
|
||||
class-not-found that reads like a broken build. Always the
|
||||
fully-qualified form.
|
||||
|
||||
## What only a device can answer
|
||||
|
||||
The emulator cannot run this app (three separate reasons, none of them
|
||||
ours — see plan 016), so the phone in someone's pocket is a tier, and
|
||||
asking for it is cheap. The first run of it, on 2026-08-17, confirmed
|
||||
the whole of A4 and found two faults **no other tier can see**:
|
||||
|
||||
- **The back gesture.** `MainActivity.onBackPressed` asks
|
||||
`webView.canGoBack()`. Nothing in a desktop shell has a back gesture,
|
||||
so no spec had ever called `page.goBack()` and the app had never
|
||||
pushed a history entry — back quit from any depth. It is a history
|
||||
entry per navigation now, which is also what made it assertable in the
|
||||
browser tier (`e2e/specs/back-navigation.spec.ts`).
|
||||
- **The safe area.** `targetSdk 35` forces edge-to-edge, so the
|
||||
transport and the tab bar sat under the gesture bar. **A browser
|
||||
viewport has no system bars**: `phone-shell.spec.ts` at 390x844 will
|
||||
keep passing on a build the device is clipping 48dp off. Insets are
|
||||
handled in `applyWindowInsets()`.
|
||||
|
||||
So when asking for a device run, ask about what the platform *adds* —
|
||||
system bars, the back gesture, focus and audio interruptions,
|
||||
permission dialogs, the keyboard — not about what the app draws. The
|
||||
drawing is what the other five tiers already cover.
|
||||
|
||||
@@ -3034,3 +3034,107 @@ reason this was caught is that the assertion about the probe ran before
|
||||
the assertion about the export. A fixture built by string surgery on a
|
||||
formatted constant needs to be whitespace-independent; it filters the
|
||||
list now.
|
||||
|
||||
## Long-press is one document listener, and the header row is a row (2026-08-17)
|
||||
|
||||
Plan 016 B2 phase 3. A phone has no right-click, and every context menu
|
||||
in this app opens from a `contextmenu` event — six components' worth,
|
||||
bound three different ways (delegated on a virtualizer, per row, per
|
||||
card). `frontend/src/utils/long-press.ts` is one document-capture
|
||||
listener installed once from `index.ts`: a touch that holds still for
|
||||
500 ms dispatches a synthetic `contextmenu` at the touch point, and
|
||||
**every existing handler runs unchanged**. No component opted in, and
|
||||
none can forget to.
|
||||
|
||||
Four things it has to get right, and each is a way the obvious version
|
||||
fails:
|
||||
|
||||
- **The target is `composedPath()[0]`, not `elementFromPoint`**, which
|
||||
stops at the outermost shadow host. Every menu here is bound inside
|
||||
one, so a host-targeted event reaches a delegated listener and no
|
||||
per-row one.
|
||||
- **A browser that fires its own must win.** Chromium already dispatches
|
||||
`contextmenu` on long-press; WebKit and the WebView vary. One arriving
|
||||
during the press cancels ours; one arriving after ours is swallowed at
|
||||
document capture.
|
||||
- **Ours is told from theirs by identity** (a `WeakSet`), not by
|
||||
`isTrusted`. `isTrusted` would work in the app and is untestable — no
|
||||
test can dispatch a trusted event — so the suppression path would have
|
||||
been the one thing with no coverage.
|
||||
- **The click ending the gesture is swallowed**, keyed on the gesture
|
||||
(cleared by the next `pointerdown`) rather than a time window, or a
|
||||
quick tap on the menu that just opened is eaten too.
|
||||
|
||||
**What cost the time was the assertion, not the code.** The e2e spec
|
||||
pressed `[role="row"]` — which is the *column header*, and it is the
|
||||
first one. The gesture fired correctly, the header correctly ignored it,
|
||||
and the failure looked exactly like a menu that would not open. Found by
|
||||
probing the running app (`playwright-cli eval`, dispatching the same
|
||||
pointer events and logging what saw the `contextmenu`), which showed the
|
||||
event reaching the row's own listener with no menu behind it — i.e. the
|
||||
handler was refusing it, not missing it. `.track-row` is the selector.
|
||||
|
||||
Verified by execution: 8 component tests (real browser, real shadow
|
||||
boundary, real timings) and 2 e2e specs against the running app, twice
|
||||
in a row. Not verified: any of it under a real finger on a real
|
||||
WebView — the pointer events are dispatched, because neither Desktop
|
||||
Chrome nor Desktop Safari has touch and there is no device tier.
|
||||
|
||||
## The first device run: A4 works, and two things only a phone could say (2026-08-17)
|
||||
|
||||
The published v1.5.0 APK, on a real phone, owner-reported. **This is the
|
||||
first runtime evidence any of the Android work has ever had** — A4
|
||||
shipped entirely reasoned from source.
|
||||
|
||||
**What holds.** Playback survives the screen locking. The MediaSession
|
||||
notification appears in the status pane *with album art* — which
|
||||
answers, in one observation, four of the open questions from plan 016:
|
||||
the foreground service starts, POST_NOTIFICATIONS was granted and the
|
||||
notification is visible, the session is picked up, and **cover art
|
||||
decoded from a `MANAGE_EXTERNAL_STORAGE` path by a service is
|
||||
readable**. The last was the one nobody could argue from documentation.
|
||||
|
||||
**Two bugs, and neither is visible from any tier we have.**
|
||||
|
||||
*Back did not navigate back.* The scaffold's
|
||||
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
|
||||
activity otherwise — and this app had never touched `history`, so that
|
||||
was false at every depth and back quit from anywhere. The fix is in the
|
||||
frontend, not in Java: a navigation is a `history` entry now
|
||||
(`recordNavigation` in `index.ts`, same URL, the destination in the
|
||||
entry's state) and `popstate` replays it with `_isBack`. The Java half
|
||||
needs no change, because the mechanism it already uses is the one we
|
||||
were failing to feed.
|
||||
|
||||
Two rules keep it honest. The **first** navigation replaces the launch
|
||||
entry rather than pushing one, or every launch costs a back press before
|
||||
the app will close. And the in-app back buttons go through
|
||||
`history.back()` rather than popping a stack of their own — `navStack`
|
||||
is **deleted**, not kept alongside, because two stacks is exactly how
|
||||
the detail view's own button and the phone's gesture come to disagree
|
||||
about how far back one press goes. `back-navigation.spec.ts` pins that
|
||||
invariant.
|
||||
|
||||
*The transport was off screen.* **`targetSdk 35` is Android 15, which
|
||||
lays every app out edge-to-edge**, ignores the deprecated
|
||||
`statusBarColor`/`navigationBarColor` the theme still sets, and hands
|
||||
the app a window the size of the screen. The WebView is `match_parent`,
|
||||
so the page's bottom band — the transport, and on a phone the tab bar —
|
||||
was drawn underneath the gesture bar. `applyWindowInsets()` pads the
|
||||
container by `systemBars | displayCutout | ime` and returns the insets
|
||||
rather than consuming them. The window background goes black to match
|
||||
the app's own ramp, or the padding shows as a blue-grey band.
|
||||
|
||||
**Neither is findable in the browser tier, and that is the lesson worth
|
||||
keeping**: a viewport has no system bars, so `phone-shell.spec.ts` at
|
||||
390x844 renders a shell that fits perfectly while the device cuts 48dp
|
||||
off the bottom — and `page.goBack()` was never called because nothing in
|
||||
a desktop shell has a back gesture. The Android tier's own note says
|
||||
failure there is invisible; this is the milder version, where the app
|
||||
works and is simply wrong in ways only the platform can show you.
|
||||
|
||||
Verified by execution: the APK builds with the Java change; 3 e2e specs
|
||||
cover the history behaviour, on Chromium locally and WebKit in CI.
|
||||
Not verified: the insets themselves, which need the next APK on the
|
||||
owner's phone. What to look for is one thing — the transport and the tab
|
||||
bar clear of the gesture bar, and the header clear of the status bar.
|
||||
|
||||
@@ -315,8 +315,8 @@ places had to agree — `abiFilters`, the Makefile's `android:package`
|
||||
anchor is what stops it also matching the fat APK's line. Adding the
|
||||
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
|
||||
|
||||
**B2, the desktop shell.** Scope decided (below); **phases 1 and 2 are
|
||||
done.**
|
||||
**B2, the desktop shell.** Scope decided (below); **phases 1, 2 and 3
|
||||
are done.**
|
||||
|
||||
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
|
||||
`<bottom-nav>` is the primary navigation, and the shell fits 320px
|
||||
@@ -326,15 +326,47 @@ done.**
|
||||
composes the real transport components rather than copying them; and
|
||||
it hides the bottom bar while it is up, so it carries its own queue
|
||||
button.
|
||||
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
|
||||
listener, installed once from `index.ts`, which turns a 500 ms
|
||||
stationary touch into a synthetic `contextmenu` at the touch point.
|
||||
Every menu in the app opens from that event, so all six components
|
||||
gained the gesture without one of them changing — which is the same
|
||||
argument `ContextMenuController` rests on, one layer lower. The
|
||||
details that are not obvious are in `NOTES.md` (2026-08-17); the one
|
||||
worth repeating is that ours is told from the browser's own
|
||||
long-press event by **identity**, not `isTrusted`, because a test
|
||||
cannot dispatch a trusted event and that path would otherwise be the
|
||||
only uncovered one.
|
||||
|
||||
What is left is the rest of the *interactions*: long-press for the
|
||||
context menus that are right-click today, and the track list's
|
||||
resizable columns, which are a pointer feature with no touch
|
||||
equivalent. Neither is started.
|
||||
What is left of B2 is the track list, whose resizable columns are a
|
||||
pointer feature with no touch equivalent. Not started.
|
||||
|
||||
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
|
||||
with all-files access, `tagwriter` can write in place.
|
||||
|
||||
### What the first device run answered (2026-08-17)
|
||||
|
||||
A4 **works**: playback survives the screen locking, and the transport
|
||||
notification appears with cover art — which also settles the service's
|
||||
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
|
||||
the lock-screen session in one observation. Everything below in "what
|
||||
none of section A answered" was written before this and is now answered
|
||||
except the OEM permission-flow variance.
|
||||
|
||||
It also found two faults no browser tier can see, both fixed and both
|
||||
awaiting the next APK for confirmation (`NOTES.md`, same date):
|
||||
|
||||
- **Back quit the app from any depth.** The scaffold asks
|
||||
`webView.canGoBack()`; the frontend had never used `history`. A
|
||||
navigation is a history entry now, and `navStack` is gone rather than
|
||||
kept beside it.
|
||||
- **The transport was under the gesture bar.** `targetSdk 35` is
|
||||
edge-to-edge by force; `applyWindowInsets()` in `MainActivity` pads
|
||||
by `systemBars | displayCutout | ime`.
|
||||
|
||||
The standing item is unchanged in kind: **B3 (tag writing) and the
|
||||
permission flow still need a device**, and so does confirming these two.
|
||||
|
||||
### What none of section A answered
|
||||
|
||||
Nothing here has been observed on a device. The permission flow in
|
||||
|
||||
@@ -683,6 +683,27 @@ moment it is most needed is the likeliest moment loading one fails.
|
||||
`first-run-wizard` and the startup chrome are eager for the ordinary
|
||||
reason — they are the first paint.
|
||||
|
||||
**A navigation is a history entry, and that is the whole back stack.**
|
||||
`index.ts` records each navigation with `pushState` (same URL — the app
|
||||
has no routes, and a path a reload cannot resolve is worse than none)
|
||||
and replays `popstate` with `_isBack`. It exists for Android, whose back
|
||||
button is not a key the page can bind: the scaffold's
|
||||
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
|
||||
activity otherwise, so an app that never touched `history` quit from any
|
||||
depth — which is what a device reported. Hooking the platform's own
|
||||
mechanism rather than adding a JNI callback is also what makes it
|
||||
testable in a browser (`page.goBack()`), and the Java half needed no
|
||||
change at all.
|
||||
|
||||
Two rules hold it up. The **first** navigation *replaces* the launch
|
||||
entry rather than pushing one, or every launch costs a back press before
|
||||
the app will close. And the in-app back buttons (`navigate-back`, fired
|
||||
by the detail views and `now-playing-view`) go through `history.back()`
|
||||
rather than a stack of their own: the old `navStack` is **deleted**, not
|
||||
kept beside it, because two stacks is precisely how a view's own back
|
||||
button and the phone's gesture come to disagree about what one press
|
||||
means.
|
||||
|
||||
**A primary view is cached, not unmounted.** `index.ts` keeps every
|
||||
primary view in the DOM and toggles a `.view-hidden` class, because that
|
||||
is what preserves `scrollTop` across navigation — so
|
||||
@@ -838,6 +859,20 @@ against the real components:
|
||||
moving focus without setting it leaves the highlight on whichever
|
||||
item the mouse last touched.
|
||||
|
||||
**And a menu opens from a finger, through the event it already has.**
|
||||
`utils/long-press.ts` is one document-capture listener installed once
|
||||
from `index.ts`: a touch that holds still for 500 ms dispatches a
|
||||
synthetic `contextmenu` at the touch point, so all six components that
|
||||
bind one — delegated on a virtualizer, per row, per card — gained the
|
||||
gesture without changing. The target is `composedPath()[0]` rather than
|
||||
`elementFromPoint`, which stops at the outermost shadow host and so
|
||||
reaches a delegated listener and no per-row one; a browser that fires
|
||||
its own long-press `contextmenu` (Chromium does, WebKit and the WebView
|
||||
vary) wins, ours being told from theirs by **identity** rather than
|
||||
`isTrusted`, since no test can dispatch a trusted event; and the click
|
||||
that ends the gesture is swallowed, keyed on the gesture rather than on
|
||||
a time window so the first tap on the menu it opened is not eaten too.
|
||||
|
||||
Three lists had no focused row to open a menu *from* — the queue panel
|
||||
and both playlist detail views — and gained a roving tab stop through
|
||||
`utils/roving-rows.ts`. **`track-list` deliberately does not use it**:
|
||||
@@ -1960,6 +1995,18 @@ like source** — it was generated once into a scratch directory and
|
||||
copied across (plan 015), it carries one deliberate edit to its
|
||||
`Taskfile.yml`, and only its output is gitignored. `build/ios/` is
|
||||
still not carried and its `includes:` entry is still dropped.
|
||||
**Its `MainActivity` owns the safe area, because `targetSdk 35` does
|
||||
not leave that to the theme.** Android 15 lays every app out
|
||||
edge-to-edge and ignores the `statusBarColor`/`navigationBarColor` the
|
||||
scaffold's theme sets, and the WebView is `match_parent` — so the page's
|
||||
bottom band, which on a phone is the transport *and* the tab bar, was
|
||||
drawn under the gesture bar. `applyWindowInsets()` pads the container by
|
||||
`systemBars | displayCutout | ime` and returns the insets rather than
|
||||
consuming them; the window background is black to match the app's own
|
||||
ramp, since that padding is what shows through. **No browser tier can
|
||||
see this class of fault** — a viewport has no system bars, so the phone
|
||||
specs render a shell that fits at the moment the device is clipping it.
|
||||
|
||||
`build/config.yml`'s `version` is the
|
||||
*metadata* version and is not what the app reports — `main.version` is
|
||||
stamped at link time from the packaging recipe's git-derived version.
|
||||
|
||||
@@ -31,9 +31,16 @@ import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.FileProvider;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
import androidx.webkit.WebViewAssetLoader;
|
||||
|
||||
import org.json.JSONObject;
|
||||
@@ -88,6 +95,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
|
||||
// Before anything renders: the page is laid out inside the
|
||||
// window, and on Android 15 the window is the whole screen.
|
||||
applyWindowInsets();
|
||||
|
||||
// Initialize the native Go library
|
||||
bridge = new WailsBridge(this);
|
||||
bridge.initialize();
|
||||
@@ -892,8 +903,61 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the web content inside the safe area.
|
||||
*
|
||||
* <p>targetSdk 35 is Android 15, which lays every app out
|
||||
* edge-to-edge and ignores the {@code statusBarColor} and
|
||||
* {@code navigationBarColor} this app's theme still sets. The
|
||||
* WebView is {@code match_parent}, so the page's bottom band -- the
|
||||
* transport and, on a phone, the tab bar -- was drawn underneath the
|
||||
* gesture bar and reported from a device as "I can't see the
|
||||
* playback controls, they seem to be off screen".
|
||||
*
|
||||
* <p>No web-tier test can see this: a browser viewport has no system
|
||||
* bars, so the phone specs at 390x844 render a shell that fits
|
||||
* while the device does not.
|
||||
*
|
||||
* <p>The insets are applied as padding and the window insets are
|
||||
* returned rather than consumed, so the WebView is laid out inside
|
||||
* them. {@code ime()} is in the mask because the same reasoning
|
||||
* covers the keyboard: a focused search box that the keyboard
|
||||
* covers is the same bug one surface over.
|
||||
*/
|
||||
private void applyWindowInsets() {
|
||||
final View container = findViewById(R.id.main_container);
|
||||
|
||||
if (container == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(container, (view, windowInsets) -> {
|
||||
Insets insets = windowInsets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars()
|
||||
| WindowInsetsCompat.Type.displayCutout()
|
||||
| WindowInsetsCompat.Type.ime());
|
||||
|
||||
view.setPadding(insets.left, insets.top, insets.right, insets.bottom);
|
||||
|
||||
return windowInsets;
|
||||
});
|
||||
|
||||
// The padded band shows the window background, which is dark
|
||||
// (this app's own default ramp is black), so the system's icons
|
||||
// have to be the light set or they vanish into it. The theme is
|
||||
// DayNight and would otherwise ask for dark icons in light mode.
|
||||
WindowInsetsControllerCompat controller =
|
||||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
|
||||
|
||||
controller.setAppearanceLightStatusBars(false);
|
||||
controller.setAppearanceLightNavigationBars(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
// The frontend records every navigation as a history entry, so
|
||||
// this is the app's own back stack: `canGoBack()` is false only
|
||||
// at the launch entry, which is where back should leave.
|
||||
if (webView != null && webView.canGoBack()) {
|
||||
webView.goBack();
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
<resources>
|
||||
<color name="wails_blue">#3574D4</color>
|
||||
<color name="wails_blue_dark">#2C5FB8</color>
|
||||
<color name="wails_background">#1B2636</color>
|
||||
<!-- The window background, which is what the launch screen shows and
|
||||
what the system-bar padding leaves visible. Black rather than the
|
||||
scaffold's blue-grey because this app's own default ramp is
|
||||
black: a band of #1B2636 above and below it reads as the app
|
||||
failing to fill the screen. -->
|
||||
<color name="wails_background">#000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="black">#FF000000</color>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* Back is the platform's, and the app has to have somewhere for it to
|
||||
* go (reported from a device: "the Android back button does not
|
||||
* navigate back in the app").
|
||||
*
|
||||
* The scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()`
|
||||
* and finishes the activity otherwise. This app never touched
|
||||
* `history`, so that was always false and back quit from any depth. A
|
||||
* navigation is a history entry now, which is why this is assertable
|
||||
* here at all: `page.goBack()` is the same `popstate` the phone's
|
||||
* gesture produces, so the browser tier can answer a question that
|
||||
* otherwise needs a device.
|
||||
*
|
||||
* What it cannot answer is whether Android's *gesture* reaches the
|
||||
* WebView, which is between the OS and the scaffold.
|
||||
*/
|
||||
type Page = import('@playwright/test').Page;
|
||||
|
||||
const activeView = (page: Page) =>
|
||||
page.getByTestId('main-content');
|
||||
|
||||
/**
|
||||
* Open an artist's detail view, which is the deepest ordinary route.
|
||||
*
|
||||
* A library artist opens `explore-artist-details` -- the catalog panel
|
||||
* standing in for a library one, as `explore-link.ts` describes -- and
|
||||
* the view name follows the component, not the source of the click.
|
||||
*/
|
||||
async function openAnArtist(app: Page): Promise<void> {
|
||||
await app.getByTestId('nav-artists').click();
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
|
||||
|
||||
// A card, by the name on it: the grid is virtualized and positioned
|
||||
// by transform, so a click at coordinates is a click at whatever
|
||||
// happens to be there.
|
||||
await app.locator('artists-view').getByText('Aurora Fields').first().click();
|
||||
await expect(activeView(app)).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'explore-artist-details',
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('the back gesture', () => {
|
||||
test('leaves a detail view for the view it was opened from', async ({
|
||||
app,
|
||||
}) => {
|
||||
await openAnArtist(app);
|
||||
|
||||
await app.goBack();
|
||||
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
|
||||
});
|
||||
|
||||
test('walks back through primary views, one press per navigation', async ({
|
||||
app,
|
||||
}) => {
|
||||
await app.getByTestId('nav-tracks').click();
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
|
||||
|
||||
await app.getByTestId('nav-albums').click();
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||||
|
||||
await app.goBack();
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
|
||||
|
||||
// Forward is free once back works, and it is what proves the entry
|
||||
// was restored rather than the view merely re-rendered.
|
||||
await app.goForward();
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||||
});
|
||||
|
||||
test('an in-app back button consumes exactly one entry', async ({ app }) => {
|
||||
await app.getByTestId('nav-tracks').click();
|
||||
await openAnArtist(app);
|
||||
|
||||
// The detail view's own back button and the phone's gesture are the
|
||||
// same press: if each popped its own stack, this would land two
|
||||
// navigations back instead of one.
|
||||
await app
|
||||
.locator('explore-artist-details')
|
||||
.getByRole('button', { name: 'Back to explore' })
|
||||
.click();
|
||||
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
|
||||
|
||||
await app.goBack();
|
||||
|
||||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
+82
-25
@@ -50,6 +50,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 {
|
||||
hasTrackPayload,
|
||||
getDragPayload,
|
||||
@@ -64,6 +65,11 @@ 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();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View caching navigation system
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -154,10 +160,6 @@ const viewCache = new Map<string, HTMLElement>();
|
||||
let currentViewEl: HTMLElement | null = null;
|
||||
let currentDetailEl: HTMLElement | null = null;
|
||||
|
||||
/** Navigation history stack for back-button support in detail views. */
|
||||
const navStack: Array<{ view: string; [key: string]: any }> = [];
|
||||
/** The current navigation detail (so we can push it onto the stack). */
|
||||
let currentNavDetail: { view: string; [key: string]: any } = { view: 'home' };
|
||||
|
||||
const mainContent = document.getElementById('main-content');
|
||||
|
||||
@@ -190,6 +192,71 @@ document.addEventListener('navigate', (e: Event) => {
|
||||
void handleNavigate((e as CustomEvent).detail);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The platform's back gesture
|
||||
// ---------------------------------------------------------------------------
|
||||
// Android's back button is not a keystroke the page can bind: the
|
||||
// scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()` and
|
||||
// otherwise finishes the activity. This app never touched `history`, so
|
||||
// that was always false and back quit the app from any depth -- reported
|
||||
// from a device as "back does not navigate back".
|
||||
//
|
||||
// So a navigation is a history entry, and back is `popstate`. It hooks
|
||||
// the platform's own mechanism rather than a JNI callback of our own,
|
||||
// which is the same reason `events.ts` hooks the runtime's transport:
|
||||
// the Java half needs no change, and the behaviour is testable in a
|
||||
// browser (`page.goBack()`) instead of only on a phone.
|
||||
//
|
||||
// Two rules keep the two stacks from disagreeing. A navigation that
|
||||
// *came from* history pushes nothing (`_isBack`), or going back would
|
||||
// deepen the stack it is unwinding. And the in-app back buttons --
|
||||
// `navigate-back`, which the detail views and `now-playing-view` fire --
|
||||
// go through `history.back()` rather than popping `navStack`
|
||||
// themselves, so one press cannot consume two entries.
|
||||
|
||||
/** The navigation an entry stands for. `undefined` on the entry that
|
||||
* predates the app's own routing, which is the one back exits from. */
|
||||
type NavState = { yjNav?: { view: string; [key: string]: any } };
|
||||
|
||||
/** Whether the app's first navigation has been recorded. It *replaces*
|
||||
* the launch entry rather than pushing, or every launch would cost one
|
||||
* back press before the app would exit. */
|
||||
let historyStarted = false;
|
||||
|
||||
/** How many entries this session has pushed beyond that first one --
|
||||
* i.e. how deep back can go while staying inside the app. */
|
||||
let pushedEntries = 0;
|
||||
|
||||
function recordNavigation(detail: { view: string; [key: string]: any }): void {
|
||||
// `_isBack` is bookkeeping, not destination: keeping it in the entry
|
||||
// would make a replayed navigation claim to be a back-navigation.
|
||||
const { _isBack: _ignored, ...nav } = detail;
|
||||
const state: NavState = { yjNav: nav };
|
||||
|
||||
// Same URL, deliberately: the app has no routes, and a path a
|
||||
// reload cannot resolve is worse than no path at all.
|
||||
if (historyStarted) {
|
||||
history.pushState(state, '');
|
||||
pushedEntries += 1;
|
||||
} else {
|
||||
history.replaceState(state, '');
|
||||
historyStarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', (e: PopStateEvent) => {
|
||||
const nav = (e.state as NavState | null)?.yjNav;
|
||||
|
||||
// Before the app's first navigation, or an entry somebody else
|
||||
// pushed: nothing to restore, and the activity should be free to
|
||||
// finish.
|
||||
if (!nav) return;
|
||||
|
||||
pushedEntries = Math.max(0, pushedEntries - 1);
|
||||
|
||||
void handleNavigate({ ...nav, _isBack: true });
|
||||
});
|
||||
|
||||
async function handleNavigate(
|
||||
detail: { view: string; [key: string]: any },
|
||||
): Promise<void> {
|
||||
@@ -199,6 +266,8 @@ async function handleNavigate(
|
||||
|
||||
const seq = ++navSeq;
|
||||
|
||||
if (!detail._isBack) recordNavigation(detail);
|
||||
|
||||
// Bookkeeping stays synchronous with the click: the search box's
|
||||
// scope and the active-view attribute describe the navigation that
|
||||
// was *asked for*, and are what the rest of the app and the e2e
|
||||
@@ -212,9 +281,6 @@ async function handleNavigate(
|
||||
|
||||
// --- Primary (cacheable) views ----------------------------------------
|
||||
if (view in VIEW_TAGS) {
|
||||
// Navigating to a primary view clears the history stack.
|
||||
navStack.length = 0;
|
||||
|
||||
// Remove any active detail view first
|
||||
if (currentDetailEl) {
|
||||
deactivateView(currentDetailEl);
|
||||
@@ -247,7 +313,6 @@ async function handleNavigate(
|
||||
// the way out. Either way this is the call that starts it.
|
||||
activateView(target);
|
||||
currentViewEl = target;
|
||||
currentNavDetail = { view };
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -256,12 +321,6 @@ async function handleNavigate(
|
||||
if (seq !== navSeq) return;
|
||||
|
||||
// --- Detail (ephemeral) views -----------------------------------------
|
||||
// Push the current view onto the nav stack before switching
|
||||
// (unless this is a back-navigation, which already popped).
|
||||
if (!detail._isBack) {
|
||||
navStack.push({ ...currentNavDetail });
|
||||
}
|
||||
|
||||
// Hide the current primary view
|
||||
if (currentViewEl) {
|
||||
currentViewEl.classList.add('view-hidden');
|
||||
@@ -274,8 +333,6 @@ async function handleNavigate(
|
||||
currentDetailEl = null;
|
||||
}
|
||||
|
||||
currentNavDetail = { ...detail };
|
||||
|
||||
switch (view) {
|
||||
case 'artist-details': {
|
||||
const { artistId, artistName } = detail;
|
||||
@@ -419,16 +476,16 @@ function schedule(fn: () => void): void {
|
||||
setTimeout(fn, 200);
|
||||
}
|
||||
|
||||
// Navigate-back: pop the nav stack and re-dispatch as a regular navigate.
|
||||
// Navigate-back: the in-app back buttons, which are the same press as
|
||||
// the phone's. It goes through the history rather than a stack of its
|
||||
// own, so one press is one entry however it arrived -- two stacks is
|
||||
// how a detail view's own button and the back gesture come to disagree.
|
||||
//
|
||||
// At the root there is nothing of ours to go back to, and going back
|
||||
// anyway would leave the app: the depth check is what stops a stray
|
||||
// `navigate-back` closing it.
|
||||
document.addEventListener('navigate-back', () => {
|
||||
const prev = navStack.pop();
|
||||
if (prev) {
|
||||
document.dispatchEvent(new CustomEvent('navigate', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { ...prev, _isBack: true },
|
||||
}));
|
||||
}
|
||||
if (pushedEntries > 0) history.back();
|
||||
});
|
||||
|
||||
// Navigate to the user's configured launch page. Falls back to 'home'
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user