Draw the menu item without Tailwind

<x-menu-item> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 16:11:56 +02:00
co-authored by Claude Opus 5
parent 2f8fb2183f
commit c040925cd4
7 changed files with 310 additions and 98 deletions
@@ -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) {
+1 -1
View File
@@ -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('/<a role="menuitem" tabindex="-1" href="\/shares\/1\/share"/')
->toMatch('/<a data-md-menu-item[^>]*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"/')
+43 -21
View File
@@ -41,6 +41,7 @@ it('draws an item as a menuitem button', function () {
$html = (string) $this->blade('<x-menu-item label="Download" icon="download" shortcut="⌘D" description="As a ZIP" wire:click="download" />');
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('<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('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('<x-menu-item label="Largest" :selected="false" />'))
->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('<x-menu-item label="Copy" />'))->toContain('min-h-12')->toContain('px-4')
expect((string) $this->blade('<x-menu-item label="Copy" />'))->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('<x-menu-separator />')))->toBe('<hr role="separator" data-md-menu-separator />')
->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('<x-menu-group label="Sort by" />'))->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('<x-menu-item label="Users" link="/admin/users" />'))
->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('<x-menu-item label="Reset" disabled />')
->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('/<svg[^>]*class="([^"]*)"/', $html, $icon) ? $icon[1] : '';
expect($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" icon-right="chevron_right" />')))
->toBe('size-5 [:where(&amp;)]:text-on-surface-variant text-sport-run')
->toBe('text-sport-run')
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" :selected="true" />')))
->toBe('size-5 [:where(&amp;)]:text-on-tertiary-container text-sport-run')
->toBe('text-sport-run')
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" disabled />')))
->toBe('size-5 text-sport-run text-on-surface/38!')
->toBe('text-sport-run')
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" />')))
->toBe('size-5 text-on-surface-variant')
->toBe('')
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="text-sport-run" />'))
->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('<x-menu-item label="Export as" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->toContain($chevron)
->toContain('rtl:-scale-x-100')
->toContain('data-md-submenu-chevron')
->toContain('data-md-mirror-rtl')
->and((string) $this->blade('<x-menu-item label="Export as" icon-right="download" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->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('<template x-teleport="body">', $html);
// One markup for both: menu.js tells the sheet's copy apart by the list around it.
expect($popover)->toContain('data-submenu-chevron')->toContain('x-data="materialSubmenu"')->toContain('data-submenu')
->and($sheet)->toContain('data-submenu-chevron')->toContain('x-data="materialSubmenu"')->toContain('aria-haspopup="menu"')->toContain('data-submenu')
expect($popover)->toContain('data-md-submenu-chevron')->toContain('x-data="materialSubmenu"')->toContain('data-md-submenu')
->and($sheet)->toContain('data-md-submenu-chevron')->toContain('x-data="materialSubmenu"')->toContain('aria-haspopup="menu"')->toContain('data-md-submenu')
->and((string) $this->blade('<x-menu-item label="Export as" icon-right="download" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->not->toContain('data-submenu-chevron');
->not->toContain('data-md-submenu-chevron');
});