diff --git a/resources/css/components.css b/resources/css/components.css index 5d00ddcc..9d34eb4a 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -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'; diff --git a/resources/css/components/actions.css b/resources/css/components/actions.css index c85ae555..f8bc535b 100644 --- a/resources/css/components/actions.css +++ b/resources/css/components/actions.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): ``'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 diff --git a/resources/css/components/choices.css b/resources/css/components/choices.css index 18391cec..515821cf 100644 --- a/resources/css/components/choices.css +++ b/resources/css/components/choices.css @@ -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] { diff --git a/resources/css/components/menu.css b/resources/css/components/menu.css index 2e68c79e..bbd05694 100644 --- a/resources/css/components/menu.css +++ b/resources/css/components/menu.css @@ -1,318 +1,428 @@ /* - * The menus: the exposed dropdown a field drops open, and what `` cannot say in utilities. + * The menus: `` 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: * + * - ``'s own popover (`[data-md-menu-popover]`) and, inside it, a submenu + * (`[data-md-submenu]`, menu-item.css). * - ``'s: the native 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); -} - -/* - * ``: 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 (``), 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 `` 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; + } + + /* + * ``: 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 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); + } + + /* + * ``: 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 (``), 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 `` 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; + } } } diff --git a/resources/css/components/select.css b/resources/css/components/select.css index c383f197..965f7326 100644 --- a/resources/css/components/select.css +++ b/resources/css/components/select.css @@ -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 diff --git a/resources/css/components/split-button.css b/resources/css/components/split-button.css index 4ff426bf..86e5920e 100644 --- a/resources/css/components/split-button.css +++ b/resources/css/components/split-button.css @@ -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] { diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css index b4c358e8..eef6db2f 100644 --- a/resources/css/tailwind.css +++ b/resources/css/tailwind.css @@ -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'; diff --git a/resources/js/menu.js b/resources/js/menu.js index 6bffec91..1b35a7f1 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -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) }, diff --git a/resources/views/components/menu.blade.php b/resources/views/components/menu.blade.php index f7b3a4ec..b0a7f573 100644 --- a/resources/views/components/menu.blade.php +++ b/resources/views/components/menu.blade.php @@ -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 — ``'s exposed picker and ``'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 -
class('relative inline-flex') }}> - + ! $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) -
- +
+ "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 }} - +
@else {{ $slot }} @@ -184,7 +177,7 @@