diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 3112edb1..54cccead 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, 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-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `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-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab. ### `` @@ -289,7 +289,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d ``` -Two to six items open above the FAB, which turns into a close button. ``: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as ``. +Two to six items open above the FAB, which turns into a close button. ``: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as ``. ### `` @@ -382,7 +382,7 @@ A few lines of context around a trigger, with an optional `title` and `actions` ``` -Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`. +Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`. ### `` diff --git a/resources/js/menu.js b/resources/js/menu.js index c65d8747..1140c641 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -3,7 +3,8 @@ * * 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. + * so they are written again whenever the trigger is used and after every morph — an open menu + * lives through one (the popover is keyed), and its button must still say so. * * 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 @@ -15,7 +16,9 @@ const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]' // A popover="auto" closes on the press that lands on its trigger, and the click that follows -// would open it again. A close this recent is taken as that press. +// would open it again. A close this recent is taken as that press. It is timed from +// `beforetoggle`, which fires as the popover closes: `toggle` is queued, and arrives after that +// click. const REOPEN_GUARD_MS = 250 document.addEventListener('alpine:init', () => { @@ -33,10 +36,21 @@ document.addEventListener('alpine:init', () => { 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()) + // it, takes the button's ARIA attributes away and gives the popover a new id; the + // observer runs before the next frame is drawn, so an open menu never moves and its + // button never shows it shut. + const observer = new MutationObserver(() => { + this.anchor() + this.label() + }) - observer.observe(this.$refs.trigger, { attributes: true, attributeFilter: ['style'], childList: true, subtree: true }) + observer.observe(this.$refs.trigger, { + attributes: true, + attributeFilter: ['style', 'aria-haspopup', 'aria-controls', 'aria-expanded'], + childList: true, + subtree: true, + }) + observer.observe(menu, { attributes: true, attributeFilter: ['id'] }) this.listeners.push(() => observer.disconnect()) // Only closes the browser starts — Escape, a press outside — arrive here alone; open() @@ -48,6 +62,10 @@ document.addEventListener('alpine:init', () => { // that was a focusable region around the trigger). this.listen(menu, 'beforetoggle', (event) => { this.focusWasInside = event.newState === 'closed' && menu.contains(document.activeElement) + + if (event.newState === 'closed') { + this.closedAt = performance.now() + } }) this.listen(menu, 'toggle', (event) => { @@ -59,8 +77,6 @@ document.addEventListener('alpine:init', () => { return } - this.closedAt = performance.now() - if (this.returnFocus && (this.focusWasInside || menu.contains(document.activeElement))) { this.control()?.focus() } @@ -83,7 +99,7 @@ document.addEventListener('alpine:init', () => { /** * 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. + * it is read there. */ anchor() { const trigger = this.$refs.trigger @@ -113,6 +129,7 @@ document.addEventListener('alpine:init', () => { } }, + /** Writes only what differs: the observer that calls this watches these same attributes. */ label() { const control = this.control() @@ -120,9 +137,13 @@ document.addEventListener('alpine:init', () => { return } - control.setAttribute('aria-haspopup', 'menu') - control.setAttribute('aria-controls', this.$refs.menu.id) - control.setAttribute('aria-expanded', String(this.isOpen())) + const attributes = { 'aria-haspopup': 'menu', 'aria-controls': this.$refs.menu.id, 'aria-expanded': String(this.isOpen()) } + + for (const [name, value] of Object.entries(attributes)) { + if (control.getAttribute(name) !== value) { + control.setAttribute(name, value) + } + } }, isOpen() { diff --git a/resources/views/components/carousel.blade.php b/resources/views/components/carousel.blade.php index 0fea12fb..ef2af072 100644 --- a/resources/views/components/carousel.blade.php +++ b/resources/views/components/carousel.blade.php @@ -44,7 +44,9 @@ longer slides inside its mask. RTL mirrors the keylines, keys and buttons. Re-measures itself when resized, when a Livewire morph resets its styles and when items - come and go. --}} + come and go. The row's id, which the buttons control, is new with every render; the row + carries a `wire:key` (see ``), so a morph patches it in place — its scroll position + and listeners kept — rather than swapping in a copy. --}} @props([ 'layout' => 'multi-browse', @@ -116,6 +118,7 @@
'material-carousel']) }} id="{{ $scrollerId }}" role="region" aria-roledescription="{{ __('carousel') }}" diff --git a/resources/views/components/fab-menu.blade.php b/resources/views/components/fab-menu.blade.php index 2461116e..d80f5ee3 100644 --- a/resources/views/components/fab-menu.blade.php +++ b/resources/views/components/fab-menu.blade.php @@ -10,7 +10,8 @@ Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a round close button in the colour itself while the list is open above it, end-aligned; the list is a `popover="auto"` menu with the menu keyboard of ``. `label` names the FAB - for screen readers. Give the items the same `color`. + for screen readers. Give the items the same `color`. Like ``'s, the list is keyed for + Livewire, so it stays open through a render of the component around it. FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px above the close button. --}} @@ -56,6 +57,7 @@
'material-fab-menu']) }} id="material-fab-menu-{{ $key }}" popover="auto" role="menu" diff --git a/resources/views/components/menu.blade.php b/resources/views/components/menu.blade.php index 627dcd16..a7ed03c6 100644 --- a/resources/views/components/menu.blade.php +++ b/resources/views/components/menu.blade.php @@ -24,6 +24,14 @@ 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 id and the anchor name are new with every render. The popover carries a `wire:key`, which + a Livewire morph matches it by before the id, so a render of the component around an open + menu patches it in place — still open, focus and listeners kept — instead of swapping in a + closed copy; menu.js then writes the menu button's ARIA attributes again. The key goes + through an attribute bag: Livewire compiles a `wire:key` written in a template into the key + of the loop iteration around it, which would give every child component after the menu the + same key. + The container is Expressive's standard menu (surface-container-low, 16px corner, elevation 2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from androidx Compose Material 3 (Apache-2.0). --}} @@ -49,6 +57,7 @@
'material-menu']) }} id="material-menu-{{ $key }}" popover="auto" role="menu" diff --git a/resources/views/components/rich-tooltip.blade.php b/resources/views/components/rich-tooltip.blade.php index 3e6267e2..d52c5ce5 100644 --- a/resources/views/components/rich-tooltip.blade.php +++ b/resources/views/components/rich-tooltip.blade.php @@ -10,6 +10,11 @@ form M3 asks for when it has actions. The bubble is a popover in surface-container with a medium corner and elevation 2, 312px at most, placed by anchor positioning on `side`. + The bubble's id and anchor name are new with every render; its `wire:key` (see ``) + lets a Livewire morph patch it in place, so an open bubble stays open — through its own + action's `wire:click` too — and resources/js/rich-tooltip.js keeps showing and hiding the + element on the page rather than one the morph took away. + RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium text in on-surface-variant, label-large actions in primary. --}} @@ -35,6 +40,7 @@ 'material-rich-tooltip']) }} id="material-rich-tooltip-{{ $key }}" popover="{{ $persistent ? 'auto' : 'manual' }}" role="{{ $persistent ? 'dialog' : 'tooltip' }}" diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index c5c577f7..f8c073c6 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -44,6 +44,88 @@ class MenuAnchorProbe extends Component } } +/** + * A Livewire component that renders again while its menus are open: `touch` from outside, a + * `keep-open` item's action, or a FAB menu item's action. + */ +class MenuMorphProbe extends Component +{ + public int $renders = 0; + + public string $sort = 'newest'; + + public string $created = ''; + + public function touch(): void + { + $this->renders++; + } + + public function sortBy(string $sort): void + { + $this->sort = $sort; + } + + public function create(string $kind): void + { + $this->created = $kind; + } + + public function render(): string + { + return <<<'BLADE' +
+

renders: {{ $renders }}, created: {{ $created }}

+ + + + + + + + + + +
+ + + + +
+
+ BLADE; + } +} + +const SORT_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"Sort\"]')"; + +const FAB_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"New\"]')"; + +const NEW_FAB = 'button[aria-label="New"]'; + +function menuMorphProbe() +{ + Livewire::component('menu-morph-probe', MenuMorphProbe::class); + + Route::middleware('web')->get('/menu-morph-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + return visit('/menu-morph-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + function showcase(string $section = 'buttons') { return visit("/material/{$section}")->waitForEvent('networkidle') @@ -251,3 +333,125 @@ it('paints a group\'s hint and a menu item\'s icon in the colour their classes n ->assertScript($ink("document.querySelector('#chosen svg')")." === {$error}") ->assertScript($ink("document.querySelector('#off svg')")." !== {$error}"); }); + +it('keeps a menu open while the component around it renders, and closes it cleanly after', function () { + $page = menuMorphProbe()->assertNoJavaScriptErrors(); + + $page->click('@sort') + ->assertAttribute('@sort', 'aria-expanded', 'true'); + + $page->script('window.eval("Livewire.first().touch()")'); + + $page->assertSeeIn('#renders', '1') + ->assertScript(SORT_MENU.".matches(':popover-open')") + ->assertAttribute('@sort', 'aria-expanded', 'true') + ->assertScript("document.querySelector('[data-test=\"sort\"]').getAttribute('aria-controls') === ".SORT_MENU.'.id') + ->assertScript(focused("textContent.trim().startsWith('Newest')")); + + $page->keys(':focus', 'Escape') + ->assertAttribute('@sort', 'aria-expanded', 'false') + ->assertScript('! '.SORT_MENU.".matches(':popover-open')") + ->assertScript(focused("dataset.test === 'sort'")); + + // Opened from the keyboard: a press this soon after the menu closed is taken for the + // light-dismiss press and ignored. + $page->keys('@sort', 'ArrowDown') + ->assertAttribute('@sort', 'aria-expanded', 'true'); + + $page->script('window.eval("Livewire.first().touch()")'); + + $page->assertSeeIn('#renders', '2') + ->click('#outside') + ->assertAttribute('@sort', 'aria-expanded', 'false') + ->assertScript('! '.SORT_MENU.".matches(':popover-open')"); +}); + +it('closes a menu on a second press of its menu button, and after the component renders', function () { + $page = menuMorphProbe(); + + // The press closes the menu before its click reaches the button: the guard against that click + // opening it again once waited for the queued toggle event, which comes after the click. + $page->click('@sort') + ->assertAttribute('@sort', 'aria-expanded', 'true') + ->click('@sort') + ->assertAttribute('@sort', 'aria-expanded', 'false') + ->assertScript('! '.SORT_MENU.".matches(':popover-open')"); + + $page->keys('@sort', 'ArrowDown') + ->assertAttribute('@sort', 'aria-expanded', 'true'); + + $page->script('window.eval("Livewire.first().touch()")'); + + // Past the reopen guard of the close above, so the second press is on its own. + $page->assertSeeIn('#renders', '1') + ->wait(0.3); + + $page->click('@sort') + ->assertAttribute('@sort', 'aria-expanded', 'false') + ->assertScript('! '.SORT_MENU.".matches(':popover-open')"); +}); + +it('keeps a menu open while a keep-open item\'s action runs', function () { + $page = menuMorphProbe(); + + $page->click('@sort') + ->click('[role="menuitemcheckbox"]:has-text("Largest")') + ->assertAttribute('[role="menuitemcheckbox"]:has-text("Largest")', 'aria-checked', 'true') + ->assertScript(SORT_MENU.".matches(':popover-open')") + ->assertAttribute('@sort', 'aria-expanded', 'true'); + + $page->click('[role="menuitemcheckbox"]:has-text("Newest")') + ->assertAttribute('[role="menuitemcheckbox"]:has-text("Newest")', 'aria-checked', 'true') + ->assertScript(SORT_MENU.".matches(':popover-open')"); +}); + +it('keeps a FAB menu open while the component around it renders, and closes it cleanly after', function () { + $page = menuMorphProbe(); + + $page->click(NEW_FAB) + ->assertAttribute(NEW_FAB, 'aria-expanded', 'true'); + + $page->script('window.eval("Livewire.first().touch()")'); + + $page->assertSeeIn('#renders', '1') + ->assertScript(FAB_MENU.".matches(':popover-open')") + ->assertAttribute(NEW_FAB, 'aria-expanded', 'true') + ->assertScript("document.querySelector('".NEW_FAB."').getAttribute('aria-controls') === ".FAB_MENU.'.id'); + + $page->keys(':focus', 'Escape') + ->assertAttribute(NEW_FAB, 'aria-expanded', 'false') + ->assertScript(focused("getAttribute('aria-label') === 'New'")); + + $page->keys(NEW_FAB, 'ArrowDown') + ->assertAttribute(NEW_FAB, 'aria-expanded', 'true'); + + $page->script('window.eval("Livewire.first().touch()")'); + + // Past the reopen guard of the Escape above, so the second press is on its own. + $page->assertSeeIn('#renders', '2') + ->wait(0.3); + + $page->click(NEW_FAB) + ->assertAttribute(NEW_FAB, 'aria-expanded', 'false') + ->assertScript('! '.FAB_MENU.".matches(':popover-open')"); +}); + +it('closes a FAB menu when an item\'s action runs, and opens and closes it cleanly after', function () { + $page = menuMorphProbe(); + + $page->click(NEW_FAB) + ->click('[role="menuitem"]:has-text("Upload files")') + ->assertSeeIn('#created', 'files') + ->assertAttribute(NEW_FAB, 'aria-expanded', 'false'); + + $page->script("document.querySelector('".NEW_FAB."').focus()"); + + $page->keys(NEW_FAB, 'ArrowDown') + ->assertAttribute(NEW_FAB, 'aria-expanded', 'true') + ->assertScript(focused("textContent.trim() === 'Upload files'")); + + $page->keys(':focus', 'Escape') + ->assertAttribute(NEW_FAB, 'aria-expanded', 'false') + ->assertScript('! '.FAB_MENU.".matches(':popover-open')") + ->assertScript(focused("getAttribute('aria-label') === 'New'")); +}); diff --git a/tests/Browser/CarouselTest.php b/tests/Browser/CarouselTest.php index 90cef8a7..06d0c3d7 100644 --- a/tests/Browser/CarouselTest.php +++ b/tests/Browser/CarouselTest.php @@ -10,7 +10,7 @@ use Livewire\Livewire; */ class CarouselMorphProbe extends Component { - public int $count = 3; + public int $count = 6; public function add(): void { @@ -219,10 +219,19 @@ it('measures itself again after a Livewire morph adds an item', function () { $page = visit('/carousel-morph-probe')->waitForEvent('networkidle') ->assertNoJavaScriptErrors() - ->assertScript($masked, 3) - ->assertAttribute('[data-material-carousel-item] >> nth=2', 'aria-label', '3 of 3'); + ->assertScript($masked, 6) + ->assertAttribute('[data-material-carousel-item] >> nth=5', 'aria-label', '6 of 6'); $page->click('Add') - ->assertScript($masked, 4) - ->assertAttribute('[data-material-carousel-item] >> nth=3', 'aria-label', '4 of 4'); + ->assertScript($masked, 7) + ->assertAttribute('[data-material-carousel-item] >> nth=6', 'aria-label', '7 of 7'); + + // Scrolled once the morph has settled, so only the row's own scroll listener can re-mask the + // items: a row the morph swapped for a copy scrolls with its items' masks left as they were. + $page->script(onCarousel(0, "await pause(300); scroller.style.scrollSnapType = 'none'; scroller.scrollTo({ left: 2 * (size + gap), behavior: 'instant' })", 'body')); + + $page->assertScript(onCarousel(0, <<<'JS' + await pause(50) + return inset(0) > 0.5 && inset(2) < 0.5 + JS, 'body')); }); diff --git a/tests/Browser/CommunicationTest.php b/tests/Browser/CommunicationTest.php index 4e24b3d8..8026751e 100644 --- a/tests/Browser/CommunicationTest.php +++ b/tests/Browser/CommunicationTest.php @@ -1,5 +1,44 @@ renders++; + } + + public function render(): string + { + return <<<'BLADE' +
+

renders: {{ $renders }}

+ + + + + + +
+ + + +
+
+ BLADE; + } +} + const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')"; const TOAST = "document.querySelector('[data-toast]')"; @@ -108,3 +147,46 @@ it('opens a persistent rich tooltip on press', function () { ->click('#communication button:has-text("Press for details")') ->assertScript("{$bubble}.matches(':popover-open')"); }); + +it('keeps a rich tooltip open while its action renders the component, and opens it again after', function () { + Livewire::component('rich-tooltip-morph-probe', RichTooltipMorphProbe::class); + + Route::middleware('web')->get('/rich-tooltip-morph-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + $persistent = "document.querySelector('[role=\"dialog\"][popover]')"; + $transient = "document.querySelector('[role=\"tooltip\"][popover]')"; + + $page = visit('/rich-tooltip-morph-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'") + ->assertNoJavaScriptErrors(); + + $page->click('@details') + ->assertScript("{$persistent}.matches(':popover-open')") + ->click('@refresh') + ->assertSeeIn('#renders', '1') + ->assertScript("{$persistent}.matches(':popover-open')"); + + $page->click('#outside') + ->assertScript("! {$persistent}.matches(':popover-open')"); + + $page->click('@details') + ->assertScript("{$persistent}.matches(':popover-open')"); + + $page->click('#outside') + ->hover('@hint') + ->assertScript("{$transient}.matches(':popover-open')") + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/Components/MorphKeysTest.php b/tests/Feature/Components/MorphKeysTest.php new file mode 100644 index 00000000..60077301 --- /dev/null +++ b/tests/Feature/Components/MorphKeysTest.php @@ -0,0 +1,48 @@ +Row

'; + } +} + +/** + * A list whose rows each hold `$markup`, then a child component. Livewire keys a child from the + * loop around it, and takes a `wire:key` written in any template rendered in the row for the row's + * key: a component that wrote its own that way would key every row's child alike. + */ +class KeyedRowsProbe extends Component +{ + public static string $markup = ''; + + public function render(): string + { + return '
@foreach ([1, 2] as $row)
'.static::$markup.'
@endforeach
'; + } +} + +it('keys what a morph must patch in place, without keying the child components after it', function (string $markup, string $key) { + Livewire::component('keyed-row-child-probe', KeyedRowChildProbe::class); + Livewire::component('keyed-rows-probe', KeyedRowsProbe::class); + + KeyedRowsProbe::$markup = $markup; + + $html = Livewire::test('keyed-rows-probe')->html(); + + preg_match_all('/]*\bwire:key="([^"]+)"[^>]*>Row<\/p>/', $html, $children); + + expect(substr_count($html, "wire:key=\"{$key}\""))->toBe(2) + ->and($children[1])->toHaveCount(2) + ->and(array_unique($children[1]))->toHaveCount(2); +})->with([ + 'menu' => ['', 'material-menu'], + 'FAB menu' => ['', 'material-fab-menu'], + 'rich tooltip' => ['', 'material-rich-tooltip'], + 'carousel' => ['
', 'material-carousel'], +]);