Plan step 36 (containment group): <x-bottom-sheet>'s class lists move into resources/css/components/bottom-sheet.css, keyed on data-md-bottom-sheet (data-md-open, data-md-standard) and data-md-bottom-sheet-scrim/-probe/-panel (data-md-preset while heights/snap gives it stops)/-handle/-grip/-announce/-body/-title/ -actions. x-transition's enter/leave classes are replaced the same way as drawer.css: @starting-style + transition-behavior: allow-discrete on the x-show-driven panel, the asymmetric open (spatial-default, emphasized-decelerate) vs close (effects-default, emphasized- accelerate) timing coming from the more specific open-state selector's own transition-duration/-timing-function, which a CSS transition always takes from the after-change style. data-md-preset adds the height-between-stops transition (the spatial spring) alongside it, independent of the open/close slide. The drag handle (C-01, already fixed in the Tailwind-era markup this replaces) renders the shared md-focus-ring and md-touch-target classes instead of a hand-rolled ring: unlike the datepicker's day, its own visible bar is not a smaller indicator drawn inside a bigger box those classes cannot reach, so nothing here refines them further. Its 22px vertical padding (SheetDefaults.kt's DragHandleVerticalPadding) is a literal, matching alert.css's 12px for the same reason: 22 is not on the measurement scale. Hooks renamed data-drag-handle -> data-md-bottom-sheet-handle, updated in resources/js/bottom-sheet.js and tests/Browser/ContainmentTest.php; data-sheet -> data-md-drawer in that test's comment. <x-menu sheet-at-compact> renders this component unchanged; menu.css now imports bottom-sheet.css, and tests/Feature/Components/MenuTest.php's stale Tailwind-class assertion for the embedded sheet is replaced with a hook check. The two sheet-at-compact browser tests in ActionsTest.php stay on assertVisible(): the sheet is still x-show-driven (a deliberate choice for this batch, keeping the existing modal/side-sheet/bottom-sheet architecture and converting only their styling), so open still shows as visibility rather than :popover-open; their comments no longer say "not rewritten in this batch". Imported from the Containment block of components.css. Browser tests owed by docs/plans/material-3-browser-tests.md, added to ContainmentTest.php but not run: dialog divider marks on scroll (including that a nested dialog keeps its own marks), the full-screen phone bar, separator always, no fade under reduced motion, the scrolling body's inset focus ring; bottom sheet preset-height cycling with its announcement and a drag settling on the nearest stop; the standard side sheet from 840px without a scrim or a focus trap, modal below it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
182 lines
6.2 KiB
JavaScript
182 lines
6.2 KiB
JavaScript
/**
|
|
* `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).
|
|
*
|
|
* 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-md-bottom-sheet-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)
|
|
},
|
|
})
|