feat(queue): give an overlaid queue a place in the back stack
The queue's pixels were already right. Measured at the reference device's 424x439, #24's overlay is 424x318 -- `.main-panel`'s rect exactly -- so the `DETAIL_LOADERS` mount the issue's Direction asks for would draw the same rectangle in the same place. What was missing was the navigation model: opening the queue on Artists and pressing back moved the page *underneath* to Albums and left the queue up, which is a press that changes something the user cannot see and costs them their place. So the queue is a *place* exactly while it is an overlay, and a *control* while it is a column. A column is a thing the user docked -- back must not undock it and a navigation must not take it away -- and that reuses #24's computed mode rather than adding a breakpoint, so the drag-resizable panel width keeps deciding it. It is in neither `VIEW_TAGS` nor `DETAIL_LOADERS`, because there is nothing to mount and moving it would cost something. `.main-panel > *` computes `contain: content` under a `.main-panel` that does too, and paint containment clips the `position: fixed` a `wa-popup` falls back to on Chrome 113 (#60) -- so the detail-view mount would have broken `queue-panel`'s working context menu on the one device this is about. The panel's ancestry today is paint-free to `body`. Two details that fail silently otherwise. The entry is unwound from the panel's `open` attribute in the observer that already ran for `aria-expanded`, not at each of the four ways out -- without that the entry is orphaned and the *next* back press is the one that closes the queue, which is this defect moved one press later. And the navigation writes neither `dataset.activeView` nor `searchStore.setCurrentView`, because both describe what is *in* the main panel and the queue covers that panel without replacing it. `now-playing-view`'s copy of the button went through the helper too: it set `open` directly, so on a phone it produced exactly the queue with no entry behind it that this removes. Closes #55
This commit is contained in:
+89
-6
@@ -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<void> {
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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', '');
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user