Add M3's uncontained multi-aspect-ratio carousel
Plan step 23, containment.md § Missing ("Carousel: the uncontained
multi-aspect-ratio layout"). `<x-carousel layout="multi-aspect">` is the
layout M3 added in November 2025: each `<x-carousel-item aspect="…">`
keeps its own ratio at the row's fixed height, held inside M3's 9:16 to
16:9 range (a square by default), with 16dp leading padding, 8dp gaps,
the extra-large corner and uncontained (default) scrolling.
The keyline machinery does not fit it: an Arrangement counts items of
one size each, and every snap position and mask follows from that size.
So the layout is a plain flex row, unmasked, and carousel.js only
measures each item's resting position off the DOM, which keeps the
previous/next buttons, the arrow keys, Home/End and bring-into-view
working. The header and SKILL.md say so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
944431668d
commit
bb9473b0b2
@@ -44,6 +44,13 @@
|
||||
* 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");
|
||||
@@ -803,6 +810,7 @@ document.addEventListener('alpine:init', () => {
|
||||
maxScroll: 0,
|
||||
rtl: false,
|
||||
vertical: false,
|
||||
measured: false,
|
||||
frame: null,
|
||||
target: null,
|
||||
targetAt: 0,
|
||||
@@ -862,6 +870,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
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) => ({
|
||||
@@ -888,6 +897,28 @@ document.addEventListener('alpine:init', () => {
|
||||
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
|
||||
@@ -970,8 +1001,9 @@ document.addEventListener('alpine:init', () => {
|
||||
cancelAnimationFrame(state.frame)
|
||||
state.frame = null
|
||||
|
||||
// Nothing is masked in the vertical full-screen layout; only the buttons change.
|
||||
if (state.vertical) {
|
||||
// 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()
|
||||
|
||||
@@ -1092,7 +1124,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
scrollToItem(index) {
|
||||
if (state.snaps[index] === undefined || (!state.vertical && !state.strategy?.valid)) {
|
||||
if (state.snaps[index] === undefined || (!state.vertical && !state.measured && !state.strategy?.valid)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1175,9 +1207,19 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
/** Whether item `index` is not fully in focus, so a press or focus should bring it there. */
|
||||
isMasked(index) {
|
||||
return state.vertical
|
||||
? Math.abs(state.snaps[index] - this.scrollOffset()) > 1
|
||||
: parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
|
||||
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')) > 0.5
|
||||
},
|
||||
|
||||
destroy() {
|
||||
|
||||
Reference in New Issue
Block a user