Draw the menu without Tailwind
<x-menu> renders data-md-menu with data-md-sheet-at-compact, and its popover data-md-menu-popover with data-md-position and data-md-vibrant; menu.css caps a long menu at 288px so it scrolls instead of running off the top layer (ACT-04) and ties the open/close transition to the trigger's corner (ACT-26), restyles a submenu to open in place inside a sheet-at-compact sheet, and still carries the dropdown a select's picker and a searchable choices field draw, so select.css and choices.css finish their step-36 TODO import. actions.css sheds the state-transition-fast and popover-transition utilities menu-item and menu no longer use; state-transition-default and [data-fab-menu] stay for fab-menu's own rewrite. data-menu, data-menu-filter, data-menu-sheet, data-menu-empty, data-sheet-at-compact, data-vibrant and data-active are renamed to data-md-*, also in menu.js, split-button.css (which renders a <x-menu> and now imports its stylesheet) and AppBarTest's overflow menu (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:
co-authored by
Claude Opus 5
parent
c040925cd4
commit
5cdeec26e8
@@ -22,6 +22,7 @@ dataset('action components', [
|
||||
'menu-separator',
|
||||
'menu-group',
|
||||
'menu-item',
|
||||
'menu',
|
||||
]);
|
||||
|
||||
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
|
||||
|
||||
@@ -73,7 +73,7 @@ it('keeps two trailing icon buttons below medium and four from it, the rest in a
|
||||
->toMatch('/<button[^>]*aria-label="Star" aria-pressed="true"/')
|
||||
// One overflow button per width, named and tooltipped.
|
||||
->and(preg_match_all('/<button[^>]*data-md-size="sm"[^>]*aria-label="More options"/', $html))->toBe(2)
|
||||
->and(preg_match_all('/role="menu"\s+data-menu\s+aria-label="More options"/', $html))->toBe(2)
|
||||
->and(preg_match_all('/role="menu"\s+data-md-menu-popover\s+data-md-position="bottom-end"\s+aria-label="More options"/', $html))->toBe(2)
|
||||
// Below medium: all but the first.
|
||||
->and($compact)
|
||||
->toMatch('/<a data-md-menu-item[^>]*role="menuitem" tabindex="-1" href="\/shares\/1\/share"/')
|
||||
|
||||
@@ -15,26 +15,41 @@ it('opens a popover menu from its trigger', function () {
|
||||
expect($anchor)->not->toBeEmpty()
|
||||
->and($html)
|
||||
->toContain('x-data="materialMenu"')
|
||||
->toContain('data-md-menu')
|
||||
->toContain('<button>More</button>')
|
||||
->toContain('popover="auto"')
|
||||
->toContain('role="menu"')
|
||||
->toContain('data-md-menu-popover')
|
||||
->toContain('aria-label="Share actions"')
|
||||
->toContain("id=\"material-menu-{$anchor[2]}\"")
|
||||
->toContain("position-anchor: {$anchor[1]}")
|
||||
->toContain('bg-surface-container-low')
|
||||
->toContain('[position-area:bottom_span-right]');
|
||||
->and(ComponentStylesheet::read('menu')->declarations("[data-md-menu-popover][data-md-position='bottom-start']"))->toBe([
|
||||
'transform-origin' => 'top',
|
||||
'position-area' => 'bottom span-right',
|
||||
]);
|
||||
});
|
||||
|
||||
it('scrolls a menu too long for the window instead of running off it', function () {
|
||||
expect((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||
->toContain('max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto')
|
||||
->not->toContain('overflow-visible');
|
||||
->toContain('data-md-menu-popover')
|
||||
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover]'))->toMatchArray([
|
||||
'max-block-size' => 'min(288px, calc(100dvh - 32px))',
|
||||
'overflow-y' => 'auto',
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens at the position asked for, in the vibrant colours on request', function () {
|
||||
$html = (string) $this->blade('<x-menu position="top-end" vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>');
|
||||
|
||||
expect($html)->toContain('[position-area:top_span-left]')->toContain('bg-tertiary-container text-on-tertiary-container');
|
||||
expect($html)->toContain('data-md-position="top-end"')->toContain('data-md-vibrant')
|
||||
->and(ComponentStylesheet::read('menu')->declarations("[data-md-menu-popover][data-md-position='top-end']"))->toBe([
|
||||
'transform-origin' => 'bottom',
|
||||
'position-area' => 'top span-left',
|
||||
])
|
||||
->and(ComponentStylesheet::read('menu')->declarations('[data-md-menu-popover][data-md-vibrant]'))->toBe([
|
||||
'--material-menu-surface' => 'var(--md-sys-color-tertiary-container)',
|
||||
'--material-menu-ink' => 'var(--md-sys-color-on-tertiary-container)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws an item as a menuitem button', function () {
|
||||
@@ -215,7 +230,7 @@ it('embeds a text field that filters the list, as a combobox over the menu', fun
|
||||
|
||||
expect($id)->not->toBeEmpty()
|
||||
->and($html)
|
||||
->toContain('data-menu-filter')
|
||||
->toContain('data-md-menu-filter')
|
||||
->toContain('role="combobox"')
|
||||
->toContain('aria-autocomplete="list"')
|
||||
->toContain("aria-controls=\"{$id[1]}-list\"")
|
||||
@@ -223,7 +238,7 @@ it('embeds a text field that filters the list, as a combobox over the menu', fun
|
||||
->toContain('placeholder="Find a person"')
|
||||
->toContain('x-on:input="refine()"')
|
||||
->toContain('x-on:keydown.stop="search($event)"')
|
||||
->toContain('<p data-menu-empty hidden')
|
||||
->toContain('<p data-md-menu-empty hidden')
|
||||
->toContain('Nothing matches')
|
||||
// A text field is not something a `role="menu"` may hold, so the list moves inside it.
|
||||
->toContain("<div id=\"{$id[1]}-list\" role=\"menu\" aria-label=\"Assign to\"")
|
||||
@@ -234,18 +249,18 @@ it('names a bare filter field, and leaves a plain menu alone', function () {
|
||||
expect((string) $this->blade('<x-menu filter><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||
->toContain('aria-label="Filter"')
|
||||
->and((string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||
->not->toContain('data-menu-filter')
|
||||
->not->toContain('data-md-menu-filter')
|
||||
->not->toContain('role="combobox"')
|
||||
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"/');
|
||||
});
|
||||
|
||||
it('tells a vibrant menu apart, so the submenus inside it take the same container', function () {
|
||||
expect((string) $this->blade('<x-menu vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||
->toContain('data-menu')
|
||||
->toContain('data-vibrant')
|
||||
->toContain('data-md-menu')
|
||||
->toContain('data-md-vibrant')
|
||||
->and((string) $this->blade('<x-menu><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>'))
|
||||
->toContain('data-menu')
|
||||
->not->toContain('data-vibrant');
|
||||
->toContain('data-md-menu')
|
||||
->not->toContain('data-md-vibrant');
|
||||
});
|
||||
|
||||
it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as the popover', function () {
|
||||
@@ -265,7 +280,7 @@ it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as th
|
||||
|
||||
// The popover is the menu it always was, and the root tells menu.js to look for the sheet.
|
||||
expect($popover)
|
||||
->toMatch('/<div x-data="materialMenu"\s+data-sheet-at-compact/')
|
||||
->toMatch('/<div x-data="materialMenu" data-md-menu\s+data-md-sheet-at-compact/')
|
||||
->toMatch('/<div\s[^>]*popover="auto"[^>]*role="menu"[^>]*aria-label="Photo actions"/')
|
||||
->toContain('wire:click="wallpaper"')
|
||||
// The sheet goes to the end of <body>, out of any bar's stacking context, and its host
|
||||
@@ -289,7 +304,7 @@ it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as th
|
||||
->toContain('aria-label="Photo actions"')
|
||||
->toContain('rounded-t-corner-xl bg-surface-container-low')
|
||||
// The same slot, drawn again as a menu: the items keep their roles and their actions.
|
||||
->toMatch("/<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" role=\"menu\" aria-label=\"Photo actions\" data-menu-sheet/")
|
||||
->toMatch("/<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" role=\"menu\" aria-label=\"Photo actions\" data-md-menu-sheet/")
|
||||
->and(substr_count($html, 'role="menuitem"'))->toBe(4)
|
||||
->and(substr_count($html, 'wire:click="wallpaper"'))->toBe(2);
|
||||
});
|
||||
@@ -298,10 +313,10 @@ it('leaves the sheet out of a menu that does not ask for it', function () {
|
||||
$html = (string) $this->blade('<x-menu label="Share"><x-slot:trigger><button>x</button></x-slot:trigger><x-menu-item label="Copy" /></x-menu>');
|
||||
|
||||
expect($html)
|
||||
->not->toContain('data-sheet-at-compact')
|
||||
->not->toContain('data-md-sheet-at-compact')
|
||||
->not->toContain('x-teleport')
|
||||
->not->toContain('materialBottomSheet')
|
||||
->not->toContain('data-menu-sheet')
|
||||
->not->toContain('data-md-menu-sheet')
|
||||
->and(substr_count($html, 'role="menuitem"'))->toBe(1);
|
||||
});
|
||||
|
||||
@@ -320,15 +335,15 @@ it('names a sheet with no menu label, and draws a filtering menu\'s field in the
|
||||
expect($sheet)
|
||||
->toContain('aria-label="Menu"')
|
||||
->toContain('x-on:input="refine()"')
|
||||
->toContain("<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" data-menu-sheet")
|
||||
->toContain('data-menu-filter')
|
||||
->toContain("<div wire:key=\"material-menu-sheet-menu\" id=\"material-menu-{$key[1]}-sheet-menu\" data-md-menu-sheet")
|
||||
->toContain('data-md-menu-filter')
|
||||
->toContain('role="combobox"')
|
||||
->toContain("aria-controls=\"material-menu-{$key[1]}-sheet-menu-list\"")
|
||||
->toContain('placeholder="Find a person"')
|
||||
->toContain("<div wire:key=\"material-menu-sheet-list\" id=\"material-menu-{$key[1]}-sheet-menu-list\" role=\"menu\"")
|
||||
->toContain('<p data-menu-empty hidden')
|
||||
->toContain('<p data-md-menu-empty hidden')
|
||||
// The sheet is M3's own container whatever the menu's colour.
|
||||
->not->toContain('data-vibrant')
|
||||
->not->toContain('data-md-vibrant')
|
||||
->not->toContain('bg-tertiary-container')
|
||||
->and(substr_count($html, 'role="combobox"'))->toBe(2);
|
||||
});
|
||||
@@ -349,3 +364,35 @@ it('marks a submenu\'s chevron, which turns to point down at the list a sheet op
|
||||
->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-md-submenu-chevron');
|
||||
});
|
||||
|
||||
it('restyles a submenu to open in place under its item inside the sheet, and cancels the sheet\'s own padding', function () {
|
||||
$css = ComponentStylesheet::read('menu');
|
||||
|
||||
expect($css->declarations('[data-md-menu-sheet]'))->toMatchArray([
|
||||
'margin-inline' => 'calc(-1 * var(--md-sys-measurement-space300))',
|
||||
'padding-inline' => 'var(--md-sys-measurement-space100)',
|
||||
])
|
||||
->and($css->declarations('[data-md-menu-sheet] [data-md-submenu]'))->toMatchArray([
|
||||
'position' => 'static',
|
||||
'overflow' => 'visible',
|
||||
])
|
||||
->and($css->declarations('[data-md-menu-sheet] [aria-expanded="true"] > [data-md-submenu-chevron]'))->toMatchArray(['rotate' => '90deg'])
|
||||
->and($css->declarations('[data-md-menu-sheet] [aria-expanded="true"] > [data-md-submenu-chevron]:dir(rtl)'))->toBe(['rotate' => '-90deg']);
|
||||
});
|
||||
|
||||
it('draws the dropdown a select or a searchable choices field opens, the same family as a menu', function () {
|
||||
$css = ComponentStylesheet::read('menu');
|
||||
|
||||
expect($css->declarations('[data-md-field-menu]'))->toMatchArray([
|
||||
'max-block-size' => '18rem',
|
||||
'border-radius' => 'var(--md-sys-shape-corner-xs)',
|
||||
'background-color' => 'var(--md-sys-color-surface-container)',
|
||||
])
|
||||
->and($css->declarations('[data-md-field-option][aria-selected="true"]'))->toBe([
|
||||
'background-color' => 'var(--md-sys-color-secondary-container)',
|
||||
'color' => 'var(--md-sys-color-on-secondary-container)',
|
||||
])
|
||||
->and($css->css)->toContain('@supports (appearance: base-select) {')
|
||||
->and(ComponentStylesheet::read('select')->imports())->toContain('./menu.css')
|
||||
->and(ComponentStylesheet::read('choices')->imports())->toContain('./menu.css');
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ it('leaves the corners to the pair, growing the inner ones under the finger', fu
|
||||
'--md-split-button-icon' => '50px',
|
||||
'--md-split-button-nudge' => '6px',
|
||||
])
|
||||
->and($css->imports())->toBe(['./button.css']);
|
||||
->and($css->imports())->toBe(['./button.css', './menu.css']);
|
||||
});
|
||||
|
||||
it('is filled unless another container variant is asked for', function () {
|
||||
|
||||
Reference in New Issue
Block a user