Merge branch 'worktree-agent-ae17d09efb642a925'
This commit is contained in:
@@ -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>`
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
Reference in New Issue
Block a user