Android: context menus should be a bottom sheet, not a popover that gets clipped #60

Closed
opened 2026-08-18 05:59:09 +00:00 by logan · 3 comments
Collaborator

Report

The long-press context menu on a track can end up behind other UI, and its buttons are too small for touch. Make the mobile context menu a bottom drawer with large, obvious options.

Findings

  • Menus are wa-popup/wa-dropdown-item anchored to the touch point, with utils/context-menu-controller.ts and MenuKeyboard providing one keyboard model. A popup near the bottom of a 439 CSS px viewport has nowhere to go.
  • Web Awesome popups set popover="manual", which the reference device's Chrome 113 does not support (Popover API is Chrome 114) — so top-layer promotion is not available there and clipping is expected, not incidental.
  • Every dialog in the app is a wa-dialog and there is deliberately no sixth pattern; a bottom sheet should be a wa-dialog variant (focus trap, Escape, accessible name via utils/name-dialog.ts) rather than a new hand-rolled overlay.

Direction

A <action-sheet> built on wa-dialog, taking the same item model the popup menus use, selected by viewport width so every existing menu call site gains it without changing. Large rows (48px+), swipe-down and backdrop tap to dismiss. Keep the keyboard model working — it is shared.

**Report** The long-press context menu on a track can end up behind other UI, and its buttons are too small for touch. Make the mobile context menu a bottom drawer with large, obvious options. **Findings** - Menus are `wa-popup`/`wa-dropdown-item` anchored to the touch point, with `utils/context-menu-controller.ts` and `MenuKeyboard` providing one keyboard model. A popup near the bottom of a 439 CSS px viewport has nowhere to go. - Web Awesome popups set `popover="manual"`, which the reference device's Chrome 113 **does not support** (Popover API is Chrome 114) — so top-layer promotion is not available there and clipping is expected, not incidental. - Every dialog in the app is a `wa-dialog` and there is deliberately no sixth pattern; a bottom sheet should be a `wa-dialog` variant (focus trap, Escape, accessible name via `utils/name-dialog.ts`) rather than a new hand-rolled overlay. **Direction** A `<action-sheet>` built on `wa-dialog`, taking the same item model the popup menus use, selected by viewport width so every existing menu call site gains it without changing. Large rows (48px+), swipe-down and backdrop tap to dismiss. Keep the keyboard model working — it is shared.
logan added a new dependency 2026-08-18 14:37:50 +00:00
logan added a new dependency 2026-08-18 14:37:53 +00:00
Author
Collaborator

#69 has added a sixth menu call site, and deliberately did not build a
bottom sheet for it
— flagging it here so this issue inherits it
rather than meeting it.

#69's Direction said "a bottom sheet on phone — see the context-menu
issue". Deciding it the other way, and the argument is this issue's own:
your Direction is an <action-sheet> on wa-dialog "taking the same
item model the popup menus use, selected by viewport width so every
existing menu call site gains it without changing
". A phone-specific
surface built inside page-header is therefore either thrown away when
that lands, or becomes exactly the second pattern this issue exists to
prevent. Blocking #69 on a Phase 4 issue to avoid that would have left a
live Priority/High defect open — every Playlists action unreachable on
a phone — for the sake of a surface that is going to be replaced from
one place anyway.

So page-header's overflow menu is wa-popup + wa-dropdown-item +
the shared MenuKeyboard, the same as the other five. What that means
for you:

  • It is one more call site, not a special case. Trigger is a
    <button aria-haspopup="menu" aria-expanded aria-controls>; the panel
    is a role="menu" with aria-label="More actions", rendered
    unconditionally so aria-controls names something real, and hidden by
    wa-popup's own .popup:not(.popup-active) { display: none }.
  • The item model is already data here, which the other five are not:
    PageAction[] is {id, label, icon, onSelect, priority, drop?}. If
    the <action-sheet> takes an item model, this call site can hand it
    one directly — it is the cheapest of the six to convert and probably
    the one to prototype against.
  • The clipping you describe does not bite at this call site, which
    is worth knowing so it is not used as evidence either way: this menu
    opens downward from the top of a full-height view, so it has
    somewhere to go even without top-layer promotion. Your Chrome 113
    finding (popover="manual" is Chrome 114) is untouched — a menu
    anchored to a touch point near the bottom of a 439px viewport still
    has nowhere to go, and that is still the reason this issue exists.
  • Touch target size is a real gap here too. The overflow rows are
    wa-dropdown-item defaults, not the 48px+ this issue asks for, and on
    a phone every Playlists action is in that menu — so this call site
    is now one of the places where the size complaint is most visible.

