Rewrite the bottom sheet without Tailwind

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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 21:16:22 +02:00
co-authored by Claude Sonnet 5
parent 9dd527f303
commit 287340b066
10 changed files with 469 additions and 41 deletions
@@ -33,9 +33,19 @@
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). --}}
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,
@@ -89,15 +99,18 @@
@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 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>
<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" aria-hidden="true" class="pointer-events-none invisible fixed start-0 top-0 w-0"></div>
<div x-ref="probe" data-md-bottom-sheet-probe aria-hidden="true"></div>
@endif
<section
@@ -105,35 +118,26 @@
x-show="open"
x-ref="sheet"
@unless ($standard) x-trap.inert.noscroll="open" @endunless
x-transition:enter="transition-[translate] duration-(--md-sys-motion-spatial-default-duration) ease-emphasized-decelerate"
x-transition:enter-start="translate-y-full"
x-transition:enter-end="translate-y-0"
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="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', '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'),
]) }}
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id']) }}
>
<div class="flex shrink-0 cursor-grab justify-center py-5.5 active:cursor-grabbing" data-drag-handle>
<div data-md-bottom-sheet-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"
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()"
@@ -141,19 +145,19 @@
</div>
@if ($stops !== [])
<span class="sr-only" aria-live="polite" x-text="announcement"></span>
<span data-md-bottom-sheet-announce class="md-visually-hidden" 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">
<div x-ref="body" data-md-bottom-sheet-body>
@if (filled($title))
<h2 id="{{ $id }}-title" class="mb-4 type-title-lg">{{ $title }}</h2>
<h2 id="{{ $id }}-title" data-md-bottom-sheet-title>{{ $title }}</h2>
@endif
{{ $slot }}
</div>
@isset($actions)
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6">{{ $actions }}</div>
<div data-md-bottom-sheet-actions>{{ $actions }}</div>
@endisset
</section>
</div>