Files
livewire-material/tests/Feature/Components/OverlayTest.php
T
Andreas Reinhold / reiniandClaude Fable 5.1 944431668d Give a bottom sheet M3's preset heights
Plan step 23, containment.md § Missing ("Bottom sheets: preset heights
and the handle's cycle-through-heights behaviour"). `heights` (or `snap`,
which is 25/50/90dvh) lists the stops a sheet moves between; it then
takes its stop's height and opens at the stop that equals `height`, or at
the first. Fewer than two stops is no stops, since M3 only requires the
non-drag alternative "if multiple preset heights exist".

That alternative is the drag handle, which M3 names a button: activating
it moves to the next stop and announces it in a live region, and from the
last stop it closes the sheet — M3's "selecting the drag handle toggles
preset heights or closes the sheet", and the same thing a handle with no
stops has always done. A drag now runs the sheet's height with the
pointer and settles on the nearest stop, or closes below the smallest one
or on a downward flick.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:42:37 +02:00

196 lines
11 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 &amp;&amp; ! 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('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>'))
->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('<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, 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('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(true, JSON.parse(')
->not->toContain('bg-scrim/32')
->not->toContain('aria-modal')
->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-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('<x-drawer standard side="start" width="30rem">Body</x-drawer>'))
->toContain('expanded:border-e expanded:border-outline-variant')
->toContain('--sheet-width: min(30rem, 25rem)')
->and((string) $this->blade('<x-drawer standard pane>Body</x-drawer>'))
->toContain('data-pane')
->not->toContain('data-standard')
->and((string) $this->blade('<x-drawer title="Filters">Body</x-drawer>'))
->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('<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('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('<span class="sr-only" 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('sr-only')
->toContain('aria-label="Close"');
});