From 944431668d8acf176261a1a179da9d0f3dc96bb5 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:42:37 +0200 Subject: [PATCH] Give a bottom sheet M3's preset heights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 4 +- resources/js/bottom-sheet.js | 137 +++++++++++++++++- .../views/components/bottom-sheet.blade.php | 84 ++++++++++- .../showcase/sections/containment.blade.php | 12 ++ tests/Feature/Components/OverlayTest.php | 30 +++- 5 files changed, 252 insertions(+), 15 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 06f979fe..4ecea7f2 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -494,7 +494,9 @@ An M3 side sheet, bound like ``; `close()` in scope. Props: `title`, `s ### `` -An M3 bottom sheet, bound like ``: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`50dvh` — M3 caps a modal sheet's initial position at half the screen; whatever you pass is held under a ceiling of the screen less M3's 72dp top margin), `actions` slot. +An M3 bottom sheet, bound like ``: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`50dvh` — M3 caps a modal sheet's initial position at half the screen; whatever you pass is held under a ceiling of the screen less M3's 72dp top margin), `heights`/`snap`, `actions` slot. + +`heights` gives it M3's **preset heights** — `heights="25dvh,50dvh,90dvh"`, `:heights="[25, 50, 90]"` (a bare number is `dvh`) or a JSON list; `snap` is the shorthand for those three. The sheet then takes its stop's height and opens at the stop that equals `height`, or at the first. The drag handle is the height control M3 requires beside the drag: activating it (press, Enter, Space) moves to the next stop and announces it, and from the last stop it closes the sheet; dragging settles on the nearest stop, or closes below the smallest. Fewer than two stops is no stops — use `height` for a single height. ### ``, `` diff --git a/resources/js/bottom-sheet.js b/resources/js/bottom-sheet.js index 91ad955a..015d323e 100644 --- a/resources/js/bottom-sheet.js +++ b/resources/js/bottom-sheet.js @@ -1,23 +1,114 @@ /** - * `materialBottomSheet(standard)`: the behaviour of ``, spread into its x-data - * alongside `open` (entangled with Livewire, or the surrounding Alpine scope's). + * `materialBottomSheet(standard, presets)`: the behaviour of ``, 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) + }, }) diff --git a/resources/views/components/bottom-sheet.blade.php b/resources/views/components/bottom-sheet.blade.php index d8a85acb..58bbf498 100644 --- a/resources/views/components/bottom-sheet.blade.php +++ b/resources/views/components/bottom-sheet.blade.php @@ -15,6 +15,24 @@ default is `50dvh`, under a ceiling of the screen less M3's 72dp top margin. Content scrolls inside. + `heights` gives the sheet M3's **preset heights**: a list of stops — `heights="25dvh,50dvh,90dvh"`, + `:heights="[25, 50, 90]"` (a bare number is read as `dvh`) or a JSON list — and the sheet then + takes the height of its current stop rather than sizing itself to its content. `snap` is the + shorthand for the library's three, `25dvh`, `50dvh` and `90dvh`. It opens at the stop that equals + `height`, or at the first one; fewer than two stops is no stops at all, since a single height is + what `height` already says. + + With stops the drag handle is M3's height control, which is the accessibility rule behind them: + "the drag handle can be dragged **or selected** to cycle through preset heights", "any drag-only + action needs a single-pointer alternative", "Tab focuses the drag handle; Space/Enter toggles + between available heights", and "selecting the drag handle toggles preset heights **or closes the + sheet**". So activating the handle — a click, Enter or Space, since it is a button — moves to the + next stop and announces it in a live region, and from the last stop it closes the sheet, which is + also what a handle with no stops does. A drag runs the sheet's height with the pointer and + settles on the nearest stop on release, or closes it below the smallest one or on a downward + flick (docs/reference/m3/components-actions-communication-containment.md § Bottom sheets → + Behaviour, Accessibility). + The handle is drawn 32×4px and pressed 48×48: `touch-target` on the button and 22px above and below it, which is M3's "drag handle has an accessible 48dp hit target" and SheetDefaults.kt's `DragHandleVerticalPadding = 22.dp` (docs/reference/m3/components-actions-communication-containment.md § Bottom sheets → Specs). --}} @@ -23,16 +41,51 @@ 'title' => null, 'standard' => false, 'height' => '50dvh', + 'heights' => null, + 'snap' => false, ]) @php $model = $attributes->wire('model')->value() ?: null; $id = $attributes->get('id') ?? 'material-bottom-sheet-'.substr(md5($model.'|'.$title), 0, 10); + + // A list, a JSON list or a comma-separated one; a bare number is a percentage of the screen, + // which is how M3 talks about a sheet's position ("capped at 50% of screen height"). + $stops = match (true) { + is_array($heights) => $heights, + is_string($heights) && str_starts_with(trim($heights), '[') => json_decode($heights, true) ?: [], + filled($heights) => explode(',', (string) $heights), + (bool) $snap => ['25dvh', '50dvh', '90dvh'], + default => [], + }; + + $stops = array_values(array_filter(array_map( + fn ($stop): string => is_numeric($stop) ? ((float) $stop).'dvh' : trim((string) $stop), + $stops, + ), 'filled')); + + // M3 asks for a non-drag way to change height "if multiple preset heights exist"; one stop is + // not multiple, and `height` already says where a single-height sheet opens. + $stops = count($stops) > 1 ? $stops : []; + $start = (int) (array_search($height, $stops, true) ?: 0); + + $presets = \Illuminate\Support\Js::from([ + 'stops' => $stops, + 'start' => $start, + 'labels' => [ + 'change' => __('Change the sheet height'), + 'close' => __('Close'), + 'announce' => array_map( + fn (int $index): string => __('Height :position of :count', ['position' => $index + 1, 'count' => count($stops)]), + array_keys($stops), + ), + ], + ]); @endphp @endunless + @if ($stops !== []) + {{-- A stop is a CSS length, and only the browser can say what `25dvh` is in pixels; this + measures one when a drag has to find the nearest. --}} + + @endif +