Give a bottom sheet M3's preset heights

Plan step 23, containment.md § Missing ("Bottom sheets: preset heights
and the handle's cycle-through-heights behaviour"). `heights` (or `snap`,
which is 25/50/90dvh) lists the stops a sheet moves between; it then
takes its stop's height and opens at the stop that equals `height`, or at
the first. Fewer than two stops is no stops, since M3 only requires the
non-drag alternative "if multiple preset heights exist".

That alternative is the drag handle, which M3 names a button: activating
it moves to the next stop and announces it in a live region, and from the
last stop it closes the sheet — M3's "selecting the drag handle toggles
preset heights or closes the sheet", and the same thing a handle with no
stops has always done. A drag now runs the sheet's height with the
pointer and settles on the nearest stop, or closes below the smallest one
or on a downward flick.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:42:37 +02:00
co-authored by Claude Fable 5.1
parent 07759cd664
commit 944431668d
5 changed files with 252 additions and 15 deletions
+129 -8
View File
@@ -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)
},
})