Android: context menus are a bottom sheet, not a popup that gets clipped #176
No files matched your search
@@ -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';
|
} from 'lit';
|
||||||
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
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 { registerViewAware } from './view-lifecycle';
|
||||||
|
import {
|
||||||
|
MENU_DISMISS_EVENT,
|
||||||
|
MENU_SHOWN_EVENT,
|
||||||
|
} from '../components/menu-surface/menu-surface';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host interface for components using the ContextMenuController.
|
* Host interface for components using the ContextMenuController.
|
||||||
@@ -17,10 +30,18 @@ export interface ContextMenuHost
|
|||||||
extends ReactiveControllerHost {
|
extends ReactiveControllerHost {
|
||||||
updateComplete: Promise<boolean>;
|
updateComplete: Promise<boolean>;
|
||||||
shadowRoot: ShadowRoot | null;
|
shadowRoot: ShadowRoot | null;
|
||||||
/** Return the main context-menu popup element. */
|
/**
|
||||||
getContextMenuPopup(): WaPopup | undefined;
|
* Return the main context-menu surface.
|
||||||
/** Return the playlist submenu popup element. */
|
*
|
||||||
getPlaylistSubmenuPopup(): WaPopup | undefined;
|
* `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
|
* Called when the context menu is closed by an
|
||||||
* outside click/contextmenu/mousedown. Components
|
* outside click/contextmenu/mousedown. Components
|
||||||
@@ -33,6 +54,15 @@ export interface ContextMenuHost
|
|||||||
/** Submenu close delay in milliseconds. */
|
/** Submenu close delay in milliseconds. */
|
||||||
const SUBMENU_CLOSE_DELAY = 150;
|
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. */
|
/** A menu item, focusable and clickable. Web Awesome sets `role` itself. */
|
||||||
type MenuItem = HTMLElement & { active?: boolean; disabled?: boolean };
|
type MenuItem = HTMLElement & { active?: boolean; disabled?: boolean };
|
||||||
|
|
||||||
@@ -73,6 +103,30 @@ export class MenuKeyboard {
|
|||||||
void this.focusFirstItem(panel);
|
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.
|
* 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));
|
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
|
// panel on an animation frame, and `focus()` on a not-yet-shown
|
||||||
// element is a silent no-op — which looks identical to a menu
|
// element is a silent no-op — which looks identical to a menu
|
||||||
// that opened and refused to take focus.
|
// 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.
|
// Bail if the menu closed while we waited.
|
||||||
if (this.panel !== panel) return;
|
if (this.panel !== panel) return;
|
||||||
|
|
||||||
@@ -259,6 +326,20 @@ export class ContextMenuController
|
|||||||
/** Bound close handler for document events. */
|
/** Bound close handler for document events. */
|
||||||
private closeHandler = () => this.close();
|
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. */
|
/** Bound mousedown handler for outside-click detection. */
|
||||||
private mousedownCloseHandler = (
|
private mousedownCloseHandler = (
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
@@ -325,12 +406,28 @@ export class ContextMenuController
|
|||||||
'mousedown',
|
'mousedown',
|
||||||
this.mousedownCloseHandler,
|
this.mousedownCloseHandler,
|
||||||
);
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
MENU_DISMISS_EVENT,
|
||||||
|
this.closeHandler,
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
MENU_SHOWN_EVENT,
|
||||||
|
this.shownHandler,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private detach(): void {
|
private detach(): void {
|
||||||
if (!this.listening) return;
|
if (!this.listening) return;
|
||||||
|
|
||||||
this.listening = false;
|
this.listening = false;
|
||||||
|
document.removeEventListener(
|
||||||
|
MENU_DISMISS_EVENT,
|
||||||
|
this.closeHandler,
|
||||||
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
MENU_SHOWN_EVENT,
|
||||||
|
this.shownHandler,
|
||||||
|
);
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
'click',
|
'click',
|
||||||
this.closeHandler,
|
this.closeHandler,
|
||||||
@@ -550,6 +647,48 @@ export const contextMenuStyles = css`
|
|||||||
z-index: 200;
|
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 {
|
.context-menu-panel {
|
||||||
background-color: var(
|
background-color: var(
|
||||||
--yj-bg-elevated,
|
--yj-bg-elevated,
|
||||||
|
|||||||
Reference in new issue
Block a user