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
+6 -4
View File
@@ -799,8 +799,10 @@ it('filters a menu\'s items as its field is typed into, moves the highlight with
it('opens a sheet-at-compact menu below 600px, focused on its first item, and the popover from 600px', function () {
$trigger = '#menus button:has-text("Photo")';
// The sheet is <x-bottom-sheet>, still x-show-based (not rewritten in this batch), so its
// openness shows as visibility, not the Popover API's :popover-open.
// The sheet is <x-bottom-sheet>, rewritten (plan step 36) but still x-show-driven — its CSS
// now animates open/close through @starting-style and transition-behavior: allow-discrete
// rather than Alpine's x-transition classes, but x-show still owns the element's display, so
// its openness shows as visibility, not the Popover API's :popover-open.
$dialog = '[role="dialog"][aria-label="Photo actions"]';
$addToAlbum = '#menus [role="menuitem"]:has-text("Add to album")';
@@ -847,8 +849,8 @@ it('filters in the sheet too, and a Livewire render keeps it open with the field
$page->script('window.eval("Livewire.first().touch()")');
// The sheet is <x-bottom-sheet>, still x-show-based, so open shows as visible, not
// :popover-open.
// The sheet is <x-bottom-sheet>, still x-show-driven (see the note above), so open shows as
// visible, not :popover-open.
$page->assertSeeIn('#renders', '1')
->assertVisible('[role="dialog"][aria-label="Assign to"]')
->assertScript(focused("getAttribute('aria-label') === 'Find a person'"))
+255 -2
View File
@@ -183,7 +183,7 @@ it('slides a side sheet in, traps the page, and closes on the scrim', function (
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null");
// This sheet's own scrim: the standard side sheet above it on the page is a `[data-sheet]` too.
// This sheet's own scrim: the standard side sheet above it on the page is a `[data-md-drawer]` too.
$page->script("{$sheet}.parentElement.querySelector(':scope > [aria-hidden=\"true\"]').click()");
$page->assertScript("getComputedStyle({$sheet}).display === 'none'")
@@ -200,7 +200,7 @@ it('dismisses a bottom sheet dragged down past a quarter of its height', functio
$page->script(<<<JS
(() => {
const handle = {$sheet}.querySelector('[data-drag-handle]');
const handle = {$sheet}.querySelector('[data-md-bottom-sheet-handle]');
const box = handle.getBoundingClientRect();
const x = box.x + box.width / 2, y = box.y + box.height / 2;
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, button: 0 }));
@@ -212,6 +212,259 @@ it('dismisses a bottom sheet dragged down past a quarter of its height', functio
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
});
class NestedDialogProbe extends Component
{
public bool $outer = false;
public bool $inner = false;
public function render(): string
{
return <<<'BLADE'
<div>
<x-button label="Open outer" wire:click="$set('outer', true)" />
<x-modal wire:model="outer" title="Outer">
<p>Outer body, long enough to scroll on its own: line one, line two, line three, line four, line five, line six, line seven, line eight, line nine, line ten, line eleven, line twelve.</p>
<x-button label="Open inner" wire:click="$set('inner', true)" />
<x-modal wire:model="inner" title="Inner">
<p>Inner body, long enough to scroll on its own, independent of the outer dialog it opened from: line one, line two, line three, line four, line five, line six, line seven, line eight, line nine, line ten, line eleven, line twelve.</p>
</x-modal>
</x-modal>
</div>
BLADE;
}
}
function nestedDialogProbe()
{
Livewire::component('nested-dialog-probe', NestedDialogProbe::class);
Route::middleware('web')->get('/nested-dialog-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:nested-dialog-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/nested-dialog-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('marks a scrolling dialog\'s divider at the edge with more content, and a nested dialog keeps its own marks', function () {
$page = nestedDialogProbe();
$page->click('button:has-text("Open outer")');
$outer = "[...document.querySelectorAll('dialog[open]')].find((d) => d.textContent.includes('Outer'))";
$outerHead = "{$outer}.querySelector('[data-md-modal-head]')";
$outerBody = "{$outer}.querySelector('[data-md-modal-body]')";
// Scrolled to the top: nothing hidden above, so the head's rule stays off.
$page->assertScript("getComputedStyle({$outerHead}, '::after').opacity === '0'");
$page->click('button:has-text("Open inner")');
$inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.textContent.includes('Inner'))";
$innerHead = "{$inner}.querySelector('[data-md-modal-head]')";
$innerBody = "{$inner}.querySelector('[data-md-modal-body]')";
$page->assertScript("getComputedStyle({$innerHead}, '::after').opacity === '0'");
$page->script("{$innerBody}.scrollTop = {$innerBody}.scrollHeight");
// The inner dialog's own body scrolled to its end marks its own head; the outer dialog, whose
// body was never touched, keeps its own mark off — each dialog watches only its own scroll.
$page->assertScript("getComputedStyle({$innerHead}, '::after').opacity === '1'")
->assertScript("getComputedStyle({$outerHead}, '::after').opacity === '0'");
});
it('shows a full-screen dialog\'s 56px phone bar below 600px, and the basic dialog above it', function () {
$page = containment()->resize(400, 800);
$page->click('#containment button:has-text("Full screen on a phone")');
$dialog = "document.querySelector('#containment dialog[open]')";
$bar = "{$dialog}.querySelector('[data-md-modal-bar]')";
$page->assertScript("getComputedStyle({$bar}).display !== 'none'")
->assertScript("Math.round({$bar}.getBoundingClientRect().height) === 56");
$page->resize(900, 800)
->assertScript("getComputedStyle({$bar}).display === 'none'");
});
it('draws separator dividers whatever the scroll, on a dialog that fits without scrolling', function () {
$page = containment();
$page->click('#containment button:has-text("Always divided (separator)")');
$dialog = "document.querySelector('#containment dialog[open]')";
$head = "{$dialog}.querySelector('[data-md-modal-head]')";
$actions = "{$dialog}.querySelector('[data-md-modal-actions]')";
$body = "{$dialog}.querySelector('[data-md-modal-body]')";
// The body fits without scrolling, so an unmarked dialog would show neither rule.
$page->assertScript("{$body}.scrollHeight <= {$body}.clientHeight + 1")
->assertScript("getComputedStyle({$head}, '::after').opacity === '1'")
->assertScript("getComputedStyle({$actions}, '::before').opacity === '1'");
});
it('opens a dialog with no fade under reduced motion', function () {
$page = visit('/material/containment', ['reducedMotion' => 'reduce'])->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->click('#containment button:has-text("Basic dialog")');
$dialog = "document.querySelector('#containment dialog[open]')";
// Reduced motion takes the spring's duration to zero, so the dialog is at its final opacity
// at once rather than mid-fade.
$page->assertScript("getComputedStyle({$dialog}).opacity === '1'");
});
class TextOnlyDialogProbe extends Component
{
public bool $open = false;
public function render(): string
{
return <<<'BLADE'
<div>
<x-button label="Open" wire:click="$set('open', true)" />
<x-modal wire:model="open" title="Read this">
<p>Line one of a body with nothing else focusable inside it, so the dialog hands its first focus to the scrolling body itself: line two, line three, line four, line five, line six, line seven, line eight, line nine, line ten, line eleven, line twelve, line thirteen, line fourteen, line fifteen.</p>
</x-modal>
</div>
BLADE;
}
}
function textOnlyDialogProbe()
{
Livewire::component('text-only-dialog-probe', TextOnlyDialogProbe::class);
Route::middleware('web')->get('/text-only-dialog-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:text-only-dialog-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/text-only-dialog-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('gives the scrolling body an inset focus ring when a text-only dialog opens from the keyboard', function () {
$page = textOnlyDialogProbe();
$page->script("document.querySelector('button').focus()");
$page->keys(':focus', 'Enter');
$body = "document.querySelector('dialog[open] [data-md-modal-body]')";
$page->assertScript("document.activeElement.matches('[data-md-modal-body]')")
->assertScript("getComputedStyle({$body}).outlineStyle === 'solid'")
->assertScript("getComputedStyle({$body}).outlineWidth === '3px'")
->assertScript("getComputedStyle({$body}).outlineOffset === '-3px'");
});
it('cycles a bottom sheet\'s preset heights from its handle, announcing each, and closes from the last', function () {
$page = containment();
$page->click('#containment button:has-text("Bottom sheet with preset heights")');
$sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')";
$handle = "{$sheet}.querySelector('[data-md-bottom-sheet-grip]')";
$announce = "{$sheet}.querySelector('[data-md-bottom-sheet-announce]')";
$page->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'");
$page->click($handle);
$page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '90dvh'")
->assertScript("{$announce}.textContent.trim() === 'Height 3 of 3'");
// From the last stop, activating the handle closes the sheet, as a handle with no stops does.
$page->click($handle);
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
});
it('settles a dragged bottom sheet on the nearest preset height', function () {
$page = containment();
$page->click('#containment button:has-text("Bottom sheet with preset heights")');
$sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')";
$page->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->wait(0.5);
// Drag up from the 50dvh stop a little way — not as far as 90dvh — and release: it settles
// back on 50dvh, the nearest stop, rather than creep on to the next one.
$page->script(<<<JS
(() => {
const handle = {$sheet}.querySelector('[data-md-bottom-sheet-handle]');
const box = handle.getBoundingClientRect();
const x = box.x + box.width / 2, y = box.y + box.height / 2;
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, button: 0 }));
window.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y - 40 }));
window.dispatchEvent(new PointerEvent('pointerup', { clientX: x, clientY: y - 40 }));
})()
JS);
$page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'");
});
it('is a standard side sheet from 840px, without a scrim or a focus trap, and the modal one below', function () {
$page = containment()->resize(1000, 800);
$sheet = "document.querySelector('#containment aside[data-md-side]')";
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-drawer-scrim]')";
$toggle = "document.querySelector('#containment button:has-text(\"Show or hide the filters\")')";
// From 840px it opens co-planar (the example starts `open: true`): no scrim shown, and the
// rest of the page stays interactive.
$page->assertScript("getComputedStyle({$sheet}).position === 'relative'")
->assertScript("getComputedStyle({$scrim}).display === 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null")
->assertScript("! {$sheet}.hasAttribute('inert')");
$page->script("{$toggle}.click()");
$page->assertScript("getComputedStyle({$sheet}).opacity === '0'");
// Below 840px the same sheet becomes the modal one: fixed, with a scrim, and the page inert.
$page->resize(700, 800)
->script("{$toggle}.click()");
$page->assertScript("getComputedStyle({$sheet}).position === 'fixed'")
->assertScript("getComputedStyle({$scrim}).display !== 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null");
});
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment();
@@ -23,6 +23,7 @@ dataset('containment components', [
'list-item',
'modal',
'drawer',
'bottom-sheet',
]);
/**
+1 -1
View File
@@ -306,7 +306,7 @@ it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as th
->toContain('aria-modal="true"')
->toContain('x-trap.inert.noscroll="open"')
->toContain('aria-label="Photo actions"')
->toContain('rounded-t-corner-xl bg-surface-container-low')
->toContain('data-md-bottom-sheet-panel')
// The same slot, drawn again as a menu: the items keep their roles and their actions.
->toMatch("/<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" role=\"menu\" aria-label=\"Photo actions\" data-md-menu-sheet/")
->and(substr_count($html, 'role="menuitem"'))->toBe(4)
+21 -9
View File
@@ -200,18 +200,19 @@ it('slides a side sheet in from either edge', function () {
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, JSON.parse(')
->toContain('bg-scrim/32')
->toContain('data-md-bottom-sheet-scrim')
->toContain('x-trap.inert.noscroll="open"')
->toContain('rounded-t-corner-xl bg-surface-container-low')
->toContain('data-drag-handle')
->toContain('data-md-bottom-sheet-panel')
->toContain('data-md-bottom-sheet-handle')
->toContain('data-md-bottom-sheet-grip')
->toContain('aria-modal="true"')
->toContain('py-5.5')
->toContain('touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant ')
->toContain('md-focus-ring md-touch-target')
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(true, JSON.parse(')
->not->toContain('bg-scrim/32')
->not->toContain('data-md-bottom-sheet-scrim')
->not->toContain('aria-modal')
->toContain('data-md-standard')
->and((string) $this->blade('<x-bottom-sheet height="90dvh">Body</x-bottom-sheet>'))
->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))');
});
@@ -258,12 +259,13 @@ it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', fun
expect($html)
->toContain('--sheet-stop: 50dvh; --sheet-max-height: min(var(--sheet-stop), calc(100dvh - 72px))')
->toContain('h-(--sheet-max-height) transition-[height]')
->toContain('data-md-preset')
->toContain('x-ref="probe"')
->toContain('data-md-bottom-sheet-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>')
->toMatch('/<span data-md-bottom-sheet-announce class="md-visually-hidden" aria-live="polite" x-text="announcement"><\/span>/')
->toContain('25dvh')
->toContain('90dvh')
->toContain('Height 2 of 3')
@@ -275,6 +277,16 @@ it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', fun
->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')
->not->toContain('data-md-preset')
->toContain('aria-label="Close"');
});
it('draws the bottom sheet from a stylesheet imported from the containment block and by the menu', function () {
expect(File::get(__DIR__.'/../../../resources/css/components.css'))
->toContain("@import './components/bottom-sheet.css';")
->and(File::get(__DIR__.'/../../../resources/css/components/menu.css'))
->toContain("@import './bottom-sheet.css';")
->and(File::get(__DIR__.'/../../../resources/js/bottom-sheet.js'))
->toContain('data-md-bottom-sheet-handle')
->not->toContain('data-drag-handle');
});