Files
yellowjacket/frontend/test/components/queue-overlay-mode.test.ts
T
logan 7ba5d321f6
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 9m18s
test(queue): pin the breakpoint listener the scrim rule rests on
The scrim's existence comes from matchMedia rather than a stylesheet, which only holds if the query is listened to — and the stub's addEventListener was a no-op, so deleting the listener left all 986 tests green. The stub records its listeners now and the new case carries a panel across the breakpoint in both directions. Watched failing with the listener removed.
2026-08-21 16:24:04 +00:00

301 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* #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[] = [];
let restoreMedia: (() => void) | null = null;
/**
* Answer the shell's phone query with `phone` until restored.
*
* Stubbed rather than emulated, for the reason `search-dialog.test.ts`
* gives: the runner's viewport is fixed at 1280x800, and the panel
* reads `matchMedia` in `connectedCallback` precisely so a test can
* answer it first.
*/
/**
* Answering the phone query is not enough on its own: what decides
* whether the scrim exists is a `change` listener, and a stub whose
* `addEventListener` is a no-op leaves that listener untested — the
* whole suite stays green with it deleted. So the stub records the
* listeners and hands back a way to fire them.
*/
function stubPhone(phone: boolean): (next: boolean) => void {
const real = window.matchMedia.bind(window);
const listeners = new Set<(e: MediaQueryListEvent) => void>();
let matches = phone;
window.matchMedia = ((q: string) =>
q.includes('max-width: 599px')
? {
get matches() {
return matches;
},
media: q,
addEventListener(_: string, fn: (e: MediaQueryListEvent) => void) {
listeners.add(fn);
},
removeEventListener(_: string, fn: (e: MediaQueryListEvent) => void) {
listeners.delete(fn);
},
}
: real(q)) as typeof window.matchMedia;
restoreMedia = () => {
window.matchMedia = real;
};
return (next: boolean) => {
matches = next;
for (const fn of listeners) {
fn({ matches: next } as MediaQueryListEvent);
}
};
}
afterEach(() => {
for (const w of wrappers.splice(0)) w.remove();
restoreMedia?.();
restoreMedia = null;
});
/**
* 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);
}
});
/**
* #171 — the scrim is a dismissal target, so it exists only where it
* has pixels to be tapped.
*
* Below 600px `.panel-content` is `width: 100%`, so the scrim is
* entirely underneath an opaque panel: measured at 424x439, host,
* panel and scrim all 424x318. Drawing it there is a `cursor:
* pointer` click target nobody can reach, and the queue is a screen
* at that width anyway (#55) — back and the close button are its ways
* out. Existence rather than `display: none`, because a hidden scrim
* is still an element carrying the handler.
*/
it('draws no scrim at phone width, where it would have no reachable pixels', async () => {
stubPhone(true);
const el = await panelIn(424);
expect(el.overlay).toBe(true);
expect(el.shadowRoot?.querySelector('.scrim')).toBeNull();
// The way out a thumb can hit is still there.
expect(
shadow(el, '[data-testid="queue-close"]')?.getAttribute('aria-label'),
).toBe('Close queue');
});
/**
* The 600899 band is where the panel is a 320px column of a wider
* content area, so the scrim has uncovered pixels and #24's
* tap-outside-to-close is real. Same width as the overlay tests
* above, with the phone query explicitly answered `false`, so this
* fails if the scrim is ever dropped for every overlay.
*/
it('keeps the scrim above phone width, where it can be tapped', async () => {
stubPhone(false);
const el = await panelIn(700);
expect(el.overlay).toBe(true);
shadow<HTMLElement>(el, '.scrim')?.click();
await el.updateComplete;
expect(el.open).toBe(false);
});
/**
* The scrim's existence comes from `matchMedia` rather than a
* stylesheet, which only holds up if the query is *listened* to — a
* panel opened on a desktop and carried across the breakpoint (a
* resized window, an unfolded phone) has to lose its scrim without
* being reopened. Nothing else in this file fires `change`, so
* deleting the listener leaves the whole suite green.
*/
it('drops the scrim when the viewport crosses the breakpoint', async () => {
const setPhone = stubPhone(false);
const el = await panelIn(700);
expect(el.shadowRoot?.querySelector('.scrim')).not.toBeNull();
setPhone(true);
await el.updateComplete;
expect(el.shadowRoot?.querySelector('.scrim')).toBeNull();
setPhone(false);
await el.updateComplete;
expect(el.shadowRoot?.querySelector('.scrim')).not.toBeNull();
});
/**
* 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);
});
});