Both scrims are shown and hidden by Alpine's `x-show`, and faded by CSS:
closing set `display: none` inline in the same frame as the opacity
change, and `transition-behavior: allow-discrete` on `display` held the
element for the fade. Firefox does not transition `display`, so no
transition started there and the scrim vanished at once — what
ContainmentTest's two fade-out tests caught.
Alpine now holds `display` itself: `x-transition` with a class string
waits for the element's computed transition-duration (the effects token,
zero under reduced motion) before hiding it, the same in every engine,
and registering `enter` too means reopening during the fade cancels the
pending hide. `display` leaves the scrims' CSS transitions, so Chrome and
Safari do not hold it a second time after Alpine's hold.
The Alpine transition also holds `$nextTick` until the sheet is shown,
which menu.js's openSheet() relies on ("Alpine holds $nextTick until
then") and which had been lost when the bottom sheet moved off
x-transition. Without it `focusItem()` could run while the sheet was
still `display: none`, so ActionsTest's sheet-at-compact menu and its
filter field did not take the focus in Firefox and Safari; both pass
there again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
165 lines
8.1 KiB
PHP
165 lines
8.1 KiB
PHP
{{-- An M3 bottom sheet: secondary content or actions anchored to the bottom of the screen.
|
||
|
||
Modal by default: over a scrim, the page inert and still, dismissed by the scrim, Escape, or a
|
||
downward drag on its handle (or anywhere on the sheet when its content is scrolled to the top).
|
||
`standard` makes it part of the page instead — no scrim, nothing inert, and it stays until closed.
|
||
The open state works as for `<x-drawer>`: `wire:model` (a flag or an id, written back `false` or
|
||
`null` on close) or `open` in the surrounding Alpine scope; `close()` is in scope inside it.
|
||
|
||
SheetBottomTokens (androidx Compose Material 3, Apache-2.0): surface-container-low, extra-large
|
||
top corners, elevation 1, a 32×4px drag handle in on-surface-variant; 640px wide at most, centred
|
||
on a wide screen; it rises on emphasized decelerate. `title` and `actions` as on a dialog.
|
||
|
||
`height` is where it opens, and M3 caps a modal sheet's initial position at half the screen —
|
||
"if content exceeds that, it can be pulled to full screen and scrolled internally" — so the
|
||
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's wrapper is the drag target and M3's 48dp hit area: the button's own visual bar is
|
||
32×4px, and `md-touch-target` (foundation/interaction.css) extends it, while the wrapper's own
|
||
22px top/bottom padding (`SheetDefaults.kt`'s `DragHandleVerticalPadding`) gives the row its
|
||
M3-specified height regardless — 4 + 22 + 22 = 48px (C-01). The button renders the shared
|
||
`md-focus-ring` and `md-touch-target` classes rather than a hand-rolled ring: unlike the
|
||
datepicker's day, its own visible bar is not a smaller indicator drawn inside a bigger box the
|
||
classes cannot reach — the box the classes draw is the button's whole hit area.
|
||
|
||
The root renders `data-md-bottom-sheet` (data-md-open, data-md-standard); the parts are
|
||
`data-md-bottom-sheet-scrim`, `-panel` (data-md-preset while stops exist), `-handle` (the drag
|
||
target), `-grip` (the 32×4px bar), `-announce`, `-body`, `-title`, `-actions` — drawn by
|
||
resources/css/components/bottom-sheet.css. `<x-menu sheet-at-compact>` renders this component
|
||
for its compact presentation. --}}
|
||
|
||
@props([
|
||
'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' }}, {{ $presets }}),
|
||
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
||
}"
|
||
x-on:keydown.window.escape="if (open && ! standard) close()"
|
||
x-bind:data-md-open="open ? '' : null"
|
||
data-md-bottom-sheet
|
||
@if ($standard) data-md-standard @endif
|
||
>
|
||
@unless ($standard)
|
||
{{-- Alpine's CSS transition holds the scrim for its fade, as in drawer.blade.php. --}}
|
||
<div data-md-bottom-sheet-scrim x-cloak x-show="open" x-transition:enter="md-scrim-transition" x-transition:leave="md-scrim-transition" x-on:click="close()" 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" data-md-bottom-sheet-probe aria-hidden="true"></div>
|
||
@endif
|
||
|
||
<section
|
||
x-cloak
|
||
x-show="open"
|
||
x-ref="sheet"
|
||
@unless ($standard) x-trap.inert.noscroll="open" @endunless
|
||
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
|
||
data-md-bottom-sheet-panel
|
||
@if ($stops !== []) data-md-preset @endif
|
||
@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']) }}
|
||
>
|
||
<div data-md-bottom-sheet-handle>
|
||
<button
|
||
type="button"
|
||
class="md-focus-ring md-touch-target"
|
||
data-md-bottom-sheet-grip
|
||
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 data-md-bottom-sheet-announce class="md-visually-hidden" aria-live="polite" x-text="announcement"></span>
|
||
@endif
|
||
|
||
<div x-ref="body" data-md-bottom-sheet-body>
|
||
@if (filled($title))
|
||
<h2 id="{{ $id }}-title" data-md-bottom-sheet-title>{{ $title }}</h2>
|
||
@endif
|
||
|
||
{{ $slot }}
|
||
</div>
|
||
|
||
@isset($actions)
|
||
<div data-md-bottom-sheet-actions>{{ $actions }}</div>
|
||
@endisset
|
||
</section>
|
||
</div>
|