Nothing here blocks you and nothing here needs undoing first.

**#69 has added a sixth menu call site, and deliberately did not build a bottom sheet for it** — flagging it here so this issue inherits it rather than meeting it. #69's Direction said "a bottom sheet on phone — see the context-menu issue". Deciding it the other way, and the argument is this issue's own: your Direction is an `<action-sheet>` on `wa-dialog` "taking the same item model the popup menus use, **selected by viewport width so every existing menu call site gains it without changing**". A phone-specific surface built inside `page-header` is therefore either thrown away when that lands, or becomes exactly the second pattern this issue exists to prevent. Blocking #69 on a Phase 4 issue to avoid that would have left a live `Priority/High` defect open — every Playlists action unreachable on a phone — for the sake of a surface that is going to be replaced from one place anyway. So `page-header`'s overflow menu is `wa-popup` + `wa-dropdown-item` + the shared `MenuKeyboard`, the same as the other five. What that means for you: - **It is one more call site, not a special case.** Trigger is a `<button aria-haspopup="menu" aria-expanded aria-controls>`; the panel is a `role="menu"` with `aria-label="More actions"`, rendered unconditionally so `aria-controls` names something real, and hidden by `wa-popup`'s own `.popup:not(.popup-active) { display: none }`. - **The item model is already data here**, which the other five are not: `PageAction[]` is `{id, label, icon, onSelect, priority, drop?}`. If the `<action-sheet>` takes an item model, this call site can hand it one directly — it is the cheapest of the six to convert and probably the one to prototype against. - **The clipping you describe does not bite at this call site**, which is worth knowing so it is not used as evidence either way: this menu opens *downward from the top* of a full-height view, so it has somewhere to go even without top-layer promotion. Your Chrome 113 finding (`popover="manual"` is Chrome 114) is untouched — a menu anchored to a touch point near the bottom of a 439px viewport still has nowhere to go, and that is still the reason this issue exists. - **Touch target size is a real gap here too.** The overflow rows are `wa-dropdown-item` defaults, not the 48px+ this issue asks for, and on a phone *every* Playlists action is in that menu — so this call site is now one of the places where the size complaint is most visible. Nothing here blocks you and nothing here needs undoing first.
Author
Collaborator

Why this happens, from the Web Awesome source — it is sharper than
"113 has no Popover API", and it explains why #62's popover was fine.

wa-popup renders <div popover="manual" part="popup"> and feature
detects:

var SUPPORTS_POPOVER = globalThis?.HTMLElement?.prototype.hasOwnProperty("popover");
...
strategy: SUPPORTS_POPOVER ? "absolute" : "fixed",

So it already has a fallback and does not simply break on 113: with
no Popover API it positions with strategy: "fixed" instead of relying
on the top layer.

position: fixed is not enough here, and the reason is in our own
stylesheet.
contain: paint makes an element a containing block for
fixed-position descendants and clips them. index.css has:

.main-panel { contain: layout style paint; }   /* line 366 */
div.sidebar { contain: layout style paint; }

Every context menu in this list is rendered from a component inside
.main-paneltrack-list, cover-grid, artists-view,
genres-view, the two Explore detail views. On a browser with the
Popover API the menu is promoted to the top layer and escapes all of
that; on 113 it is a fixed-position box contained and clipped by the
main panel
. That is a deterministic, structural clip, not a
z-index race, and it is why the report says "behind other UI".

The contrast is the useful part. While doing #62 I measured
job-indicator's popover at the device's own 424x439 and it was
neither clipped nor covered at any width. That is not luck: it lives in
.top-bar, and no ancestor of the top bar has containment, so even
the fixed fallback lands correctly. Same component, same browser, two
different outcomes decided by which side of contain: paint the menu
is on.

