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:
co-authored by
Claude Fable 5.1
parent
07759cd664
commit
944431668d
@@ -494,7 +494,9 @@ An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `s
|
||||
|
||||
### `<x-bottom-sheet>`
|
||||
|
||||
An M3 bottom sheet, bound like `<x-modal>`: 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 `<x-modal>`: 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.
|
||||
|
||||
### `<x-carousel>`, `<x-carousel-item>`
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -127,7 +127,7 @@ it('slides a side sheet in from either edge, and is a pane from expanded when as
|
||||
|
||||
it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () {
|
||||
expect((string) $this->blade('<x-bottom-sheet title="Share via">Body</x-bottom-sheet>'))
|
||||
->toContain('...materialBottomSheet(false)')
|
||||
->toContain('...materialBottomSheet(false, JSON.parse(')
|
||||
->toContain('bg-scrim/32')
|
||||
->toContain('x-trap.inert.noscroll="open"')
|
||||
->toContain('rounded-t-corner-xl bg-surface-container-low')
|
||||
@@ -137,7 +137,7 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s
|
||||
->toContain('touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant ')
|
||||
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
|
||||
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
|
||||
->toContain('...materialBottomSheet(true)')
|
||||
->toContain('...materialBottomSheet(true, JSON.parse(')
|
||||
->not->toContain('bg-scrim/32')
|
||||
->not->toContain('aria-modal')
|
||||
->and((string) $this->blade('<x-bottom-sheet height="90dvh">Body</x-bottom-sheet>'))
|
||||
@@ -167,3 +167,29 @@ it('is M3\'s standard side sheet from expanded, and the modal one below', functi
|
||||
->not->toContain('data-standard')
|
||||
->not->toContain('expanded:hidden');
|
||||
});
|
||||
|
||||
it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', function () {
|
||||
$html = (string) $this->blade('<x-bottom-sheet title="Share via" snap>Body</x-bottom-sheet>');
|
||||
|
||||
expect($html)
|
||||
->toContain('--sheet-stop: 50dvh; --sheet-max-height: min(var(--sheet-stop), calc(100dvh - 72px))')
|
||||
->toContain('h-(--sheet-max-height) transition-[height]')
|
||||
->toContain('x-ref="probe"')
|
||||
->toContain('aria-label="Change the sheet height"')
|
||||
->toContain('x-bind:aria-label="handleLabel"')
|
||||
->toContain('x-on:click="activate()"')
|
||||
->toContain('<span class="sr-only" aria-live="polite" x-text="announcement"></span>')
|
||||
->toContain('25dvh')
|
||||
->toContain('90dvh')
|
||||
->toContain('Height 2 of 3')
|
||||
->and((string) $this->blade('<x-bottom-sheet heights="30dvh,60dvh" height="60dvh">Body</x-bottom-sheet>'))
|
||||
->toContain('--sheet-stop: 60dvh;')
|
||||
->toContain('30dvh')
|
||||
->and((string) $this->blade('<x-bottom-sheet :heights="[25, 50, 90]">Body</x-bottom-sheet>'))
|
||||
->toContain('--sheet-stop: 50dvh;')
|
||||
->and((string) $this->blade('<x-bottom-sheet heights="50dvh">Body</x-bottom-sheet>'))
|
||||
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
|
||||
->not->toContain('--sheet-stop')
|
||||
->not->toContain('sr-only')
|
||||
->toContain('aria-label="Close"');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user