Files
Andreas Reinhold / reiniandClaude Opus 5 921c8cef04 Give a text-only dialog's scrolling body the first focus in every engine
With nothing focusable inside, showModal() focuses the <dialog> itself.
Chrome's scroll containers are keyboard-focusable, so there the scrolling
body became the focus delegate and modal.css drew its inset ring; Firefox
and WebKit focused the dialog, where the arrow keys scroll nothing, and
WebKit never lets Tab reach a scroll container, so a keyboard could not
read a long text-only dialog there at all.

<x-modal> now opens through materialShowModal() (resources/js/dialog.js):
showModal(), and when the dialog took the focus itself and its body
overflows, the body gets tabindex="0" and the focus, as in Chrome. The
tabindex goes when the dialog closes. The body is wire:ignore.self, like
the dialog: a Livewire render would otherwise morph the tabindex away and
WebKit dropped the focus out of the dialog. ContainmentTest now also
checks the focus stays on the body through a render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:23:15 +02:00

278 lines
10 KiB
CSS

/*
* `<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 (max/min), 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.
*
* Every rule that reads the full-screen flag or the head's icon and subtitle goes through
* `> [data-md-modal-box] >`, as the divider marks do: a basic dialog opened from inside a
* full-screen dialog's body is a descendant of it, and must keep its own title and padding.
*
* `[data-md-modal-body]:focus-visible` is the scrolling body catching the dialog's first focus when
* nothing inside can take it — natively in Chrome, whose scroll containers are focusable, and
* through `materialShowModal()` (resources/js/dialog.js) in Firefox and WebKit — 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-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [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] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [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] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [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-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [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-box] > [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;
}
}