Files
livewire-material/resources/css/components/menu.css
T
Andreas Reinhold / reiniandClaude Opus 5 deb1442dfb Open a menu in a bottom sheet on a compact window
Plan step 22, item 8 (actions.md § Missing, "Adaptive menu → bottom sheet
at compact"): M3 says "at compact breakpoints, consider swapping a menu for
a bottom sheet", and nothing connected <x-menu> to <x-bottom-sheet>.

<x-menu sheet-at-compact> draws its slot twice, in the popover and in a
modal <x-bottom-sheet> teleported to <body>, and below `medium`
(upTo('medium')) the trigger opens the sheet. The trigger says
aria-haspopup="dialog" there and "menu" from medium, aria-expanded in
both. Items keep their roles and the APG keyboard in the sheet; choosing
one, Escape or Tab close it and return focus to the trigger; a submenu
opens in place under its item; `filter` works in both; a resize across
600px closes whichever is open. The sheet has a fixed id that menu.js
makes unique and keeps as its wire:key, and the lists are keyed, so a
Livewire render patches an open sheet instead of swapping it.

bottom-sheet.blade.php is used as is. The filter now finds its field and
empty row per list instead of through x-refs.

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

319 lines
11 KiB
CSS

/*
* The menus: the exposed dropdown a field drops open, and what `<x-menu>` cannot say in utilities.
*
* Two lists wear the dropdown, so a form reads as one family:
*
* - `<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 `.field-menu` and `.field-option`.
*
* M3's menu as its tokens have it: surface-container at elevation 2, extra-small 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.
*
* Unlayered, as the rest of the package's component CSS, so a utility on the call site cannot half-undo
* a row.
*
* 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.
*/
/*
* `<x-menu>` and the submenus inside it.
*
* A submenu is a popover of its own nested in the menu's, so it cannot take the container colour
* from a class the item knows nothing about: the menu declares the pair as custom properties,
* which a nested popover inherits down the DOM whatever the top layer does with its painting. The
* fallbacks are the standard menu's, for a submenu in some other list (a FAB menu's).
*/
[data-menu] {
--material-menu-surface: var(--md-sys-color-surface-container-low);
--material-menu-ink: var(--md-sys-color-on-surface);
}
[data-menu][data-vibrant] {
--material-menu-surface: var(--md-sys-color-tertiary-container);
--material-menu-ink: var(--md-sys-color-on-tertiary-container);
}
[data-submenu] {
background: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
color: var(--material-menu-ink, var(--md-sys-color-on-surface));
}
/*
* `<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-menu-filter] {
position: sticky;
inset-block-start: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 0.75rem;
min-block-size: 3rem;
padding-inline: 1rem;
border-block-end: 1px solid var(--md-sys-color-outline-variant);
background: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
}
[data-menu-filter] input {
flex: 1;
min-inline-size: 0;
border: 0;
background: transparent;
padding: 0;
color: inherit;
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
outline: none;
}
[data-menu-filter] input::placeholder {
color: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 70%, transparent);
}
/* The filter hides what the query leaves out with the `hidden` attribute; a row's own
`display: flex` utility would otherwise beat the user agent's `[hidden] { display: none }`. */
:is([data-menu], [data-menu-sheet]) [hidden] {
display: none;
}
/* `data-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-menu], [data-menu-sheet]) [role^="menuitem"][data-active]:not([aria-checked="true"], [aria-current="page"]) {
background: 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).
*/
[data-menu-sheet] [data-menu-filter] {
padding-inline: 1.5rem;
}
/*
* 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-menu-sheet] [data-submenu] {
position: static;
inset: auto;
inline-size: auto;
max-inline-size: none;
block-size: auto;
max-block-size: none;
margin: 0;
padding-block: 0.125rem;
padding-inline: 1rem 0;
overflow: visible;
border-radius: 0;
background: 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-menu-sheet] [aria-expanded="true"] + [data-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 `rtl:-scale-x-100` mirrors — so the same RTL test. */
[data-menu-sheet] [data-submenu-chevron] {
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-menu-sheet] [aria-expanded="true"] > [data-submenu-chevron] {
rotate: 90deg;
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
rotate: -90deg;
}
}
.field-menu {
max-block-size: 18rem;
overflow-y: auto;
padding-block: 0.5rem;
border-radius: var(--md-sys-shape-corner-xs);
background: 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. */
.field-menu[popover] {
inset: auto;
top: anchor(bottom);
left: anchor(left);
width: anchor-size(width);
margin: 0.25rem 0;
border: 0;
padding-inline: 0;
color: var(--md-sys-color-on-surface);
position-try-fallbacks: flip-block;
}
.field-option[aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
.field-option {
display: flex;
align-items: center;
gap: 0.75rem;
min-block-size: 3rem;
padding-inline: 1rem;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
cursor: pointer;
}
/* `data-active` is the row the arrow keys or the pointer are on. */
.field-option[data-active] {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
.field-option[aria-selected="true"] {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
.field-option[aria-selected="true"][data-active] {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
}
.field-check {
margin-inline-start: auto;
}
.field-option:not([aria-selected="true"]) .field-check {
visibility: hidden;
}
@supports (appearance: base-select) {
select.field-control,
select.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.field-control::picker(select) {
position-try-order: normal;
max-block-size: 18rem;
max-inline-size: calc(100vw - 2rem);
margin-block: 0.25rem;
padding-block: 0.5rem;
border: 0;
border-radius: var(--md-sys-shape-corner-xs);
background: 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.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.field-control:open::picker(select) {
opacity: 0;
scale: 0.95;
}
}
select.field-control option {
display: flex;
align-items: center;
gap: 0.75rem;
min-block-size: 3rem;
padding-block: 0;
padding-inline: 1rem;
background: transparent;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
outline: none;
cursor: pointer;
}
@media (hover: hover) {
select.field-control option:hover {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
select.field-control option:focus-visible {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
select.field-control option:checked {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
@media (hover: hover) {
select.field-control option:checked:hover {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
}
}
select.field-control option:checked:focus-visible {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 10%, var(--md-sys-color-secondary-container));
}
select.field-control option:disabled {
background: 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.field-control option::checkmark {
content: "";
order: 1;
flex: none;
inline-size: 1.5rem;
block-size: 1.5rem;
margin-inline-start: auto;
background-color: currentColor;
mask: url("../../svg/symbols/outlined/check.svg") center / contain no-repeat;
}
}