count++;
}
public function render(): string
{
return <<<'BLADE'
@foreach (range(1, $count) as $number)
@endforeach
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 << {
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 = [])
{
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 && 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 () {
carouselShowcase(['reducedMotion' => 'reduce'])
->assertScript(onCarousel(0, <<<'JS'
root.querySelector('[aria-label="Next"]').click()
const arrived = at(1)
await pause(50)
return arrived && 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'
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
@foreach (range(1, 6) as $number)
@endforeach
@livewireScripts
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'
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
@livewireScripts
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 << {
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));
});