Two consequences for this issue:

  • A bottom sheet fixes it by construction, not by styling. A
    wa-dialog uses the native <dialog> top layer, which 113 does
    have (showModal() is Chrome 37) — so the sheet escapes containment
    for the same reason the drawer and every dialog in the app already
    do. That is a stronger argument for the Direction than "the buttons
    are small".
  • No tier here can reproduce the clip. Chromium and WebKit in CI
    both support the Popover API, so the menus are top-layered and
    correct; a spec asserting "the menu is not clipped" passes on the
    broken build. If you want a regression guard, assert the mechanism
    — that the menu is a dialog rather than a popup at phone widths —
    rather than the symptom.

Not claiming this; filed because whoever takes it should not have to
re-derive it.

**Why this happens, from the Web Awesome source — it is sharper than "113 has no Popover API", and it explains why #62's popover was fine.** `wa-popup` renders `<div popover="manual" part="popup">` and feature detects: ```js var SUPPORTS_POPOVER = globalThis?.HTMLElement?.prototype.hasOwnProperty("popover"); ... strategy: SUPPORTS_POPOVER ? "absolute" : "fixed", ``` So it already has a fallback and does **not** simply break on 113: with no Popover API it positions with `strategy: "fixed"` instead of relying on the top layer. **`position: fixed` is not enough here, and the reason is in our own stylesheet.** `contain: paint` makes an element a containing block for fixed-position descendants *and* clips them. `index.css` has: ``` .main-panel { contain: layout style paint; } /* line 366 */ div.sidebar { contain: layout style paint; } ``` Every context menu in this list is rendered from a component inside `.main-panel` — `track-list`, `cover-grid`, `artists-view`, `genres-view`, the two Explore detail views. On a browser with the Popover API the menu is promoted to the top layer and escapes all of that; on 113 it is a fixed-position box **contained and clipped by the main panel**. That is a deterministic, structural clip, not a z-index race, and it is why the report says "behind other UI". **The contrast is the useful part.** While doing #62 I measured `job-indicator`'s popover at the device's own 424x439 and it was neither clipped nor covered at any width. That is not luck: it lives in `.top-bar`, and **no ancestor of the top bar has containment**, so even the fixed fallback lands correctly. Same component, same browser, two different outcomes decided by which side of `contain: paint` the menu is on. Two consequences for this issue: - **A bottom sheet fixes it by construction**, not by styling. A `wa-dialog` uses the native `<dialog>` top layer, which 113 *does* have (`showModal()` is Chrome 37) — so the sheet escapes containment for the same reason the drawer and every dialog in the app already do. That is a stronger argument for the Direction than "the buttons are small". - **No tier here can reproduce the clip.** Chromium and WebKit in CI both support the Popover API, so the menus are top-layered and correct; a spec asserting "the menu is not clipped" passes on the broken build. If you want a regression guard, assert the *mechanism* — that the menu is a dialog rather than a popup at phone widths — rather than the symptom. Not claiming this; filed because whoever takes it should not have to re-derive it.
logan self-assigned this 2026-08-21 05:55:06 +00:00
logan added the
Status
In Progress
label 2026-08-21 05:55:07 +00:00
Author
Collaborator

Taking this, branch 60-context-menu-action-sheet. It opens Phase 4 in
#73's order and is the item I flagged on #174 as the one to take next.

Why now, specifically: a device is attached. Your second comment
says it outright — "No tier here can reproduce the clip", because CI's
Chromium and WebKit both have the Popover API. So today this issue rests
on reading the Web Awesome source, which is good evidence and is not a
measurement. A Light Phone III (Android 14, arm64, Chrome 113, 424x439)
is plugged in, so the first thing I will do is reproduce the clip on
the real engine and post the numbers
, before writing any code. If it
does not reproduce, that is worth knowing more than the fix is.

The plan, and the one thing I would do differently from the
Direction.

Your Direction asks for an <action-sheet> on wa-dialog "selected by
viewport width so every existing menu call site gains it without
changing". The mechanism is right and I am keeping it; the "without
changing" is not quite reachable, and I would rather say so up front
than quietly do twelve edits.

