Files
Andreas Reinhold / reiniandClaude Opus 5 7db522826b Fade and shrink popovers out in every engine, Firefox included
Menus, submenus, tooltips, rich tooltips and the FAB menu held their
exit with `transition-behavior: allow-discrete` on `display` and
`overlay`. Firefox transitions neither (MDN browser-compat-data,
`display.is_transitionable`: Chrome 117, Safari 18, Firefox none), so
every one of them vanished on its first frame there. A script cannot
hold a popover open instead: `beforetoggle` is not cancellable on the
way out, and the browser's own light dismiss (Escape, a press outside)
never asks.

resources/js/popover-exit.js: a popover marked `data-md-popover-exit`
closes for real at once — focus, aria-expanded and toggle stay the
browser's — and a copy taken in `beforetoggle`, while it is still drawn,
stands in for the exit. The copy is decoration: a manual popover in the
top layer (closing no other popover), inert, aria-hidden, without ids or
nested popovers, `x-ignore`d so Alpine starts nothing in it, pinned to
the popover's box with its resolved colours. It is shown with its
transitions off, so `@starting-style` does not replay the entry, then
marked `data-md-popover-closing`, which each stylesheet turns into its
closed values (`:popover-open:not([data-md-popover-closing])`, and the
FAB menu's items' sink), so it moves on the component's own tokens. It
is removed once the longest of them has run, and opening the popover
again takes it away. Under reduced motion every duration is zero and no
copy is made. `display`, `overlay` and `allow-discrete` leave the
transitions, so Chrome and Safari take the same path.

Browser tests in Chrome, Firefox and Safari slow the motion tokens so a
round trip still finds the exit on screen: a menu after Escape and after
a press outside (the real menu closed and focus back on its button, the
copy inert, fading, with no Alpine state, and gone after), a reopen
part-way through, reduced motion, a submenu while its menu stays open, a
tooltip, the FAB menu's items part-way down their sink, and a persistent
rich tooltip.

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

428 lines
17 KiB
CSS

