Below 600px `.panel-content` is `width: 100%`, so the scrim sat entirely underneath an opaque panel -- measured at 424x439, host, panel and scrim all 424x318. It dimmed nothing and dismissed nothing there while wearing `cursor: pointer`, so #24's tap-outside-to-close did not exist on the device it was drawn for. Of the issue's two directions this takes the second. A gutter is the drawer pattern and buys the affordance by taking width off a full-screen surface on a 424px viewport; #55 already made the queue a *screen* at that width, whose ways out are back and a 44px close button. So there is no scrim there rather than an unreachable one. Existence is `matchMedia` rather than `display: none`, on `job-band`'s rule: a hidden scrim is still an element carrying the handler. The 600-899 band, where the panel is a 320px column of a wider content area and the scrim has real uncovered pixels, is untouched. The e2e half asserts *absence* at 424x439 rather than clicking, because a phone-width case that clicks the scrim's centre hits the panel and passes on the broken build -- which the issue anticipates. Closes #171
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import { test, expect, openTheQueue } from '../support/fixtures.js';
|
||
|
||
/**
|
||
* #55 — the queue is a *place* while it covers the content, and a
|
||
* *control* while it sits beside it.
|
||
*
|
||
* #24 already made the pixels right: measured at the reference device's
|
||
* 424×439, the overlaid panel is 424×318, which is `.main-panel`'s rect
|
||
* exactly. What was missing was the navigation model, and the defect was
|
||
* measurable in one line — opening the queue on Artists and pressing
|
||
* back moved the page *underneath* to Albums and left the queue up. A
|
||
* back press that changes something the user cannot see, and costs them
|
||
* their place, is the whole of "it does not flow".
|
||
*
|
||
* **These assert the entry, not the attribute.** The temptation is to
|
||
* check `#queue-button[aria-expanded]` and stop, which is the shell's
|
||
* own bookkeeping and was right throughout the bug: what has to be true
|
||
* is that *one* back press closes the queue and the *next* one
|
||
* navigates. Asserting only the first would pass on a build that
|
||
* orphans the entry, which is the defect moved one press later — the
|
||
* same trap `back-navigation.spec.ts` documents about `data-active-view`
|
||
* and `layout-overflow.spec.ts` set for #69.
|
||
*
|
||
* **Three of these nine fail on the build before #55**, and the other
|
||
* six cannot, which is worth knowing before trusting them: "the entry
|
||
* is not orphaned" and "the column is not in the stack" are both
|
||
* vacuously true of a build that pushes no entry at all, and the
|
||
* containment assertion pins the mount that was *not* taken. They guard
|
||
* the next change rather than reproducing this one — the three that
|
||
* reproduce it are the two back-press tests and the touch target.
|
||
*/
|
||
type Page = import('@playwright/test').Page;
|
||
|
||
/** The reference device's real viewport, not a resized desktop. */
|
||
const DEVICE = { width: 424, height: 439 };
|
||
|
||
/** Wide enough that the queue is a column: 1280 − 200 − 320 ≥ 480. */
|
||
const DESKTOP = { width: 1280, height: 800 };
|
||
|
||
/**
|
||
* The Compact band, where the queue is a *screen* (644 − 320 < 480) and
|
||
* the bottom bar still carries its button.
|
||
*
|
||
* Two of these tests need both facts at once and only this band has
|
||
* them: below 600px #59 takes the button off the bar, so there is no
|
||
* toggle to re-press and the queue is opened from Now Playing — which
|
||
* is itself a detail view, so "the destination stays lit" is vacuously
|
||
* true there rather than tested.
|
||
*/
|
||
const COMPACT = { width: 700, height: 600 };
|
||
|
||
const activeView = (page: Page) => page.getByTestId('main-content');
|
||
const queue = (page: Page) => page.locator('#queue-panel');
|
||
const toggle = (page: Page) => page.locator('#queue-button');
|
||
|
||
/**
|
||
* Whether the queue is up.
|
||
*
|
||
* The panel's own attribute rather than the toggle's `aria-expanded`,
|
||
* because below 600px there is no toggle to ask (#59) — and the panel
|
||
* is the one fact both of them reflect anyway.
|
||
*/
|
||
async function expectQueue(page: Page, open: boolean): Promise<void> {
|
||
const panel = queue(page);
|
||
|
||
if (open) {
|
||
await expect(panel).toHaveAttribute('open', '');
|
||
} else {
|
||
await expect(panel).not.toHaveAttribute('open', '');
|
||
}
|
||
}
|
||
|
||
test.describe('the queue is a screen where it covers the content', () => {
|
||
test.beforeEach(async ({ app }) => {
|
||
await app.setViewportSize(DEVICE);
|
||
await app.getByTestId('tab-albums').click();
|
||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||
});
|
||
|
||
// On a phone the queue is opened from Now Playing (#59), so the page
|
||
// *underneath* it is `now-playing` and the journey is two entries
|
||
// deep: albums -> now-playing -> queue. That is the real route a user
|
||
// takes, which is why these do not reach for the shortcut.
|
||
|
||
test('back closes the queue and leaves the page where it was', async ({
|
||
app,
|
||
}) => {
|
||
await expect(queue(app)).toHaveAttribute('overlay', '');
|
||
|
||
await openTheQueue(app);
|
||
await expectQueue(app, true);
|
||
|
||
await app.goBack();
|
||
|
||
await expectQueue(app, false);
|
||
// The page underneath is untouched. Before #55 this was the
|
||
// *previous* view, because the queue was not in the stack at all
|
||
// and back spent an entry navigating something nobody could see.
|
||
await expect(activeView(app)).toHaveAttribute(
|
||
'data-active-view',
|
||
'now-playing',
|
||
);
|
||
});
|
||
|
||
test('costs exactly one entry, so the next press navigates', async ({
|
||
app,
|
||
}) => {
|
||
await openTheQueue(app);
|
||
await expectQueue(app, true);
|
||
|
||
await app.goBack();
|
||
await expectQueue(app, false);
|
||
await expect(activeView(app)).toHaveAttribute(
|
||
'data-active-view',
|
||
'now-playing',
|
||
);
|
||
|
||
await app.goBack();
|
||
|
||
// Exactly one entry each: the second press leaves Now Playing for
|
||
// the page it was opened from, rather than being swallowed by a
|
||
// queue that had already closed.
|
||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||
});
|
||
|
||
/**
|
||
* Every route out unwinds the entry, and they do it through the
|
||
* panel's own `open` attribute rather than each knowing about
|
||
* history — which is why a fourth route added later gets this free.
|
||
*
|
||
* The failure this pins is silent: close by button, and if the entry
|
||
* is orphaned the app looks correct until the next back press does
|
||
* nothing at all. It is a guard rather than a reproduction — a build
|
||
* with no entry to orphan passes it — and it is paired with the two
|
||
* above, which do reproduce.
|
||
*/
|
||
for (const [name, dismiss] of [
|
||
[
|
||
'the close button',
|
||
async (app: Page) => {
|
||
await app.getByRole('button', { name: 'Close queue' }).click();
|
||
},
|
||
],
|
||
[
|
||
'Escape',
|
||
async (app: Page) => {
|
||
await app.keyboard.press('Escape');
|
||
},
|
||
],
|
||
] as Array<[string, (app: Page) => Promise<void>]>) {
|
||
test(`${name} leaves no entry behind`, async ({ app }) => {
|
||
await openTheQueue(app);
|
||
await expectQueue(app, true);
|
||
|
||
await dismiss(app);
|
||
await expectQueue(app, false);
|
||
|
||
await app.goBack();
|
||
|
||
// One press, one screen: Now Playing is what the queue was opened
|
||
// from, so leaving it lands on Albums. An orphaned entry would
|
||
// have spent this press on nothing and left it here.
|
||
await expect(activeView(app)).toHaveAttribute(
|
||
'data-active-view',
|
||
'albums',
|
||
);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* A detail view leaves the destination it was opened from lit
|
||
* (`active-view-store`, #72), and the queue inherits that — it is
|
||
* published with `isPrimary: false`, so `isActive('albums')` is still
|
||
* true underneath it.
|
||
*
|
||
* `aria-current` rather than a class, for the reason
|
||
* `back-navigation.spec.ts` gives: the class was right throughout the
|
||
* bug that rule exists for.
|
||
*/
|
||
|
||
/**
|
||
* With the panel spanning the whole width there is no scrim here at
|
||
* all (#171), so the close button is the only pointer route out of a
|
||
* full-screen surface. Measured at 424×439 before #55: **25×21px**.
|
||
*/
|
||
test('offers a way out a thumb can hit', async ({ app }) => {
|
||
await openTheQueue(app);
|
||
|
||
const box = await app
|
||
.getByRole('button', { name: 'Close queue' })
|
||
.boundingBox();
|
||
|
||
expect(box).not.toBeNull();
|
||
expect(box!.width).toBeGreaterThanOrEqual(44);
|
||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||
});
|
||
|
||
/**
|
||
* #171 — and it draws no scrim, because there is nowhere to tap.
|
||
*
|
||
* `.panel-content` is `width: 100%` here, so the scrim sat entirely
|
||
* underneath it: measured at 424×439, host, panel and scrim all
|
||
* 424×318. #24's tap-outside-to-close cannot exist on a surface with
|
||
* no outside, and a `cursor: pointer` layer nobody can reach is a
|
||
* claim the component cannot keep.
|
||
*
|
||
* Asserted as absence rather than by clicking, for the reason the
|
||
* issue gives: a naive phone case clicks the scrim's centre and hits
|
||
* the panel, so it passes on the build this exists to fail. The scrim
|
||
* is still real between 600 and 899px, which `queue-overlay.spec.ts`
|
||
* asserts at 900×600 by clicking it.
|
||
*/
|
||
test('draws no scrim, because a screen has no outside to tap', async ({
|
||
app,
|
||
}) => {
|
||
await openTheQueue(app);
|
||
|
||
const scrim = await queue(app).evaluate(
|
||
(el) => el.shadowRoot!.querySelector('.scrim') !== null,
|
||
);
|
||
|
||
expect(scrim).toBe(false);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* **The mechanism, because no tier here can see the consequence.**
|
||
*
|
||
* #55's Direction asked for a `DETAIL_LOADERS` mount, which would put
|
||
* the panel inside `.main-panel > *`. That box is paint-contained under
|
||
* a `.main-panel` that is too, and `contain: paint` makes an element a
|
||
* containing block for fixed descendants *and clips them* — which is
|
||
* what a `wa-popup` falls back to on the reference device's Chrome 113,
|
||
* where the Popover API does not exist (#60, `.planning/NOTES.md`).
|
||
* `queue-panel` has a context menu, so that mount would have broken a
|
||
* working menu on the one device this issue is about.
|
||
*
|
||
* CI's Chromium and WebKit both *have* the Popover API, so the menu is
|
||
* top-layered and correct here either way: a spec asserting "the menu is
|
||
* not clipped" is green on the broken build. What a browser can answer
|
||
* honestly is where the element is, so that is what this asks.
|
||
*/
|
||
test('the panel stays out of the paint-contained region', async ({ app }) => {
|
||
await app.setViewportSize(DEVICE);
|
||
|
||
// Open, because that is the only state in which a menu can be opened
|
||
// from it — and because the host drops `paint` from its own
|
||
// containment deliberately in overlay mode, so a closed panel answers
|
||
// a different question.
|
||
await openTheQueue(app);
|
||
await expectQueue(app, true);
|
||
|
||
const ancestry = await app.evaluate(() => {
|
||
const chain: Array<{ tag: string; contain: string }> = [];
|
||
|
||
for (
|
||
let el = document.getElementById('queue-panel');
|
||
el && el !== document.documentElement;
|
||
el = el.parentElement
|
||
) {
|
||
chain.push({
|
||
tag: el.tagName.toLowerCase(),
|
||
contain: getComputedStyle(el).contain,
|
||
});
|
||
}
|
||
|
||
return chain;
|
||
});
|
||
|
||
expect(ancestry.length).toBeGreaterThan(1);
|
||
expect(ancestry.some((a) => a.tag === 'main')).toBe(false);
|
||
|
||
for (const { tag, contain } of ancestry) {
|
||
expect(
|
||
`${tag}: ${contain}`,
|
||
'a paint-contained ancestor clips a fixed-positioned popup on Chrome 113',
|
||
).not.toMatch(/paint|content|strict/);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Two properties need the queue to be a *screen* and the bar to still
|
||
* have its button, and only the Compact band has both — below 600px #59
|
||
* takes the button off the bar.
|
||
*/
|
||
test.describe('a screen opened from the bar', () => {
|
||
test.beforeEach(async ({ app }) => {
|
||
await app.setViewportSize(COMPACT);
|
||
await app.getByTestId('nav-albums').click();
|
||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||
await expect(queue(app)).toHaveAttribute('overlay', '');
|
||
});
|
||
|
||
/**
|
||
* A detail view leaves the destination it was opened from lit
|
||
* (`active-view-store`, #72), and the queue inherits that — it is
|
||
* published with `isPrimary: false`, so `isActive('albums')` is still
|
||
* true underneath it.
|
||
*
|
||
* `aria-current` rather than a class, for the reason
|
||
* `back-navigation.spec.ts` gives: the class was right throughout the
|
||
* bug that rule exists for.
|
||
*/
|
||
test('leaves the destination it was opened from highlighted', async ({
|
||
app,
|
||
}) => {
|
||
// By testid, not by role: at 700px the sidebar is in icon mode, so
|
||
// what the item is *named* is a different question from which item
|
||
// it is. The assertion is still `aria-current`, which is the
|
||
// accessible fact.
|
||
const albums = app.getByTestId('nav-albums');
|
||
|
||
await expect(albums).toHaveAttribute('aria-current', 'page');
|
||
|
||
await toggle(app).click();
|
||
await expectQueue(app, true);
|
||
|
||
await expect(albums).toHaveAttribute('aria-current', 'page');
|
||
});
|
||
|
||
/** The toggle is a fourth way out, and it unwinds the entry like the
|
||
* other three — through the panel's attribute, not its own handler. */
|
||
test('closes from the same toggle, leaving no entry behind', async ({
|
||
app,
|
||
}) => {
|
||
await toggle(app).click();
|
||
await expectQueue(app, true);
|
||
|
||
await toggle(app).click();
|
||
await expectQueue(app, false);
|
||
|
||
await app.goBack();
|
||
|
||
await expect(activeView(app)).not.toHaveAttribute(
|
||
'data-active-view',
|
||
'albums',
|
||
);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* The column is not a place. Somebody docked it; back must not undock
|
||
* it, and navigating to another view must not take it away.
|
||
*
|
||
* This is the half a viewport breakpoint would get wrong: the mode is
|
||
* computed from the panel's own drag-resizable width, so the queue
|
||
* becomes a screen exactly when it stops being affordable as a column.
|
||
*/
|
||
test.describe('a docked queue is not in the back stack', () => {
|
||
test.beforeEach(async ({ app }) => {
|
||
await app.setViewportSize(DESKTOP);
|
||
});
|
||
|
||
test('survives a navigation, and back navigates the page', async ({
|
||
app,
|
||
}) => {
|
||
await app.getByTestId('nav-albums').click();
|
||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||
|
||
await toggle(app).click();
|
||
await expectQueue(app, true);
|
||
await expect(queue(app)).not.toHaveAttribute('overlay', '');
|
||
|
||
await app.getByTestId('nav-artists').click();
|
||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
|
||
await expectQueue(app, true);
|
||
|
||
await app.goBack();
|
||
|
||
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
|
||
await expectQueue(app, true);
|
||
});
|
||
});
|