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:
Andreas Reinhold / reini
2026-09-14 21:44:38 +02:00
co-authored by Claude Sonnet 5
parent 287340b066
commit a149970dd0
9 changed files with 503 additions and 134 deletions
+98 -10
View File
@@ -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));
});