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