Files
livewire-material/tests/Feature/Components/MenuTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 287340b066 Rewrite the bottom sheet without Tailwind
Plan step 36 (containment group): <x-bottom-sheet>'s class lists move
into resources/css/components/bottom-sheet.css, keyed on
data-md-bottom-sheet (data-md-open, data-md-standard) and
data-md-bottom-sheet-scrim/-probe/-panel (data-md-preset while
heights/snap gives it stops)/-handle/-grip/-announce/-body/-title/
-actions. x-transition's enter/leave classes are replaced the same way
as drawer.css: @starting-style + transition-behavior: allow-discrete
on the x-show-driven panel, the asymmetric open (spatial-default,
emphasized-decelerate) vs close (effects-default, emphasized-
accelerate) timing coming from the more specific open-state selector's
own transition-duration/-timing-function, which a CSS transition
always takes from the after-change style. data-md-preset adds the
height-between-stops transition (the spatial spring) alongside it,
independent of the open/close slide.

The drag handle (C-01, already fixed in the Tailwind-era markup this
replaces) renders the shared md-focus-ring and md-touch-target classes
instead of a hand-rolled ring: unlike the datepicker's day, its own
visible bar is not a smaller indicator drawn inside a bigger box those
classes cannot reach, so nothing here refines them further. Its 22px
vertical padding (SheetDefaults.kt's DragHandleVerticalPadding) is a
literal, matching alert.css's 12px for the same reason: 22 is not on
the measurement scale.

Hooks renamed data-drag-handle -> data-md-bottom-sheet-handle,
updated in resources/js/bottom-sheet.js and
tests/Browser/ContainmentTest.php; data-sheet -> data-md-drawer in
that test's comment. <x-menu sheet-at-compact> renders this component
unchanged; menu.css now imports bottom-sheet.css, and
tests/Feature/Components/MenuTest.php's stale Tailwind-class assertion
for the embedded sheet is replaced with a hook check. The two
sheet-at-compact browser tests in ActionsTest.php stay on
assertVisible(): the sheet is still x-show-driven (a deliberate choice
for this batch, keeping the existing modal/side-sheet/bottom-sheet
architecture and converting only their styling), so open still shows
as visibility rather than :popover-open; their comments no longer say
"not rewritten in this batch".

Imported from the Containment block of components.css. Browser tests
owed by docs/plans/material-3-browser-tests.md, added to
ContainmentTest.php but not run: dialog divider marks on scroll
(including that a nested dialog keeps its own marks), the full-screen
phone bar, separator always, no fade under reduced motion, the
scrolling body's inset focus ring; bottom sheet preset-height cycling
with its announcement and a drag settling on the nearest stop; the
standard side sheet from 840px without a scrim or a focus trap, modal
below it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 21:16:22 +02:00

414 lines
23 KiB
PHP

<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('opens a popover menu from its trigger', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu label="Share actions">
<x-slot:trigger><button>More</button></x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" />
</x-menu>
BLADE);
preg_match('/anchor-name: (--material-menu-([a-z0-9]+))/', $html, $anchor);
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]}")
->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('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('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 () {
$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"')
->toContain('wire:click="download"')
->toContain('Download')
->toContain('⌘D')
->toContain('As a ZIP')
->toContain('data-md-menu-item-description')
->toContain('data-md-menu-item-shortcut');
});
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('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)
->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('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')
->and(ComponentStylesheet::read('menu-group')->declarations('[data-md-menu-group-label]'))->toMatchArray([
'padding-inline' => 'var(--md-sys-measurement-space200)',
'padding-block' => 'var(--md-sys-measurement-space100) var(--md-sys-measurement-space50)',
]);
});
it('marks the page an item leads to, and carries a badge', function () {
$html = (string) $this->blade('<x-menu-item label="Support" icon="support" link="/admin/support" current badge="3" />');
expect($html)
->toContain('role="menuitem"')
->toContain('aria-current="page"')
->not->toContain('aria-checked')
->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('data-md-badge');
});
it('links an item, and keeps a disabled one out of reach', function () {
$this->blade('<x-menu-item label="Settings" link="/settings" />')
->assertSee('href="/settings"', false)
->assertSee('wire:navigate', false);
$this->blade('<x-menu-item label="Reset" disabled />')
->assertSee('aria-disabled="true"', 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'])
->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 () {
$this->blade('<x-menu-separator />')->assertSee('role="separator"', false);
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
->assertSee('role="group" aria-label="Sort by"', false);
});
it('separates clusters by a gap instead of a divider when asked', function () {
$html = (string) $this->blade('<x-menu-group gap><x-menu-item label="Cut" /></x-menu-group>');
$css = ComponentStylesheet::read('menu-group');
// M3 Expressive's grouped layout: 2px between the items of a cluster, 8px between clusters —
// the same 8px the divider keeps above and below its line.
expect($html)
->toContain('role="group"')
->toContain('data-md-gap')
->toContain('data-md-menu-group-items')
->not->toContain('aria-label')
->and($css->declarations('[data-md-menu-group][data-md-gap]:not(:first-child)'))->toBe(['margin-block-start' => 'var(--md-sys-measurement-space100)'])
->and($css->declarations('[data-md-menu-group-items]'))->toMatchArray(['gap' => 'var(--md-sys-measurement-space25)'])
->and((string) $this->blade('<x-menu-group label="Then" gap><x-menu-item label="Cut" /></x-menu-group>'))
->toContain('aria-label="Then"')
->toContain('data-md-menu-group-items')
->and((string) $this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>'))
->not->toContain('data-md-gap')
->not->toContain('data-md-menu-group-items')
->and($css->declarations('[data-md-menu-group]:not([data-md-gap])'))->toBe(['padding-block' => 'var(--md-sys-measurement-space50)']);
});
it('adds icon-class to the leading icon, over its own colour but not over disabled', function () {
$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('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('text-sport-run')
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" icon-class="text-sport-run" disabled />')))
->toBe('text-sport-run')
->and($leading((string) $this->blade('<x-menu-item label="Running plan" icon="directions_run" />')))
->toBe('')
->and((string) $this->blade('<x-menu-item label="Next" icon-right="chevron_right" icon-class="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) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent) !important']);
});
it('opens a submenu beside the item that holds it', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu-item label="Send to" icon="send" submenu>
<x-menu-item label="A person" icon="person" />
</x-menu-item>
BLADE);
preg_match('/anchor-name: (--material-submenu-([a-z0-9]+))/', $html, $anchor);
expect($anchor)->not->toBeEmpty()
->and($html)
->toContain('x-data="materialSubmenu"')
->toContain('x-on:keydown.right.prevent.stop="open(\'first\')"')
->toContain('x-on:pointerenter="hover($event)"')
->toContain('aria-haspopup="menu"')
->toContain('aria-expanded="false"')
->toContain("aria-controls=\"material-submenu-{$anchor[2]}\"")
->toContain("id=\"material-submenu-{$anchor[2]}\"")
->toContain("position-anchor: {$anchor[1]}")
->toContain('data-md-submenu')
->toContain('aria-label="Send to"')
// Opening a submenu chooses nothing, so the menu around it stays where it was.
->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 () {
$chevron = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/outlined-20/chevron_right.svg'));
$chevron = substr($chevron, (int) strpos($chevron, '><') + 1);
expect((string) $this->blade('<x-menu-item label="Export as" submenu><x-menu-item label="ZIP" /></x-menu-item>'))
->toContain($chevron)
->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);
});
it('embeds a text field that filters the list, as a combobox over the menu', function () {
$html = (string) $this->blade('<x-menu label="Assign to" filter="Find a person"><x-slot:trigger><button>x</button></x-slot:trigger><x-menu-item label="Ada" /></x-menu>');
preg_match('/id="(material-menu-[a-z0-9]+)"/', $html, $id);
expect($id)->not->toBeEmpty()
->and($html)
->toContain('data-md-menu-filter')
->toContain('role="combobox"')
->toContain('aria-autocomplete="list"')
->toContain("aria-controls=\"{$id[1]}-list\"")
->toContain('aria-label="Find a person"')
->toContain('placeholder="Find a person"')
->toContain('x-on:input="refine()"')
->toContain('x-on:keydown.stop="search($event)"')
->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\"")
->toMatch('/<div\s[^>]*popover="auto"(?![^>]*role="menu")/');
});
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-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-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-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 () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu label="Photo actions" sheet-at-compact>
<x-slot:trigger><button>More</button></x-slot:trigger>
<x-menu-item label="Set as wallpaper" icon="wallpaper" wire:click="wallpaper" />
<x-menu-item label="Delete" icon="delete" />
</x-menu>
BLADE);
preg_match('/id="material-menu-([a-z0-9]+)"/', $html, $key);
expect($key)->not->toBeEmpty();
[$popover, $sheet] = explode('<template x-teleport="body">', $html);
// 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" 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
// hears the keyboard, the clicks and the filter in the menu's scope.
->and($sheet)
->toContain('x-ref="sheetHost"')
->toContain('x-on:keydown.capture="sheetKeydown($event)"')
->toContain('x-on:click="activate($event)"')
->toContain('<div x-data="materialMenuSheet">')
// A modal bottom sheet, named by the menu's label. Its id is the same in every render, so
// a Livewire morph patches it rather than swapping it (menu.js makes it unique and keeps
// this one as the key); the lists carry keys of their own, as the popover does.
->toContain('...materialBottomSheet(false, JSON.parse(')
->toContain('id="material-menu-sheet"')
->not->toContain("material-menu-{$key[1]}-sheet\"")
// A key given to the Blade component would become the key of the loop around the menu.
->not->toMatch('/<section\s[^>]*wire:key/')
->toContain('role="dialog"')
->toContain('aria-modal="true"')
->toContain('x-trap.inert.noscroll="open"')
->toContain('aria-label="Photo actions"')
->toContain('data-md-bottom-sheet-panel')
// 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-md-menu-sheet/")
->and(substr_count($html, 'role="menuitem"'))->toBe(4)
->and(substr_count($html, 'wire:click="wallpaper"'))->toBe(2);
});
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-md-sheet-at-compact')
->not->toContain('x-teleport')
->not->toContain('materialBottomSheet')
->not->toContain('data-md-menu-sheet')
->and(substr_count($html, 'role="menuitem"'))->toBe(1);
});
it('names a sheet with no menu label, and draws a filtering menu\'s field in the sheet too', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu filter="Find a person" sheet-at-compact vibrant>
<x-slot:trigger><button>x</button></x-slot:trigger>
<x-menu-item label="Ada" />
</x-menu>
BLADE);
preg_match('/id="material-menu-([a-z0-9]+)"/', $html, $key);
[, $sheet] = explode('<template x-teleport="body">', $html);
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-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-md-menu-empty hidden')
// The sheet is M3's own container whatever the menu's colour.
->not->toContain('data-md-vibrant')
->not->toContain('bg-tertiary-container')
->and(substr_count($html, 'role="combobox"'))->toBe(2);
});
it('marks a submenu\'s chevron, which turns to point down at the list a sheet opens in place', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu label="Share" sheet-at-compact>
<x-slot:trigger><button>x</button></x-slot:trigger>
<x-menu-item label="Export as" submenu><x-menu-item label="ZIP" /></x-menu-item>
</x-menu>
BLADE);
[$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-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-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->declarations('select[data-md-field-control]::picker(select)', ['@supports (appearance: base-select)']))->toMatchArray(['appearance' => 'base-select'])
->and(ComponentStylesheet::read('select')->imports())->toContain('./menu.css')
->and(ComponentStylesheet::read('choices')->imports())->toContain('./menu.css');
});
it('opts the select itself into the customizable select after its own appearance: none', function () {
// menu.css and select.css share `material.components` and the selector's specificity, and
// select.css's block always follows the menu.css it imports: the opt-in has to be written
// there, or `appearance: none` wins and the picker is never the menu.
$select = ComponentStylesheet::read('select');
expect($select->declarations('select[data-md-field-control]'))->toMatchArray(['appearance' => 'none'])
->and($select->declarations('select[data-md-field-control]', ['@supports (appearance: base-select)']))->toMatchArray(['appearance' => 'base-select'])
->and(ComponentStylesheet::read('menu')->has('select[data-md-field-control]', ['@supports (appearance: base-select)']))->toBeFalse();
});