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:
Andreas Reinhold / reini
2026-09-14 07:14:29 +02:00
co-authored by Claude Opus 5
parent 944431668d
commit bb9473b0b2
6 changed files with 163 additions and 19 deletions
+48 -6
View File
@@ -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() {