Merge branch 'worktree-agent-a3082a7e6ef1bee94'
This commit is contained in:
@@ -1,23 +1,114 @@
|
||||
/**
|
||||
* `materialBottomSheet(standard)`: the behaviour of `<x-bottom-sheet>`, spread into its x-data
|
||||
* alongside `open` (entangled with Livewire, or the surrounding Alpine scope's).
|
||||
* `materialBottomSheet(standard, presets)`: the behaviour of `<x-bottom-sheet>`, spread into its
|
||||
* x-data alongside `open` (entangled with Livewire, or the surrounding Alpine scope's).
|
||||
*
|
||||
* A downward drag follows the pointer and, released past a quarter of the sheet's height or
|
||||
* flicked down, closes it; otherwise it springs back. The drag starts on the handle, or anywhere
|
||||
* on the sheet while its content is scrolled to the top, so scrolling the content still scrolls.
|
||||
* Without preset heights the sheet is as tall as its content allows and a downward drag follows the
|
||||
* pointer: released past a quarter of the sheet's height or flicked down, it closes; otherwise it
|
||||
* springs back. Activating the handle closes it.
|
||||
*
|
||||
* With preset heights (`presets.stops`, CSS lengths from `heights`/`snap`) the sheet is the height
|
||||
* of its current stop. A drag then runs that height with the pointer and settles on the nearest
|
||||
* stop when it is let go — below the smallest stop, or on a downward flick, it closes instead. M3
|
||||
* requires a single-pointer alternative to any drag, and names the handle as it: activating it
|
||||
* moves to the next stop and announces it, and from the last stop it closes the sheet, which is
|
||||
* M3's "selecting the drag handle toggles preset heights or closes the sheet".
|
||||
*
|
||||
* The drag starts on the handle, or anywhere on the sheet while its content is scrolled to the top,
|
||||
* so scrolling the content still scrolls. The sheet's inline `--sheet-stop` is what the CSS reads
|
||||
* for the current stop; only while a drag is running does this write a pixel height, with the
|
||||
* transition off, so the sheet tracks the pointer rather than easing after it.
|
||||
*/
|
||||
const DISMISS_FRACTION = 0.25
|
||||
const FLICK_PX_PER_MS = 0.5
|
||||
|
||||
window.materialBottomSheet = (standard = false) => ({
|
||||
/** M3's top margin for a bottom sheet, which its stops are held under. */
|
||||
const TOP_MARGIN = 72
|
||||
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
|
||||
window.materialBottomSheet = (standard = false, presets = {}) => ({
|
||||
standard,
|
||||
stops: presets.stops ?? [],
|
||||
labels: presets.labels ?? {},
|
||||
start: presets.start ?? 0,
|
||||
stop: presets.start ?? 0,
|
||||
dragged: 0,
|
||||
dragging: false,
|
||||
height: 0,
|
||||
announcement: '',
|
||||
|
||||
/** Whether this sheet has more than one height to move between. */
|
||||
get preset() {
|
||||
return this.stops.length > 1
|
||||
},
|
||||
|
||||
/** What the handle does next: the last stop closes the sheet, as a handle with no stops does. */
|
||||
get handleLabel() {
|
||||
return this.preset && this.stop < this.stops.length - 1 ? this.labels.change : this.labels.close
|
||||
},
|
||||
|
||||
/** Only a running drag writes inline styles; at rest the classes and `--sheet-stop` own the box. */
|
||||
get sheetStyle() {
|
||||
if (!this.dragging) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return this.preset ? { height: `${this.height}px`, transition: 'none' } : { translate: `0 ${this.dragged}px`, transition: 'none' }
|
||||
},
|
||||
|
||||
close() {
|
||||
this.dragged = 0
|
||||
this.dragging = false
|
||||
this.move(this.start, false)
|
||||
this.open = typeof this.open === 'boolean' ? false : null
|
||||
},
|
||||
|
||||
/** The handle's press, Enter and Space: the next stop, or the way out. */
|
||||
activate() {
|
||||
if (this.preset && this.stop < this.stops.length - 1) {
|
||||
this.move(this.stop + 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.close()
|
||||
},
|
||||
|
||||
/** Settles on a stop: the CSS variable the box is sized from, and a word for the screen reader. */
|
||||
move(index, announce = true) {
|
||||
if (!this.preset) {
|
||||
return
|
||||
}
|
||||
|
||||
this.stop = index
|
||||
this.$refs.sheet?.style.setProperty('--sheet-stop', this.stops[index])
|
||||
|
||||
if (announce) {
|
||||
this.announcement = this.labels.announce?.[index] ?? ''
|
||||
}
|
||||
},
|
||||
|
||||
/** The tallest a sheet may be: the screen less M3's 72dp top margin. */
|
||||
ceiling() {
|
||||
return Math.max(0, window.innerHeight - TOP_MARGIN)
|
||||
},
|
||||
|
||||
/** Each stop in pixels — only the browser can say what `25dvh` is. */
|
||||
sizes() {
|
||||
const probe = this.$refs.probe
|
||||
const ceiling = this.ceiling()
|
||||
|
||||
return this.stops.map((stop) => {
|
||||
probe.style.height = stop
|
||||
|
||||
const size = probe.getBoundingClientRect().height
|
||||
|
||||
probe.style.removeProperty('height')
|
||||
|
||||
return Math.min(size, ceiling)
|
||||
})
|
||||
},
|
||||
|
||||
dragStart(event) {
|
||||
const sheet = this.$refs.sheet
|
||||
const onHandle = event.target.closest('[data-drag-handle]')
|
||||
@@ -27,18 +118,28 @@ window.materialBottomSheet = (standard = false) => ({
|
||||
return
|
||||
}
|
||||
|
||||
const preset = this.preset
|
||||
const startY = event.clientY
|
||||
const startHeight = sheet.offsetHeight
|
||||
let lastY = startY
|
||||
let lastAt = performance.now()
|
||||
let velocity = 0
|
||||
|
||||
this.dragging = true
|
||||
this.height = startHeight
|
||||
|
||||
const move = (moveEvent) => {
|
||||
const now = performance.now()
|
||||
|
||||
velocity = (moveEvent.clientY - lastY) / Math.max(now - lastAt, 1)
|
||||
lastY = moveEvent.clientY
|
||||
lastAt = now
|
||||
this.dragged = Math.max(0, moveEvent.clientY - startY)
|
||||
|
||||
if (preset) {
|
||||
this.height = clamp(startHeight - (moveEvent.clientY - startY), 0, this.ceiling())
|
||||
} else {
|
||||
this.dragged = Math.max(0, moveEvent.clientY - startY)
|
||||
}
|
||||
}
|
||||
|
||||
const end = () => {
|
||||
@@ -46,7 +147,11 @@ window.materialBottomSheet = (standard = false) => ({
|
||||
window.removeEventListener('pointerup', end)
|
||||
window.removeEventListener('pointercancel', end)
|
||||
|
||||
if (this.dragged > sheet.offsetHeight * DISMISS_FRACTION || (this.dragged > 0 && velocity > FLICK_PX_PER_MS)) {
|
||||
this.dragging = false
|
||||
|
||||
if (preset) {
|
||||
this.release(velocity)
|
||||
} else if (this.dragged > sheet.offsetHeight * DISMISS_FRACTION || (this.dragged > 0 && velocity > FLICK_PX_PER_MS)) {
|
||||
this.close()
|
||||
} else {
|
||||
this.dragged = 0
|
||||
@@ -57,4 +162,20 @@ window.materialBottomSheet = (standard = false) => ({
|
||||
window.addEventListener('pointerup', end)
|
||||
window.addEventListener('pointercancel', end)
|
||||
},
|
||||
|
||||
/** A preset-height drag let go: the nearest stop, or out past the smallest one. */
|
||||
release(velocity) {
|
||||
const sizes = this.sizes()
|
||||
const smallest = Math.min(...sizes)
|
||||
|
||||
if (velocity > FLICK_PX_PER_MS || this.height < smallest * (1 - DISMISS_FRACTION)) {
|
||||
this.close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const nearest = sizes.reduce((best, size, index) => (Math.abs(size - this.height) < Math.abs(sizes[best] - this.height) ? index : best), 0)
|
||||
|
||||
this.move(nearest)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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