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] {
+235 -125
View File
@@ -1,113 +1,220 @@
/*
* 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] {
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.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);
}
[data-menu][data-vibrant] {
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, 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;
}
}
}
[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-submenu] {
background: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
color: var(--material-menu-ink, var(--md-sys-color-on-surface));
}
[data-md-menu-popover][data-md-position='bottom-start'] {
transform-origin: top;
position-area: bottom span-right;
}
/*
* `<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-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-menu-filter] {
[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-inline: 1rem;
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: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
}
background-color: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
}
[data-menu-filter] input {
[data-md-menu-filter] input {
flex: 1;
min-inline-size: 0;
border: 0;
background: transparent;
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-menu-filter] input::placeholder {
[data-md-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] {
[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-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);
}
/* `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-menu-sheet] [data-menu-filter] {
padding-inline: 1.5rem;
}
[data-md-menu-sheet] {
margin-inline: calc(-1 * var(--md-sys-measurement-space300));
padding-inline: var(--md-sys-measurement-space100);
}
/*
* 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
[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.
* list is shown while its item says `aria-expanded="true"`, and it fades in on the effects
* track.
*/
[data-menu-sheet] [data-submenu] {
[data-md-menu-sheet] [data-md-submenu] {
position: static;
inset: auto;
inline-size: auto;
@@ -115,103 +222,104 @@
block-size: auto;
max-block-size: none;
margin: 0;
padding-block: 0.125rem;
padding-inline: 1rem 0;
padding-block: var(--md-sys-measurement-space25);
padding-inline: var(--md-sys-measurement-space200) 0;
overflow: visible;
border-radius: 0;
background: transparent;
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-menu-sheet] [aria-expanded="true"] + [data-submenu] {
[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 `rtl:-scale-x-100` mirrors — so the same RTL test. */
[data-menu-sheet] [data-submenu-chevron] {
/* 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-menu-sheet] [aria-expanded="true"] > [data-submenu-chevron] {
[data-md-menu-sheet] [aria-expanded="true"] > [data-md-submenu-chevron] {
rotate: 90deg;
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
&:dir(rtl) {
rotate: -90deg;
}
}
}
[data-md-field-menu] {
[data-md-field-menu] {
max-block-size: 18rem;
overflow-y: auto;
padding-block: 0.5rem;
padding-block: var(--md-sys-measurement-space100);
border-radius: var(--md-sys-shape-corner-xs);
background: var(--md-sys-color-surface-container);
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] {
/* 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;
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"] {
[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] {
[data-md-field-option] {
display: flex;
align-items: center;
gap: 0.75rem;
min-block-size: 3rem;
padding-inline: 1rem;
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-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
/* `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: var(--md-sys-color-secondary-container);
[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-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-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] {
[data-md-field-check] {
margin-inline-start: auto;
}
}
[data-md-field-option]:not([aria-selected="true"]) [data-md-field-check] {
[data-md-field-option]:not([aria-selected="true"]) [data-md-field-check] {
visibility: hidden;
}
}
@supports (appearance: base-select) {
@supports (appearance: base-select) {
select[data-md-field-control],
select[data-md-field-control]::picker(select) {
appearance: base-select;
@@ -226,12 +334,12 @@
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;
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: var(--md-sys-color-surface-container);
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
box-shadow: var(--md-sys-elevation-2);
opacity: 0;
@@ -259,45 +367,46 @@
select[data-md-field-control] option {
display: flex;
align-items: center;
gap: 0.75rem;
min-block-size: 3rem;
gap: 12px;
min-block-size: var(--md-sys-measurement-space600);
padding-block: 0;
padding-inline: 1rem;
background: transparent;
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-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
select[data-md-field-control] option:focus-visible {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
select[data-md-field-control] option:checked {
background: var(--md-sys-color-secondary-container);
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-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
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-mix(in srgb, var(--md-sys-color-on-secondary-container) 10%, var(--md-sys-color-secondary-container));
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: transparent;
background-color: transparent;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
@@ -309,10 +418,11 @@
content: "";
order: 1;
flex: none;
inline-size: 1.5rem;
block-size: 1.5rem;
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 () {