Files
livewire-material/resources/js/carousel.js
T
Andreas Reinhold / reiniandClaude Opus 5 f895b553b6 Leave carousel items unmasked under reduced motion
Plan step 12, containment.md C-05. Only the parallax half of M3's rule
was honoured: the mask was still written every frame, so items kept
growing and shrinking between the keylines. Under reduced motion the
inset, the shift and the label fade are now all zero, so every item
stays at the strategy's large size and the keylines only decide where
the row snaps. `--material-carousel-pin` went with it: with no mask
there is nothing to pin the content to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:59:15 +02:00

1127 lines
46 KiB
JavaScript

/**
* `materialCarousel`: the behaviour of `<x-carousel>` — 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 nothing is masked at all: M3
* says the parallax goes and items "should no longer expand as they come into view — all items
* are the same size", so every item stays at the strategy's large size and the keylines only
* decide where the row snaps.
*
* ---------------------------------------------------------------------------------------
* 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
// 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 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,
settle: null,
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.scrolled(), { passive: true })
// A scroll the person makes themselves is theirs to end wherever it ends.
for (const type of ['pointerdown', 'wheel', 'touchstart']) {
this.listen(scroller, type, () => (state.target = null), { 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]'),
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())
},
/**
* Each scroll frame, and once the row comes to rest: a scroll the buttons or keys started
* must end on its item. WebKit on Linux can re-snap a smooth scroll to the item it left
* when the masks change the layout under it, so an arrival somewhere else is sent on
* again, once, at once.
*/
scrolled() {
this.schedule()
clearTimeout(state.settle)
state.settle = setTimeout(() => {
const target = state.target
if (target === null || performance.now() - state.targetAt > TARGET_MS * 3) {
return
}
state.target = null
const left = state.snaps[target]
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
}
}, SETTLE_MS)
},
/** 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
// M3: "When reduced motion settings are turned on, the parallax effect should be
// removed and carousel items should no longer expand as they come into view. All
// items are the same size" (docs/reference/m3/styles.md § Motion → Accessibility
// requirements). The keylines still decide where the row snaps; nothing is masked,
// moved or faded, so every item stays at strategy.itemSize.
const still = 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 = still ? 0 : clamp((size - keyline.size) / 2, 0, size / 2)
const shift = still ? 0 : state.rtl ? -translation : translation
const opacity = still || item.labelWidth === 0 ? 1 : clamp((item.labelWidth - size + keyline.size) / item.labelWidth, 0, 1)
const written = `${inset.toFixed(2)}|${shift.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-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)
clearTimeout(state.settle)
state.listeners.forEach((remove) => remove())
state.resizes?.disconnect()
state.mutations?.disconnect()
},
}
})
})