/** * `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 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 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-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) } // 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.materialCarousel === 'full-screen' state.measured = root.dataset.materialCarousel === 'multi-aspect' state.items = [...scroller.children] .filter((element) => element.matches(ITEM)) .map((element) => ({ element, surface: element.querySelector('[data-material-carousel-surface]'), label: element.querySelector('[data-material-carousel-label]'), 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.padding) || 0 const paddingEnd = Number(root.dataset.paddingEnd) || 0 const probe = this.$refs.probe const preferred = probe ? probe.getBoundingClientRect().width : null const count = state.items.length const keylines = { hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.centered !== undefined), uncontained: () => uncontainedKeylineList(space, preferred ?? 0, 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, 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, so "not fully open" is "cut off by the row". if (state.measured) { 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() }, } }) })