From c040925cd461ae8d2d2df55a1899e76ea4839b4b Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 16:11:56 +0200 Subject: [PATCH] Draw the menu item without Tailwind renders data-md-menu-item, data-md-description and its aria-* state, with data-md-keep-open renamed from data-keep-open; menu-item.css draws SegmentedMenuTokens' 48px row, 16px sides, 12px gaps, 4/12px corners, the selected/current colours and the shared --md-menu-item-ink icon-class outranks except when disabled (ACT-11/19/27/28); a submenu's popover shares its container colour with the menu it opens from and menu.js follows the rename (plan step 36, actions). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/menu-item.css | 213 ++++++++++++++++++ resources/js/menu.js | 4 +- .../views/components/menu-item.blade.php | 123 ++++------ .../Components/ActionStylesheetsTest.php | 1 + tests/Feature/Components/AppBarTest.php | 2 +- tests/Feature/Components/MenuTest.php | 64 ++++-- 7 files changed, 310 insertions(+), 98 deletions(-) create mode 100644 resources/css/components/menu-item.css diff --git a/resources/css/components.css b/resources/css/components.css index 62c6cbb3..5d00ddcc 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -22,6 +22,7 @@ @import './components/fab.css'; @import './components/menu-separator.css'; @import './components/menu-group.css'; +@import './components/menu-item.css'; /* Inputs, selection and data */ @import './components/form.css'; diff --git a/resources/css/components/menu-item.css b/resources/css/components/menu-item.css new file mode 100644 index 00000000..f4c6daa5 --- /dev/null +++ b/resources/css/components/menu-item.css @@ -0,0 +1,213 @@ +/* + * : one row in an `` — an action, a link, or a choice — and, with `submenu`, + * the trigger and popover of a nested menu beside it. + * + * SegmentedMenuTokens (androidx Compose Material 3, Apache-2.0): 48px row (M3's published "List + * item height" wins over the token's own 44dp `Item`, docs/audits/m3-alignment/actions.md § + * ACT-28), 16px either side (`ItemLeadingSpace`/`ItemTrailingSpace`, § ACT-11), 12px between the + * icon, the label and the trailing content (`ItemBetweenSpace`, off the 8dp grid), 4px corners + * (`ItemShape`) that open to 12px at either end of the list (`ItemFirstChildShape`/ + * `ItemLastChildShape` — the library's own list ends, 12px, win over `GroupShape`'s 8dp so a + * cluster's ends and a list's ends match, menu-group.css). A selected row + * (`role="menuitemcheckbox"`, the tick beside the colour and shape § ACT-27) takes + * `ItemSelectedShape`'s 12px corner in tertiary-container; `current` — a menu of places rather + * than choices — takes the same 12px corner in secondary-container, the navigation-indicator + * role. `description` (`ItemSupportingTextFont`) grows the row by 8px top and bottom; `shortcut` + * (`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 + * two springs, not one (§ ACT-19), and `icon-class` paints only the leading icon, over that ink + * but never over disabled. + * + * `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 + * (resources/js/menu.js). The popover shares its container colour with the menu it opens from + * through `--material-menu-surface`/`--material-menu-ink` (menu.css defines them on + * `[data-md-menu-popover]`; the fallbacks here are the standard menu's, for a submenu inside some + * other list, e.g. a FAB menu's). Inside an `` sheet the same markup + * opens in place instead — menu.css restyles `[data-md-submenu]` and `[data-md-submenu-chevron]` + * there. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; +@import './badge.css'; + +@layer material.components { + [data-md-menu-item] { + --md-menu-item-ink: var(--md-sys-color-on-surface-variant); + + position: relative; + isolation: isolate; + display: flex; + inline-size: 100%; + min-block-size: var(--md-sys-measurement-space600); + align-items: center; + gap: 12px; + padding-inline: var(--md-sys-measurement-space200); + border-radius: var(--md-sys-shape-corner-xs); + background-color: transparent; + color: var(--md-sys-color-on-surface); + text-align: start; + cursor: pointer; + outline: none; + -webkit-user-select: none; + user-select: none; + transition-property: border-radius, background-color, color; + transition-duration: + var(--md-sys-motion-spatial-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-effects-fast), var(--md-sys-motion-effects-fast); + + &::before { + 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 { + outline: 3px solid var(--md-sys-color-secondary); + 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); + } + } + + [data-md-menu-item]:first-child { + border-start-start-radius: var(--md-sys-shape-corner-md); + border-start-end-radius: var(--md-sys-shape-corner-md); + } + + [data-md-menu-item]:last-child { + border-end-start-radius: var(--md-sys-shape-corner-md); + border-end-end-radius: var(--md-sys-shape-corner-md); + } + + [data-md-menu-item][data-md-description] { + padding-block: var(--md-sys-measurement-space100); + } + + [data-md-menu-item][aria-checked='true'] { + --md-menu-item-ink: var(--md-sys-color-on-tertiary-container); + + border-radius: var(--md-sys-shape-corner-md); + background-color: var(--md-sys-color-tertiary-container); + color: var(--md-sys-color-on-tertiary-container); + } + + [data-md-menu-item][aria-current='page']:not([aria-checked='true']) { + --md-menu-item-ink: var(--md-sys-color-on-secondary-container); + + border-radius: var(--md-sys-shape-corner-md); + background-color: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + } + + [data-md-menu-item][aria-disabled='true'] { + --md-menu-item-ink: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); + + pointer-events: none; + color: var(--md-menu-item-ink); + } + + [data-md-menu-item] [data-md-icon] { + color: var(--md-menu-item-ink); + } + + [data-md-menu-item][aria-disabled='true'] [data-md-icon] { + color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent) !important; + } + + [data-md-menu-item-text] { + min-inline-size: 0; + flex: 1; + } + + [data-md-menu-item-label] { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font: var(--md-sys-typescale-body-lg); + letter-spacing: var(--md-sys-typescale-body-lg-tracking); + font-variation-settings: normal; + } + + [data-md-menu-item-description] { + display: block; + color: var(--md-menu-item-ink); + font: var(--md-sys-typescale-body-md); + letter-spacing: var(--md-sys-typescale-body-md-tracking); + font-variation-settings: normal; + } + + [data-md-menu-item-shortcut] { + flex-shrink: 0; + color: var(--md-menu-item-ink); + font: var(--md-sys-typescale-label-sm); + letter-spacing: var(--md-sys-typescale-label-sm-tracking); + font-variation-settings: normal; + } + + /* The submenu popover: a menu of its own, sharing the container colour of the list it opens + from. The fallbacks are the standard menu's, for a submenu inside some other list. */ + [data-md-submenu] { + inset: auto; + margin: 0; + margin-inline: 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, var(--md-sys-color-surface-container-low)); + color: var(--material-menu-ink, var(--md-sys-color-on-surface)); + box-shadow: var(--md-sys-elevation-2); + transform-origin: top; + position-area: inline-end span-block-end; + position-try-fallbacks: flip-inline; + 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 d762ed64..6bffec91 100644 --- a/resources/js/menu.js +++ b/resources/js/menu.js @@ -372,7 +372,7 @@ const menu = () => ({ // A submenu opened in place is closed with the sheet, as a nested popover closes with // its menu. - for (const submenu of list.querySelectorAll('[data-submenu]')) { + for (const submenu of list.querySelectorAll('[data-md-submenu]')) { window.Alpine.$data(submenu)?.close?.() } } @@ -604,7 +604,7 @@ const menu = () => ({ activate(event) { const item = event.target.closest(ITEMS) - if (!item || item.getAttribute('aria-disabled') === 'true' || item.hasAttribute('data-keep-open')) { + if (!item || item.getAttribute('aria-disabled') === 'true' || item.hasAttribute('data-md-keep-open')) { return } diff --git a/resources/views/components/menu-item.blade.php b/resources/views/components/menu-item.blade.php index 1104835e..00f2098d 100644 --- a/resources/views/components/menu-item.blade.php +++ b/resources/views/components/menu-item.blade.php @@ -5,13 +5,13 @@ 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, 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, 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-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, 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. `submenu` turns the item into a menu of its own: the slot holds ``s instead of a label, and they open in a second popover beside this one, on the item's end, flipping to its @@ -30,17 +30,17 @@ come back to the item, and Escape closes nothing more. `icon-class` is for an icon whose colour means something of its own, a sport's glyph in the - sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected - item's too: the icon's own colour then carries no specificity, because which of two colour - utilities wins depends on the order Tailwind emits them. A disabled item's icon stays - disabled. + sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon over the ink + this file gives it, because a caller's unlayered class always outranks the package's layer — + except a disabled item's icon, which stays disabled regardless. - 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. --}} + 48px tall (M3's published row height wins over SegmentedMenuTokens' own 44dp, ACT-28), + body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive spacing, + ACT-11), 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, apart (ACT-19). + + Drawn by resources/css/components/menu-item.css from `data-md-menu-item`, + `data-md-description` and the item's aria-* state. --}} @props([ 'label' => null, @@ -69,52 +69,28 @@ $key = $submenu ? \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null; $anchor = $submenu ? "--material-submenu-{$key}" : null; - $attributes = $attributes - ->class([ - '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', - 'py-2' => filled($description), - 'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true, - 'rounded-corner-md bg-secondary-container text-on-secondary-container' => $current && $selected !== true, - 'pointer-events-none text-on-surface/38' => $disabled, - ]) - ->merge(array_filter([ - 'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox', - 'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'), - 'aria-current' => $current ? 'page' : null, - 'aria-disabled' => $disabled ? 'true' : null, - 'tabindex' => '-1', - 'x-ref' => $submenu ? 'trigger' : null, - 'style' => $submenu ? "anchor-name: {$anchor}" : null, - 'aria-haspopup' => $submenu ? 'menu' : null, - 'aria-expanded' => $submenu ? 'false' : null, - 'aria-controls' => $submenu ? "material-submenu-{$key}" : null, - 'x-on:click' => $submenu ? "toggle('first')" : null, - 'type' => $isLink ? null : 'button', - 'href' => $isLink ? $link : null, - 'target' => $isLink && $external ? '_blank' : null, - 'rel' => $isLink && $external ? 'noopener' : 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. - 'data-keep-open' => $keepOpen || $submenu ? true : null, - ], fn ($value): bool => $value !== null)); - - $iconInk = match (true) { - $disabled => 'text-on-surface/38', - $selected === true => 'text-on-tertiary-container', - $current => 'text-on-secondary-container', - default => 'text-on-surface-variant', - }; - - $leadingIcon = match (true) { - blank($iconClass) => 'size-5 '.$iconInk, - $disabled => \Illuminate\Support\Arr::toCssClasses(['size-5', $iconClass, 'text-on-surface/38!']), - $selected === true => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-tertiary-container', $iconClass]), - $current => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-secondary-container', $iconClass]), - default => \Illuminate\Support\Arr::toCssClasses(['size-5 [:where(&)]:text-on-surface-variant', $iconClass]), - }; + $attributes = $attributes->merge(array_filter([ + 'data-md-menu-item' => true, + 'data-md-description' => filled($description) ? true : null, + 'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox', + 'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'), + 'aria-current' => $current ? 'page' : null, + 'aria-disabled' => $disabled ? 'true' : null, + 'tabindex' => '-1', + 'x-ref' => $submenu ? 'trigger' : null, + 'style' => $submenu ? "anchor-name: {$anchor}" : null, + 'aria-haspopup' => $submenu ? 'menu' : null, + 'aria-expanded' => $submenu ? 'false' : null, + 'aria-controls' => $submenu ? "material-submenu-{$key}" : null, + 'x-on:click' => $submenu ? "toggle('first')" : null, + 'type' => $isLink ? null : 'button', + 'href' => $isLink ? $link : null, + 'target' => $isLink && $external ? '_blank' : null, + 'rel' => $isLink && $external ? 'noopener' : 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. + 'data-md-keep-open' => $keepOpen || $submenu ? true : null, + ], fn ($value): bool => $value !== null)); @endphp @if ($submenu) @@ -127,32 +103,32 @@ <{{ $tag }} {{ $attributes }}> @if ($icon) - + @endif - - {{ $submenu ? $label : ($label ?? $slot) }} + + {{ $submenu ? $label : ($label ?? $slot) }} @if ($description) - {{ $description }} + {{ $description }} @endif @if ($badge !== null && $badge !== false && $badge !== '') - + @endif @if ($shortcut) - {{ $shortcut }} + {{ $shortcut }} @endif @if ($iconRight) - + @elseif ($submenu) {{-- M3's submenu marker: it points the way the list opens, and turns over in an RTL page. --}} - + @elseif ($selected === true) {{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}} - + @endif @@ -163,13 +139,12 @@ id="material-submenu-{{ $key }}" popover="auto" role="menu" - data-submenu + data-md-submenu aria-label="{{ $label }}" tabindex="-1" style="position-anchor: {{ $anchor }}" x-on:keydown.stop="navigate($event)" x-on:click="activate($event)" - class="m-0 mx-1 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] origin-top overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 popover-transition [inset:auto] [position-area:inline-end_span-block-end] [position-try-fallbacks:flip-inline]" > {{ $slot }} diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index 34aa35b3..f4db40d3 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -21,6 +21,7 @@ dataset('action components', [ 'fab', 'menu-separator', 'menu-group', + 'menu-item', ]); it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php index 5a92962d..c4f9b850 100644 --- a/tests/Feature/Components/AppBarTest.php +++ b/tests/Feature/Components/AppBarTest.php @@ -76,7 +76,7 @@ it('keeps two trailing icon buttons below medium and four from it, the rest in a ->and(preg_match_all('/role="menu"\s+data-menu\s+aria-label="More options"/', $html))->toBe(2) // Below medium: all but the first. ->and($compact) - ->toMatch('/toMatch('/]*role="menuitem" tabindex="-1" href="\/shares\/1\/share"/') ->toMatch('/role="menuitemcheckbox" aria-checked="true"[^>]*wire:click="star"/') ->toMatch('/role="menuitem"[^>]*wire:click="archive"/') ->toMatch('/role="menuitem" aria-disabled="true"/') diff --git a/tests/Feature/Components/MenuTest.php b/tests/Feature/Components/MenuTest.php index 58b9cce0..4419faea 100644 --- a/tests/Feature/Components/MenuTest.php +++ b/tests/Feature/Components/MenuTest.php @@ -41,6 +41,7 @@ it('draws an item as a menuitem button', function () { $html = (string) $this->blade(''); expect($html) + ->toContain('data-md-menu-item') ->toContain('role="menuitem"') ->toContain('tabindex="-1"') ->toContain('type="button"') @@ -48,7 +49,8 @@ it('draws an item as a menuitem button', function () { ->toContain('Download') ->toContain('⌘D') ->toContain('As a ZIP') - ->toContain('size-5 text-on-surface-variant'); + ->toContain('data-md-menu-item-description') + ->toContain('data-md-menu-item-shortcut'); }); it('makes a selectable item a menuitemcheckbox, ticked at its end', function () { @@ -58,9 +60,12 @@ it('makes a selectable item a menuitemcheckbox, ticked at its end', function () expect((string) $this->blade('')) ->toContain('role="menuitemcheckbox"') ->toContain('aria-checked="true"') - ->toContain('bg-tertiary-container') - ->toContain('data-keep-open') + ->toContain('data-md-keep-open') ->toContain($check) + ->and(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-checked='true']"))->toMatchArray([ + 'background-color' => 'var(--md-sys-color-tertiary-container)', + 'color' => 'var(--md-sys-color-on-tertiary-container)', + ]) ->and((string) $this->blade('')) ->toContain('aria-checked="false"') ->not->toContain($check) @@ -69,7 +74,11 @@ it('makes a selectable item a menuitemcheckbox, ticked at its end', function () }); 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') + expect((string) $this->blade(''))->toContain('data-md-menu-item') + ->and(ComponentStylesheet::read('menu-item')->declarations('[data-md-menu-item]'))->toMatchArray([ + 'min-block-size' => 'var(--md-sys-measurement-space600)', + 'padding-inline' => 'var(--md-sys-measurement-space200)', + ]) ->and(trim((string) $this->blade('')))->toBe('
') ->and(ComponentStylesheet::read('menu-separator')->declarations('[data-md-menu-separator]'))->toBe(['block-size' => '1px', 'margin-block' => 'var(--md-sys-measurement-space100)', 'margin-inline' => 'var(--md-sys-measurement-space200)', 'border-width' => '0', 'background-color' => 'var(--md-sys-color-outline-variant)']) ->and((string) $this->blade(''))->toContain('data-md-menu-group-label') @@ -86,11 +95,15 @@ it('marks the page an item leads to, and carries a badge', function () { ->toContain('role="menuitem"') ->toContain('aria-current="page"') ->not->toContain('aria-checked') - ->toContain('bg-secondary-container text-on-secondary-container') + ->toContain('data-md-badge') ->toContain('>3<') + ->and(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-current='page']:not([aria-checked='true'])"))->toMatchArray([ + 'background-color' => 'var(--md-sys-color-secondary-container)', + 'color' => 'var(--md-sys-color-on-secondary-container)', + ]) ->and((string) $this->blade('')) ->not->toContain('aria-current') - ->not->toContain('secondary-container'); + ->not->toContain('data-md-badge'); }); it('links an item, and keeps a disabled one out of reach', function () { @@ -100,7 +113,9 @@ it('links an item, and keeps a disabled one out of reach', function () { $this->blade('') ->assertSee('aria-disabled="true"', false) - ->assertSee('pointer-events-none', false); + ->assertSee('data-md-menu-item', false); + + expect(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-disabled='true']"))->toMatchArray(['pointer-events' => 'none']); }); it('separates and labels groups', function () { @@ -136,15 +151,19 @@ it('adds icon-class to the leading icon, over its own colour but not over disabl $leading = fn (string $html): string => preg_match('/]*class="([^"]*)"/', $html, $icon) ? $icon[1] : ''; expect($leading((string) $this->blade(''))) - ->toBe('size-5 [:where(&)]:text-on-surface-variant text-sport-run') + ->toBe('text-sport-run') ->and($leading((string) $this->blade(''))) - ->toBe('size-5 [:where(&)]:text-on-tertiary-container text-sport-run') + ->toBe('text-sport-run') ->and($leading((string) $this->blade(''))) - ->toBe('size-5 text-sport-run text-on-surface/38!') + ->toBe('text-sport-run') ->and($leading((string) $this->blade(''))) - ->toBe('size-5 text-on-surface-variant') + ->toBe('') ->and((string) $this->blade('')) - ->not->toContain('text-sport-run'); + ->not->toContain('text-sport-run') + // icon-class is a caller class, unlayered, so it always outranks the item's own layered + // ink — except for a disabled item, whose icon rule is !important. + ->and(ComponentStylesheet::read('menu-item')->declarations('[data-md-menu-item] [data-md-icon]'))->toBe(['color' => 'var(--md-menu-item-ink)']) + ->and(ComponentStylesheet::read('menu-item')->declarations("[data-md-menu-item][aria-disabled='true'] [data-md-icon]"))->toBe(['color' => 'color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent) !important']); }); it('opens a submenu beside the item that holds it', function () { @@ -166,13 +185,15 @@ it('opens a submenu beside the item that holds it', function () { ->toContain("aria-controls=\"material-submenu-{$anchor[2]}\"") ->toContain("id=\"material-submenu-{$anchor[2]}\"") ->toContain("position-anchor: {$anchor[1]}") - ->toContain('data-submenu') + ->toContain('data-md-submenu') ->toContain('aria-label="Send to"') - ->toContain('[position-area:inline-end_span-block-end]') - ->toContain('[position-try-fallbacks:flip-inline]') // Opening a submenu chooses nothing, so the menu around it stays where it was. - ->toContain('data-keep-open') - ->toContain('A person'); + ->toContain('data-md-keep-open') + ->toContain('A person') + ->and(ComponentStylesheet::read('menu-item')->declarations('[data-md-submenu]'))->toMatchArray([ + 'position-area' => 'inline-end span-block-end', + 'position-try-fallbacks' => 'flip-inline', + ]); }); it('marks a submenu item with a chevron instead of a tick', function () { @@ -181,7 +202,8 @@ it('marks a submenu item with a chevron instead of a tick', function () { expect((string) $this->blade('')) ->toContain($chevron) - ->toContain('rtl:-scale-x-100') + ->toContain('data-md-submenu-chevron') + ->toContain('data-md-mirror-rtl') ->and((string) $this->blade('')) ->not->toContain($chevron); }); @@ -322,8 +344,8 @@ it('marks a submenu\'s chevron, which turns to point down at the list a sheet op [$popover, $sheet] = explode('