diff --git a/frontend/index.ts b/frontend/index.ts index cdaf1c9..f5b1f6b 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -67,6 +67,7 @@ import '@store/theme-store'; import './src/services/keyboard-shortcut-service'; import { activateView, deactivateView } from '@utils/view-lifecycle'; import { installLongPressContextMenu } from '@utils/long-press'; +import { openQueue, queuePanelElement } from '@utils/open-queue'; import { installTopBarFit } from './src/services/top-bar-fit'; import { hasTrackPayload, @@ -336,6 +337,33 @@ window.addEventListener('popstate', (e: PopStateEvent) => { void handleNavigate({ ...nav, _isBack: true }); }); +/** + * The queue, while it is a screen (#55). + * + * It is *not* in `VIEW_TAGS` and *not* in `DETAIL_LOADERS`: there is + * nothing to mount, because the panel is already in the document and, + * as an overlay, already occupies `.main-panel`'s rect exactly. What a + * navigation adds is the two things that make a screen a screen — a + * history entry, so the platform's back gesture answers it, and a + * destination to leave, so navigating anywhere else takes it away. + * + * Keeping it out of both tables is what keeps its context menu working + * on the reference device: `.main-panel > *` is paint-contained and a + * `wa-popup` falls back to `position: fixed` on Chrome 113, which + * escapes overflow but not containment (#60). The panel stays in + * `.content-area`, which is not paint-contained, exactly as it is + * today. + */ +const QUEUE_VIEW = 'queue'; + +/** Close a queue that is being navigated away from. A *column* is not + * a place, so it survives a navigation the way the sidebar does. */ +function dismissQueueScreen(): void { + const panel = queuePanelElement(); + + if (panel?.hasAttribute('overlay')) panel.removeAttribute('open'); +} + async function handleNavigate( detail: { view: string; [key: string]: any }, ): Promise { @@ -347,6 +375,25 @@ async function handleNavigate( if (!detail._isBack) recordNavigation(detail); + if (view === QUEUE_VIEW) { + // The shell says where the user is; `false` because the queue is + // not a primary view, so nothing in either nav lights while it + // is up -- the same rule a detail view gets, and the reason the + // tab the queue was opened from stays lit. + activeViewStore.setView(view, false); + queuePanelElement()?.setAttribute('open', ''); + + // Deliberately not `searchStore.setCurrentView` and not + // `dataset.activeView`: both describe what is *in the main + // panel*, and the queue covers that panel without replacing it. + // Overwriting either would disable the search box belonging to + // the page underneath and make every `data-active-view` + // selector in the suite disagree with the element it names. + return; + } + + dismissQueueScreen(); + // Bookkeeping stays synchronous with the click: the search box's // scope and the active-view attribute describe the navigation that // was *asked for*, and are what the rest of the app and the e2e @@ -631,13 +678,17 @@ const queuePanel = document.getElementById('queue-panel') as HTMLElement | null; if (queueButton && queuePanel) { queueButton.addEventListener('click', () => { - const isOpen = queuePanel.hasAttribute('open'); - - if (isOpen) { + if (queuePanel.hasAttribute('open')) { + // Closing goes through the panel either way; where the queue + // is a screen the observer below is what unwinds its history + // entry, so this button, Escape, the scrim and the close + // button all take the same route out. queuePanel.removeAttribute('open'); - } else { - queuePanel.setAttribute('open', ''); + + return; } + + openQueue(); }); // The button says whether the panel is open, and it learns that @@ -655,7 +706,39 @@ if (queueButton && queuePanel) { ); }; - new MutationObserver(reflectQueueState).observe(queuePanel, { + /** + * Keep the back stack honest about a queue that closed itself. + * + * Where the queue is a screen its `open` attribute and the current + * history entry are two statements of one fact, and the panel can + * change its half on its own -- Escape, the scrim, the close button, + * and anything added later. Reconciling here rather than at each of + * those is the same reason this observer already exists for + * `aria-expanded`: the attribute is the one fact, and a state kept + * beside a click is right until something else changes it. + * + * Without this the entry is orphaned and the *next* back press is + * the one that closes the queue -- a press that appears to do + * nothing, which is the defect this issue is about, moved one press + * later. + * + * `history.back()` rather than a stack of our own, for the reason + * `navigate-back` does: two stacks is how a component's own way out + * and the phone's gesture come to disagree about what one press + * means. + */ + const reconcileQueueHistory = () => { + if (queuePanel.hasAttribute('open')) return; + + const state = history.state as NavState | null; + + if (state?.yjNav?.view === QUEUE_VIEW) history.back(); + }; + + new MutationObserver(() => { + reflectQueueState(); + reconcileQueueHistory(); + }).observe(queuePanel, { attributes: true, attributeFilter: ['open'], }); diff --git a/frontend/src/components/now-playing-view/now-playing-view.ts b/frontend/src/components/now-playing-view/now-playing-view.ts index ac55cd7..d426dd3 100644 --- a/frontend/src/components/now-playing-view/now-playing-view.ts +++ b/frontend/src/components/now-playing-view/now-playing-view.ts @@ -15,6 +15,7 @@ import { FavoritesController } from '@store/controllers/favorites-controller'; import { designTokens } from '../../styles/tokens.css'; import { srOnly } from '../../styles/sr-only.css'; import { ICON_QUEUE } from '@utils/icon-language'; +import { openQueue as showQueue } from '@utils/open-queue'; /** * What is playing, at the size a phone has room for (plan 016 B2, @@ -226,13 +227,15 @@ export class NowPlayingView extends LitElement { * * This view hides the bottom bar (index.css), and the bar is where * the queue button lives -- so without this, going full-screen - * would take the queue away. It toggles the same `open` attribute + * would take the queue away. It goes through the same helper * `index.ts` does, because the panel's state is an attribute on one * element and a second mechanism for it is a second thing to keep - * in step. + * in step -- which is exactly what this button was: it set `open` + * directly, so on a phone it produced a queue with no history entry + * behind it and back moved the page underneath instead (#55). */ private openQueue() { - document.getElementById('queue-panel')?.setAttribute('open', ''); + showQueue(); } private toggleFavorite() { diff --git a/frontend/src/utils/open-queue.ts b/frontend/src/utils/open-queue.ts new file mode 100644 index 0000000..65e7d33 --- /dev/null +++ b/frontend/src/utils/open-queue.ts @@ -0,0 +1,54 @@ +/** + * Opening the queue, from the two buttons that do it. + * + * **The queue is a place while it is covering the content, and a + * control while it sits beside it** (#55). Those are not two components + * and not two mount points — they are the two presentations #24 already + * computes, and this is the one line that turns that measurement into a + * navigation decision. + * + * A column is a thing the user docked: back must not undock it, and + * navigating to Albums must not take it away. An overlay is a screen — + * at the reference device's 424x439 it is 424x318, which is + * `.main-panel`'s rect exactly — so it needs the two things a screen + * has and this one did not: an entry in the back stack, and a way out + * that answers the platform's own gesture. Measured before this existed: + * opening the queue on Artists and pressing back moved the page + * *underneath* to Albums and left the queue up. + * + * The mode is read off the panel rather than from a viewport width, for + * the reason `queue-panel.overlay` is computed at all: the panel is + * drag-resizable between 200 and 500px and persisted, so a breakpoint + * is wrong by up to 180px in the direction that hurts. + */ +export function queuePanelElement(): HTMLElement | null { + return document.getElementById('queue-panel'); +} + +/** Whether the queue is currently a screen rather than a column. */ +export function queueIsAScreen(): boolean { + return queuePanelElement()?.hasAttribute('overlay') ?? false; +} + +/** + * Show the queue: a navigation where it is a screen, an attribute where + * it is a column. + * + * Both routes end at the same `open` attribute on the same element — + * `index.ts` handles `navigate {view: 'queue'}` by setting it — because + * the panel's state is one fact and a second mechanism for it is a + * second thing to keep in step. + */ +export function openQueue(): void { + if (queueIsAScreen()) { + document.dispatchEvent(new CustomEvent('navigate', { + bubbles: true, + composed: true, + detail: { view: 'queue' }, + })); + + return; + } + + queuePanelElement()?.setAttribute('open', ''); +} diff --git a/frontend/test/utils/open-queue.test.ts b/frontend/test/utils/open-queue.test.ts new file mode 100644 index 0000000..03ed5cb --- /dev/null +++ b/frontend/test/utils/open-queue.test.ts @@ -0,0 +1,101 @@ +/** + * Opening the queue is a *navigation* where the queue is a screen, and + * an *attribute* where it is a column (#55). + * + * This is the one decision in that change, so it is pinned at the tier + * that can state it without a shell: the mode is read off the panel's + * own `overlay` attribute — which #24 computes from the measured widths + * — and never from a viewport breakpoint. A breakpoint would silently + * assume the default 320px panel and be wrong by up to 180px for a user + * who has dragged it wide, in the direction that hurts. + * + * What this tier cannot see is the other half: that the entry is + * unwound when the panel closes, which lives in the shell's mutation + * observer. `e2e/specs/queue-as-a-screen.spec.ts` is where that is + * asserted, and it is asserted as *two* back presses rather than one. + */ +import { afterEach, describe, expect, it } from 'vitest'; + +import { openQueue, queueIsAScreen } from '@utils/open-queue'; + +function panel(overlay: boolean): HTMLElement { + const el = document.createElement('div'); + + el.id = 'queue-panel'; + if (overlay) el.setAttribute('overlay', ''); + document.body.appendChild(el); + + return el; +} + +function recordNavigations(): string[] { + const seen: string[] = []; + const listener = (e: Event) => { + seen.push((e as CustomEvent).detail.view); + }; + + document.addEventListener('navigate', listener); + cleanup.push(() => document.removeEventListener('navigate', listener)); + + return seen; +} + +const cleanup: Array<() => void> = []; + +afterEach(() => { + while (cleanup.length) cleanup.pop()!(); + document.getElementById('queue-panel')?.remove(); +}); + +describe('opening the queue', () => { + it('navigates where the queue covers the content', () => { + const el = panel(true); + const seen = recordNavigations(); + + expect(queueIsAScreen()).toBe(true); + + openQueue(); + + expect(seen).toEqual(['queue']); + // The shell answers the navigation by setting the attribute, so + // the helper deliberately does *not* set it as well: two + // mechanisms for one fact is two things to keep in step, which + // is what `now-playing-view`'s copy of this button was. + expect(el.hasAttribute('open')).toBe(false); + }); + + it('sets the attribute where the queue is a column', () => { + const el = panel(false); + const seen = recordNavigations(); + + expect(queueIsAScreen()).toBe(false); + + openQueue(); + + // A column is a thing the user docked. Back must not undock it, + // so it is not a history entry and therefore not a navigation. + expect(seen).toEqual([]); + expect(el.hasAttribute('open')).toBe(true); + }); + + it('follows the panel rather than the viewport', () => { + const el = panel(false); + const seen = recordNavigations(); + + openQueue(); + expect(seen).toEqual([]); + + // Nothing about the window changed; the panel got wider, which + // is exactly the case a media query cannot express. + el.removeAttribute('open'); + el.setAttribute('overlay', ''); + + openQueue(); + expect(seen).toEqual(['queue']); + }); + + it('says the queue is not a screen when there is no panel at all', () => { + expect(queueIsAScreen()).toBe(false); + expect(() => openQueue()).not.toThrow(); + }); +});