<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
238 lines
10 KiB
PHP
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'));
|
|
});
|