/*
* The menus: `<x-menu>` and what a form's own dropdowns wear too, so a menu and a form read as
* one family.
*
* Three lists wear it:
*
* - `<x-menu>`'s own popover (`[data-md-menu-popover]`) and, inside it, a submenu
* (`[data-md-submenu]`, menu-item.css).
* - `<x-select>`'s: the native <select>, opted into the browser's customizable select
* (`appearance: base-select`). `::picker(select)` is the menu and each <option> a row, while
* the keyboard, type-to-find, the screen reader and the form value stay the browser's own.
* - `<x-choices searchable>`'s listbox, which Alpine draws, as `[data-md-field-menu]` and
* `[data-md-field-option]`.
*
* M3's menu as its tokens have it (StandardMenuTokens/VibrantMenuTokens/SegmentedMenuTokens,
* androidx Compose Material 3, Apache-2.0): surface-container-low at elevation 2, large corner,
* 48px rows in body-large, the chosen row in secondary-container with a tick at its end (so it is
* not told by colour alone), and the state layer on hover (only where a pointer can hover) and
* focus.
*
* `[data-md-menu]` is `<x-menu>`'s root, holding the trigger and the popover; the popover itself
* is `[data-md-menu-popover]`, capped at 288px so a long menu scrolls instead of running off the
* top layer's edge (ACT-04), and transitions in by growing from its trigger's corner while it
* fades, on the spatial and effects springs respectively (ACT-26). `data-md-vibrant` swaps its
* container for tertiary-container; a nested submenu inherits the colour through
* `--material-menu-surface`/`--material-menu-ink`, which the sheet's own copy of the list falls
* back to instead (it is not a descendant of the popover).
*
* `sheet-at-compact`'s bottom sheet is `<x-bottom-sheet>` (bottom-sheet.css): `[data-md-menu-sheet]`
* cancels its 24px padding and restyles a submenu to open in place under its item instead of
* beside it (M3 calls submenus "best suited to large screens").
*
* Where the browser has no customizable select, the select keeps its native list, which
* `color-scheme` themes. Every rule for the select's own rows is inside `@supports`: a browser
* that paints option colours into its native list would otherwise show a half-painted one.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './bottom-sheet.css';
@layer material.components {
[data-md-menu] {
position: relative;
display: inline-flex;
}
[data-md-menu-trigger] {
display: inline-flex;
}
[data-md-menu-popover] {
--material-menu-surface: var(--md-sys-color-surface-container-low);
--material-menu-ink: var(--md-sys-color-on-surface);
inset: auto;
margin: 0;
margin-block: var(--md-sys-measurement-space50);
min-inline-size: 112px;
max-inline-size: 280px;
max-block-size: min(288px, calc(100dvh - 32px));
overflow-y: auto;
border-width: 0;
border-radius: var(--md-sys-shape-corner-lg);
padding: var(--md-sys-measurement-space50);
background-color: var(--material-menu-surface);
color: var(--material-menu-ink);
box-shadow: var(--md-sys-elevation-2);
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
opacity: 0;
scale: 0.95;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast);
&:popover-open:not([data-md-popover-closing]) {
opacity: 1;
scale: 1;
}
@starting-style {
&:popover-open {
opacity: 0;
scale: 0.95;
}
}
}
[data-md-menu-popover]:has(> [data-md-menu-filter]) {
padding: 0;
}
[data-md-menu-popover][data-md-vibrant] {
--material-menu-surface: var(--md-sys-color-tertiary-container);
--material-menu-ink: var(--md-sys-color-on-tertiary-container);
}
[data-md-menu-popover][data-md-position='bottom-start'] {
transform-origin: top;
position-area: bottom span-right;
}
[data-md-menu-popover][data-md-position='bottom-end'] {
transform-origin: top;
position-area: bottom span-left;
}
[data-md-menu-popover][data-md-position='top-start'] {
transform-origin: bottom;
position-area: top span-right;
}
[data-md-menu-popover][data-md-position='top-end'] {
transform-origin: bottom;
position-area: top span-left;
}
/*
* `<x-menu filter>`: M3's menu as a filtering surface. The field stays put while the list
* scrolls under it and takes the menu's own container, so a vibrant menu's field is vibrant
* too. It is a plain <input> with no field chrome — M3's menus embed a text field, not a text
* field component.
*/
[data-md-menu-filter] {
position: sticky;
inset-block-start: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 12px;
min-block-size: var(--md-sys-measurement-space600);
padding-inline: var(--md-sys-measurement-space200);
border-block-end: 1px solid var(--md-sys-color-outline-variant);
background-color: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
}
[data-md-menu-filter] input {
flex: 1;
min-inline-size: 0;
border-width: 0;
background-color: transparent;
padding: 0;
color: inherit;
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
font-variation-settings: normal;
outline: none;
}
[data-md-menu-filter] input::placeholder {
color: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 70%, transparent);
}
[data-md-menu-list] {
padding: var(--md-sys-measurement-space50);
}
[data-md-menu-empty] {
padding-inline: var(--md-sys-measurement-space200);
padding-block: 12px;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
/* The filter hides what the query leaves out with the `hidden` attribute; a row's own
`display: flex` would otherwise beat the user agent's `[hidden] { display: none }`. */
:is([data-md-menu-popover], [data-md-menu-sheet]) [hidden] {
display: none;
}
/* `data-md-active` is the row the arrow keys are on while the focus stays in the field — the
state layer the roving focus would have drawn, without taking the focus off the field. A
chosen or current row keeps its own colour. */
:is([data-md-menu-popover], [data-md-menu-sheet]) [role^="menuitem"][data-md-active]:not([aria-checked="true"], [aria-current="page"]) {
background-color: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 8%, transparent);
}
/*
* `<x-menu sheet-at-compact>`: the copy of the list in the bottom sheet a compact window opens
* instead of the popover. The sheet is surface-container-low, as the standard menu is, so the
* fallbacks above already paint its field; the field reaches both edges of the sheet and holds
* its icon where the rows hold theirs, 24px in (the list is 8px in, a row's own padding 16px).
* `-24px` cancels the bottom sheet's own padding.
*/
[data-md-menu-sheet] {
margin-inline: calc(-1 * var(--md-sys-measurement-space300));
padding-inline: var(--md-sys-measurement-space100);
}
[data-md-menu-sheet]:has(> [data-md-menu-filter]) {
padding-inline: 0;
}
[data-md-menu-sheet] [data-md-menu-filter] {
padding-inline: var(--md-sys-measurement-space300);
}
[data-md-menu-sheet] [data-md-menu-list] {
padding-inline: var(--md-sys-measurement-space100);
padding-block: var(--md-sys-measurement-space50) 0;
}
/*
* A submenu there opens in place: its list stands under the item, inset by 16px, instead of in
* a popover beside it, which a sheet has no room for (M3 calls submenus "best suited to large
* screens", docs/reference/m3/components-actions-communication-containment.md § Menus). The
* markup is the popover's, never shown as one: the user agent's `[popover]` box is undone, the
* list is shown while its item says `aria-expanded="true"`, and it fades in on the effects
* track.
*/
[data-md-menu-sheet] [data-md-submenu] {
position: static;
inset: auto;
inline-size: auto;
max-inline-size: none;
block-size: auto;
max-block-size: none;
margin: 0;
padding-block: var(--md-sys-measurement-space25);
padding-inline: var(--md-sys-measurement-space200) 0;
overflow: visible;
border-radius: 0;
background-color: transparent;
color: inherit;
box-shadow: none;
opacity: 1;
scale: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-menu-sheet] [aria-expanded="true"] + [data-md-submenu] {
display: block;
@starting-style {
opacity: 0;
}
}
/* The chevron turns from the way a popover would open to the way this list did: down, which
is a quarter turn the other way on the chevron `mirror-rtl` mirrors too. */
[data-md-menu-sheet] [data-md-submenu-chevron] {
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-md-menu-sheet] [aria-expanded="true"] > [data-md-submenu-chevron] {
rotate: 90deg;
&:is([dir='rtl'], [dir='rtl'] *) {
rotate: -90deg;
}
}
[data-md-field-menu] {
max-block-size: 18rem;
overflow-y: auto;
padding-block: var(--md-sys-measurement-space100);
border-radius: var(--md-sys-shape-corner-xs);
background-color: var(--md-sys-color-surface-container);
box-shadow: var(--md-sys-elevation-2);
}
/* As a popover (`<x-choices searchable>`), it hangs under its field, as wide, and goes above
only when there is no room below. */
[data-md-field-menu][popover] {
inset: auto;
top: anchor(bottom);
left: anchor(left);
width: anchor-size(width);
margin: var(--md-sys-measurement-space50) 0;
border-width: 0;
padding-inline: 0;
color: var(--md-sys-color-on-surface);
position-try-fallbacks: flip-block;
}
[data-md-field-option][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
[data-md-field-option] {
display: flex;
align-items: center;
gap: 12px;
min-block-size: var(--md-sys-measurement-space600);
padding-inline: var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
font-variation-settings: normal;
cursor: pointer;
}
/* `data-md-active` is the row the arrow keys or the pointer are on. */
[data-md-field-option][data-md-active] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
[data-md-field-option][aria-selected="true"] {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-md-field-option][aria-selected="true"][data-md-active] {
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
}
[data-md-field-check] {
margin-inline-start: auto;
}
[data-md-field-option]:not([aria-selected="true"]) [data-md-field-check] {
visibility: hidden;
}
@supports (appearance: base-select) {
/* The select itself opts in from select.css, after its own `appearance: none`: both sit in
`material.components` with the same specificity, and select.css's block always comes
after this file, which it imports. */
select[data-md-field-control]::picker(select) {
appearance: base-select;
}
/* The menu. The browser anchors it to the select; the select covers its whole
field (field.css), so the menu is at least the field's width. It hangs under
the field and goes above only when it does not fit there — the browser's own
order tries the roomier side first, which opened a menu upwards with room to
spare under it. It opens with a fade and a grow on the spatial spring and
closes with a fade, as `<x-menu>` does. */
select[data-md-field-control]::picker(select) {
position-try-order: normal;
max-block-size: 18rem;
max-inline-size: calc(100vw - 32px);
margin-block: var(--md-sys-measurement-space50);
padding-block: var(--md-sys-measurement-space100);
border-width: 0;
border-radius: var(--md-sys-shape-corner-xs);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
box-shadow: var(--md-sys-elevation-2);
opacity: 0;
transform-origin: top;
transition-property: opacity, display, overlay;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
transition-behavior: allow-discrete;
}
select[data-md-field-control]:open::picker(select) {
opacity: 1;
transition-property: opacity, scale, display, overlay;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
@starting-style {
select[data-md-field-control]:open::picker(select) {
opacity: 0;
scale: 0.95;
}
}
select[data-md-field-control] option {
display: flex;
align-items: center;
gap: 12px;
min-block-size: var(--md-sys-measurement-space600);
padding-block: 0;
padding-inline: var(--md-sys-measurement-space200);
background-color: transparent;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
font-variation-settings: normal;
outline: none;
cursor: pointer;
}
@media (hover: hover) {
select[data-md-field-control] option:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
select[data-md-field-control] option:focus-visible {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
select[data-md-field-control] option:checked {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
@media (hover: hover) {
select[data-md-field-control] option:checked:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
}
}
select[data-md-field-control] option:checked:focus-visible {
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 10%, var(--md-sys-color-secondary-container));
}
select[data-md-field-control] option:disabled {
background-color: transparent;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
/* The tick, at the row's end: the browser draws it before the label, and hides
it on every row but the chosen one. The Material Symbol, as a mask, so it
takes the row's ink. */
select[data-md-field-control] option::checkmark {
content: "";
order: 1;
flex: none;
inline-size: var(--md-sys-measurement-space300);
block-size: var(--md-sys-measurement-space300);
margin-inline-start: auto;
background-color: currentColor;
mask: url("../../svg/symbols/outlined/check.svg") center / contain no-repeat;
}
}
}