Merge branch 'worktree-agent-a6769a5cd2c4b070e'
This commit is contained in:
+124
-54
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user