Rewrite the carousel without Tailwind
Plan step 36 (containment group): <x-carousel>'s and <x-carousel-item>'s class lists move into resources/css/components/carousel.css and carousel-item.css, keyed on data-md-carousel (its value is the layout) and the parts' data-md-carousel-* hooks (-probe, -scroller, -controls with "auto"/"always", -previous/-next, -item, -surface, -content, -label/-label-text). Every selector uses a `>` combinator rather than a bare descendant one, because a carousel item can itself hold a nested carousel whose own root would otherwise match its parent's layout rules too (list.css already solves the same problem for segmented list rows). Behaviour is unchanged: resources/js/carousel.js (the keyline maths, C-05's reduced-motion fix, C-11's vertical full-screen layout, C-12's padding, C-18's item-as-tab-stop, the multi-aspect layout) is touched only where it reads or writes the renamed hooks and dataset properties; every inline custom property it writes (--material-carousel-*) is untouched. The item renders the shared md-focus-ring class (foundation/interaction.css) instead of a hand-rolled ring, refined to an inset offset since an outward one would draw under the neighbouring item. The previous/next buttons mirror whole in RTL from carousel.css rather than through <x-icon mirror-rtl>, which <x-button icon> has no prop to reach (a component outside this batch); the technique matches how the Tailwind-era markup already mirrored the whole button. The overlay label's literal white ink over the scrim (C-25) is kept, with the same reasoning as before. Hooks renamed data-material-carousel(-item/-surface/-content/-label) -> data-md-carousel(-item/-surface/-content/-label), data-padding(-end) -> data-md-padding(-end), data-centered -> data-md-centered, updated in the same commit: resources/js/carousel.js, tests/Feature/Components/ CarouselTest.php (rewritten on data-md-* and ComponentStylesheet) and tests/Browser/CarouselTest.php. Browser tests owed by docs/plans/material-3-browser-tests.md, added but not run: the multi-aspect carousel's previous/next, arrow keys, Home and End (scoped by data-md-carousel="multi-aspect" rather than a position in the showcase, so reordering its examples cannot silently mis-target the wrong carousel); a reduced-motion click on an item cut off only by the row's own edge, which documents rather than fixes a real gap — isMasked()'s inset check is always false once C-05 zeroes every item's inset, so the click-to-reveal affordance does not fire there (found by the Chromium baseline, step 32; fixing it is outside a hook rename). Imported from the Containment block of components.css. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
287340b066
commit
a149970dd0
@@ -51,10 +51,10 @@ function onCarousel(int $index, string $body, string $scope = '#carousel'): stri
|
||||
(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 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-material-carousel-surface]')
|
||||
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)
|
||||
@@ -66,7 +66,7 @@ function onCarousel(int $index, string $body, string $scope = '#carousel'): stri
|
||||
JS;
|
||||
}
|
||||
|
||||
const FIRST_ITEM = '#carousel [data-material-carousel-item] >> nth=0';
|
||||
const FIRST_ITEM = '#carousel [data-md-carousel-item] >> nth=0';
|
||||
|
||||
function carouselShowcase(array $options = [])
|
||||
{
|
||||
@@ -151,14 +151,14 @@ it('brings a partly hidden item into focus when it is pressed, and leaves an ope
|
||||
|
||||
// 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-material-carousel-content]\').click()'));
|
||||
$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-material-carousel-content]\').click()'));
|
||||
$page->script(onCarousel(0, 'items[4].querySelector(\'[data-md-carousel-content]\').click()'));
|
||||
|
||||
$page->assertScript(onCarousel(0, 'return open(4) && scroller.scrollLeft > 0'));
|
||||
});
|
||||
@@ -174,6 +174,27 @@ it('scrolls instantly and leaves every item unmasked under reduced motion', func
|
||||
JS));
|
||||
});
|
||||
|
||||
it('leaves a reduced-motion click on an item cut off at the row\'s edge where it is', 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 none of them count as masked at all),
|
||||
// and the click-to-reveal affordance gates on that inset, so a press on an item only cut off
|
||||
// by the row's own edge — not by a mask — does not scroll it into view. Documented here, not
|
||||
// fixed: fixing isMasked() for this case is outside a hook rename.
|
||||
$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(400)
|
||||
return scroller.scrollLeft === 0
|
||||
JS));
|
||||
});
|
||||
|
||||
it('mirrors in a right-to-left page', function () {
|
||||
Route::middleware('web')->get('/carousel-rtl-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
@@ -200,7 +221,7 @@ it('mirrors in a right-to-left page', function () {
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript(onCarousel(0, 'return size > 0 && at(0) && open(0) && ! open(items.length - 1)', 'body'));
|
||||
|
||||
$page->keys('[data-material-carousel-item] >> nth=0', 'ArrowLeft')
|
||||
$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
|
||||
@@ -209,7 +230,7 @@ it('mirrors in a right-to-left page', function () {
|
||||
$page->click('button[aria-label="Next"]')
|
||||
->assertScript(onCarousel(0, 'return at(2)', 'body'));
|
||||
|
||||
$page->keys('[data-material-carousel-item] >> nth=2', 'ArrowRight')
|
||||
$page->keys('[data-md-carousel-item] >> nth=2', 'ArrowRight')
|
||||
->assertScript(onCarousel(0, 'return at(1)', 'body'));
|
||||
});
|
||||
|
||||
@@ -231,16 +252,16 @@ it('measures itself again after a Livewire morph adds an item', function () {
|
||||
</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 })()";
|
||||
$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-material-carousel-item] >> nth=5', 'aria-label', '6 of 6');
|
||||
->assertAttribute('[data-md-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');
|
||||
->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.
|
||||
@@ -251,3 +272,70 @@ it('measures itself again after a Livewire morph adds an item', function () {
|
||||
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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user