Rewrite the modal (dialog) without Tailwind
Plan step 36 (containment group): <x-modal>'s class lists move into resources/css/components/modal.css, keyed on data-md-modal (with data-md-fullscreen) and data-md-modal-box/-bar/-head/-title/-subtitle/ -body/-content/-actions. Sizes stay px (560/280/48px matching DialogTokens.kt/FullScreenDialogTokens.kt and the 56px full-screen header, C-17), spacing the measurement tokens, breakpoints a literal 600px range query. The phone-only header bar and the pinned head/actions keep their existing responsive behaviour (a subtitle or an icon keeps the head on screen below 600px; only the bar's own divider-eligibility needs a computed flag, since it alone can sit beside a head that is also on screen) — reproduced with :has() against data-md-icon/ data-md-modal-subtitle wherever that is self-contained, and with an explicit data-md-modal-divider attribute where it is not. The scroll-driven dividers rename data-dialog-head/-actions -> data-md-modal-head/-actions, data-overflow-top/-bottom -> data-md-overflow-top/-bottom (resources/js/dialog.js); the dialog-dividers Alpine directive keeps its name, since it isn't a data-md-* hook. resources/css/components/dialog.css (Tailwind-era) is replaced by modal.css, imported from the Containment block of components.css; its import leaves resources/css/tailwind.css. No other component renders <x-modal> or dialog.css's rules (checked: datepicker, timepicker and search draw their own dialog-shaped surfaces independently). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9ff5a7a809
commit
3c235ecc1e
@@ -61,5 +61,6 @@
|
|||||||
@import './components/card.css';
|
@import './components/card.css';
|
||||||
@import './components/list-item.css';
|
@import './components/list-item.css';
|
||||||
@import './components/list.css';
|
@import './components/list.css';
|
||||||
|
@import './components/modal.css';
|
||||||
|
|
||||||
/* Navigation */
|
/* Navigation */
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
/*
|
|
||||||
* `<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/*
|
||||||
|
* `<x-modal>`: 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 <dialog>; 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 `<dialog>` 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 `<dialog>` 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,6 @@
|
|||||||
@import './tokens/theme.css';
|
@import './tokens/theme.css';
|
||||||
@import './tokens/utilities.css';
|
@import './tokens/utilities.css';
|
||||||
|
|
||||||
/* Containment — leaving in step 36 */
|
|
||||||
@import './components/dialog.css';
|
|
||||||
|
|
||||||
/* Navigation — leaving in step 36 */
|
/* Navigation — leaving in step 36 */
|
||||||
@import './components/tabs.css';
|
@import './components/tabs.css';
|
||||||
@import './components/app-bar.css';
|
@import './components/app-bar.css';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* `x-dialog-dividers`, on `<x-modal>`'s body — the one part of a dialog that scrolls: marks the
|
* `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
|
* `<dialog>` with `data-md-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
|
* `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
|
* those as the dividers under the pinned header and over the pinned actions; a body that fits
|
||||||
* marks neither.
|
* 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
|
// 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.
|
// fraction short of `scrollHeight`, which is rounded.
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
dialog.toggleAttribute('data-overflow-top', el.scrollTop >= 1)
|
dialog.toggleAttribute('data-md-overflow-top', el.scrollTop >= 1)
|
||||||
dialog.toggleAttribute('data-overflow-bottom', el.scrollHeight - el.clientHeight - el.scrollTop >= 1)
|
dialog.toggleAttribute('data-md-overflow-bottom', el.scrollHeight - el.clientHeight - el.scrollTop >= 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resizes = new ResizeObserver(measure)
|
const resizes = new ResizeObserver(measure)
|
||||||
resizes.observe(el)
|
resizes.observe(el)
|
||||||
|
|
||||||
const content = el.querySelector(':scope > [data-dialog-content]')
|
const content = el.querySelector(':scope > [data-md-modal-content]')
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
resizes.observe(content)
|
resizes.observe(content)
|
||||||
@@ -43,8 +43,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
cleanup(() => {
|
cleanup(() => {
|
||||||
resizes.disconnect()
|
resizes.disconnect()
|
||||||
el.removeEventListener('scroll', measure)
|
el.removeEventListener('scroll', measure)
|
||||||
dialog.removeAttribute('data-overflow-top')
|
dialog.removeAttribute('data-md-overflow-top')
|
||||||
dialog.removeAttribute('data-overflow-bottom')
|
dialog.removeAttribute('data-md-overflow-bottom')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,14 +20,16 @@
|
|||||||
on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon
|
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
|
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
|
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
|
full-screen dialogs "only in compact breakpoints", FullScreenDialogTokens), with a 56px
|
||||||
the notch. `persistent` ignores Escape and the scrim, for a dialog that must be
|
close-and-title bar clear of the notch (docs/audits/m3-alignment/containment.md, C-17).
|
||||||
answered. It opens on the fast spatial spring and closes at once, as M3's do.
|
`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
|
"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
|
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, C-06): the header and the action row are their own rows of the flex
|
||||||
and only the body between them scrolls, each with the 24dp padding the box used to carry.
|
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"
|
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
|
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 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
|
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
|
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;
|
is nothing to divide, and no rule. resources/css/components/modal.css draws the rules, keyed
|
||||||
`x-dialog-dividers` (resources/js/dialog.js), on the body, says which of them show, watching
|
on `data-md-modal-divider` (only where a body exists to divide, the bar's own flag computed in
|
||||||
the body and the one element wrapped around the slot, so content a Livewire render adds
|
PHP because it alone can sit beside a header that is *also* on screen) and `data-md-separator`;
|
||||||
counts.
|
`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 `<dialog>` 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
|
`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`
|
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
|
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([
|
@props([
|
||||||
'title' => null,
|
'title' => null,
|
||||||
@@ -91,29 +100,22 @@
|
|||||||
@if ($alert) role="alertdialog" @endif
|
@if ($alert) role="alertdialog" @endif
|
||||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||||
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
|
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
|
||||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->merge(['id' => $id]) }}
|
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id'])->merge(array_filter([
|
||||||
@class([
|
'id' => $id,
|
||||||
'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',
|
'data-md-modal' => true,
|
||||||
'backdrop:bg-scrim/32',
|
'data-md-fullscreen' => $fullscreen ? true : null,
|
||||||
'opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
], fn ($value): bool => $value !== null)) }}
|
||||||
'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'),
|
|
||||||
])
|
|
||||||
>
|
>
|
||||||
<div @class([
|
<div data-md-modal-box @if (filled($boxClass)) class="{{ $boxClass }}" @endif>
|
||||||
'flex max-h-[inherit] flex-col overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3',
|
|
||||||
'max-medium:h-full max-medium:rounded-corner-none max-medium:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
|
|
||||||
$boxClass,
|
|
||||||
])>
|
|
||||||
@if ($fullscreen)
|
@if ($fullscreen)
|
||||||
<div
|
<div
|
||||||
@if ($body && $barAboveBody) data-dialog-head @if ($separator) data-separator @endif @endif
|
data-md-modal-bar
|
||||||
class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"
|
@if ($body && $barAboveBody) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
|
||||||
>
|
>
|
||||||
<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))
|
||||||
<span class="truncate type-title-lg">{{ $title }}</span>
|
<span data-md-modal-bar-title>{{ $title }}</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@@ -121,45 +123,33 @@
|
|||||||
@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
|
<div
|
||||||
@if ($body) data-dialog-head @if ($separator) data-separator @endif @endif
|
data-md-modal-head
|
||||||
@class(['shrink-0 px-6 pt-6', 'pb-2' => $body, 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])
|
@if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
|
||||||
>
|
>
|
||||||
@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" size="24" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (filled($title))
|
@if (filled($title))
|
||||||
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-medium:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
|
<h2 id="{{ $id }}-title" data-md-modal-title>{{ $title }}</h2>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@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" data-md-modal-subtitle>{{ $subtitle }}</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($body)
|
@if ($body)
|
||||||
<div id="{{ $id }}-body" data-dialog-body x-dialog-dividers @class([
|
<div id="{{ $id }}-body" data-md-modal-body x-dialog-dividers>
|
||||||
'min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant',
|
<div data-md-modal-content>{{ $slot }}</div>
|
||||||
'pt-2' => $header,
|
|
||||||
'max-medium:pt-4' => $header && $barAboveBody,
|
|
||||||
'pt-6' => ! $header,
|
|
||||||
'pb-2' => isset($actions),
|
|
||||||
'pb-6' => ! isset($actions),
|
|
||||||
])>
|
|
||||||
<div data-dialog-content>{{ $slot }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@isset($actions)
|
@isset($actions)
|
||||||
<div
|
<div
|
||||||
@if ($body) data-dialog-actions @if ($separator) data-separator @endif @endif
|
data-md-modal-actions
|
||||||
@class([
|
@if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
|
||||||
'flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6',
|
|
||||||
'pt-4' => $body,
|
|
||||||
'pt-6' => ! $body,
|
|
||||||
'max-medium:min-h-14 max-medium:pt-2 max-medium:pb-2' => $fullscreen,
|
|
||||||
])
|
|
||||||
>
|
>
|
||||||
{{ $actions }}
|
{{ $actions }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ dataset('containment components', [
|
|||||||
'card',
|
'card',
|
||||||
'list',
|
'list',
|
||||||
'list-item',
|
'list-item',
|
||||||
|
'modal',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,14 +23,15 @@ it('opens a native dialog entangled with a Livewire property, out of the morph\'
|
|||||||
|
|
||||||
Livewire::test($component)
|
Livewire::test($component)
|
||||||
->assertSeeHtml('<dialog')
|
->assertSeeHtml('<dialog')
|
||||||
|
->assertSeeHtml('data-md-modal')
|
||||||
->assertSeeHtml('wire:ignore.self')
|
->assertSeeHtml('wire:ignore.self')
|
||||||
->assertSeeHtml('open: window.Livewire.find(')
|
->assertSeeHtml('open: window.Livewire.find(')
|
||||||
->assertSeeHtml("entangle('confirming').live")
|
->assertSeeHtml("entangle('confirming').live")
|
||||||
->assertSeeHtml('close() { this.open = typeof this.open === \'boolean\' ? false : null }')
|
->assertSeeHtml('close() { this.open = typeof this.open === \'boolean\' ? false : null }')
|
||||||
->assertSeeHtml('rounded-corner-xl bg-surface-container-high')
|
->assertSeeHtml('data-md-modal-box')
|
||||||
->assertSeeHtml('type-headline-sm')
|
->assertSeeHtml('data-md-modal-title')
|
||||||
->assertSee('This cannot be undone.')
|
->assertSee('This cannot be undone.')
|
||||||
->assertSeeHtml('text-center')
|
->assertSeeHtml('data-md-icon')
|
||||||
->assertDontSeeHtml('wire:model="confirming"');
|
->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-data="{ close() { this.open = false } }"')
|
||||||
->toContain('x-on:cancel.prevent=""')
|
->toContain('x-on:cancel.prevent=""')
|
||||||
->not->toContain('x-on:click.self')
|
->not->toContain('x-on:click.self')
|
||||||
->toContain('max-medium:h-dvh')
|
->toContain('data-md-fullscreen')
|
||||||
->toContain('aria-label="Close"');
|
->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('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
|
$html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-medium:hidden">/')
|
->toMatch('/<h2 id="[^"]+-title" data-md-modal-title>Enable 2FA<\/h2>/')
|
||||||
->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" data-md-modal-subtitle>Scan the code<\/p>/')
|
||||||
->not->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>'))
|
||||||
->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"')
|
->toMatch('/<p id="[^"]+-subtitle" data-md-modal-subtitle>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>/');
|
->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 () {
|
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">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('data-md-modal-box')
|
||||||
->not->toContain('overflow-y-auto rounded-corner-xl')
|
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
|
||||||
->toMatch('/<div\s+data-dialog-head\s+class="shrink-0 px-6 pt-6 pb-2"\s*>/')
|
->toMatch('/<div id="[^"]+-body" data-md-modal-body x-dialog-dividers>\s*<div data-md-modal-content>Body<\/div>\s*<\/div>/')
|
||||||
->toMatch('/<div id="[^"]+-body" data-dialog-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>/')
|
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s*>/');
|
||||||
->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*>/');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('divides a scrolling body from its header and actions only while content is hidden past them', function () {
|
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.
|
// The script marks the dialog, which a morph leaves alone, and nothing is marked before it runs.
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('x-dialog-dividers')
|
->toContain('x-dialog-dividers')
|
||||||
->not->toContain('data-overflow-top')
|
->not->toContain('data-md-overflow-top')
|
||||||
->not->toContain('data-overflow-bottom')
|
->not->toContain('data-md-overflow-bottom')
|
||||||
->not->toContain('data-separator')
|
->not->toContain('data-md-separator')
|
||||||
->not->toContain('role="separator"')
|
->not->toContain('role="separator"')
|
||||||
->and(substr_count($html, 'data-dialog-head'))->toBe(1)
|
->and(substr_count($html, 'data-md-modal-head'))->toBe(1)
|
||||||
->and(substr_count($html, 'data-dialog-actions'))->toBe(1);
|
->and(substr_count($html, 'data-md-modal-actions'))->toBe(1);
|
||||||
|
|
||||||
expect(File::get(__DIR__.'/../../../resources/css/components/dialog.css'))
|
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
|
||||||
->toContain('dialog[data-overflow-top] > * > [data-dialog-head]::after')
|
->toContain('dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-head][data-md-modal-divider]::after')
|
||||||
->toContain('dialog[data-overflow-bottom] > * > [data-dialog-actions]::before')
|
->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('background-color: var(--md-sys-color-outline-variant);')
|
||||||
->toContain('height: 1px;')
|
->toContain('height: 1px;')
|
||||||
->toContain('position: absolute;')
|
->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);')
|
->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/material.js'))->toContain("import './dialog.js'")
|
||||||
->and(File::get(__DIR__.'/../../../resources/js/dialog.js'))
|
->and(File::get(__DIR__.'/../../../resources/js/dialog.js'))
|
||||||
->toContain("directive('dialog-dividers'")
|
->toContain("directive('dialog-dividers'")
|
||||||
->toContain('new ResizeObserver(measure)')
|
->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 () {
|
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>'))
|
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-md-modal-head\s+data-md-modal-divider\s+data-md-separator\s*>/')
|
||||||
->toMatch('/<div\s+data-dialog-actions\s+data-separator\s+class="flex shrink-0/')
|
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s+data-md-separator\s*>/')
|
||||||
->and(File::get(__DIR__.'/../../../resources/css/components/dialog.css'))
|
->and(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
|
||||||
->toContain('[data-dialog-head][data-separator]::after')
|
->toContain('[data-md-modal-head][data-md-modal-divider][data-md-separator]::after')
|
||||||
->toContain('[data-dialog-actions][data-separator]::before');
|
->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('<x-modal title="Discard the draft?" separator><x-slot:actions><button>Discard</button></x-slot:actions></x-modal>'))
|
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')
|
->toMatch('/<div\s+data-md-modal-head\s*>/')
|
||||||
->not->toContain('data-dialog-actions')
|
->not->toContain('data-md-modal-divider')
|
||||||
->not->toContain('data-separator')
|
->not->toContain('data-md-separator')
|
||||||
->not->toContain('x-dialog-dividers')
|
->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.
|
// No header: only the actions carry the divider hook; no actions: nothing does.
|
||||||
expect((string) $this->blade('<x-modal>Body<x-slot:actions><button>OK</button></x-slot:actions></x-modal>'))
|
expect((string) $this->blade('<x-modal>Body<x-slot:actions><button>OK</button></x-slot:actions></x-modal>'))
|
||||||
->not->toContain('data-dialog-head')
|
->not->toContain('data-md-modal-head')
|
||||||
->toContain('data-dialog-actions')
|
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s*>/')
|
||||||
->toContain('text-on-surface-variant pt-6 pb-2"')
|
|
||||||
->and((string) $this->blade('<x-modal title="About">Body</x-modal>'))
|
->and((string) $this->blade('<x-modal title="About">Body</x-modal>'))
|
||||||
->toContain('data-dialog-head')
|
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
|
||||||
->not->toContain('data-dialog-actions')
|
->not->toContain('data-md-modal-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 () {
|
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>');
|
$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.
|
// 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)
|
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-md-modal-bar\s+data-md-modal-divider\s*>/')
|
||||||
->toMatch('/<div\s+data-dialog-head\s+class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"/')
|
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
|
||||||
->toContain('text-on-surface-variant pt-2 max-medium:pt-4 pb-2"')
|
->and(preg_match_all('/<div\s+data-md-modal-bar[\s>]/', $titled))->toBe(1)
|
||||||
->and(substr_count($titled, 'data-dialog-head'))->toBe(2);
|
->and(preg_match_all('/<div\s+data-md-modal-head[\s>]/', $titled))->toBe(1);
|
||||||
|
|
||||||
// A subtitle keeps the header on a phone, so the rule stays under it rather than under the bar.
|
// 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>');
|
$subtitled = (string) $this->blade('<x-modal title="Share settings" subtitle="Who can see it" fullscreen>Body</x-modal>');
|
||||||
|
|
||||||
expect($subtitled)
|
expect($subtitled)
|
||||||
->toMatch('/<div\s+class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"/')
|
->toMatch('/<div\s+data-md-modal-bar\s*>/')
|
||||||
->toContain('text-on-surface-variant pt-2 pb-6"')
|
->not->toContain('data-md-modal-bar data-md-modal-divider')
|
||||||
->and(substr_count($subtitled, 'data-dialog-head'))->toBe(1)
|
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
|
||||||
->and((string) $this->blade('<x-modal fullscreen>Body</x-modal>'))
|
->and((string) $this->blade('<x-modal fullscreen>Body</x-modal>'))
|
||||||
->toMatch('/<div\s+data-dialog-head\s+class="flex h-14 shrink-0/');
|
->toMatch('/<div\s+data-md-modal-bar\s+data-md-modal-divider\s*>/');
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () {
|
||||||
@@ -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 () {
|
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('data-md-modal-bar')
|
||||||
->toContain('max-medium:min-h-14 max-medium:pt-2 max-medium:pb-2')
|
->toContain('data-md-fullscreen')
|
||||||
// Its rule follows the scroll, like any dialog's, instead of always showing.
|
->and(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
|
||||||
->not->toContain('max-medium:border-t');
|
->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 () {
|
it('leaves a pane open on Escape unless it is asked to close then too', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user