tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
CI's WebKit twice left the carousel on its first item after Next. A scroll started by the buttons or keys that comes to rest anywhere but its item is now sent there once more; a scroll the person starts themselves cancels that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
1123 lines
45 KiB
JavaScript
1123 lines
45 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 the content stays pinned to
|
|
* the mask's leading edge instead of sliding inside it: the frame still opens and closes with
|
|
* the scroll the user makes, but nothing moves on its own.
|
|
*
|
|
* ---------------------------------------------------------------------------------------
|
|
* Keyline maths ported from androidx (https://github.com/androidx/androidx), commit
|
|
* 7ac433e44e797de53af85226797862687f37735f:
|
|
*
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Arrangement.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineList.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
|
|
* (carouselItem, calculateMaxScrollOffset, CarouselDefaults)
|
|
*
|
|
* The full-screen arrangement follows material-components-android's
|
|
* FullScreenCarouselStrategy (one large item the size of the container).
|
|
*
|
|
* Copyright 2023-2024 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
* ---------------------------------------------------------------------------------------
|
|
*/
|
|
|
|
// CarouselDefaults (Carousel.kt), in CSS pixels for dp. ------------------------------------
|
|
|
|
const MIN_SMALL_ITEM_SIZE = 40
|
|
const MAX_SMALL_ITEM_SIZE = 56
|
|
const ANCHOR_SIZE = 10
|
|
const MEDIUM_LARGE_ITEM_DIFF_THRESHOLD = 0.85
|
|
const MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1
|
|
|
|
/** A programmatic scroll still counts as where the carousel is going for this long. */
|
|
const TARGET_MS = 700
|
|
|
|
// 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]'),
|
|
content: element.querySelector('[data-material-carousel-content]'),
|
|
label: element.querySelector('[data-material-carousel-label]'),
|
|
labelWidth: 0,
|
|
written: '',
|
|
}))
|
|
|
|
const count = state.items.length
|
|
const keylines = {
|
|
hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.centered !== undefined),
|
|
uncontained: () => uncontainedKeylineList(space, preferred ?? 0, itemSpacing),
|
|
'full-screen': () => fullScreenKeylineList(space, itemSpacing),
|
|
}[root.dataset.materialCarousel] ?? (() => multiBrowseKeylineList(space, preferred ?? 0, itemSpacing, count))
|
|
|
|
const strategy = count === 0 ? createStrategy(EMPTY, space, itemSpacing, 0, 0) : createStrategy(keylines(), space, itemSpacing, padding, padding)
|
|
|
|
state.strategy = strategy
|
|
|
|
if (!strategy.valid) {
|
|
root.style.removeProperty('--material-carousel-slot')
|
|
state.items.forEach(({ element }) => element.style.removeProperty('scroll-margin-inline-start'))
|
|
state.mutations?.takeRecords()
|
|
|
|
return
|
|
}
|
|
|
|
const size = strategy.itemSize
|
|
|
|
root.style.setProperty('--material-carousel-slot', `${size}px`)
|
|
|
|
state.maxScroll = Math.max(0, size * count + itemSpacing * (count - 1) - space)
|
|
state.snaps = state.items.map(({ element }, index) => {
|
|
const offset = snapPositionOffset(strategy, index, count)
|
|
|
|
element.style.setProperty('scroll-margin-inline-start', `${offset}px`)
|
|
|
|
return clamp(index * (size + itemSpacing) - offset, 0, state.maxScroll)
|
|
})
|
|
|
|
// Measured at the full size, before any mask: how much room the label needs.
|
|
state.items.forEach((item) => {
|
|
item.labelWidth = item.label?.firstElementChild ? item.label.firstElementChild.getBoundingClientRect().width + 32 : 0
|
|
})
|
|
|
|
this.render()
|
|
},
|
|
|
|
schedule() {
|
|
state.frame ??= requestAnimationFrame(() => this.render())
|
|
},
|
|
|
|
/**
|
|
* 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
|
|
const pinned = state.reducedMotion.matches
|
|
|
|
state.items.forEach((item, index) => {
|
|
const center = index * (size + strategy.itemSpacing) + size / 2 - scroll
|
|
const before = keylineBefore(keylines, center)
|
|
const after = keylineAfter(keylines, center)
|
|
const progress = before === after ? 1 : (center - before.unadjustedOffset) / (after.unadjustedOffset - before.unadjustedOffset)
|
|
const keyline = lerpKeyline(before, after, progress)
|
|
|
|
let translation = keyline.offset - center
|
|
|
|
if (before === after && keyline.size !== 0) {
|
|
translation += (center - keyline.unadjustedOffset) / keyline.size
|
|
}
|
|
|
|
const inset = clamp((size - keyline.size) / 2, 0, size / 2)
|
|
const shift = state.rtl ? -translation : translation
|
|
const pin = pinned ? (state.rtl ? -inset : inset) : 0
|
|
const opacity = item.labelWidth > 0 ? clamp((item.labelWidth - size + keyline.size) / item.labelWidth, 0, 1) : 1
|
|
const written = `${inset.toFixed(2)}|${shift.toFixed(2)}|${pin.toFixed(2)}|${opacity.toFixed(3)}`
|
|
|
|
if (written === item.written) {
|
|
return
|
|
}
|
|
|
|
// On the surface, not the item: the item is the snap area, and nothing
|
|
// about it changes while the carousel scrolls.
|
|
item.written = written
|
|
item.surface.style.setProperty('--material-carousel-inset', `${inset.toFixed(2)}px`)
|
|
item.surface.style.setProperty('--material-carousel-shift', `${shift.toFixed(2)}px`)
|
|
item.surface.style.setProperty('--material-carousel-pin', `${pin.toFixed(2)}px`)
|
|
item.surface.style.setProperty('--material-carousel-label-shift', `${(state.rtl ? -inset : inset).toFixed(2)}px`)
|
|
item.surface.style.setProperty('--material-carousel-label', opacity.toFixed(3))
|
|
})
|
|
|
|
if (this.$refs.previous) {
|
|
this.$refs.previous.disabled = scroll <= 1
|
|
}
|
|
|
|
if (this.$refs.next) {
|
|
this.$refs.next.disabled = scroll >= state.maxScroll - 1
|
|
}
|
|
|
|
state.mutations?.takeRecords()
|
|
},
|
|
|
|
/** The scroll offset from the start edge, positive in both directions. */
|
|
scrollOffset() {
|
|
return clamp(Math.abs(this.$refs.scroller.scrollLeft), 0, state.maxScroll)
|
|
},
|
|
|
|
/** The item nearest the current scroll position. */
|
|
current() {
|
|
const scroll = this.scrollOffset()
|
|
|
|
return state.snaps.reduce((best, snap, index) => (Math.abs(snap - scroll) < Math.abs(state.snaps[best] - scroll) ? index : best), 0)
|
|
},
|
|
|
|
/** Where the carousel is headed: an unfinished scroll's target, or where it is. */
|
|
heading() {
|
|
const moving = state.target !== null && performance.now() - state.targetAt < TARGET_MS
|
|
|
|
return moving ? state.snaps[state.target] : this.scrollOffset()
|
|
},
|
|
|
|
next() {
|
|
const from = this.heading()
|
|
const index = state.snaps.findIndex((snap) => snap > from + 1)
|
|
|
|
if (index >= 0) {
|
|
this.scrollToItem(index)
|
|
}
|
|
},
|
|
|
|
previous() {
|
|
const from = this.heading()
|
|
const index = state.snaps.findLastIndex((snap) => snap < from - 1)
|
|
|
|
if (index >= 0) {
|
|
this.scrollToItem(index)
|
|
}
|
|
},
|
|
|
|
scrollToItem(index) {
|
|
if (!state.strategy?.valid || state.snaps[index] === undefined) {
|
|
return
|
|
}
|
|
|
|
state.target = index
|
|
state.targetAt = performance.now()
|
|
|
|
const left = state.snaps[index]
|
|
|
|
this.$refs.scroller.scrollTo({
|
|
left: state.rtl ? -left : left,
|
|
behavior: state.reducedMotion.matches ? 'instant' : 'smooth',
|
|
})
|
|
},
|
|
|
|
/** The row's own keys, while the row itself has focus: a control inside an item keeps its keys. */
|
|
navigate(event) {
|
|
if (event.target !== this.$refs.scroller || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
|
return
|
|
}
|
|
|
|
const forward = state.rtl ? 'ArrowLeft' : 'ArrowRight'
|
|
const backward = state.rtl ? 'ArrowRight' : 'ArrowLeft'
|
|
|
|
const action = {
|
|
[forward]: () => this.next(),
|
|
[backward]: () => this.previous(),
|
|
Home: () => this.scrollToItem(0),
|
|
End: () => this.scrollToItem(state.items.length - 1),
|
|
}[event.key]
|
|
|
|
if (action) {
|
|
event.preventDefault()
|
|
action()
|
|
}
|
|
},
|
|
|
|
/** Focus inside an item brings that item into focus, as Compose's bring-into-view does. */
|
|
reveal(event) {
|
|
const element = event.target === this.$refs.scroller ? null : event.target.closest(ITEM)
|
|
const index = state.items.findIndex((item) => item.element === element)
|
|
|
|
if (index >= 0 && this.isMasked(index)) {
|
|
this.scrollToItem(index)
|
|
}
|
|
},
|
|
|
|
/** A press on an item that is not fully open opens it, unless it pressed a control. */
|
|
open(event) {
|
|
const element = event.target.closest(ITEM)
|
|
const index = state.items.findIndex((item) => item.element === element)
|
|
|
|
const control = event.target.closest(INTERACTIVE)
|
|
|
|
if (index >= 0 && this.isMasked(index) && !(control && element.contains(control))) {
|
|
this.scrollToItem(index)
|
|
}
|
|
},
|
|
|
|
isMasked(index) {
|
|
return parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
|
|
},
|
|
|
|
destroy() {
|
|
cancelAnimationFrame(state.frame)
|
|
clearTimeout(state.settle)
|
|
state.listeners.forEach((remove) => remove())
|
|
state.resizes?.disconnect()
|
|
state.mutations?.disconnect()
|
|
},
|
|
}
|
|
})
|
|
})
|