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
164 lines
7.9 KiB
PHP
164 lines
7.9 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)
|
||
<div data-md-bottom-sheet-scrim x-cloak x-show="open" 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>
|