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
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('is a carousel region of focusable slides named n of m', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-carousel label="Recent uploads">
|
||||
@@ -26,6 +28,8 @@ it('is a carousel region of focusable slides named n of m', function () {
|
||||
->and(substr_count($html, 'role="group"'))->toBe(3)
|
||||
// M3 puts the tab stop on each item and tells you not to focus the container.
|
||||
->and(substr_count($html, 'tabindex="0"'))->toBe(3)
|
||||
// The item's own interaction is the shared focus-ring class, never a hand-rolled one.
|
||||
->and(substr_count($html, 'class="md-focus-ring"'))->toBe(3)
|
||||
->and($html)->toMatch('/<div\s+x-ref="scroller"(?:(?!tabindex)[^>])*>/');
|
||||
});
|
||||
|
||||
@@ -54,27 +58,37 @@ it('browses many by default: 186px items that snap one at a time, 205px high', f
|
||||
$html = (string) $this->blade('<x-carousel><x-carousel-item>A</x-carousel-item></x-carousel>');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-material-carousel="multi-browse"')
|
||||
->toContain('data-md-carousel="multi-browse"')
|
||||
->toContain('aria-label="Carousel"')
|
||||
->toContain('--material-carousel-item-width: 186px; --material-carousel-slot: 186px; --material-carousel-height: 205px')
|
||||
->toContain('x-ref="probe"')
|
||||
->toContain('data-padding="16"')
|
||||
->toContain('data-padding-end="16"')
|
||||
->toContain('h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2')
|
||||
->toContain('snap-x snap-mandatory')
|
||||
->toContain('snap-start snap-always')
|
||||
->toContain('rounded-corner-xl')
|
||||
->toContain('[clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]')
|
||||
->not->toContain('data-centered');
|
||||
->toContain('data-md-padding="16"')
|
||||
->toContain('data-md-padding-end="16"')
|
||||
->toContain('data-md-carousel-item')
|
||||
->not->toContain('data-md-centered');
|
||||
|
||||
$carousel = ComponentStylesheet::read('carousel');
|
||||
$item = ComponentStylesheet::read('carousel-item');
|
||||
|
||||
// The specs table's 16dp leading/trailing and 8dp top/bottom padding, and one item per swipe.
|
||||
expect($carousel->declarations("[data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-scroller]"))
|
||||
->toHaveKey('overflow-x', 'auto')
|
||||
->toHaveKey('padding-block', 'var(--md-sys-measurement-space100)')
|
||||
->and($item->declarations('[data-md-carousel-item]'))
|
||||
->toHaveKey('scroll-snap-align', 'start')
|
||||
->toHaveKey('scroll-snap-stop', 'always')
|
||||
->and($item->declarations("[data-md-carousel]:not([data-md-carousel='full-screen']):not([data-md-carousel='multi-aspect']) > [data-md-carousel-scroller] > [data-md-carousel-item]"))
|
||||
->toHaveKey('width', 'var(--material-carousel-slot)')
|
||||
->toHaveKey('border-radius', 'var(--md-sys-shape-corner-xl)');
|
||||
});
|
||||
|
||||
it('takes the item width in pixels or as a CSS length, and a height and padding', function () {
|
||||
expect((string) $this->blade('<x-carousel item-width="220" height="18rem" padding="24"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('--material-carousel-item-width: 220px; --material-carousel-slot: 220px; --material-carousel-height: 18rem')
|
||||
->toContain('data-padding="24"')
|
||||
->toContain('data-md-padding="24"')
|
||||
->and((string) $this->blade('<x-carousel layout="uncontained"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('data-padding="16"')
|
||||
->toContain('data-padding-end="0"')
|
||||
->toContain('data-md-padding="16"')
|
||||
->toContain('data-md-padding-end="0"')
|
||||
->and((string) $this->blade('<x-carousel item-width="40%"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('--material-carousel-item-width: 40%;');
|
||||
});
|
||||
@@ -83,39 +97,65 @@ it('lays out a hero that fills the width unless capped, centred on request', fun
|
||||
$filled = (string) $this->blade('<x-carousel layout="hero" centered><x-carousel-item>A</x-carousel-item></x-carousel>');
|
||||
|
||||
expect($filled)
|
||||
->toContain('data-material-carousel="hero"')
|
||||
->toContain('data-centered')
|
||||
->toContain('data-md-carousel="hero"')
|
||||
->toContain('data-md-centered')
|
||||
->toContain('--material-carousel-slot: calc(100% - 64px)')
|
||||
->toContain('snap-x snap-mandatory')
|
||||
->not->toContain('x-ref="probe"')
|
||||
->not->toContain('--material-carousel-item-width')
|
||||
->and((string) $this->blade('<x-carousel layout="hero" item-width="600"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('--material-carousel-item-width: 600px')
|
||||
->toContain('x-ref="probe"')
|
||||
->not->toContain('data-centered');
|
||||
->not->toContain('data-md-centered');
|
||||
|
||||
$carousel = ComponentStylesheet::read('carousel');
|
||||
|
||||
// multi-browse and hero are the two snap-scrolling layouts (uncontained and multi-aspect
|
||||
// keep default scrolling, M3's recommendation for each).
|
||||
expect($carousel->declarations("[data-md-carousel='multi-browse'] > [data-md-carousel-scroller], [data-md-carousel='hero'] > [data-md-carousel-scroller]"))
|
||||
->toBe(['scroll-snap-type' => 'x mandatory']);
|
||||
});
|
||||
|
||||
it('leaves an uncontained carousel unsnapped, and scrolls a full-screen one down the page', function () {
|
||||
$carousel = ComponentStylesheet::read('carousel');
|
||||
$item = ComponentStylesheet::read('carousel-item');
|
||||
|
||||
expect((string) $this->blade('<x-carousel layout="uncontained" item-width="180"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('data-material-carousel="uncontained"')
|
||||
->toContain('data-md-carousel="uncontained"')
|
||||
->toContain('--material-carousel-item-width: 180px')
|
||||
->not->toContain('snap-mandatory')
|
||||
->and($carousel->has("[data-md-carousel='uncontained'] > [data-md-carousel-scroller]"))->toBeFalse()
|
||||
->and((string) $this->blade('<x-carousel layout="full-screen" centered controls><x-carousel-item label="Morning">A</x-carousel-item></x-carousel>'))
|
||||
->toContain('data-material-carousel="full-screen"')
|
||||
// M3: one edge-to-edge item at a time, scrolled vertically, 16px apart, no end padding,
|
||||
// no corner, no mask, and never wider than the medium window it is meant for.
|
||||
->toContain('mx-auto h-(--material-carousel-height) max-w-210 snap-y snap-mandatory flex-col gap-4 overflow-x-hidden overflow-y-auto overscroll-y-contain')
|
||||
->toContain('data-padding="0" data-padding-end="0"')
|
||||
->toContain('data-md-carousel="full-screen"')
|
||||
->toContain('data-md-padding="0" data-md-padding-end="0"')
|
||||
->toContain('aria-label="Previous"')
|
||||
->not->toContain('--material-carousel-slot')
|
||||
->not->toContain('snap-x')
|
||||
->not->toContain('rounded-corner-xl')
|
||||
->not->toContain('clip-path')
|
||||
->not->toContain('rtl:-scale-x-100')
|
||||
->not->toContain('x-ref="probe"')
|
||||
->not->toContain('data-centered')
|
||||
->not->toContain('data-md-centered')
|
||||
->and((string) $this->blade('<x-carousel layout="sideways"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('data-material-carousel="multi-browse"');
|
||||
->toContain('data-md-carousel="multi-browse"');
|
||||
|
||||
// M3: one edge-to-edge item at a time, scrolled vertically, 16px apart, no corner, no mask,
|
||||
// and never wider than the medium window it is meant for (C-11).
|
||||
expect($carousel->declarations("[data-md-carousel='full-screen'] > [data-md-carousel-scroller]"))
|
||||
->toBe([
|
||||
'margin-inline' => 'auto',
|
||||
'height' => 'var(--material-carousel-height)',
|
||||
'max-width' => '840px',
|
||||
'flex-direction' => 'column',
|
||||
'gap' => 'var(--md-sys-measurement-space200)',
|
||||
'overflow-x' => 'hidden',
|
||||
'overflow-y' => 'auto',
|
||||
'overscroll-behavior-y' => 'contain',
|
||||
'scroll-snap-type' => 'y mandatory',
|
||||
])
|
||||
->and($item->declarations("[data-md-carousel='full-screen'] > [data-md-carousel-scroller] > [data-md-carousel-item]"))
|
||||
->toBe(['width' => '100%'])
|
||||
// Only a masked layout (not full-screen, not multi-aspect) shifts and clips the surface.
|
||||
->and($item->declarations("[data-md-carousel]:not([data-md-carousel='full-screen']):not([data-md-carousel='multi-aspect']) > [data-md-carousel-scroller] > [data-md-carousel-item] > [data-md-carousel-surface]"))
|
||||
->toBe([
|
||||
'border-radius' => 'var(--md-sys-shape-corner-xl)',
|
||||
'translate' => 'var(--material-carousel-shift) 0',
|
||||
'clip-path' => 'inset(0 var(--material-carousel-inset, 0px) round var(--md-sys-shape-corner-xl))',
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers previous and next buttons for fine pointers, always, or never', function () {
|
||||
@@ -124,37 +164,64 @@ it('offers previous and next buttons for fine pointers, always, or never', funct
|
||||
preg_match('/id="(material-carousel-[a-z0-9]+)"/', $auto, $scroller);
|
||||
|
||||
expect($auto)
|
||||
->toContain('hidden pointer-fine:flex')
|
||||
->toContain('data-md-carousel-controls="auto"')
|
||||
->toContain('data-md-carousel-previous')
|
||||
->toContain('data-md-carousel-next')
|
||||
->toContain('aria-label="Previous"')
|
||||
->toContain('aria-label="Next"')
|
||||
->toContain('x-on:click="previous()"')
|
||||
->toContain('x-on:click="next()"')
|
||||
->toContain("aria-controls=\"{$scroller[1]}\"")
|
||||
->toContain('rtl:-scale-x-100')
|
||||
->and(substr_count($auto, "aria-controls=\"{$scroller[1]}\""))->toBe(2);
|
||||
|
||||
expect((string) $this->blade('<x-carousel controls><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->toContain('aria-label="Next"')
|
||||
->not->toContain('pointer-fine:flex');
|
||||
->toContain('data-md-carousel-controls="always"')
|
||||
->toContain('aria-label="Next"');
|
||||
|
||||
expect((string) $this->blade('<x-carousel :controls="false"><x-carousel-item>A</x-carousel-item></x-carousel>'))
|
||||
->not->toContain('data-md-carousel-controls')
|
||||
->not->toContain('aria-label="Previous"')
|
||||
->not->toContain('aria-label="Next"');
|
||||
|
||||
$carousel = ComponentStylesheet::read('carousel');
|
||||
|
||||
expect($carousel->declarations("[data-md-carousel] > [data-md-carousel-controls='always']"))
|
||||
->toBe(['display' => 'flex'])
|
||||
->and($carousel->declarations("[data-md-carousel] > [data-md-carousel-controls='auto']", ['@media (pointer: fine)']))
|
||||
->toBe(['display' => 'flex'])
|
||||
// The button carries no class of its own, and mirrors whole in RTL rather than through
|
||||
// an icon prop button.blade.php does not expose.
|
||||
->and($carousel->declarations("[data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-controls] > [data-md-carousel-previous]:dir(rtl), [data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-controls] > [data-md-carousel-next]:dir(rtl)"))
|
||||
->toBe(['transform' => 'scaleX(-1)']);
|
||||
});
|
||||
|
||||
it('lays a label over an item on a scrim, and passes attributes to the item', function () {
|
||||
$html = (string) $this->blade('<x-carousel-item label="Lake Constance" wire:key="lake"><img src="/a.jpg" alt="" /></x-carousel-item>');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-material-carousel-item')
|
||||
->toContain('data-md-carousel-item')
|
||||
->toContain('wire:key="lake"')
|
||||
->toContain('data-material-carousel-label')
|
||||
->toContain('data-md-carousel-label')
|
||||
->toContain('data-md-carousel-label-text')
|
||||
->toContain('Lake Constance')
|
||||
->toContain('from-scrim/60')
|
||||
->toContain('type-title-md text-white')
|
||||
->toContain('[&>img]:size-full [&>img]:object-cover')
|
||||
->and((string) $this->blade('<x-carousel-item>A</x-carousel-item>'))
|
||||
->not->toContain('data-material-carousel-label');
|
||||
->not->toContain('data-md-carousel-label');
|
||||
|
||||
$item = ComponentStylesheet::read('carousel-item');
|
||||
|
||||
// The scrim under the label and the item's literal white ink over it are deliberate (C-25).
|
||||
expect($item->declarations('[data-md-carousel-surface] > [data-md-carousel-label]'))
|
||||
->toHaveKey('background', 'linear-gradient(to top, color-mix(in srgb, var(--md-sys-color-scrim) 60%, transparent), transparent)')
|
||||
->and($item->declarations('[data-md-carousel-label] > [data-md-carousel-label-text]'))
|
||||
->toBe([
|
||||
'overflow' => 'hidden',
|
||||
'color' => 'white',
|
||||
'font' => 'var(--md-sys-typescale-title-md)',
|
||||
'letter-spacing' => 'var(--md-sys-typescale-title-md-tracking)',
|
||||
'text-overflow' => 'ellipsis',
|
||||
'white-space' => 'nowrap',
|
||||
'translate' => 'var(--material-carousel-label-shift) 0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lets a multi-aspect carousel size each item by its own ratio, between 9:16 and 16:9', function () {
|
||||
@@ -169,22 +236,26 @@ it('lets a multi-aspect carousel size each item by its own ratio, between 9:16 a
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-material-carousel="multi-aspect"')
|
||||
->toContain('data-md-carousel="multi-aspect"')
|
||||
->toContain('--material-carousel-pad: 16px')
|
||||
->toContain('--material-carousel-height: 180px')
|
||||
->toContain('ps-(--material-carousel-pad)')
|
||||
->toContain('data-padding="16" data-padding-end="0"')
|
||||
->toContain('data-md-padding="16" data-md-padding-end="0"')
|
||||
// M3's own range holds whatever the item asks for: 16/9 at the top, 9/16 at the bottom.
|
||||
->toContain('aspect-ratio: 1.7778')
|
||||
->toContain('aspect-ratio: 0.5625')
|
||||
->toContain('aspect-ratio: 1')
|
||||
->toContain('w-auto rounded-corner-xl')
|
||||
// Uncontained scrolling, and nothing masks an item whose width is its own.
|
||||
->not->toContain('snap-mandatory')
|
||||
->not->toContain('--material-carousel-slot')
|
||||
->not->toContain('clip-path')
|
||||
->not->toContain('x-ref="probe"')
|
||||
->and(substr_count($html, 'aspect-ratio: 1.7778'))->toBe(2)
|
||||
->and((string) $this->blade('<x-carousel><x-carousel-item aspect="16/9">A</x-carousel-item></x-carousel>'))
|
||||
->not->toContain('aspect-ratio');
|
||||
|
||||
$carousel = ComponentStylesheet::read('carousel');
|
||||
$item = ComponentStylesheet::read('carousel-item');
|
||||
|
||||
// Uncontained scrolling, and nothing masks an item whose width is its own.
|
||||
expect($carousel->declarations("[data-md-carousel='multi-aspect'] > [data-md-carousel-scroller]"))
|
||||
->toBe(['padding-inline-start' => 'var(--material-carousel-pad)'])
|
||||
->and($item->declarations("[data-md-carousel='multi-aspect'] > [data-md-carousel-scroller] > [data-md-carousel-item]"))
|
||||
->toBe(['width' => 'auto', 'border-radius' => 'var(--md-sys-shape-corner-xl)']);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ dataset('containment components', [
|
||||
'modal',
|
||||
'drawer',
|
||||
'bottom-sheet',
|
||||
'carousel-item',
|
||||
'carousel',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user