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) ->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-material-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'); }); 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-padding="24"') ->and((string) $this->blade('A')) ->toContain('data-padding="16"') ->toContain('data-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-material-carousel="hero"') ->toContain('data-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('A')) ->toContain('--material-carousel-item-width: 600px') ->toContain('x-ref="probe"') ->not->toContain('data-centered'); }); it('leaves an uncontained carousel unsnapped, and scrolls a full-screen one down the page', function () { expect((string) $this->blade('A')) ->toContain('data-material-carousel="uncontained"') ->toContain('--material-carousel-item-width: 180px') ->not->toContain('snap-mandatory') ->and((string) $this->blade('A')) ->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('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') ->and((string) $this->blade('A')) ->toContain('data-material-carousel="multi-browse"'); }); 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('hidden pointer-fine:flex') ->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('A')) ->toContain('aria-label="Next"') ->not->toContain('pointer-fine:flex'); expect((string) $this->blade('A')) ->not->toContain('aria-label="Previous"') ->not->toContain('aria-label="Next"'); }); 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-material-carousel-item') ->toContain('wire:key="lake"') ->toContain('data-material-carousel-label') ->toContain('Lake Constance') ->toContain('from-scrim/60') ->toContain('type-title-md text-white') ->toContain('[&>img]:size-full [&>img]:object-cover') ->and((string) $this->blade('A')) ->not->toContain('data-material-carousel-label'); });