Collapse the page header actions that do not fit, instead of clipping them #136

Merged
logan merged 2 commits from fix/69-page-header-action-overflow into main 2026-08-19 19:29:15 +00:00
12 changed files with 1304 additions and 123 deletions
Showing only changes of commit f967916550 - Show all commits
+318
View File
@@ -0,0 +1,318 @@
import { test, expect } from '../support/fixtures.js';
/**
* #69: the Playlists header's buttons could not be reached.
*
* Three text buttons — Import (91px), New Playlist (122px), New Smart
* Playlist (162px), 390px in total — inside a header that gets 700px at
* 900×600. "New Smart Playlist" rendered **114 of its 162px**, and at
* phone width the Android report was the plain version of it: you
* cannot scroll to reach them, and scrolling is not how page controls
* should be exposed anyway.
*
* **`layout-overflow.spec.ts` passes on the broken build**, which is why
* this file exists rather than a case being added there. That spec
* asserts the *shell* needs no sideways scrolling; clipping *inside* a
* component is invisible to it. So the measurement here is per-button
* and per-header, against the widths the app promises.
*
* Plan 018's size matrix is the promise being kept: **no action is ever
* unreachable at any supported size.** These are its three bands.
*/
const VIEWPORTS = [
// Desktop's worst case, and not the enforced minimum: the sidebar
// collapses to icons *below* 900, so the content area is 843px at 899
// and 700px at 900. Testing "the minimum" and stopping misses it.
{ name: '900×600 (widest sidebar, narrowest content)', width: 900, height: 600 },
{ name: '800×600 (the enforced minimum)', width: 800, height: 600 },
{ name: '390×780 (phone)', width: 390, height: 780 },
// WCAG 1.4.10's reflow target, which plan 018 promises the app fits.
{ name: '320×600 (400% zoom)', width: 320, height: 600 },
];
/** Every action the Playlists header can offer, in declared order. */
const ACTIONS = ['Import', 'New Playlist', 'New Smart Playlist'];
/**
* What the header is actually rendering, measured rather than inferred.
*
* A shadow query is the wrong tool for *asserting* — that is what
* `getByRole` below is for — but it is the right one for a measurement,
* because the number this issue is about (a button 48px wider than the
* box holding it) is not in the accessibility tree at all.
*/
const headerFit = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const root = document
.querySelector('[data-testid="main-content"] playlist-view')
?.shadowRoot?.querySelector('page-header')?.shadowRoot;
if (!root) return null;
const header = root.querySelector<HTMLElement>('.page-header')!;
const box = header.getBoundingClientRect();
const title = root.querySelector<HTMLElement>('h1')!;
const clipped = [
...root.querySelectorAll<HTMLElement>('.action, .more-button'),
]
.filter((b) => !b.hidden)
.filter((b) => {
const r = b.getBoundingClientRect();
return r.right > box.right + 1 || r.left < box.left - 1;
})
.map((b) => b.dataset['actionId'] ?? 'more');
return {
overflow: header.scrollWidth - header.clientWidth,
clipped,
titleTruncated: title.scrollWidth > title.clientWidth + 1,
buttons: [...root.querySelectorAll<HTMLElement>('.action')]
.filter((b) => !b.hidden)
.map((b) => b.textContent?.trim() ?? ''),
menu: [
...root.querySelectorAll('#page-header-overflow wa-dropdown-item'),
].map((i) => i.textContent?.trim() ?? ''),
};
});
test.describe('the page header never clips an action', () => {
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-playlists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlists',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
});
for (const vp of VIEWPORTS) {
test(`every action is reachable at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
// Polled: the fit is decided by a ResizeObserver, so it settles a
// frame after the resize rather than with it.
await expect
.poll(async () => (await headerFit(app))?.clipped)
.toEqual([]);
const fit = (await headerFit(app))!;
expect(fit.overflow).toBeLessThanOrEqual(0);
// Between them, buttons and menu account for all three. This is
// the assertion the issue asks for: not "it fits" but "nothing
// was dropped to make it fit".
expect([...fit.buttons, ...fit.menu].sort()).toEqual([...ACTIONS].sort());
});
}
/**
* The title gives way before an action does.
*
* Once the heading can ellipsis it absorbs the pressure, and
* `scrollWidth` then reports a header that fits perfectly while the
* heading reads "Playlis…" — this issue's own failure mode moved from
* the button to the title, and invisible to exactly the measurement
* that missed it the first time. At the desktop sizes there is always
* an action to collapse instead.
*/
test('does not truncate the heading to keep a button', async ({ app }) => {
for (const vp of VIEWPORTS.slice(0, 2)) {
await app.setViewportSize({ width: vp.width, height: vp.height });
await expect
.poll(async () => (await headerFit(app))?.titleTruncated)
.toBe(false);
}
});
/**
* Asserted through the accessibility tree, never a shadow query. An
* overflow menu is exactly the shape that grows a nameless control,
* and this repo has shipped one four times — most recently the
* queue's own close button.
*/
test('the overflow is a named control that opens a named menu', async ({
app,
}) => {
await app.setViewportSize({ width: 900, height: 600 });
const more = app.getByRole('button', { name: 'More actions' });
await expect(more).toBeVisible();
await expect(more).toHaveAttribute('aria-expanded', 'false');
await more.click();
await expect(more).toHaveAttribute('aria-expanded', 'true');
const menu = app.getByRole('menu', { name: 'More actions' });
await expect(menu).toBeVisible();
// Collapsed at 900×600: Import (lowest priority) and New Smart
// Playlist. New Playlist stays a button because it is the drop
// target, and a closed menu cannot be one.
await expect(
menu.getByRole('menuitem', { name: 'Import' }),
).toBeVisible();
await expect(
app.getByRole('button', { name: 'New Playlist', exact: true }),
).toBeVisible();
});
/**
* The phone case is the original report. Every action is in the menu
* at 390px, and the menu is reachable by name — which is the whole of
* "these need to be reachable in a sensible way".
*/
test('offers every action from the menu on a phone', async ({ app }) => {
await app.setViewportSize({ width: 390, height: 780 });
const more = app.getByRole('button', { name: 'More actions' });
await expect(more).toBeVisible();
await more.click();
const menu = app.getByRole('menu', { name: 'More actions' });
for (const label of ACTIONS) {
await expect(menu.getByRole('menuitem', { name: label })).toBeVisible();
}
});
/**
* Escape closes it and focus goes back to the trigger — `MenuKeyboard`
* is shared with every other menu in the app precisely so this is not
* a second keyboard model, and this is what proves it was wired up
* rather than merely imported.
*/
test('takes the keyboard, and gives it back', async ({ app }) => {
await app.setViewportSize({ width: 900, height: 600 });
const more = app.getByRole('button', { name: 'More actions' });
await more.click();
const menu = app.getByRole('menu', { name: 'More actions' });
await expect(menu).toBeVisible();
// The first item takes focus on open. `wa-dropdown-item` sets its
// own role in its own first update, so this is polled rather than
// read: a query at the host's updateComplete finds nothing, which
// reads exactly like a menu that refused to take focus.
await expect
.poll(async () =>
app.evaluate(() => {
// Stops where `MenuKeyboard`'s own `deepActiveElement` stops:
// on the *host* whose shadow root has no active element.
// Descending unconditionally lands inside the focused
// `wa-dropdown-item`'s own shadow root, where nothing is
// focused — which reads exactly like a menu that refused the
// keyboard, on a build where it did not.
let el = document.activeElement;
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
return el?.textContent?.trim() ?? null;
}),
)
.toBe('Import');
await app.keyboard.press('Escape');
await expect(more).toHaveAttribute('aria-expanded', 'false');
await expect(more).toBeFocused();
});
/**
* New Playlist is a drop target, and declaring it as data must not
* take that away — which is why a `PageAction` carries the drop
* handlers rather than the header owning a notion of dropping.
*
* Nothing covered this before, in either tier, and it is the one
* behaviour the migration could plausibly have destroyed silently:
* dragging still *looks* fine against a button that no longer
* accepts anything.
*/
test('New Playlist still accepts a dropped track', async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
const button = app.getByRole('button', {
name: 'New Playlist',
exact: true,
});
await expect(button).toBeVisible();
const result = await app.evaluate(async () => {
const view = document.querySelector(
'[data-testid="main-content"] playlist-view',
)!;
const target = view.shadowRoot!
.querySelector('page-header')!
.shadowRoot!.querySelector('[data-testid="page-action-new-playlist"]')!;
const data = new DataTransfer();
data.setData(
'application/x-yj-tracks',
JSON.stringify({ filePaths: ['/tmp/dropped.mp3'] }),
);
const fire = (type: string) =>
target.dispatchEvent(
new DragEvent(type, {
bubbles: true,
cancelable: true,
dataTransfer: data,
}),
);
fire('dragover');
await new Promise((r) => setTimeout(r, 50));
// The affordance is the host's state reaching the header's
// button, which is the half a plain handler call would not prove.
const highlighted = target.classList.contains('drag-over');
fire('drop');
await new Promise((r) => setTimeout(r, 200));
return {
highlighted,
opened: view.shadowRoot!.querySelector('.create-form') !== null,
};
});
expect(result).toEqual({ highlighted: true, opened: true });
// Leave the view as it was found.
await app.keyboard.press('Escape');
});
/**
* An action given back when the window widens again. The collapsed
* set is a function of the current width and not of how it got there
* — a rule that only ever *added* to it would never widen.
*/
test('gives the buttons back when the window grows', async ({ app }) => {
await app.setViewportSize({ width: 390, height: 780 });
await expect.poll(async () => (await headerFit(app))?.buttons).toEqual([]);
await app.setViewportSize({ width: 1440, height: 900 });
await expect
.poll(async () => (await headerFit(app))?.buttons)
.toEqual(ACTIONS);
await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]);
});
});
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 256a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm168 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm224-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>

After

Width:  |  Height:  |  Size: 443 B

@@ -3,6 +3,7 @@ import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@components/page-header/page-header';
import type { PageAction } from '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { downloadStore, stateLabel } from '@store/download-store';
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
@@ -246,23 +247,23 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
override render() {
return html`
<page-header heading="Downloads">
${this.tab === 'requests'
? html`
<wa-button
slot="actions"
size="small"
appearance="outlined"
?disabled=${this.checking}
title="Search every download client for everything on this list right now, instead of waiting for the next scheduled check"
@click=${() => void this.checkNow()}
>
<wa-icon slot="start" name="rotate"></wa-icon>
${this.checking ? 'Searching…' : 'Check now'}
</wa-button>
`
: nothing}
</page-header>
<page-header
heading="Downloads"
.actions=${this.tab === 'requests'
? ([
{
id: 'check-now',
label: this.checking
? 'Searching\u2026'
: 'Check now',
icon: 'rotate',
disabled: this.checking,
title: 'Search every download client for everything on this list right now, instead of waiting for the next scheduled check',
onSelect: () => void this.checkNow(),
},
] satisfies PageAction[])
: []}
></page-header>
<p class="subtitle">
Music you have requested, and the download attempts that
+20 -18
View File
@@ -1,8 +1,9 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { GetShelves } from '@go/home/service.js';
import { ICON_SHUFFLE } from '@utils/icon-language';
import type { PageAction } from '@components/page-header/page-header';
import { GetAlbumTracks } from '@go/library/library.js';
import type * as home from '@go/home/models.js';
import type * as library from '@go/library/models.js';
@@ -283,23 +284,24 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
override render() {
return html`
<page-header heading="Home">
<!-- "Shuffle" alone was two different controls with one
name: this one and the transport's shuffle mode.
They were never on screen together until the app
started landing on Home (H-8), and a cached view is
in the accessibility tree either way. -->
<wa-button
slot="actions"
size="small"
appearance="plain"
title="Reshuffle the suggestions"
@click=${() => void this.load()}
>
<wa-icon slot="start" name="shuffle"></wa-icon>
Shuffle suggestions
</wa-button>
</page-header>
<page-header
heading="Home"
.actions=${[
{
// "Shuffle" alone was two different controls
// with one name: this one and the transport's
// shuffle mode. They were never on screen
// together until the app started landing on
// Home (H-8), and a cached view is in the
// accessibility tree either way.
id: 'shuffle-suggestions',
label: 'Shuffle suggestions',
icon: ICON_SHUFFLE,
title: 'Reshuffle the suggestions',
onSelect: () => void this.load(),
},
] satisfies PageAction[]}
></page-header>
<p class="lede">Somewhere to start listening.</p>
${this.renderBody()}
`;
@@ -1,8 +1,16 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import { designTokens } from '../../styles/tokens.css';
import {
MenuKeyboard,
contextMenuStyles,
} from '../../utils/context-menu-controller';
import { ICON_MORE_ACTIONS } from '../../utils/icon-language';
/**
* The one arrangement every primary view uses to say what it is.
@@ -18,6 +26,19 @@ import { designTokens } from '../../styles/tokens.css';
* Title, count, sort, actions — in that order, in one component, so a
* new view gets the shape by using it rather than by copying whichever
* neighbour it happened to read.
*
* **Actions are data, and `<slot name="actions">` is the exception.**
* Playlists' three buttons totalled 390px inside a header that gets
* 700px at 900×600 and clipped "New Smart Playlist" to 114 of its 162
* (#69) — a live defect at a size the app promises, against plan 018's
* *no action is ever unreachable at any supported size*. The header
* cannot fix that for slotted markup: it cannot move another
* component's light-DOM children into a dropdown and keep their
* behaviour, and arbitrary markup offers nothing generic to render as
* a menu item. So a host declares `PageAction[]` and the header picks
* the rendering. The slot survives for markup a data list genuinely
* cannot express, at the stated cost that **a slotted action does not
* collapse** and must therefore fit at 800×600.
*/
export interface SortOption {
@@ -27,6 +48,50 @@ export interface SortOption {
export type SortDirection = 'asc' | 'desc';
/**
* An action that only makes sense while it is a button.
*
* A drop target is the case: you cannot drag a track onto a closed
* menu, so the affordance is absent from the overflow rather than
* approximated there. The header wires these onto the button it
* renders and owns none of them — the same division the sort control
* already lives by.
*/
export interface PageActionDrop {
/** True while an acceptable payload is over the button. */
active?: boolean;
onDragOver: (e: DragEvent) => void;
onDragLeave: (e: DragEvent) => void;
onDrop: (e: DragEvent) => void;
}
/**
* One thing a view can do, as data rather than as markup.
*
* `<slot name="actions">` cannot be collapsed, and that is a fact about
* the API rather than an effort estimate (#69): a component cannot move
* another component's light-DOM children into a dropdown and keep their
* behaviour, and there is nothing generic in arbitrary markup to render
* as a menu item. Declaring an action instead is what lets the header
* choose between the two renderings.
*/
export interface PageAction {
id: string;
label: string;
/** From `utils/icon-language`, never a literal. */
icon: string;
onSelect: () => void;
/**
* Higher survives longer. The lowest collapses first, ties broken
* by declaration order from the right, so a host that says nothing
* gets "the last one written goes first".
*/
priority?: number;
disabled?: boolean;
title?: string;
drop?: PageActionDrop;
}
@customElement('page-header')
export class PageHeader extends LitElement {
/**
@@ -80,8 +145,82 @@ export class PageHeader extends LitElement {
@property({ type: Boolean })
busy = false;
/**
* What this view can do, in the order it wants them shown.
*
* The header decides what *fits*; the host decides what *happens*.
* That is the rule the sort control already lives by — it asks for
* a sort rather than performing one — and actions follow it, which
* is why an action carries a handler rather than the header
* carrying a verb it would have to interpret.
*/
@property({ attribute: false })
actions: PageAction[] = [];
/** Action ids currently in the overflow menu. Derived, never set by a host. */
@state()
private collapsed: ReadonlySet<string> = new Set();
@state()
private menuOpen = false;
@query('.page-header')
private headerEl?: HTMLElement;
@query('.more-button')
private moreButton?: HTMLButtonElement;
@query('#page-header-overflow')
private menuPanel?: HTMLElement;
@query('wa-popup')
private popup?: WaPopup;
private menuKeyboard = new MenuKeyboard(() => this.closeMenu());
private resizeObserver?: ResizeObserver;
/**
* Whether the outside-click listener is attached.
*
* A `removeEventListener` with no matching `add` is not harmless
* here: `view-lifecycle.test.ts` counts document listeners across a
* view's life and an unconditional detach on disconnect shows up as
* `held: -1`, which is the same accounting that would hide a real
* leak in the other direction.
*/
private outsideCloseAttached = false;
/**
* What the last fit was measured against.
*
* `updated()` runs on every pass, so it has to say what it depends
* on or it re-measures — and a measurement here forces synchronous
* layout. Width changes arrive through the ResizeObserver; this key
* covers everything *else* in the flex row that can change how much
* of it the actions are left.
*/
private lastFitKey = '';
override connectedCallback(): void {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.measureFit());
this.resizeObserver.observe(this);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.resizeObserver?.disconnect();
this.resizeObserver = undefined;
this.detachOutsideClose();
}
static override styles = [
designTokens,
contextMenuStyles,
css`
:host {
display: block;
@@ -96,12 +235,26 @@ export class PageHeader extends LitElement {
border-bottom: 1px solid var(--yj-border-subtle, #333);
}
/* The title gives way before an action does.
Everything in this row was flex-shrink: 0, so whatever
came last lost — and the actions come last, which is how
the "More actions" button ended up 76px off the right
edge of a 320px viewport with every action already
collapsed into it. The title is the one thing here the
navigation also says (the sidebar item is selected, the
bottom-nav tab is current), so it is the cheapest thing
to truncate; the count, the sort and the actions are each
the only place they are said. */
h1 {
margin: 0;
font-size: var(--yj-text-xl, 18px);
font-weight: 600;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.count {
@@ -191,6 +344,98 @@ export class PageHeader extends LitElement {
::slotted(*) {
flex-shrink: 0;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.action,
.more-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: var(--yj-text-md, 13px);
font-family: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
flex-shrink: 0;
}
.more-button {
padding: 6px 10px;
}
/* The display: flex above outranks the UA stylesheet's
rule for [hidden], and hiding is how an action
collapses. (No backticks in here: one ends the css
literal, and what you get is "css(...) is not a
function" a long way from the cause.) */
.action[hidden],
.more-button[hidden] {
display: none;
}
.action:hover,
.more-button:hover,
.action.drag-over {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
.action.drag-over {
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
);
}
.action:disabled {
opacity: 0.5;
cursor: default;
}
.action:focus-visible,
.more-button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
wa-popup {
z-index: 200;
}
/* A component states what it drops at phone width itself,
in its own stylesheet, because a media query inside a
shadow root is answered by the viewport and the shell
cannot reach in. Here that is one word: the sort control
is 172px of a 320px header, and "Sort:" is ~40px of it
for a label the adjacent direction arrow already implies.
It stays in the accessibility tree — it is the select's
accessible name, so hiding it outright would rename the
control to nothing — which is config-field's bug, one
component over. clip-path rather than display: none for
the reason styles/sr-only.css.ts gives. */
@media (max-width: 599px) {
.sort-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}
`,
];
@@ -212,11 +457,293 @@ export class PageHeader extends LitElement {
${this.renderCount()}
<div class="spacer"></div>
${this.renderScope()} ${this.renderSort()}
${this.renderActions()}
<slot name="actions"></slot>
</header>
`;
}
protected override updated(): void {
const key = [
this.heading,
this.count,
this.countNoun,
this.countPlural,
this.searchTerm,
this.sortOptions.length,
this.sortField,
this.sortDirection,
this.busy,
this.actions.map((a) => `${a.id}:${a.label}:${a.disabled ?? false}`).join(','),
].join('|');
if (key === this.lastFitKey) return;
this.lastFitKey = key;
this.measureFit();
}
// =================================================================
// What fits
// =================================================================
/**
* Decide which actions are buttons and which are menu items.
*
* Two things about the shape of this are load-bearing.
*
* **Every pass starts from all-visible**, so the collapsed set is a
* pure function of the current width rather than of the order the
* widths arrived in. A rule that only ever *added* to the set would
* never give an action back when the window grew, and one that
* adjusted by a step would need a hysteresis band to stop it
* oscillating on the pixel where a button exactly fits.
*
* **It flips `hidden` on the rendered nodes rather than re-rendering
* between steps.** Reading `scrollWidth` forces layout, which is the
* point; awaiting a Lit update between steps instead would let the
* intermediate all-visible state paint, so the fix would flash the
* overflow it exists to prevent. The reactive state is set once, at
* the end, and the next render agrees with what was measured.
*
* The budget is the *header's* overflow and not the actions row's,
* because the count and the sort control are `flex-shrink: 0` and
* are therefore competing for the same width — only `.scope` gives
* way, which is what it has an ellipsis for.
*/
private measureFit(): void {
const header = this.headerEl;
if (!header) return;
if (this.actions.length === 0) {
this.commitCollapsed(new Set());
return;
}
const buttons = new Map<string, HTMLElement>();
for (const el of this.renderRoot.querySelectorAll<HTMLElement>(
'[data-action-id]',
)) {
const id = el.dataset['actionId'];
if (id !== undefined) buttons.set(id, el);
}
const more = this.moreButton;
const title = this.renderRoot.querySelector('h1');
/**
* Nothing is clipped — which is not the same as the header not
* overflowing, and the difference is a trap worth naming.
*
* Once the title can ellipsis, it absorbs the pressure and
* `scrollWidth` reports a header that fits perfectly while the
* heading reads "Playlis…". That is this issue's own failure
* mode moved from the button to the title, and it is invisible
* to exactly the same measurement that missed it the first time.
* So the title's own truncation counts as not fitting, and
* collapsing an action is tried before the title gives way.
*/
const fits = () =>
header.scrollWidth <= header.clientWidth &&
(title === null || title.scrollWidth <= title.clientWidth + 1);
for (const el of buttons.values()) el.hidden = false;
if (more) more.hidden = true;
const collapsed = new Set<string>();
if (!fits()) {
if (more) more.hidden = false;
for (const action of this.collapseOrder()) {
collapsed.add(action.id);
const el = buttons.get(action.id);
if (el) el.hidden = true;
if (fits()) break;
}
}
this.commitCollapsed(collapsed);
}
/** Lowest priority first; ties broken from the right. */
private collapseOrder(): PageAction[] {
return this.actions
.map((action, index) => ({ action, index }))
.sort(
(a, b) =>
(a.action.priority ?? 0) - (b.action.priority ?? 0) ||
b.index - a.index,
)
.map(({ action }) => action);
}
private commitCollapsed(next: Set<string>): void {
const same =
next.size === this.collapsed.size &&
[...next].every((id) => this.collapsed.has(id));
if (same) return;
this.collapsed = next;
// Nothing left to show in it. Closing rather than leaving an
// empty menu open is the same rule the shelves follow.
if (next.size === 0 && this.menuOpen) this.closeMenu();
}
// =================================================================
// Rendering
// =================================================================
private renderActions() {
if (this.actions.length === 0) return nothing;
const overflowed = this.actions.filter((a) => this.collapsed.has(a.id));
return html`
<div class="actions">
${this.actions.map((a) => this.renderActionButton(a))}
<wa-popup
placement="bottom-end"
flip
shift
.active=${this.menuOpen}
>
<button
slot="anchor"
class="more-button"
type="button"
data-testid="page-actions-more"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded=${this.menuOpen ? 'true' : 'false'}
aria-controls="page-header-overflow"
?hidden=${overflowed.length === 0}
@click=${this.onMoreClick}
>
<wa-icon name=${ICON_MORE_ACTIONS}></wa-icon>
</button>
<div
id="page-header-overflow"
class="context-menu-panel"
role="menu"
aria-label="More actions"
>
${overflowed.map(
(a) => html`
<wa-dropdown-item
?disabled=${a.disabled ?? false}
@click=${() => this.onActionSelect(a)}
>
<wa-icon
slot="icon"
name=${a.icon}
></wa-icon>
${a.label}
</wa-dropdown-item>
`,
)}
</div>
</wa-popup>
</div>
`;
}
private renderActionButton(a: PageAction) {
const drop = a.drop;
return html`
<button
class="action ${drop?.active === true ? 'drag-over' : ''}"
type="button"
data-action-id=${a.id}
data-testid=${`page-action-${a.id}`}
title=${a.title ?? nothing}
?disabled=${a.disabled ?? false}
?hidden=${this.collapsed.has(a.id)}
@click=${() => a.onSelect()}
@dragover=${(e: DragEvent) => drop?.onDragOver(e)}
@dragleave=${(e: DragEvent) => drop?.onDragLeave(e)}
@drop=${(e: DragEvent) => drop?.onDrop(e)}
>
<wa-icon name=${a.icon}></wa-icon>
${a.label}
</button>
`;
}
// =================================================================
// The overflow menu
// =================================================================
private onActionSelect(a: PageAction): void {
if (a.disabled === true) return;
this.closeMenu();
a.onSelect();
}
private onMoreClick = (): void => {
if (this.menuOpen) {
this.closeMenu();
return;
}
this.menuOpen = true;
void this.updateComplete.then(() => {
if (!this.menuOpen) return;
this.popup?.reposition();
this.menuKeyboard.open(this.menuPanel ?? null, this.moreButton);
this.attachOutsideClose();
});
};
private closeMenu(): void {
if (!this.menuOpen) return;
this.detachOutsideClose();
this.menuKeyboard.close();
this.menuOpen = false;
}
/**
* A click anywhere else closes it. `composedPath` rather than
* `contains`, because the trigger and the panel are both inside
* this shadow root and a click retargets at the host.
*/
private onOutsideDown = (e: Event): void => {
if (e.composedPath().includes(this.menuPanel as EventTarget)) return;
if (e.composedPath().includes(this.moreButton as EventTarget)) return;
this.closeMenu();
};
private attachOutsideClose(): void {
if (this.outsideCloseAttached) return;
this.outsideCloseAttached = true;
document.addEventListener('mousedown', this.onOutsideDown, true);
}
private detachOutsideClose(): void {
if (!this.outsideCloseAttached) return;
this.outsideCloseAttached = false;
document.removeEventListener('mousedown', this.onOutsideDown, true);
}
private renderCount() {
if (this.count === null) return nothing;
@@ -258,7 +785,10 @@ export class PageHeader extends LitElement {
if (this.sortOptions.length === 1) {
return html`
<div class="sort">
<span>Sort: ${this.sortOptions[0]?.label}</span>
<span
><span class="sort-label">Sort: </span
>${this.sortOptions[0]?.label}</span
>
${this.renderDirectionButton(ascending)}
</div>
`;
@@ -267,7 +797,7 @@ export class PageHeader extends LitElement {
return html`
<div class="sort">
<label>
Sort:
<span class="sort-label">Sort:</span>
<select
data-testid="page-sort"
.value=${this.sortField}
@@ -40,7 +40,10 @@ import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/
import {
ICON_NEW,
ICON_PLAYLIST,
ICON_SMART_PLAYLIST,
} from '@utils/icon-language';
import '@components/page-header/page-header';
import type { PageAction } from '@components/page-header/page-header';
const SCROLL_DEBOUNCE_MS = 100;
@@ -194,11 +197,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
contain: layout style;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-spinner {
display: inline-block;
width: 14px;
@@ -209,33 +207,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
animation: spin 0.6s linear infinite;
}
.new-playlist-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-family: inherit;
}
.new-playlist-button:hover,
.new-playlist-button.drag-over {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
.new-playlist-button.drag-over {
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
);
}
.create-form {
display: flex;
align-items: center;
@@ -455,25 +426,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
min-width: 0;
}
.import-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-family: inherit;
}
.import-button:hover {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
.import-error {
padding: 0.5em 0.75em;
margin: 0.5em 16px 0;
@@ -1022,10 +974,11 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
) => {
const related =
e.relatedTarget as Node | null;
const btn =
this.shadowRoot?.querySelector(
'.new-playlist-button',
);
// The button the event was bound to, rather than a selector for
// it: `page-header` renders it now, so it is not in this shadow
// root at all and the old `.new-playlist-button` lookup would
// find nothing and leave the highlight stuck on.
const btn = e.currentTarget as Element | null;
if (btn && !btn.contains(related)) {
this.dragOverNewButton = false;
@@ -1470,6 +1423,49 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
this.saveSortPreferences();
};
/**
* The three things this page can do, as data.
*
* The priority order is what #69's Direction asks for and it is
* only interesting for one of them: **New Playlist is highest
* because it is the drop target**. You cannot drag a track onto a
* closed menu, so collapsing it is the one collapse here that
* removes a capability rather than relocating it. Import is lowest
* because it is the rarest, and at 900×600 it is the only one that
* has to go.
*/
private headerActions(): PageAction[] {
return [
{
id: 'import',
label: 'Import',
icon: 'file-import',
priority: 0,
onSelect: () => void this.handleImportPlaylist(),
},
{
id: 'new-playlist',
label: 'New Playlist',
icon: ICON_NEW,
priority: 2,
onSelect: () => this.handleNewPlaylistClick(),
drop: {
active: this.dragOverNewButton,
onDragOver: this.onNewButtonDragOver,
onDragLeave: this.onNewButtonDragLeave,
onDrop: this.onNewButtonDrop,
},
},
{
id: 'new-smart-playlist',
label: 'New Smart Playlist',
icon: ICON_SMART_PLAYLIST,
priority: 1,
onSelect: () => this.handleNewSmartPlaylistClick(),
},
];
}
override render() {
return html`
<page-header
@@ -1484,33 +1480,8 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
search-term=${this.searchCtrl.term}
?busy=${this.refreshing}
@sort-change=${this.onPageHeaderSort}
.actions=${this.headerActions()}
>
<div slot="actions" class="header-actions">
<button
class="import-button"
@click=${this.handleImportPlaylist}
>
<wa-icon name="file-import"></wa-icon>
Import
</button>
<button
class="new-playlist-button ${this.dragOverNewButton ? 'drag-over' : ''}"
@click=${this.handleNewPlaylistClick}
@dragover=${this.onNewButtonDragOver}
@dragleave=${this.onNewButtonDragLeave}
@drop=${this.onNewButtonDrop}
>
<wa-icon name=${ICON_NEW}></wa-icon>
New Playlist
</button>
<button
class="new-playlist-button"
@click=${this.handleNewSmartPlaylistClick}
>
<wa-icon name="filter"></wa-icon>
New Smart Playlist
</button>
</div>
</page-header>
${this.importError
@@ -1765,7 +1736,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
: entry.summary.IsSmart
? html`<wa-icon
class="playlist-icon"
name="filter"
name=${ICON_SMART_PLAYLIST}
></wa-icon>`
: nothing}
${isRenaming
@@ -64,6 +64,7 @@ import { list } from '@utils/binding';
import {
ICON_PLAYLIST,
ICON_QUEUE,
ICON_SMART_PLAYLIST,
} from '@utils/icon-language';
@@ -1214,7 +1215,7 @@ export class SmartPlaylistDetails
<wa-icon name="arrow-left"></wa-icon>
</button>
<div class="playlist-avatar">
<wa-icon name="filter"></wa-icon>
<wa-icon name=${ICON_SMART_PLAYLIST}></wa-icon>
</div>
<div class="playlist-info">
<h1
+1
View File
@@ -41,6 +41,7 @@ solid/compact-disc
solid/copy
solid/database
solid/download
solid/ellipsis
solid/file-import
solid/filter
solid/floppy-disk
+24
View File
@@ -54,6 +54,18 @@ export const ICON_PLAYLIST = 'list';
*/
export const ICON_NEW = 'plus';
/**
* A smart playlist — the rule, and the thing the rule makes.
*
* Governed for the reason `ICON_AUTOTAG` states: it was already at
* three call sites (the Playlists header, the row marker beside a smart
* playlist's name, and `smart-playlist-details`'s avatar), and a name
* stops being a detail of one component the moment there are two. It is
* deliberately *not* `ICON_NEW`, even on the button that makes one:
* an icon names the noun it acts on, and the noun here is the rule.
*/
export const ICON_SMART_PLAYLIST = 'filter';
/**
* The request ("want") toggle, as an outline/solid pair.
*
@@ -100,6 +112,18 @@ export const ICON_AUTOTAG = 'tag';
*/
export const ICON_DOWNLOADING = 'download';
/**
* The rest of what this thing can do.
*
* `page-header` collapses the actions that do not fit into one menu
* behind this, so the glyph has to name *more of the same nouns* rather
* than any one of them — which is what an ellipsis is and what `bars`
* (the navigation drawer, one component over in `bottom-nav`) is not.
* It is deliberately the only meaning it carries: an overflow menu that
* shared an icon with a destination would be the `list` problem again.
*/
export const ICON_MORE_ACTIONS = 'ellipsis';
/**
* Take this away.
*
+12 -1
View File
@@ -7,6 +7,7 @@
* opening it.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/home-view/home-view';
import { stub, calls, lastArgs, stubFailure } from '@test/support/harness';
@@ -166,7 +167,17 @@ describe('home view', () => {
const before = calls('home.Service.GetShelves').length;
shadow<HTMLElement>(el, 'wa-button')!.click();
// The action is declared to `page-header` rather than slotted as
// markup (#69), so it is a button in *that* shadow root now.
const header = shadow<HTMLElement>(el, 'page-header')!;
await (header as LitElement).updateComplete;
header.shadowRoot!
.querySelector<HTMLButtonElement>(
'[data-testid="page-action-shuffle-suggestions"]',
)!
.click();
await el.updateComplete;
expect(calls('home.Service.GetShelves').length).toBe(before + 1);
@@ -44,6 +44,8 @@ const GOVERNED = [
'regular/bookmark',
'bars-staggered',
'tag',
'filter',
'ellipsis',
];
/** The one file allowed to say them, plus its own test. */
+320 -1
View File
@@ -9,7 +9,7 @@
* the thing no assertion can — the header looking wrong.
*/
import { describe, expect, it } from 'vitest';
import type { PageHeader } from '@components/page-header/page-header';
import type { PageAction, PageHeader } from '@components/page-header/page-header';
import '@components/page-header/page-header';
import { fixture, shadow, shadowAll, update, visual } from '@test/support/render';
@@ -19,6 +19,67 @@ const SORTS = [
{ id: 'tracks', label: 'Tracks' },
];
/**
* Three actions of the shape that broke: Playlists' own, whose widths
* (91 + 122 + 162 = 390px) are what a 700px header could not hold.
*/
function playlistActions(seen: string[]): PageAction[] {
return [
{
id: 'import',
label: 'Import',
icon: 'file-import',
priority: 0,
onSelect: () => seen.push('import'),
},
{
id: 'new-playlist',
label: 'New Playlist',
icon: 'plus',
priority: 2,
onSelect: () => seen.push('new-playlist'),
},
{
id: 'new-smart-playlist',
label: 'New Smart Playlist',
icon: 'filter',
priority: 1,
onSelect: () => seen.push('new-smart-playlist'),
},
];
}
/**
* Resize and let the fit settle.
*
* The rule is driven by a ResizeObserver, which delivers before paint
* and therefore after the microtask queue an `updateComplete` drains —
* so this waits on frames rather than on promises, and then on the
* render the measurement asks for.
*/
async function widthOf(el: PageHeader, px: number): Promise<void> {
el.style.width = `${px}px`;
for (let frame = 0; frame < 3; frame += 1) {
await new Promise((r) => requestAnimationFrame(r));
await el.updateComplete;
}
}
/** The labels currently rendered as buttons, in order. */
function buttons(el: PageHeader): string[] {
return shadowAll<HTMLButtonElement>(el, '.action')
.filter((b) => !b.hidden)
.map((b) => b.textContent?.trim() ?? '');
}
/** The labels currently in the overflow menu, in order. */
function menu(el: PageHeader): string[] {
return shadowAll(el, '#page-header-overflow wa-dropdown-item').map(
(i) => i.textContent?.trim() ?? '',
);
}
describe('<page-header>', () => {
it('renders the heading as the page\u2019s only h1', async () => {
const el = await fixture<PageHeader>('page-header', {
@@ -153,6 +214,264 @@ describe('<page-header>', () => {
});
});
/**
* #69: Playlists slotted three buttons totalling 390px into a header
* that gets 700px at 900×600, and "New Smart Playlist" rendered 114 of
* its 162. It survived a spec named `layout-overflow` because that one
* asserts the *shell* needs no sideways scrolling — clipping inside a
* component is invisible to it.
*
* The header can only fix that for actions it renders itself, which is
* why they are data now. These are the assertions about the rule; the
* e2e spec is what checks it against the real widths.
*/
describe('<page-header> actions', () => {
it('renders a declared action, and asks the host to perform it', async () => {
// Same division the sort control already lives by: the header
// decides what fits, the host decides what happens.
const seen: string[] = [];
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
actions: playlistActions(seen),
});
await widthOf(el, 1200);
expect(buttons(el)).toEqual([
'Import',
'New Playlist',
'New Smart Playlist',
]);
shadow<HTMLButtonElement>(el, '[data-testid="page-action-import"]')!.click();
expect(seen).toEqual(['import']);
});
it('hides the overflow trigger while everything fits', async () => {
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
actions: playlistActions([]),
});
await widthOf(el, 1200);
expect(shadow<HTMLButtonElement>(el, '.more-button')!.hidden).toBe(true);
expect(menu(el)).toEqual([]);
});
it('collapses the lowest priority first', async () => {
// Import is lowest because it is rarest; New Playlist is highest
// because it is the drop target, and a closed menu cannot be one.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
actions: playlistActions([]),
});
// Asserted as the *order* rather than at two chosen widths: which
// pixel drops which button depends on the font and on the shell
// this tier does not have, and pinning those numbers here would be
// a test of the fixture. What the host declares is a sequence.
const states: string[][] = [];
for (let width = 1200; width >= 300; width -= 40) {
await widthOf(el, width);
const now = menu(el);
const last = states[states.length - 1];
if (last === undefined || last.join() !== now.join()) states.push(now);
}
expect(states).toEqual([
[],
['Import'],
['Import', 'New Smart Playlist'],
['Import', 'New Playlist', 'New Smart Playlist'],
]);
// The menu lists them in the host's declared order, not in the
// order they happened to collapse — a menu that reshuffles itself
// as the window narrows is a menu nobody can learn.
expect(buttons(el)).toEqual([]);
});
it('gives an action back when the width returns', async () => {
// Every pass starts from all-visible, so the collapsed set is a
// function of the current width and not of how it got there. A rule
// that only ever added to the set would never widen again.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
actions: playlistActions([]),
});
await widthOf(el, 420);
expect(buttons(el)).toEqual([]);
await widthOf(el, 1200);
expect(menu(el)).toEqual([]);
expect(buttons(el)).toEqual([
'Import',
'New Playlist',
'New Smart Playlist',
]);
});
it('collapses an action before it truncates the title', async () => {
// The title can ellipsis, which means `scrollWidth` reports a
// header that fits perfectly while the heading reads "Playlis…" —
// this issue's failure mode moved from the button to the title, and
// invisible to the same measurement that missed it the first time.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
actions: playlistActions([]),
});
await widthOf(el, 700);
const h1 = shadow<HTMLElement>(el, 'h1')!;
expect(h1.scrollWidth).toBeLessThanOrEqual(h1.clientWidth + 1);
expect(menu(el).length).toBeGreaterThan(0);
});
it('names the overflow trigger and says what it controls', async () => {
// An overflow menu is exactly the shape that grows a nameless
// control, and `aria-controls` cannot name an element that is not
// in the DOM — which is why the panel renders unconditionally and
// `wa-popup` hides it, the same rule `config-section` follows.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
actions: playlistActions([]),
});
await widthOf(el, 480);
const more = shadow<HTMLButtonElement>(el, '.more-button')!;
expect(more.hidden).toBe(false);
expect(more.getAttribute('aria-label')).toBe('More actions');
expect(more.getAttribute('aria-expanded')).toBe('false');
expect(more.getAttribute('aria-haspopup')).toBe('menu');
const panel = shadow<HTMLElement>(el, '#page-header-overflow')!;
expect(more.getAttribute('aria-controls')).toBe(panel.id);
expect(panel.getAttribute('role')).toBe('menu');
more.click();
await el.updateComplete;
expect(
shadow<HTMLButtonElement>(el, '.more-button')!.getAttribute(
'aria-expanded',
),
).toBe('true');
});
it('runs a collapsed action from the menu, and closes it', async () => {
const seen: string[] = [];
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
actions: playlistActions(seen),
});
await widthOf(el, 700);
shadow<HTMLButtonElement>(el, '.more-button')!.click();
await el.updateComplete;
shadowAll<HTMLElement>(el, '#page-header-overflow wa-dropdown-item')[0]!.click();
await el.updateComplete;
expect(seen).toEqual(['import']);
expect(
shadow<HTMLButtonElement>(el, '.more-button')!.getAttribute(
'aria-expanded',
),
).toBe('false');
});
it('keeps a drop target a drop target, and does not fake one in the menu', async () => {
// You cannot drag a track onto a closed menu, so the affordance is
// absent from the overflow rather than approximated there. The
// header wires the handlers onto the button and owns none of them.
const dropped: string[] = [];
const actions: PageAction[] = [
{
id: 'new-playlist',
label: 'New Playlist',
icon: 'plus',
onSelect: () => undefined,
drop: {
active: true,
onDragOver: () => dropped.push('over'),
onDragLeave: () => dropped.push('leave'),
onDrop: () => dropped.push('drop'),
},
},
];
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
actions,
});
await widthOf(el, 1200);
const button = shadow<HTMLElement>(
el,
'[data-testid="page-action-new-playlist"]',
)!;
expect(button.classList.contains('drag-over')).toBe(true);
button.dispatchEvent(new DragEvent('dragover', { bubbles: true }));
button.dispatchEvent(new DragEvent('drop', { bubbles: true }));
expect(dropped).toEqual(['over', 'drop']);
// …and collapsed, it is a menu item with no drop wiring at all.
await widthOf(el, 120);
expect(menu(el)).toEqual(['New Playlist']);
expect(
shadow<HTMLElement>(el, '[data-testid="page-action-new-playlist"]')
?.hidden,
).toBe(true);
});
it('renders nothing at all for a view with no actions', async () => {
// Two of the three hosts have one action and one has none while its
// other tab is up; an empty actions row is not a mode.
const el = await fixture<PageHeader>('page-header', { heading: 'Albums' });
await widthOf(el, 900);
expect(shadow(el, '.actions')).toBeNull();
});
});
describe('<page-header> as each view wears it', () => {
// One baseline per arrangement rather than per view: the point is
// that eight views produce four shapes, not eight.