BLADE; } }; Livewire::test($component) ->assertSeeHtml('assertSeeHtml('wire:ignore.self') ->assertSeeHtml('open: window.Livewire.find(') ->assertSeeHtml("entangle('confirming').live") ->assertSeeHtml('close() { this.open = typeof this.open === \'boolean\' ? false : null }') ->assertSeeHtml('rounded-corner-xl bg-surface-container-high') ->assertSeeHtml('type-headline-sm') ->assertSee('This cannot be undone.') ->assertSeeHtml('text-center') ->assertDontSeeHtml('wire:model="confirming"'); }); it('uses the surrounding Alpine scope without wire:model, and stays open when persistent', function () { $html = (string) $this->blade('Text'); expect($html) ->toContain('x-data="{ close() { this.open = false } }"') ->toContain('x-on:cancel.prevent=""') ->not->toContain('x-on:click.self') ->toContain('max-medium:h-dvh') ->toContain('aria-label="Close"'); }); it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title', function () { $html = (string) $this->blade('Text'); expect($html) ->toMatch('/

/') ->toMatch('/

Scan the code<\/p>/') ->not->toContain('class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"') ->and((string) $this->blade('Text'))->toContain('class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"') ->and((string) $this->blade('Text'))->toMatch('/

Only a subtitle<\/p>/'); }); it('pins a dialog\'s headline and actions and scrolls only the body between them', function () { $html = (string) $this->blade('Body'); expect($html) ->toContain('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3') ->not->toContain('overflow-y-auto rounded-corner-xl') ->toMatch('//') ->toMatch('/

\s*
Body<\/div>\s*<\/div>/') ->toMatch('//'); }); it('divides a scrolling body from its header and actions only while content is hidden past them', function () { $html = (string) $this->blade('Body'); // 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-overflow-top') ->not->toContain('data-overflow-bottom') ->not->toContain('data-separator') ->not->toContain('role="separator"') ->and(substr_count($html, 'data-dialog-head'))->toBe(1) ->and(substr_count($html, 'data-dialog-actions'))->toBe(1); expect(File::get(__DIR__.'/../../../resources/css/components/dialog.css')) ->toContain('dialog[data-overflow-top] > * > [data-dialog-head]::after') ->toContain('dialog[data-overflow-bottom] > * > [data-dialog-actions]::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 then the rule appears without a fade. ->toContain('transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);') ->and(File::get(__DIR__.'/../../../resources/css/tailwind.css'))->toContain("@import './components/dialog.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-dialog-content]'); }); it('draws the dividers whatever the scroll with separator, and only where a body has neighbours', function () { expect((string) $this->blade('Body')) ->toMatch('/toMatch('/and(File::get(__DIR__.'/../../../resources/css/components/dialog.css')) ->toContain('[data-dialog-head][data-separator]::after') ->toContain('[data-dialog-actions][data-separator]::before'); // Without a body there is nothing to divide, and the header keeps M3's 24dp to the actions. expect((string) $this->blade('')) ->not->toContain('data-dialog-head') ->not->toContain('data-dialog-actions') ->not->toContain('data-separator') ->not->toContain('x-dialog-dividers') ->toContain('class="shrink-0 px-6 pt-6"') ->toContain('flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6 pt-6"'); // No header: the body keeps its 24dp top and only the actions carry a rule; no actions: its 24dp bottom. expect((string) $this->blade('Body')) ->not->toContain('data-dialog-head') ->toContain('data-dialog-actions') ->toContain('text-on-surface-variant pt-6 pb-2"') ->and((string) $this->blade('Body')) ->toContain('data-dialog-head') ->not->toContain('data-dialog-actions') ->toContain('text-on-surface-variant pt-2 pb-6"'); }); 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('Body'); // Both carry the hook: the bar shows only below medium, and this header only from it. expect($titled) ->toMatch('/toMatch('/toContain('text-on-surface-variant pt-2 max-medium:pt-4 pb-2"') ->and(substr_count($titled, 'data-dialog-head'))->toBe(2); // A subtitle keeps the header on a phone, so the rule stays under it rather than under the bar. $subtitled = (string) $this->blade('Body'); expect($subtitled) ->toMatch('/toContain('text-on-surface-variant pt-2 pb-6"') ->and(substr_count($subtitled, 'data-dialog-head'))->toBe(1) ->and((string) $this->blade('Body')) ->toMatch('/blade('This cannot be undone.'); 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('Body')) ->not->toContain('role="alertdialog"') ->toMatch('/aria-describedby="material-dialog-[a-z0-9]+-subtitle"/') ->and((string) $this->blade('Body')) ->not->toContain('aria-describedby'); }); it('gives a full-screen dialog M3\'s 56px header and action bar', function () { expect((string) $this->blade('Body')) ->toContain('flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden') ->toContain('max-medium:min-h-14 max-medium:pt-2 max-medium:pb-2') // Its rule follows the scroll, like any dialog's, instead of always showing. ->not->toContain('max-medium:border-t'); }); it('leaves a pane open on Escape unless it is asked to close then too', function () { expect((string) $this->blade('Body')) ->toContain('x-on:keydown.window.escape="if (open && ! wide) close()"') ->and((string) $this->blade('Body')) ->toContain('x-on:keydown.window.escape="if (open) close()"') ->and((string) $this->blade('Body')) ->not->toContain('keydown.window.escape'); }); it('always offers a way out of a side sheet, as M3 requires', function () { expect((string) $this->blade('Body'))->toContain('aria-label="Close"') ->and((string) $this->blade('Body'))->not->toContain('aria-label="Close"') ->and((string) $this->blade('Body'))->toContain('aria-label="Close"') ->and((string) $this->blade('Body'))->toContain('aria-label="Close"') ->and((string) $this->blade('Body'))->toContain('aria-label="Close"') ->and((string) $this->blade('Body'))->not->toContain('aria-label="Close"'); }); it('puts a side sheet\'s actions on the left, in M3\'s 72dp row', function () { expect((string) $this->blade('Body')) ->toContain('flex min-h-18 shrink-0 flex-wrap items-center justify-start gap-2 pt-4'); }); it('slides a side sheet in from either edge, and is a pane from expanded when asked', function () { expect((string) $this->blade('Body')) ->toContain('x-trap.inert.noscroll="open && ! wide"') ->toContain('end-0 medium:rounded-s-corner-lg') ->toContain('bg-surface-container-low') ->toContain('role="dialog"') ->toContain('aria-label="Close"') ->and((string) $this->blade('Body'))->toContain('start-0 medium:rounded-e-corner-lg') ->and((string) $this->blade('Body')) ->toContain('data-pane') ->toContain('--pane-width: 28rem') ->toContain("matchMedia('(width >= 52.5rem)')"); }); it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () { expect((string) $this->blade('Body')) ->toContain('...materialBottomSheet(false, JSON.parse(') ->toContain('bg-scrim/32') ->toContain('x-trap.inert.noscroll="open"') ->toContain('rounded-t-corner-xl bg-surface-container-low') ->toContain('data-drag-handle') ->toContain('aria-modal="true"') ->toContain('py-5.5') ->toContain('touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant ') ->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))') ->and((string) $this->blade('Body')) ->toContain('...materialBottomSheet(true, JSON.parse(') ->not->toContain('bg-scrim/32') ->not->toContain('aria-modal') ->and((string) $this->blade('Body')) ->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('Body'); expect($html) ->toContain('data-standard') ->toContain('expanded:sticky expanded:top-0 expanded:h-dvh expanded:shrink-0 expanded:self-start') ->toContain('expanded:relative expanded:top-0 expanded:z-auto expanded:h-full expanded:rounded-corner-none expanded:bg-surface expanded:shadow-none') ->toContain('expanded:border-s expanded:border-outline-variant') ->toContain('--sheet-width: min(25rem, 25rem)') ->toContain("matchMedia('(width >= 52.5rem)')") ->toContain('x-trap.inert.noscroll="open && ! wide"') ->toContain('bg-scrim/32 expanded:hidden') ->toContain('aria-label="Close"') ->and((string) $this->blade('Body')) ->toContain('expanded:border-e expanded:border-outline-variant') ->toContain('--sheet-width: min(30rem, 25rem)') ->and((string) $this->blade('Body')) ->toContain('data-pane') ->not->toContain('data-standard') ->and((string) $this->blade('Body')) ->not->toContain('data-standard') ->not->toContain('expanded:hidden'); }); it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', function () { $html = (string) $this->blade('Body'); expect($html) ->toContain('--sheet-stop: 50dvh; --sheet-max-height: min(var(--sheet-stop), calc(100dvh - 72px))') ->toContain('h-(--sheet-max-height) transition-[height]') ->toContain('x-ref="probe"') ->toContain('aria-label="Change the sheet height"') ->toContain('x-bind:aria-label="handleLabel"') ->toContain('x-on:click="activate()"') ->toContain('') ->toContain('25dvh') ->toContain('90dvh') ->toContain('Height 2 of 3') ->and((string) $this->blade('Body')) ->toContain('--sheet-stop: 60dvh;') ->toContain('30dvh') ->and((string) $this->blade('Body')) ->toContain('--sheet-stop: 50dvh;') ->and((string) $this->blade('Body')) ->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))') ->not->toContain('--sheet-stop') ->not->toContain('sr-only') ->toContain('aria-label="Close"'); });