Files
livewire-material/resources/css/components/dialog.css
T
Andreas Reinhold / reiniandClaude Opus 5 ff16b12b3b Draw M3's focus ring on a dialog's scrolling body
Chrome makes a scroll container with nothing focusable inside a keyboard stop, and
showModal() gives it the dialog's first focus, which showed the browser's own ring.
The body now takes M3's 3px secondary indicator, inset so the dialog's rounded,
overflow-hidden box does not clip it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 11:04:40 +02:00

77 lines
3.2 KiB
CSS

/*
* `<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;
}
}