Every context menu in the app opens from a `contextmenu` event, bound three different ways across six components -- delegated on a virtualizer, per row, per card. A phone has no right-click, so a phone reached none of them (plan 016 B2 phase 3). This is one document-capture listener installed once from `index.ts`, not six components' worth of touch handling: a touch that holds still for 500ms dispatches a synthetic `contextmenu` at the touch point, and every existing handler runs unchanged. A seam no component has to opt into is one no future component can forget. Four details are load-bearing, each 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 long-press `contextmenu` (Chromium does; WebKit and the Android WebView vary) wins, and ours is told from theirs by identity rather than `isTrusted`: `isTrusted` works in the app and is untestable, which would leave the suppression path as the one thing with no coverage. And the click ending the gesture is swallowed, keyed on the gesture rather than a time window, or the first tap on the menu it just opened is eaten too. The e2e spec presses `.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.
128 lines
4.0 KiB
TypeScript
128 lines
4.0 KiB
TypeScript
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();
|
|
});
|
|
});
|