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
@@ -4,6 +4,13 @@
|
||||
* 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,
|
||||
* 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"]'
|
||||
|
||||
@@ -14,6 +21,7 @@ const REOPEN_GUARD_MS = 250
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialMenu', () => ({
|
||||
closedAt: -Infinity,
|
||||
anchored: null,
|
||||
returnFocus: true,
|
||||
focusWasInside: false,
|
||||
listeners: [],
|
||||
@@ -22,6 +30,14 @@ document.addEventListener('alpine:init', () => {
|
||||
const menu = this.$refs.menu
|
||||
|
||||
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()
|
||||
// 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]')
|
||||
},
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const control = this.control()
|
||||
|
||||
@@ -82,6 +131,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
open(focus = 'first') {
|
||||
this.label()
|
||||
this.anchor()
|
||||
|
||||
if (!this.isOpen()) {
|
||||
this.$refs.menu.showPopover()
|
||||
|
||||
Reference in New Issue
Block a user