From 8640d815c8472c33478e8ef35ccd393fbf855c68 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 18:50:14 +0200 Subject: [PATCH] Keep open menus and rich tooltips open through a Livewire render , and gave their popover an id that is new with every render, and Livewire's morph matches an element without a wire:key by its id. So every render of the component around them swapped the popover for a closed copy: an open menu closed, its listeners stayed behind on the old element (Escape or a press outside then left aria-expanded="true" on the trigger and focus unreturned), a keep-open item's wire:click closed its menu when the response came, and a rich tooltip went on showing the detached bubble, so it never opened again. 's row had the same kind of id: after a render it scrolled without its listeners, and its items stopped re-masking. The popover, the bubble and the row now carry a wire:key, so the morph patches them in place and changes the id and anchor name together with the trigger's, as it already did for everything else. 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 have given every child component after the menu in a row the same key. A morph also removes the menu button's ARIA attributes, which only script writes. menu.js now writes them again after every morph, so the button of a menu that stays open, and a FAB menu's close look, still say it is open, and aria-controls names the popover's new id. A second press on an open menu's button opened it again, render or not: the popover closes on the press, and the guard against the click that follows was timed from the toggle event, which is queued and arrives after that click. It is timed from beforetoggle now. Browser tests with Livewire probes in Chromium, Firefox and WebKit, and a render test for the keys and the keys of the child components after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2 --- .../livewire-material-development/SKILL.md | 6 +- resources/js/menu.js | 43 +++- resources/views/components/carousel.blade.php | 5 +- resources/views/components/fab-menu.blade.php | 4 +- resources/views/components/menu.blade.php | 9 + .../views/components/rich-tooltip.blade.php | 6 + tests/Browser/ActionsTest.php | 204 ++++++++++++++++++ tests/Browser/CarouselTest.php | 19 +- tests/Browser/CommunicationTest.php | 82 +++++++ tests/Feature/Components/MorphKeysTest.php | 48 +++++ 10 files changed, 405 insertions(+), 21 deletions(-) create mode 100644 tests/Feature/Components/MorphKeysTest.php 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'], +]);