Files
Andreas Reinhold / reiniandClaude Opus 5 cab8d804a5 Reveal a cut-off carousel item under reduced motion
Plan step 36 review of a149970d. Reduced motion writes a zero inset for
every item, so isMasked() never called one partly shown and a press on
an item the row cut off did nothing. It now reads the item's box against
the scrollport there, as it already did for multi-aspect, and the
browser test the rewrite added to document the gap asserts the reveal
instead (plan step 32's owed test).

A focused item's inset ring is 3px in again: Tailwind's
-outline-offset-3 is 3px, not the 12px the rewrite wrote.

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

1245 lines
51 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. An item is then "not fully open" when the row cuts it off, so a
* press or focus on it still brings it into view.
*
* ---------------------------------------------------------------------------------------
* 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 layout uses none of it: M3 gives it one edge-to-edge item at a time, scrolled
* vertically, so the browser's own scroll snap is the whole of it and this script only works out
* where each item comes to rest, for the buttons and the keys.
*
* The uncontained multi-aspect-ratio layout uses none of it either, and for a reason that is in
* the maths: an Arrangement counts large, medium and small items of one size each, and every
* keyline, snap position and mask follows from that one size, so items that each keep their own
* aspect ratio have no arrangement to fit. That layout is a plain flex row the browser scrolls,
* every item laid out at its own ratio and none of them masked; the resting positions the buttons,
* the keys and bring-into-view need are measured off the DOM instead.
*
* 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-md-carousel-item]'
const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])'
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
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)
}
// 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,
focalInset: 0,
rtl: false,
vertical: false,
measured: 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 style = getComputedStyle(scroller)
state.rtl = style.direction === 'rtl'
state.vertical = root.dataset.mdCarousel === 'full-screen'
state.measured = root.dataset.mdCarousel === 'multi-aspect'
state.items = [...scroller.children]
.filter((element) => element.matches(ITEM))
.map((element) => ({
element,
surface: element.querySelector('[data-md-carousel-surface]'),
label: element.querySelector('[data-md-carousel-label]'),
labelWidth: 0,
written: '',
}))
// M3's full-screen layout is one edge-to-edge item at a time, scrolled
// vertically: no keylines, no mask, no end padding — the browser's scroll snap
// does the whole of it, and this only works out where each item comes to rest.
if (state.vertical) {
const space = scroller.clientHeight
const spacing = parseFloat(style.rowGap) || 0
state.strategy = null
state.maxScroll = Math.max(0, (space + spacing) * state.items.length - spacing - space)
state.snaps = state.items.map((_, index) => clamp(index * (space + spacing), 0, state.maxScroll))
this.render()
return
}
// The uncontained multi-aspect-ratio layout: no strategy, no mask, and each item
// resting where its own width puts it. `scrollLeft` is negative in RTL, so an
// offset is taken from whichever edge the row starts at.
if (state.measured) {
const width = scroller.clientWidth
const leading = parseFloat(style.paddingInlineStart) || 0
const box = scroller.getBoundingClientRect()
const scroll = Math.abs(scroller.scrollLeft)
state.strategy = null
state.maxScroll = Math.max(0, scroller.scrollWidth - width)
state.snaps = state.items.map(({ element }) => {
const item = element.getBoundingClientRect()
return clamp(scroll + (state.rtl ? box.right - item.right : item.left - box.left) - leading, 0, state.maxScroll)
})
this.render()
return
}
const space = scroller.clientWidth
const itemSpacing = parseFloat(style.columnGap) || 0
const padding = Number(root.dataset.mdPadding) || 0
const paddingEnd = Number(root.dataset.mdPaddingEnd) || 0
const probe = this.$refs.probe
const preferred = probe ? probe.getBoundingClientRect().width : null
const count = state.items.length
const keylines = {
hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.mdCentered !== undefined),
uncontained: () => uncontainedKeylineList(space, preferred ?? 0, itemSpacing),
}[root.dataset.mdCarousel] ?? (() => multiBrowseKeylineList(space, preferred ?? 0, itemSpacing, count))
const strategy = count === 0 ? createStrategy(EMPTY, space, itemSpacing, 0, 0) : createStrategy(keylines(), space, itemSpacing, padding, paddingEnd)
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 offset = state.snaps[target]
if (offset !== undefined && Math.abs(this.scrollOffset() - offset) > 1) {
this.scrollTo(offset, 'instant')
}
}, SETTLE_MS)
},
/** Carousel.kt's carouselItem layer block, for every item. */
render() {
cancelAnimationFrame(state.frame)
state.frame = null
// Nothing is masked in the full-screen or multi-aspect layouts; only the buttons
// change.
if (state.vertical || state.measured) {
this.buttons()
state.mutations?.takeRecords()
return
}
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
// Content padding shrinks every keyline near the ends (Strategy.kt's
// createShiftedKeylineListForContentPadding), so an item at the focal size can still
// carry a small inset there: that inset, not zero, is "fully open" for isMasked().
state.focalInset = still ? 0 : clamp((size - (firstFocal(keylines)?.size ?? size)) / 2, 0, size / 2)
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))
})
this.buttons()
state.mutations?.takeRecords()
},
/** A control that would scroll past an end is off. */
buttons() {
const scroll = this.scrollOffset()
if (this.$refs.previous) {
this.$refs.previous.disabled = scroll <= 1
}
if (this.$refs.next) {
this.$refs.next.disabled = scroll >= state.maxScroll - 1
}
},
/** The scroll offset from the start edge, positive in every direction. */
scrollOffset() {
const scroller = this.$refs.scroller
return clamp(state.vertical ? scroller.scrollTop : Math.abs(scroller.scrollLeft), 0, state.maxScroll)
},
/** Scrolls to an offset on whichever axis this carousel runs along. */
scrollTo(offset, behavior) {
this.$refs.scroller.scrollTo({
...(state.vertical ? { top: offset } : { left: state.rtl ? -offset : offset }),
behavior,
})
},
/** 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.snaps[index] === undefined || (!state.vertical && !state.measured && !state.strategy?.valid)) {
return
}
state.target = index
state.targetAt = performance.now()
this.scrollTo(state.snaps[index], state.reducedMotion.matches ? 'instant' : 'smooth')
},
/**
* The items' keys, while an item itself has focus — M3: "Tab or Arrows moves to the
* previous or next carousel item; Space or Enter activates the focused carousel item".
* A control inside an item keeps its own keys, because focus is then on the control
* and not on the item.
*/
navigate(event) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return
}
const index = state.items.findIndex((item) => item.element === event.target)
if (index < 0) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
if (this.isMasked(index)) {
event.preventDefault()
this.scrollToItem(index)
}
return
}
const forward = state.vertical ? 'ArrowDown' : state.rtl ? 'ArrowLeft' : 'ArrowRight'
const backward = state.vertical ? 'ArrowUp' : state.rtl ? 'ArrowRight' : 'ArrowLeft'
const to = {
[forward]: index + 1,
[backward]: index - 1,
Home: 0,
End: state.items.length - 1,
}[event.key]
const target = to === undefined ? undefined : state.items[to]
if (!target) {
return
}
// The browser would jump the row to the newly focused item; the smooth scroll to
// its snap position is this script's.
event.preventDefault()
target.element.focus({ preventScroll: true })
this.scrollToItem(to)
},
/** Focus inside an item brings that item into focus, as Compose's bring-into-view does. */
reveal(event) {
const element = 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)
// The item is a tab stop itself, so it matches INTERACTIVE: only a control inside it counts.
const control = event.target.closest(INTERACTIVE)
if (index >= 0 && this.isMasked(index) && !(control && control !== element && element.contains(control))) {
this.scrollToItem(index)
}
},
/** Whether item `index` is not fully in focus, so a press or focus should bring it there. */
isMasked(index) {
if (state.vertical) {
return Math.abs(state.snaps[index] - this.scrollOffset()) > 1
}
// Nothing masks a multi-aspect item, nor any item under reduced motion (render()
// writes a zero inset for every one), so there "not fully open" is "cut off by the
// row": its box reaching past the scrollport's edge.
if (state.measured || state.reducedMotion.matches) {
const row = this.$refs.scroller.getBoundingClientRect()
const item = state.items[index].element.getBoundingClientRect()
return item.left < row.left - 1 || item.right > row.right + 1
}
return parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > state.focalInset + 0.5
},
destroy() {
cancelAnimationFrame(state.frame)
clearTimeout(state.settle)
state.listeners.forEach((remove) => remove())
state.resizes?.disconnect()
state.mutations?.disconnect()
},
}
})
})