From ad724662cab5b9c9aed6e58b34abbacab70f8ec3 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 07:40:30 +0200 Subject: [PATCH] Add the M3 Expressive carousel Multi-browse, hero, uncontained and full-screen layouts on a native scroll-snap row, with Compose's keyline maths masking each item as it scrolls. Completes Phase 5. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy --- NOTICE | 4 + docs/plans/livewire-material.md | 19 + .../livewire-material-development/SKILL.md | 14 + resources/js/carousel.js | 1085 +++++++++++++++++ resources/js/material.js | 1 + .../views/components/carousel-item.blade.php | 50 + resources/views/components/carousel.blade.php | 159 +++ resources/views/showcase/index.blade.php | 1 + resources/views/showcase/layout.blade.php | 2 +- .../showcase/sections/carousel.blade.php | 87 ++ tests/Browser/CarouselTest.php | 228 ++++ tests/Feature/Components/CarouselTest.php | 143 +++ 12 files changed, 1792 insertions(+), 1 deletion(-) create mode 100644 resources/js/carousel.js create mode 100644 resources/views/components/carousel-item.blade.php create mode 100644 resources/views/components/carousel.blade.php create mode 100644 resources/views/showcase/sections/carousel.blade.php create mode 100644 tests/Browser/CarouselTest.php create mode 100644 tests/Feature/Components/CarouselTest.php diff --git a/NOTICE b/NOTICE index b2a6e79f..43bcc1da 100644 --- a/NOTICE +++ b/NOTICE @@ -17,6 +17,10 @@ M3 Expressive loading indicator (resources/svg/loading-indicator) recorded at the top of bin/loading-indicator.mjs. Copyright The Android Open Source Project. Apache License 2.0. +M3 Expressive carousel keylines (resources/js/carousel.js) + Ported from androidx Compose Material 3 (carousel/*.kt), as recorded at the top of the file. + Copyright The Android Open Source Project. Apache License 2.0. + Google Sans Flex (resources/fonts/google-sans-flex) Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt). Subset: Latin and Latin Extended, weight 400–700, roundness 0–100. diff --git a/docs/plans/livewire-material.md b/docs/plans/livewire-material.md index 49aebca2..ad64f62c 100644 --- a/docs/plans/livewire-material.md +++ b/docs/plans/livewire-material.md @@ -427,6 +427,25 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero, full-screen on CSS scroll-snap), `collapse`. +**Phase 5 is done (2026-09-13).** What changed from the steps above: + +- **`` was built by a separate agent in its own worktree**, porting Compose's keyline + maths (`Arrangement`, `Keylines`, `Strategy`, `KeylineSnapPosition`, androidx + `7ac433e44e797de53af85226797862687f37735f`; checked against 43 values from androidx's unit tests). + Items are laid out at the large size on a native scroll-snap row and masked each frame with a + `clip-path` inset; snap positions are `scroll-margin-inline-start`. Full-screen follows + material-components-android, which Compose lacks; one item per swipe is `scroll-snap-stop`, not + fling physics. Hooks are `data-material-carousel*`: the design guard rejects `carousel*` classes + as daisyUI's. +- **The drawer is the side sheet**, with `pane` for list-detail from `xl`, and the bottom sheet is its + own component (modal or `standard`) with drag-to-dismiss. +- **``'s trailing slot is `end`**: a slot named like the `trailing` prop replaced it. +- **The design guard also rejects Blade directives inside component tags** (`@class` on + ``), which reach the browser as text. +- **`x-trap.inert` hides the page with `aria-hidden`**, not `inert`; tests assert what it sets. +- **WebKit returns focus from a dialog only to an element that had focus**, and a click does not + focus a button there: tests open dialogs from the keyboard. + ### Phase 6 — Text inputs and selection 25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`, diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index f5d3a8c2..d306758f 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -347,6 +347,20 @@ An M3 side sheet, bound like ``; `close()` in scope. Props: `title`, `s An M3 bottom sheet, bound like ``: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`90dvh`), `actions` slot. +### ``, `` + +```blade + + @foreach ($photos as $photo) + + {{ $photo->alt }} + + @endforeach + +``` + +A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). ``: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, 0), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). ``: slot is an `` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop. + ## Testing the design ```php diff --git a/resources/js/carousel.js b/resources/js/carousel.js new file mode 100644 index 00000000..e7d77874 --- /dev/null +++ b/resources/js/carousel.js @@ -0,0 +1,1085 @@ +/** + * `materialCarousel`: the behaviour of `` — M3 Expressive's keyline carousel on a + * native scroll container. + * + * The browser scrolls: touch, trackpad, Shift and the wheel, the scrollbar's keys, and CSS + * scroll snap (one item per swipe, as Compose's single-advance fling). Every item is laid out + * at the large size, end to end, exactly as Compose's Pager lays them out; this script then + * does what Compose's `Modifier.carouselItem` does in its layer block. On each scroll frame it + * asks the ported Strategy for the keylines at that scroll offset, finds the two keylines + * around each item's centre, and interpolates between them: the item is masked to the + * interpolated size (a `clip-path` inset from both sides, rounded 28px) and shifted by the + * interpolated offset, so large items shrink to medium and small and out past an anchor while + * their content keeps its full size — the parallax of M3's carousel. + * + * Snap positions are Compose's KeylineSnapPosition, written as each item's + * `scroll-margin-inline-start`, so the first and last items can come fully into focus. The + * arrow keys, Home, End and the previous/next buttons scroll from one of those positions to + * the next; so does a press on an item that is not fully open, and focus moving into one. + * + * Nothing here is saved in the DOM beyond inline styles, which a Livewire morph (or any other + * patcher) resets to what the server rendered. A MutationObserver sees that — and items added + * or removed — and measures again; it ignores this script's own writes by taking their + * records. A ResizeObserver does the same for a new width. RTL is read when measuring: + * scroll offsets are negative there and shifts mirror, as Compose's `translationX` does. + * + * Under reduced motion the buttons and keys scroll instantly, and the content stays pinned to + * the mask's leading edge instead of sliding inside it: the frame still opens and closes with + * the scroll the user makes, but nothing moves on its own. + * + * --------------------------------------------------------------------------------------- + * Keyline maths ported from androidx (https://github.com/androidx/androidx), commit + * 7ac433e44e797de53af85226797862687f37735f: + * + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Arrangement.kt + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineList.kt + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt + * (carouselItem, calculateMaxScrollOffset, CarouselDefaults) + * + * The full-screen arrangement follows material-components-android's + * FullScreenCarouselStrategy (one large item the size of the container). + * + * Copyright 2023-2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * --------------------------------------------------------------------------------------- + */ + +// CarouselDefaults (Carousel.kt), in CSS pixels for dp. ------------------------------------ + +const MIN_SMALL_ITEM_SIZE = 40 +const MAX_SMALL_ITEM_SIZE = 56 +const ANCHOR_SIZE = 10 +const MEDIUM_LARGE_ITEM_DIFF_THRESHOLD = 0.85 +const MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1 + +/** A programmatic scroll still counts as where the carousel is going for this long. */ +const TARGET_MS = 700 + +const ITEM = '[data-material-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) +const lerp = (start, stop, fraction) => start + (stop - start) * fraction + +/** Strategy.kt's `lerp(outputMin, outputMax, inputMin, inputMax, value)`. */ +const lerpRange = (outputMin, outputMax, inputMin, inputMax, value) => { + if (value <= inputMin) { + return outputMin + } + + if (value >= inputMax) { + return outputMax + } + + return lerp(outputMin, outputMax, (value - inputMin) / (inputMax - inputMin)) +} + +// Arrangement.kt --------------------------------------------------------------------------- + +const arrangementCost = (arrangement, targetLargeSize) => { + const { smallSize, smallCount, mediumSize, mediumCount, largeSize, largeCount } = arrangement + + const valid = + largeCount > 0 && smallCount > 0 && mediumCount > 0 + ? largeSize > mediumSize && mediumSize > smallSize + : largeCount > 0 && smallCount > 0 + ? largeSize > smallSize + : true + + return valid ? Math.abs(targetLargeSize - largeSize) * arrangement.priority : Number.MAX_VALUE +} + +const arrangementItemCount = (arrangement) => arrangement.largeCount + arrangement.mediumCount + arrangement.smallCount + +const fitArrangement = (priority, space, itemSpacing, smallCount, smallSize, minSmallSize, maxSmallSize, mediumCount, mediumSize, largeCount, largeSize) => { + const totalItemCount = largeCount + mediumCount + smallCount + const spaceWithoutSpacing = space - (totalItemCount - 1) * itemSpacing + let arrangedSmallSize = clamp(smallSize, minSmallSize, maxSmallSize) + let arrangedMediumSize = mediumSize + let arrangedLargeSize = largeSize + + const delta = spaceWithoutSpacing - (arrangedLargeSize * largeCount + arrangedMediumSize * mediumCount + arrangedSmallSize * smallCount) + + // Small items give way first, within their range. + if (smallCount > 0 && delta > 0) { + arrangedSmallSize += Math.min(delta / smallCount, maxSmallSize - arrangedSmallSize) + } else if (smallCount > 0 && delta < 0) { + arrangedSmallSize += Math.max(delta / smallCount, minSmallSize - arrangedSmallSize) + } + + arrangedSmallSize = smallCount > 0 ? arrangedSmallSize : 0 + + // Then the large size that fits, with medium items halfway between large and small. + arrangedLargeSize = (spaceWithoutSpacing - (smallCount + mediumCount / 2) * arrangedSmallSize) / (largeCount + mediumCount / 2) + arrangedMediumSize = (arrangedLargeSize + arrangedSmallSize) / 2 + + // A medium item flexes by up to 10% to bring the large size back towards its target. + if (mediumCount > 0 && arrangedLargeSize !== largeSize) { + const targetAdjustment = (largeSize - arrangedLargeSize) * largeCount + const availableMediumFlex = arrangedMediumSize * MEDIUM_ITEM_FLEX_PERCENTAGE * mediumCount + const distribute = Math.min(Math.abs(targetAdjustment), availableMediumFlex) + + if (targetAdjustment > 0) { + arrangedMediumSize -= distribute / mediumCount + arrangedLargeSize += distribute / largeCount + } else { + arrangedMediumSize += distribute / mediumCount + arrangedLargeSize -= distribute / largeCount + } + } + + return { + priority, + smallSize: arrangedSmallSize, + smallCount, + mediumSize: arrangedMediumSize, + mediumCount, + largeSize: arrangedLargeSize, + largeCount, + } +} + +const findLowestCostArrangement = ({ space, itemSpacing, targetSmallSize, minSmallSize, maxSmallSize, smallCounts, targetMediumSize, mediumCounts, targetLargeSize, largeCounts }) => { + let lowest = null + let priority = 1 + + for (const largeCount of largeCounts) { + for (const mediumCount of mediumCounts) { + for (const smallCount of smallCounts) { + const candidate = fitArrangement(priority, space, itemSpacing, smallCount, targetSmallSize, minSmallSize, maxSmallSize, mediumCount, targetMediumSize, largeCount, targetLargeSize) + + if (lowest === null || arrangementCost(candidate, targetLargeSize) < arrangementCost(lowest, targetLargeSize)) { + lowest = candidate + + // Candidates come in priority order: one that keeps the target size is the best. + if (arrangementCost(lowest, targetLargeSize) === 0) { + return lowest + } + } + + priority++ + } + } + } + + return lowest +} + +// KeylineList.kt --------------------------------------------------------------------------- + +const EMPTY = { keylines: [] } + +/** A keyline list with the indices KeylineList derives from its keylines' flags. */ +const keylineList = (keylines) => { + const firstFocalIndex = keylines.findIndex((keyline) => keyline.isFocal) + + return { + keylines, + pivotIndex: keylines.findIndex((keyline) => keyline.isPivot), + firstNonAnchorIndex: keylines.findIndex((keyline) => !keyline.isAnchor), + lastNonAnchorIndex: keylines.findLastIndex((keyline) => !keyline.isAnchor), + firstFocalIndex, + lastFocalIndex: keylines.findLastIndex((keyline) => keyline.isFocal), + } +} + +const firstFocal = (list) => list.keylines[list.firstFocalIndex] +const lastFocal = (list) => list.keylines[list.lastFocalIndex] + +const isFirstFocalItemAtStartOfContainer = (list) => { + const focal = firstFocal(list) + + return focal.offset - focal.size / 2 >= 0 && list.firstFocalIndex === list.firstNonAnchorIndex +} + +const isLastFocalItemAtEndOfContainer = (list, space) => { + const focal = lastFocal(list) + + return focal.offset + focal.size / 2 <= space && list.lastFocalIndex === list.lastNonAnchorIndex +} + +const firstIndexAfterFocalRangeWithSize = (list, size) => { + for (let index = list.lastFocalIndex; index < list.keylines.length; index++) { + if (list.keylines[index].size === size) { + return index + } + } + + return list.keylines.length - 1 +} + +const lastIndexBeforeFocalRangeWithSize = (list, size) => { + for (let index = list.firstFocalIndex - 1; index >= 0; index--) { + if (list.keylines[index].size === size) { + return index + } + } + + return 0 +} + +const keylineBefore = (list, unadjustedOffset) => { + for (let index = list.keylines.length - 1; index >= 0; index--) { + if (list.keylines[index].unadjustedOffset < unadjustedOffset) { + return list.keylines[index] + } + } + + return list.keylines[0] +} + +const keylineAfter = (list, unadjustedOffset) => list.keylines.find((keyline) => keyline.unadjustedOffset >= unadjustedOffset) ?? list.keylines.at(-1) + +/** The focal range KeylineListScopeImpl finds as keylines are added: the first run of the largest size. */ +const focalRange = (sizes) => { + let firstFocalIndex = -1 + let focalItemSize = 0 + + sizes.forEach(({ size, isAnchor }, index) => { + if (!isAnchor && size > focalItemSize) { + firstFocalIndex = index + focalItemSize = size + } + }) + + let lastFocalIndex = firstFocalIndex + + if (firstFocalIndex >= 0) { + while (lastFocalIndex < sizes.length - 1 && sizes[lastFocalIndex + 1].size === focalItemSize) { + lastFocalIndex++ + } + } + + return { firstFocalIndex, lastFocalIndex, focalItemSize } +} + +const isCutoffLeft = (size, offset) => offset - size / 2 < 0 && offset + size / 2 > 0 +const isCutoffRight = (size, offset, space) => offset - size / 2 < space && offset + size / 2 > space + +const keylinesWithPivot = (sizes, pivotIndex, pivotOffset, { firstFocalIndex, lastFocalIndex, focalItemSize }, space, itemSpacing) => { + if (sizes.length === 0 || pivotIndex < 0 || pivotIndex >= sizes.length) { + return EMPTY + } + + const isFocal = (index) => index >= firstFocalIndex && index <= lastFocalIndex + const pivot = sizes[pivotIndex] + + const keylines = [ + { + size: pivot.size, + offset: pivotOffset, + unadjustedOffset: pivotOffset, + isFocal: isFocal(pivotIndex), + isAnchor: pivot.isAnchor, + isPivot: true, + cutoff: isCutoffLeft(pivot.size, pivotOffset) + ? pivotOffset - pivot.size / 2 + : isCutoffRight(pivot.size, pivotOffset, space) + ? pivotOffset + pivot.size / 2 - space + : 0, + }, + ] + + let offset = pivotOffset - focalItemSize / 2 - itemSpacing + let unadjustedOffset = pivotOffset - focalItemSize / 2 - itemSpacing + + for (let index = pivotIndex - 1; index >= 0; index--) { + const { size, isAnchor } = sizes[index] + const keylineOffset = offset - size / 2 + + keylines.unshift({ + size, + offset: keylineOffset, + unadjustedOffset: unadjustedOffset - focalItemSize / 2, + isFocal: isFocal(index), + isAnchor, + isPivot: false, + cutoff: isCutoffLeft(size, keylineOffset) ? Math.abs(keylineOffset - size / 2) : 0, + }) + + offset -= size + itemSpacing + unadjustedOffset -= focalItemSize + itemSpacing + } + + offset = pivotOffset + focalItemSize / 2 + itemSpacing + unadjustedOffset = pivotOffset + focalItemSize / 2 + itemSpacing + + for (let index = pivotIndex + 1; index < sizes.length; index++) { + const { size, isAnchor } = sizes[index] + const keylineOffset = offset + size / 2 + + keylines.push({ + size, + offset: keylineOffset, + unadjustedOffset: unadjustedOffset + focalItemSize / 2, + isFocal: isFocal(index), + isAnchor, + isPivot: false, + cutoff: isCutoffRight(size, keylineOffset, space) ? keylineOffset + size / 2 - space : 0, + }) + + offset += size + itemSpacing + unadjustedOffset += focalItemSize + itemSpacing + } + + return keylineList(keylines) +} + +const pivotedKeylineList = (sizes, space, itemSpacing, pivotIndex, pivotOffset) => keylinesWithPivot(sizes, pivotIndex, pivotOffset, focalRange(sizes), space, itemSpacing) + +const alignedKeylineList = (sizes, space, itemSpacing, centered) => { + const range = focalRange(sizes) + const focalItemCount = range.lastFocalIndex - range.firstFocalIndex + + let pivotOffset = range.focalItemSize / 2 + + if (centered) { + const itemSpacingSplit = itemSpacing === 0 || focalItemCount % 2 === 0 ? 0 : itemSpacing / 2 + const itemSpaceCounts = Math.trunc(focalItemCount / 2) * itemSpacing + + pivotOffset = space / 2 - (range.focalItemSize / 2) * focalItemCount - itemSpacingSplit - itemSpaceCounts + } + + return keylinesWithPivot(sizes, range.firstFocalIndex, pivotOffset, range, space, itemSpacing) +} + +const lerpKeyline = (start, end, fraction) => ({ + size: lerp(start.size, end.size, fraction), + offset: lerp(start.offset, end.offset, fraction), + unadjustedOffset: lerp(start.unadjustedOffset, end.unadjustedOffset, fraction), + isFocal: fraction < 0.5 ? start.isFocal : end.isFocal, + isAnchor: fraction < 0.5 ? start.isAnchor : end.isAnchor, + isPivot: fraction < 0.5 ? start.isPivot : end.isPivot, + cutoff: lerp(start.cutoff, end.cutoff, fraction), +}) + +const lerpKeylineList = (from, to, fraction) => keylineList(from.keylines.map((keyline, index) => lerpKeyline(keyline, to.keylines[index], fraction))) + +// Keylines.kt ------------------------------------------------------------------------------ + +const leftAlignedKeylineList = (space, itemSpacing, leftAnchorSize, rightAnchorSize, arrangement) => + alignedKeylineList( + [ + { size: leftAnchorSize, isAnchor: true }, + ...Array.from({ length: arrangement.largeCount }, () => ({ size: arrangement.largeSize, isAnchor: false })), + ...Array.from({ length: arrangement.mediumCount }, () => ({ size: arrangement.mediumSize, isAnchor: false })), + ...Array.from({ length: arrangement.smallCount }, () => ({ size: arrangement.smallSize, isAnchor: false })), + { size: rightAnchorSize, isAnchor: true }, + ], + space, + itemSpacing, + false, + ) + +const centerAlignedKeylineList = (space, itemSpacing, leftAnchorSize, rightAnchorSize, arrangement) => { + const repeat = (count, size) => Array.from({ length: count }, () => ({ size, isAnchor: false })) + + return alignedKeylineList( + [ + { size: leftAnchorSize, isAnchor: true }, + ...repeat(Math.trunc(arrangement.smallCount / 2), arrangement.smallSize), + ...repeat(Math.trunc(arrangement.mediumCount / 2), arrangement.mediumSize), + ...repeat(arrangement.largeCount, arrangement.largeSize), + ...repeat(Math.trunc(arrangement.mediumCount / 2), arrangement.mediumSize), + ...repeat(Math.trunc(arrangement.smallCount / 2), arrangement.smallSize), + { size: rightAnchorSize, isAnchor: true }, + ], + space, + itemSpacing, + true, + ) +} + +const largeCountsBetween = (min, max) => Array.from({ length: max - min + 1 }, (_, index) => max - index) + +const multiBrowseKeylineList = (space, preferredItemSize, itemSpacing, itemCount) => { + if (space === 0 || preferredItemSize === 0) { + return EMPTY + } + + let smallCounts = [1] + const mediumCounts = [1, 0] + + const targetLargeSize = Math.min(preferredItemSize, space) + // A balanced arrangement has small items a third of the large size, within their range. + const targetSmallSize = clamp(targetLargeSize / 3, MIN_SMALL_ITEM_SIZE, MAX_SMALL_ITEM_SIZE) + const targetMediumSize = (targetLargeSize + targetSmallSize) / 2 + + if (space < MIN_SMALL_ITEM_SIZE * 2) { + smallCounts = [0] + } + + const minAvailableLargeSpace = space - targetMediumSize * Math.max(...mediumCounts) - MAX_SMALL_ITEM_SIZE * Math.max(...smallCounts) + const minLargeCount = Math.max(1, Math.floor(minAvailableLargeSpace / targetLargeSize)) + const maxLargeCount = Math.ceil(space / targetLargeSize) + const largeCounts = largeCountsBetween(minLargeCount, maxLargeCount) + + const find = (smalls, mediums) => + findLowestCostArrangement({ + space, + itemSpacing, + targetSmallSize, + minSmallSize: MIN_SMALL_ITEM_SIZE, + maxSmallSize: MAX_SMALL_ITEM_SIZE, + smallCounts: smalls, + targetMediumSize, + mediumCounts: mediums, + targetLargeSize, + largeCounts, + }) + + let arrangement = find(smallCounts, mediumCounts) + + // Fewer items than keylines: drop small, then medium ones (keeping one medium). + if (arrangement !== null && arrangementItemCount(arrangement) > itemCount) { + let keylineSurplus = arrangementItemCount(arrangement) - itemCount + let { smallCount, mediumCount } = arrangement + + while (keylineSurplus > 0) { + if (smallCount > 0) { + smallCount -= 1 + } else if (mediumCount > 1) { + mediumCount -= 1 + } + + keylineSurplus -= 1 + } + + arrangement = find([smallCount], [mediumCount]) + } + + return arrangement === null ? EMPTY : leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, arrangement) +} + +const mediumChildSize = (minimumMediumSize, largeItemSize, remainingSpace) => { + // Large enough that a third of it is cut off… + let mediumItemSize = Math.max(remainingSpace * 1.5, minimumMediumSize) + + // …but different enough from the large size to move when scrolled. + const largeItemThreshold = largeItemSize * MEDIUM_LARGE_ITEM_DIFF_THRESHOLD + + if (mediumItemSize > largeItemThreshold) { + mediumItemSize = Math.min(Math.max(largeItemThreshold, remainingSpace * 1.2), largeItemSize) + } + + return mediumItemSize +} + +const uncontainedKeylineList = (space, itemSize, itemSpacing) => { + if (space === 0 || itemSize === 0) { + return EMPTY + } + + const largeItemSize = Math.min(itemSize + itemSpacing, space) + const largeCount = Math.max(1, Math.floor(space / largeItemSize)) + const remainingSpace = space - largeCount * largeItemSize + const mediumCount = remainingSpace > 0 ? 1 : 0 + const mediumItemSize = mediumChildSize(ANCHOR_SIZE, largeItemSize, remainingSpace) + + const arrangement = { priority: 0, smallSize: 0, smallCount: 0, mediumSize: mediumItemSize, mediumCount, largeSize: largeItemSize, largeCount } + + // Half the cut-off item at the start, so the motion there matches the end. + const leftAnchorSize = Math.max(Math.min(ANCHOR_SIZE, itemSize), mediumItemSize * 0.5) + + return leftAlignedKeylineList(space, itemSpacing, leftAnchorSize, ANCHOR_SIZE, arrangement) +} + +const heroKeylineList = (space, maxItemSize, itemSpacing, itemCount, isCentered) => { + if (space === 0) { + return EMPTY + } + + const shouldCenter = isCentered && itemCount >= 3 + + let smallCounts = itemCount <= 1 ? [0] : shouldCenter ? [2] : [1] + + const targetLargeSize = Math.min(maxItemSize ?? space, space) + const targetSmallSize = clamp(targetLargeSize / 3, MIN_SMALL_ITEM_SIZE, MAX_SMALL_ITEM_SIZE) + + // Room for the small items and a large item at least 25% larger than them. + const fullscreenThreshold = MIN_SMALL_ITEM_SIZE * Math.max(...smallCounts) + MIN_SMALL_ITEM_SIZE * 1.25 + + if (space < fullscreenThreshold) { + smallCounts = [0] + } + + const minAvailableLargeSpace = space - MIN_SMALL_ITEM_SIZE * Math.max(...smallCounts) + const minLargeCount = Math.max(1, Math.floor(minAvailableLargeSpace / targetLargeSize)) + const maxLargeCount = Math.ceil(space / targetLargeSize) + + const arrangement = findLowestCostArrangement({ + space, + itemSpacing, + targetSmallSize, + minSmallSize: MIN_SMALL_ITEM_SIZE, + maxSmallSize: MAX_SMALL_ITEM_SIZE, + smallCounts, + targetMediumSize: 0, + mediumCounts: [0], + targetLargeSize, + largeCounts: largeCountsBetween(minLargeCount, maxLargeCount), + }) + + if (arrangement === null) { + return EMPTY + } + + return shouldCenter && itemCount >= arrangementItemCount(arrangement) + ? centerAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, arrangement) + : leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, arrangement) +} + +const fullScreenKeylineList = (space, itemSpacing) => + space === 0 + ? EMPTY + : leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, { priority: 0, smallSize: 0, smallCount: 0, mediumSize: 0, mediumCount: 0, largeSize: space, largeCount: 1 }) + +// Strategy.kt ------------------------------------------------------------------------------ + +const shiftedKeylineListForContentPadding = (from, space, itemSpacing, contentPadding, pivot, pivotIndex) => { + const sizeReduction = contentPadding / from.keylines.filter((keyline) => !keyline.isAnchor).length + + const shifted = pivotedKeylineList( + from.keylines.map((keyline) => ({ size: keyline.size - Math.abs(sizeReduction), isAnchor: keyline.isAnchor })), + space, + itemSpacing, + pivotIndex, + pivot.offset - sizeReduction / 2 + contentPadding, + ) + + // Items are still laid out end to end at the full size, so the unadjusted offsets stay. + return keylineList(shifted.keylines.map((keyline, index) => ({ ...keyline, unadjustedOffset: from.keylines[index].unadjustedOffset }))) +} + +const moveKeylineAndShift = (from, srcIndex, dstIndex, space, itemSpacing) => { + const pivotDirection = srcIndex > dstIndex ? 1 : -1 + const pivotDelta = (from.keylines[srcIndex].size - from.keylines[srcIndex].cutoff + itemSpacing) * pivotDirection + const moved = [...from.keylines] + const [keyline] = moved.splice(srcIndex, 1) + + moved.splice(dstIndex, 0, keyline) + + return pivotedKeylineList( + moved.map(({ size, isAnchor }) => ({ size, isAnchor })), + space, + itemSpacing, + from.pivotIndex + pivotDirection, + from.keylines[from.pivotIndex].offset + pivotDelta, + ) +} + +const startKeylineSteps = (defaults, space, itemSpacing, beforeContentPadding) => { + if (defaults.keylines.length === 0) { + return [] + } + + const steps = [defaults] + + if (isFirstFocalItemAtStartOfContainer(defaults)) { + if (beforeContentPadding !== 0) { + steps.push(shiftedKeylineListForContentPadding(defaults, space, itemSpacing, beforeContentPadding, firstFocal(defaults), defaults.firstFocalIndex)) + } + + return steps + } + + const startIndex = defaults.firstNonAnchorIndex + const numberOfSteps = defaults.firstFocalIndex - startIndex + + if (numberOfSteps <= 0 && firstFocal(defaults).cutoff > 0) { + steps.push(moveKeylineAndShift(defaults, 0, 0, space, itemSpacing)) + + return steps + } + + for (let step = 0; step < numberOfSteps; step++) { + const previous = steps.at(-1) + const originalItemIndex = startIndex + step + let dstIndex = defaults.keylines.length - 1 + + if (originalItemIndex > 0) { + dstIndex = firstIndexAfterFocalRangeWithSize(previous, defaults.keylines[originalItemIndex - 1].size) - 1 + } + + steps.push(moveKeylineAndShift(previous, defaults.firstNonAnchorIndex, dstIndex, space, itemSpacing)) + } + + if (beforeContentPadding !== 0) { + const last = steps.at(-1) + + steps[steps.length - 1] = shiftedKeylineListForContentPadding(last, space, itemSpacing, beforeContentPadding, firstFocal(last), last.firstFocalIndex) + } + + return steps +} + +const endKeylineSteps = (defaults, space, itemSpacing, afterContentPadding) => { + if (defaults.keylines.length === 0) { + return [] + } + + const steps = [defaults] + + if (isLastFocalItemAtEndOfContainer(defaults, space)) { + if (afterContentPadding !== 0) { + steps.push(shiftedKeylineListForContentPadding(defaults, space, itemSpacing, -afterContentPadding, lastFocal(defaults), defaults.lastFocalIndex)) + } + + return steps + } + + const startIndex = defaults.lastFocalIndex + const endIndex = defaults.lastNonAnchorIndex + const numberOfSteps = endIndex - startIndex + + if (numberOfSteps <= 0 && lastFocal(defaults).cutoff > 0) { + steps.push(moveKeylineAndShift(defaults, 0, 0, space, itemSpacing)) + + return steps + } + + for (let step = 0; step < numberOfSteps; step++) { + const previous = steps.at(-1) + const originalItemIndex = endIndex - step + let dstIndex = 0 + + if (originalItemIndex < defaults.keylines.length - 1) { + dstIndex = lastIndexBeforeFocalRangeWithSize(previous, defaults.keylines[originalItemIndex + 1].size) + 1 + } + + steps.push(moveKeylineAndShift(previous, defaults.lastNonAnchorIndex, dstIndex, space, itemSpacing)) + } + + if (afterContentPadding !== 0) { + const last = steps.at(-1) + + steps[steps.length - 1] = shiftedKeylineListForContentPadding(last, space, itemSpacing, -afterContentPadding, lastFocal(last), last.lastFocalIndex) + } + + return steps +} + +const stepInterpolationPoints = (totalShiftDistance, steps, isShiftingLeft) => { + const points = [0] + + if (totalShiftDistance === 0 || steps.length === 0) { + return points + } + + for (let index = 1; index < steps.length; index++) { + const previous = steps[index - 1].keylines + const current = steps[index].keylines + const distanceShifted = isShiftingLeft ? current[0].unadjustedOffset - previous[0].unadjustedOffset : previous.at(-1).unadjustedOffset - current.at(-1).unadjustedOffset + + points.push(index === steps.length - 1 ? 1 : points[index - 1] + distanceShifted / totalShiftDistance) + } + + return points +} + +const createStrategy = (defaults, space, itemSpacing, beforeContentPadding, afterContentPadding) => { + const startSteps = startKeylineSteps(defaults, space, itemSpacing, beforeContentPadding) + const endSteps = endKeylineSteps(defaults, space, itemSpacing, afterContentPadding) + + const startShiftDistance = startSteps.length === 0 ? 0 : Math.max(startSteps.at(-1).keylines[0].unadjustedOffset - startSteps[0].keylines[0].unadjustedOffset, beforeContentPadding) + const endShiftDistance = endSteps.length === 0 ? 0 : Math.max(endSteps[0].keylines.at(-1).unadjustedOffset - endSteps.at(-1).keylines.at(-1).unadjustedOffset, afterContentPadding) + + const itemSize = defaults.keylines.length > 0 && defaults.firstFocalIndex >= 0 ? firstFocal(defaults).size : 0 + + return { + defaults, + startSteps, + endSteps, + space, + itemSpacing, + itemSize, + valid: defaults.keylines.length > 0 && space !== 0 && itemSize > 0, + startShiftDistance, + endShiftDistance, + startShiftPoints: stepInterpolationPoints(startShiftDistance, startSteps, true), + endShiftPoints: stepInterpolationPoints(endShiftDistance, endSteps, false), + } +} + +const keylinesForScrollOffset = (strategy, scrollOffset, maxScrollOffset) => { + const offset = Math.max(0, scrollOffset) + const startShiftOffset = strategy.startShiftDistance + const endShiftOffset = Math.max(0, maxScrollOffset - strategy.endShiftDistance) + + if (offset >= startShiftOffset && offset <= endShiftOffset) { + return strategy.defaults + } + + let interpolation = lerpRange(1, 0, 0, startShiftOffset, offset) + let shiftPoints = strategy.startShiftPoints + let steps = strategy.startSteps + + if (offset > endShiftOffset) { + interpolation = lerpRange(0, 1, endShiftOffset, maxScrollOffset, offset) + shiftPoints = strategy.endShiftPoints + steps = strategy.endSteps + + // End shifting from offset 0: go straight from the last start step to the last end step. + if (endShiftOffset < 0.01 && strategy.startSteps.length === 2 && strategy.endSteps.length === 2) { + steps = [strategy.startSteps.at(-1), strategy.endSteps.at(-1)] + } + } + + let fromStepIndex = 0 + let toStepIndex = 0 + let steppedInterpolation = 0 + let lowerBounds = shiftPoints[0] + + for (let index = 1; index < steps.length; index++) { + const upperBounds = shiftPoints[index] + + if (interpolation <= upperBounds) { + fromStepIndex = index - 1 + toStepIndex = index + steppedInterpolation = lerpRange(0, 1, lowerBounds, upperBounds, interpolation) + + break + } + + lowerBounds = upperBounds + } + + return lerpKeylineList(steps[fromStepIndex], steps[toStepIndex], steppedInterpolation) +} + +// KeylineSnapPosition.kt ------------------------------------------------------------------- + +const snapPositionOffset = (strategy, itemIndex, itemCount) => { + if (!strategy.valid) { + return 0 + } + + let offset = Math.round(firstFocal(strategy.defaults).unadjustedOffset - strategy.itemSize / 2) + + const lastStartStep = strategy.startSteps.length - 1 + + if (itemIndex <= lastStartStep) { + const step = strategy.startSteps[clamp(lastStartStep - itemIndex, 0, lastStartStep)] + + offset = Math.round(firstFocal(step).unadjustedOffset - strategy.itemSize / 2) + } + + const lastEndStep = strategy.endSteps.length - 1 + const lastItemIndex = itemCount - 1 + const focalCount = strategy.defaults.lastFocalIndex - strategy.defaults.firstFocalIndex + 1 + + if (itemIndex >= lastItemIndex - lastEndStep && itemCount > focalCount) { + const step = strategy.endSteps[clamp(lastEndStep - (lastItemIndex - itemIndex), 0, lastEndStep)] + + offset = Math.round(lastFocal(step).unadjustedOffset - strategy.itemSize / 2) + } + + return offset +} + +// The component ---------------------------------------------------------------------------- + +document.addEventListener('alpine:init', () => { + window.Alpine.data('materialCarousel', () => { + // Plain state, out of Alpine's reactivity: it changes on every scroll frame. + const state = { + items: [], + strategy: null, + snaps: [], + maxScroll: 0, + rtl: false, + frame: null, + target: null, + targetAt: 0, + listeners: [], + mutations: null, + resizes: null, + reducedMotion: null, + } + + return { + init() { + const scroller = this.$refs.scroller + + state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') + + this.listen(scroller, 'scroll', () => this.schedule(), { passive: true }) + this.listen(scroller, 'keydown', (event) => this.navigate(event)) + this.listen(scroller, 'focusin', (event) => this.reveal(event)) + this.listen(scroller, 'click', (event) => this.open(event)) + this.listen(state.reducedMotion, 'change', () => this.schedule()) + + state.resizes = new ResizeObserver(() => this.refresh()) + state.resizes.observe(scroller) + + // A morph resets the inline styles written here, and may add or remove items. + state.mutations = new MutationObserver((records) => { + if (records.some((record) => (record.type === 'childList' ? record.target === scroller : this.owns(record.target)))) { + this.refresh() + } + }) + state.mutations.observe(this.$root, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] }) + + this.refresh() + }, + + /** The elements this script writes styles on. */ + owns(element) { + return element === this.$root || state.items.some((item) => item.element === element || item.surface === element) + }, + + listen(target, type, handler, options) { + target.addEventListener(type, handler, options) + state.listeners.push(() => target.removeEventListener(type, handler, options)) + }, + + /** Measures the container and items, and builds the strategy for this width. */ + refresh() { + const root = this.$root + const scroller = this.$refs.scroller + const space = scroller.clientWidth + const itemSpacing = parseFloat(getComputedStyle(scroller).columnGap) || 0 + const padding = Number(root.dataset.padding) || 0 + const probe = this.$refs.probe + const preferred = probe ? probe.getBoundingClientRect().width : null + + state.rtl = getComputedStyle(scroller).direction === 'rtl' + state.items = [...scroller.children] + .filter((element) => element.matches(ITEM)) + .map((element) => ({ + element, + surface: element.querySelector('[data-material-carousel-surface]'), + content: element.querySelector('[data-material-carousel-content]'), + label: element.querySelector('[data-material-carousel-label]'), + labelWidth: 0, + written: '', + })) + + const count = state.items.length + const keylines = { + hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.centered !== undefined), + uncontained: () => uncontainedKeylineList(space, preferred ?? 0, itemSpacing), + 'full-screen': () => fullScreenKeylineList(space, itemSpacing), + }[root.dataset.materialCarousel] ?? (() => multiBrowseKeylineList(space, preferred ?? 0, itemSpacing, count)) + + const strategy = count === 0 ? createStrategy(EMPTY, space, itemSpacing, 0, 0) : createStrategy(keylines(), space, itemSpacing, padding, padding) + + state.strategy = strategy + + if (!strategy.valid) { + root.style.removeProperty('--material-carousel-slot') + state.items.forEach(({ element }) => element.style.removeProperty('scroll-margin-inline-start')) + state.mutations?.takeRecords() + + return + } + + const size = strategy.itemSize + + root.style.setProperty('--material-carousel-slot', `${size}px`) + + state.maxScroll = Math.max(0, size * count + itemSpacing * (count - 1) - space) + state.snaps = state.items.map(({ element }, index) => { + const offset = snapPositionOffset(strategy, index, count) + + element.style.setProperty('scroll-margin-inline-start', `${offset}px`) + + return clamp(index * (size + itemSpacing) - offset, 0, state.maxScroll) + }) + + // Measured at the full size, before any mask: how much room the label needs. + state.items.forEach((item) => { + item.labelWidth = item.label?.firstElementChild ? item.label.firstElementChild.getBoundingClientRect().width + 32 : 0 + }) + + this.render() + }, + + schedule() { + state.frame ??= requestAnimationFrame(() => this.render()) + }, + + /** Carousel.kt's carouselItem layer block, for every item. */ + render() { + cancelAnimationFrame(state.frame) + state.frame = null + + const strategy = state.strategy + + if (!strategy?.valid) { + return + } + + const scroll = this.scrollOffset() + const keylines = keylinesForScrollOffset(strategy, scroll, state.maxScroll) + const size = strategy.itemSize + const pinned = state.reducedMotion.matches + + state.items.forEach((item, index) => { + const center = index * (size + strategy.itemSpacing) + size / 2 - scroll + const before = keylineBefore(keylines, center) + const after = keylineAfter(keylines, center) + const progress = before === after ? 1 : (center - before.unadjustedOffset) / (after.unadjustedOffset - before.unadjustedOffset) + const keyline = lerpKeyline(before, after, progress) + + let translation = keyline.offset - center + + if (before === after && keyline.size !== 0) { + translation += (center - keyline.unadjustedOffset) / keyline.size + } + + const inset = clamp((size - keyline.size) / 2, 0, size / 2) + const shift = state.rtl ? -translation : translation + const pin = pinned ? (state.rtl ? -inset : inset) : 0 + const opacity = item.labelWidth > 0 ? clamp((item.labelWidth - size + keyline.size) / item.labelWidth, 0, 1) : 1 + const written = `${inset.toFixed(2)}|${shift.toFixed(2)}|${pin.toFixed(2)}|${opacity.toFixed(3)}` + + if (written === item.written) { + return + } + + // On the surface, not the item: the item is the snap area, and nothing + // about it changes while the carousel scrolls. + item.written = written + item.surface.style.setProperty('--material-carousel-inset', `${inset.toFixed(2)}px`) + item.surface.style.setProperty('--material-carousel-shift', `${shift.toFixed(2)}px`) + item.surface.style.setProperty('--material-carousel-pin', `${pin.toFixed(2)}px`) + item.surface.style.setProperty('--material-carousel-label-shift', `${(state.rtl ? -inset : inset).toFixed(2)}px`) + item.surface.style.setProperty('--material-carousel-label', opacity.toFixed(3)) + }) + + if (this.$refs.previous) { + this.$refs.previous.disabled = scroll <= 1 + } + + if (this.$refs.next) { + this.$refs.next.disabled = scroll >= state.maxScroll - 1 + } + + state.mutations?.takeRecords() + }, + + /** The scroll offset from the start edge, positive in both directions. */ + scrollOffset() { + return clamp(Math.abs(this.$refs.scroller.scrollLeft), 0, state.maxScroll) + }, + + /** The item nearest the current scroll position. */ + current() { + const scroll = this.scrollOffset() + + return state.snaps.reduce((best, snap, index) => (Math.abs(snap - scroll) < Math.abs(state.snaps[best] - scroll) ? index : best), 0) + }, + + /** Where the carousel is headed: an unfinished scroll's target, or where it is. */ + heading() { + const moving = state.target !== null && performance.now() - state.targetAt < TARGET_MS + + return moving ? state.snaps[state.target] : this.scrollOffset() + }, + + next() { + const from = this.heading() + const index = state.snaps.findIndex((snap) => snap > from + 1) + + if (index >= 0) { + this.scrollToItem(index) + } + }, + + previous() { + const from = this.heading() + const index = state.snaps.findLastIndex((snap) => snap < from - 1) + + if (index >= 0) { + this.scrollToItem(index) + } + }, + + scrollToItem(index) { + if (!state.strategy?.valid || state.snaps[index] === undefined) { + return + } + + state.target = index + state.targetAt = performance.now() + + const left = state.snaps[index] + + this.$refs.scroller.scrollTo({ + left: state.rtl ? -left : left, + behavior: state.reducedMotion.matches ? 'instant' : 'smooth', + }) + }, + + /** The row's own keys, while the row itself has focus: a control inside an item keeps its keys. */ + navigate(event) { + if (event.target !== this.$refs.scroller || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return + } + + const forward = state.rtl ? 'ArrowLeft' : 'ArrowRight' + const backward = state.rtl ? 'ArrowRight' : 'ArrowLeft' + + const action = { + [forward]: () => this.next(), + [backward]: () => this.previous(), + Home: () => this.scrollToItem(0), + End: () => this.scrollToItem(state.items.length - 1), + }[event.key] + + if (action) { + event.preventDefault() + action() + } + }, + + /** Focus inside an item brings that item into focus, as Compose's bring-into-view does. */ + reveal(event) { + const element = event.target === this.$refs.scroller ? null : event.target.closest(ITEM) + const index = state.items.findIndex((item) => item.element === element) + + if (index >= 0 && this.isMasked(index)) { + this.scrollToItem(index) + } + }, + + /** A press on an item that is not fully open opens it, unless it pressed a control. */ + open(event) { + const element = event.target.closest(ITEM) + const index = state.items.findIndex((item) => item.element === element) + + const control = event.target.closest(INTERACTIVE) + + if (index >= 0 && this.isMasked(index) && !(control && element.contains(control))) { + this.scrollToItem(index) + } + }, + + isMasked(index) { + return parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5 + }, + + destroy() { + cancelAnimationFrame(state.frame) + state.listeners.forEach((remove) => remove()) + state.resizes?.disconnect() + state.mutations?.disconnect() + }, + } + }) +}) diff --git a/resources/js/material.js b/resources/js/material.js index 41224204..2803b98f 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -16,3 +16,4 @@ import './rich-tooltip.js' import './progress.js' import './list-rows.js' import './bottom-sheet.js' +import './carousel.js' diff --git a/resources/views/components/carousel-item.blade.php b/resources/views/components/carousel-item.blade.php new file mode 100644 index 00000000..81b2f05d --- /dev/null +++ b/resources/views/components/carousel-item.blade.php @@ -0,0 +1,50 @@ +{{-- One item of an ``: a slide of art — an ``, which fills and crops to the + item, or any element sized `size-full`. + + + Lake Constance at dusk + + + `label` lays a short line of text over the bottom of the art, on a scrim, pinned to the + item's visible edge and fading out as the item narrows — Compose's carousel sample, which + fades its label chip in once the mask is wide enough to hold it. + + A `group` with `aria-roledescription="slide"`, named "n of m" by the carousel around it + (WAI-ARIA's carousel pattern). 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`. Only inside ``. --}} + +@props([ + 'label' => null, +]) + +
class([ + 'relative h-full w-(--material-carousel-slot) max-w-full shrink-0 snap-start snap-always', + ]) + ->merge([ + 'role' => 'group', + 'aria-roledescription' => __('slide'), + 'aria-label' => '[material-carousel-position]', + 'data-material-carousel-item' => true, + ]) }}> + +
diff --git a/resources/views/components/carousel.blade.php b/resources/views/components/carousel.blade.php new file mode 100644 index 00000000..7fade74d --- /dev/null +++ b/resources/views/components/carousel.blade.php @@ -0,0 +1,159 @@ +{{-- M3 Expressive's carousel: a row of ``s that grow and shrink as they scroll. + + + @foreach ($photos as $photo) + + {{ $photo->alt }} + + @endforeach + + + `layout` is one of M3's four: + - `multi-browse` (the default): large items at the start, then a medium and a small one, for + browsing many — HorizontalMultiBrowseCarousel. `item-width` is the width large items + would like to be (186px, Compose's sample); the carousel adjusts it so a whole + arrangement fits, small items between 40 and 56px. + - `hero`: one large item and a small one after it, `centered` between two small ones — + HorizontalCenteredHeroCarousel and material-components-android's start-aligned hero. The + large item fills the width unless `item-width` caps it, and more large items fit when it + does. + - `uncontained`: items keep `item-width`; the one cut off at the end narrows as it leaves — + HorizontalUncontainedCarousel. No snapping, as Compose's uncontained fling. + - `full-screen`: one item the width of the carousel at a time + (FullScreenCarouselStrategy). + `item-width` takes pixels or any CSS length. `height` is the items' height (205px, Compose's + sample). `padding` is Compose's `contentPadding` in pixels (0): the first and last items + rest that far in from the edges while items in between scroll to them. Items are 8px apart + with M3's extra-large corner. + + The row is a native scroll container with CSS scroll snap, one item per swipe, as Compose's + single-advance fling; touch, trackpad and Shift with the wheel scroll it. resources/js/ + 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. + + WAI-ARIA's carousel pattern: the row is a focusable `region` with + `aria-roledescription="carousel"`, named by `label` ("Carousel" by default); each item is a + `group` with `aria-roledescription="slide"` named "n of m". With the row focused the arrow + keys move one item, Home and End to the ends; focus moving into an item, or a press on one + that is not fully open, brings it into focus. `controls` adds previous and next icon + buttons under the row — by default only where the pointer is fine (a mouse or trackpad); + `true` always, `false` never. Under reduced motion they scroll instantly and content no + longer slides inside its mask. RTL mirrors the keylines, keys and buttons. + + Re-measures itself when resized, when a Livewire morph resets its styles and when items + come and go. --}} + +@props([ + 'layout' => 'multi-browse', + 'itemWidth' => null, + 'height' => null, + 'padding' => 0, + 'centered' => false, + 'label' => null, + 'controls' => null, +]) + +@php + $layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'full-screen'], true) ? $layout : 'multi-browse'; + $controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL); + $label ??= __('Carousel'); + $scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)); + + $cssLength = fn (mixed $value): ?string => match (true) { + $value === null, $value === '' => null, + is_numeric($value) => ((float) $value).'px', + default => (string) $value, + }; + + // What the keylines are asked for, and the width items take before (or without) script. + $preferredWidth = match ($layout) { + 'multi-browse', 'uncontained' => $cssLength($itemWidth) ?? '186px', + 'hero' => $cssLength($itemWidth), + 'full-screen' => null, + }; + $slotWidth = match (true) { + $layout === 'full-screen' => '100%', + $preferredWidth === null => 'calc(100% - 64px)', + default => $preferredWidth, + }; + + // "n of m": every item rendered in the slot leaves a placeholder for its position. An inner + // carousel has already replaced its own by the time this one renders. + $slides = $slot->toHtml(); + $slideCount = substr_count($slides, '[material-carousel-position]'); + $slidePosition = 0; + $slides = preg_replace_callback( + '/\[material-carousel-position\]/', + function () use (&$slidePosition, $slideCount): string { + $slidePosition++; + + return e(__(':position of :count', ['position' => $slidePosition, 'count' => $slideCount])); + }, + $slides, + ); + + $attributes = $attributes + ->class('relative') + ->merge(array_filter([ + 'data-material-carousel' => $layout, + 'data-centered' => $layout === 'hero' && $centered ? true : null, + 'data-padding' => (string) max(0, (float) $padding), + 'style' => implode('; ', array_filter([ + $preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null, + "--material-carousel-slot: {$slotWidth}", + '--material-carousel-height: '.($cssLength($height) ?? '205px'), + ])), + ], fn ($value): bool => $value !== null)); +@endphp + +
+ @if ($preferredWidth) + + @endif + +
$layout !== 'uncontained', + ]) + > + {!! $slides !!} +
+ + @if ($controls !== false) +
$controls === null, + 'flex' => $controls === true, + ])> + + +
+ @endif +
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index 408c7080..d9a48eb4 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -17,5 +17,6 @@ @include('livewire-material::showcase.sections.communication') @include('livewire-material::showcase.sections.progress') @include('livewire-material::showcase.sections.containment') + @include('livewire-material::showcase.sections.carousel') @endsection diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index 85f36002..5918a015 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -18,7 +18,7 @@ Livewire Material diff --git a/resources/views/showcase/sections/carousel.blade.php b/resources/views/showcase/sections/carousel.blade.php new file mode 100644 index 00000000..fb8e6d6e --- /dev/null +++ b/resources/views/showcase/sections/carousel.blade.php @@ -0,0 +1,87 @@ +@php + $examples = [ + 'Multi-browse' => <<<'BLADE' + + @foreach ([ + ['Cookie', 'cookie-9', 'bg-primary-container text-on-primary-container'], + ['Clover', 'clover-4', 'bg-tertiary-container text-on-tertiary-container'], + ['Burst', 'soft-burst', 'bg-secondary-container text-on-secondary-container'], + ['Flower', 'flower', 'bg-primary text-on-primary'], + ['Gem', 'gem', 'bg-surface-container-highest text-primary'], + ['Puffy', 'puffy', 'bg-tertiary text-on-tertiary'], + ['Sunny', 'sunny', 'bg-secondary text-on-secondary'], + ['Heart', 'heart', 'bg-inverse-surface text-inverse-primary'], + ] as [$name, $shape, $colours]) + +
+ +
+
+ @endforeach +
+ BLADE, + 'Hero, centred' => <<<'BLADE' + + @foreach ([ + ['Arch', 'arch', 'bg-secondary-container text-on-secondary-container'], + ['Pentagon', 'pentagon', 'bg-primary-container text-on-primary-container'], + ['Boom', 'soft-boom', 'bg-tertiary-container text-on-tertiary-container'], + ['Bun', 'bun', 'bg-surface-container-highest text-secondary'], + ['Ghost', 'ghostish', 'bg-primary text-on-primary'], + ] as [$name, $shape, $colours]) + +
+ +
+
+ @endforeach +
+ BLADE, + 'Uncontained' => <<<'BLADE' + + @foreach ([ + ['cookie-6', 'bg-tertiary-container text-on-tertiary-container'], + ['diamond', 'bg-primary-container text-on-primary-container'], + ['clover-8', 'bg-secondary-container text-on-secondary-container'], + ['pixel-circle', 'bg-surface-container-highest text-tertiary'], + ['burst', 'bg-secondary text-on-secondary'], + ['oval', 'bg-primary text-on-primary'], + ] as [$shape, $colours]) + +
+ +
+
+ @endforeach +
+ BLADE, + 'Full-screen' => <<<'BLADE' + + @foreach ([ + ['Morning', 'very-sunny', 'bg-linear-to-br from-primary-container to-tertiary-container text-on-primary-container'], + ['Noon', 'clam-shell', 'bg-linear-to-br from-secondary-container to-primary-container text-on-secondary-container'], + ['Night', 'puffy-diamond', 'bg-linear-to-br from-inverse-surface to-primary text-inverse-primary'], + ] as [$name, $shape, $colours]) + +
+ +
+
+ @endforeach +
+ BLADE, + ]; +@endphp + + diff --git a/tests/Browser/CarouselTest.php b/tests/Browser/CarouselTest.php new file mode 100644 index 00000000..9a83893c --- /dev/null +++ b/tests/Browser/CarouselTest.php @@ -0,0 +1,228 @@ +count++; + } + + public function render(): string + { + return <<<'BLADE' +
+ + @foreach (range(1, $count) as $number) +
+ @endforeach +
+ +
+ BLADE; + } +} + +/** + * A script run against one carousel of the showcase (0 multi-browse, 1 hero, 2 uncontained, + * 3 full-screen), or of another page under `scope`, scrolled into view. `root`, `scroller` and + * `items` are in scope, with `surface(i)`, `inset(i)` (the mask on each side of item i), + * `snap(i)` (the scroll offset that brings item i into focus) and `at(i)`; the script's value is + * the result. Pest retries a failing assertion and gives each attempt a second: keep scripts well + * inside it. + */ +function onCarousel(int $index, string $body, string $scope = '#carousel'): string +{ + return << { + const root = document.querySelectorAll('{$scope} [x-data="materialCarousel"]')[{$index}] + const scroller = root.querySelector('[role="region"]') + const items = [...scroller.querySelectorAll('[data-material-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 inset = (i) => parseFloat(surface(i).style.getPropertyValue('--material-carousel-inset')) + const snap = (i) => Math.min(Math.max(i * (size + gap) - parseFloat(items[i].style.scrollMarginInlineStart), 0), scroller.scrollWidth - scroller.clientWidth) + const at = (i) => Math.abs(Math.abs(scroller.scrollLeft) - snap(i)) < 1.5 + 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 FIRST_SCROLLER = '#carousel [role="region"] >> nth=0'; + +function carouselShowcase(array $options = []) +{ + return visit('/material', $options) + ->waitForEvent('networkidle') + ->assertScript("typeof window.Alpine !== 'undefined'"); +} + +it('masks items by their place between the keylines, and changes the large one as it scrolls', function () { + $page = carouselShowcase() + ->assertNoJavaScriptErrors() + ->assertScript(onCarousel(0, <<<'JS' + return size > 0 && inset(0) < 0.5 && inset(items.length - 1) > 0.5 + && getComputedStyle(surface(0)).clipPath.startsWith('inset(') + JS)); + + $page->script(onCarousel(0, "scroller.style.scrollSnapType = 'none'; scroller.scrollTo({ left: 2 * (size + gap), behavior: 'instant' })")); + + $page->assertScript(onCarousel(0, <<<'JS' + await pause(50) + return inset(0) > 0.5 && inset(2) < 0.5 && surface(0).style.getPropertyValue('--material-carousel-shift') !== '0.00px' + JS)); + + // Half-way between keylines, an item is part-way between two sizes. + $page->script(onCarousel(0, "scroller.scrollTo({ left: 2.5 * (size + gap), behavior: 'instant' })")); + + $page->assertScript(onCarousel(0, <<<'JS' + await pause(50) + return inset(2) > 0.5 && inset(2) < size / 2 - 1 + JS)); +}); + +it('moves one item with the next and previous buttons', function () { + $page = carouselShowcase() + ->assertScript(onCarousel(0, 'return at(0) && root.querySelector(\'[aria-label="Previous"]\').disabled')); + + $page->click('#carousel button[aria-label="Next"] >> nth=0') + ->assertScript(onCarousel(0, 'return at(1) && inset(1) < 0.5 && !root.querySelector(\'[aria-label="Previous"]\').disabled')); + + $page->click('#carousel button[aria-label="Next"] >> nth=0') + ->assertScript(onCarousel(0, 'return at(2)')); + + $page->click('#carousel button[aria-label="Previous"] >> nth=0') + ->assertScript(onCarousel(0, 'return at(1)')); +}); + +it('moves one item with the arrow keys, and to the ends with Home and End', function () { + $page = carouselShowcase(); + + $page->keys(FIRST_SCROLLER, 'ArrowRight') + ->assertScript(onCarousel(0, 'return at(1)')); + + $page->keys(FIRST_SCROLLER, 'ArrowRight') + ->assertScript(onCarousel(0, 'return at(2)')); + + $page->keys(FIRST_SCROLLER, 'ArrowLeft') + ->assertScript(onCarousel(0, 'return at(1)')); + + $page->keys(FIRST_SCROLLER, 'End') + ->assertScript(onCarousel(0, 'return Math.abs(scroller.scrollLeft - (scroller.scrollWidth - scroller.clientWidth)) < 1.5 && inset(items.length - 1) < 0.5 && root.querySelector(\'[aria-label="Next"]\').disabled')); + + $page->keys(FIRST_SCROLLER, 'Home') + ->assertScript(onCarousel(0, 'return at(0)')); +}); + +it('snaps a scroll that stops between items onto an item', function () { + $page = carouselShowcase(); + + $page->script(onCarousel(0, "scroller.scrollTo({ left: 0.7 * (size + gap), behavior: 'instant' })")); + + $page->assertScript(onCarousel(0, <<<'JS' + await pause(100) + return items.some((_, i) => at(i)) && scroller.scrollLeft > 0 + JS)); +}); + +it('brings a partly hidden item into focus when it is pressed', function () { + $page = carouselShowcase() + ->assertScript(onCarousel(0, 'return inset(4) > 0.5')); + + $page->script(onCarousel(0, 'items[4].querySelector(\'[data-material-carousel-content]\').click()')); + + $page->assertScript(onCarousel(0, 'return inset(4) < 0.5 && scroller.scrollLeft > 0')); +}); + +it('scrolls instantly and keeps content pinned to its mask under reduced motion', function () { + carouselShowcase(['reducedMotion' => 'reduce']) + ->assertScript(onCarousel(0, <<<'JS' + root.querySelector('[aria-label="Next"]').click() + const arrived = at(1) + await pause(50) + const last = items.length - 1 + return arrived && inset(last) > 0.5 + && surface(last).style.getPropertyValue('--material-carousel-pin') === surface(last).style.getPropertyValue('--material-carousel-inset') + JS)); +}); + +it('mirrors in a right-to-left page', function () { + Route::middleware('web')->get('/carousel-rtl-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + +
+ + @foreach (range(1, 6) as $number) +
+ @endforeach +
+
+ @livewireScripts + + + BLADE)); + + $page = visit('/carousel-rtl-probe')->waitForEvent('networkidle') + ->assertNoJavaScriptErrors() + ->assertScript(onCarousel(0, 'return size > 0 && at(0) && inset(0) < 0.5 && inset(items.length - 1) > 0.5', 'body')); + + $page->keys('[role="region"]', 'ArrowLeft') + ->assertScript(onCarousel(0, <<<'JS' + return scroller.scrollLeft < -1 && at(1) && inset(0) > 0.5 && inset(1) < 0.5 + && parseFloat(surface(0).style.getPropertyValue('--material-carousel-shift')) < 0 + JS, 'body')); + + $page->click('button[aria-label="Next"]') + ->assertScript(onCarousel(0, 'return at(2)', 'body')); + + $page->keys('[role="region"]', 'ArrowRight') + ->assertScript(onCarousel(0, 'return at(1)', 'body')); +}); + +it('measures itself again after a Livewire morph adds an item', function () { + Livewire::component('carousel-morph-probe', CarouselMorphProbe::class); + + Route::middleware('web')->get('/carousel-morph-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + 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 })()"; + + $page = visit('/carousel-morph-probe')->waitForEvent('networkidle') + ->assertNoJavaScriptErrors() + ->assertScript($masked, 3) + ->assertAttribute('[data-material-carousel-item] >> nth=2', 'aria-label', '3 of 3'); + + $page->click('Add') + ->assertScript($masked, 4) + ->assertAttribute('[data-material-carousel-item] >> nth=3', 'aria-label', '4 of 4'); +}); diff --git a/tests/Feature/Components/CarouselTest.php b/tests/Feature/Components/CarouselTest.php new file mode 100644 index 00000000..47f48567 --- /dev/null +++ b/tests/Feature/Components/CarouselTest.php @@ -0,0 +1,143 @@ +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('tabindex="0"') + ->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); +}); + +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="0"') + ->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="16"') + ->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 a full-screen one a page wide', 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"') + ->toContain('--material-carousel-slot: 100%') + ->toContain('snap-mandatory') + ->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'); +});