The panel is flex-shrink: 0 in the flow of .content-area, so an open queue was paid for by the main panel rather than covering it. Measured on Playlists: 379px of content left at 900x600 with all three of the page header's actions clipped, 69px at 390px, and 0px at 320px — where the content was not degraded but gone. It goes to an overlay with a scrim when the content cannot spare the width, and the rule is computed rather than breakpointed: `available - panelWidth < 480`, where available is .content-area's width and so already accounts for the sidebar's collapse at 900. A media query cannot express this, which is the reason for the property: the panel is drag-resizable between 200 and 500px and persisted, so a viewport breakpoint silently assumes the default 320 and is wrong by up to 180px for a user who widened it — in the direction that hurts, since a wider queue is exactly when the content can least afford it. 480 is a judgement and the comment says so: there is no cliff to derive it from (the track list rescales continuously, 213px to 124px columns with no row overflow), so it is anchored to keep the default 1100px window inline while putting every measured-broken case on the overlay side. The overlay is a presentation and not a fork — #55 asks for one component with two mount points — so the roving tab stop, Alt+Arrow reorder, drag reorder and selection semantics are untouched. Escape closes it and returns focus, attached only while the overlay is up: it is a dismissal rather than a shortcut, which is why it is not a panel-scoped binding. The scrim covers the content area only, not the sidebar or the transport, because the queue is not modal. Refs #24
176 lines
5.5 KiB
TypeScript
176 lines
5.5 KiB
TypeScript
/**
|
||
* #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);
|
||
});
|
||
});
|