Files
yellowjacket/frontend/src/components/menu-surface/menu-surface.ts
T
logan 8c85db8968
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 9m33s
docs(ui): quote the shipped fade's own measurement
The comment on `wa-dialog::part(body)` carried bottom-edge pixels from
an intermediate probe (29,33,36) while `.planning/NOTES.md` recorded
the final sample against the shipped rule (22,24,27) — the same
gradient, read a few pixels higher up the box. A measurement written in
two places has to agree, or neither can be trusted.
2026-08-23 07:04:04 -04:00

364 lines
14 KiB
TypeScript

/**
* Where a context menu is drawn: a popup on a desktop, a bottom sheet
* on a phone (#60).
*
* Every context menu in this app is a `.context-menu-panel` inside a
* `<wa-popup>` anchored to the touch point, driven by
* `ContextMenuController`. On the reference device that is structurally
* broken, and the failure was measured on the hardware rather than
* inferred:
*
* - Chrome 113 has **no Popover API** (`popover` is Chrome 114), so
* `wa-popup` takes its own documented fallback and positions with
* `strategy: "fixed"` instead of the top layer. Measured on the
* device: `HTMLElement.prototype.hasOwnProperty('popover')` is false
* and the popup's computed `position` is `fixed`.
* - `index.css` puts `contain: layout style paint` on `.main-panel`,
* the ancestor of every view. Paint containment **clips** fixed
* descendants. Measured: `.main-panel` computes `contain: content`
* and spans 0-318 of a 439px viewport, while the open menu spans
* 191-401 — so 83px of it, three of its seven items, is cut off.
*
* A `<dialog>` fixes it by construction rather than by styling, because
* `showModal()` is Chrome 37 and uses the real top layer. **That was
* measured too, and it needed to be**: every other dialog in this app
* is mounted in `index.html`, *outside* `.main-panel`, so "dialogs are
* fine" was not evidence about a dialog opened from inside a view. A
* probe dialog appended to `track-list`'s shadow root paints to y=439,
* over the mini player and the tab bar, with the contained ancestor
* still there.
*
* Four things about this component are load-bearing.
*
* **It is one element with two presentations, not two components.**
* The host keeps rendering exactly the panel it rendered before and
* slots it into whichever surface is up, so the twelve call sites
* changed one tag name each and nothing else — no second item model, no
* second keyboard model, and `ContextMenuController` still drives
* `.active` and `.anchor` as if it were talking to a `wa-popup`.
*
* **Which surface exists is `matchMedia`, not a media query.** The
* decision is whether a `<dialog>` is in the tree at all, which is
* `job-band` and `player-controls`' rule: a `display: none` surface is
* still in the shadow root and still something a positional or by-role
* query finds.
*
* **The sheet has to un-do the UA stylesheet to be full-bleed.**
* A native `<dialog>` carries `max-width: calc(100% - 6px - 2em)` and
* `margin: auto`, which on the device produced a 354px panel floating
* in the middle of a 424px screen. `max-width: none` and explicit
* margins are what make it a sheet rather than a small centred box.
* The *positioning* needs no such care: a top-layer dialog's containing
* block is the viewport even with a paint-contained ancestor, which is
* why `bottom: 0` reaches y=439 and not the main panel's 318.
*
* **Dismissal has to travel back.** `wa-dialog` closes itself on
* Escape, which would otherwise leave the controller's
* `contextMenuOpen` true and the menu unopenable until something else
* cleared it. `menu-dismiss` is that signal, and the controller listens
* for it on the document beside the click and contextmenu listeners it
* already has.
*/
import { LitElement, css, html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import { PHONE_QUERY } from '@utils/breakpoints';
import { nameDialogsIn } from '@utils/name-dialog';
/** The event a surface dispatches when it closed itself. */
export const MENU_DISMISS_EVENT = 'menu-dismiss';
/**
* The event a surface dispatches once it has finished showing.
*
* Only the sheet sends it, and only because `wa-dialog` moves focus to
* itself on the frame after `showModal()` -- see `MenuKeyboard.refocus`
* for why waiting longer is not the fix.
*/
export const MENU_SHOWN_EVENT = 'menu-shown';
/**
* A `wa-popup` anchor: a real element or a virtual one.
*
* `undefined` rather than `null` for "not set yet", because that is
* what `wa-popup`'s own property accepts — this surface hands the value
* straight through and must not widen it.
*/
type MenuAnchor = WaPopup['anchor'] | undefined;
/** A `wa-dialog`, as much of it as this file needs. */
type DialogEl = HTMLElement & { open: boolean };
@customElement('menu-surface')
export class MenuSurface extends LitElement {
/** Whether the menu is showing. Set by `ContextMenuController`. */
@property({ type: Boolean }) active = false;
/**
* Where the popup hangs from. Ignored in sheet mode, which is
* anchored to the bottom of the screen rather than to the touch
* point — that is the whole point of a sheet.
*/
@property({ attribute: false }) anchor: MenuAnchor = undefined;
/**
* `wa-popup`'s placement, defaulted because all twelve call sites
* passed the same one. Kept as a property so a future menu that
* wants another does not have to reach past this component.
*/
@property() placement = 'bottom-start';
/**
* What to call the sheet, for a surface whose content is not a
* `.context-menu-panel` with an `aria-label` of its own -- the
* playlist submenu, whose content is a `playlist-picker`.
*/
@property() label = '';
@state() private sheet = false;
@query('wa-popup') private popup?: WaPopup;
@query('wa-dialog') private dialog?: DialogEl;
private phoneQuery?: MediaQueryList;
static override styles = css`
:host {
display: contents;
}
wa-popup {
z-index: 200;
}
/* The sheet. A native dialog's UA stylesheet centres it and
caps its width, which on the device drew a 354px box in the
middle of a 424px screen — so all four of these are undoing
that rather than decorating. */
wa-dialog::part(dialog) {
margin: auto auto 0 auto;
max-width: none;
max-height: 85vh;
width: 100%;
border-radius: 12px 12px 0 0;
background: var(--yj-bg-elevated, #343a40);
padding: 0;
}
/* **A long menu scrolls; it does not hang off the bottom.**
Measured on the device at 80vh: seven 48px rows plus the grip
came to 364px against a 351px dialog, so the last row's
bottom was at y=452 on a 439px screen -- the one row a
destructive action is most likely to be. The cap has to stay
(a sheet covering the whole screen is a page, not a sheet),
so the body is what gives.
**And a body that scrolls says so** (#207). Scrolling was the
whole of the fix above, which left the last item reachable
and nothing on screen admitting it was there -- measured at
424x439, eight items ending at y=470 with the fold at 439,
and worse when the cut lands on a row boundary, where the
sheet ends in a clean edge that reads as the end of the list.
Two layers, and the *order* is what asks the question: a
shadow pinned to the bottom of the box (attachment scroll),
and over it a cover of the sheet's own colour painted at the
end of the *content* (attachment local), which therefore
scrolls up over the shadow and hides it exactly when there is
nothing more to see. So the affordance is absent on a menu
that fits, present the moment one does not, and gone again at
the end of the list -- with no scroll listener, no
measurement, and nothing reaching into wa-dialog's shadow
root for the scroller. background-attachment is Chrome 4;
the reference device is Chrome 113.
**The curve is steep because the rows under it stay live.**
A scrim over a menu item is that item's text surface, and
this app's rule is that text clears 4.5:1 on every surface it
can sit on -- which the light ramp, whose bgElevated is
#e9ecef, is what makes non-theoretical. A row is 48px with
its label centred, so 32px of scrim that is already down to
a quarter strength at 14px reaches y-centre at about 0.06 and
spends its weight on the strip below the last legible label.
Measured on the dark ramp at x=300, flat 52,58,64 throughout
before: 50,56,62 at y=330, 33,37,40 at y=350 and 22,24,27 at
the bottom edge, and flat again at the end of the list. The
light ramp puts 9.9:1 on the last label. */
wa-dialog::part(body) {
padding: 0;
overflow-y: auto;
background:
linear-gradient(
var(--yj-bg-elevated, #343a40),
var(--yj-bg-elevated, #343a40)
)
bottom / 100% 32px no-repeat local,
linear-gradient(
to top,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0.25) 45%,
rgba(0, 0, 0, 0) 100%
)
bottom / 100% 32px no-repeat scroll;
}
/* A sheet is dragged at with a thumb, so it says where its top
edge is. Decorative: the panel below it carries the actions. */
.grip {
width: 36px;
height: 4px;
margin: 8px auto 4px;
border-radius: 2px;
background: var(--yj-text-tertiary, #888);
}
`;
override connectedCallback(): void {
super.connectedCallback();
// Looked up here rather than at module load, so a test can
// install its own matchMedia before the element is created.
this.phoneQuery = window.matchMedia?.(PHONE_QUERY);
this.sheet = this.phoneQuery?.matches ?? false;
this.phoneQuery?.addEventListener('change', this.onPhoneChange);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.phoneQuery?.removeEventListener('change', this.onPhoneChange);
}
private onPhoneChange = (e: MediaQueryListEvent): void => {
this.sheet = e.matches;
};
/**
* Re-run the popup's positioning.
*
* Forwarded rather than dropped because `page-header` calls it when
* it opens the overflow menu: the popup is rendered before the
* button it anchors to has settled. A sheet has nothing to
* reposition -- it is anchored to the bottom of the screen -- so
* there it is deliberately a no-op rather than an error.
*/
reposition(): void {
this.popup?.reposition();
}
/**
* The panel the host slotted in. It is light DOM here and stays in
* the host's shadow root, which is what keeps the host's own
* `contextMenuStyles` applying to it in both presentations.
*/
private get panel(): HTMLElement | null {
return this.querySelector('.context-menu-panel');
}
override updated(): void {
const panel = this.panel;
// The sheet's rows are bigger, and that rule lives in the one
// stylesheet every call site already includes rather than in
// twelve places. The attribute is how it knows.
if (panel) panel.toggleAttribute('data-sheet', this.sheet);
if (this.sheet) {
this.syncSheet(panel);
return;
}
if (this.popup) {
if (this.anchor) this.popup.anchor = this.anchor;
this.popup.active = this.active;
}
}
private syncSheet(panel: HTMLElement | null): void {
const dialog = this.dialog;
if (!dialog) return;
// The dialog is named after the menu it contains, so no call
// site has to say the same thing twice: the panel already
// carries `role="menu"` and an `aria-label` naming what it acts
// on. `without-header` renders no heading, which is
// `name-dialog`'s documented `aria-label` path.
const label = panel?.getAttribute('aria-label') || this.label;
if (label) dialog.setAttribute('label', label);
nameDialogsIn(this.shadowRoot);
if (dialog.open !== this.active) dialog.open = this.active;
}
/**
* `wa-dialog` closed itself — Escape, or its own close button.
* The controller owns `contextMenuOpen`, so it has to hear about
* it or the menu is left open in state and shut on screen.
*/
private onDialogShown = (): void => {
if (!this.active) return;
this.dispatchEvent(
new CustomEvent(MENU_SHOWN_EVENT, {
bubbles: true,
composed: true,
}),
);
};
private onDialogHide = (): void => {
if (!this.active) return;
this.dispatchEvent(
new CustomEvent(MENU_DISMISS_EVENT, {
bubbles: true,
composed: true,
}),
);
};
override render() {
if (this.sheet) {
// **The anchor stays out of the sheet.** One call site --
// `page-header`'s overflow menu -- slots its own trigger
// button as the thing the popup hangs from, and a sheet
// hangs from the bottom of the screen instead. Rendering
// that slot outside the dialog is what keeps the button on
// the page rather than inside the surface it opens.
return html`
<slot name="anchor"></slot>
<wa-dialog
without-header
data-testid="menu-sheet"
@wa-after-show=${this.onDialogShown}
@wa-hide=${this.onDialogHide}
>
<div class="grip"></div>
<slot></slot>
</wa-dialog>
`;
}
return html`
<wa-popup placement=${this.placement} flip shift>
<slot name="anchor" slot="anchor"></slot>
<slot></slot>
</wa-popup>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'menu-surface': MenuSurface;
}
}