Merge branch 'worktree-agent-ae17d09efb642a925'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 11:03:41 +02:00
8 changed files with 275 additions and 29 deletions
@@ -515,7 +515,7 @@ An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; cl
</x-modal> </x-modal>
``` ```
Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (a divider under the headline and above the actions), `persistent` (no Escape or scrim), `fullscreen` (whole screen on a compact window, below `medium`, for forms — M3 allows a full-screen dialog only there), `alert` (`role="alertdialog"` for a dialog that interrupts to say something important — not for forms), `box-class`. The headline and the action row are pinned and only the body between them scrolls, as M3 requires, so do not put `overflow` on `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead. Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (draw the dividers under the headline and above the actions always, not only while the body scrolls), `persistent` (no Escape or scrim), `fullscreen` (whole screen on a compact window, below `medium`, for forms — M3 allows a full-screen dialog only there), `alert` (`role="alertdialog"` for a dialog that interrupts to say something important — not for forms), `box-class`. The headline and the action row are pinned and only the body between them scrolls, as M3 requires, so do not put `overflow` on `box-class`. Every dialog divides a scrolling body from them by itself: a 1px outline-variant rule under the header once the body is scrolled away from its top, and one over the actions while more is below (under the close-and-title bar on a phone for `fullscreen`); a body that fits shows neither, and nothing moves when one appears. So do not add an `<x-divider>` at the top or bottom of the body, and do not wrap the body's content in a scroll container of its own — the rules follow the body's scroll, and content a Livewire render adds updates them. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead.
### `<x-drawer>` ### `<x-drawer>`
+58
View File
@@ -0,0 +1,58 @@
/*
* `<x-modal>`'s dividers: the rule under the pinned header and the rule over the pinned actions.
*
* M3 lists a divider in the anatomy of both dialogs, 1dp high (docs/reference/m3/
* components-actions-communication-containment.md § Dialogs Anatomy, Specs), in outline-variant
* (DividerTokens: Color = OutlineVariant, Thickness = 1dp, androidx Compose Material 3,
* Apache-2.0). A scrolling dialog keeps its title and buttons pinned and scrolls only what is
* between them (§ Dialogs Behaviour), and a rule there only says something while content is
* hidden on its far side: the one under the header shows once the body is scrolled away from its
* top, the one over the actions while more of the body is below. resources/js/dialog.js marks the
* `<dialog>` with `data-overflow-top` and `data-overflow-bottom`; a row with `data-separator` (the
* `separator` prop) draws its rule whatever the scroll.
*
* Each rule is a pseudo-element laid over the edge of its own row's padding, as Material Web's
* dialog lays its dividers at the bottom of the headline and the top of the actions
* (material-components/material-web, dialog/internal/_dialog.scss): it takes no room, so showing
* it moves nothing, and it lies outside the body's scrollport, so no content scrolls over it. The
* rule is full-bleed, the width of the dialog. Only the head and actions of a dialog that has a
* body carry the hooks (resources/views/components/modal.blade.php).
*
* The child combinators tie a mark to its own dialog's rows, never to those of a dialog opened
* from inside its body. The rule fades on the effects-fast token, which reduced motion sets to
* zero (resources/css/tokens/motion.css), so there it simply appears.
*/
@layer components {
[data-dialog-head],
[data-dialog-actions] {
position: relative;
}
[data-dialog-head]::after,
[data-dialog-actions]::before {
content: '';
position: absolute;
inset-inline: 0;
height: 1px;
background-color: var(--md-sys-color-outline-variant);
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-dialog-head]::after {
bottom: 0;
}
[data-dialog-actions]::before {
top: 0;
}
dialog[data-overflow-top] > * > [data-dialog-head]::after,
dialog[data-overflow-bottom] > * > [data-dialog-actions]::before,
[data-dialog-head][data-separator]::after,
[data-dialog-actions][data-separator]::before {
opacity: 1;
}
}
+1
View File
@@ -24,6 +24,7 @@
@import './components/groups.css'; @import './components/groups.css';
@import './components/list.css'; @import './components/list.css';
@import './components/collapse.css'; @import './components/collapse.css';
@import './components/dialog.css';
@import './components/field.css'; @import './components/field.css';
@import './components/menu.css'; @import './components/menu.css';
@import './components/selection.css'; @import './components/selection.css';
+50
View File
@@ -0,0 +1,50 @@
/**
* `x-dialog-dividers`, on `<x-modal>`'s body the one part of a dialog that scrolls: marks the
* `<dialog>` with `data-overflow-top` while the body is scrolled away from its top, and
* `data-overflow-bottom` while more of it is below. resources/css/components/dialog.css draws
* those as the dividers under the pinned header and over the pinned actions; a body that fits
* marks neither.
*
* The marks go on the `<dialog>` rather than the body because the dialog is `wire:ignore.self`: a
* Livewire morph hands the body back the server's attributes, which would wipe a mark until the
* next scroll, but it never touches the dialog's own.
*
* Measured on scroll, and whenever the body or what is in it changes size: the body when the window
* or the dialog does (opening, too a closed dialog has no size, and the observer reports the
* one it opens to), and the element the component wraps around the slot when a morph, an image or
* a disclosure makes the content taller or shorter while the body stays at its cap.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.directive('dialog-dividers', (el, _, { cleanup }) => {
const dialog = el.closest('dialog')
if (!dialog) {
return
}
// A pixel of slack: at a fractional device pixel ratio the end of a scroll can stop a
// fraction short of `scrollHeight`, which is rounded.
const measure = () => {
dialog.toggleAttribute('data-overflow-top', el.scrollTop >= 1)
dialog.toggleAttribute('data-overflow-bottom', el.scrollHeight - el.clientHeight - el.scrollTop >= 1)
}
const resizes = new ResizeObserver(measure)
resizes.observe(el)
const content = el.querySelector(':scope > [data-dialog-content]')
if (content) {
resizes.observe(content)
}
el.addEventListener('scroll', measure, { passive: true })
cleanup(() => {
resizes.disconnect()
el.removeEventListener('scroll', measure)
dialog.removeAttribute('data-overflow-top')
dialog.removeAttribute('data-overflow-bottom')
})
})
})
+1
View File
@@ -17,6 +17,7 @@ import './rich-tooltip.js'
import './progress.js' import './progress.js'
import './list-rows.js' import './list-rows.js'
import './bottom-sheet.js' import './bottom-sheet.js'
import './dialog.js'
import './carousel.js' import './carousel.js'
import './chips.js' import './chips.js'
import './field.js' import './field.js'
+42 -20
View File
@@ -28,7 +28,21 @@
the buttons at the bottom" (docs/reference/m3/components-actions-communication-containment.md the buttons at the bottom" (docs/reference/m3/components-actions-communication-containment.md
§ Dialogs Behaviour): the header and the action row are their own rows of the flex column § Dialogs Behaviour): the header and the action row are their own rows of the flex column
and only the body between them scrolls, each with the 24dp padding the box used to carry. and only the body between them scrolls, each with the 24dp padding the box used to carry.
`separator` draws M3's divider under the pinned header and above the pinned actions.
Between that body and the rows pinned around it go M3's optional dividers (the 1dp "Divider"
in both dialogs' anatomy, § Dialogs Anatomy, Specs), each only while content is hidden on
its far side: the rule under the header once the body is scrolled away from its top, the rule
over the actions while more of the body is below, so a body that fits draws neither. On a
phone a full-screen dialog's top rule lies under its close-and-title bar, unless a subtitle
or an icon keeps the header there, and its action bar's rule follows the scroll like any
other. `separator` draws both rules whatever the scroll. The gaps M3 gives are split around
the rules 8px above and 8px below the top one (16dp title to body), 8px above and 16px below
the bottom one (24dp body to actions) so the scrolling body keeps room inside its edges for
a floating label or a focus ring, and a rule that appears moves nothing. Without a body there
is nothing to divide, and no rule. resources/css/components/dialog.css draws the rules;
`x-dialog-dividers` (resources/js/dialog.js), on the body, says which of them show, watching
the body and the one element wrapped around the slot, so content a Livewire render adds
counts.
`alert` is M3's "on web, basic dialogs should have the alert dialog role": it sets `alert` is M3's "on web, basic dialogs should have the alert dialog role": it sets
`role="alertdialog"` and points `aria-describedby` at the body, for the dialog that `role="alertdialog"` and points `aria-describedby` at the body, for the dialog that
@@ -51,9 +65,12 @@
$model = $attributes->wire('model')->value() ?: null; $model = $attributes->wire('model')->value() ?: null;
$id = $attributes->get('id') ?? 'material-dialog-'.substr(md5($model.'|'.$title), 0, 10); $id = $attributes->get('id') ?? 'material-dialog-'.substr(md5($model.'|'.$title), 0, 10);
$header = filled($title) || filled($subtitle) || $icon; $header = filled($title) || filled($subtitle) || $icon;
$body = $slot->isNotEmpty();
// On a phone a full-screen dialog's bar names it, and the header hides unless an icon or a subtitle keeps it.
$barAboveBody = $fullscreen && (! $header || (! $icon && blank($subtitle)));
$describedBy = implode(' ', array_filter([ $describedBy = implode(' ', array_filter([
filled($subtitle) ? $id.'-subtitle' : null, filled($subtitle) ? $id.'-subtitle' : null,
$alert && $slot->isNotEmpty() ? $id.'-body' : null, $alert && $body ? $id.'-body' : null,
])); ]));
@endphp @endphp
@@ -89,7 +106,10 @@
$boxClass, $boxClass,
])> ])>
@if ($fullscreen) @if ($fullscreen)
<div class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"> <div
@if ($body && $barAboveBody) data-dialog-head @if ($separator) data-separator @endif @endif
class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"
>
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" /> <x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
@if (filled($title)) @if (filled($title))
@@ -100,7 +120,10 @@
@if ($header) @if ($header)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}} {{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['shrink-0 px-6 pt-6', 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])> <div
@if ($body) data-dialog-head @if ($separator) data-separator @endif @endif
@class(['shrink-0 px-6 pt-6', 'pb-2' => $body, 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])
>
@if ($icon) @if ($icon)
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" /> <x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif @endif
@@ -112,33 +135,32 @@
@if (filled($subtitle)) @if (filled($subtitle))
<p id="{{ $id }}-subtitle" @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p> <p id="{{ $id }}-subtitle" @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif @endif
@if ($separator)
<x-livewire-material::divider class="mt-4" />
@endif
</div> </div>
@endif @endif
@if ($slot->isNotEmpty()) @if ($body)
<div id="{{ $id }}-body" @class([ <div id="{{ $id }}-body" x-dialog-dividers @class([
'min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant', 'min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant',
'pt-4' => $header, 'pt-2' => $header,
'max-medium:pt-4' => $header && $barAboveBody,
'pt-6' => ! $header, 'pt-6' => ! $header,
'pb-2' => isset($actions),
'pb-6' => ! isset($actions), 'pb-6' => ! isset($actions),
])> ])>
{{ $slot }} <div data-dialog-content>{{ $slot }}</div>
</div> </div>
@endif @endif
@isset($actions) @isset($actions)
@if ($separator) <div
<x-livewire-material::divider class="shrink-0" /> @if ($body) data-dialog-actions @if ($separator) data-separator @endif @endif
@endif @class([
'flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6',
<div @class([ 'pt-4' => $body,
'flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6', 'pt-6' => ! $body,
'max-medium:min-h-14 max-medium:border-t max-medium:border-outline-variant max-medium:pt-2 max-medium:pb-2' => $fullscreen, 'max-medium:min-h-14 max-medium:pt-2 max-medium:pb-2' => $fullscreen,
])> ])
>
{{ $actions }} {{ $actions }}
</div> </div>
@endisset @endisset
@@ -153,6 +153,40 @@
</x-modal> </x-modal>
</div> </div>
BLADE, BLADE,
'A dialog whose body scrolls: dividers only while content is hidden' => <<<'BLADE'
<div x-data="{ open: false }">
<x-button label="Long body" variant="tonal" x-on:click="open = true" />
<x-modal title="Terms of the share link" subtitle="Scroll them: a rule appears under the headline once the text leaves its top, and the one over the buttons goes at its end.">
<div class="space-y-4">
<p>A share link opens the files you chose, and only those. Adding a file to the folder later does not add it to a link you have already sent; make a new link for it.</p>
<p>Anyone who has the link can open it until it expires. Forwarding the link forwards that access, so send it to the people who need it and to nobody else.</p>
<p>A password, when you set one, is asked for every time the link is opened. Send it separately from the link, by another channel, so one intercepted message is not enough.</p>
<p>The link expires at the time you picked. After that it shows a page saying so, and downloads that were still running stop at once.</p>
<p>You can end a link early from the list of shares. Ending it takes effect immediately for everyone, including people who opened it a moment ago.</p>
<p>Downloads are counted per file. The count is shown to you and never to the people you shared with.</p>
<p>Files larger than 2 GB are offered as a single archive only when the whole share fits under the archive limit; otherwise each file downloads on its own.</p>
<p>Previews are generated for images, PDFs and plain text. Other types show their name, size and an icon.</p>
<p>Deleting a file you shared removes it from every link that holds it. People who open such a link see the files that are left.</p>
<p>These terms apply to every link you create from now on. Links you created before keep the terms they were made under.</p>
</div>
<x-slot:actions>
<x-button label="Decline" x-on:click="close()" />
<x-button label="Accept" x-on:click="close()" />
</x-slot:actions>
</x-modal>
</div>
<div x-data="{ open: false }">
<x-button label="Always divided (separator)" variant="tonal" x-on:click="open = true" />
<x-modal title="Who can open the link" separator>
<x-radio label="Audience" name="showcase-dialog-audience" value="password" :options="[['id' => 'anyone', 'name' => 'Anyone with the link'], ['id' => 'password', 'name' => 'Anyone with the password'], ['id' => 'team', 'name' => 'My team only']]" />
<x-slot:actions>
<x-button label="Cancel" x-on:click="close()" />
<x-button label="Save" x-on:click="close()" />
</x-slot:actions>
</x-modal>
</div>
BLADE,
'A standard side sheet: co-planar from expanded, modal below' => <<<'BLADE' 'A standard side sheet: co-planar from expanded, modal below' => <<<'BLADE'
<div x-data="{ open: true }" class="w-full"> <div x-data="{ open: true }" class="w-full">
<div class="expanded:flex expanded:items-start expanded:gap-6"> <div class="expanded:flex expanded:items-start expanded:gap-6">
+88 -8
View File
@@ -1,5 +1,6 @@
<?php <?php
use Illuminate\Support\Facades\File;
use Livewire\Component; use Livewire\Component;
use Livewire\Livewire; use Livewire\Livewire;
@@ -50,21 +51,98 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
expect($html) expect($html)
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-medium:hidden">/') ->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>/') ->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">') ->not->toContain('class="shrink-0 px-6 pt-6 pb-2 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 title="Help" fullscreen>Text</x-modal>'))->toContain('class="shrink-0 px-6 pt-6 pb-2 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>/'); ->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 () { 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>'); $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) expect($html)
->toContain('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3') ->toContain('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3')
->not->toContain('overflow-y-auto rounded-corner-xl') ->not->toContain('overflow-y-auto rounded-corner-xl')
->toContain('<div class="shrink-0 px-6 pt-6">') ->toMatch('/<div\s+data-dialog-head\s+class="shrink-0 px-6 pt-6 pb-2"\s*>/')
->toMatch('/<div id="[^"]+-body" class="min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant pt-4">/') ->toMatch('/<div id="[^"]+-body" x-dialog-dividers class="min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant pt-2 pb-2">\s*<div data-dialog-content>Body<\/div>\s*<\/div>/')
->toContain('flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6') ->toMatch('/<div\s+data-dialog-actions\s+class="flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6 pt-4"\s*>/');
->and(substr_count($html, 'role="separator"'))->toBe(2); });
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-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/material.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('<x-modal title="Terms" separator>Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>'))
->toMatch('/<div\s+data-dialog-head\s+data-separator\s+class="shrink-0 px-6 pt-6 pb-2"/')
->toMatch('/<div\s+data-dialog-actions\s+data-separator\s+class="flex shrink-0/')
->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('<x-modal title="Discard the draft?" separator><x-slot:actions><button>Discard</button></x-slot:actions></x-modal>'))
->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('<x-modal>Body<x-slot:actions><button>OK</button></x-slot:actions></x-modal>'))
->not->toContain('data-dialog-head')
->toContain('data-dialog-actions')
->toContain('text-on-surface-variant pt-6 pb-2"')
->and((string) $this->blade('<x-modal title="About">Body</x-modal>'))
->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('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>');
// Both carry the hook: the bar shows only below medium, and this header only from it.
expect($titled)
->toMatch('/<div\s+data-dialog-head\s+class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"/')
->toMatch('/<div\s+data-dialog-head\s+class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"/')
->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('<x-modal title="Share settings" subtitle="Who can see it" fullscreen>Body</x-modal>');
expect($subtitled)
->toMatch('/<div\s+class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"/')
->toContain('text-on-surface-variant pt-2 pb-6"')
->and(substr_count($subtitled, 'data-dialog-head'))->toBe(1)
->and((string) $this->blade('<x-modal fullscreen>Body</x-modal>'))
->toMatch('/<div\s+data-dialog-head\s+class="flex h-14 shrink-0/');
}); });
it('is an alert dialog when it interrupts, and describes itself by its subtitle', function () { it('is an alert dialog when it interrupts, and describes itself by its subtitle', function () {
@@ -85,7 +163,9 @@ it('is an alert dialog when it interrupts, and describes itself by its subtitle'
it('gives a full-screen dialog M3\'s 56px header and action bar', function () { 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>')) 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('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'); ->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 () { it('leaves a pane open on Escape unless it is asked to close then too', function () {