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:
co-authored by
Claude Sonnet 5
parent
9dd527f303
commit
287340b066
@@ -63,5 +63,6 @@
|
||||
@import './components/list.css';
|
||||
@import './components/modal.css';
|
||||
@import './components/drawer.css';
|
||||
@import './components/bottom-sheet.css';
|
||||
|
||||
/* Navigation */
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* `<x-bottom-sheet>`: M3's modal and standard bottom sheets (SheetBottomTokens.kt, androidx
|
||||
* Compose Material 3, Apache-2.0; docs/reference/m3/
|
||||
* components-actions-communication-containment.md § Bottom sheets → Specs).
|
||||
*
|
||||
* [data-md-bottom-sheet] the wrapper; data-md-open, data-md-standard
|
||||
* [data-md-bottom-sheet-scrim] modal only
|
||||
* [data-md-bottom-sheet-probe] hidden, measures a stop's px height for a drag to settle on
|
||||
* [data-md-bottom-sheet-panel] surface-container-low, extra-large top corners, elevation 1;
|
||||
* data-md-preset while `heights`/`snap` gives it stops
|
||||
* [data-md-bottom-sheet-handle] the drag target and M3's 48dp hit area (C-01)
|
||||
* [data-md-bottom-sheet-grip] the 32×4px bar, the shared classes' touch target
|
||||
* [data-md-bottom-sheet-announce] a live region, only while stops exist
|
||||
* [data-md-bottom-sheet-body], [data-md-bottom-sheet-title], [data-md-bottom-sheet-actions]
|
||||
*
|
||||
* 640px max width, centred; 28px top corners only (`DockedContainerShape` = CornerExtraLargeTop);
|
||||
* 50dvh default open height under a ceiling of the screen less M3's 72dp top margin (C-19). It
|
||||
* rises on emphasized decelerate and only falls on emphasized accelerate — the asymmetric timing a
|
||||
* more specific selector for the open state supplies, since a CSS transition always takes its
|
||||
* duration and easing from the *after-change* style (the technique resources/css/components/
|
||||
* drawer.css also uses). `data-md-preset` adds the height-between-stops transition on the spatial
|
||||
* spring, independent of the open/close slide.
|
||||
*
|
||||
* The handle's own 22px top/bottom padding (`SheetDefaults.kt`'s `DragHandleVerticalPadding`) is a
|
||||
* literal — 22 is not on the measurement scale — matching alert.css's 12px for the same reason.
|
||||
* The grip button draws the shared `md-focus-ring` and `md-touch-target` classes rather than a
|
||||
* hand-rolled ring: its own visible bar is not a smaller indicator drawn inside a bigger box those
|
||||
* classes cannot reach (unlike the datepicker's day) — the box they draw *is* the button's whole
|
||||
* hit area, so nothing here refines them further.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@layer material.components {
|
||||
[data-md-bottom-sheet-scrim] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
transition-property: opacity, display;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-default);
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-md-bottom-sheet-scrim] {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet-probe] {
|
||||
position: fixed;
|
||||
inset-inline-start: 0;
|
||||
top: 0;
|
||||
width: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet-panel] {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin-inline: auto;
|
||||
flex-direction: column;
|
||||
max-height: var(--sheet-max-height);
|
||||
touch-action: pan-y;
|
||||
border-radius: var(--md-sys-shape-corner-xl) var(--md-sys-shape-corner-xl) var(--md-sys-shape-corner-none) var(--md-sys-shape-corner-none);
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
box-shadow: var(--md-sys-elevation-1);
|
||||
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||
translate: 0 100%;
|
||||
transition-property: translate, display;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet][data-md-open] [data-md-bottom-sheet-panel] {
|
||||
translate: 0 0;
|
||||
transition-duration: var(--md-sys-motion-spatial-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-md-bottom-sheet][data-md-open] [data-md-bottom-sheet-panel] {
|
||||
translate: 0 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* With stops the panel is the height of its current one, moving between them on the spatial
|
||||
spring, independent of the translate that opens and closes the sheet. */
|
||||
[data-md-bottom-sheet-panel][data-md-preset] {
|
||||
height: var(--sheet-max-height);
|
||||
transition-property: translate, height, display;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate), var(--md-sys-motion-spatial-default), var(--md-sys-motion-easing-emphasized-accelerate);
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet][data-md-open] [data-md-bottom-sheet-panel][data-md-preset] {
|
||||
transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate), var(--md-sys-motion-spatial-default), var(--md-sys-motion-easing-emphasized-decelerate);
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet-handle] {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
padding-block: 22px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet-handle]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet-grip] {
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
background-color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet-body] {
|
||||
min-height: 0;
|
||||
flex: 1 1 0%;
|
||||
overflow-y: auto;
|
||||
padding-inline: var(--md-sys-measurement-space300);
|
||||
padding-bottom: var(--md-sys-measurement-space300);
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet-title] {
|
||||
margin-bottom: var(--md-sys-measurement-space200);
|
||||
font: var(--md-sys-typescale-title-lg);
|
||||
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet-actions] {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--md-sys-measurement-space100);
|
||||
padding-inline: var(--md-sys-measurement-space300);
|
||||
padding-bottom: var(--md-sys-measurement-space300);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@import './icon.css';
|
||||
@import './bottom-sheet.css';
|
||||
|
||||
@layer material.components {
|
||||
[data-md-menu] {
|
||||
|
||||
@@ -111,7 +111,7 @@ window.materialBottomSheet = (standard = false, presets = {}) => ({
|
||||
|
||||
dragStart(event) {
|
||||
const sheet = this.$refs.sheet
|
||||
const onHandle = event.target.closest('[data-drag-handle]')
|
||||
const onHandle = event.target.closest('[data-md-bottom-sheet-handle]')
|
||||
const atTop = this.$refs.body.scrollTop <= 0
|
||||
|
||||
if (event.button !== 0 || (!onHandle && !atTop) || event.target.closest('input, textarea, select, [contenteditable]')) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'"))
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user