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
@@ -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
<div
x-data="{
...materialBottomSheet({{ $standard ? 'true' : 'false' }}),
...materialBottomSheet({{ $standard ? 'true' : 'false' }}, {{ $presets }}),
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
}"
x-on:keydown.window.escape="if (open && ! standard) close()"
@@ -41,6 +94,12 @@
<div x-cloak x-show="open" x-transition.opacity.duration.200ms x-on:click="close()" class="fixed inset-0 z-40 bg-scrim/32" aria-hidden="true"></div>
@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. --}}
<div x-ref="probe" aria-hidden="true" class="pointer-events-none invisible fixed start-0 top-0 w-0"></div>
@endif
<section
x-cloak
x-show="open"
@@ -52,22 +111,39 @@
x-transition:leave="transition-[translate] duration-(--md-sys-motion-effects-default-duration) ease-emphasized-accelerate"
x-transition:leave-start="translate-y-0"
x-transition:leave-end="translate-y-full"
x-bind:style="dragged ? { translate: `0 ${dragged}px`, transition: 'none' } : {}"
x-bind:style="sheetStyle"
x-on:pointerdown="dragStart($event)"
id="{{ $id }}"
role="dialog"
@unless ($standard) aria-modal="true" @endunless
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-max-height: min({{ $height }}, calc(100dvh - 72px))"
@if ($stops === [])
style="--sheet-max-height: min({{ $height }}, calc(100dvh - 72px))"
@else
style="--sheet-stop: {{ $stops[$start] }}; --sheet-max-height: min(var(--sheet-stop), calc(100dvh - 72px))"
@endif
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1',
// With stops the sheet is the height of its stop, not of its content, and it moves
// between them on the spatial track — anything that changes size does.
'h-(--sheet-max-height) transition-[height] duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default' => $stops !== [],
$attributes->get('class'),
]) }}
>
<div class="flex shrink-0 cursor-grab justify-center py-5.5 active:cursor-grabbing" data-drag-handle>
<button type="button" class="touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant outline-offset-4 focus-visible:outline-3 focus-visible:outline-secondary" aria-label="{{ __('Close') }}" x-on:click="close()"></button>
<button
type="button"
class="touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant outline-offset-4 focus-visible:outline-3 focus-visible:outline-secondary"
aria-label="{{ $stops === [] ? __('Close') : __('Change the sheet height') }}"
@if ($stops !== []) x-bind:aria-label="handleLabel" @endif
x-on:click="activate()"
></button>
</div>
@if ($stops !== [])
<span class="sr-only" aria-live="polite" x-text="announcement"></span>
@endif
<div x-ref="body" class="min-h-0 flex-1 overflow-y-auto px-6 pb-6">
@if (filled($title))
<h2 id="{{ $id }}-title" class="mb-4 type-title-lg">{{ $title }}</h2>
@@ -184,6 +184,18 @@
</x-list>
</x-bottom-sheet>
</div>
<div x-data="{ open: false }">
<x-button label="Bottom sheet with preset heights" variant="tonal" x-on:click="open = true" />
<x-bottom-sheet title="Nearby places" snap>
<p class="type-body-md text-on-surface-variant">Press the drag handle, or focus it and press Enter, to move to the next height; from the last it closes. Dragging it settles on the nearest.</p>
<x-list>
<x-list-item title="Hafen" description="240 m · open until 23:00" icon="location_on" />
<x-list-item title="Stadtbibliothek" description="600 m · closes at 18:00" icon="location_on" />
<x-list-item title="Seepromenade" description="1.1 km · always open" icon="location_on" />
</x-list>
</x-bottom-sheet>
</div>
BLADE,
];
@endphp