diff --git a/resources/css/components.css b/resources/css/components.css index ab39623f..5479e500 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -61,5 +61,6 @@ @import './components/card.css'; @import './components/list-item.css'; @import './components/list.css'; +@import './components/modal.css'; /* Navigation */ diff --git a/resources/css/components/dialog.css b/resources/css/components/dialog.css deleted file mode 100644 index 74f2d82c..00000000 --- a/resources/css/components/dialog.css +++ /dev/null @@ -1,76 +0,0 @@ -/* - * ``'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 - * `` 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; - } -} - -/* - * The scrolling body is a keyboard stop of its own when nothing inside it can take focus: Chrome - * makes a scroll container focusable so the keyboard can scroll it, and `showModal()` hands it the - * dialog's first focus. It then showed the browser's own ring. It takes M3's focus indicator - * instead — 3px in secondary (tokens/state.css) — drawn inside its edge, because the dialog's - * rounded, overflow-hidden box would clip a ring drawn 2px outside. - */ -@layer components { - [data-dialog-body] { - outline: none; - } - - [data-dialog-body]:focus-visible { - outline: 3px solid var(--md-sys-color-secondary); - outline-offset: -3px; - } -} diff --git a/resources/css/components/modal.css b/resources/css/components/modal.css new file mode 100644 index 00000000..6f169341 --- /dev/null +++ b/resources/css/components/modal.css @@ -0,0 +1,271 @@ +/* + * ``: M3's basic and full-screen dialogs (DialogTokens.kt, FullScreenDialogTokens.kt, + * androidx Compose Material 3, Apache-2.0; docs/reference/m3/ + * components-actions-communication-containment.md § Dialogs → Specs). + * + * [data-md-modal] the ; data-md-fullscreen while `fullscreen` holds + * [data-md-modal-box] surface-container-high, extra-large corner, elevation 3 + * [data-md-modal-bar] the 56px phone-only header bar (fullscreen, below 600px) + * [data-md-modal-head] title/subtitle/icon, pinned + * [data-md-modal-body] the only part that scrolls; [data-md-modal-content] wraps the slot + * [data-md-modal-actions] pinned, trailing-aligned + * + * 560/280px width (min/max), 48px scrim margin, 28px corner and 24dp padding all match the specs + * page; a compact window's full-screen dialog swaps the box for the whole screen with a 56px bar + * (C-17: this used to be 64px) instead of the headline block. It opens on the fast spatial spring + * and closes at once — a native `` makes an exit transition awkward, since + * `@starting-style` only ever supplies an entry. + * + * The dividers (§ Dialogs → Anatomy, C-06): a 1px outline-variant rule under the pinned head and + * over the pinned actions, each only while the body has more content hidden on its side. + * resources/js/dialog.js marks the `` with data-md-overflow-top/-bottom as the body + * scrolls; `data-md-modal-divider` says an element takes part at all — the head and the actions + * always do while a body exists (structural: whichever follows or precedes it), the phone bar + * only when it is the row actually sitting above the body there (computed in the view, because a + * subtitle or an icon can keep the head on screen too, in which case the bar must not draw a rule + * the head already owns); `data-md-separator` (the `separator` prop) draws the rule whatever the + * scroll. The rule is a pseudo-element over the row's own edge, so showing it moves nothing. + * + * `[data-md-modal-body]:focus-visible` is Chrome's own focusable scroll container catching the + * dialog's first focus when nothing inside can take it — M3's 3px secondary indicator, drawn + * inside the edge because the box's rounded, overflow-hidden corner would clip one drawn outside. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; +@import './button.css'; + +@layer material.components { + [data-md-modal] { + margin: auto; + max-width: 560px; + min-width: 280px; + width: calc(100vw - 48px); + max-height: calc(100dvh - 48px); + overflow: visible; + padding: 0; + background-color: transparent; + color: var(--md-sys-color-on-surface); + opacity: 1; + scale: 1; + transition-property: opacity, scale; + transition-duration: var(--md-sys-motion-spatial-fast-duration); + transition-timing-function: var(--md-sys-motion-spatial-fast); + } + + @starting-style { + [data-md-modal] { + opacity: 0; + scale: 0.95; + } + } + + [data-md-modal]::backdrop { + background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent); + } + + @media (width < 600px) { + [data-md-modal][data-md-fullscreen] { + margin: 0; + width: 100%; + max-width: none; + min-width: 0; + height: 100dvh; + max-height: none; + } + } + + [data-md-modal-box] { + display: flex; + max-height: inherit; + flex-direction: column; + overflow: hidden; + border-radius: var(--md-sys-shape-corner-xl); + background-color: var(--md-sys-color-surface-container-high); + box-shadow: var(--md-sys-elevation-3); + } + + @media (width < 600px) { + [data-md-modal][data-md-fullscreen] > [data-md-modal-box] { + height: 100%; + border-radius: var(--md-sys-shape-corner-none); + padding-top: var(--material-safe-top, env(safe-area-inset-top)); + } + } + + /* The phone-only header bar: M3's full-screen dialog header, 56dp (C-17). */ + [data-md-modal-bar] { + display: flex; + height: var(--md-sys-measurement-space700); + flex-shrink: 0; + align-items: center; + gap: var(--md-sys-measurement-space50); + padding-inline: var(--md-sys-measurement-space50); + } + + @media (width >= 600px) { + [data-md-modal-bar] { + display: none; + } + } + + [data-md-modal-bar-title] { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font: var(--md-sys-typescale-title-lg); + letter-spacing: var(--md-sys-typescale-title-lg-tracking); + } + + [data-md-modal-head] { + flex-shrink: 0; + padding-inline: var(--md-sys-measurement-space300); + padding-top: var(--md-sys-measurement-space300); + } + + [data-md-modal-head]:has(+ [data-md-modal-body]) { + padding-bottom: var(--md-sys-measurement-space100); + } + + [data-md-modal-head]:has([data-md-icon]) { + text-align: center; + } + + /* On a phone the bar already names a plain dialog; the head hides unless an icon or a + subtitle keeps it there (the bar cannot hold either). */ + @media (width < 600px) { + [data-md-modal][data-md-fullscreen] [data-md-modal-head]:not(:has([data-md-icon])):not(:has([data-md-modal-subtitle])) { + display: none; + } + } + + [data-md-modal-head] > [data-md-icon] { + margin-inline: auto; + margin-bottom: var(--md-sys-measurement-space200); + color: var(--md-sys-color-secondary); + } + + [data-md-modal-title] { + font: var(--md-sys-typescale-headline-sm); + letter-spacing: var(--md-sys-typescale-headline-sm-tracking); + } + + @media (width < 600px) { + [data-md-modal][data-md-fullscreen]:not(:has([data-md-modal-head] [data-md-icon])) [data-md-modal-title] { + display: none; + } + } + + [data-md-modal-subtitle] { + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-body-md); + letter-spacing: var(--md-sys-typescale-body-md-tracking); + } + + [data-md-modal-title] ~ [data-md-modal-subtitle], + [data-md-modal-head] > [data-md-icon] ~ [data-md-modal-subtitle] { + margin-top: var(--md-sys-measurement-space200); + } + + @media (width < 600px) { + [data-md-modal][data-md-fullscreen]:not(:has([data-md-modal-head] [data-md-icon])) [data-md-modal-subtitle] { + margin-top: 0; + } + } + + [data-md-modal-body] { + min-height: 0; + flex: 1 1 0%; + overflow-y: auto; + padding-inline: var(--md-sys-measurement-space300); + padding-top: var(--md-sys-measurement-space300); + padding-bottom: var(--md-sys-measurement-space300); + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-body-md); + letter-spacing: var(--md-sys-typescale-body-md-tracking); + outline: none; + } + + [data-md-modal-body]:focus-visible { + outline: 3px solid var(--md-sys-color-secondary); + outline-offset: -3px; + } + + [data-md-modal-head] ~ [data-md-modal-body] { + padding-top: var(--md-sys-measurement-space100); + } + + @media (width < 600px) { + [data-md-modal][data-md-fullscreen] [data-md-modal-head]:not(:has([data-md-icon])):not(:has([data-md-modal-subtitle])) ~ [data-md-modal-body] { + padding-top: var(--md-sys-measurement-space200); + } + } + + [data-md-modal-body]:has(~ [data-md-modal-actions]) { + padding-bottom: var(--md-sys-measurement-space100); + } + + [data-md-modal-actions] { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--md-sys-measurement-space100); + padding-inline: var(--md-sys-measurement-space300); + padding-top: var(--md-sys-measurement-space300); + padding-bottom: var(--md-sys-measurement-space300); + } + + [data-md-modal-body] ~ [data-md-modal-actions] { + padding-top: var(--md-sys-measurement-space200); + } + + @media (width < 600px) { + [data-md-modal][data-md-fullscreen] [data-md-modal-actions] { + min-height: var(--md-sys-measurement-space700); + padding-top: var(--md-sys-measurement-space100); + padding-bottom: var(--md-sys-measurement-space100); + } + } + + /* The pinned-edge dividers: a pseudo-element over the row's own padding, drawn only while + something is scrolled past it (or `separator` says always). */ + [data-md-modal-head], + [data-md-modal-bar], + [data-md-modal-actions] { + position: relative; + } + + [data-md-modal-head]::after, + [data-md-modal-bar]::after, + [data-md-modal-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-md-modal-head]::after, + [data-md-modal-bar]::after { + bottom: 0; + } + + [data-md-modal-actions]::before { + top: 0; + } + + dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-head][data-md-modal-divider]::after, + dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-bar][data-md-modal-divider]::after, + dialog[data-md-overflow-bottom] > [data-md-modal-box] > [data-md-modal-actions][data-md-modal-divider]::before, + [data-md-modal-head][data-md-modal-divider][data-md-separator]::after, + [data-md-modal-bar][data-md-modal-divider][data-md-separator]::after, + [data-md-modal-actions][data-md-modal-divider][data-md-separator]::before { + opacity: 1; + } +} diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css index 0d0876aa..97ad47d2 100644 --- a/resources/css/tailwind.css +++ b/resources/css/tailwind.css @@ -17,9 +17,6 @@ @import './tokens/theme.css'; @import './tokens/utilities.css'; -/* Containment — leaving in step 36 */ -@import './components/dialog.css'; - /* Navigation — leaving in step 36 */ @import './components/tabs.css'; @import './components/app-bar.css'; diff --git a/resources/js/dialog.js b/resources/js/dialog.js index a3b48a89..28a3bfbb 100644 --- a/resources/js/dialog.js +++ b/resources/js/dialog.js @@ -1,7 +1,7 @@ /** * `x-dialog-dividers`, on ``'s body — the one part of a dialog that scrolls: marks the - * `` 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 + * `` with `data-md-overflow-top` while the body is scrolled away from its top, and + * `data-md-overflow-bottom` while more of it is below. resources/css/components/modal.css draws * those as the dividers under the pinned header and over the pinned actions; a body that fits * marks neither. * @@ -25,14 +25,14 @@ document.addEventListener('alpine:init', () => { // 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) + dialog.toggleAttribute('data-md-overflow-top', el.scrollTop >= 1) + dialog.toggleAttribute('data-md-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]') + const content = el.querySelector(':scope > [data-md-modal-content]') if (content) { resizes.observe(content) @@ -43,8 +43,8 @@ document.addEventListener('alpine:init', () => { cleanup(() => { resizes.disconnect() el.removeEventListener('scroll', measure) - dialog.removeAttribute('data-overflow-top') - dialog.removeAttribute('data-overflow-bottom') + dialog.removeAttribute('data-md-overflow-top') + dialog.removeAttribute('data-md-overflow-bottom') }) }) }) diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php index bb28feeb..622fb601 100644 --- a/resources/views/components/modal.blade.php +++ b/resources/views/components/modal.blade.php @@ -20,14 +20,16 @@ on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon above a centred title, as M3 draws a dialog with a hero icon. `fullscreen` makes a dialog that holds a form take the whole screen on a compact window (below `medium`, 600px — M3 uses - full-screen dialogs "only in compact breakpoints"), with a 56px close-and-title bar clear of - the notch. `persistent` ignores Escape and the scrim, for a dialog that must be - answered. It opens on the fast spatial spring and closes at once, as M3's do. + full-screen dialogs "only in compact breakpoints", FullScreenDialogTokens), with a 56px + close-and-title bar clear of the notch (docs/audits/m3-alignment/containment.md, C-17). + `persistent` ignores Escape and the scrim, for a dialog that must be answered. It opens on the + fast spatial spring and closes at once, as M3's do. "Dialog content generally shouldn't scroll; if it must, the title stays pinned at the top and 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 - and only the body between them scrolls, each with the 24dp padding the box used to carry. + § Dialogs → Behaviour, C-06): 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. 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 @@ -39,16 +41,23 @@ 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. + is nothing to divide, and no rule. resources/css/components/modal.css draws the rules, keyed + on `data-md-modal-divider` (only where a body exists to divide, the bar's own flag computed in + PHP because it alone can sit beside a header that is *also* on screen) and `data-md-separator`; + `x-dialog-dividers` (resources/js/dialog.js), on the body, watches it and the element wrapped + around the slot, so content a Livewire render adds counts, and marks the `` with + `data-md-overflow-top`/`data-md-overflow-bottom`. - `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" (C-20): it sets `role="alertdialog"` and points `aria-describedby` at the body, for the dialog that interrupts to say something important. It is opt-in, because ARIA-APG keeps `alertdialog` for exactly that and a form dialog would over-announce with it. A `subtitle` is always the - dialog's description. --}} + dialog's description. + + The root renders `data-md-modal` and `data-md-fullscreen`; its parts are `data-md-modal-*`, + drawn by resources/css/components/modal.css: `-box`, `-bar` (the phone-only header bar), + `-head`, `-title`, `-subtitle`, `-body`, `-content` (the resize-observed wrapper around the + slot), `-actions`. `box-class` lands on the box. --}} @props([ 'title' => null, @@ -91,29 +100,22 @@ @if ($alert) role="alertdialog" @endif @if (filled($title)) aria-labelledby="{{ $id }}-title" @endif @if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif - {{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->merge(['id' => $id]) }} - @class([ - 'm-auto max-h-[calc(100dvh-3rem)] w-[calc(100vw-3rem)] max-w-[35rem] min-w-70 overflow-visible bg-transparent p-0 text-on-surface', - 'backdrop:bg-scrim/32', - 'opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast', - 'max-medium:m-0 max-medium:h-dvh max-medium:max-h-none max-medium:w-full max-medium:max-w-none max-medium:min-w-0' => $fullscreen, - $attributes->get('class'), - ]) + {{ $attributes->whereDoesntStartWith('wire:model')->except(['id'])->merge(array_filter([ + 'id' => $id, + 'data-md-modal' => true, + 'data-md-fullscreen' => $fullscreen ? true : null, + ], fn ($value): bool => $value !== null)) }} > -
$fullscreen, - $boxClass, - ])> +
@if ($fullscreen)
@if (filled($title)) - {{ $title }} + {{ $title }} @endif
@endif @@ -121,45 +123,33 @@ @if ($header) {{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
$body, 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon]) + data-md-modal-head + @if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif > @if ($icon) - + @endif @if (filled($title)) -

$fullscreen && ! $icon])>{{ $title }}

+

{{ $title }}

@endif @if (filled($subtitle)) -

filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}

+

{{ $subtitle }}

@endif
@endif @if ($body) -
$header, - 'max-medium:pt-4' => $header && $barAboveBody, - 'pt-6' => ! $header, - 'pb-2' => isset($actions), - 'pb-6' => ! isset($actions), - ])> -
{{ $slot }}
+
+
{{ $slot }}
@endif @isset($actions)
$body, - 'pt-6' => ! $body, - 'max-medium:min-h-14 max-medium:pt-2 max-medium:pb-2' => $fullscreen, - ]) + data-md-modal-actions + @if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif > {{ $actions }}
diff --git a/tests/Feature/Components/ContainmentStylesheetsTest.php b/tests/Feature/Components/ContainmentStylesheetsTest.php index 84cb4dbc..2e838141 100644 --- a/tests/Feature/Components/ContainmentStylesheetsTest.php +++ b/tests/Feature/Components/ContainmentStylesheetsTest.php @@ -21,6 +21,7 @@ dataset('containment components', [ 'card', 'list', 'list-item', + 'modal', ]); /** diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index 9ef4396b..ab50d500 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -23,14 +23,15 @@ it('opens a native dialog entangled with a Livewire property, out of the morph\' Livewire::test($component) ->assertSeeHtml('assertSeeHtml('data-md-modal') ->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') + ->assertSeeHtml('data-md-modal-box') + ->assertSeeHtml('data-md-modal-title') ->assertSee('This cannot be undone.') - ->assertSeeHtml('text-center') + ->assertSeeHtml('data-md-icon') ->assertDontSeeHtml('wire:model="confirming"'); }); @@ -41,30 +42,36 @@ it('uses the surrounding Alpine scope without wire:model, and stays open when pe ->toContain('x-data="{ close() { this.open = false } }"') ->toContain('x-on:cancel.prevent=""') ->not->toContain('x-on:click.self') - ->toContain('max-medium:h-dvh') + ->toContain('data-md-fullscreen') ->toContain('aria-label="Close"'); }); -it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title', function () { +it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title, in the CSS', 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>/'); + ->toMatch('/

Enable 2FA<\/h2>/') + ->toMatch('/

Scan the code<\/p>/') + ->and((string) $this->blade('Text')) + ->toMatch('/

Only a subtitle<\/p>/') + ->not->toContain('data-md-modal-title'); + + // The markup is the same at every width; modal.css (ContainmentStylesheetsTest) decides which + // of the bar/head/title/subtitle show below 600px, keyed on the icon and the subtitle. + expect(File::get(__DIR__.'/../../../resources/css/components/modal.css')) + ->toContain('[data-md-modal][data-md-fullscreen] [data-md-modal-head]:not(:has([data-md-icon])):not(:has([data-md-modal-subtitle]))') + ->toContain('[data-md-modal][data-md-fullscreen]:not(:has([data-md-modal-head] [data-md-icon])) [data-md-modal-title]') + ->toContain('[data-md-modal][data-md-fullscreen]:not(:has([data-md-modal-head] [data-md-icon])) [data-md-modal-subtitle]'); }); 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('//'); + ->toContain('data-md-modal-box') + ->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 () { @@ -73,76 +80,74 @@ it('divides a scrolling body from its header and actions only while content is h // 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('data-md-overflow-top') + ->not->toContain('data-md-overflow-bottom') + ->not->toContain('data-md-separator') ->not->toContain('role="separator"') - ->and(substr_count($html, 'data-dialog-head'))->toBe(1) - ->and(substr_count($html, 'data-dialog-actions'))->toBe(1); + ->and(substr_count($html, 'data-md-modal-head'))->toBe(1) + ->and(substr_count($html, 'data-md-modal-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') + expect(File::get(__DIR__.'/../../../resources/css/components/modal.css')) + ->toContain('dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-head][data-md-modal-divider]::after') + ->toContain('dialog[data-md-overflow-bottom] > [data-md-modal-box] > [data-md-modal-actions][data-md-modal-divider]::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. + // A token that reduced motion sets to zero, so there the rule simply appears. ->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/css/components.css'))->toContain("@import './components/modal.css';") + ->and(File::get(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('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]'); + ->toContain('[data-md-modal-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'); + ->toMatch('//') + ->toMatch('//') + ->and(File::get(__DIR__.'/../../../resources/css/components/modal.css')) + ->toContain('[data-md-modal-head][data-md-modal-divider][data-md-separator]::after') + ->toContain('[data-md-modal-actions][data-md-modal-divider][data-md-separator]::before'); - // Without a body there is nothing to divide, and the header keeps M3's 24dp to the actions. + // Without a body there is nothing to divide, and the header carries no divider hook. 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"'); + ->toMatch('//') + ->not->toContain('data-md-modal-divider') + ->not->toContain('data-md-separator') + ->not->toContain('x-dialog-dividers'); - // No header: the body keeps its 24dp top and only the actions carry a rule; no actions: its 24dp bottom. + // No header: only the actions carry the divider hook; no actions: nothing does. expect((string) $this->blade('Body')) - ->not->toContain('data-dialog-head') - ->toContain('data-dialog-actions') - ->toContain('text-on-surface-variant pt-6 pb-2"') + ->not->toContain('data-md-modal-head') + ->toMatch('//') ->and((string) $this->blade('Body')) - ->toContain('data-dialog-head') - ->not->toContain('data-dialog-actions') - ->toContain('text-on-surface-variant pt-2 pb-6"'); + ->toMatch('//') + ->not->toContain('data-md-modal-actions'); }); 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. + // Both carry the divider hook here (no icon, no subtitle): the bar shows only below medium, + // and this header only from it (modal.css). 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); + ->toMatch('//') + ->toMatch('//') + ->and(preg_match_all('/]/', $titled))->toBe(1) + ->and(preg_match_all('/]/', $titled))->toBe(1); // 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) + ->toMatch('//') + ->not->toContain('data-md-modal-bar data-md-modal-divider') + ->toMatch('//') ->and((string) $this->blade('Body')) - ->toMatch('/toMatch('//'); }); it('is an alert dialog when it interrupts, and describes itself by its subtitle', function () { @@ -162,10 +167,11 @@ 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 () { 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'); + ->toContain('data-md-modal-bar') + ->toContain('data-md-fullscreen') + ->and(File::get(__DIR__.'/../../../resources/css/components/modal.css')) + ->toContain('height: var(--md-sys-measurement-space700);') + ->toContain('min-height: var(--md-sys-measurement-space700);'); }); it('leaves a pane open on Escape unless it is asked to close then too', function () {