Draw the menu without Tailwind

<x-menu> renders data-md-menu with data-md-sheet-at-compact, and its
popover data-md-menu-popover with data-md-position and data-md-vibrant;
menu.css caps a long menu at 288px so it scrolls instead of running
off the top layer (ACT-04) and ties the open/close transition to the
trigger's corner (ACT-26), restyles a submenu to open in place inside
a sheet-at-compact sheet, and still carries the dropdown a select's
picker and a searchable choices field draw, so select.css and
choices.css finish their step-36 TODO import. actions.css sheds the
state-transition-fast and popover-transition utilities menu-item and
menu no longer use; state-transition-default and [data-fab-menu]
stay for fab-menu's own rewrite.

data-menu, data-menu-filter, data-menu-sheet, data-menu-empty,
data-sheet-at-compact, data-vibrant and data-active are renamed to
data-md-*, also in menu.js, split-button.css (which renders a
<x-menu> and now imports its stylesheet) and AppBarTest's overflow
menu (plan step 36, actions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 16:16:49 +02:00
co-authored by Claude Opus 5
parent c040925cd4
commit 5cdeec26e8
13 changed files with 501 additions and 390 deletions
+1
View File
@@ -23,6 +23,7 @@
@import './components/menu-separator.css';
@import './components/menu-group.css';
@import './components/menu-item.css';
@import './components/menu.css';
/* Inputs, selection and data */
@import './components/form.css';
+9 -50
View File
@@ -1,6 +1,8 @@
/*
* The transitions an action runs between its states — buttons, connected segments, menu items,
* the FAB menu's trigger, and an extended FAB collapsing to a FAB on scroll.
* What is left of the Tailwind-era actions utilities, waiting on the FAB menu's own rewrite (plan
* step 36, actions): `<x-fab-menu>`'s trigger still names `state-transition-default`, and its
* list still selects `[data-fab-menu]`. Once that component moves into fab-menu.css, in
* `material.components`, this file empties and leaves tailwind.css.
*
* M3 has two spring styles and they are not interchangeable: spatial moves things (position,
* size, rotation, rounded corners) and overshoots by 9% on the way, effects changes how things
@@ -10,34 +12,15 @@
* (`duration-*` and `ease-*` each emit one value); CSS can, by giving `transition-property` a
* list and matching it position for position.
*
* `state-transition-fast` is the press/hover/select transition of a control the finger lands on:
* shape, size and the place it sits on the fast spatial spring (a `fab` button nudges up when a
* snackbar arrives under it), colour and elevation on the fast effects spring.
* `state-transition-default` is the same pair one step slower, for the bigger morph of a FAB
* turning into a close button.
* `state-transition-default` is the FAB menu trigger's morph into a close button: shape, size and
* position on the default spatial spring, colour and elevation on the default effects spring
* beside it.
*
* `popover-transition` is the enter and exit of a menu: M3 asks for a transition that ties the
* list to its trigger, so it scales up from its `transform-origin` on the spatial spring while it
* fades in on the effects one, and `allow-discrete` keeps `display` and `overlay` alive long
* enough for the exit to be seen.
*
* These are `@utility` declarations, so they compile into Tailwind's utilities layer like any
* other class: a `transition-*` utility written beside one on the call site will fight it, and
* This is an `@utility` declaration, so it compiles into Tailwind's utilities layer like any
* other class: a `transition-*` utility written beside it on the call site will fight it, and
* whichever Tailwind emits last wins. Pass neither.
*/
@utility state-transition-fast {
transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow;
transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
transition-timing-function:
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
}
@utility state-transition-default {
transition-property: border-radius, padding, margin, background-color, color, box-shadow;
transition-duration:
@@ -48,30 +31,6 @@
var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default);
}
@utility popover-transition {
opacity: 0;
scale: 0.95;
transition-property: opacity, scale, display, overlay;
transition-duration:
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function:
var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear;
transition-behavior: allow-discrete;
&:popover-open {
opacity: 1;
scale: 1;
}
@starting-style {
&:popover-open {
opacity: 0;
scale: 0.95;
}
}
}
/*
* A FAB menu's items rise into place as the list opens and sink back as it closes. The entry is
* `@starting-style` on the item itself; the exit needs the state to be readable while the popover
+1 -1
View File
@@ -18,7 +18,7 @@
@import './icon.css';
@import './chip-set.css';
@import './chip.css';
/* TODO(step 36): @import './menu.css' once the menu leaves Tailwind (actions stream); its field-menu rules draw the open list. */
@import './menu.css';
@layer material.components {
[data-md-choices-empty] {
+382 -272
View File
@@ -1,318 +1,428 @@
/*
* The menus: the exposed dropdown a field drops open, and what `<x-menu>` cannot say in utilities.
* The menus: `<x-menu>` and what a form's own dropdowns wear too, so a menu and a form read as
* one family.
*
* Two lists wear the dropdown, so a form reads as one family:
* Two lists wear the dropdown:
*
* - `<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]`.
* (`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: 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.
* 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.
*
* Unlayered, as the rest of the package's component CSS, so a utility on the call site cannot half-undo
* a row.
* `[data-md-menu]` is `<x-menu>`'s root, holding the trigger and the popover; the popover itself
* is `[data-md-menu-popover]`, capped at 18rem 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>` (still Tailwind): `[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.
* `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);
}
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
[data-menu][data-vibrant] {
--material-menu-surface: var(--md-sys-color-tertiary-container);
--material-menu-ink: var(--md-sys-color-on-tertiary-container);
}
@import './icon.css';
[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;
}
}
[data-md-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. */
[data-md-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;
}
[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: 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. */
[data-md-field-option][data-active] {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
[data-md-field-option][aria-selected="true"] {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-md-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));
}
[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) {
select[data-md-field-control],
select[data-md-field-control]::picker(select) {
appearance: base-select;
@layer material.components {
[data-md-menu] {
position: relative;
display: inline-flex;
}
/* 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 - 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);
[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;
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;
scale: 0.95;
transition-property: opacity, scale, display, overlay;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
transition-duration:
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
transition-timing-function:
var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear;
transition-behavior: allow-discrete;
@starting-style {
select[data-md-field-control]:open::picker(select) {
opacity: 0;
scale: 0.95;
&:popover-open {
opacity: 1;
scale: 1;
}
@starting-style {
&:popover-open {
opacity: 0;
scale: 0.95;
}
}
}
select[data-md-field-control] option {
[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: 0.75rem;
min-block-size: 3rem;
padding-block: 0;
padding-inline: 1rem;
background: transparent;
color: var(--md-sys-color-on-surface);
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;
cursor: pointer;
}
@media (hover: hover) {
select[data-md-field-control] option:hover {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
[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;
}
}
select[data-md-field-control] option:focus-visible {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
/* 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);
}
select[data-md-field-control] option:checked {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-md-menu-sheet] [aria-expanded="true"] > [data-md-submenu-chevron] {
rotate: 90deg;
@media (hover: hover) {
select[data-md-field-control] option:checked:hover {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
&:dir(rtl) {
rotate: -90deg;
}
}
select[data-md-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));
[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);
}
select[data-md-field-control] option:disabled {
background: transparent;
/* 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;
}
/* 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: 1.5rem;
block-size: 1.5rem;
[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-active` is the row the arrow keys or the pointer are on. */
[data-md-field-option][data-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-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;
background-color: currentColor;
mask: url("../../svg/symbols/outlined/check.svg") center / contain no-repeat;
}
[data-md-field-option]:not([aria-selected="true"]) [data-md-field-check] {
visibility: hidden;
}
@supports (appearance: base-select) {
select[data-md-field-control],
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;
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
@import './field.css';
@import './icon.css';
/* TODO(step 36): @import './menu.css' once the menu leaves Tailwind (actions stream); its select rules draw the open list. */
@import './menu.css';
@layer material.components {
/* A select covers its whole field — out over the padding and the leading icon at
@@ -21,6 +21,7 @@
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css';
@import './menu.css';
@layer material.components {
[data-md-split-button] {
-1
View File
@@ -18,7 +18,6 @@
@import './tokens/utilities.css';
/* Actions and communication — leaving in step 36 */
@import './components/actions.css';
@import './components/menu.css';
/* Inputs, selection and data — leaving in step 36 */
@import './components/datepicker.css';
+10 -10
View File
@@ -134,7 +134,7 @@ const menu = () => ({
}
})
if (this.$el.hasAttribute('data-sheet-at-compact')) {
if (this.$el.hasAttribute('data-md-sheet-at-compact')) {
this.compact = upTo('medium')
// A window resized across 600px while the menu is open would leave it in the
@@ -256,12 +256,12 @@ const menu = () => ({
/** The list the person is looking at: the popover, or the sheet's copy of it. */
list() {
return this.shown === 'sheet' ? this.$refs.sheetHost.querySelector('[data-menu-sheet]') : this.$refs.menu
return this.shown === 'sheet' ? this.$refs.sheetHost.querySelector('[data-md-menu-sheet]') : this.$refs.menu
},
/** The filter field at the top of a list, if the menu has one; a submenu never does. */
field(root = this.list()) {
return root?.querySelector(':scope > [data-menu-filter] input') ?? null
return root?.querySelector(':scope > [data-md-menu-filter] input') ?? null
},
open(focus = 'first') {
@@ -365,7 +365,7 @@ const menu = () => ({
return
}
const list = this.$refs.sheetHost?.querySelector('[data-menu-sheet]')
const list = this.$refs.sheetHost?.querySelector('[data-md-menu-sheet]')
if (list) {
this.clear(list)
@@ -400,7 +400,7 @@ const menu = () => ({
* submenu keeps its `popover` attribute in the sheet too, where it opens in place.
*/
items(root = this.list()) {
return [...root.querySelectorAll(ITEMS)].filter((item) => item.closest('[popover], [data-menu-sheet]') === root)
return [...root.querySelectorAll(ITEMS)].filter((item) => item.closest('[popover], [data-md-menu-sheet]') === root)
},
/** The menu scrolls when it is too long for the window, so the item taken has to be shown. */
@@ -521,7 +521,7 @@ const menu = () => ({
const left = this.visible(root)
root.querySelector('[data-menu-empty]').hidden = left.length > 0
root.querySelector('[data-md-menu-empty]').hidden = left.length > 0
this.mark(left[0] ?? null, root)
},
@@ -540,7 +540,7 @@ const menu = () => ({
for (const each of this.items(root)) {
if (each !== item) {
each.removeAttribute('data-active')
each.removeAttribute('data-md-active')
}
}
@@ -551,7 +551,7 @@ const menu = () => ({
}
item.id ||= `${root.id}-item-${this.items(root).indexOf(item)}`
item.setAttribute('data-active', '')
item.setAttribute('data-md-active', '')
field?.setAttribute('aria-activedescendant', item.id)
if (this.isOpen()) {
@@ -562,7 +562,7 @@ const menu = () => ({
/** The APG combobox keyboard, on the field: the list moves under it and Enter takes a row. */
search(event) {
const left = this.visible()
const current = left.findIndex((item) => item.hasAttribute('data-active'))
const current = left.findIndex((item) => item.hasAttribute('data-md-active'))
const move = (index) => {
event.preventDefault()
@@ -651,7 +651,7 @@ document.addEventListener('alpine:init', () => {
/** In the sheet of a `sheet-at-compact` menu the submenu opens in place, under its item. */
init() {
this.inline = this.$el.closest('[data-menu-sheet]') !== null
this.inline = this.$el.closest('[data-md-menu-sheet]') !== null
base.init.call(this)
},
+26 -33
View File
@@ -33,12 +33,12 @@
same key.
A menu too long for the window scrolls, as M3 asks, rather than running off the edge of the
top layer where nothing can reach it: 18rem at most, and less on a short window. The arrow
keys, Home, End and typeahead bring the item they move to into view, and a disabled item is
among them: M3 keeps one reachable so a person can find out that it exists.
top layer where nothing can reach it (ACT-04): 18rem at most, and less on a short window. The
arrow keys, Home, End and typeahead bring the item they move to into view, and a disabled
item is among them: M3 keeps one reachable so a person can find out that it exists.
It opens by growing out of the corner nearest its trigger and fades as it goes
(`popover-transition`), which is the transition M3 asks to tie a menu to what opened it.
It opens by growing out of the corner nearest its trigger and fades as it goes, which is the
transition M3 asks to tie a menu to what opened it (ACT-26).
`filter` is M3's menu as a filtering surface ("autocomplete"): a text field at the top of the
list, which stays put while the list scrolls under it, narrowing the items to those whose
@@ -79,7 +79,11 @@
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
2), or `vibrant` in tertiary-container StandardMenuTokens and VibrantMenuTokens from
androidx Compose Material 3 (Apache-2.0). --}}
androidx Compose Material 3 (Apache-2.0).
Drawn by resources/css/components/menu.css, which also draws the dropdown a form's own lists
wear — `<x-select>`'s exposed picker and `<x-choices searchable>`'s listbox — so a menu and a
form's own dropdowns read as one family (`[data-md-field-menu]`, `[data-md-field-option]`). --}}
@props([
'label' => null,
@@ -102,8 +106,8 @@
$sheetLabel = filled($label) ? $label : __('Menu');
@endphp
<div x-data="materialMenu" @if ($sheet) data-sheet-at-compact @endif {{ $attributes->class('relative inline-flex') }}>
<span x-ref="trigger" class="inline-flex" style="anchor-name: {{ $anchor }}"
<div x-data="materialMenu" data-md-menu @if ($sheet) data-md-sheet-at-compact @endif {{ $attributes }}>
<span x-ref="trigger" data-md-menu-trigger style="anchor-name: {{ $anchor }}"
x-on:click="toggle('first')"
x-on:keydown.down.prevent="open('first')"
x-on:keydown.up.prevent="open('last')"
@@ -115,29 +119,18 @@
id="material-menu-{{ $key }}"
popover="auto"
@unless ($filtering) role="menu" @endunless
data-menu
@if ($vibrant) data-vibrant @endif
data-md-menu-popover
data-md-position="{{ $position }}"
@if ($vibrant) data-md-vibrant @endif
@if ($label && ! $filtering) aria-label="{{ $label }}" @endif
tabindex="-1"
style="position-anchor: {{ $anchor }}"
x-on:keydown="navigate($event)"
x-on:click="activate($event)"
@class([
'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'p-1' => ! $filtering,
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'popover-transition',
'bg-surface-container-low text-on-surface' => ! $vibrant,
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
'origin-top [position-area:bottom_span-right]' => $position === 'bottom-start',
'origin-top [position-area:bottom_span-left]' => $position === 'bottom-end',
'origin-bottom [position-area:top_span-right]' => $position === 'top-start',
'origin-bottom [position-area:top_span-left]' => $position === 'top-end',
])
>
@if ($filtering)
<div data-menu-filter>
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" />
<div data-md-menu-filter>
<x-livewire-material::icon name="search" size="20" />
<input
type="text"
@@ -157,10 +150,10 @@
'id' => "material-menu-{$key}-list",
'role' => 'menu',
'aria-label' => $label,
], fn ($value): bool => filled($value))) }} class="p-1">
], fn ($value): bool => filled($value))) }} data-md-menu-list>
{{ $slot }}
<p data-menu-empty hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</p>
<p data-md-menu-empty hidden>{{ __('Nothing matches') }}</p>
</div>
@else
{{ $slot }}
@@ -184,7 +177,7 @@
<template x-teleport="body">
<div
x-ref="sheetHost"
data-menu-sheet-host
data-md-menu-sheet-host
x-on:keydown.capture="sheetKeydown($event)"
x-on:click="activate($event)"
x-on:input="refine()"
@@ -195,9 +188,9 @@
<div {{ new \Illuminate\View\ComponentAttributeBag([
'wire:key' => 'material-menu-sheet-menu',
'id' => "material-menu-{$key}-sheet-menu",
]) }} data-menu-sheet class="-mx-6">
<div data-menu-filter>
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" />
]) }} data-md-menu-sheet>
<div data-md-menu-filter>
<x-livewire-material::icon name="search" size="20" />
<input
type="text"
@@ -216,10 +209,10 @@
'id' => "material-menu-{$key}-sheet-menu-list",
'role' => 'menu',
'aria-label' => $label,
], fn ($value): bool => filled($value))) }} class="px-2 pt-1">
], fn ($value): bool => filled($value))) }} data-md-menu-list>
{{ $slot }}
<p data-menu-empty hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</p>
<p data-md-menu-empty hidden>{{ __('Nothing matches') }}</p>
</div>
</div>
@else
@@ -228,7 +221,7 @@
'id' => "material-menu-{$key}-sheet-menu",
'role' => 'menu',
'aria-label' => $label,
], fn ($value): bool => filled($value))) }} data-menu-sheet class="-mx-6 px-2">
], fn ($value): bool => filled($value))) }} data-md-menu-sheet>
{{ $slot }}
</div>
@endif
@@ -22,6 +22,7 @@ dataset('action components', [
'menu-separator',
'menu-group',
'menu-item',
'menu',
]);
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
+1 -1
View File
@@ -73,7 +73,7 @@ it('keeps two trailing icon buttons below medium and four from it, the rest in a
->toMatch('/<button[^>]*aria-label="Star" aria-pressed="true"/')
// One overflow button per width, named and tooltipped.
->and(preg_match_all('/<button[^>]*data-md-size="sm"[^>]*aria-label="More options"/', $html))->toBe(2)
->and(preg_match_all('/role="menu"\s+data-menu\s+aria-label="More options"/', $html))->toBe(2)
->and(preg_match_all('/role="menu"\s+data-md-menu-popover\s+data-md-position="bottom-end"\s+aria-label="More options"/', $html))->toBe(2)
// Below medium: all but the first.
->and($compact)
->toMatch('/<a data-md-menu-item[^>]*role="menuitem" tabindex="-1" href="\/shares\/1\/share"/')
+67 -20
View File
@@ -15,26 +15,41 @@ it('opens a popover menu from its trigger', function () {
expect($anchor)->not->toBeEmpty()
->and($html)
->toContain('x-data="materialMenu"')
->toContain('data-md-menu')
->toContain('<button>More</button>')
->toContain('popover="auto"')
->toContain('role="menu"')
->toContain('data-md-menu-popover')
->toContain('aria-label="Share actions"')
->toContain("id=\"material-menu-{$anchor[2]}\"")
->toContain("position-anchor: {$anchor[1]}")
->toContain('bg-surface-container-low')
->toContain('[position-area:bottom_span-right]');
->and(ComponentStylesheet::read('menu')->declarations("[data-md-menu-popover][data-md-position='bottom-start']"))->toBe([
'transform-origin' => 'top',
'position-area' => 'bottom span-right',
]);
});
it('scrolls a menu too long for the window instead of running off it', function () {
expect((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto')
->not->toContain('overflow-visible');
->toContain('data-md-menu-popover')
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover]'))->toMatchArray([
'max-block-size' => 'min(288px, calc(100dvh - 32px))',
'overflow-y' => 'auto',
]);
});
it('opens at the position asked for, in the vibrant colours on request', function () {
$html = (string) $this->blade('<x-menu position="top-end" vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>');
expect($html)->toContain('[position-area:top_span-left]')->toContain('bg-tertiary-container text-on-tertiary-container');
expect($html)->toContain('data-md-position="top-end"')->toContain('data-md-vibrant')
->and(ComponentStylesheet::read('menu')->declarations("[data-md-menu-popover][data-md-position='top-end']"))->toBe([
'transform-origin' => 'bottom',
'position-area' => 'top span-left',
])
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover][data-md-vibrant]'))->toBe([
'--material-menu-surface' => 'var(--md-sys-color-tertiary-container)',
'--material-menu-ink' => 'var(--md-sys-color-on-tertiary-container)',
]);
});
it('draws an item as a menuitem button', function () {
@@ -215,7 +230,7 @@ it('embeds a text field that filters the list, as a combobox over the menu', fun
expect($id)->not->toBeEmpty()
->and($html)
->toContain('data-menu-filter')
->toContain('data-md-menu-filter')
->toContain('role="combobox"')
->toContain('aria-autocomplete="list"')
->toContain("aria-controls=\"{$id[1]}-list\"")
@@ -223,7 +238,7 @@ it('embeds a text field that filters the list, as a combobox over the menu', fun
->toContain('placeholder="Find a person"')
->toContain('x-on:input="refine()"')
->toContain('x-on:keydown.stop="search($event)"')
->toContain('<p data-menu-empty hidden')
->toContain('<p data-md-menu-empty hidden')
->toContain('Nothing matches')
// A text field is not something a `role="menu"` may hold, so the list moves inside it.
->toContain("<div id=\"{$id[1]}-list\" role=\"menu\" aria-label=\"Assign to\"")
@@ -234,18 +249,18 @@ it('names a bare filter field, and leaves a plain menu alone', function () {
expect((string) $this->blade('<x-menu filter><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('aria-label="Filter"')
->and((string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->not->toContain('data-menu-filter')
->not->toContain('data-md-menu-filter')
->not->toContain('role="combobox"')
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"/');
});
it('tells a vibrant menu apart, so the submenus inside it take the same container', function () {
expect((string) $this->blade('<x-menu vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-menu')
->toContain('data-vibrant')
->toContain('data-md-menu')
->toContain('data-md-vibrant')
->and((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-menu')
->not->toContain('data-vibrant');
->toContain('data-md-menu')
->not->toContain('data-md-vibrant');
});
it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as the popover', function () {
@@ -265,7 +280,7 @@ it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as th
// The popover is the menu it always was, and the root tells menu.js to look for the sheet.
expect($popover)
->toMatch('/<div x-data="materialMenu"\s+data-sheet-at-compact/')
->toMatch('/<div x-data="materialMenu" data-md-menu\s+data-md-sheet-at-compact/')
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"[^>]*aria-label="Photo actions"/')
->toContain('wire:click="wallpaper"')
// The sheet goes to the end of <body>, out of any bar's stacking context, and its host
@@ -289,7 +304,7 @@ it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as th
->toContain('aria-label="Photo actions"')
->toContain('rounded-t-corner-xl bg-surface-container-low')
// The same slot, drawn again as a menu: the items keep their roles and their actions.
->toMatch("/<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" role=\"menu\" aria-label=\"Photo actions\" data-menu-sheet/")
->toMatch("/<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" role=\"menu\" aria-label=\"Photo actions\" data-md-menu-sheet/")
->and(substr_count($html, 'role="menuitem"'))->toBe(4)
->and(substr_count($html, 'wire:click="wallpaper"'))->toBe(2);
});
@@ -298,10 +313,10 @@ it('leaves the sheet out of a menu that does not ask for it', function () {
$html = (string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger><x-menu-item label="Copy" /></x-menu>');
expect($html)
->not->toContain('data-sheet-at-compact')
->not->toContain('data-md-sheet-at-compact')
->not->toContain('x-teleport')
->not->toContain('materialBottomSheet')
->not->toContain('data-menu-sheet')
->not->toContain('data-md-menu-sheet')
->and(substr_count($html, 'role="menuitem"'))->toBe(1);
});
@@ -320,15 +335,15 @@ it('names a sheet with no menu label, and draws a filtering menu\'s field in the
expect($sheet)
->toContain('aria-label="Menu"')
->toContain('x-on:input="refine()"')
->toContain("<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" data-menu-sheet")
->toContain('data-menu-filter')
->toContain("<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" data-md-menu-sheet")
->toContain('data-md-menu-filter')
->toContain('role="combobox"')
->toContain("aria-controls=\"material-menu-{$key[1]}-sheet-menu-list\"")
->toContain('placeholder="Find a person"')
->toContain("<div wire:key=\"material-menu-sheet-list\" id=\"material-menu-{$key[1]}-sheet-menu-list\" role=\"menu\"")
->toContain('<p data-menu-empty hidden')
->toContain('<p data-md-menu-empty hidden')
// The sheet is M3's own container whatever the menu's colour.
->not->toContain('data-vibrant')
->not->toContain('data-md-vibrant')
->not->toContain('bg-tertiary-container')
->and(substr_count($html, 'role="combobox"'))->toBe(2);
});
@@ -349,3 +364,35 @@ it('marks a submenu\'s chevron, which turns to point down at the list a sheet op
->and((string) $this->blade('<x-menu-item label="Export as" icon-right="download" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->not->toContain('data-md-submenu-chevron');
});
it('restyles a submenu to open in place under its item inside the sheet, and cancels the sheet\'s own padding', function () {
$css = ComponentStylesheet::read('menu');
expect($css->declarations('[data-md-menu-sheet]'))->toMatchArray([
'margin-inline' => 'calc(-1 * var(--md-sys-measurement-space300))',
'padding-inline' => 'var(--md-sys-measurement-space100)',
])
->and($css->declarations('[data-md-menu-sheet] [data-md-submenu]'))->toMatchArray([
'position' => 'static',
'overflow' => 'visible',
])
->and($css->declarations('[data-md-menu-sheet] [aria-expanded="true"] > [data-md-submenu-chevron]'))->toMatchArray(['rotate' => '90deg'])
->and($css->declarations('[data-md-menu-sheet] [aria-expanded="true"] > [data-md-submenu-chevron]:dir(rtl)'))->toBe(['rotate' => '-90deg']);
});
it('draws the dropdown a select or a searchable choices field opens, the same family as a menu', function () {
$css = ComponentStylesheet::read('menu');
expect($css->declarations('[data-md-field-menu]'))->toMatchArray([
'max-block-size' => '18rem',
'border-radius' => 'var(--md-sys-shape-corner-xs)',
'background-color' => 'var(--md-sys-color-surface-container)',
])
->and($css->declarations('[data-md-field-option][aria-selected="true"]'))->toBe([
'background-color' => 'var(--md-sys-color-secondary-container)',
'color' => 'var(--md-sys-color-on-secondary-container)',
])
->and($css->css)->toContain('@supports (appearance: base-select) {')
->and(ComponentStylesheet::read('select')->imports())->toContain('./menu.css')
->and(ComponentStylesheet::read('choices')->imports())->toContain('./menu.css');
});
+1 -1
View File
@@ -37,7 +37,7 @@ it('leaves the corners to the pair, growing the inner ones under the finger', fu
'--md-split-button-icon' => '50px',
'--md-split-button-nudge' => '6px',
])
->and($css->imports())->toBe(['./button.css']);
->and($css->imports())->toBe(['./button.css', './menu.css']);
});
it('is filled unless another container variant is asked for', function () {