Hang a menu on its menu button rather than the trigger's wrapper

<x-menu> named its CSS anchor on the <span> around the trigger slot.
A trigger taken out of the flow, such as <x-button fab> fixed to the
bottom corner of a phone's window, left that span behind as an empty
box where the page put it, and the menu opened there. menu.js now moves
the name onto the menu button, beside any name the button already
carries for its tooltip, and moves it again whenever a Livewire morph
puts the server's attributes and a fresh name back. The wrapper keeps
the name until Alpine starts, or when there is no menu button.

A menu that fits neither below nor above its start edge now also tries
the opposite side and end together, so a default-position menu on a
FAB in the bottom-right corner opens above it, end-aligned. Before, it
fell back to its base position and overflowed the window.

This also applies to <x-fab-menu>, <x-account-menu>, <x-split-button>
and the <x-section-nav> picker, which share menu.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 18:18:39 +02:00
co-authored by Claude Opus 5
parent fb29169d44
commit a83d7f62ea
4 changed files with 159 additions and 6 deletions
@@ -244,7 +244,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
</x-menu> </x-menu>
``` ```
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab. `<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
### `<x-button-group>` ### `<x-button-group>`
+50
View File
@@ -4,6 +4,13 @@
* The menu button is the trigger's first button or link. Its ARIA attributes are written by * The menu button is the trigger's first button or link. Its ARIA attributes are written by
* script, which a Livewire morph removes along with anything else the server did not render, * script, which a Livewire morph removes along with anything else the server did not render,
* so they are written again whenever the trigger is used. * so they are written again whenever the trigger is used.
*
* The popover hangs on the menu button by CSS anchor positioning. The server can only name the
* wrapper around the trigger slot, and a trigger taken out of the flow a `position: fixed` FAB
* in a corner of the window leaves that wrapper behind as an empty box where the page put it,
* so the menu opened there. Script moves the name onto the menu button, beside any name the button
* carries itself (a button's tooltip anchors on it too), and moves it again after every morph,
* which puts the server's attributes, and a fresh name, back.
*/ */
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]' const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
@@ -14,6 +21,7 @@ const REOPEN_GUARD_MS = 250
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', () => ({ window.Alpine.data('materialMenu', () => ({
closedAt: -Infinity, closedAt: -Infinity,
anchored: null,
returnFocus: true, returnFocus: true,
focusWasInside: false, focusWasInside: false,
listeners: [], listeners: [],
@@ -22,6 +30,14 @@ document.addEventListener('alpine:init', () => {
const menu = this.$refs.menu const menu = this.$refs.menu
this.label() this.label()
this.anchor()
// A morph rewrites the wrapper's style with this render's name and the button's without
// it; the observer runs before the next frame is drawn, so an open menu never moves.
const observer = new MutationObserver(() => this.anchor())
observer.observe(this.$refs.trigger, { attributes: true, attributeFilter: ['style'], childList: true, subtree: true })
this.listeners.push(() => observer.disconnect())
// Only closes the browser starts — Escape, a press outside — arrive here alone; open() // Only closes the browser starts — Escape, a press outside — arrive here alone; open()
// and close() have already done their part, synchronously, because this event is // and close() have already done their part, synchronously, because this event is
@@ -64,6 +80,39 @@ document.addEventListener('alpine:init', () => {
return this.$refs.trigger.querySelector('button, a[href], [tabindex]') return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
}, },
/**
* Moves the anchor name the server gave the wrapper onto the menu button. The wrapper holds a
* name only as rendered this render's, which the popover's `position-anchor` matches so
* it is read there, never from the popover, which a morph may still be replacing.
*/
anchor() {
const trigger = this.$refs.trigger
const control = this.control()
const rendered = trigger.style.getPropertyValue('anchor-name').trim()
const name = rendered.startsWith('--') ? rendered : this.anchored
// No menu button, or an engine without anchor positioning: the wrapper keeps the name.
if (!control || !name) {
return
}
const names = control.style
.getPropertyValue('anchor-name')
.split(',')
.map((each) => each.trim())
.filter((each) => each.startsWith('--'))
if (!names.includes(name)) {
control.style.setProperty('anchor-name', [...names.filter((each) => each !== this.anchored), name].join(', '))
}
this.anchored = name
if (rendered !== '') {
trigger.style.removeProperty('anchor-name')
}
},
label() { label() {
const control = this.control() const control = this.control()
@@ -82,6 +131,7 @@ document.addEventListener('alpine:init', () => {
open(focus = 'first') { open(focus = 'first') {
this.label() this.label()
this.anchor()
if (!this.isOpen()) { if (!this.isOpen()) {
this.$refs.menu.showPopover() this.$refs.menu.showPopover()
+11 -5
View File
@@ -13,10 +13,16 @@
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded, The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor
positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping
when there is no room; a click outside or Escape closes it. The keyboard is WAI-ARIA's menu when there is no room to the other side, the other end, or both, so a menu on a FAB in a
button: Enter, Space or ArrowDown open on the first item, ArrowUp on the last; arrows, Home, corner of the window opens back across it; a click outside or Escape closes it. The keyboard
End and typing a letter move between items; Tab closes; activating an item closes the menu is WAI-ARIA's menu button: Enter, Space or ArrowDown open on the first item, ArrowUp on the
unless the item says `keep-open`, and Escape returns focus to the trigger. last; arrows, Home, End and typing a letter move between items; Tab closes; activating an item
closes the menu unless the item says `keep-open`, and Escape returns focus to the trigger.
The anchor name is rendered on the wrapper around the trigger slot, the only element the
server can name, and resources/js/menu.js moves it onto the menu button itself: a trigger
that is `position: fixed` (`<x-button fab>` on a phone) leaves the wrapper behind as an empty
box where the page put it, and the menu opened there.
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
2), or `vibrant` in tertiary-container StandardMenuTokens and VibrantMenuTokens from 2), or `vibrant` in tertiary-container StandardMenuTokens and VibrantMenuTokens from
@@ -53,7 +59,7 @@
x-on:click="activate($event)" x-on:click="activate($event)"
@class([ @class([
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]', 'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'my-1 [position-try-fallbacks:flip-block,flip-inline]', 'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0', 'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'bg-surface-container-low text-on-surface' => ! $vibrant, 'bg-surface-container-low text-on-surface' => ! $vibrant,
'bg-tertiary-container text-on-tertiary-container' => $vibrant, 'bg-tertiary-container text-on-tertiary-container' => $vibrant,
+97
View File
@@ -1,7 +1,49 @@
<?php <?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
const MORE = '#menus [aria-label="More"]'; const MORE = '#menus [aria-label="More"]';
class MenuAnchorProbe extends Component
{
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function render(): string
{
return <<<'BLADE'
<div class="grid justify-items-start gap-6 p-4">
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-menu label="Share actions">
<x-slot:trigger>
<x-button icon="more_vert" tooltip="More" data-test="more" />
</x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" />
<x-menu-item label="Download" icon="download" />
</x-menu>
<x-menu label="Create">
<x-slot:trigger>
<x-button fab icon="add" label="New plan" data-test="fab" />
</x-slot:trigger>
<x-menu-item label="Running plan" icon="directions_run" />
<x-menu-item label="Cycling plan" icon="directions_bike" />
</x-menu>
</div>
BLADE;
}
}
function showcase(string $section = 'buttons') function showcase(string $section = 'buttons')
{ {
return visit("/material/{$section}")->waitForEvent('networkidle') return visit("/material/{$section}")->waitForEvent('networkidle')
@@ -124,3 +166,58 @@ it('animates the loading indicator in the browser', function () {
showcase()->assertScript("{$clock} > 0.1"); showcase()->assertScript("{$clock} > 0.1");
}); });
/** A script giving the rects of a menu button (`control`) and of the menu it opens (`menu`). */
function menuAgainst(string $test, string $label): string
{
return "(() => { const control = document.querySelector('[data-test=\"{$test}\"]').getBoundingClientRect(); const menu = document.querySelector('[role=\"menu\"][aria-label=\"{$label}\"]').getBoundingClientRect(); return { control, menu }; })()";
}
it('hangs a menu on its menu button, even when the button is fixed to a corner of the window', function () {
Livewire::component('menu-anchor-probe', MenuAnchorProbe::class);
Route::middleware('web')->get('/menu-anchor-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:menu-anchor-probe />
@livewireScripts
</body>
</html>
BLADE));
$placed = menuAgainst('fab', 'Create');
$above = "(({ control, menu }) => getComputedStyle(document.querySelector('[data-test=\"fab\"]')).position === 'fixed' && menu.bottom <= control.top && control.top - menu.bottom <= 16 && Math.abs(menu.right - control.right) <= 16 && menu.left >= 0 && menu.top >= 0)({$placed})";
$page = visit('/menu-anchor-probe')->resize(400, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->click('@fab')
->assertAttribute('@fab', 'aria-expanded', 'true')
->assertScript($above);
$page->keys(':focus', 'Escape')->assertAttribute('@fab', 'aria-expanded', 'false');
// A Livewire render names the anchor afresh, on the wrapper again. Opened from the keyboard: a
// press this soon after the menu closed is taken for the light-dismiss press and ignored.
$page->script('window.eval("Livewire.first().touch()")');
$page->assertSeeIn('#renders', '1')
->script("document.querySelector('[data-test=\"fab\"]').focus()");
$page->keys(':focus', 'ArrowDown')
->assertAttribute('@fab', 'aria-expanded', 'true')
->assertScript($above);
// A button that also anchors its own tooltip keeps it, and the menu hangs under the button.
$page->resize(1024, 800)
->click('@more')
->assertAttribute('@more', 'aria-expanded', 'true')
->assertScript("(() => { const names = getComputedStyle(document.querySelector('[data-test=\"more\"]')).getPropertyValue('anchor-name'); return names.includes('--material-button-') && names.includes('--material-menu-'); })()")
->assertScript('(({ control, menu }) => menu.top >= control.bottom && menu.top - control.bottom <= 16 && Math.abs(menu.left - control.left) <= 16)('.menuAgainst('more', 'Share actions').')');
});