A modal side sheet, bottom sheet or the modal rail closed on any Escape the window heard, so a dialog opened from a sheet, a menu, select list or searchable choice inside one, a sheet opened from a sheet, and a sheet inside a dialog each closed two layers on one press. And a dialog or a second sheet rendered elsewhere on the page sat inside the `aria-hidden` the first sheet's `x-trap.inert` put on its siblings, so a screen reader could not read it, while the sheet's focus trap took every Tab inside the dialog back to the inert sheet. resources/js/layers.js adds `x-layer`, on each of those panels beside its `x-trap`. An Escape is the panel's only when nothing has handled it and the nearest open layer around its target is the panel itself - not an open dialog, popover or customizable select, nor a panel inside it; the panel claims it with preventDefault(), which also keeps a dialog around it from cancelling, and dispatches `material-escape`, which the views close on. A panel that opens lifts `aria-hidden` from its own ancestors and puts it back on close only where a panel still open hides them; materialShowModal() does the same for `<x-modal>`, whose new `x-trap.noautofocus.noreturn` pauses the sheet's focus trap while it is open and moves no focus of its own. The searchable choice, the search view and the supporting pane's sheet now preventDefault() the Escape they act on, so the dialog or sheet around them stays. Four browser tests stack the layers every way above and fail without the change in Chrome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
352 lines
21 KiB
PHP
352 lines
21 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\File;
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
|
|
|
it('opens a native dialog entangled with a Livewire property, out of the morph\'s reach', function () {
|
|
$component = new class extends Component
|
|
{
|
|
public bool $confirming = false;
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<x-modal wire:model="confirming" title="Delete this share?" subtitle="This cannot be undone." icon="delete">
|
|
<x-slot:actions><button>Delete</button></x-slot:actions>
|
|
</x-modal>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
};
|
|
|
|
Livewire::test($component)
|
|
->assertSeeHtml('<dialog')
|
|
->assertSeeHtml('data-md-modal')
|
|
->assertSeeHtml('wire:ignore.self')
|
|
->assertSeeHtml('open: window.Livewire.find(')
|
|
->assertSeeHtml("entangle('confirming').live")
|
|
->assertSeeHtml('close() { this.open = typeof this.open === \'boolean\' ? false : null }')
|
|
->assertSeeHtml('data-md-modal-box')
|
|
->assertSeeHtml('data-md-modal-title')
|
|
->assertSee('This cannot be undone.')
|
|
->assertSeeHtml('data-md-icon')
|
|
->assertDontSeeHtml('wire:model="confirming"');
|
|
});
|
|
|
|
it('uses the surrounding Alpine scope without wire:model, and stays open when persistent', function () {
|
|
$html = (string) $this->blade('<x-modal title="Help" persistent fullscreen>Text</x-modal>');
|
|
|
|
expect($html)
|
|
->toContain('x-data="{ close() { this.open = false } }"')
|
|
->toContain('x-on:cancel.prevent=""')
|
|
->not->toContain('x-on:click.self')
|
|
->toContain('data-md-fullscreen')
|
|
->toContain('aria-label="Close"');
|
|
});
|
|
|
|
it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title, in the CSS', function () {
|
|
$html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
|
|
|
|
expect($html)
|
|
->toMatch('/<h2 id="[^"]+-title" data-md-modal-title>Enable 2FA<\/h2>/')
|
|
->toMatch('/<p id="[^"]+-subtitle" data-md-modal-subtitle>Scan the code<\/p>/')
|
|
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))
|
|
->toMatch('/<p id="[^"]+-subtitle" data-md-modal-subtitle>Only a subtitle<\/p>/')
|
|
->not->toContain('data-md-modal-title');
|
|
|
|
// The markup is the same at every width; modal.css (ContainmentStylesheetsTest) decides which
|
|
// of the bar/head/title/subtitle show below 600px, keyed on the icon and the subtitle.
|
|
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
|
|
->toContain('[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [data-md-modal-subtitle]))')
|
|
->toContain('[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon])) > [data-md-modal-title]')
|
|
->toContain('[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon])) > [data-md-modal-subtitle]');
|
|
|
|
// Each through its own box, so a basic dialog opened inside a full-screen one's body keeps its
|
|
// title and padding on a phone.
|
|
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))->not->toMatch('/\[data-md-fullscreen\](?::[^ ]+)? \[/');
|
|
});
|
|
|
|
it('pins a dialog\'s headline and actions and scrolls only the body between them', function () {
|
|
$html = (string) $this->blade('<x-modal title="Terms" subtitle="Please read">Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>');
|
|
|
|
expect($html)
|
|
->toContain('data-md-modal-box')
|
|
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
|
|
->toMatch('/<div id="[^"]+-body" wire:ignore.self data-md-modal-body x-dialog-dividers>\s*<div data-md-modal-content>Body<\/div>\s*<\/div>/')
|
|
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s*>/');
|
|
});
|
|
|
|
it('divides a scrolling body from its header and actions only while content is hidden past them', function () {
|
|
$html = (string) $this->blade('<x-modal title="Terms">Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>');
|
|
|
|
// The script marks the dialog, which a morph leaves alone, and nothing is marked before it runs.
|
|
expect($html)
|
|
->toContain('x-dialog-dividers')
|
|
->not->toContain('data-md-overflow-top')
|
|
->not->toContain('data-md-overflow-bottom')
|
|
->not->toContain('data-md-separator')
|
|
->not->toContain('role="separator"')
|
|
->and(substr_count($html, 'data-md-modal-head'))->toBe(1)
|
|
->and(substr_count($html, 'data-md-modal-actions'))->toBe(1);
|
|
|
|
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
|
|
->toContain('dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-head][data-md-modal-divider]::after')
|
|
->toContain('dialog[data-md-overflow-bottom] > [data-md-modal-box] > [data-md-modal-actions][data-md-modal-divider]::before')
|
|
->toContain('background-color: var(--md-sys-color-outline-variant);')
|
|
->toContain('height: 1px;')
|
|
->toContain('position: absolute;')
|
|
// A token that reduced motion sets to zero, so there the rule simply appears.
|
|
->toContain('transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);')
|
|
->and(File::get(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/modal.css';")
|
|
->and(File::get(__DIR__.'/../../../resources/js/material.js'))->toContain("import './dialog.js'")
|
|
->and(File::get(__DIR__.'/../../../resources/js/dialog.js'))
|
|
->toContain("directive('dialog-dividers'")
|
|
->toContain('new ResizeObserver(measure)')
|
|
->toContain('[data-md-modal-content]');
|
|
});
|
|
|
|
it('draws the dividers whatever the scroll with separator, and only where a body has neighbours', function () {
|
|
expect((string) $this->blade('<x-modal title="Terms" separator>Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>'))
|
|
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s+data-md-separator\s*>/')
|
|
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s+data-md-separator\s*>/')
|
|
->and(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
|
|
->toContain('[data-md-modal-head][data-md-modal-divider][data-md-separator]::after')
|
|
->toContain('[data-md-modal-actions][data-md-modal-divider][data-md-separator]::before');
|
|
|
|
// Without a body there is nothing to divide, and the header carries no divider hook.
|
|
expect((string) $this->blade('<x-modal title="Discard the draft?" separator><x-slot:actions><button>Discard</button></x-slot:actions></x-modal>'))
|
|
->toMatch('/<div\s+data-md-modal-head\s*>/')
|
|
->not->toContain('data-md-modal-divider')
|
|
->not->toContain('data-md-separator')
|
|
->not->toContain('x-dialog-dividers');
|
|
|
|
// No header: only the actions carry the divider hook; no actions: nothing does.
|
|
expect((string) $this->blade('<x-modal>Body<x-slot:actions><button>OK</button></x-slot:actions></x-modal>'))
|
|
->not->toContain('data-md-modal-head')
|
|
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s*>/')
|
|
->and((string) $this->blade('<x-modal title="About">Body</x-modal>'))
|
|
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
|
|
->not->toContain('data-md-modal-actions');
|
|
});
|
|
|
|
it('puts a full-screen dialog\'s top divider under its bar on a phone, unless the header stays there', function () {
|
|
$titled = (string) $this->blade('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>');
|
|
|
|
// Both carry the divider hook here (no icon, no subtitle): the bar shows only below medium,
|
|
// and this header only from it (modal.css).
|
|
expect($titled)
|
|
->toMatch('/<div\s+data-md-modal-bar\s+data-md-modal-divider\s*>/')
|
|
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
|
|
->and(preg_match_all('/<div\s+data-md-modal-bar[\s>]/', $titled))->toBe(1)
|
|
->and(preg_match_all('/<div\s+data-md-modal-head[\s>]/', $titled))->toBe(1);
|
|
|
|
// A subtitle keeps the header on a phone, so the rule stays under it rather than under the bar.
|
|
$subtitled = (string) $this->blade('<x-modal title="Share settings" subtitle="Who can see it" fullscreen>Body</x-modal>');
|
|
|
|
expect($subtitled)
|
|
->toMatch('/<div\s+data-md-modal-bar\s*>/')
|
|
->not->toContain('data-md-modal-bar data-md-modal-divider')
|
|
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
|
|
->and((string) $this->blade('<x-modal fullscreen>Body</x-modal>'))
|
|
->toMatch('/<div\s+data-md-modal-bar\s+data-md-modal-divider\s*>/');
|
|
});
|
|
|
|
it('is an alert dialog when it interrupts, and describes itself by its subtitle', function () {
|
|
$alert = (string) $this->blade('<x-modal title="Delete this share?" subtitle="Recipients lose access." alert>This cannot be undone.</x-modal>');
|
|
|
|
preg_match('/id="(material-dialog-[a-z0-9]+)"/', $alert, $id);
|
|
|
|
expect($alert)
|
|
->toContain('role="alertdialog"')
|
|
->toContain("aria-describedby=\"{$id[1]}-subtitle {$id[1]}-body\"")
|
|
->and((string) $this->blade('<x-modal title="Share settings" subtitle="Who can see it">Body</x-modal>'))
|
|
->not->toContain('role="alertdialog"')
|
|
->toMatch('/aria-describedby="material-dialog-[a-z0-9]+-subtitle"/')
|
|
->and((string) $this->blade('<x-modal title="Share settings">Body</x-modal>'))
|
|
->not->toContain('aria-describedby');
|
|
});
|
|
|
|
it('gives a full-screen dialog M3\'s 56px header and action bar', function () {
|
|
expect((string) $this->blade('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>'))
|
|
->toContain('data-md-modal-bar')
|
|
->toContain('data-md-fullscreen')
|
|
->and(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
|
|
->toContain('height: var(--md-sys-measurement-space700);')
|
|
->toContain('min-height: var(--md-sys-measurement-space700);');
|
|
});
|
|
|
|
it('always offers a way out of a side sheet, as M3 requires', function () {
|
|
expect((string) $this->blade('<x-drawer title="Details">Body</x-drawer>'))->toContain('aria-label="Close"')
|
|
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false">Body</x-drawer>'))->not->toContain('aria-label="Close"')
|
|
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false" :close-on-escape="false">Body</x-drawer>'))->toContain('aria-label="Close"')
|
|
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false" without-backdrop-close>Body</x-drawer>'))->toContain('aria-label="Close"')
|
|
->and((string) $this->blade('<x-drawer standard :with-close-button="false">Body</x-drawer>'))->toContain('aria-label="Close"');
|
|
});
|
|
|
|
it('puts a side sheet\'s actions on the left, in M3\'s 72dp row', function () {
|
|
expect((string) $this->blade('<x-drawer title="Details">Body<x-slot:actions><button>Delete</button></x-slot:actions></x-drawer>'))
|
|
->toMatch('/<div\s+data-md-drawer-actions\s*>/');
|
|
});
|
|
|
|
it('slides a side sheet in from either edge', function () {
|
|
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
|
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
|
// One layer among others: an Escape above or inside it is not its own (resources/js/layers.js).
|
|
->toContain('x-layer="open && ! wide"')
|
|
->toContain('x-on:material-escape="close()"')
|
|
->not->toContain('keydown.window.escape')
|
|
->toContain('x-bind:data-md-drawer-collapsed="collapsed ? \'\' : null"')
|
|
->toContain('x-effect="settle(open)"')
|
|
->toContain('data-md-side="end"')
|
|
->toContain('data-md-drawer-sheet')
|
|
->toContain('role="dialog"')
|
|
->toContain('aria-label="Close"')
|
|
->and((string) $this->blade('<x-drawer side="start">Body</x-drawer>'))->toContain('data-md-side="start"');
|
|
});
|
|
|
|
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('data-md-bottom-sheet-scrim')
|
|
->toContain('x-trap.inert.noscroll="open"')
|
|
->toContain('x-layer="open" x-on:material-escape="close()"')
|
|
->toContain('data-md-bottom-sheet-panel')
|
|
->toContain('data-md-bottom-sheet-handle')
|
|
->toContain('data-md-bottom-sheet-grip')
|
|
->toContain('aria-modal="true"')
|
|
->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('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))');
|
|
});
|
|
|
|
it('is M3\'s standard side sheet from expanded, and the modal one below', function () {
|
|
$html = (string) $this->blade('<x-drawer standard title="Filters">Body</x-drawer>');
|
|
|
|
expect($html)
|
|
->toContain('data-md-standard')
|
|
->toContain('--sheet-width: min(400px, 400px)')
|
|
->toContain("matchMedia('(width >= 840px)')")
|
|
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
|
->toContain('x-bind:data-md-drawer-collapsed="collapsed ? \'\' : null"')
|
|
->toContain('x-effect="settle(open)"')
|
|
->toContain('aria-label="Close"')
|
|
->and((string) $this->blade('<x-drawer standard side="start" width="30rem">Body</x-drawer>'))
|
|
->toContain('data-md-side="start"')
|
|
->toContain('--sheet-width: min(30rem, 400px)')
|
|
->and((string) $this->blade('<x-drawer title="Filters">Body</x-drawer>'))
|
|
->not->toContain('data-md-standard');
|
|
|
|
$css = ComponentStylesheet::read('drawer');
|
|
$expanded = ['@media (width >= 840px)'];
|
|
|
|
expect($css->has('[data-md-drawer][data-md-standard]', $expanded))->toBeTrue()
|
|
->and($css->declarations('[data-md-drawer][data-md-standard][data-md-drawer-collapsed]', $expanded))->toBe(['display' => 'none'])
|
|
// Closing gives the room back as the sheet leaves (M3's side sheets, "Adaptive"): the root's
|
|
// inline size and the parent's gap spring to none, and the collapse waits for that exit.
|
|
->and($css->declarations('[data-md-drawer][data-md-standard]', $expanded))->toMatchArray([
|
|
'overflow' => 'clip',
|
|
'inline-size' => '0',
|
|
'margin-inline-start' => 'calc(-1 * var(--md-drawer-gap, 0px))',
|
|
'transition-property' => 'inline-size, margin-inline',
|
|
])
|
|
->and($css->declarations('[data-md-drawer][data-md-standard][data-md-open]', $expanded))->toMatchArray([
|
|
'inline-size' => 'min(var(--sheet-width), calc(100vw - 64px))',
|
|
'margin-inline' => '0',
|
|
])
|
|
// The old view's `expanded:hidden` on a standard sheet's scrim.
|
|
->and($css->declarations('[data-md-drawer][data-md-standard] > [data-md-drawer-scrim]', $expanded))->toBe(['display' => 'none'])
|
|
->and($css->declarations('[data-md-drawer][data-md-standard] > [data-md-drawer-sheet]', $expanded))->toHaveKey('box-shadow', 'none')
|
|
->and($css->has("[data-md-drawer][data-md-standard] > [data-md-drawer-sheet][data-md-side='end']", $expanded))->toBeTrue()
|
|
->and($css->has("[data-md-drawer][data-md-standard] > [data-md-drawer-sheet][data-md-side='start']", $expanded))->toBeTrue();
|
|
});
|
|
|
|
it('slides a side sheet out as it slid in, from its own edge in either direction, and fades its scrim both ways', function () {
|
|
$css = ComponentStylesheet::read('drawer');
|
|
|
|
// Alpine's CSS transition holds `display` through the exit (the view's `x-transition`), not
|
|
// `allow-discrete`, which Firefox does not honour for `display`.
|
|
expect($css->declarations('[data-md-drawer-sheet]'))
|
|
->toHaveKey('translate', 'var(--sheet-offset) 0')
|
|
->toHaveKey('transition-property', 'translate')
|
|
->not->toHaveKey('transition-behavior')
|
|
->not->toHaveKey('opacity')
|
|
->and($css->declarations("[data-md-drawer-sheet][data-md-side='end']:is([dir='rtl'], [dir='rtl'] *)"))->toBe(['--sheet-offset' => '-100%'])
|
|
->and($css->declarations("[data-md-drawer-sheet][data-md-side='start']:is([dir='rtl'], [dir='rtl'] *)"))->toBe(['--sheet-offset' => '100%'])
|
|
->and($css->declarations('[data-md-drawer][data-md-open] > [data-md-drawer-sheet]', ['@starting-style']))->toBe(['translate' => 'var(--sheet-offset) 0'])
|
|
->and($css->declarations('[data-md-drawer-scrim]'))->toHaveKey('opacity', '0')
|
|
->and($css->declarations('[data-md-drawer][data-md-open] > [data-md-drawer-scrim]'))->toBe(['opacity' => '1'])
|
|
->and((string) $this->blade('<x-drawer title="Filters">Body</x-drawer>'))
|
|
->toMatch('/<div data-md-drawer-scrim x-cloak x-show="open" x-transition:enter="md-transition" x-transition:leave="md-transition"/')
|
|
->toMatch('/<aside\s+x-cloak\s+x-show="open"\s+x-transition:enter="md-transition"\s+x-transition:leave="md-transition"/');
|
|
});
|
|
|
|
it('draws the drawer from a stylesheet imported from the containment block, with the pane prop gone', function () {
|
|
$view = File::get(__DIR__.'/../../../resources/views/components/drawer.blade.php');
|
|
|
|
expect($view)
|
|
->not->toContain("'pane'")
|
|
->not->toContain('paneWidth')
|
|
->not->toContain('paneCloseOnEscape')
|
|
->not->toContain('pane-width')
|
|
->not->toContain('pane-close-on-escape')
|
|
->not->toContain('data-pane')
|
|
->and(File::get(__DIR__.'/../../../resources/css/all.css'))
|
|
->toContain("@import './components/drawer.css';");
|
|
});
|
|
|
|
it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', function () {
|
|
$html = (string) $this->blade('<x-bottom-sheet title="Share via" snap>Body</x-bottom-sheet>');
|
|
|
|
expect($html)
|
|
->toContain('--sheet-stop: 50dvh; --sheet-max-height: min(var(--sheet-stop), calc(100dvh - 72px))')
|
|
->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()"')
|
|
->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')
|
|
->and((string) $this->blade('<x-bottom-sheet heights="30dvh,60dvh" height="60dvh">Body</x-bottom-sheet>'))
|
|
->toContain('--sheet-stop: 60dvh;')
|
|
->toContain('30dvh')
|
|
->and((string) $this->blade('<x-bottom-sheet :heights="[25, 50, 90]">Body</x-bottom-sheet>'))
|
|
->toContain('--sheet-stop: 50dvh;')
|
|
->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('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/all.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');
|
|
|
|
$css = ComponentStylesheet::read('bottom-sheet');
|
|
|
|
// The scrim fades out as well as in, a nested sheet keeps its own state, and the handle's ring
|
|
// stands 4px off its bar.
|
|
expect($css->declarations('[data-md-bottom-sheet-scrim]'))->toHaveKey('opacity', '0')
|
|
->and($css->declarations('[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-scrim]'))->toBe(['opacity' => '1'])
|
|
->and($css->declarations('[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-panel]', ['@starting-style']))->toBe(['translate' => '0 100%'])
|
|
->and($css->declarations('[data-md-bottom-sheet-grip]:focus-visible'))->toBe(['outline-offset' => '4px'])
|
|
->and($css->css)->not->toContain('[data-md-open] [data-md-');
|
|
});
|