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)); });