Draw the menu item's state layer and focus ring from the classes

Plan step 36 ("Interaction is the shared classes", the user, 2026-09-14).
menu-item.css hand-rolled md-state-layer and md-focus-ring, including a
separate aria-disabled rule the class already carries; the row now renders
the classes and keeps only the inward -3px ring offset a row edge to edge
in the list needs. No md-touch-target: the row is already 48px tall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 18:07:12 +02:00
co-authored by Claude Opus 5
parent 7803298fe7
commit 6c30f1e3ae
3 changed files with 11 additions and 38 deletions
+6 -36
View File
@@ -16,7 +16,11 @@
* (`ItemTrailingSupportingTextFont`) sits at the end. Every quiet part of the row — the icon, the * (`ItemTrailingSupportingTextFont`) sits at the end. Every quiet part of the row — the icon, the
* description, the trailing text — inks together as `--md-menu-item-ink`; corner and colour are * description, the trailing text — inks together as `--md-menu-item-ink`; corner and colour are
* two springs, not one (§ ACT-19), and `icon-class` paints only the leading icon, over that ink * two springs, not one (§ ACT-19), and `icon-class` paints only the leading icon, over that ink
* but never over disabled. * but never over disabled. The row renders the foundation's `md-state-layer` and `md-focus-ring`
* (foundation/interaction.css) — no `md-touch-target`, since the row is already 48px tall — and
* keeps only one refinement: the ring reads inward (-3px), because a row sits edge to edge in the
* list and an outward one would run past it. A disabled item stays focusable and keeps its ring,
* because M3 keeps it reachable, but the class already withholds the layer from `aria-disabled`.
* *
* `submenu` turns the row into the WAI-ARIA menu button for a second `role="menu"` popover beside * `submenu` turns the row into the WAI-ARIA menu button for a second `role="menu"` popover beside
* it, anchored to its end and flipping to the start where the window has no room * it, anchored to its end and flipping to the start where the window has no room
@@ -37,8 +41,6 @@
[data-md-menu-item] { [data-md-menu-item] {
--md-menu-item-ink: var(--md-sys-color-on-surface-variant); --md-menu-item-ink: var(--md-sys-color-on-surface-variant);
position: relative;
isolation: isolate;
display: flex; display: flex;
inline-size: 100%; inline-size: 100%;
min-block-size: var(--md-sys-measurement-space600); min-block-size: var(--md-sys-measurement-space600);
@@ -50,7 +52,6 @@
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
text-align: start; text-align: start;
cursor: pointer; cursor: pointer;
outline: none;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
transition-property: border-radius, background-color, color; transition-property: border-radius, background-color, color;
@@ -61,41 +62,10 @@
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), var(--md-sys-motion-effects-fast);
&::before { /* The row sits edge to edge in the list, so the ring reads inward, not past the item. */
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: currentColor;
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:focus-visible { &:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px; outline-offset: -3px;
} }
&:focus-visible::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
/* A disabled item stays focusable and keeps its focus ring, but has no state layer. */
&[aria-disabled='true']::before {
display: none;
}
} }
[data-md-menu-item]:first-child { [data-md-menu-item]:first-child {
@@ -90,7 +90,7 @@
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null, 'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
// Opening a submenu is not choosing anything: the outer menu stays where it was. // Opening a submenu is not choosing anything: the outer menu stays where it was.
'data-md-keep-open' => $keepOpen || $submenu ? true : null, 'data-md-keep-open' => $keepOpen || $submenu ? true : null,
], fn ($value): bool => $value !== null)); ], fn ($value): bool => $value !== null))->class(['md-state-layer', 'md-focus-ring']);
@endphp @endphp
@if ($submenu) @if ($submenu)
+4 -1
View File
@@ -130,8 +130,11 @@ it('links an item, and keeps a disabled one out of reach', function () {
->assertSee('aria-disabled="true"', false) ->assertSee('aria-disabled="true"', false)
->assertSee('data-md-menu-item', false); ->assertSee('data-md-menu-item', false);
// A disabled item stays focusable and keeps its ring, but the foundation's md-state-layer class
// (which the item renders, not a copy of its own) already withholds the layer on aria-disabled.
expect(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-disabled='true']"))->toMatchArray(['pointer-events' => 'none']) expect(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-disabled='true']"))->toMatchArray(['pointer-events' => 'none'])
->and(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-disabled='true']::before"))->toBe(['display' => 'none']); ->and(ComponentStylesheet::read('menu-item')->has('[data-md-menu-item]::before'))->toBeFalse()
->and((string) $this->blade('<x-menu-item label="Reset" disabled />'))->toContain('class="md-state-layer md-focus-ring"');
}); });
it('separates and labels groups', function () { it('separates and labels groups', function () {