Merge branch 'worktree-agent-a6769a5cd2c4b070e'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:19:47 +02:00
26 changed files with 747 additions and 214 deletions
+124 -54
View File
@@ -23,9 +23,10 @@
* 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.
* 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
@@ -39,8 +40,9 @@
* 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).
* The full-screen layout uses none of it: M3 gives it one edge-to-edge item at a time, scrolled
* vertically, so the browser's own scroll snap is the whole of it and this script only works out
* where each item comes to rest, for the buttons and the keys.
*
* Copyright 2023-2024 The Android Open Source Project
*
@@ -546,11 +548,6 @@ const heroKeylineList = (space, maxItemSize, itemSpacing, itemCount, isCentered)
: 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) => {
@@ -805,6 +802,7 @@ document.addEventListener('alpine:init', () => {
snaps: [],
maxScroll: 0,
rtl: false,
vertical: false,
frame: null,
target: null,
targetAt: 0,
@@ -860,32 +858,50 @@ document.addEventListener('alpine:init', () => {
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
const style = getComputedStyle(scroller)
state.rtl = getComputedStyle(scroller).direction === 'rtl'
state.rtl = style.direction === 'rtl'
state.vertical = root.dataset.materialCarousel === 'full-screen'
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: '',
}))
// M3's full-screen layout is one edge-to-edge item at a time, scrolled
// vertically: no keylines, no mask, no end padding — the browser's scroll snap
// does the whole of it, and this only works out where each item comes to rest.
if (state.vertical) {
const space = scroller.clientHeight
const spacing = parseFloat(style.rowGap) || 0
state.strategy = null
state.maxScroll = Math.max(0, (space + spacing) * state.items.length - spacing - space)
state.snaps = state.items.map((_, index) => clamp(index * (space + spacing), 0, state.maxScroll))
this.render()
return
}
const space = scroller.clientWidth
const itemSpacing = parseFloat(style.columnGap) || 0
const padding = Number(root.dataset.padding) || 0
const paddingEnd = Number(root.dataset.paddingEnd) || 0
const probe = this.$refs.probe
const preferred = probe ? probe.getBoundingClientRect().width : null
const count = state.items.length
const keylines = {
hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.centered !== undefined),
uncontained: () => uncontainedKeylineList(space, preferred ?? 0, itemSpacing),
'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)
const strategy = count === 0 ? createStrategy(EMPTY, space, itemSpacing, 0, 0) : createStrategy(keylines(), space, itemSpacing, padding, paddingEnd)
state.strategy = strategy
@@ -941,10 +957,10 @@ document.addEventListener('alpine:init', () => {
state.target = null
const left = state.snaps[target]
const offset = state.snaps[target]
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
if (offset !== undefined && Math.abs(this.scrollOffset() - offset) > 1) {
this.scrollTo(offset, 'instant')
}
}, SETTLE_MS)
},
@@ -954,6 +970,14 @@ 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) {
this.buttons()
state.mutations?.takeRecords()
return
}
const strategy = state.strategy
if (!strategy?.valid) {
@@ -963,7 +987,13 @@ document.addEventListener('alpine:init', () => {
const scroll = this.scrollOffset()
const keylines = keylinesForScrollOffset(strategy, scroll, state.maxScroll)
const size = strategy.itemSize
const pinned = state.reducedMotion.matches
// 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
@@ -978,11 +1008,10 @@ document.addEventListener('alpine:init', () => {
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)}`
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
@@ -993,11 +1022,19 @@ document.addEventListener('alpine:init', () => {
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))
})
this.buttons()
state.mutations?.takeRecords()
},
/** A control that would scroll past an end is off. */
buttons() {
const scroll = this.scrollOffset()
if (this.$refs.previous) {
this.$refs.previous.disabled = scroll <= 1
}
@@ -1005,13 +1042,21 @@ document.addEventListener('alpine:init', () => {
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. */
/** The scroll offset from the start edge, positive in every direction. */
scrollOffset() {
return clamp(Math.abs(this.$refs.scroller.scrollLeft), 0, state.maxScroll)
const scroller = this.$refs.scroller
return clamp(state.vertical ? scroller.scrollTop : Math.abs(scroller.scrollLeft), 0, state.maxScroll)
},
/** Scrolls to an offset on whichever axis this carousel runs along. */
scrollTo(offset, behavior) {
this.$refs.scroller.scrollTo({
...(state.vertical ? { top: offset } : { left: state.rtl ? -offset : offset }),
behavior,
})
},
/** The item nearest the current scroll position. */
@@ -1047,46 +1092,68 @@ document.addEventListener('alpine:init', () => {
},
scrollToItem(index) {
if (!state.strategy?.valid || state.snaps[index] === undefined) {
if (state.snaps[index] === undefined || (!state.vertical && !state.strategy?.valid)) {
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',
})
this.scrollTo(state.snaps[index], state.reducedMotion.matches ? 'instant' : 'smooth')
},
/** The row's own keys, while the row itself has focus: a control inside an item keeps its keys. */
/**
* 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.target !== this.$refs.scroller || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return
}
const forward = state.rtl ? 'ArrowLeft' : 'ArrowRight'
const backward = state.rtl ? 'ArrowRight' : 'ArrowLeft'
const index = state.items.findIndex((item) => item.element === event.target)
const action = {
[forward]: () => this.next(),
[backward]: () => this.previous(),
Home: () => this.scrollToItem(0),
End: () => this.scrollToItem(state.items.length - 1),
if (index < 0) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
if (this.isMasked(index)) {
event.preventDefault()
this.scrollToItem(index)
}
return
}
const forward = state.vertical ? 'ArrowDown' : state.rtl ? 'ArrowLeft' : 'ArrowRight'
const backward = state.vertical ? 'ArrowUp' : state.rtl ? 'ArrowRight' : 'ArrowLeft'
const to = {
[forward]: index + 1,
[backward]: index - 1,
Home: 0,
End: state.items.length - 1,
}[event.key]
if (action) {
event.preventDefault()
action()
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 === this.$refs.scroller ? null : event.target.closest(ITEM)
const element = event.target.closest?.(ITEM)
const index = state.items.findIndex((item) => item.element === element)
if (index >= 0 && this.isMasked(index)) {
@@ -1106,8 +1173,11 @@ document.addEventListener('alpine:init', () => {
}
},
/** Whether item `index` is not fully in focus, so a press or focus should bring it there. */
isMasked(index) {
return parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
return state.vertical
? Math.abs(state.snaps[index] - this.scrollOffset()) > 1
: parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
},
destroy() {
+26
View File
@@ -11,6 +11,11 @@
* Not a stretched link (`::after { inset: 0 }`): Safari makes no containing block of a <tr>, so in
* a table every overlay would cover the whole table; and not one <button> around the row, which
* could hold no other buttons. The listeners sit on `document` and are added once.
*
* A row that is also `data-list-actionable` is M3's **directly actionable card** (`<x-card
* actionable>`): the card is the one tab stop, so Enter and Space on the card reach the opener,
* and the card's other actions follow it in the tab order with their own keys. The opener itself
* carries `tabindex="-1"`, which the card's caller writes.
*/
/** Anything that answers a click itself — the row's own controls, and the opener. */
@@ -99,6 +104,27 @@ document.addEventListener('click', (event) => {
opener.click()
})
// A directly actionable card is a control, so it answers Enter and Space the way a
// button does — by reaching the opener, which owns the href or the wire:click.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return
}
if (event.defaultPrevented || !(event.target instanceof Element) || !event.target.matches('[data-list-actionable]')) {
return
}
const opener = event.target.querySelector('[data-list-open]')
if (!opener) {
return
}
event.preventDefault()
opener.click()
})
// The middle button is not a `click`, and on a row that goes somewhere it means
// what it means on a link.
document.addEventListener('auxclick', (event) => {