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:
co-authored by
Claude Opus 5
parent
fb29169d44
commit
a83d7f62ea
@@ -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>`
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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').')');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user