Decide the supported sizes, and stop the queue taking the page's width #132

Merged
logan merged 4 commits from feat/24-supported-sizes-queue-model into main 2026-08-19 15:22:40 +00:00
4 changed files with 610 additions and 0 deletions
Showing only changes of commit 4025106234 - Show all commits
+187
View File
@@ -0,0 +1,187 @@
import { test, expect } from '../support/fixtures.js';
/**
* #24 — the queue panel does not take the page's width away from it.
*
* The panel is `flex-shrink: 0` in the flow of `.content-area`, so an
* open queue used to be paid for by the main panel. Measured on
* Playlists before the fix:
*
* | viewport | main panel |
* |---|---|
* | 900×600 | 379px — all three header actions clipped |
* | 390×780 | 69px |
* | 320×600 | **0px** |
*
* **900×600 is the worst desktop case, not the 800×600 minimum**, and
* that is the trap this file exists to keep closed: the sidebar
* collapses to icons *below* 900, so the main panel is 843px at 899 and
* 700px at 900. A spec that checks "the minimum" and stops has not
* checked the worst case — which is what every viewport list in this
* suite did before this.
*
* These assert the *content's* width rather than the panel's mode
* wherever they can, because the mode is the mechanism and the width is
* the complaint.
*/
/** The bands from plan 018's size matrix, plus the pixel above the collapse. */
const BANDS = [
{ name: 'a wide desktop (1280×800)', width: 1280, height: 800, inline: true },
{ name: 'the default window (1100×720)', width: 1100, height: 720, inline: true },
{ name: 'a laptop (1024×768)', width: 1024, height: 768, inline: true },
{ name: 'the worst desktop width (900×600)', width: 900, height: 600, inline: false },
{ name: 'the enforced minimum (800×600)', width: 800, height: 600, inline: false },
{ name: 'a phone (390×780)', width: 390, height: 780, inline: false },
{ name: '400% zoom (320×600)', width: 320, height: 600, inline: false },
];
/**
* How much room the content has, and whether the shell needs scrolling
* to reach any of itself.
*/
const shellGeometry = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const main = document.querySelector('#main-content')!.getBoundingClientRect();
const panel = document.querySelector('#queue-panel')!;
return {
mainWidth: Math.round(main.width),
overlay: panel.hasAttribute('overlay'),
open: panel.hasAttribute('open'),
bodyScrollWidth: document.body.scrollWidth,
bodyClientWidth: document.body.clientWidth,
};
});
async function openQueue(page: import('@playwright/test').Page) {
const toggle = page.locator('#queue-button');
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
}
test.describe('an open queue leaves the content its width', () => {
for (const band of BANDS) {
test(`at ${band.name}`, async ({ app }) => {
await app.setViewportSize({ width: band.width, height: band.height });
await openQueue(app);
// The mode is settled by a ResizeObserver, so poll rather than
// read once: a single read races the resize and reports the
// previous viewport's answer.
await expect
.poll(async () => (await shellGeometry(app)).overlay)
.toBe(!band.inline);
const geo = await shellGeometry(app);
// The floor is the point of the whole issue. Inline, the queue is
// affordable and the content keeps the rest; as an overlay the
// content keeps *everything*, which is what makes 0px at 320
// impossible rather than merely unlikely.
expect(geo.mainWidth).toBeGreaterThanOrEqual(320);
if (!band.inline) {
expect(geo.mainWidth).toBeGreaterThanOrEqual(
Math.min(band.width, 320),
);
}
// And opening the queue must not make the shell overflow.
expect(geo.bodyScrollWidth).toBeLessThanOrEqual(geo.bodyClientWidth);
});
}
});
test.describe('an overlaid queue says it is over the content', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize({ width: 900, height: 600 });
});
test('draws a scrim and closes when it is clicked', async ({ app }) => {
await openQueue(app);
const panel = app.locator('#queue-panel');
await expect(panel).toHaveAttribute('overlay', '');
// The scrim is `aria-hidden` on purpose — it is a dismissal target,
// and the named routes out are the close button and Escape — so it
// is located structurally rather than by role.
await panel.evaluate((el) =>
el.shadowRoot!.querySelector<HTMLElement>('.scrim')!.click(),
);
await expect(app.locator('#queue-button')).toHaveAttribute(
'aria-expanded',
'false',
);
});
/**
* `getByRole`, not a shadow-root query: this repo has shipped a
* nameless control three times, and a drawer with a scrim is exactly
* the shape that grows a fourth.
*/
test('offers a named close button', async ({ app }) => {
await openQueue(app);
const close = app.getByRole('button', { name: 'Close queue' });
await expect(close).toBeVisible();
await close.click();
await expect(app.locator('#queue-button')).toHaveAttribute(
'aria-expanded',
'false',
);
});
test('closes on Escape and gives focus back to the toggle', async ({
app,
}) => {
const toggle = app.locator('#queue-button');
await toggle.focus();
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await app.keyboard.press('Escape');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(toggle).toBeFocused();
});
});
/**
* The inline panel is the mode that already worked, and the one every
* other queue spec is written against. It keeps its resize handle and
* gains none of the overlay's chrome.
*/
test.describe('a wide window keeps the queue beside the content', () => {
test('no scrim, no close button, and the content is narrower', async ({
app,
}) => {
await app.setViewportSize({ width: 1280, height: 800 });
const widthWithoutQueue = (await shellGeometry(app)).mainWidth;
await openQueue(app);
await expect(app.locator('#queue-panel')).not.toHaveAttribute(
'overlay',
'',
);
const geo = await shellGeometry(app);
expect(geo.mainWidth).toBeLessThan(widthWithoutQueue);
await expect(
app.getByRole('button', { name: 'Close queue' }),
).toHaveCount(0);
});
});
+8
View File
@@ -231,6 +231,14 @@ body div.sidebar {
display: flex;
overflow: hidden;
contain: layout style;
/* The containing block for the queue panel's overlay mode (plan
018, #24), which spans this box rather than taking width from
the main panel beside it. `contain: layout` already establishes
one; this says so on purpose, so that removing the containment
for a paint reason does not silently reparent the overlay to the
viewport. */
position: relative;
}
.main-panel {
@@ -72,6 +72,24 @@ const MIN_WIDTH = 200;
const MAX_WIDTH = 500;
const DEFAULT_WIDTH = 320;
/**
* The narrowest main panel the queue is allowed to leave behind before
* it stops being a column and becomes an overlay (plan 018, issue #24).
*
* There is no cliff to derive this from, and pretending otherwise would
* be the more dishonest answer: the track list rescales its columns
* continuously (213px down to 124px between main widths of 900 and 544,
* with no row overflow at any of them) and the album grid steps 3
* columns to 2 without breaking. So this is a judgement, anchored at
* both ends — it keeps the *default* 1100px window inline, because an
* inline queue is a desktop affordance people choose and demoting the
* common case to an overlay would be a regression in feel; and it puts
* every case measured as broken on the overlay side, which is 900x600
* (main = 379px, where all three of the Playlists header's actions are
* clipped) and every phone width (main = 69px at 390, 0px at 320).
*/
const MAIN_PANEL_FLOOR = 480;
@customElement('queue-panel')
export class QueuePanel
extends LitElement
@@ -85,6 +103,22 @@ export class QueuePanel
@property({ type: Boolean, reflect: true })
open = false;
/**
* Whether the panel is covering the content instead of sitting
* beside it. **Computed, never set by a caller** — it is reflected
* so the stylesheet and a spec can both read it.
*
* It is deliberately *not* a media query, which is the whole reason
* this is a property and not a `@media` block. The panel's width is
* user state: drag-resizable between MIN_WIDTH and MAX_WIDTH and
* persisted. A breakpoint at a fixed viewport width silently
* assumes the default 320, so it is wrong by up to 180px for a user
* who has widened the panel — in the direction that hurts, since a
* wider queue is exactly when the content can least afford it.
*/
@property({ type: Boolean, reflect: true })
overlay = false;
@state()
private isDragging = false;
@@ -190,6 +224,19 @@ export class QueuePanel
private panelWidth = DEFAULT_WIDTH;
private scrollbarDragging = false;
/** Watches `.content-area`, which is the viewport minus the sidebar. */
private spaceObserver?: ResizeObserver;
/**
* What had focus when the overlay opened, so Escape and the scrim
* can give it back. Focus is only taken back if the panel had it —
* the same rule `MenuKeyboard` follows, for the same reason: the
* queue can also be closed by the button in the bottom bar, and
* yanking focus away from wherever the user actually is would be
* worse than leaving it.
*/
private overlayOpener: HTMLElement | null = null;
// _itemSize is an internal property applied via Object.assign in BaseLayout's
// config setter. Setting it to match the actual fixed .track-item height (49px)
// prevents lit-virtualizer's scroll error correction from fighting the native
@@ -265,6 +312,86 @@ export class QueuePanel
border-left: 1px solid var(--yj-border-subtle, #333);
}
/* ---------------------------------------------------------
Overlay mode (plan 018, #24).
In flow the panel takes its width *from the main panel*,
which is the reported bug: at 900x600 that left 379px and
clipped every action in the Playlists header, and at 320px
it left 0px — the content was not degraded but gone.
Here the host spans the whole content area instead and
stops being a layout participant, so the main panel keeps
its full width and the queue sits over it. The host itself
is transparent and click-through; the scrim and the panel
are what take pointer events. The containment drops paint,
which would otherwise clip the panel's own shadow.
--------------------------------------------------------- */
:host([overlay]) {
position: absolute;
inset: 0;
width: auto;
background-color: transparent;
overflow: visible;
pointer-events: none;
contain: layout style;
z-index: 20;
}
/* Closed, an overlay is not there at all. In flow the panel is
width: 0, which is its own way of saying this; absolutely
positioned there is no width to collapse. */
:host([overlay]:not([open])) {
display: none;
}
:host([overlay][open]) {
border-left: none;
}
:host([overlay]) .panel-content {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: var(--queue-width, ${unsafeCSS(DEFAULT_WIDTH)}px);
max-width: 100%;
box-sizing: border-box;
background-color: var(--yj-bg-surface, #212529);
border-left: 1px solid var(--yj-border-subtle, #333);
box-shadow: -8px 0 24px rgb(0 0 0 / 45%);
pointer-events: auto;
}
/* Dragging the edge of something that is already covering the
content answers a question nobody asked, and it is a
mouse-only affordance either way. */
:host([overlay]) .resize-handle {
display: none;
}
.scrim {
position: absolute;
inset: 0;
background-color: rgb(0 0 0 / 45%);
pointer-events: auto;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
}
/* The phone gets the whole width: below 600 there is no
"beside" left to be, and this is the shape #55 turns into a
real screen. A media query inside a shadow root is answered
by the viewport, so the component states this itself rather
than the shell reaching in. */
@media (max-width: 599px) {
:host([overlay]) .panel-content {
width: 100%;
}
}
.resize-handle {
position: absolute;
top: 0;
@@ -656,6 +783,20 @@ export class QueuePanel
'--queue-width',
`${this.panelWidth}px`,
);
// The mode is a measurement, so it is observed rather than
// computed once: the parent is `.content-area`, whose width is
// the viewport minus the sidebar — including the sidebar's own
// collapse at 900px, which is what makes 900 the *worst*
// desktop width rather than the minimum.
this.updateOverlayMode();
if (this.parentElement) {
this.spaceObserver = new ResizeObserver(() =>
this.updateOverlayMode(),
);
this.spaceObserver.observe(this.parentElement);
}
document.addEventListener(
'mousemove',
this.handleMouseMove,
@@ -690,6 +831,9 @@ export class QueuePanel
super.disconnectedCallback();
this.creditsUnsub?.();
this.creditsUnsub = undefined;
this.spaceObserver?.disconnect();
this.spaceObserver = undefined;
document.removeEventListener('keydown', this.onOverlayKeydown);
document.removeEventListener(
'mousemove',
this.handleMouseMove,
@@ -736,7 +880,79 @@ export class QueuePanel
this.delegationAttached = false;
}
/**
* Decide whether the queue can afford to be a column.
*
* The parent is `.content-area`, so its width is the viewport minus
* the sidebar and the sum already accounts for the sidebar's own
* collapse. It is stable across the panel's own open/closed state
* in both modes — in flow the panel is a child of that box, and as
* an overlay it is out of flow — so this cannot oscillate.
*/
private updateOverlayMode = () => {
const available = this.parentElement?.clientWidth ?? 0;
// Before layout there is nothing to measure, and answering 0 by
// flipping to overlay would show the scrim for a frame.
if (available === 0) return;
this.overlay = available - this.panelWidth < MAIN_PANEL_FLOOR;
};
/**
* Escape closes a scrimmed overlay, which is the one keyboard rule
* every dialog in this app already follows.
*
* It is a document listener rather than a panel-scoped binding
* (`services/shortcut-scope.ts`) because it is not a *shortcut*: it
* is the dismissal of something covering the page, and it has to
* work while focus is still behind the scrim. It is attached only
* while the overlay is actually up and removed on close, so it is
* scoped to a state rather than being a permanent global. Nothing
* else binds Escape — the shortcut service only uses it to blur a
* text input.
*/
private onOverlayKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || !this.open || !this.overlay) return;
e.stopPropagation();
this.closeFromOverlay();
};
private closeFromOverlay = () => {
const hadFocus = this.contains(
document.activeElement as Node | null,
);
this.open = false;
if (hadFocus) {
const back =
this.overlayOpener ??
document.getElementById('queue-button');
back?.focus();
}
this.overlayOpener = null;
};
override updated() {
// The overlay owns Escape only while it is up.
if (this.open && this.overlay) {
document.addEventListener('keydown', this.onOverlayKeydown);
this.overlayOpener ??=
document.activeElement instanceof HTMLElement &&
document.activeElement !== document.body
? document.activeElement
: null;
} else {
document.removeEventListener('keydown', this.onOverlayKeydown);
if (!this.open) this.overlayOpener = null;
}
// Closed, the panel is `width: 0` — which hides it from the eye
// and from nobody else: its Clear and Add buttons still took tab
// stops at x=1440 and were still read out (H-5). `inert` is the
@@ -1631,6 +1847,11 @@ export class QueuePanel
'--queue-width',
`${clampedWidth}px`,
);
// Widening the panel is one of the two ways the content can run
// out of room, and it is the way a viewport-width media query
// cannot see at all.
this.updateOverlayMode();
};
private handleMouseUp = () => {
@@ -1714,6 +1935,15 @@ export class QueuePanel
const tracks = this.queue.tracks;
return html`
${this.overlay
? html`<div
class="scrim"
part="scrim"
data-testid="queue-scrim"
aria-hidden="true"
@click=${this.closeFromOverlay}
></div>`
: nothing}
<div class="panel-content">
<div
class="resize-handle ${this.isDragging
@@ -1763,6 +1993,16 @@ export class QueuePanel
name=${ICON_PLAYLIST}
></wa-icon>
</button>
${this.overlay
? html`<button
class="header-action-button"
data-testid="queue-close"
aria-label="Close queue"
@click=${this.closeFromOverlay}
>
<wa-icon name="xmark"></wa-icon>
</button>`
: nothing}
</div>
</div>
@@ -0,0 +1,175 @@
/**
* #24 — the queue stops being a column when it cannot afford to be one.
*
* In flow the panel is `flex-shrink: 0`, so it takes its width *from
* the main panel* rather than covering it. Measured against the running
* app on the Playlists page, that left 379px of content at 900×600 —
* with all three of the page header's actions clipped — 69px at 390px
* wide, and **0px** at 320px, where the content was not degraded but
* gone.
*
* The rule is `available - panelWidth >= MAIN_PANEL_FLOOR`, and the
* reason it is a computed property rather than a `@media` block is the
* third test here: the panel's width is user state, drag-resizable
* between 200 and 500px and persisted, so a breakpoint on the viewport
* alone is wrong by up to 180px for a user who has widened it — in the
* direction that hurts, since a wider queue is exactly when the content
* can least afford it.
*
* The parent is `.content-area`, i.e. the viewport minus the sidebar,
* which is why these mount into a sized wrapper rather than into
* `document.body`: the width that decides this is the *parent's*, and
* `fixture()` would hand the panel the whole test window.
*/
import { describe, expect, it, afterEach } from 'vitest';
import '@components/queue-panel/queue-panel';
import type { QueuePanel } from '@components/queue-panel/queue-panel';
import { shadow } from '@test/support/render';
const wrappers: HTMLElement[] = [];
afterEach(() => {
for (const w of wrappers.splice(0)) w.remove();
});
/**
* Mount a panel inside a parent of a stated width.
*
* The wrapper is `position: relative` and `display: flex` because that
* is what `.content-area` is; the mode is measured from
* `parentElement.clientWidth`, so a wrapper that collapses to its
* content would measure the panel rather than the space around it.
*/
async function panelIn(parentWidth: number): Promise<QueuePanel> {
const wrapper = document.createElement('div');
wrapper.style.cssText = `position: relative; display: flex; width: ${parentWidth}px;`;
document.body.append(wrapper);
wrappers.push(wrapper);
const el = document.createElement('queue-panel') as QueuePanel;
el.open = true;
wrapper.append(el);
await el.updateComplete;
await settle(el);
return el;
}
/**
* A ResizeObserver delivers on a frame, not a microtask, so the mode
* lands a frame after the width that decides it.
*/
async function settle(el: QueuePanel): Promise<void> {
for (let frame = 0; frame < 4; frame += 1) {
await new Promise((r) => {
requestAnimationFrame(() => r(null));
});
await el.updateComplete;
}
}
/** Drag the resize handle by `dx`, the way a user widens the panel. */
async function dragHandleBy(el: QueuePanel, dx: number): Promise<void> {
const handle = shadow<HTMLElement>(el, '.resize-handle');
const startX = el.getBoundingClientRect().left;
if (!handle) throw new Error('no resize handle to drag');
handle.dispatchEvent(
new MouseEvent('mousedown', { clientX: startX, bubbles: true }),
);
document.dispatchEvent(
new MouseEvent('mousemove', { clientX: startX - dx, bubbles: true }),
);
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
await settle(el);
}
describe('the queue panel decides whether it can be a column', () => {
it('stays inline while the content can spare the width', async () => {
const el = await panelIn(1080);
expect(el.overlay).toBe(false);
expect(el.hasAttribute('overlay')).toBe(false);
});
it('becomes an overlay when it cannot', async () => {
const el = await panelIn(700);
expect(el.overlay).toBe(true);
expect(el.hasAttribute('overlay')).toBe(true);
});
/**
* The test the media query could not have passed. The parent does not
* move; only the user's own panel width does.
*/
it('flips to overlay when the user widens the panel, at a fixed width', async () => {
const el = await panelIn(880);
expect(el.overlay).toBe(false);
await dragHandleBy(el, 180);
expect(el.overlay).toBe(true);
});
it('gives an overlay a scrim and a named way out, and an inline panel neither', async () => {
const overlaid = await panelIn(700);
expect(shadow(overlaid, '.scrim')).toBeTruthy();
const close = shadow(overlaid, '[data-testid="queue-close"]');
expect(close?.getAttribute('aria-label')).toBe('Close queue');
const inline = await panelIn(1080);
expect(inline.shadowRoot?.querySelector('.scrim')).toBeNull();
expect(
inline.shadowRoot?.querySelector('[data-testid="queue-close"]'),
).toBeNull();
});
it('closes on the scrim, on the close button and on Escape', async () => {
for (const close of [
(el: QueuePanel) => shadow<HTMLElement>(el, '.scrim')?.click(),
(el: QueuePanel) =>
shadow<HTMLElement>(el, '[data-testid="queue-close"]')?.click(),
() =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
),
]) {
const el = await panelIn(700);
expect(el.open).toBe(true);
close(el);
await el.updateComplete;
expect(el.open).toBe(false);
}
});
/**
* Escape belongs to the overlay, not to the queue. An inline panel is
* beside the content rather than over it, so there is nothing to
* dismiss and the key has to reach whatever else wants it.
*/
it('leaves Escape alone while inline', async () => {
const el = await panelIn(1080);
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await el.updateComplete;
expect(el.open).toBe(true);
});
});