Playlists slotted three buttons totalling 390px into a header that gets 700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px with the queue closed, and 158 of 162 at the 800x600 enforced minimum. On a phone none of the three could be reached at all, which is what the Android report said. Plan 018's size matrix promises the opposite: no action is ever unreachable at any supported size. The header could not fix that for slotted markup, and that is a fact about the API rather than an effort estimate — a component cannot move another component's light-DOM children into a dropdown and keep their behaviour, and arbitrary markup offers nothing generic to render as a menu item. So a host passes `PageAction[]` and the header chooses the rendering; the slot survives for markup a data list cannot express, at the stated cost that a slotted action does not collapse. All three hosts that slot actions migrated, which also normalises the plain-<button>/<wa-button> split between them onto one shape the header styles — and lets it measure a button that has already upgraded, rather than a wa-button whose shadow DOM arrives in its own first update. Four things in it are load-bearing: - Every measuring pass starts from all-visible, so the collapsed set is a pure function of the current width and an action comes back when the window grows. It flips `hidden` imperatively rather than re-rendering between steps, or the intermediate state paints and the fix flashes the overflow it exists to prevent. - "Fits" means nothing is clipped, not that the header does not overflow. Once the title can ellipsis it absorbs the pressure and scrollWidth reports a perfect fit while the heading reads "Playlis…" — this bug moved from the button to the title, and invisible to the same measurement that missed it the first time. - New Playlist has the highest priority because it is the drop target and a closed menu cannot be one. `PageAction.drop` therefore carries the host's own handlers; the affordance is absent from the overflow rather than approximated there. - The overflow trigger is a named button with aria-expanded and an aria-controls naming a panel that is always in the DOM, and the keyboard model is the shared `MenuKeyboard`. `layout-overflow.spec.ts` passes on the broken build — it asserts the shell needs no sideways scrolling, and clipping inside a component is invisible to it, which is why this defect survived a spec named for it. The new spec measures each button against its own header at four viewports and asserts buttons plus menu account for every declared action, without which it would pass vacuously on a build rendering none. Closes #69
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import { test, expect } from '../support/fixtures.js';
|
||
|
||
/**
|
||
* #69: the Playlists header's buttons could not be reached.
|
||
*
|
||
* Three text buttons — Import (91px), New Playlist (122px), New Smart
|
||
* Playlist (162px), 390px in total — inside a header that gets 700px at
|
||
* 900×600. "New Smart Playlist" rendered **114 of its 162px**, and at
|
||
* phone width the Android report was the plain version of it: you
|
||
* cannot scroll to reach them, and scrolling is not how page controls
|
||
* should be exposed anyway.
|
||
*
|
||
* **`layout-overflow.spec.ts` passes on the broken build**, which is why
|
||
* this file exists rather than a case being added there. That spec
|
||
* asserts the *shell* needs no sideways scrolling; clipping *inside* a
|
||
* component is invisible to it. So the measurement here is per-button
|
||
* and per-header, against the widths the app promises.
|
||
*
|
||
* Plan 018's size matrix is the promise being kept: **no action is ever
|
||
* unreachable at any supported size.** These are its three bands.
|
||
*/
|
||
|
||
const VIEWPORTS = [
|
||
// Desktop's worst case, and not the enforced minimum: the sidebar
|
||
// collapses to icons *below* 900, so the content area is 843px at 899
|
||
// and 700px at 900. Testing "the minimum" and stopping misses it.
|
||
{ name: '900×600 (widest sidebar, narrowest content)', width: 900, height: 600 },
|
||
{ name: '800×600 (the enforced minimum)', width: 800, height: 600 },
|
||
{ name: '390×780 (phone)', width: 390, height: 780 },
|
||
// WCAG 1.4.10's reflow target, which plan 018 promises the app fits.
|
||
{ name: '320×600 (400% zoom)', width: 320, height: 600 },
|
||
];
|
||
|
||
/** Every action the Playlists header can offer, in declared order. */
|
||
const ACTIONS = ['Import', 'New Playlist', 'New Smart Playlist'];
|
||
|
||
/**
|
||
* What the header is actually rendering, measured rather than inferred.
|
||
*
|
||
* A shadow query is the wrong tool for *asserting* — that is what
|
||
* `getByRole` below is for — but it is the right one for a measurement,
|
||
* because the number this issue is about (a button 48px wider than the
|
||
* box holding it) is not in the accessibility tree at all.
|
||
*/
|
||
const headerFit = (page: import('@playwright/test').Page) =>
|
||
page.evaluate(() => {
|
||
const root = document
|
||
.querySelector('[data-testid="main-content"] playlist-view')
|
||
?.shadowRoot?.querySelector('page-header')?.shadowRoot;
|
||
|
||
if (!root) return null;
|
||
|
||
const header = root.querySelector<HTMLElement>('.page-header')!;
|
||
const box = header.getBoundingClientRect();
|
||
const title = root.querySelector<HTMLElement>('h1')!;
|
||
|
||
const clipped = [
|
||
...root.querySelectorAll<HTMLElement>('.action, .more-button'),
|
||
]
|
||
.filter((b) => !b.hidden)
|
||
.filter((b) => {
|
||
const r = b.getBoundingClientRect();
|
||
|
||
return r.right > box.right + 1 || r.left < box.left - 1;
|
||
})
|
||
.map((b) => b.dataset['actionId'] ?? 'more');
|
||
|
||
return {
|
||
overflow: header.scrollWidth - header.clientWidth,
|
||
clipped,
|
||
titleTruncated: title.scrollWidth > title.clientWidth + 1,
|
||
buttons: [...root.querySelectorAll<HTMLElement>('.action')]
|
||
.filter((b) => !b.hidden)
|
||
.map((b) => b.textContent?.trim() ?? ''),
|
||
menu: [
|
||
...root.querySelectorAll('#page-header-overflow wa-dropdown-item'),
|
||
].map((i) => i.textContent?.trim() ?? ''),
|
||
};
|
||
});
|
||
|
||
test.describe('the page header never clips an action', () => {
|
||
test.beforeEach(async ({ app }) => {
|
||
await app.getByTestId('nav-playlists').click();
|
||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||
'data-active-view',
|
||
'playlists',
|
||
);
|
||
});
|
||
|
||
test.afterEach(async ({ app }) => {
|
||
await app.setViewportSize({ width: 1280, height: 800 });
|
||
});
|
||
|
||
for (const vp of VIEWPORTS) {
|
||
test(`every action is reachable at ${vp.name}`, async ({ app }) => {
|
||
await app.setViewportSize({ width: vp.width, height: vp.height });
|
||
|
||
// Polled: the fit is decided by a ResizeObserver, so it settles a
|
||
// frame after the resize rather than with it.
|
||
await expect
|
||
.poll(async () => (await headerFit(app))?.clipped)
|
||
.toEqual([]);
|
||
|
||
const fit = (await headerFit(app))!;
|
||
|
||
expect(fit.overflow).toBeLessThanOrEqual(0);
|
||
|
||
// Between them, buttons and menu account for all three. This is
|
||
// the assertion the issue asks for: not "it fits" but "nothing
|
||
// was dropped to make it fit".
|
||
expect([...fit.buttons, ...fit.menu].sort()).toEqual([...ACTIONS].sort());
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The title gives way before an action does.
|
||
*
|
||
* Once the heading can ellipsis it absorbs the pressure, and
|
||
* `scrollWidth` then reports a header that fits perfectly while the
|
||
* heading reads "Playlis…" — this issue's own failure mode moved from
|
||
* the button to the title, and invisible to exactly the measurement
|
||
* that missed it the first time. At the desktop sizes there is always
|
||
* an action to collapse instead.
|
||
*/
|
||
test('does not truncate the heading to keep a button', async ({ app }) => {
|
||
for (const vp of VIEWPORTS.slice(0, 2)) {
|
||
await app.setViewportSize({ width: vp.width, height: vp.height });
|
||
|
||
await expect
|
||
.poll(async () => (await headerFit(app))?.titleTruncated)
|
||
.toBe(false);
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Asserted through the accessibility tree, never a shadow query. An
|
||
* overflow menu is exactly the shape that grows a nameless control,
|
||
* and this repo has shipped one four times — most recently the
|
||
* queue's own close button.
|
||
*/
|
||
test('the overflow is a named control that opens a named menu', async ({
|
||
app,
|
||
}) => {
|
||
await app.setViewportSize({ width: 900, height: 600 });
|
||
|
||
const more = app.getByRole('button', { name: 'More actions' });
|
||
|
||
await expect(more).toBeVisible();
|
||
await expect(more).toHaveAttribute('aria-expanded', 'false');
|
||
|
||
await more.click();
|
||
|
||
await expect(more).toHaveAttribute('aria-expanded', 'true');
|
||
|
||
const menu = app.getByRole('menu', { name: 'More actions' });
|
||
|
||
await expect(menu).toBeVisible();
|
||
|
||
// Collapsed at 900×600: Import (lowest priority) and New Smart
|
||
// Playlist. New Playlist stays a button because it is the drop
|
||
// target, and a closed menu cannot be one.
|
||
await expect(
|
||
menu.getByRole('menuitem', { name: 'Import' }),
|
||
).toBeVisible();
|
||
await expect(
|
||
app.getByRole('button', { name: 'New Playlist', exact: true }),
|
||
).toBeVisible();
|
||
});
|
||
|
||
/**
|
||
* The phone case is the original report. Every action is in the menu
|
||
* at 390px, and the menu is reachable by name — which is the whole of
|
||
* "these need to be reachable in a sensible way".
|
||
*/
|
||
test('offers every action from the menu on a phone', async ({ app }) => {
|
||
await app.setViewportSize({ width: 390, height: 780 });
|
||
|
||
const more = app.getByRole('button', { name: 'More actions' });
|
||
|
||
await expect(more).toBeVisible();
|
||
await more.click();
|
||
|
||
const menu = app.getByRole('menu', { name: 'More actions' });
|
||
|
||
for (const label of ACTIONS) {
|
||
await expect(menu.getByRole('menuitem', { name: label })).toBeVisible();
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Escape closes it and focus goes back to the trigger — `MenuKeyboard`
|
||
* is shared with every other menu in the app precisely so this is not
|
||
* a second keyboard model, and this is what proves it was wired up
|
||
* rather than merely imported.
|
||
*/
|
||
test('takes the keyboard, and gives it back', async ({ app }) => {
|
||
await app.setViewportSize({ width: 900, height: 600 });
|
||
|
||
const more = app.getByRole('button', { name: 'More actions' });
|
||
|
||
await more.click();
|
||
|
||
const menu = app.getByRole('menu', { name: 'More actions' });
|
||
|
||
await expect(menu).toBeVisible();
|
||
|
||
// The first item takes focus on open. `wa-dropdown-item` sets its
|
||
// own role in its own first update, so this is polled rather than
|
||
// read: a query at the host's updateComplete finds nothing, which
|
||
// reads exactly like a menu that refused to take focus.
|
||
await expect
|
||
.poll(async () =>
|
||
app.evaluate(() => {
|
||
// Stops where `MenuKeyboard`'s own `deepActiveElement` stops:
|
||
// on the *host* whose shadow root has no active element.
|
||
// Descending unconditionally lands inside the focused
|
||
// `wa-dropdown-item`'s own shadow root, where nothing is
|
||
// focused — which reads exactly like a menu that refused the
|
||
// keyboard, on a build where it did not.
|
||
let el = document.activeElement;
|
||
|
||
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
|
||
|
||
return el?.textContent?.trim() ?? null;
|
||
}),
|
||
)
|
||
.toBe('Import');
|
||
|
||
await app.keyboard.press('Escape');
|
||
|
||
await expect(more).toHaveAttribute('aria-expanded', 'false');
|
||
await expect(more).toBeFocused();
|
||
});
|
||
|
||
/**
|
||
* New Playlist is a drop target, and declaring it as data must not
|
||
* take that away — which is why a `PageAction` carries the drop
|
||
* handlers rather than the header owning a notion of dropping.
|
||
*
|
||
* Nothing covered this before, in either tier, and it is the one
|
||
* behaviour the migration could plausibly have destroyed silently:
|
||
* dragging still *looks* fine against a button that no longer
|
||
* accepts anything.
|
||
*/
|
||
test('New Playlist still accepts a dropped track', async ({ app }) => {
|
||
await app.setViewportSize({ width: 1280, height: 800 });
|
||
|
||
const button = app.getByRole('button', {
|
||
name: 'New Playlist',
|
||
exact: true,
|
||
});
|
||
|
||
await expect(button).toBeVisible();
|
||
|
||
const result = await app.evaluate(async () => {
|
||
const view = document.querySelector(
|
||
'[data-testid="main-content"] playlist-view',
|
||
)!;
|
||
const target = view.shadowRoot!
|
||
.querySelector('page-header')!
|
||
.shadowRoot!.querySelector('[data-testid="page-action-new-playlist"]')!;
|
||
|
||
const data = new DataTransfer();
|
||
|
||
data.setData(
|
||
'application/x-yj-tracks',
|
||
JSON.stringify({ filePaths: ['/tmp/dropped.mp3'] }),
|
||
);
|
||
|
||
const fire = (type: string) =>
|
||
target.dispatchEvent(
|
||
new DragEvent(type, {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
dataTransfer: data,
|
||
}),
|
||
);
|
||
|
||
fire('dragover');
|
||
await new Promise((r) => setTimeout(r, 50));
|
||
|
||
// The affordance is the host's state reaching the header's
|
||
// button, which is the half a plain handler call would not prove.
|
||
const highlighted = target.classList.contains('drag-over');
|
||
|
||
fire('drop');
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
|
||
return {
|
||
highlighted,
|
||
opened: view.shadowRoot!.querySelector('.create-form') !== null,
|
||
};
|
||
});
|
||
|
||
expect(result).toEqual({ highlighted: true, opened: true });
|
||
|
||
// Leave the view as it was found.
|
||
await app.keyboard.press('Escape');
|
||
});
|
||
|
||
/**
|
||
* An action given back when the window widens again. The collapsed
|
||
* set is a function of the current width and not of how it got there
|
||
* — a rule that only ever *added* to it would never widen.
|
||
*/
|
||
test('gives the buttons back when the window grows', async ({ app }) => {
|
||
await app.setViewportSize({ width: 390, height: 780 });
|
||
|
||
await expect.poll(async () => (await headerFit(app))?.buttons).toEqual([]);
|
||
|
||
await app.setViewportSize({ width: 1440, height: 900 });
|
||
|
||
await expect
|
||
.poll(async () => (await headerFit(app))?.buttons)
|
||
.toEqual(ACTIONS);
|
||
await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]);
|
||
});
|
||
});
|