Put the menu on M3's Expressive spacing and behaviour
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
429be9b64f
commit
9828c9258a
@@ -259,7 +259,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
||||
</x-menu>
|
||||
```
|
||||
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` 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`. `<x-menu-item>`: `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.
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` 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`. `<x-menu-item>`: `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.
|
||||
|
||||
### `<x-button-group>`
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@props(['label'])
|
||||
|
||||
<div role="group" aria-label="{{ $label }}" {{ $attributes->class('py-1 first:pt-0 last:pb-0') }}>
|
||||
<div aria-hidden="true" class="px-3 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
|
||||
<div aria-hidden="true" class="px-4 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@@ -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 `<x-badge>` 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 `<x-badge>` 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)
|
||||
<x-livewire-material::icon :name="$iconRight" optical="20" :class="'size-5 '.$iconInk" />
|
||||
@elseif ($selected === true)
|
||||
{{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}}
|
||||
<x-livewire-material::icon name="check" optical="20" :class="'size-5 '.$iconInk" />
|
||||
@endif
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{{-- A line between groups of items in an `<x-menu>`. --}}
|
||||
{{-- A line between groups of items in an `<x-menu>`: 1px, with M3's 8px above and below it, inset
|
||||
to the 16px the items keep either side. --}}
|
||||
|
||||
<hr role="separator" {{ $attributes->class('mx-3 my-1 h-px border-0 bg-outline-variant') }} />
|
||||
<hr role="separator" {{ $attributes->class('mx-4 my-2 h-px border-0 bg-outline-variant') }} />
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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('<x-menu-item label="Newest" :selected="true" keep-open />'))
|
||||
->toContain('role="menuitemcheckbox"')
|
||||
->toContain('aria-checked="true"')
|
||||
->toContain('bg-tertiary-container')
|
||||
->toContain('data-keep-open')
|
||||
->toContain($check)
|
||||
->and((string) $this->blade('<x-menu-item label="Largest" :selected="false" />'))
|
||||
->toContain('aria-checked="false"');
|
||||
->toContain('aria-checked="false"')
|
||||
->not->toContain($check)
|
||||
->and((string) $this->blade('<x-menu-item label="Newest" :selected="true" icon-right="chevron_right" />'))
|
||||
->not->toContain($check);
|
||||
});
|
||||
|
||||
it('gives an item M3\'s 48px row and 16px sides, and the separator its 8px', function () {
|
||||
expect((string) $this->blade('<x-menu-item label="Copy" />'))->toContain('min-h-12')->toContain('px-4')
|
||||
->and((string) $this->blade('<x-menu-separator />'))->toContain('mx-4 my-2')
|
||||
->and((string) $this->blade('<x-menu-group label="Sort by" />'))->toContain('px-4 pt-2 pb-1');
|
||||
});
|
||||
|
||||
it('marks the page an item leads to, and carries a badge', function () {
|
||||
|
||||
Reference in New Issue
Block a user