tests / feature (8.4) (push) Successful in 1m50s
tests / feature (8.5) (push) Successful in 1m54s
tests / browser (chrome, chromium) (push) Successful in 7m52s
tests / browser (firefox, firefox) (push) Successful in 11m56s
tests / browser (safari, webkit) (push) Successful in 12m35s
The browser plugin runs every call on a page again when its first attempt takes over a second, and on the runner a press can. For most presses that costs nothing. For two kinds it breaks the test: a press that opens a dialog, a sheet, a full-screen view or a modal rail over its own trigger, whose second press can never land, and a press that changes state a second one would change again — a menu trigger, a toggle, a chip, a range picker's day, a paging key, a Save. The bottom sheet with preset heights timed out on WebKit that way after the date picker had on Firefox. Every such press in the browser suite now goes through pressOnce(), 253 of them across sixteen files, not only the ones the runner happened to catch; focus moves inside an open view, Escape, links and plain "set" actions keep the retry, which is harmless for them. Two samples that were still racing the machine: - The standard side sheet's exit is caught half-way with its motion stretched, as every other mid-exit sample is, and fullSpeed() takes the stretch off again before the test times a reopen against the real exit. Under load on Linux WebKit it had failed two runs in five; it passes ten in ten. - The switch-and-checkbox row measures its widths once, so it now waits for the resize to land and the brand face to load before it does. Browser 300 passed on Firefox and WebKitGTK in a Linux container held to two busy cores, and on Chrome, Firefox and WebKit on macOS. Feature 1159 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
375 lines
17 KiB
PHP
375 lines
17 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,
|
|
* 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'));
|
|
|
|
// A retried press drifts the carousel one further item each time: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#carousel button[aria-label="Next"] >> nth=0');
|
|
|
|
$page->assertScript(onCarousel(0, 'return at(1) && inset(1) < 0.5 && !root.querySelector(\'[aria-label="Previous"]\').disabled'));
|
|
|
|
pressOnce($page)->click('#carousel button[aria-label="Next"] >> nth=0');
|
|
|
|
$page->assertScript(onCarousel(0, 'return at(2)'));
|
|
|
|
pressOnce($page)->click('#carousel button[aria-label="Previous"] >> nth=0');
|
|
|
|
$page->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. A retried press drifts the carousel one further item each
|
|
// time: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->keys(FIRST_ITEM, 'ArrowRight');
|
|
|
|
$page->assertScript(onCarousel(0, 'return at(1) && document.activeElement === items[1]'));
|
|
|
|
pressOnce($page)->keys(':focus', 'ArrowRight');
|
|
|
|
$page->assertScript(onCarousel(0, 'return at(2)'));
|
|
|
|
pressOnce($page)->keys(':focus', 'ArrowLeft');
|
|
|
|
$page->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. Whether
|
|
// the row had arrived is read in the same task as the click, against the slot size measured
|
|
// before it, and kept, so a retry cannot turn a smooth scroll that finishes later into a pass:
|
|
// under reduced motion it must be instant. (The row re-measures its slot a frame later, so the
|
|
// assertion below reads a fresh size rather than this one.)
|
|
$page = carouselShowcase(['reducedMotion' => 'reduce']);
|
|
|
|
$page->script(onCarousel(0, 'root.querySelector(\'[aria-label="Next"]\').click(); window.__arrivedAtOnce = at(1)'));
|
|
|
|
$page->assertScript(onCarousel(0, <<<'JS'
|
|
await pause(50)
|
|
return window.__arrivedAtOnce === true && 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 () {
|
|
// Under reduced motion, the row writes a zero inset for every item, 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>
|
|
<link rel="icon" href="data:,">
|
|
<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'));
|
|
|
|
// A retried press drifts the carousel one further item each time: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->keys('[data-md-carousel-item] >> nth=0', 'ArrowLeft');
|
|
|
|
$page->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'));
|
|
|
|
pressOnce($page)->click('button[aria-label="Next"]');
|
|
|
|
$page->assertScript(onCarousel(0, 'return at(2)', 'body'));
|
|
|
|
pressOnce($page)->keys('[data-md-carousel-item] >> nth=2', 'ArrowRight');
|
|
|
|
$page->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>
|
|
<link rel="icon" href="data:,">
|
|
<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');
|
|
|
|
// A counter a retry would double: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('Add');
|
|
|
|
$page->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'));
|
|
|
|
// A retried press drifts the carousel one further item each time: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click(MULTI_ASPECT_SCOPE.' button[aria-label="Next"]');
|
|
|
|
$page->assertScript(onMultiAspectCarousel(<<<'JS'
|
|
await pause(400)
|
|
return scroller.scrollLeft > 0
|
|
JS));
|
|
|
|
pressOnce($page)->click(MULTI_ASPECT_SCOPE.' button[aria-label="Previous"]');
|
|
|
|
$page->assertScript(onMultiAspectCarousel(<<<'JS'
|
|
await pause(400)
|
|
return scroller.scrollLeft === 0
|
|
JS));
|
|
|
|
pressOnce($page)->keys(MULTI_ASPECT_SCOPE.' [data-md-carousel-item] >> nth=0', 'ArrowRight');
|
|
|
|
$page->assertScript(onMultiAspectCarousel(<<<'JS'
|
|
await pause(400)
|
|
return scroller.scrollLeft > 0 && document.activeElement === items[1]
|
|
JS));
|
|
|
|
pressOnce($page)->keys(':focus', 'ArrowLeft');
|
|
|
|
$page->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));
|
|
});
|