diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 79ca943f..7e234121 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -244,7 +244,7 @@ M3's plain tooltip, standalone around any trigger: ` ``` -``: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. ``: `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. +``: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `` 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`. ``: `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. ### `` diff --git a/resources/js/menu.js b/resources/js/menu.js index 345a990d..c65d8747 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -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() diff --git a/resources/views/components/menu.blade.php b/resources/views/components/menu.blade.php index a119fea9..627dcd16 100644 --- a/resources/views/components/menu.blade.php +++ b/resources/views/components/menu.blade.php @@ -13,10 +13,16 @@ 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 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 - button: Enter, Space or ArrowDown open on the first item, ArrowUp on the 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. + when there is no room — to the other side, the other end, or both, so a menu on a FAB in a + corner of the window opens back across it; a click outside or Escape closes it. The keyboard + is WAI-ARIA's menu button: Enter, Space or ArrowDown open on the first item, ArrowUp on the + 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` (`` 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 2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from @@ -53,7 +59,7 @@ x-on:click="activate($event)" @class([ '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', 'bg-surface-container-low text-on-surface' => ! $vibrant, 'bg-tertiary-container text-on-tertiary-container' => $vibrant, diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index 03c9ec7a..05791424 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -1,7 +1,49 @@ renders++; + } + + public function render(): string + { + return <<<'BLADE' +
+

renders: {{ $renders }}

+ + + + + + + + + + + + + + + + + + +
+ BLADE; + } +} + function showcase(string $section = 'buttons') { return visit("/material/{$section}")->waitForEvent('networkidle') @@ -124,3 +166,58 @@ it('animates the loading indicator in the browser', function () { 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' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + 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').')'); +});