Files
livewire-material/tests/Browser/CarouselTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 8640d815c8 Keep open menus and rich tooltips open through a Livewire render
<x-menu>, <x-fab-menu> and <x-rich-tooltip> 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. <x-carousel>'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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:50:14 +02:00

238 lines
10 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* A Livewire component whose items only the server changes, to watch a carousel through a morph.
*/
class CarouselMorphProbe extends Component
{
public int $count = 6;
public function add(): void
{
$this->count++;
}
public function render(): string
{
return <<<'BLADE'
<div class="p-4" style="width: 600px">
<x-carousel label="Server" item-width="200">
@foreach (range(1, $count) as $number)
<x-carousel-item wire:key="item-{{ $number }}"><div class="size-full bg-primary-container"></div></x-carousel-item>
@endforeach
</x-carousel>
<button type="button" wire:click="add">Add</button>
</div>
BLADE;
}
}
/**
* A script run against one carousel of the showcase (0 multi-browse, 1 hero, 2 uncontained,
* 3 full-screen), or of another page under `scope`, scrolled into view. `root`, `scroller` and
* `items` are in scope, with `surface(i)`, `inset(i)` (the mask on each side of item i),
* `snap(i)` (the scroll offset that brings item i into focus) and `at(i)`; the script's value is
* the result. Pest retries a failing assertion and gives each attempt a second: keep scripts well
* inside it.
*/
function onCarousel(int $index, string $body, string $scope = '#carousel'): string
{
return <<<JS
(async () => {
const root = document.querySelectorAll('{$scope} [x-data="materialCarousel"]')[{$index}]
const scroller = root.querySelector('[role="region"]')
const items = [...scroller.querySelectorAll('[data-material-carousel-item]')]
const gap = parseFloat(getComputedStyle(scroller).columnGap)
const size = parseFloat(root.style.getPropertyValue('--material-carousel-slot'))
const surface = (i) => items[i].querySelector('[data-material-carousel-surface]')
const inset = (i) => parseFloat(surface(i).style.getPropertyValue('--material-carousel-inset'))
const snap = (i) => Math.min(Math.max(i * (size + gap) - parseFloat(items[i].style.scrollMarginInlineStart), 0), scroller.scrollWidth - scroller.clientWidth)
const at = (i) => Math.abs(Math.abs(scroller.scrollLeft) - snap(i)) < 1.5
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
if (root.getBoundingClientRect().top < 0 || root.getBoundingClientRect().bottom > innerHeight) root.scrollIntoView({ block: 'center' })
{$body}
})()
JS;
}
const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0';
function carouselShowcase(array $options = [])
{
return visit('/material/carousel', $options)
->waitForEvent('networkidle')
->assertScript("typeof window.Alpine !== 'undefined'");
}
it('masks items by their place between the keylines, and changes the large one as it scrolls', function () {
$page = carouselShowcase()
->assertNoJavaScriptErrors()
->assertScript(onCarousel(0, <<<'JS'
return size > 0 && inset(0) < 0.5 && inset(items.length - 1) > 0.5
&& getComputedStyle(surface(0)).clipPath.startsWith('inset(')
JS));
$page->script(onCarousel(0, "scroller.style.scrollSnapType = 'none'; scroller.scrollTo({ left: 2 * (size + gap), behavior: 'instant' })"));
$page->assertScript(onCarousel(0, <<<'JS'
await pause(50)
return inset(0) > 0.5 && inset(2) < 0.5 && surface(0).style.getPropertyValue('--material-carousel-shift') !== '0.00px'
JS));
// Half-way between keylines, an item is part-way between two sizes.
$page->script(onCarousel(0, "scroller.scrollTo({ left: 2.5 * (size + gap), behavior: 'instant' })"));
$page->assertScript(onCarousel(0, <<<'JS'
await pause(50)
return inset(2) > 0.5 && inset(2) < size / 2 - 1
JS));
});
it('moves one item with the next and previous buttons', function () {
$page = carouselShowcase()
->assertScript(onCarousel(0, 'return at(0) && root.querySelector(\'[aria-label="Previous"]\').disabled'));
$page->click('#carousel button[aria-label="Next"] >> nth=0')
->assertScript(onCarousel(0, 'return at(1) && inset(1) < 0.5 && !root.querySelector(\'[aria-label="Previous"]\').disabled'));
$page->click('#carousel button[aria-label="Next"] >> nth=0')
->assertScript(onCarousel(0, 'return at(2)'));
$page->click('#carousel button[aria-label="Previous"] >> nth=0')
->assertScript(onCarousel(0, 'return at(1)'));
});
it('moves one item with the arrow keys, and to the ends with Home and End', function () {
$page = carouselShowcase();
$page->keys(FIRST_SCROLLER, 'ArrowRight')
->assertScript(onCarousel(0, 'return at(1)'));
$page->keys(FIRST_SCROLLER, 'ArrowRight')
->assertScript(onCarousel(0, 'return at(2)'));
$page->keys(FIRST_SCROLLER, 'ArrowLeft')
->assertScript(onCarousel(0, 'return at(1)'));
$page->keys(FIRST_SCROLLER, 'End')
->assertScript(onCarousel(0, 'return Math.abs(scroller.scrollLeft - (scroller.scrollWidth - scroller.clientWidth)) < 1.5 && inset(items.length - 1) < 0.5 && root.querySelector(\'[aria-label="Next"]\').disabled'));
$page->keys(FIRST_SCROLLER, 'Home')
->assertScript(onCarousel(0, 'return at(0)'));
});
it('snaps a scroll that stops between items onto an item', function () {
$page = carouselShowcase();
$page->script(onCarousel(0, "scroller.scrollTo({ left: 0.7 * (size + gap), behavior: 'instant' })"));
$page->assertScript(onCarousel(0, <<<'JS'
await pause(100)
return items.some((_, i) => at(i)) && scroller.scrollLeft > 0
JS));
});
it('brings a partly hidden item into focus when it is pressed', function () {
$page = carouselShowcase()
->assertScript(onCarousel(0, 'return inset(4) > 0.5'));
$page->script(onCarousel(0, 'items[4].querySelector(\'[data-material-carousel-content]\').click()'));
$page->assertScript(onCarousel(0, 'return inset(4) < 0.5 && scroller.scrollLeft > 0'));
});
it('scrolls instantly and keeps content pinned to its mask under reduced motion', function () {
carouselShowcase(['reducedMotion' => 'reduce'])
->assertScript(onCarousel(0, <<<'JS'
root.querySelector('[aria-label="Next"]').click()
const arrived = at(1)
await pause(50)
const last = items.length - 1
return arrived && inset(last) > 0.5
&& surface(last).style.getPropertyValue('--material-carousel-pin') === surface(last).style.getPropertyValue('--material-carousel-inset')
JS));
});
it('mirrors in a right-to-left page', function () {
Route::middleware('web')->get('/carousel-rtl-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html dir="rtl">
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<div class="p-4" style="width: 600px">
<x-carousel label="Right to left" item-width="200" controls>
@foreach (range(1, 6) as $number)
<x-carousel-item><div class="size-full bg-primary-container"></div></x-carousel-item>
@endforeach
</x-carousel>
</div>
@livewireScripts
</body>
</html>
BLADE));
$page = visit('/carousel-rtl-probe')->waitForEvent('networkidle')
->assertNoJavaScriptErrors()
->assertScript(onCarousel(0, 'return size > 0 && at(0) && inset(0) < 0.5 && inset(items.length - 1) > 0.5', 'body'));
$page->keys('[role="region"]', 'ArrowLeft')
->assertScript(onCarousel(0, <<<'JS'
return scroller.scrollLeft < -1 && at(1) && inset(0) > 0.5 && inset(1) < 0.5
&& parseFloat(surface(0).style.getPropertyValue('--material-carousel-shift')) < 0
JS, 'body'));
$page->click('button[aria-label="Next"]')
->assertScript(onCarousel(0, 'return at(2)', 'body'));
$page->keys('[role="region"]', 'ArrowRight')
->assertScript(onCarousel(0, 'return at(1)', 'body'));
});
it('measures itself again after a Livewire morph adds an item', function () {
Livewire::component('carousel-morph-probe', CarouselMorphProbe::class);
Route::middleware('web')->get('/carousel-morph-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:carousel-morph-probe />
@livewireScripts
</body>
</html>
BLADE));
$masked = "(() => { const root = document.querySelector('[x-data=\"materialCarousel\"]'); const items = [...root.querySelectorAll('[data-material-carousel-item]')]; return root.style.getPropertyValue('--material-carousel-slot').endsWith('px') && items.every((item) => item.querySelector('[data-material-carousel-surface]').style.getPropertyValue('--material-carousel-inset').endsWith('px')) && items.length })()";
$page = visit('/carousel-morph-probe')->waitForEvent('networkidle')
->assertNoJavaScriptErrors()
->assertScript($masked, 6)
->assertAttribute('[data-material-carousel-item] >> nth=5', 'aria-label', '6 of 6');
$page->click('Add')
->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'));
});