From a149970dd0a7f58eacae465315a6ca6985d709f1 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 21:44:38 +0200 Subject: [PATCH] Rewrite the carousel without Tailwind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 36 (containment group): 's and '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 , which 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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 2 + resources/css/components/carousel-item.css | 111 ++++++++++++ resources/css/components/carousel.css | 113 ++++++++++++ resources/js/carousel.js | 18 +- .../views/components/carousel-item.blade.php | 50 +++--- resources/views/components/carousel.blade.php | 70 ++++---- tests/Browser/CarouselTest.php | 108 ++++++++++-- tests/Feature/Components/CarouselTest.php | 163 +++++++++++++----- .../Components/ContainmentStylesheetsTest.php | 2 + 9 files changed, 503 insertions(+), 134 deletions(-) create mode 100644 resources/css/components/carousel-item.css create mode 100644 resources/css/components/carousel.css diff --git a/resources/css/components.css b/resources/css/components.css index 35e33bd1..d1eecd23 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -64,5 +64,7 @@ @import './components/modal.css'; @import './components/drawer.css'; @import './components/bottom-sheet.css'; +@import './components/carousel-item.css'; +@import './components/carousel.css'; /* Navigation */ diff --git a/resources/css/components/carousel-item.css b/resources/css/components/carousel-item.css new file mode 100644 index 00000000..bb6d48dc --- /dev/null +++ b/resources/css/components/carousel-item.css @@ -0,0 +1,111 @@ +/* + * : one slide of an , masked and shifted by the keyline maths + * carousel.css's header describes, unless the row is full-screen or multi-aspect, where nothing + * masks it at all (docs/reference/m3/components-actions-communication-containment.md § Carousel; + * docs/audits/m3-alignment/containment.md, C-11, C-12, C-25). + * + * [data-md-carousel-item] the tab stop (C-18), a focusable `group`, `md-focus-ring` + * [data-md-carousel-surface] the masked, shifted, extra-large-corner (28px) box + * [data-md-carousel-content] the slot (an , cropped) + * [data-md-carousel-label] the "n of m"-named overlay on the scrim + * [data-md-carousel-label-text] + * + * The item renders the shared `md-focus-ring` class (foundation/interaction.css) rather than a + * hand-rolled ring — the box the class draws *is* the item's own whole hit area — refined here to + * an inset ring: an item sits edge to edge with its neighbours in the row, so the class's own 2px + * *outward* offset would draw under the next item instead of around this one. + * + * Full-screen: edge to edge, no corner, no mask — `--material-carousel-shift`/`-inset` go unread. + * Multi-aspect: the view writes the item's own `aspect-ratio` inline (`aspect`, a prop, held + * inside M3's 9:16–16:9 range); the extra-large corner still applies but nothing masks it, since + * an Arrangement describes items of one size and a multi-aspect row has none. Selectors use `>` + * throughout, matching carousel.css, so a carousel nested inside an item never inherits its + * parent's layout rules. + * + * The label's ink is a literal `white`, not a role: it sits over `--md-sys-color-scrim`, which is + * black in every scheme, theme and contrast level, so white is the one ink that always has the + * scrim's contrast — a role (`inverse-on-surface`) is dark ink in a dark scheme and fails there + * (C-25, deliberately kept, as the component's original header explained). + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.components { + [data-md-carousel-item] { + position: relative; + height: 100%; + flex-shrink: 0; + scroll-snap-align: start; + scroll-snap-stop: always; + } + + [data-md-carousel-item]:focus-visible { + outline-offset: -12px; + } + + [data-md-carousel='full-screen'] > [data-md-carousel-scroller] > [data-md-carousel-item] { + width: 100%; + } + + [data-md-carousel='multi-aspect'] > [data-md-carousel-scroller] > [data-md-carousel-item] { + width: auto; + border-radius: var(--md-sys-shape-corner-xl); + } + + [data-md-carousel]:not([data-md-carousel='full-screen']):not([data-md-carousel='multi-aspect']) > [data-md-carousel-scroller] > [data-md-carousel-item] { + width: var(--material-carousel-slot); + max-width: 100%; + border-radius: var(--md-sys-shape-corner-xl); + } + + [data-md-carousel-item] > [data-md-carousel-surface] { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background-color: var(--md-sys-color-surface-container-highest); + color: var(--md-sys-color-on-surface); + } + + [data-md-carousel='multi-aspect'] > [data-md-carousel-scroller] > [data-md-carousel-item] > [data-md-carousel-surface] { + border-radius: var(--md-sys-shape-corner-xl); + } + + [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] { + 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)); + } + + [data-md-carousel-surface] > [data-md-carousel-content] { + width: 100%; + height: 100%; + + & > img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + [data-md-carousel-surface] > [data-md-carousel-label] { + pointer-events: none; + position: absolute; + inset-inline: 0; + bottom: 0; + display: flex; + padding: var(--md-sys-measurement-space500) var(--md-sys-measurement-space200) var(--md-sys-measurement-space200); + background: linear-gradient(to top, color-mix(in srgb, var(--md-sys-color-scrim) 60%, transparent), transparent); + opacity: var(--material-carousel-label); + } + + [data-md-carousel-label] > [data-md-carousel-label-text] { + 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; + } +} diff --git a/resources/css/components/carousel.css b/resources/css/components/carousel.css new file mode 100644 index 00000000..4e0f4dcd --- /dev/null +++ b/resources/css/components/carousel.css @@ -0,0 +1,113 @@ +/* + * : M3 Expressive's carousel — multi-browse, hero, uncontained, the uncontained + * multi-aspect-ratio layout (added November 2025) and full-screen, on a native scroll container + * with CSS scroll snap. resources/js/carousel.js ports Compose's keyline maths (Arrangement.kt, + * Keylines.kt, KeylineList.kt, Strategy.kt, KeylineSnapPosition.kt, Carousel.kt, androidx Compose + * Material 3 @ commit 7ac433e44e797de53af85226797862687f37735f, Apache-2.0) and writes the masks + * and sizes as inline custom properties every scroll frame; this file draws everything the script + * does not — the row's layout per layout, and the controls — and carousel-item.css draws the + * item and its mask (docs/reference/m3/components-actions-communication-containment.md + * § Carousel; docs/audits/m3-alignment/containment.md). + * + * [data-md-carousel] the row wrapper; its value is the layout + * [data-md-carousel-probe] hidden, measures the preferred item width for the script + * [data-md-carousel-scroller] the scroll container (role="region") + * [data-md-carousel-item] one slide — carousel-item.css + * [data-md-carousel-controls] previous/next, "auto" (pointer: fine only) or "always" + * [data-md-carousel-previous], [data-md-carousel-next] + * + * Specs table: 16dp leading/trailing padding, 8dp top/bottom and between items, for every layout + * but uncontained (leading only) and full-screen (0, edge to edge — C-12); a `>` combinator + * throughout, never a bare descendant one, because a carousel item can itself hold a nested + * carousel (a `multi-aspect` gallery inside a `hero` slide) whose own root would otherwise match + * its parent's rules too. Full-screen scrolls vertically, one edge-to-edge item at a time, capped + * at the 840px medium window it is meant for (C-11); reduced motion needs no rule here at all — + * the script writes zero inset, shift and full opacity itself for every layout (C-05). + * + * The previous/next icons mirror in RTL by scaling the whole button, as the row's arrow keys and + * scroll direction already do (`resources/js/carousel.js`'s own `state.rtl`), rather than through + * ``: `` has no prop to reach it, and flipping a round icon + * button whole reads the same as flipping only its glyph. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './button.css'; +@import './carousel-item.css'; + +@layer material.components { + [data-md-carousel] { + position: relative; + } + + [data-md-carousel] > [data-md-carousel-probe] { + position: absolute; + inset-inline-start: 0; + top: 0; + height: 0; + width: var(--material-carousel-item-width); + visibility: hidden; + pointer-events: none; + } + + [data-md-carousel] > [data-md-carousel-scroller] { + display: flex; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + [data-md-carousel='full-screen'] > [data-md-carousel-scroller] { + 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; + } + + [data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-scroller] { + height: calc(var(--material-carousel-height) + 16px); + gap: var(--md-sys-measurement-space100); + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + padding-block: var(--md-sys-measurement-space100); + } + + [data-md-carousel='multi-aspect'] > [data-md-carousel-scroller] { + padding-inline-start: var(--material-carousel-pad); + } + + [data-md-carousel='multi-browse'] > [data-md-carousel-scroller], + [data-md-carousel='hero'] > [data-md-carousel-scroller] { + scroll-snap-type: x mandatory; + } + + [data-md-carousel] > [data-md-carousel-controls] { + display: none; + margin-top: 12px; + justify-content: flex-end; + gap: var(--md-sys-measurement-space100); + } + + [data-md-carousel] > [data-md-carousel-controls='always'] { + display: flex; + } + + @media (pointer: fine) { + [data-md-carousel] > [data-md-carousel-controls='auto'] { + display: flex; + } + } + + [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) { + transform: scaleX(-1); + } +} diff --git a/resources/js/carousel.js b/resources/js/carousel.js index 0d199027..27d8cb51 100644 --- a/resources/js/carousel.js +++ b/resources/js/carousel.js @@ -81,7 +81,7 @@ const TARGET_MS = 700 // How long the row must go without a scroll event to count as having come to rest. const SETTLE_MS = 150 -const ITEM = '[data-material-carousel-item]' +const ITEM = '[data-md-carousel-item]' const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])' const clamp = (value, min, max) => Math.min(Math.max(value, min), max) @@ -870,14 +870,14 @@ document.addEventListener('alpine:init', () => { const style = getComputedStyle(scroller) state.rtl = style.direction === 'rtl' - state.vertical = root.dataset.materialCarousel === 'full-screen' - state.measured = root.dataset.materialCarousel === 'multi-aspect' + state.vertical = root.dataset.mdCarousel === 'full-screen' + state.measured = root.dataset.mdCarousel === 'multi-aspect' state.items = [...scroller.children] .filter((element) => element.matches(ITEM)) .map((element) => ({ element, - surface: element.querySelector('[data-material-carousel-surface]'), - label: element.querySelector('[data-material-carousel-label]'), + surface: element.querySelector('[data-md-carousel-surface]'), + label: element.querySelector('[data-md-carousel-label]'), labelWidth: 0, written: '', })) @@ -922,16 +922,16 @@ document.addEventListener('alpine:init', () => { const space = scroller.clientWidth const itemSpacing = parseFloat(style.columnGap) || 0 - const padding = Number(root.dataset.padding) || 0 - const paddingEnd = Number(root.dataset.paddingEnd) || 0 + const padding = Number(root.dataset.mdPadding) || 0 + const paddingEnd = Number(root.dataset.mdPaddingEnd) || 0 const probe = this.$refs.probe const preferred = probe ? probe.getBoundingClientRect().width : null const count = state.items.length const keylines = { - hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.centered !== undefined), + hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.mdCentered !== undefined), uncontained: () => uncontainedKeylineList(space, preferred ?? 0, itemSpacing), - }[root.dataset.materialCarousel] ?? (() => multiBrowseKeylineList(space, preferred ?? 0, itemSpacing, count)) + }[root.dataset.mdCarousel] ?? (() => multiBrowseKeylineList(space, preferred ?? 0, itemSpacing, count)) const strategy = count === 0 ? createStrategy(EMPTY, space, itemSpacing, 0, 0) : createStrategy(keylines(), space, itemSpacing, padding, paddingEnd) diff --git a/resources/views/components/carousel-item.blade.php b/resources/views/components/carousel-item.blade.php index 05ae1400..5c41a654 100644 --- a/resources/views/components/carousel-item.blade.php +++ b/resources/views/components/carousel-item.blade.php @@ -1,5 +1,5 @@ {{-- One item of an ``: a slide of art — an ``, which fills and crops to the - item, or any element sized `size-full`. + item, or any element sized to fill it. Lake Constance at dusk @@ -10,18 +10,24 @@ fades its label chip in once the mask is wide enough to hold it. The scrim is what M3 asks for under text on an image, and the scrim is `--md-sys-color-scrim` — black in every scheme, theme and contrast level — so its ink is the one absolute the theme keeps, `white`; a role - (`inverse-on-surface`) is dark ink in a dark scheme and fails on the scrim there. + (`inverse-on-surface`) is dark ink in a dark scheme and fails on the scrim there + (docs/audits/m3-alignment/containment.md, C-25, deliberately kept). A focusable `group` with `aria-roledescription="slide"`, named "n of m" by the carousel around it. M3 puts the tab stop on the item, not on the row: Tab reaches the first item, the arrow keys move between them and Space or Enter opens the focused one (docs/reference/m3/components-actions-communication-containment.md § Carousel → - Accessibility). The item is laid out at the carousel's large size and masked: - the surface inside is clipped by `--material-carousel-inset` from both sides with M3's - extra-large corner (28px, CarouselDefaults' item shape) and moved by - `--material-carousel-shift`, both written by resources/js/carousel.js. Without script the - item shows unmasked, at its `item-width`. In a `full-screen` carousel it is none of that: the - item fills the row, edge to edge, with no corner and no mask. + Accessibility). It renders the shared `md-focus-ring` class (foundation/interaction.css) + rather than a hand-rolled ring — the box the class draws is the item's own whole hit area — + which resources/css/components/carousel-item.css refines to an inset ring: an outward offset + would draw under the next item, since items sit edge to edge in the row. + + The item is laid out at the carousel's large size and masked: the surface inside is clipped + by `--material-carousel-inset` from both sides with M3's extra-large corner (28px, + CarouselDefaults' item shape) and moved by `--material-carousel-shift`, both written by + resources/js/carousel.js. Without script the item shows unmasked, at its `item-width`. In a + `full-screen` carousel it is none of that: the item fills the row, edge to edge, with no + corner and no mask. In a `multi-aspect` carousel the item is as wide as its own `aspect` makes it at the row's height, and nothing masks it: M3's uncontained multi-aspect-ratio layout is for items whose @@ -44,7 +50,6 @@ ]) @php - $vertical = $layout === 'full-screen'; $multiAspect = $layout === 'multi-aspect'; // M3's 9:16 minimum and 16:9 maximum. `16/9`, `16:9` and `1.78` all say the same thing. @@ -62,38 +67,23 @@ @endphp
class([ - 'focus-ring relative h-full shrink-0 snap-start snap-always focus-visible:-outline-offset-3', - 'w-full' => $vertical, - 'w-auto rounded-corner-xl' => $multiAspect, - 'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical && ! $multiAspect, - ]) + ->class('md-focus-ring') ->merge(array_filter([ 'role' => 'group', 'aria-roledescription' => __('slide'), 'aria-label' => '[material-carousel-position]', - 'data-material-carousel-item' => true, + 'data-md-carousel-item' => true, 'tabindex' => '0', 'style' => $ratio === null ? null : "aspect-ratio: {$ratio}", ], fn ($value): bool => $value !== null)) }}> -
$multiAspect, - 'rounded-corner-xl translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]' => ! $vertical && ! $multiAspect, - ]) - > -
+
+
{{ $slot }}
@if (filled($label)) - diff --git a/resources/views/components/carousel.blade.php b/resources/views/components/carousel.blade.php index f8d9858a..df7fca0a 100644 --- a/resources/views/components/carousel.blade.php +++ b/resources/views/components/carousel.blade.php @@ -53,8 +53,8 @@ carousel.js ports Compose's keylines (Arrangement, Keylines, KeylineList, Strategy, KeylineSnapPosition and Carousel.kt at androidx commit 7ac433e44e797de53af85226797862687f37735f, Apache-2.0) and masks each item on every scroll - frame, content at full size, so items change size between the keylines. Without script - the row still scrolls and snaps, unmasked. + frame, content at full size, so items change size between the keylines. Without script the + row still scrolls and snaps, unmasked. The row is a `region` with `aria-roledescription="carousel"`, named by `label` ("Carousel" by default); each item is a focusable `group` with `aria-roledescription="slide"` named @@ -72,7 +72,14 @@ Re-measures itself when resized, when a Livewire morph resets its styles and when items come and go. The row's id, which the buttons control, is new with every render; the row carries a `wire:key` (see ``), so a morph patches it in place — its scroll position - and listeners kept — rather than swapping in a copy. --}} + and listeners kept — rather than swapping in a copy. + + Every part below is drawn by resources/css/components/carousel.css and carousel-item.css, + keyed on `data-md-carousel` (its value is the layout) and the parts' own `data-md-carousel-*` + hooks; the script keeps writing its masks and sizes as inline custom properties + (`--material-carousel-*`), which those stylesheets read. The previous/next buttons carry no + class of their own — `` already draws its interaction states — and mirror in RTL by + scaling the whole button from carousel.css, since `icon` has no prop to reach `mirror-rtl`. --}} @props([ 'layout' => 'multi-browse', @@ -104,8 +111,8 @@ 'hero' => $cssLength($itemWidth), 'multi-aspect', 'full-screen' => null, }; - // The full-screen item is `h-full w-full` and a multi-aspect one is as wide as its own ratio - // makes it: neither takes a slot. + // The full-screen item fills the row and a multi-aspect one is as wide as its own ratio makes + // it: neither takes a slot. $slotWidth = match (true) { $vertical, $multiAspect => null, $preferredWidth === null => 'calc(100% - 64px)', @@ -133,27 +140,25 @@ $paddingStart = $vertical ? 0.0 : $padding; $paddingEnd = $vertical || $layout === 'uncontained' || $multiAspect ? 0.0 : $padding; - $attributes = $attributes - ->class('relative') - ->merge(array_filter([ - 'data-material-carousel' => $layout, - 'data-centered' => $layout === 'hero' && $centered ? true : null, - 'data-padding' => (string) $paddingStart, - 'data-padding-end' => (string) $paddingEnd, - 'style' => implode('; ', array_filter([ - $preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null, - $slotWidth ? "--material-carousel-slot: {$slotWidth}" : null, - // Without keylines there is nothing to anchor the first item in from the edge, so - // the multi-aspect row carries the specs table's leading padding itself. - $multiAspect ? "--material-carousel-pad: {$paddingStart}px" : null, - '--material-carousel-height: '.($cssLength($height) ?? '205px'), - ])), - ], fn ($value): bool => $value !== null)); + $attributes = $attributes->merge(array_filter([ + 'data-md-carousel' => $layout, + 'data-md-centered' => $layout === 'hero' && $centered ? true : null, + 'data-md-padding' => (string) $paddingStart, + 'data-md-padding-end' => (string) $paddingEnd, + 'style' => implode('; ', array_filter([ + $preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null, + $slotWidth ? "--material-carousel-slot: {$slotWidth}" : null, + // Without keylines there is nothing to anchor the first item in from the edge, so + // the multi-aspect row carries the specs table's leading padding itself. + $multiAspect ? "--material-carousel-pad: {$paddingStart}px" : null, + '--material-carousel-height: '.($cssLength($height) ?? '205px'), + ])), + ], fn ($value): bool => $value !== null)); @endphp
@if ($preferredWidth) - + @endif
$vertical, - 'h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2' => ! $vertical, - 'ps-(--material-carousel-pad)' => $multiAspect, - // M3's two scrolling modes: snap-scrolling everywhere but the two uncontained - // layouts, which it gives default scrolling. - 'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained' && ! $multiAspect, - ]) + data-md-carousel-scroller > {!! $slides !!}
@if ($controls !== false) -
$controls === null, - 'flex' => $controls === true, - ])> +
@endif diff --git a/tests/Browser/CarouselTest.php b/tests/Browser/CarouselTest.php index 792f4761..d45f1dff 100644 --- a/tests/Browser/CarouselTest.php +++ b/tests/Browser/CarouselTest.php @@ -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' @@ -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 () { 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 << { + 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)); +}); diff --git a/tests/Feature/Components/CarouselTest.php b/tests/Feature/Components/CarouselTest.php index 8842b40b..83a36c24 100644 --- a/tests/Feature/Components/CarouselTest.php +++ b/tests/Feature/Components/CarouselTest.php @@ -1,5 +1,7 @@ blade(<<<'BLADE' @@ -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('/])*>/'); }); @@ -54,27 +58,37 @@ it('browses many by default: 186px items that snap one at a time, 205px high', f $html = (string) $this->blade('A'); 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('A')) ->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('A')) - ->toContain('data-padding="16"') - ->toContain('data-padding-end="0"') + ->toContain('data-md-padding="16"') + ->toContain('data-md-padding-end="0"') ->and((string) $this->blade('A')) ->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('A'); 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('A')) ->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('A')) - ->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('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('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('A')) - ->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('A')) - ->toContain('aria-label="Next"') - ->not->toContain('pointer-fine:flex'); + ->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]: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(''); 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('A')) - ->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('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)']); }); diff --git a/tests/Feature/Components/ContainmentStylesheetsTest.php b/tests/Feature/Components/ContainmentStylesheetsTest.php index 3bc95b9e..2be71f64 100644 --- a/tests/Feature/Components/ContainmentStylesheetsTest.php +++ b/tests/Feature/Components/ContainmentStylesheetsTest.php @@ -24,6 +24,8 @@ dataset('containment components', [ 'modal', 'drawer', 'bottom-sheet', + 'carousel-item', + 'carousel', ]); /**