Plan step 19, containment.md C-06, C-17 and C-20. The whole box scrolled, so a long dialog scrolled its headline and its buttons away; the header, the body and the action row are now separate rows of the flex column, each carrying the 24dp padding the box used to hold, and only the body scrolls. `separator` draws the divider under the header and above the actions, where it now stays put. The full-screen header is M3's 56dp (was 64) and its action bar 56 (was ~72). New `alert` prop for M3's "on web, basic dialogs should have the alert dialog role", and a `subtitle` is always the dialog's `aria-describedby`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
138 lines
7.6 KiB
PHP
138 lines
7.6 KiB
PHP
<?php
|
|
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
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('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('<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('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('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
|
|
|
|
expect($html)
|
|
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-medium:hidden">/')
|
|
->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code<\/p>/')
|
|
->not->toContain('<div class="shrink-0 px-6 pt-6 max-medium:hidden">')
|
|
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="shrink-0 px-6 pt-6 max-medium:hidden">')
|
|
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant">Only a subtitle<\/p>/');
|
|
});
|
|
|
|
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" separator>Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>');
|
|
|
|
expect($html)
|
|
->toContain('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3')
|
|
->not->toContain('overflow-y-auto rounded-corner-xl')
|
|
->toContain('<div class="shrink-0 px-6 pt-6">')
|
|
->toMatch('/<div id="[^"]+-body" class="min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant pt-4">/')
|
|
->toContain('flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6')
|
|
->and(substr_count($html, 'role="separator"'))->toBe(2);
|
|
});
|
|
|
|
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('flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden')
|
|
->toContain('max-medium:min-h-14 max-medium:border-t max-medium:border-outline-variant max-medium:pt-2 max-medium:pb-2');
|
|
});
|
|
|
|
it('leaves a pane open on Escape unless it is asked to close then too', function () {
|
|
expect((string) $this->blade('<x-drawer pane>Body</x-drawer>'))
|
|
->toContain('x-on:keydown.window.escape="if (open && ! wide) close()"')
|
|
->and((string) $this->blade('<x-drawer pane pane-close-on-escape>Body</x-drawer>'))
|
|
->toContain('x-on:keydown.window.escape="if (open) close()"')
|
|
->and((string) $this->blade('<x-drawer pane pane-close-on-escape :close-on-escape="false">Body</x-drawer>'))
|
|
->not->toContain('keydown.window.escape');
|
|
});
|
|
|
|
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 pane :with-close-button="false">Body</x-drawer>'))->toContain('aria-label="Close"')
|
|
->and((string) $this->blade('<x-drawer pane pane-close-on-escape :with-close-button="false">Body</x-drawer>'))->not->toContain('aria-label="Close"');
|
|
});
|
|
|
|
it('slides a side sheet in from either edge, and is a pane from expanded when asked', function () {
|
|
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
|
->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('<x-drawer side="start">Body</x-drawer>'))->toContain('start-0 medium:rounded-e-corner-lg')
|
|
->and((string) $this->blade('<x-drawer pane pane-width="28rem">Body</x-drawer>'))
|
|
->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('<x-bottom-sheet title="Share via">Body</x-bottom-sheet>'))
|
|
->toContain('...materialBottomSheet(false)')
|
|
->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')
|
|
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
|
|
->toContain('...materialBottomSheet(true)')
|
|
->not->toContain('bg-scrim/32')
|
|
->not->toContain('aria-modal');
|
|
});
|