Found running the full browser suite for plan step 38's last batch, unrelated to its content changes (confirmed by reverting every touched file and reproducing both failures against the pre-batch commit): CarouselTest never pinned a window size, so the multi-browse row's keyline math only lined "scroll by 2 slots" up with the open keyline at some widths — the host's own default (here, wider than the carousel was ever exercised at) left a remainder large enough to fail four assertions. carouselShowcase() now resizes to 1280x900, matching the width other browser tests already treat as "large desktop". One test still failed after that: it put a `.click()` inside the script assertScript asserts, and assertScript retries its whole expression on a false result (this file's own comment says so) — a retry re-fires the click, drifting the carousel one item further each time and never recovering, unlike every other test here, which clicks through a separate call first. Split the click out the same way. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
350 lines
16 KiB
PHP
350 lines
16 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 style="padding: var(--md-sys-measurement-space200); width: 600px;">
|
|
<x-carousel label="Server" item-width="200">
|
|
@foreach (range(1, $count) as $number)
|
|
<x-carousel-item wire:key="item-{{ $number }}"><div style="width: 100%; height: 100%; background-color: var(--md-sys-color-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 multi-aspect, 4 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), `open(i)` (item i is at the largest size any item has here), `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.
|
|
*
|
|
* `open(i)` rather than a zero inset: M3's 16dp of content padding, the carousel's default since
|
|
* containment.md C-12, shrinks every keyline near the ends (Compose's
|
|
* createShiftedKeylineListForContentPadding), so a large item resting at the start or the end is
|
|
* masked by that share of the padding — 1.33px on each side in the showcase's first carousel.
|
|
*/
|
|
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-md-carousel-item]')]
|
|
const gap = parseFloat(getComputedStyle(scroller).columnGap)
|
|
const size = parseFloat(root.style.getPropertyValue('--material-carousel-slot'))
|
|
const surface = (i) => items[i].querySelector('[data-md-carousel-surface]')
|
|
const inset = (i) => parseFloat(surface(i).style.getPropertyValue('--material-carousel-inset'))
|
|
const open = (i) => inset(i) - Math.min(...items.map((_, j) => inset(j))) < 0.5
|
|
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_ITEM = '#carousel [data-md-carousel-item] >> nth=0';
|
|
|
|
function carouselShowcase(array $options = [])
|
|
{
|
|
// Pinned rather than left to the host's own window: at a wide enough viewport the multi-browse
|
|
// row's 8 items (220px each) leave a remainder the keyline math does not reduce to zero the way
|
|
// it does here, so "scroll by exactly 2 slots" no longer lands item 2 on the open keyline.
|
|
return visit('/material/carousel', $options)
|
|
->resize(1280, 900)
|
|
->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 && open(0) && ! open(items.length - 1)
|
|
&& 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();
|
|
|
|
// M3 puts the tab stop on the item, so the keys are the focused item's and each one moves
|
|
// focus to the item it scrolls to.
|
|
$page->keys(FIRST_ITEM, 'ArrowRight')
|
|
->assertScript(onCarousel(0, 'return at(1) && document.activeElement === items[1]'));
|
|
|
|
$page->keys(':focus', 'ArrowRight')
|
|
->assertScript(onCarousel(0, 'return at(2)'));
|
|
|
|
$page->keys(':focus', 'ArrowLeft')
|
|
->assertScript(onCarousel(0, 'return at(1)'));
|
|
|
|
$page->keys(':focus', 'End')
|
|
->assertScript(onCarousel(0, 'return Math.abs(scroller.scrollLeft - (scroller.scrollWidth - scroller.clientWidth)) < 1.5 && open(items.length - 1) && root.querySelector(\'[aria-label="Next"]\').disabled'));
|
|
|
|
$page->keys(':focus', '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, and leaves an open one where it is', function () {
|
|
$page = carouselShowcase()
|
|
->assertScript(onCarousel(0, 'return open(1) && ! open(4)'));
|
|
|
|
// Item 1 rests at the large size, short only of the content padding's share: focusing or
|
|
// pressing it moves nothing.
|
|
$page->script(onCarousel(0, 'items[1].focus(); items[1].querySelector(\'[data-md-carousel-content]\').click()'));
|
|
|
|
$page->assertScript(onCarousel(0, <<<'JS'
|
|
await pause(400)
|
|
return scroller.scrollLeft === 0 && open(1)
|
|
JS));
|
|
|
|
$page->script(onCarousel(0, 'items[4].querySelector(\'[data-md-carousel-content]\').click()'));
|
|
|
|
$page->assertScript(onCarousel(0, 'return open(4) && scroller.scrollLeft > 0'));
|
|
});
|
|
|
|
it('scrolls instantly and leaves every item unmasked under reduced motion', function () {
|
|
// A click, then a separate read-only assertScript: assertScript retries its whole expression
|
|
// on a false result (this file's own comment above), and a click inside it would fire again on
|
|
// every retry, drifting the carousel one further item each time and never recovering.
|
|
$page = carouselShowcase(['reducedMotion' => 'reduce']);
|
|
|
|
$page->script(onCarousel(0, 'root.querySelector(\'[aria-label="Next"]\').click()'));
|
|
|
|
$page->assertScript(onCarousel(0, <<<'JS'
|
|
await pause(50)
|
|
return at(1) && items.every((_, i) => inset(i) === 0)
|
|
&& items.every((item) => Math.abs(item.getBoundingClientRect().width - size) < 1.5)
|
|
JS));
|
|
});
|
|
|
|
it('brings an item cut off at the row\'s edge into view when it is pressed under reduced motion', function () {
|
|
// Owed by the Chromium baseline (step 32, docs/plans/material-3-browser-tests.md): reduced
|
|
// motion writes a zero inset for every item (C-05), so a mask cannot say an item is only partly
|
|
// shown; carousel.js reads the item's box against the row's instead, as for multi-aspect.
|
|
$page = carouselShowcase(['reducedMotion' => 'reduce'])
|
|
->assertScript(onCarousel(0, <<<'JS'
|
|
const row = scroller.getBoundingClientRect()
|
|
const item = items[4].getBoundingClientRect()
|
|
return scroller.scrollLeft === 0 && item.right > row.right + 1
|
|
JS));
|
|
|
|
$page->script(onCarousel(0, 'items[4].querySelector(\'[data-md-carousel-content]\').click()'));
|
|
|
|
$page->assertScript(onCarousel(0, <<<'JS'
|
|
await pause(100)
|
|
const row = scroller.getBoundingClientRect()
|
|
const item = items[4].getBoundingClientRect()
|
|
return Math.abs(scroller.scrollLeft) > 0 && item.left >= row.left - 1 && item.right <= row.right + 1
|
|
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 style="background-color: var(--md-sys-color-surface);">
|
|
<div style="padding: var(--md-sys-measurement-space200); width: 600px;">
|
|
<x-carousel label="Right to left" item-width="200" controls>
|
|
@foreach (range(1, 6) as $number)
|
|
<x-carousel-item><div style="width: 100%; height: 100%; background-color: var(--md-sys-color-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) && open(0) && ! open(items.length - 1)', 'body'));
|
|
|
|
$page->keys('[data-md-carousel-item] >> nth=0', 'ArrowLeft')
|
|
->assertScript(onCarousel(0, <<<'JS'
|
|
return scroller.scrollLeft < -1 && at(1) && ! open(0) && open(1)
|
|
&& 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('[data-md-carousel-item] >> nth=2', '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 style="background-color: var(--md-sys-color-surface);">
|
|
<livewire:carousel-morph-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
$masked = "(() => { const root = document.querySelector('[x-data=\"materialCarousel\"]'); const items = [...root.querySelectorAll('[data-md-carousel-item]')]; return root.style.getPropertyValue('--material-carousel-slot').endsWith('px') && items.every((item) => item.querySelector('[data-md-carousel-surface]').style.getPropertyValue('--material-carousel-inset').endsWith('px')) && items.length })()";
|
|
|
|
$page = visit('/carousel-morph-probe')->waitForEvent('networkidle')
|
|
->assertNoJavaScriptErrors()
|
|
->assertScript($masked, 6)
|
|
->assertAttribute('[data-md-carousel-item] >> nth=5', 'aria-label', '6 of 6');
|
|
|
|
$page->click('Add')
|
|
->assertScript($masked, 7)
|
|
->assertAttribute('[data-md-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'));
|
|
});
|
|
|
|
/**
|
|
* A script against the showcase's multi-aspect carousel, scoped by its own `data-md-carousel`
|
|
* value rather than a position in the page (fragile if the showcase ever reorders its examples).
|
|
* Nothing masks a multi-aspect item (no `--material-carousel-slot`, no keylines), so
|
|
* `onCarousel()`'s size-based helpers do not apply: this carousel's resting positions come
|
|
* straight off each item's own `getBoundingClientRect()`, as `carousel.js`'s `isMasked()` reads
|
|
* them for `state.measured`.
|
|
*/
|
|
function onMultiAspectCarousel(string $body): string
|
|
{
|
|
return <<<JS
|
|
(async () => {
|
|
const root = document.querySelector('#carousel [data-md-carousel="multi-aspect"]')
|
|
const scroller = root.querySelector('[role="region"]')
|
|
const items = [...scroller.querySelectorAll('[data-md-carousel-item]')]
|
|
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 MULTI_ASPECT_SCOPE = '#carousel [data-md-carousel="multi-aspect"]';
|
|
|
|
it('moves the multi-aspect carousel one item at a time with the buttons, arrows, Home and End', function () {
|
|
$page = carouselShowcase()
|
|
->assertNoJavaScriptErrors()
|
|
->assertScript(onMultiAspectCarousel('return scroller.scrollLeft === 0 && items.length > 2'));
|
|
|
|
$page->click(MULTI_ASPECT_SCOPE.' button[aria-label="Next"]')
|
|
->assertScript(onMultiAspectCarousel(<<<'JS'
|
|
await pause(400)
|
|
return scroller.scrollLeft > 0
|
|
JS));
|
|
|
|
$page->click(MULTI_ASPECT_SCOPE.' button[aria-label="Previous"]')
|
|
->assertScript(onMultiAspectCarousel(<<<'JS'
|
|
await pause(400)
|
|
return scroller.scrollLeft === 0
|
|
JS));
|
|
|
|
$page->keys(MULTI_ASPECT_SCOPE.' [data-md-carousel-item] >> nth=0', 'ArrowRight')
|
|
->assertScript(onMultiAspectCarousel(<<<'JS'
|
|
await pause(400)
|
|
return scroller.scrollLeft > 0 && document.activeElement === items[1]
|
|
JS));
|
|
|
|
$page->keys(':focus', 'ArrowLeft')
|
|
->assertScript(onMultiAspectCarousel(<<<'JS'
|
|
await pause(400)
|
|
return scroller.scrollLeft === 0 && document.activeElement === items[0]
|
|
JS));
|
|
|
|
$page->keys(':focus', 'End')
|
|
->assertScript(onMultiAspectCarousel(<<<'JS'
|
|
await pause(400)
|
|
return document.activeElement === items[items.length - 1]
|
|
&& Math.abs(scroller.scrollLeft - (scroller.scrollWidth - scroller.clientWidth)) < 1.5
|
|
JS));
|
|
|
|
$page->keys(':focus', 'Home')
|
|
->assertScript(onMultiAspectCarousel(<<<'JS'
|
|
await pause(400)
|
|
return document.activeElement === items[0] && scroller.scrollLeft === 0
|
|
JS));
|
|
});
|