Android: context menus are a bottom sheet, not a popup that gets clipped #176
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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. */
|
||||
wa-dialog::part(body) {
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,20 @@ import type {
|
||||
} from 'lit';
|
||||
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
|
||||
/**
|
||||
* What this controller needs of a surface: something it can switch on
|
||||
* and point at. Both `wa-popup` and `menu-surface` satisfy it.
|
||||
*/
|
||||
export type MenuTarget = HTMLElement & {
|
||||
active: boolean;
|
||||
anchor?: WaPopup['anchor'];
|
||||
};
|
||||
|
||||
import { registerViewAware } from './view-lifecycle';
|
||||
import {
|
||||
MENU_DISMISS_EVENT,
|
||||
MENU_SHOWN_EVENT,
|
||||
} from '../components/menu-surface/menu-surface';
|
||||
|
||||
/**
|
||||
* Host interface for components using the ContextMenuController.
|
||||
@@ -17,10 +30,18 @@ export interface ContextMenuHost
|
||||
extends ReactiveControllerHost {
|
||||
updateComplete: Promise<boolean>;
|
||||
shadowRoot: ShadowRoot | null;
|
||||
/** Return the main context-menu popup element. */
|
||||
getContextMenuPopup(): WaPopup | undefined;
|
||||
/** Return the playlist submenu popup element. */
|
||||
getPlaylistSubmenuPopup(): WaPopup | undefined;
|
||||
/**
|
||||
* Return the main context-menu surface.
|
||||
*
|
||||
* `MenuSurface` since #60, which is a `wa-popup` above 600px and a
|
||||
* bottom sheet below it. The type is the narrow shape this
|
||||
* controller drives rather than either element, so a host that
|
||||
* still renders a bare `wa-popup` — the playlist submenu does —
|
||||
* satisfies it unchanged.
|
||||
*/
|
||||
getContextMenuPopup(): MenuTarget | undefined;
|
||||
/** Return the playlist submenu surface. */
|
||||
getPlaylistSubmenuPopup(): MenuTarget | undefined;
|
||||
/**
|
||||
* Called when the context menu is closed by an
|
||||
* outside click/contextmenu/mousedown. Components
|
||||
@@ -33,6 +54,15 @@ export interface ContextMenuHost
|
||||
/** Submenu close delay in milliseconds. */
|
||||
const SUBMENU_CLOSE_DELAY = 150;
|
||||
|
||||
/**
|
||||
* How long to keep trying to put focus on a menu's first item.
|
||||
*
|
||||
* Long enough to outlast `wa-dialog`'s show animation, which ends by
|
||||
* focusing the dialog; short enough that a menu which genuinely has no
|
||||
* items stops rather than spinning for the life of the page.
|
||||
*/
|
||||
const FOCUS_RETRY_BUDGET_MS = 500;
|
||||
|
||||
/** A menu item, focusable and clickable. Web Awesome sets `role` itself. */
|
||||
type MenuItem = HTMLElement & { active?: boolean; disabled?: boolean };
|
||||
|
||||
@@ -73,6 +103,30 @@ export class MenuKeyboard {
|
||||
void this.focusFirstItem(panel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take focus back, for a surface that finished showing after we
|
||||
* had already placed it.
|
||||
*
|
||||
* `wa-dialog` focuses `[autofocus]` or *itself* on the animation
|
||||
* frame after `showModal()`, and it cannot see our first menu item
|
||||
* to prefer it: the panel is slotted through `menu-surface`, so the
|
||||
* dialog's own `querySelector` stops at the `<slot>`. Retrying on a
|
||||
* longer budget does not fix this either -- the first attempt
|
||||
* *succeeds*, and the steal happens afterwards. Measured on the
|
||||
* device: the sheet opened with focus on the `<dialog>` and every
|
||||
* arrow key went nowhere.
|
||||
*
|
||||
* So the surface says when it has settled and this re-asserts. It
|
||||
* is a no-op for a menu that is closed or that already has focus.
|
||||
*/
|
||||
refocus(): void {
|
||||
const panel = this.panel;
|
||||
|
||||
if (!panel || panel.contains(deepActiveElement())) return;
|
||||
|
||||
void this.focusFirstItem(panel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the first item, once the items are items.
|
||||
*
|
||||
@@ -91,11 +145,24 @@ export class MenuKeyboard {
|
||||
|
||||
await Promise.all(candidates.map((el) => el.updateComplete ?? null));
|
||||
|
||||
// …and once the popup has positioned itself. `wa-popup` places the
|
||||
// …and once the surface has shown itself. `wa-popup` places the
|
||||
// panel on an animation frame, and `focus()` on a not-yet-shown
|
||||
// element is a silent no-op — which looks identical to a menu
|
||||
// that opened and refused to take focus.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
//
|
||||
// **The budget is time, not frames, because #60 gave this a
|
||||
// second kind of surface.** Three frames was enough for a
|
||||
// popup; a `wa-dialog` runs a show *animation* and moves focus
|
||||
// to the dialog itself when it finishes, which lands after
|
||||
// those frames and takes the focus back. Measured on the
|
||||
// device: the sheet opened with `document.activeElement` on the
|
||||
// `<dialog>`, so every arrow key went nowhere. Retrying to a
|
||||
// deadline is `roving-grid`'s rule for the same reason — the
|
||||
// thing being waited for is another component's animation, not
|
||||
// a fixed number of paints.
|
||||
const deadline = Date.now() + FOCUS_RETRY_BUDGET_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
// Bail if the menu closed while we waited.
|
||||
if (this.panel !== panel) return;
|
||||
|
||||
@@ -259,6 +326,20 @@ export class ContextMenuController
|
||||
/** Bound close handler for document events. */
|
||||
private closeHandler = () => this.close();
|
||||
|
||||
/**
|
||||
* A surface finished showing; see `MenuKeyboard.refocus`.
|
||||
*
|
||||
* **Not while the submenu is up.** Both surfaces send this, and the
|
||||
* submenu's sheet opens *over* the main one -- so re-asserting
|
||||
* focus on the main panel's first item would snatch it straight
|
||||
* back out of the playlist picker the user just opened.
|
||||
*/
|
||||
private shownHandler = () => {
|
||||
if (this.contextMenuOpen && !this.playlistSubmenuOpen) {
|
||||
this.keyboard.refocus();
|
||||
}
|
||||
};
|
||||
|
||||
/** Bound mousedown handler for outside-click detection. */
|
||||
private mousedownCloseHandler = (
|
||||
e: MouseEvent,
|
||||
@@ -325,12 +406,28 @@ export class ContextMenuController
|
||||
'mousedown',
|
||||
this.mousedownCloseHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
MENU_DISMISS_EVENT,
|
||||
this.closeHandler,
|
||||
);
|
||||
document.addEventListener(
|
||||
MENU_SHOWN_EVENT,
|
||||
this.shownHandler,
|
||||
);
|
||||
}
|
||||
|
||||
private detach(): void {
|
||||
if (!this.listening) return;
|
||||
|
||||
this.listening = false;
|
||||
document.removeEventListener(
|
||||
MENU_DISMISS_EVENT,
|
||||
this.closeHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
MENU_SHOWN_EVENT,
|
||||
this.shownHandler,
|
||||
);
|
||||
document.removeEventListener(
|
||||
'click',
|
||||
this.closeHandler,
|
||||
@@ -550,6 +647,48 @@ export const contextMenuStyles = css`
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------
|
||||
The sheet (#60).
|
||||
|
||||
menu-surface puts data-sheet on the panel when it is drawn
|
||||
as a bottom sheet, and these rules are here rather than in that
|
||||
component because the panel is the *host's* light DOM: it lives
|
||||
in the host's shadow root, so only the host's stylesheet can
|
||||
reach it. This file is the one every call site already includes,
|
||||
which is what makes twelve menus grow thumb-sized rows from one
|
||||
edit.
|
||||
|
||||
Measured on the device before the change: rows were 29px, against
|
||||
the 44px floor plan 018 promises and the 48px this issue asks
|
||||
for. --------------------------------------------------------- */
|
||||
.context-menu-panel[data-sheet] {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
min-width: 0;
|
||||
padding: 4px 0 8px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.context-menu-panel[data-sheet] wa-dropdown-item {
|
||||
font-size: var(--yj-text-md, 0.9375rem);
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.context-menu-panel[data-sheet] wa-dropdown-item::part(base) {
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* A submenu arrow means "a flyout opens to the right", which is not
|
||||
what happens on a phone and is not a thing a thumb can aim at.
|
||||
The row still works — it is the tap handler that opens the
|
||||
playlist picker — so what goes is the arrow, not the item. */
|
||||
.context-menu-panel[data-sheet] .submenu-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu-panel {
|
||||
background-color: var(
|
||||
--yj-bg-elevated,
|
||||
|
||||
Reference in New Issue
Block a user