Plan step 19, containment.md C-18. The row was the focusable `region` and the items were not focusable at all, which is the thing M3's accessibility page draws a Don't for: "use Tab to place initial focus on the first carousel item", "avoid focusing on the carousel container". Each item is now `tabindex="0"` with the focus ring drawn inside it, the row is out of the tab order, and from a focused item the arrows move one item (moving focus with them), Home and End go to the ends, and Space or Enter opens one that is not fully in view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
1155 lines
46 KiB
JavaScript
1155 lines
46 KiB
JavaScript
/**
|
|
* `materialCarousel`: the behaviour of `<x-carousel>` — M3 Expressive's keyline carousel on a
|
|
* native scroll container.
|
|
*
|
|
* The browser scrolls: touch, trackpad, Shift and the wheel, the scrollbar's keys, and CSS
|
|
* scroll snap (one item per swipe, as Compose's single-advance fling). Every item is laid out
|
|
* at the large size, end to end, exactly as Compose's Pager lays them out; this script then
|
|
* does what Compose's `Modifier.carouselItem` does in its layer block. On each scroll frame it
|
|
* asks the ported Strategy for the keylines at that scroll offset, finds the two keylines
|
|
* around each item's centre, and interpolates between them: the item is masked to the
|
|
* interpolated size (a `clip-path` inset from both sides, rounded 28px) and shifted by the
|
|
* interpolated offset, so large items shrink to medium and small and out past an anchor while
|
|
* their content keeps its full size — the parallax of M3's carousel.
|
|
*
|
|
* Snap positions are Compose's KeylineSnapPosition, written as each item's
|
|
* `scroll-margin-inline-start`, so the first and last items can come fully into focus. The
|
|
* arrow keys, Home, End and the previous/next buttons scroll from one of those positions to
|
|
* the next; so does a press on an item that is not fully open, and focus moving into one.
|
|
*
|
|
* Nothing here is saved in the DOM beyond inline styles, which a Livewire morph (or any other
|
|
* patcher) resets to what the server rendered. A MutationObserver sees that — and items added
|
|
* or removed — and measures again; it ignores this script's own writes by taking their
|
|
* records. A ResizeObserver does the same for a new width. RTL is read when measuring:
|
|
* scroll offsets are negative there and shifts mirror, as Compose's `translationX` does.
|
|
*
|
|
* Under reduced motion the buttons and keys scroll instantly and nothing is masked at all: M3
|
|
* says the parallax goes and items "should no longer expand as they come into view — all items
|
|
* are the same size", so every item stays at the strategy's large size and the keylines only
|
|
* decide where the row snaps.
|
|
*
|
|
* ---------------------------------------------------------------------------------------
|
|
* Keyline maths ported from androidx (https://github.com/androidx/androidx), commit
|
|
* 7ac433e44e797de53af85226797862687f37735f:
|
|
*
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Arrangement.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Keylines.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineList.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Strategy.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/KeylineSnapPosition.kt
|
|
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
|
|
* (carouselItem, calculateMaxScrollOffset, CarouselDefaults)
|
|
*
|
|
* The full-screen arrangement follows material-components-android's
|
|
* FullScreenCarouselStrategy (one large item the size of the container).
|
|
*
|
|
* Copyright 2023-2024 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
* ---------------------------------------------------------------------------------------
|
|
*/
|
|
|
|
// CarouselDefaults (Carousel.kt), in CSS pixels for dp. ------------------------------------
|
|
|
|
const MIN_SMALL_ITEM_SIZE = 40
|
|
const MAX_SMALL_ITEM_SIZE = 56
|
|
const ANCHOR_SIZE = 10
|
|
const MEDIUM_LARGE_ITEM_DIFF_THRESHOLD = 0.85
|
|
const MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1
|
|
|
|
/** A programmatic scroll still counts as where the carousel is going for this long. */
|
|
const TARGET_MS = 700
|
|
|
|
// How long the row must go without a scroll event to count as having come to rest.
|
|
const SETTLE_MS = 150
|
|
|
|
const ITEM = '[data-material-carousel-item]'
|
|
const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])'
|
|
|
|
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
|
const lerp = (start, stop, fraction) => start + (stop - start) * fraction
|
|
|
|
/** Strategy.kt's `lerp(outputMin, outputMax, inputMin, inputMax, value)`. */
|
|
const lerpRange = (outputMin, outputMax, inputMin, inputMax, value) => {
|
|
if (value <= inputMin) {
|
|
return outputMin
|
|
}
|
|
|
|
if (value >= inputMax) {
|
|
return outputMax
|
|
}
|
|
|
|
return lerp(outputMin, outputMax, (value - inputMin) / (inputMax - inputMin))
|
|
}
|
|
|
|
// Arrangement.kt ---------------------------------------------------------------------------
|
|
|
|
const arrangementCost = (arrangement, targetLargeSize) => {
|
|
const { smallSize, smallCount, mediumSize, mediumCount, largeSize, largeCount } = arrangement
|
|
|
|
const valid =
|
|
largeCount > 0 && smallCount > 0 && mediumCount > 0
|
|
? largeSize > mediumSize && mediumSize > smallSize
|
|
: largeCount > 0 && smallCount > 0
|
|
? largeSize > smallSize
|
|
: true
|
|
|
|
return valid ? Math.abs(targetLargeSize - largeSize) * arrangement.priority : Number.MAX_VALUE
|
|
}
|
|
|
|
const arrangementItemCount = (arrangement) => arrangement.largeCount + arrangement.mediumCount + arrangement.smallCount
|
|
|
|
const fitArrangement = (priority, space, itemSpacing, smallCount, smallSize, minSmallSize, maxSmallSize, mediumCount, mediumSize, largeCount, largeSize) => {
|
|
const totalItemCount = largeCount + mediumCount + smallCount
|
|
const spaceWithoutSpacing = space - (totalItemCount - 1) * itemSpacing
|
|
let arrangedSmallSize = clamp(smallSize, minSmallSize, maxSmallSize)
|
|
let arrangedMediumSize = mediumSize
|
|
let arrangedLargeSize = largeSize
|
|
|
|
const delta = spaceWithoutSpacing - (arrangedLargeSize * largeCount + arrangedMediumSize * mediumCount + arrangedSmallSize * smallCount)
|
|
|
|
// Small items give way first, within their range.
|
|
if (smallCount > 0 && delta > 0) {
|
|
arrangedSmallSize += Math.min(delta / smallCount, maxSmallSize - arrangedSmallSize)
|
|
} else if (smallCount > 0 && delta < 0) {
|
|
arrangedSmallSize += Math.max(delta / smallCount, minSmallSize - arrangedSmallSize)
|
|
}
|
|
|
|
arrangedSmallSize = smallCount > 0 ? arrangedSmallSize : 0
|
|
|
|
// Then the large size that fits, with medium items halfway between large and small.
|
|
arrangedLargeSize = (spaceWithoutSpacing - (smallCount + mediumCount / 2) * arrangedSmallSize) / (largeCount + mediumCount / 2)
|
|
arrangedMediumSize = (arrangedLargeSize + arrangedSmallSize) / 2
|
|
|
|
// A medium item flexes by up to 10% to bring the large size back towards its target.
|
|
if (mediumCount > 0 && arrangedLargeSize !== largeSize) {
|
|
const targetAdjustment = (largeSize - arrangedLargeSize) * largeCount
|
|
const availableMediumFlex = arrangedMediumSize * MEDIUM_ITEM_FLEX_PERCENTAGE * mediumCount
|
|
const distribute = Math.min(Math.abs(targetAdjustment), availableMediumFlex)
|
|
|
|
if (targetAdjustment > 0) {
|
|
arrangedMediumSize -= distribute / mediumCount
|
|
arrangedLargeSize += distribute / largeCount
|
|
} else {
|
|
arrangedMediumSize += distribute / mediumCount
|
|
arrangedLargeSize -= distribute / largeCount
|
|
}
|
|
}
|
|
|
|
return {
|
|
priority,
|
|
smallSize: arrangedSmallSize,
|
|
smallCount,
|
|
mediumSize: arrangedMediumSize,
|
|
mediumCount,
|
|
largeSize: arrangedLargeSize,
|
|
largeCount,
|
|
}
|
|
}
|
|
|
|
const findLowestCostArrangement = ({ space, itemSpacing, targetSmallSize, minSmallSize, maxSmallSize, smallCounts, targetMediumSize, mediumCounts, targetLargeSize, largeCounts }) => {
|
|
let lowest = null
|
|
let priority = 1
|
|
|
|
for (const largeCount of largeCounts) {
|
|
for (const mediumCount of mediumCounts) {
|
|
for (const smallCount of smallCounts) {
|
|
const candidate = fitArrangement(priority, space, itemSpacing, smallCount, targetSmallSize, minSmallSize, maxSmallSize, mediumCount, targetMediumSize, largeCount, targetLargeSize)
|
|
|
|
if (lowest === null || arrangementCost(candidate, targetLargeSize) < arrangementCost(lowest, targetLargeSize)) {
|
|
lowest = candidate
|
|
|
|
// Candidates come in priority order: one that keeps the target size is the best.
|
|
if (arrangementCost(lowest, targetLargeSize) === 0) {
|
|
return lowest
|
|
}
|
|
}
|
|
|
|
priority++
|
|
}
|
|
}
|
|
}
|
|
|
|
return lowest
|
|
}
|
|
|
|
// KeylineList.kt ---------------------------------------------------------------------------
|
|
|
|
const EMPTY = { keylines: [] }
|
|
|
|
/** A keyline list with the indices KeylineList derives from its keylines' flags. */
|
|
const keylineList = (keylines) => {
|
|
const firstFocalIndex = keylines.findIndex((keyline) => keyline.isFocal)
|
|
|
|
return {
|
|
keylines,
|
|
pivotIndex: keylines.findIndex((keyline) => keyline.isPivot),
|
|
firstNonAnchorIndex: keylines.findIndex((keyline) => !keyline.isAnchor),
|
|
lastNonAnchorIndex: keylines.findLastIndex((keyline) => !keyline.isAnchor),
|
|
firstFocalIndex,
|
|
lastFocalIndex: keylines.findLastIndex((keyline) => keyline.isFocal),
|
|
}
|
|
}
|
|
|
|
const firstFocal = (list) => list.keylines[list.firstFocalIndex]
|
|
const lastFocal = (list) => list.keylines[list.lastFocalIndex]
|
|
|
|
const isFirstFocalItemAtStartOfContainer = (list) => {
|
|
const focal = firstFocal(list)
|
|
|
|
return focal.offset - focal.size / 2 >= 0 && list.firstFocalIndex === list.firstNonAnchorIndex
|
|
}
|
|
|
|
const isLastFocalItemAtEndOfContainer = (list, space) => {
|
|
const focal = lastFocal(list)
|
|
|
|
return focal.offset + focal.size / 2 <= space && list.lastFocalIndex === list.lastNonAnchorIndex
|
|
}
|
|
|
|
const firstIndexAfterFocalRangeWithSize = (list, size) => {
|
|
for (let index = list.lastFocalIndex; index < list.keylines.length; index++) {
|
|
if (list.keylines[index].size === size) {
|
|
return index
|
|
}
|
|
}
|
|
|
|
return list.keylines.length - 1
|
|
}
|
|
|
|
const lastIndexBeforeFocalRangeWithSize = (list, size) => {
|
|
for (let index = list.firstFocalIndex - 1; index >= 0; index--) {
|
|
if (list.keylines[index].size === size) {
|
|
return index
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
const keylineBefore = (list, unadjustedOffset) => {
|
|
for (let index = list.keylines.length - 1; index >= 0; index--) {
|
|
if (list.keylines[index].unadjustedOffset < unadjustedOffset) {
|
|
return list.keylines[index]
|
|
}
|
|
}
|
|
|
|
return list.keylines[0]
|
|
}
|
|
|
|
const keylineAfter = (list, unadjustedOffset) => list.keylines.find((keyline) => keyline.unadjustedOffset >= unadjustedOffset) ?? list.keylines.at(-1)
|
|
|
|
/** The focal range KeylineListScopeImpl finds as keylines are added: the first run of the largest size. */
|
|
const focalRange = (sizes) => {
|
|
let firstFocalIndex = -1
|
|
let focalItemSize = 0
|
|
|
|
sizes.forEach(({ size, isAnchor }, index) => {
|
|
if (!isAnchor && size > focalItemSize) {
|
|
firstFocalIndex = index
|
|
focalItemSize = size
|
|
}
|
|
})
|
|
|
|
let lastFocalIndex = firstFocalIndex
|
|
|
|
if (firstFocalIndex >= 0) {
|
|
while (lastFocalIndex < sizes.length - 1 && sizes[lastFocalIndex + 1].size === focalItemSize) {
|
|
lastFocalIndex++
|
|
}
|
|
}
|
|
|
|
return { firstFocalIndex, lastFocalIndex, focalItemSize }
|
|
}
|
|
|
|
const isCutoffLeft = (size, offset) => offset - size / 2 < 0 && offset + size / 2 > 0
|
|
const isCutoffRight = (size, offset, space) => offset - size / 2 < space && offset + size / 2 > space
|
|
|
|
const keylinesWithPivot = (sizes, pivotIndex, pivotOffset, { firstFocalIndex, lastFocalIndex, focalItemSize }, space, itemSpacing) => {
|
|
if (sizes.length === 0 || pivotIndex < 0 || pivotIndex >= sizes.length) {
|
|
return EMPTY
|
|
}
|
|
|
|
const isFocal = (index) => index >= firstFocalIndex && index <= lastFocalIndex
|
|
const pivot = sizes[pivotIndex]
|
|
|
|
const keylines = [
|
|
{
|
|
size: pivot.size,
|
|
offset: pivotOffset,
|
|
unadjustedOffset: pivotOffset,
|
|
isFocal: isFocal(pivotIndex),
|
|
isAnchor: pivot.isAnchor,
|
|
isPivot: true,
|
|
cutoff: isCutoffLeft(pivot.size, pivotOffset)
|
|
? pivotOffset - pivot.size / 2
|
|
: isCutoffRight(pivot.size, pivotOffset, space)
|
|
? pivotOffset + pivot.size / 2 - space
|
|
: 0,
|
|
},
|
|
]
|
|
|
|
let offset = pivotOffset - focalItemSize / 2 - itemSpacing
|
|
let unadjustedOffset = pivotOffset - focalItemSize / 2 - itemSpacing
|
|
|
|
for (let index = pivotIndex - 1; index >= 0; index--) {
|
|
const { size, isAnchor } = sizes[index]
|
|
const keylineOffset = offset - size / 2
|
|
|
|
keylines.unshift({
|
|
size,
|
|
offset: keylineOffset,
|
|
unadjustedOffset: unadjustedOffset - focalItemSize / 2,
|
|
isFocal: isFocal(index),
|
|
isAnchor,
|
|
isPivot: false,
|
|
cutoff: isCutoffLeft(size, keylineOffset) ? Math.abs(keylineOffset - size / 2) : 0,
|
|
})
|
|
|
|
offset -= size + itemSpacing
|
|
unadjustedOffset -= focalItemSize + itemSpacing
|
|
}
|
|
|
|
offset = pivotOffset + focalItemSize / 2 + itemSpacing
|
|
unadjustedOffset = pivotOffset + focalItemSize / 2 + itemSpacing
|
|
|
|
for (let index = pivotIndex + 1; index < sizes.length; index++) {
|
|
const { size, isAnchor } = sizes[index]
|
|
const keylineOffset = offset + size / 2
|
|
|
|
keylines.push({
|
|
size,
|
|
offset: keylineOffset,
|
|
unadjustedOffset: unadjustedOffset + focalItemSize / 2,
|
|
isFocal: isFocal(index),
|
|
isAnchor,
|
|
isPivot: false,
|
|
cutoff: isCutoffRight(size, keylineOffset, space) ? keylineOffset + size / 2 - space : 0,
|
|
})
|
|
|
|
offset += size + itemSpacing
|
|
unadjustedOffset += focalItemSize + itemSpacing
|
|
}
|
|
|
|
return keylineList(keylines)
|
|
}
|
|
|
|
const pivotedKeylineList = (sizes, space, itemSpacing, pivotIndex, pivotOffset) => keylinesWithPivot(sizes, pivotIndex, pivotOffset, focalRange(sizes), space, itemSpacing)
|
|
|
|
const alignedKeylineList = (sizes, space, itemSpacing, centered) => {
|
|
const range = focalRange(sizes)
|
|
const focalItemCount = range.lastFocalIndex - range.firstFocalIndex
|
|
|
|
let pivotOffset = range.focalItemSize / 2
|
|
|
|
if (centered) {
|
|
const itemSpacingSplit = itemSpacing === 0 || focalItemCount % 2 === 0 ? 0 : itemSpacing / 2
|
|
const itemSpaceCounts = Math.trunc(focalItemCount / 2) * itemSpacing
|
|
|
|
pivotOffset = space / 2 - (range.focalItemSize / 2) * focalItemCount - itemSpacingSplit - itemSpaceCounts
|
|
}
|
|
|
|
return keylinesWithPivot(sizes, range.firstFocalIndex, pivotOffset, range, space, itemSpacing)
|
|
}
|
|
|
|
const lerpKeyline = (start, end, fraction) => ({
|
|
size: lerp(start.size, end.size, fraction),
|
|
offset: lerp(start.offset, end.offset, fraction),
|
|
unadjustedOffset: lerp(start.unadjustedOffset, end.unadjustedOffset, fraction),
|
|
isFocal: fraction < 0.5 ? start.isFocal : end.isFocal,
|
|
isAnchor: fraction < 0.5 ? start.isAnchor : end.isAnchor,
|
|
isPivot: fraction < 0.5 ? start.isPivot : end.isPivot,
|
|
cutoff: lerp(start.cutoff, end.cutoff, fraction),
|
|
})
|
|
|
|
const lerpKeylineList = (from, to, fraction) => keylineList(from.keylines.map((keyline, index) => lerpKeyline(keyline, to.keylines[index], fraction)))
|
|
|
|
// Keylines.kt ------------------------------------------------------------------------------
|
|
|
|
const leftAlignedKeylineList = (space, itemSpacing, leftAnchorSize, rightAnchorSize, arrangement) =>
|
|
alignedKeylineList(
|
|
[
|
|
{ size: leftAnchorSize, isAnchor: true },
|
|
...Array.from({ length: arrangement.largeCount }, () => ({ size: arrangement.largeSize, isAnchor: false })),
|
|
...Array.from({ length: arrangement.mediumCount }, () => ({ size: arrangement.mediumSize, isAnchor: false })),
|
|
...Array.from({ length: arrangement.smallCount }, () => ({ size: arrangement.smallSize, isAnchor: false })),
|
|
{ size: rightAnchorSize, isAnchor: true },
|
|
],
|
|
space,
|
|
itemSpacing,
|
|
false,
|
|
)
|
|
|
|
const centerAlignedKeylineList = (space, itemSpacing, leftAnchorSize, rightAnchorSize, arrangement) => {
|
|
const repeat = (count, size) => Array.from({ length: count }, () => ({ size, isAnchor: false }))
|
|
|
|
return alignedKeylineList(
|
|
[
|
|
{ size: leftAnchorSize, isAnchor: true },
|
|
...repeat(Math.trunc(arrangement.smallCount / 2), arrangement.smallSize),
|
|
...repeat(Math.trunc(arrangement.mediumCount / 2), arrangement.mediumSize),
|
|
...repeat(arrangement.largeCount, arrangement.largeSize),
|
|
...repeat(Math.trunc(arrangement.mediumCount / 2), arrangement.mediumSize),
|
|
...repeat(Math.trunc(arrangement.smallCount / 2), arrangement.smallSize),
|
|
{ size: rightAnchorSize, isAnchor: true },
|
|
],
|
|
space,
|
|
itemSpacing,
|
|
true,
|
|
)
|
|
}
|
|
|
|
const largeCountsBetween = (min, max) => Array.from({ length: max - min + 1 }, (_, index) => max - index)
|
|
|
|
const multiBrowseKeylineList = (space, preferredItemSize, itemSpacing, itemCount) => {
|
|
if (space === 0 || preferredItemSize === 0) {
|
|
return EMPTY
|
|
}
|
|
|
|
let smallCounts = [1]
|
|
const mediumCounts = [1, 0]
|
|
|
|
const targetLargeSize = Math.min(preferredItemSize, space)
|
|
// A balanced arrangement has small items a third of the large size, within their range.
|
|
const targetSmallSize = clamp(targetLargeSize / 3, MIN_SMALL_ITEM_SIZE, MAX_SMALL_ITEM_SIZE)
|
|
const targetMediumSize = (targetLargeSize + targetSmallSize) / 2
|
|
|
|
if (space < MIN_SMALL_ITEM_SIZE * 2) {
|
|
smallCounts = [0]
|
|
}
|
|
|
|
const minAvailableLargeSpace = space - targetMediumSize * Math.max(...mediumCounts) - MAX_SMALL_ITEM_SIZE * Math.max(...smallCounts)
|
|
const minLargeCount = Math.max(1, Math.floor(minAvailableLargeSpace / targetLargeSize))
|
|
const maxLargeCount = Math.ceil(space / targetLargeSize)
|
|
const largeCounts = largeCountsBetween(minLargeCount, maxLargeCount)
|
|
|
|
const find = (smalls, mediums) =>
|
|
findLowestCostArrangement({
|
|
space,
|
|
itemSpacing,
|
|
targetSmallSize,
|
|
minSmallSize: MIN_SMALL_ITEM_SIZE,
|
|
maxSmallSize: MAX_SMALL_ITEM_SIZE,
|
|
smallCounts: smalls,
|
|
targetMediumSize,
|
|
mediumCounts: mediums,
|
|
targetLargeSize,
|
|
largeCounts,
|
|
})
|
|
|
|
let arrangement = find(smallCounts, mediumCounts)
|
|
|
|
// Fewer items than keylines: drop small, then medium ones (keeping one medium).
|
|
if (arrangement !== null && arrangementItemCount(arrangement) > itemCount) {
|
|
let keylineSurplus = arrangementItemCount(arrangement) - itemCount
|
|
let { smallCount, mediumCount } = arrangement
|
|
|
|
while (keylineSurplus > 0) {
|
|
if (smallCount > 0) {
|
|
smallCount -= 1
|
|
} else if (mediumCount > 1) {
|
|
mediumCount -= 1
|
|
}
|
|
|
|
keylineSurplus -= 1
|
|
}
|
|
|
|
arrangement = find([smallCount], [mediumCount])
|
|
}
|
|
|
|
return arrangement === null ? EMPTY : leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, arrangement)
|
|
}
|
|
|
|
const mediumChildSize = (minimumMediumSize, largeItemSize, remainingSpace) => {
|
|
// Large enough that a third of it is cut off…
|
|
let mediumItemSize = Math.max(remainingSpace * 1.5, minimumMediumSize)
|
|
|
|
// …but different enough from the large size to move when scrolled.
|
|
const largeItemThreshold = largeItemSize * MEDIUM_LARGE_ITEM_DIFF_THRESHOLD
|
|
|
|
if (mediumItemSize > largeItemThreshold) {
|
|
mediumItemSize = Math.min(Math.max(largeItemThreshold, remainingSpace * 1.2), largeItemSize)
|
|
}
|
|
|
|
return mediumItemSize
|
|
}
|
|
|
|
const uncontainedKeylineList = (space, itemSize, itemSpacing) => {
|
|
if (space === 0 || itemSize === 0) {
|
|
return EMPTY
|
|
}
|
|
|
|
const largeItemSize = Math.min(itemSize + itemSpacing, space)
|
|
const largeCount = Math.max(1, Math.floor(space / largeItemSize))
|
|
const remainingSpace = space - largeCount * largeItemSize
|
|
const mediumCount = remainingSpace > 0 ? 1 : 0
|
|
const mediumItemSize = mediumChildSize(ANCHOR_SIZE, largeItemSize, remainingSpace)
|
|
|
|
const arrangement = { priority: 0, smallSize: 0, smallCount: 0, mediumSize: mediumItemSize, mediumCount, largeSize: largeItemSize, largeCount }
|
|
|
|
// Half the cut-off item at the start, so the motion there matches the end.
|
|
const leftAnchorSize = Math.max(Math.min(ANCHOR_SIZE, itemSize), mediumItemSize * 0.5)
|
|
|
|
return leftAlignedKeylineList(space, itemSpacing, leftAnchorSize, ANCHOR_SIZE, arrangement)
|
|
}
|
|
|
|
const heroKeylineList = (space, maxItemSize, itemSpacing, itemCount, isCentered) => {
|
|
if (space === 0) {
|
|
return EMPTY
|
|
}
|
|
|
|
const shouldCenter = isCentered && itemCount >= 3
|
|
|
|
let smallCounts = itemCount <= 1 ? [0] : shouldCenter ? [2] : [1]
|
|
|
|
const targetLargeSize = Math.min(maxItemSize ?? space, space)
|
|
const targetSmallSize = clamp(targetLargeSize / 3, MIN_SMALL_ITEM_SIZE, MAX_SMALL_ITEM_SIZE)
|
|
|
|
// Room for the small items and a large item at least 25% larger than them.
|
|
const fullscreenThreshold = MIN_SMALL_ITEM_SIZE * Math.max(...smallCounts) + MIN_SMALL_ITEM_SIZE * 1.25
|
|
|
|
if (space < fullscreenThreshold) {
|
|
smallCounts = [0]
|
|
}
|
|
|
|
const minAvailableLargeSpace = space - MIN_SMALL_ITEM_SIZE * Math.max(...smallCounts)
|
|
const minLargeCount = Math.max(1, Math.floor(minAvailableLargeSpace / targetLargeSize))
|
|
const maxLargeCount = Math.ceil(space / targetLargeSize)
|
|
|
|
const arrangement = findLowestCostArrangement({
|
|
space,
|
|
itemSpacing,
|
|
targetSmallSize,
|
|
minSmallSize: MIN_SMALL_ITEM_SIZE,
|
|
maxSmallSize: MAX_SMALL_ITEM_SIZE,
|
|
smallCounts,
|
|
targetMediumSize: 0,
|
|
mediumCounts: [0],
|
|
targetLargeSize,
|
|
largeCounts: largeCountsBetween(minLargeCount, maxLargeCount),
|
|
})
|
|
|
|
if (arrangement === null) {
|
|
return EMPTY
|
|
}
|
|
|
|
return shouldCenter && itemCount >= arrangementItemCount(arrangement)
|
|
? centerAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, arrangement)
|
|
: leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, arrangement)
|
|
}
|
|
|
|
const fullScreenKeylineList = (space, itemSpacing) =>
|
|
space === 0
|
|
? EMPTY
|
|
: leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, { priority: 0, smallSize: 0, smallCount: 0, mediumSize: 0, mediumCount: 0, largeSize: space, largeCount: 1 })
|
|
|
|
// Strategy.kt ------------------------------------------------------------------------------
|
|
|
|
const shiftedKeylineListForContentPadding = (from, space, itemSpacing, contentPadding, pivot, pivotIndex) => {
|
|
const sizeReduction = contentPadding / from.keylines.filter((keyline) => !keyline.isAnchor).length
|
|
|
|
const shifted = pivotedKeylineList(
|
|
from.keylines.map((keyline) => ({ size: keyline.size - Math.abs(sizeReduction), isAnchor: keyline.isAnchor })),
|
|
space,
|
|
itemSpacing,
|
|
pivotIndex,
|
|
pivot.offset - sizeReduction / 2 + contentPadding,
|
|
)
|
|
|
|
// Items are still laid out end to end at the full size, so the unadjusted offsets stay.
|
|
return keylineList(shifted.keylines.map((keyline, index) => ({ ...keyline, unadjustedOffset: from.keylines[index].unadjustedOffset })))
|
|
}
|
|
|
|
const moveKeylineAndShift = (from, srcIndex, dstIndex, space, itemSpacing) => {
|
|
const pivotDirection = srcIndex > dstIndex ? 1 : -1
|
|
const pivotDelta = (from.keylines[srcIndex].size - from.keylines[srcIndex].cutoff + itemSpacing) * pivotDirection
|
|
const moved = [...from.keylines]
|
|
const [keyline] = moved.splice(srcIndex, 1)
|
|
|
|
moved.splice(dstIndex, 0, keyline)
|
|
|
|
return pivotedKeylineList(
|
|
moved.map(({ size, isAnchor }) => ({ size, isAnchor })),
|
|
space,
|
|
itemSpacing,
|
|
from.pivotIndex + pivotDirection,
|
|
from.keylines[from.pivotIndex].offset + pivotDelta,
|
|
)
|
|
}
|
|
|
|
const startKeylineSteps = (defaults, space, itemSpacing, beforeContentPadding) => {
|
|
if (defaults.keylines.length === 0) {
|
|
return []
|
|
}
|
|
|
|
const steps = [defaults]
|
|
|
|
if (isFirstFocalItemAtStartOfContainer(defaults)) {
|
|
if (beforeContentPadding !== 0) {
|
|
steps.push(shiftedKeylineListForContentPadding(defaults, space, itemSpacing, beforeContentPadding, firstFocal(defaults), defaults.firstFocalIndex))
|
|
}
|
|
|
|
return steps
|
|
}
|
|
|
|
const startIndex = defaults.firstNonAnchorIndex
|
|
const numberOfSteps = defaults.firstFocalIndex - startIndex
|
|
|
|
if (numberOfSteps <= 0 && firstFocal(defaults).cutoff > 0) {
|
|
steps.push(moveKeylineAndShift(defaults, 0, 0, space, itemSpacing))
|
|
|
|
return steps
|
|
}
|
|
|
|
for (let step = 0; step < numberOfSteps; step++) {
|
|
const previous = steps.at(-1)
|
|
const originalItemIndex = startIndex + step
|
|
let dstIndex = defaults.keylines.length - 1
|
|
|
|
if (originalItemIndex > 0) {
|
|
dstIndex = firstIndexAfterFocalRangeWithSize(previous, defaults.keylines[originalItemIndex - 1].size) - 1
|
|
}
|
|
|
|
steps.push(moveKeylineAndShift(previous, defaults.firstNonAnchorIndex, dstIndex, space, itemSpacing))
|
|
}
|
|
|
|
if (beforeContentPadding !== 0) {
|
|
const last = steps.at(-1)
|
|
|
|
steps[steps.length - 1] = shiftedKeylineListForContentPadding(last, space, itemSpacing, beforeContentPadding, firstFocal(last), last.firstFocalIndex)
|
|
}
|
|
|
|
return steps
|
|
}
|
|
|
|
const endKeylineSteps = (defaults, space, itemSpacing, afterContentPadding) => {
|
|
if (defaults.keylines.length === 0) {
|
|
return []
|
|
}
|
|
|
|
const steps = [defaults]
|
|
|
|
if (isLastFocalItemAtEndOfContainer(defaults, space)) {
|
|
if (afterContentPadding !== 0) {
|
|
steps.push(shiftedKeylineListForContentPadding(defaults, space, itemSpacing, -afterContentPadding, lastFocal(defaults), defaults.lastFocalIndex))
|
|
}
|
|
|
|
return steps
|
|
}
|
|
|
|
const startIndex = defaults.lastFocalIndex
|
|
const endIndex = defaults.lastNonAnchorIndex
|
|
const numberOfSteps = endIndex - startIndex
|
|
|
|
if (numberOfSteps <= 0 && lastFocal(defaults).cutoff > 0) {
|
|
steps.push(moveKeylineAndShift(defaults, 0, 0, space, itemSpacing))
|
|
|
|
return steps
|
|
}
|
|
|
|
for (let step = 0; step < numberOfSteps; step++) {
|
|
const previous = steps.at(-1)
|
|
const originalItemIndex = endIndex - step
|
|
let dstIndex = 0
|
|
|
|
if (originalItemIndex < defaults.keylines.length - 1) {
|
|
dstIndex = lastIndexBeforeFocalRangeWithSize(previous, defaults.keylines[originalItemIndex + 1].size) + 1
|
|
}
|
|
|
|
steps.push(moveKeylineAndShift(previous, defaults.lastNonAnchorIndex, dstIndex, space, itemSpacing))
|
|
}
|
|
|
|
if (afterContentPadding !== 0) {
|
|
const last = steps.at(-1)
|
|
|
|
steps[steps.length - 1] = shiftedKeylineListForContentPadding(last, space, itemSpacing, -afterContentPadding, lastFocal(last), last.lastFocalIndex)
|
|
}
|
|
|
|
return steps
|
|
}
|
|
|
|
const stepInterpolationPoints = (totalShiftDistance, steps, isShiftingLeft) => {
|
|
const points = [0]
|
|
|
|
if (totalShiftDistance === 0 || steps.length === 0) {
|
|
return points
|
|
}
|
|
|
|
for (let index = 1; index < steps.length; index++) {
|
|
const previous = steps[index - 1].keylines
|
|
const current = steps[index].keylines
|
|
const distanceShifted = isShiftingLeft ? current[0].unadjustedOffset - previous[0].unadjustedOffset : previous.at(-1).unadjustedOffset - current.at(-1).unadjustedOffset
|
|
|
|
points.push(index === steps.length - 1 ? 1 : points[index - 1] + distanceShifted / totalShiftDistance)
|
|
}
|
|
|
|
return points
|
|
}
|
|
|
|
const createStrategy = (defaults, space, itemSpacing, beforeContentPadding, afterContentPadding) => {
|
|
const startSteps = startKeylineSteps(defaults, space, itemSpacing, beforeContentPadding)
|
|
const endSteps = endKeylineSteps(defaults, space, itemSpacing, afterContentPadding)
|
|
|
|
const startShiftDistance = startSteps.length === 0 ? 0 : Math.max(startSteps.at(-1).keylines[0].unadjustedOffset - startSteps[0].keylines[0].unadjustedOffset, beforeContentPadding)
|
|
const endShiftDistance = endSteps.length === 0 ? 0 : Math.max(endSteps[0].keylines.at(-1).unadjustedOffset - endSteps.at(-1).keylines.at(-1).unadjustedOffset, afterContentPadding)
|
|
|
|
const itemSize = defaults.keylines.length > 0 && defaults.firstFocalIndex >= 0 ? firstFocal(defaults).size : 0
|
|
|
|
return {
|
|
defaults,
|
|
startSteps,
|
|
endSteps,
|
|
space,
|
|
itemSpacing,
|
|
itemSize,
|
|
valid: defaults.keylines.length > 0 && space !== 0 && itemSize > 0,
|
|
startShiftDistance,
|
|
endShiftDistance,
|
|
startShiftPoints: stepInterpolationPoints(startShiftDistance, startSteps, true),
|
|
endShiftPoints: stepInterpolationPoints(endShiftDistance, endSteps, false),
|
|
}
|
|
}
|
|
|
|
const keylinesForScrollOffset = (strategy, scrollOffset, maxScrollOffset) => {
|
|
const offset = Math.max(0, scrollOffset)
|
|
const startShiftOffset = strategy.startShiftDistance
|
|
const endShiftOffset = Math.max(0, maxScrollOffset - strategy.endShiftDistance)
|
|
|
|
if (offset >= startShiftOffset && offset <= endShiftOffset) {
|
|
return strategy.defaults
|
|
}
|
|
|
|
let interpolation = lerpRange(1, 0, 0, startShiftOffset, offset)
|
|
let shiftPoints = strategy.startShiftPoints
|
|
let steps = strategy.startSteps
|
|
|
|
if (offset > endShiftOffset) {
|
|
interpolation = lerpRange(0, 1, endShiftOffset, maxScrollOffset, offset)
|
|
shiftPoints = strategy.endShiftPoints
|
|
steps = strategy.endSteps
|
|
|
|
// End shifting from offset 0: go straight from the last start step to the last end step.
|
|
if (endShiftOffset < 0.01 && strategy.startSteps.length === 2 && strategy.endSteps.length === 2) {
|
|
steps = [strategy.startSteps.at(-1), strategy.endSteps.at(-1)]
|
|
}
|
|
}
|
|
|
|
let fromStepIndex = 0
|
|
let toStepIndex = 0
|
|
let steppedInterpolation = 0
|
|
let lowerBounds = shiftPoints[0]
|
|
|
|
for (let index = 1; index < steps.length; index++) {
|
|
const upperBounds = shiftPoints[index]
|
|
|
|
if (interpolation <= upperBounds) {
|
|
fromStepIndex = index - 1
|
|
toStepIndex = index
|
|
steppedInterpolation = lerpRange(0, 1, lowerBounds, upperBounds, interpolation)
|
|
|
|
break
|
|
}
|
|
|
|
lowerBounds = upperBounds
|
|
}
|
|
|
|
return lerpKeylineList(steps[fromStepIndex], steps[toStepIndex], steppedInterpolation)
|
|
}
|
|
|
|
// KeylineSnapPosition.kt -------------------------------------------------------------------
|
|
|
|
const snapPositionOffset = (strategy, itemIndex, itemCount) => {
|
|
if (!strategy.valid) {
|
|
return 0
|
|
}
|
|
|
|
let offset = Math.round(firstFocal(strategy.defaults).unadjustedOffset - strategy.itemSize / 2)
|
|
|
|
const lastStartStep = strategy.startSteps.length - 1
|
|
|
|
if (itemIndex <= lastStartStep) {
|
|
const step = strategy.startSteps[clamp(lastStartStep - itemIndex, 0, lastStartStep)]
|
|
|
|
offset = Math.round(firstFocal(step).unadjustedOffset - strategy.itemSize / 2)
|
|
}
|
|
|
|
const lastEndStep = strategy.endSteps.length - 1
|
|
const lastItemIndex = itemCount - 1
|
|
const focalCount = strategy.defaults.lastFocalIndex - strategy.defaults.firstFocalIndex + 1
|
|
|
|
if (itemIndex >= lastItemIndex - lastEndStep && itemCount > focalCount) {
|
|
const step = strategy.endSteps[clamp(lastEndStep - (lastItemIndex - itemIndex), 0, lastEndStep)]
|
|
|
|
offset = Math.round(lastFocal(step).unadjustedOffset - strategy.itemSize / 2)
|
|
}
|
|
|
|
return offset
|
|
}
|
|
|
|
// The component ----------------------------------------------------------------------------
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
window.Alpine.data('materialCarousel', () => {
|
|
// Plain state, out of Alpine's reactivity: it changes on every scroll frame.
|
|
const state = {
|
|
items: [],
|
|
strategy: null,
|
|
snaps: [],
|
|
maxScroll: 0,
|
|
rtl: false,
|
|
frame: null,
|
|
target: null,
|
|
targetAt: 0,
|
|
settle: null,
|
|
listeners: [],
|
|
mutations: null,
|
|
resizes: null,
|
|
reducedMotion: null,
|
|
}
|
|
|
|
return {
|
|
init() {
|
|
const scroller = this.$refs.scroller
|
|
|
|
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
|
|
this.listen(scroller, 'scroll', () => this.scrolled(), { passive: true })
|
|
|
|
// A scroll the person makes themselves is theirs to end wherever it ends.
|
|
for (const type of ['pointerdown', 'wheel', 'touchstart']) {
|
|
this.listen(scroller, type, () => (state.target = null), { passive: true })
|
|
}
|
|
this.listen(scroller, 'keydown', (event) => this.navigate(event))
|
|
this.listen(scroller, 'focusin', (event) => this.reveal(event))
|
|
this.listen(scroller, 'click', (event) => this.open(event))
|
|
this.listen(state.reducedMotion, 'change', () => this.schedule())
|
|
|
|
state.resizes = new ResizeObserver(() => this.refresh())
|
|
state.resizes.observe(scroller)
|
|
|
|
// A morph resets the inline styles written here, and may add or remove items.
|
|
state.mutations = new MutationObserver((records) => {
|
|
if (records.some((record) => (record.type === 'childList' ? record.target === scroller : this.owns(record.target)))) {
|
|
this.refresh()
|
|
}
|
|
})
|
|
state.mutations.observe(this.$root, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] })
|
|
|
|
this.refresh()
|
|
},
|
|
|
|
/** The elements this script writes styles on. */
|
|
owns(element) {
|
|
return element === this.$root || state.items.some((item) => item.element === element || item.surface === element)
|
|
},
|
|
|
|
listen(target, type, handler, options) {
|
|
target.addEventListener(type, handler, options)
|
|
state.listeners.push(() => target.removeEventListener(type, handler, options))
|
|
},
|
|
|
|
/** Measures the container and items, and builds the strategy for this width. */
|
|
refresh() {
|
|
const root = this.$root
|
|
const scroller = this.$refs.scroller
|
|
const space = scroller.clientWidth
|
|
const itemSpacing = parseFloat(getComputedStyle(scroller).columnGap) || 0
|
|
const padding = Number(root.dataset.padding) || 0
|
|
const paddingEnd = Number(root.dataset.paddingEnd) || 0
|
|
const probe = this.$refs.probe
|
|
const preferred = probe ? probe.getBoundingClientRect().width : null
|
|
|
|
state.rtl = getComputedStyle(scroller).direction === 'rtl'
|
|
state.items = [...scroller.children]
|
|
.filter((element) => element.matches(ITEM))
|
|
.map((element) => ({
|
|
element,
|
|
surface: element.querySelector('[data-material-carousel-surface]'),
|
|
label: element.querySelector('[data-material-carousel-label]'),
|
|
labelWidth: 0,
|
|
written: '',
|
|
}))
|
|
|
|
const count = state.items.length
|
|
const keylines = {
|
|
hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.centered !== undefined),
|
|
uncontained: () => uncontainedKeylineList(space, preferred ?? 0, itemSpacing),
|
|
'full-screen': () => fullScreenKeylineList(space, itemSpacing),
|
|
}[root.dataset.materialCarousel] ?? (() => multiBrowseKeylineList(space, preferred ?? 0, itemSpacing, count))
|
|
|
|
const strategy = count === 0 ? createStrategy(EMPTY, space, itemSpacing, 0, 0) : createStrategy(keylines(), space, itemSpacing, padding, 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 left = state.snaps[target]
|
|
|
|
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
|
|
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
|
|
}
|
|
}, SETTLE_MS)
|
|
},
|
|
|
|
/** Carousel.kt's carouselItem layer block, for every item. */
|
|
render() {
|
|
cancelAnimationFrame(state.frame)
|
|
state.frame = null
|
|
|
|
const strategy = state.strategy
|
|
|
|
if (!strategy?.valid) {
|
|
return
|
|
}
|
|
|
|
const scroll = this.scrollOffset()
|
|
const keylines = keylinesForScrollOffset(strategy, scroll, state.maxScroll)
|
|
const size = strategy.itemSize
|
|
|
|
// M3: "When reduced motion settings are turned on, the parallax effect should be
|
|
// removed and carousel items should no longer expand as they come into view. All
|
|
// items are the same size" (docs/reference/m3/styles.md § Motion → Accessibility
|
|
// requirements). The keylines still decide where the row snaps; nothing is masked,
|
|
// moved or faded, so every item stays at strategy.itemSize.
|
|
const still = state.reducedMotion.matches
|
|
|
|
state.items.forEach((item, index) => {
|
|
const center = index * (size + strategy.itemSpacing) + size / 2 - scroll
|
|
const before = keylineBefore(keylines, center)
|
|
const after = keylineAfter(keylines, center)
|
|
const progress = before === after ? 1 : (center - before.unadjustedOffset) / (after.unadjustedOffset - before.unadjustedOffset)
|
|
const keyline = lerpKeyline(before, after, progress)
|
|
|
|
let translation = keyline.offset - center
|
|
|
|
if (before === after && keyline.size !== 0) {
|
|
translation += (center - keyline.unadjustedOffset) / keyline.size
|
|
}
|
|
|
|
const inset = still ? 0 : clamp((size - keyline.size) / 2, 0, size / 2)
|
|
const shift = still ? 0 : state.rtl ? -translation : translation
|
|
const opacity = still || item.labelWidth === 0 ? 1 : clamp((item.labelWidth - size + keyline.size) / item.labelWidth, 0, 1)
|
|
const written = `${inset.toFixed(2)}|${shift.toFixed(2)}|${opacity.toFixed(3)}`
|
|
|
|
if (written === item.written) {
|
|
return
|
|
}
|
|
|
|
// On the surface, not the item: the item is the snap area, and nothing
|
|
// about it changes while the carousel scrolls.
|
|
item.written = written
|
|
item.surface.style.setProperty('--material-carousel-inset', `${inset.toFixed(2)}px`)
|
|
item.surface.style.setProperty('--material-carousel-shift', `${shift.toFixed(2)}px`)
|
|
item.surface.style.setProperty('--material-carousel-label-shift', `${(state.rtl ? -inset : inset).toFixed(2)}px`)
|
|
item.surface.style.setProperty('--material-carousel-label', opacity.toFixed(3))
|
|
})
|
|
|
|
if (this.$refs.previous) {
|
|
this.$refs.previous.disabled = scroll <= 1
|
|
}
|
|
|
|
if (this.$refs.next) {
|
|
this.$refs.next.disabled = scroll >= state.maxScroll - 1
|
|
}
|
|
|
|
state.mutations?.takeRecords()
|
|
},
|
|
|
|
/** The scroll offset from the start edge, positive in both directions. */
|
|
scrollOffset() {
|
|
return clamp(Math.abs(this.$refs.scroller.scrollLeft), 0, state.maxScroll)
|
|
},
|
|
|
|
/** The item nearest the current scroll position. */
|
|
current() {
|
|
const scroll = this.scrollOffset()
|
|
|
|
return state.snaps.reduce((best, snap, index) => (Math.abs(snap - scroll) < Math.abs(state.snaps[best] - scroll) ? index : best), 0)
|
|
},
|
|
|
|
/** Where the carousel is headed: an unfinished scroll's target, or where it is. */
|
|
heading() {
|
|
const moving = state.target !== null && performance.now() - state.targetAt < TARGET_MS
|
|
|
|
return moving ? state.snaps[state.target] : this.scrollOffset()
|
|
},
|
|
|
|
next() {
|
|
const from = this.heading()
|
|
const index = state.snaps.findIndex((snap) => snap > from + 1)
|
|
|
|
if (index >= 0) {
|
|
this.scrollToItem(index)
|
|
}
|
|
},
|
|
|
|
previous() {
|
|
const from = this.heading()
|
|
const index = state.snaps.findLastIndex((snap) => snap < from - 1)
|
|
|
|
if (index >= 0) {
|
|
this.scrollToItem(index)
|
|
}
|
|
},
|
|
|
|
scrollToItem(index) {
|
|
if (!state.strategy?.valid || state.snaps[index] === undefined) {
|
|
return
|
|
}
|
|
|
|
state.target = index
|
|
state.targetAt = performance.now()
|
|
|
|
const left = state.snaps[index]
|
|
|
|
this.$refs.scroller.scrollTo({
|
|
left: state.rtl ? -left : left,
|
|
behavior: state.reducedMotion.matches ? 'instant' : 'smooth',
|
|
})
|
|
},
|
|
|
|
/**
|
|
* The 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.rtl ? 'ArrowLeft' : 'ArrowRight'
|
|
const backward = 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)
|
|
|
|
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()
|
|
},
|
|
}
|
|
})
|
|
})
|