blade(<<<'BLADE' A lake A hill A town BLADE); preg_match('/id="(material-carousel-[a-z0-9]+)"/', $html, $scroller); expect($scroller)->not->toBeEmpty() ->and($html) ->toContain('x-data="materialCarousel"') ->toContain('role="region"') ->toContain('aria-roledescription="carousel"') ->toContain('aria-label="Recent uploads"') ->toContain('aria-roledescription="slide"') ->toContain('aria-label="1 of 3"') ->toContain('aria-label="2 of 3"') ->toContain('aria-label="3 of 3"') ->toContain('A lake') ->not->toContain('[material-carousel-position]') ->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('/])*>/'); }); it('counts the slides of a carousel inside a slide on their own', function () { $html = (string) $this->blade(<<<'BLADE' 1 2 3 Two BLADE); expect($html) ->toContain('aria-label="1 of 3"') ->toContain('aria-label="3 of 3"') ->toContain('aria-label="1 of 2"') ->toContain('aria-label="2 of 2"'); }); it('browses many by default: 186px items that snap one at a time, 205px high', function () { $html = (string) $this->blade('A'); expect($html) ->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-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('A')) ->toContain('--material-carousel-item-width: 220px; --material-carousel-slot: 220px; --material-carousel-height: 18rem') ->toContain('data-md-padding="24"') ->and((string) $this->blade('A')) ->toContain('data-md-padding="16"') ->toContain('data-md-padding-end="0"') ->and((string) $this->blade('A')) ->toContain('--material-carousel-item-width: 40%;'); }); it('lays out a hero that fills the width unless capped, centred on request', function () { $filled = (string) $this->blade('A'); expect($filled) ->toContain('data-md-carousel="hero"') ->toContain('data-md-centered') ->toContain('--material-carousel-slot: calc(100% - 64px)') ->not->toContain('x-ref="probe"') ->not->toContain('--material-carousel-item-width') ->and((string) $this->blade('A')) ->toContain('--material-carousel-item-width: 600px') ->toContain('x-ref="probe"') ->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('A')) ->toContain('data-md-carousel="uncontained"') ->toContain('--material-carousel-item-width: 180px') ->and($carousel->has("[data-md-carousel='uncontained'] > [data-md-carousel-scroller]"))->toBeFalse() ->and((string) $this->blade('A')) ->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('x-ref="probe"') ->not->toContain('data-md-centered') ->and((string) $this->blade('A')) ->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 () { $auto = (string) $this->blade('A'); preg_match('/id="(material-carousel-[a-z0-9]+)"/', $auto, $scroller); expect($auto) ->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]}\"") ->and(substr_count($auto, "aria-controls=\"{$scroller[1]}\""))->toBe(2); expect((string) $this->blade('A')) ->toContain('data-md-carousel-controls="always"') ->toContain('aria-label="Next"'); expect((string) $this->blade('A')) ->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]:is([dir='rtl'], [dir='rtl'] *), [data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-controls] > [data-md-carousel-next]:is([dir='rtl'], [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(''); expect($html) ->toContain('data-md-carousel-item') ->toContain('wire:key="lake"') ->toContain('data-md-carousel-label') ->toContain('data-md-carousel-label-text') ->toContain('Lake Constance') ->and((string) $this->blade('A')) ->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 () { $html = (string) $this->blade(<<<'BLADE' A B C D E BLADE); expect($html) ->toContain('data-md-carousel="multi-aspect"') ->toContain('--material-carousel-pad: 16px') ->toContain('--material-carousel-height: 180px') ->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') ->not->toContain('--material-carousel-slot') ->not->toContain('x-ref="probe"') ->and(substr_count($html, 'aspect-ratio: 1.7778'))->toBe(2) ->and((string) $this->blade('A')) ->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)']); }); it('rings a focused item 3px inside its edge, and reveals an item the row cuts off under reduced motion', function () { expect(ComponentStylesheet::read('carousel-item')->declarations('[data-md-carousel-item]:focus-visible'))->toBe(['outline-offset' => '-3px']) ->and(File::get(__DIR__.'/../../../resources/js/carousel.js'))->toContain('if (state.measured || state.reducedMotion.matches) {'); });