/** * `materialBottomSheet(standard, presets)`: the behaviour of ``, spread into its * x-data alongside `open` (entangled with Livewire, or the surrounding Alpine scope's). * * 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 /** 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]') const atTop = this.$refs.body.scrollTop <= 0 if (event.button !== 0 || (!onHandle && !atTop) || event.target.closest('input, textarea, select, [contenteditable]')) { 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 if (preset) { this.height = clamp(startHeight - (moveEvent.clientY - startY), 0, this.ceiling()) } else { this.dragged = Math.max(0, moveEvent.clientY - startY) } } const end = () => { window.removeEventListener('pointermove', move) window.removeEventListener('pointerup', end) window.removeEventListener('pointercancel', end) 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 } } window.addEventListener('pointermove', move) 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) }, })