fix(page-header): collapse the actions that do not fit into a menu
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m36s
CI / e2e (pull_request) Successful in 7m17s

Playlists slotted three buttons totalling 390px into a header that gets
700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px
with the queue closed, and 158 of 162 at the 800x600 enforced minimum.
On a phone none of the three could be reached at all, which is what the
Android report said. Plan 018's size matrix promises the opposite: no
action is ever unreachable at any supported size.

The header could not fix that for slotted markup, and that is a fact
about the API rather than an effort estimate — a component 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 passes `PageAction[]` and the header chooses the
rendering; the slot survives for markup a data list cannot express, at
the stated cost that a slotted action does not collapse.

All three hosts that slot actions migrated, which also normalises the
plain-<button>/<wa-button> split between them onto one shape the header
styles — and lets it measure a button that has already upgraded, rather
than a wa-button whose shadow DOM arrives in its own first update.

Four things in it are load-bearing:

- Every measuring pass starts from all-visible, so the collapsed set is
  a pure function of the current width and an action comes back when
  the window grows. It flips `hidden` imperatively rather than
  re-rendering between steps, or the intermediate state paints and the
  fix flashes the overflow it exists to prevent.
- "Fits" means nothing is clipped, not that the header does not
  overflow. Once the title can ellipsis it absorbs the pressure and
  scrollWidth reports a perfect fit while the heading reads "Playlis…"
  — this bug moved from the button to the title, and invisible to the
  same measurement that missed it the first time.
- New Playlist has the highest priority because it is the drop target
  and a closed menu cannot be one. `PageAction.drop` therefore carries
  the host's own handlers; the affordance is absent from the overflow
  rather than approximated there.
- The overflow trigger is a named button with aria-expanded and an
  aria-controls naming a panel that is always in the DOM, and the
  keyboard model is the shared `MenuKeyboard`.

`layout-overflow.spec.ts` passes on the broken build — it asserts the
shell needs no sideways scrolling, and clipping inside a component is
invisible to it, which is why this defect survived a spec named for it.
The new spec measures each button against its own header at four
viewports and asserts buttons plus menu account for every declared
action, without which it would pass vacuously on a build rendering none.

Closes #69
This commit is contained in:
2026-08-19 15:08:57 -04:00
parent 3fa7c7734b
commit f967916550
12 changed files with 1304 additions and 123 deletions
@@ -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