From 9828c9258a24b773e21f56be96df01453042d9ee Mon Sep 17 00:00:00 2001 From: Andreas Reinhold Date: Mon, 14 Sep 2026 06:04:03 +0200 Subject: [PATCH] Put the menu on M3's Expressive spacing and behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items were 44px tall with the baseline menu's 12px sides, the separator had half its 8dp padding, a disabled item was skipped by the keyboard entirely, a chosen item was told by colour and shape alone, and the list simply faded in place. Now: 48px rows with 16px either side, an 8px separator, disabled items focusable but never activatable, a trailing check on a selected item, and an enter-exit that scales the list out of the corner nearest its trigger — the scale on the spatial spring, the fade on the effects one. Plan step 18, actions.md ACT-11, ACT-12, ACT-13, ACT-26, ACT-27, ACT-28. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 +- resources/css/components/actions.css | 34 +++++++++++++++++-- resources/js/menu.js | 7 +++- .../views/components/menu-group.blade.php | 2 +- .../views/components/menu-item.blade.php | 22 ++++++++---- .../views/components/menu-separator.blade.php | 5 +-- resources/views/components/menu.blade.php | 16 +++++---- tests/Feature/Components/MenuTest.php | 17 ++++++++-- 8 files changed, 83 insertions(+), 22 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index dd6149df..e1d51567 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -259,7 +259,7 @@ M3's plain tooltip, standalone around any trigger: ` ``` -``: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. ``: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab. +``: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. ``: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls. ### `` diff --git a/resources/css/components/actions.css b/resources/css/components/actions.css index d1868614..3eb2b95a 100644 --- a/resources/css/components/actions.css +++ b/resources/css/components/actions.css @@ -15,8 +15,14 @@ * `state-transition-default` is the same pair one step slower, for the bigger morph of a FAB * turning into a close button. * - * Unlayered, as the rest of the package's component CSS, so a `transition-*` utility on the call - * site cannot half-undo one. + * `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 + * whichever Tailwind emits last wins. Pass neither. */ @utility state-transition-fast { @@ -38,3 +44,27 @@ var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default), var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default); } + +@utility popover-transition { + opacity: 0; + scale: 0.95; + transition-property: opacity, scale, display, overlay; + transition-duration: + var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration), + var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration); + transition-timing-function: + var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear; + transition-behavior: allow-discrete; + + &:popover-open { + opacity: 1; + scale: 1; + } + + @starting-style { + &:popover-open { + opacity: 0; + scale: 0.95; + } + } +} diff --git a/resources/js/menu.js b/resources/js/menu.js index 4e8df277..9462d03b 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -179,8 +179,13 @@ document.addEventListener('alpine:init', () => { } }, + /** + * Every item, disabled ones included: M3 keeps a disabled item focusable ("disabled items + * can still receive focus, just aren't selectable") so a person reading the menu with the + * keyboard learns that it exists. activate() is where the refusal lives. + */ items() { - return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.getAttribute('aria-disabled') !== 'true') + return [...this.$refs.menu.querySelectorAll(ITEMS)] }, /** The menu scrolls when it is too long for the window, so the item taken has to be shown. */ diff --git a/resources/views/components/menu-group.blade.php b/resources/views/components/menu-group.blade.php index a5311157..61c73c91 100644 --- a/resources/views/components/menu-group.blade.php +++ b/resources/views/components/menu-group.blade.php @@ -3,7 +3,7 @@ @props(['label'])
class('py-1 first:pt-0 last:pb-0') }}> - + {{ $slot }}
diff --git a/resources/views/components/menu-item.blade.php b/resources/views/components/menu-item.blade.php index 68beca99..ec66e8fa 100644 --- a/resources/views/components/menu-item.blade.php +++ b/resources/views/components/menu-item.blade.php @@ -4,11 +4,13 @@ under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link` makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected` (true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes - Expressive's selected shape and tertiary-container. `current` is for a menu of places rather + Expressive's selected shape and tertiary-container, and a `check` at its end unless it has an + `icon-right` of its own — M3 asks for a cue beyond the colour and the shape. `current` is for a menu of places rather than choices — a section picker — and marks the page you are on: `aria-current="page"`, the selected shape in secondary-container, the colour M3 gives the navigation indicator. `badge` - draws `` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, out of - reach. `keep-open` leaves the menu open when it is activated — for a choice the person may + draws `` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, reachable by the + keyboard but not selectable — M3 keeps a disabled item focusable so a person can find out it + is there. `keep-open` leaves the menu open when it is activated — for a choice the person may want to change twice. `icon-class` is for an icon whose colour means something of its own, a sport's glyph in the @@ -17,9 +19,12 @@ utilities wins depends on the order Tailwind emits them. A disabled item's icon stays disabled. - 44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to - 12px at the ends of the list, the corner on the spatial spring and the colour on the effects - one (`state-transition-fast`). --}} + 48px tall, body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive + spacing), 4px corners that open to 12px at the ends of the list — the corner on the spatial + spring and the colour on the effects one (`state-transition-fast`). The row is 48px rather + than SegmentedMenuTokens' 44px: M3's menu page publishes 48dp and asks for ≥48×48 targets + inside an item's slots, and the library's own select and choices menus are 48px, so the more + binding source wins over the more specific one. --}} @props([ 'label' => null, @@ -44,7 +49,7 @@ $attributes = $attributes ->class([ - 'group/item state-layer flex w-full min-h-11 cursor-pointer items-center gap-3 px-3 text-start outline-none', + 'group/item state-layer flex w-full min-h-12 cursor-pointer items-center gap-3 px-4 text-start outline-none', 'rounded-corner-xs first:rounded-t-corner-md last:rounded-b-corner-md', 'state-transition-fast', 'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary', @@ -105,5 +110,8 @@ @if ($iconRight) + @elseif ($selected === true) + {{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}} + @endif diff --git a/resources/views/components/menu-separator.blade.php b/resources/views/components/menu-separator.blade.php index 82c3aee5..a0b6332c 100644 --- a/resources/views/components/menu-separator.blade.php +++ b/resources/views/components/menu-separator.blade.php @@ -1,3 +1,4 @@ -{{-- A line between groups of items in an ``. --}} +{{-- A line between groups of items in an ``: 1px, with M3's 8px above and below it, inset + to the 16px the items keep either side. --}} -
class('mx-3 my-1 h-px border-0 bg-outline-variant') }} /> +
class('mx-4 my-2 h-px border-0 bg-outline-variant') }} /> diff --git a/resources/views/components/menu.blade.php b/resources/views/components/menu.blade.php index a228ce73..50fc9dae 100644 --- a/resources/views/components/menu.blade.php +++ b/resources/views/components/menu.blade.php @@ -34,7 +34,11 @@ 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. + 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. The container is Expressive's standard menu (surface-container-low, 16px corner, elevation 2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from @@ -73,13 +77,13 @@ @class([ 'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]', 'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]', - 'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0', + 'popover-transition', 'bg-surface-container-low text-on-surface' => ! $vibrant, 'bg-tertiary-container text-on-tertiary-container' => $vibrant, - '[position-area:bottom_span-right]' => $position === 'bottom-start', - '[position-area:bottom_span-left]' => $position === 'bottom-end', - '[position-area:top_span-right]' => $position === 'top-start', - '[position-area:top_span-left]' => $position === 'top-end', + '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', ]) > {{ $slot }} diff --git a/tests/Feature/Components/MenuTest.php b/tests/Feature/Components/MenuTest.php index a75ec3ea..44ec425b 100644 --- a/tests/Feature/Components/MenuTest.php +++ b/tests/Feature/Components/MenuTest.php @@ -49,14 +49,27 @@ it('draws an item as a menuitem button', function () { ->toContain('size-5 text-on-surface-variant'); }); -it('makes a selectable item a menuitemcheckbox', function () { +it('makes a selectable item a menuitemcheckbox, ticked at its end', function () { + $check = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/outlined-20/check.svg')); + $check = substr($check, (int) strpos($check, '><') + 1); + expect((string) $this->blade('')) ->toContain('role="menuitemcheckbox"') ->toContain('aria-checked="true"') ->toContain('bg-tertiary-container') ->toContain('data-keep-open') + ->toContain($check) ->and((string) $this->blade('')) - ->toContain('aria-checked="false"'); + ->toContain('aria-checked="false"') + ->not->toContain($check) + ->and((string) $this->blade('')) + ->not->toContain($check); +}); + +it('gives an item M3\'s 48px row and 16px sides, and the separator its 8px', function () { + expect((string) $this->blade(''))->toContain('min-h-12')->toContain('px-4') + ->and((string) $this->blade(''))->toContain('mx-4 my-2') + ->and((string) $this->blade(''))->toContain('px-4 pt-2 pb-1'); }); it('marks the page an item leads to, and carries a badge', function () {