diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php
index 1a088980..69579742 100644
--- a/tests/Browser/ActionsTest.php
+++ b/tests/Browser/ActionsTest.php
@@ -171,6 +171,20 @@ it('opens a menu from the keyboard, on the last item with ArrowUp', function ()
->assertScript(focused("textContent.trim().startsWith('Delete')"));
});
+it('shows no state layer on a disabled menu item even when keyboard focus reaches it', function () {
+ $trigger = '#menus button:has-text("Sort")';
+ $reset = '#menus [role="menuitem"]:has-text("Reset")';
+
+ $page = showcase('menus')->click($trigger);
+
+ // M3 keeps a disabled item reachable so a person can find out it is there, but
+ // .md-state-layer withholds the layer itself from [aria-disabled="true"] (interaction.css).
+ $page->keys(':focus', 'End')
+ ->assertScript(focused("textContent.trim().startsWith('Reset')"))
+ ->assertAttribute($reset, 'aria-disabled', 'true')
+ ->assertScript("getComputedStyle(document.activeElement, '::before').display === 'none'");
+});
+
it('opens a menu the moment the page can be used', function () {
// The guard against a light-dismiss press reopening the menu once measured from the page's
// time origin, and swallowed every click in the first quarter second.
@@ -501,6 +515,68 @@ it('takes a press on a small connected segment at its 48px edge, past what it dr
JS);
});
+class GroupSizeProbe extends Component
+{
+ public function render(): string
+ {
+ return <<<'BLADE'
+
+
+
+
+ BLADE;
+ }
+}
+
+function groupSizeProbe()
+{
+ Livewire::component('group-size-probe', GroupSizeProbe::class);
+
+ Route::middleware('web')->get('/group-size-probe', fn () => Blade::render(<<<'BLADE'
+
+
+
+
+ @vite(config('livewire-material.showcase.vite'))
+ @livewireStyles
+
+
+
+ @livewireScripts
+
+
+ BLADE));
+
+ return visit('/group-size-probe')->waitForEvent('networkidle')
+ ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
+}
+
+it('takes a press at an xs or sm group segment\'s 48px edge, past what it draws, from the shared classes', function () {
+ // M3: "XS and S connected button groups have a 48dp target area and a 48dp minimum width" —
+ // group.blade.php reaches it with md-touch-target, not a rule of its own.
+ groupSizeProbe()
+ ->assertScript(<<<'JS'
+ (() => {
+ const segment = document.querySelector('input[name="xs-group"][value="a"]').parentElement
+ const box = segment.getBoundingClientRect()
+ const x = box.left + box.width / 2
+ return box.height < 48
+ && document.elementFromPoint(x, box.top - 3) === segment
+ && document.elementFromPoint(x, box.bottom + 3) === segment
+ })()
+ JS)
+ ->assertScript(<<<'JS'
+ (() => {
+ const segment = document.querySelector('input[name="sm-group"][value="a"]').parentElement
+ const box = segment.getBoundingClientRect()
+ const x = box.left + box.width / 2
+ return box.height < 48
+ && document.elementFromPoint(x, box.top - 3) === segment
+ && document.elementFromPoint(x, box.bottom + 3) === segment
+ })()
+ JS);
+});
+
it('keeps one choice in a single, required selection group', function () {
$group = '#buttons [data-md-selection="single"]';
$view = "document.querySelector('{$group}').parentElement.querySelector('code').textContent";
diff --git a/tests/Browser/CommunicationTest.php b/tests/Browser/CommunicationTest.php
index 1531fe61..cd36caf0 100644
--- a/tests/Browser/CommunicationTest.php
+++ b/tests/Browser/CommunicationTest.php
@@ -197,6 +197,26 @@ it('grows to M3\'s two-line height with a description, and Alt+G reaches its act
->assertScript('document.activeElement === '.TOAST."?.querySelector('[data-md-toast-action]')");
});
+it('keeps a two-line snackbar\'s action on the title\'s row from 600px, wrapping under it only below that', function () {
+ $page = visit('/material/communication')->resize(400, 800)->waitForEvent('networkidle')
+ ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
+
+ $page->script("materialToast('Upload paused', { description: 'The connection dropped at 64%.', action: { label: 'Retry now', handler: () => {} } })");
+
+ $title = TOAST."?.querySelector('[data-md-toast-title]')";
+ $action = TOAST."?.querySelector('[data-md-toast-action]')";
+
+ // Below 600px M3's "two lines with longer action" wraps the action under both lines of text
+ // (toast.css's [data-md-toast-wrap]), so it starts only once the title's line has ended.
+ $page->assertScript("{$title}.textContent.includes('Upload paused')")
+ ->assertScript("{$action}.getBoundingClientRect().top >= {$title}.getBoundingClientRect().bottom");
+
+ // From 600px it stays beside the title's line instead: the two boxes overlap vertically.
+ $page->resize(600, 800)
+ ->assertScript("{$action}.getBoundingClientRect().top < {$title}.getBoundingClientRect().bottom")
+ ->assertScript("{$action}.getBoundingClientRect().bottom > {$title}.getBoundingClientRect().top");
+});
+
it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
@@ -206,6 +226,31 @@ it('opens a persistent rich tooltip on press', function () {
->assertScript("{$bubble}.matches(':popover-open')");
});
+it('closes a transient rich tooltip 1.5s after focus leaves, not at once', function () {
+ $trigger = '#communication button[aria-label="About expiry"]';
+ $bubble = "document.querySelector('#communication [role=\"tooltip\"][popover]')";
+
+ $page = visit('/material/communication')->waitForEvent('networkidle')
+ ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
+
+ // A key press first, so the browser is in keyboard modality, as a keyboard user would be.
+ $page->keys('#communication', 'Tab');
+ $page->script("document.querySelector('{$trigger}').focus()");
+
+ $page->assertScript("document.activeElement === document.querySelector('{$trigger}')")
+ ->assertScript("{$bubble}.matches(':popover-open')");
+
+ $page->script("document.querySelector('{$trigger}').blur()");
+
+ // Still open shortly after focus leaves: it stands for 1.5s (rich-tooltip.js's
+ // LEAVE_GRACE_MS), not closing the instant focus does.
+ $page->wait(0.3)
+ ->assertScript("{$bubble}.matches(':popover-open')");
+
+ $page->wait(1.4)
+ ->assertScript("! {$bubble}.matches(':popover-open')");
+});
+
it('keeps a rich tooltip open while its action renders the component, and opens it again after', function () {
Livewire::component('rich-tooltip-morph-probe', RichTooltipMorphProbe::class);
diff --git a/tests/Browser/DataTest.php b/tests/Browser/DataTest.php
index ada65635..882abebd 100644
--- a/tests/Browser/DataTest.php
+++ b/tests/Browser/DataTest.php
@@ -132,3 +132,18 @@ it('draws each page step 40px and lets it be pressed anywhere in its 48px target
->assertScript("(() => { const step = document.querySelector('[data-md-pagination] button[aria-label=\"Go to page 2\"]'); const box = step.getBoundingClientRect(); return box.width === 40 && box.height === 40 && document.elementFromPoint(box.left + box.width / 2, box.top - 3) === step; })()")
->assertScript("getComputedStyle(document.querySelector('[data-md-pagination] [aria-current=\"page\"]')).backgroundColor !== 'rgba(0, 0, 0, 0)'");
});
+
+it('shows a page step\'s keyboard focus ring, from md-focus-ring', function () {
+ $step = "document.querySelector('[data-md-pagination] button[aria-label=\"Go to page 2\"]')";
+
+ $page = dataProbe();
+
+ // A key press first puts the browser in keyboard modality, as a keyboard user would be —
+ // Firefox only counts a scripted focus as :focus-visible after one.
+ $page->keys('#by-name [data-md-sort-header-button]', 'Tab');
+ $page->script("{$step}.focus()");
+
+ $page->assertScript("document.activeElement === {$step}")
+ ->assertScript("getComputedStyle({$step}).outlineStyle === 'solid'")
+ ->assertScript("getComputedStyle({$step}).outlineWidth === '3px'");
+});
diff --git a/tests/Browser/FieldsTest.php b/tests/Browser/FieldsTest.php
index 1ce1921f..cb5d5ec9 100644
--- a/tests/Browser/FieldsTest.php
+++ b/tests/Browser/FieldsTest.php
@@ -144,6 +144,22 @@ it('binds a select to Livewire', function () {
->assertSeeIn('#hours', '1');
});
+it('opens the customizable select picker as M3\'s menu, where the browser supports it', function () {
+ $select = "document.querySelector('#hours-field')";
+ $option = "{$select}.querySelector('option')";
+
+ $page = fieldProbe()
+ // menu.css only styles the picker inside @supports (appearance: base-select), so this
+ // also confirms the browser running the suite actually has it.
+ ->assertScript("CSS.supports('appearance', 'base-select') === true")
+ ->assertScript("getComputedStyle({$select}).appearance === 'base-select'");
+
+ $page->click('#hours-field')
+ ->assertScript("{$select}.matches(':open')")
+ // M3's menu row height (menu-item.css's 48px), not the browser's own tiny option row.
+ ->assertScript("parseFloat(getComputedStyle({$option}).minBlockSize) === 48");
+});
+
it('keeps a partly ticked checkbox in step with the server', function () {
$all = "document.querySelector('#all')";