The controller does not own the markup — each host renders its own
<wa-popup> with a .context-menu-panel inside it. So a zero-edit
version would have to move those nodes at open time, and Lit owns
them. What is reachable is a one-line change per call site: a
<menu-surface> element with the same .active / .anchor API
ContextMenuController already drives, which renders a wa-popup above
600px and a wa-dialog sheet below it, with the host's existing panel
slotted into either. Twelve tag swaps, no other change at any call site,
and the decision lives in one component.

contextMenuStyles is what makes the rest free, and it is the piece
worth knowing before starting: it is already a shared stylesheet every
one of these hosts includes, and it already styles
.context-menu-panel wa-dropdown-item. So the 48px rows go there once
rather than twelve times, which is the "gains it without changing" your
Direction was reaching for, one layer down from where it was expected.

The element existence is matchMedia, not a media query — job-band
and player-controls' rule, since this decides whether a <dialog> is
in the tree at all.

Deliberately not in this pass, and I will say so on the issue rather
than leave it implied: the playlist submenu. It is a hover-anchored
flyout; inside a top-layer dialog it stops being clipped, which is this
issue's actual complaint, but a nested flyout is not a sheet. I will
measure it on the device and file what I find rather than growing this
change to cover it.

The regression guard will assert the mechanism — that the surface is
a native <dialog> and not a popup at phone width — for the reason your
comment gives and the reason queue-as-a-screen.spec.ts does the same:
a spec asserting "not clipped" passes on the broken build in every tier
that can run it.

Taking this, branch `60-context-menu-action-sheet`. It opens Phase 4 in #73's order and is the item I flagged on #174 as the one to take next. **Why now, specifically: a device is attached.** Your second comment says it outright — *"No tier here can reproduce the clip"*, because CI's Chromium and WebKit both have the Popover API. So today this issue rests on reading the Web Awesome source, which is good evidence and is not a measurement. A Light Phone III (Android 14, arm64, Chrome 113, 424x439) is plugged in, so the first thing I will do is **reproduce the clip on the real engine and post the numbers**, before writing any code. If it does not reproduce, that is worth knowing more than the fix is. **The plan, and the one thing I would do differently from the Direction.** Your Direction asks for an `<action-sheet>` on `wa-dialog` "selected by viewport width so every existing menu call site gains it without changing". The mechanism is right and I am keeping it; the "without changing" is not quite reachable, and I would rather say so up front than quietly do twelve edits. The controller does not own the markup — each host renders its own `<wa-popup>` with a `.context-menu-panel` inside it. So a zero-edit version would have to *move* those nodes at open time, and Lit owns them. What is reachable is a **one-line change per call site**: a `<menu-surface>` element with the same `.active` / `.anchor` API `ContextMenuController` already drives, which renders a `wa-popup` above 600px and a `wa-dialog` sheet below it, with the host's existing panel slotted into either. Twelve tag swaps, no other change at any call site, and the decision lives in one component. **`contextMenuStyles` is what makes the rest free**, and it is the piece worth knowing before starting: it is already a shared stylesheet every one of these hosts includes, and it already styles `.context-menu-panel wa-dropdown-item`. So the 48px rows go there once rather than twelve times, which is the "gains it without changing" your Direction was reaching for, one layer down from where it was expected. The element existence is `matchMedia`, not a media query — `job-band` and `player-controls`' rule, since this decides whether a `<dialog>` is in the tree at all. **Deliberately not in this pass**, and I will say so on the issue rather than leave it implied: the playlist submenu. It is a hover-anchored flyout; inside a top-layer dialog it stops being clipped, which is this issue's actual complaint, but a nested flyout is not a sheet. I will measure it on the device and file what I find rather than growing this change to cover it. The regression guard will assert the **mechanism** — that the surface is a native `<dialog>` and not a popup at phone width — for the reason your comment gives and the reason `queue-as-a-screen.spec.ts` does the same: a spec asserting "not clipped" passes on the broken build in every tier that can run it.
logan closed this issue 2026-08-21 07:25:55 +00:00
gitea-actions bot removed the
Status
In Progress
label 2026-08-21 07:26:06 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: yonlu/yellowjacket#60