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-separator.css';
@import './components/menu-group.css'; @import './components/menu-group.css';
@import './components/menu-item.css'; @import './components/menu-item.css';
@import './components/menu.css';
/* Inputs, selection and data */ /* Inputs, selection and data */
@import './components/form.css'; @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, * What is left of the Tailwind-era actions utilities, waiting on the FAB menu's own rewrite (plan
* the FAB menu's trigger, and an extended FAB collapsing to a FAB on scroll. * 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, * 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 * 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 * (`duration-*` and `ease-*` each emit one value); CSS can, by giving `transition-property` a
* list and matching it position for position. * list and matching it position for position.
* *
* `state-transition-fast` is the press/hover/select transition of a control the finger lands on: * `state-transition-default` is the FAB menu trigger's morph into a close button: shape, size and
* shape, size and the place it sits on the fast spatial spring (a `fab` button nudges up when a * position on the default spatial spring, colour and elevation on the default effects spring
* snackbar arrives under it), colour and elevation on the fast effects spring. * beside it.
* `state-transition-default` is the same pair one step slower, for the bigger morph of a FAB
* turning into a close button.
* *
* `popover-transition` is the enter and exit of a menu: M3 asks for a transition that ties the * This is an `@utility` declaration, so it compiles into Tailwind's utilities layer like any
* list to its trigger, so it scales up from its `transform-origin` on the spatial spring while it * other class: a `transition-*` utility written beside it on the call site will fight it, and
* 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
* whichever Tailwind emits last wins. Pass neither. * 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 { @utility state-transition-default {
transition-property: border-radius, padding, margin, background-color, color, box-shadow; transition-property: border-radius, padding, margin, background-color, color, box-shadow;
transition-duration: transition-duration:
@@ -48,30 +31,6 @@
var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default); 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 * 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 * `@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 './icon.css';
@import './chip-set.css'; @import './chip-set.css';
@import './chip.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 { @layer material.components {
[data-md-choices-empty] { [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 * - `<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 * (`appearance: base-select`). `::picker(select)` is the menu and each <option> a row, while
* keyboard, type-to-find, the screen reader and the form value stay the browser's own. * 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]`. * - `<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 * M3's menu as its tokens have it (StandardMenuTokens/VibrantMenuTokens/SegmentedMenuTokens,
* body-large, the chosen row in secondary-container with a tick at its end (so it is not told by colour * androidx Compose Material 3, Apache-2.0): surface-container-low at elevation 2, large corner,
* alone), and the state layer on hover (only where a pointer can hover) and focus. * 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 * `[data-md-menu]` is `<x-menu>`'s root, holding the trigger and the popover; the popover itself
* a row. * 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 * 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 * `color-scheme` themes. Every rule for the select's own rows is inside `@supports`: a browser
* paints option colours into its native list would otherwise show a half-painted one. * that paints option colours into its native list would otherwise show a half-painted one.
*/ */
/* @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
* `<x-menu>` and the submenus inside it.
*
* A submenu is a popover of its own nested in the menu's, so it cannot take the container colour
* from a class the item knows nothing about: the menu declares the pair as custom properties,
* which a nested popover inherits down the DOM whatever the top layer does with its painting. The
* fallbacks are the standard menu's, for a submenu in some other list (a FAB menu's).
*/
[data-menu] {
--material-menu-surface: var(--md-sys-color-surface-container-low);
--material-menu-ink: var(--md-sys-color-on-surface);
}
[data-menu][data-vibrant] { @import './icon.css';
--material-menu-surface: var(--md-sys-color-tertiary-container);
--material-menu-ink: var(--md-sys-color-on-tertiary-container);
}
[data-submenu] { @layer material.components {
background: var(--material-menu-surface, var(--md-sys-color-surface-container-low)); [data-md-menu] {
color: var(--material-menu-ink, var(--md-sys-color-on-surface)); position: relative;
} display: inline-flex;
/*
* `<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;
} }
/* The menu. The browser anchors it to the select; the select covers its whole [data-md-menu-trigger] {
field (field.css), so the menu is at least the field's width. It hangs under display: inline-flex;
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 [data-md-menu-popover] {
closes with a fade, as `<x-menu>` does. */ --material-menu-surface: var(--md-sys-color-surface-container-low);
select[data-md-field-control]::picker(select) { --material-menu-ink: var(--md-sys-color-on-surface);
position-try-order: normal;
max-block-size: 18rem; inset: auto;
max-inline-size: calc(100vw - 2rem); margin: 0;
margin-block: 0.25rem; margin-block: var(--md-sys-measurement-space50);
padding-block: 0.5rem; min-inline-size: 112px;
border: 0; max-inline-size: 280px;
border-radius: var(--md-sys-shape-corner-xs); max-block-size: min(288px, calc(100dvh - 32px));
background: var(--md-sys-color-surface-container); overflow-y: auto;
color: var(--md-sys-color-on-surface); 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); box-shadow: var(--md-sys-elevation-2);
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
opacity: 0; opacity: 0;
transform-origin: top; scale: 0.95;
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-property: opacity, scale, display, overlay;
transition-duration: var(--md-sys-motion-spatial-fast-duration); transition-duration:
transition-timing-function: var(--md-sys-motion-spatial-fast); 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 { &:popover-open {
select[data-md-field-control]:open::picker(select) { opacity: 1;
opacity: 0; scale: 1;
scale: 0.95; }
@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; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 12px;
min-block-size: 3rem; min-block-size: var(--md-sys-measurement-space600);
padding-block: 0; padding-inline: var(--md-sys-measurement-space200);
padding-inline: 1rem; border-block-end: 1px solid var(--md-sys-color-outline-variant);
background: transparent; background-color: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
color: var(--md-sys-color-on-surface); }
[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); font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking); letter-spacing: var(--md-sys-typescale-body-lg-tracking);
font-variation-settings: normal;
outline: none; outline: none;
cursor: pointer;
} }
@media (hover: hover) { [data-md-menu-filter] input::placeholder {
select[data-md-field-control] option:hover { color: color-mix(in srgb, var(--material-menu-ink, var(--md-sys-color-on-surface)) 70%, transparent);
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, 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 { /* The chevron turns from the way a popover would open to the way this list did: down, which
background: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent); 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 { [data-md-menu-sheet] [aria-expanded="true"] > [data-md-submenu-chevron] {
background: var(--md-sys-color-secondary-container); rotate: 90deg;
color: var(--md-sys-color-on-secondary-container);
}
@media (hover: hover) { &:dir(rtl) {
select[data-md-field-control] option:checked:hover { rotate: -90deg;
background: 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 { [data-md-field-menu] {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 10%, var(--md-sys-color-secondary-container)); 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 { /* As a popover (`<x-choices searchable>`), it hangs under its field, as wide, and goes above
background: transparent; 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); color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default; cursor: default;
} }
/* The tick, at the row's end: the browser draws it before the label, and hides [data-md-field-option] {
it on every row but the chosen one. The Material Symbol, as a mask, so it display: flex;
takes the row's ink. */ align-items: center;
select[data-md-field-control] option::checkmark { gap: 12px;
content: ""; min-block-size: var(--md-sys-measurement-space600);
order: 1; padding-inline: var(--md-sys-measurement-space200);
flex: none; color: var(--md-sys-color-on-surface);
inline-size: 1.5rem; font: var(--md-sys-typescale-body-lg);
block-size: 1.5rem; 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; 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 './field.css';
@import './icon.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 { @layer material.components {
/* A select covers its whole field — out over the padding and the leading icon at /* 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; @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css'; @import './button.css';
@import './menu.css';
@layer material.components { @layer material.components {
[data-md-split-button] { [data-md-split-button] {
-1
View File
@@ -18,7 +18,6 @@
@import './tokens/utilities.css'; @import './tokens/utilities.css';
/* Actions and communication — leaving in step 36 */ /* Actions and communication — leaving in step 36 */
@import './components/actions.css'; @import './components/actions.css';
@import './components/menu.css';
/* Inputs, selection and data — leaving in step 36 */ /* Inputs, selection and data — leaving in step 36 */
@import './components/datepicker.css'; @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') this.compact = upTo('medium')
// A window resized across 600px while the menu is open would leave it in the // 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. */ /** The list the person is looking at: the popover, or the sheet's copy of it. */
list() { 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. */ /** The filter field at the top of a list, if the menu has one; a submenu never does. */
field(root = this.list()) { 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') { open(focus = 'first') {
@@ -365,7 +365,7 @@ const menu = () => ({
return return
} }
const list = this.$refs.sheetHost?.querySelector('[data-menu-sheet]') const list = this.$refs.sheetHost?.querySelector('[data-md-menu-sheet]')
if (list) { if (list) {
this.clear(list) this.clear(list)
@@ -400,7 +400,7 @@ const menu = () => ({
* submenu keeps its `popover` attribute in the sheet too, where it opens in place. * submenu keeps its `popover` attribute in the sheet too, where it opens in place.
*/ */
items(root = this.list()) { 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. */ /** 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) 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) this.mark(left[0] ?? null, root)
}, },
@@ -540,7 +540,7 @@ const menu = () => ({
for (const each of this.items(root)) { for (const each of this.items(root)) {
if (each !== item) { 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.id ||= `${root.id}-item-${this.items(root).indexOf(item)}`
item.setAttribute('data-active', '') item.setAttribute('data-md-active', '')
field?.setAttribute('aria-activedescendant', item.id) field?.setAttribute('aria-activedescendant', item.id)
if (this.isOpen()) { 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. */ /** The APG combobox keyboard, on the field: the list moves under it and Enter takes a row. */
search(event) { search(event) {
const left = this.visible() 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) => { const move = (index) => {
event.preventDefault() 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. */ /** In the sheet of a `sheet-at-compact` menu the submenu opens in place, under its item. */
init() { init() {
this.inline = this.$el.closest('[data-menu-sheet]') !== null this.inline = this.$el.closest('[data-md-menu-sheet]') !== null
base.init.call(this) base.init.call(this)
}, },
+26 -33
View File
@@ -33,12 +33,12 @@
same key. same key.
A menu too long for the window scrolls, as M3 asks, rather than running off the edge of the 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 top layer where nothing can reach it (ACT-04): 18rem at most, and less on a short window. The
keys, Home, End and typeahead bring the item they move to into view, and a disabled item is arrow keys, Home, End and typeahead bring the item they move to into view, and a disabled
among them: M3 keeps one reachable so a person can find out that it exists. 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 It opens by growing out of the corner nearest its trigger and fades as it goes, which is the
(`popover-transition`), which is the transition M3 asks to tie a menu to what opened it. 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 `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 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 The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
2), or `vibrant` in tertiary-container StandardMenuTokens and VibrantMenuTokens from 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([ @props([
'label' => null, 'label' => null,
@@ -102,8 +106,8 @@
$sheetLabel = filled($label) ? $label : __('Menu'); $sheetLabel = filled($label) ? $label : __('Menu');
@endphp @endphp
<div x-data="materialMenu" @if ($sheet) data-sheet-at-compact @endif {{ $attributes->class('relative inline-flex') }}> <div x-data="materialMenu" data-md-menu @if ($sheet) data-md-sheet-at-compact @endif {{ $attributes }}>
<span x-ref="trigger" class="inline-flex" style="anchor-name: {{ $anchor }}" <span x-ref="trigger" data-md-menu-trigger style="anchor-name: {{ $anchor }}"
x-on:click="toggle('first')" x-on:click="toggle('first')"
x-on:keydown.down.prevent="open('first')" x-on:keydown.down.prevent="open('first')"
x-on:keydown.up.prevent="open('last')" x-on:keydown.up.prevent="open('last')"
@@ -115,29 +119,18 @@
id="material-menu-{{ $key }}" id="material-menu-{{ $key }}"
popover="auto" popover="auto"
@unless ($filtering) role="menu" @endunless @unless ($filtering) role="menu" @endunless
data-menu data-md-menu-popover
@if ($vibrant) data-vibrant @endif data-md-position="{{ $position }}"
@if ($vibrant) data-md-vibrant @endif
@if ($label && ! $filtering) aria-label="{{ $label }}" @endif @if ($label && ! $filtering) aria-label="{{ $label }}" @endif
tabindex="-1" tabindex="-1"
style="position-anchor: {{ $anchor }}" style="position-anchor: {{ $anchor }}"
x-on:keydown="navigate($event)" x-on:keydown="navigate($event)"
x-on:click="activate($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) @if ($filtering)
<div data-menu-filter> <div data-md-menu-filter>
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" /> <x-livewire-material::icon name="search" size="20" />
<input <input
type="text" type="text"
@@ -157,10 +150,10 @@
'id' => "material-menu-{$key}-list", 'id' => "material-menu-{$key}-list",
'role' => 'menu', 'role' => 'menu',
'aria-label' => $label, 'aria-label' => $label,
], fn ($value): bool => filled($value))) }} class="p-1"> ], fn ($value): bool => filled($value))) }} data-md-menu-list>
{{ $slot }} {{ $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 @else
{{ $slot }} {{ $slot }}
@@ -184,7 +177,7 @@
<template x-teleport="body"> <template x-teleport="body">
<div <div
x-ref="sheetHost" x-ref="sheetHost"
data-menu-sheet-host data-md-menu-sheet-host
x-on:keydown.capture="sheetKeydown($event)" x-on:keydown.capture="sheetKeydown($event)"
x-on:click="activate($event)" x-on:click="activate($event)"
x-on:input="refine()" x-on:input="refine()"
@@ -195,9 +188,9 @@
<div {{ new \Illuminate\View\ComponentAttributeBag([ <div {{ new \Illuminate\View\ComponentAttributeBag([
'wire:key' => 'material-menu-sheet-menu', 'wire:key' => 'material-menu-sheet-menu',
'id' => "material-menu-{$key}-sheet-menu", 'id' => "material-menu-{$key}-sheet-menu",
]) }} data-menu-sheet class="-mx-6"> ]) }} data-md-menu-sheet>
<div data-menu-filter> <div data-md-menu-filter>
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" /> <x-livewire-material::icon name="search" size="20" />
<input <input
type="text" type="text"
@@ -216,10 +209,10 @@
'id' => "material-menu-{$key}-sheet-menu-list", 'id' => "material-menu-{$key}-sheet-menu-list",
'role' => 'menu', 'role' => 'menu',
'aria-label' => $label, 'aria-label' => $label,
], fn ($value): bool => filled($value))) }} class="px-2 pt-1"> ], fn ($value): bool => filled($value))) }} data-md-menu-list>
{{ $slot }} {{ $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>
</div> </div>
@else @else
@@ -228,7 +221,7 @@
'id' => "material-menu-{$key}-sheet-menu", 'id' => "material-menu-{$key}-sheet-menu",
'role' => 'menu', 'role' => 'menu',
'aria-label' => $label, '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 }} {{ $slot }}
</div> </div>
@endif @endif
@@ -22,6 +22,7 @@ dataset('action components', [
'menu-separator', 'menu-separator',
'menu-group', 'menu-group',
'menu-item', 'menu-item',
'menu',
]); ]);
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { 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"/') ->toMatch('/<button[^>]*aria-label="Star" aria-pressed="true"/')
// One overflow button per width, named and tooltipped. // 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('/<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. // Below medium: all but the first.
->and($compact) ->and($compact)
->toMatch('/<a data-md-menu-item[^>]*role="menuitem" tabindex="-1" href="\/shares\/1\/share"/') ->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() expect($anchor)->not->toBeEmpty()
->and($html) ->and($html)
->toContain('x-data="materialMenu"') ->toContain('x-data="materialMenu"')
->toContain('data-md-menu')
->toContain('<button>More</button>') ->toContain('<button>More</button>')
->toContain('popover="auto"') ->toContain('popover="auto"')
->toContain('role="menu"') ->toContain('role="menu"')
->toContain('data-md-menu-popover')
->toContain('aria-label="Share actions"') ->toContain('aria-label="Share actions"')
->toContain("id=\"material-menu-{$anchor[2]}\"") ->toContain("id=\"material-menu-{$anchor[2]}\"")
->toContain("position-anchor: {$anchor[1]}") ->toContain("position-anchor: {$anchor[1]}")
->toContain('bg-surface-container-low') ->and(ComponentStylesheet::read('menu')->declarations("[data-md-menu-popover][data-md-position='bottom-start']"))->toBe([
->toContain('[position-area:bottom_span-right]'); 'transform-origin' => 'top',
'position-area' => 'bottom span-right',
]);
}); });
it('scrolls a menu too long for the window instead of running off it', function () { 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>')) 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') ->toContain('data-md-menu-popover')
->not->toContain('overflow-visible'); ->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 () { 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>'); $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 () { 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() expect($id)->not->toBeEmpty()
->and($html) ->and($html)
->toContain('data-menu-filter') ->toContain('data-md-menu-filter')
->toContain('role="combobox"') ->toContain('role="combobox"')
->toContain('aria-autocomplete="list"') ->toContain('aria-autocomplete="list"')
->toContain("aria-controls=\"{$id[1]}-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('placeholder="Find a person"')
->toContain('x-on:input="refine()"') ->toContain('x-on:input="refine()"')
->toContain('x-on:keydown.stop="search($event)"') ->toContain('x-on:keydown.stop="search($event)"')
->toContain('<p data-menu-empty hidden') ->toContain('<p data-md-menu-empty hidden')
->toContain('Nothing matches') ->toContain('Nothing matches')
// A text field is not something a `role="menu"` may hold, so the list moves inside it. // 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\"") ->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>')) expect((string) $this->blade('<x-menu filter><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('aria-label="Filter"') ->toContain('aria-label="Filter"')
->and((string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>')) ->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"') ->not->toContain('role="combobox"')
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"/'); ->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"/');
}); });
it('tells a vibrant menu apart, so the submenus inside it take the same container', function () { 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>')) expect((string) $this->blade('<x-menu vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-menu') ->toContain('data-md-menu')
->toContain('data-vibrant') ->toContain('data-md-vibrant')
->and((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>')) ->and((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
->toContain('data-menu') ->toContain('data-md-menu')
->not->toContain('data-vibrant'); ->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 () { 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. // The popover is the menu it always was, and the root tells menu.js to look for the sheet.
expect($popover) 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"/') ->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"[^>]*aria-label="Photo actions"/')
->toContain('wire:click="wallpaper"') ->toContain('wire:click="wallpaper"')
// The sheet goes to the end of <body>, out of any bar's stacking context, and its host // 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('aria-label="Photo actions"')
->toContain('rounded-t-corner-xl bg-surface-container-low') ->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. // 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, 'role="menuitem"'))->toBe(4)
->and(substr_count($html, 'wire:click="wallpaper"'))->toBe(2); ->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>'); $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) expect($html)
->not->toContain('data-sheet-at-compact') ->not->toContain('data-md-sheet-at-compact')
->not->toContain('x-teleport') ->not->toContain('x-teleport')
->not->toContain('materialBottomSheet') ->not->toContain('materialBottomSheet')
->not->toContain('data-menu-sheet') ->not->toContain('data-md-menu-sheet')
->and(substr_count($html, 'role="menuitem"'))->toBe(1); ->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) expect($sheet)
->toContain('aria-label="Menu"') ->toContain('aria-label="Menu"')
->toContain('x-on:input="refine()"') ->toContain('x-on:input="refine()"')
->toContain("<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" data-menu-sheet") ->toContain("<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" data-md-menu-sheet")
->toContain('data-menu-filter') ->toContain('data-md-menu-filter')
->toContain('role="combobox"') ->toContain('role="combobox"')
->toContain("aria-controls=\"material-menu-{$key[1]}-sheet-menu-list\"") ->toContain("aria-controls=\"material-menu-{$key[1]}-sheet-menu-list\"")
->toContain('placeholder="Find a person"') ->toContain('placeholder="Find a person"')
->toContain("<div wire:key=\"material-menu-sheet-list\" id=\"material-menu-{$key[1]}-sheet-menu-list\" role=\"menu\"") ->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. // 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') ->not->toContain('bg-tertiary-container')
->and(substr_count($html, 'role="combobox"'))->toBe(2); ->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>')) ->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'); ->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-icon' => '50px',
'--md-split-button-nudge' => '6px', '--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 () { it('is filled unless another container variant is asked for', function () {