From a29a147f1a9ad3119cc433129de2cf6e9756bf60 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 18:40:43 +0200 Subject: [PATCH] Cover six actions/communication behaviours the browser suite missed Plan step 36's handoff listed six behaviours no browser test exercised yet: - FieldsTest: 's open list uses the customizable picker (appearance: base-select) where the browser supports it, drawn as M3's 48px-row menu. - CommunicationTest: a two-line snackbar's action stays on the title's row from 600px, only wrapping under it below that (toast.css's [data-md-toast-wrap]). - ActionsTest: a disabled menu item reached by the keyboard withholds its state layer (interaction.css's [aria-disabled="true"] rule). - DataTest: a paginator's page step shows md-focus-ring's outline on keyboard focus, alongside its already-tested 48px target. - CommunicationTest: a transient rich tooltip stays open 1.5s after focus leaves rather than closing at once (rich-tooltip.js's LEAVE_GRACE_MS). - ActionsTest: an xs or sm group segment takes a press at its 48px edge, past its drawn size, now that the target comes from the shared md-touch-target class rather than a rule of its own. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- tests/Browser/ActionsTest.php | 76 +++++++++++++++++++++++++++++ tests/Browser/CommunicationTest.php | 45 +++++++++++++++++ tests/Browser/DataTest.php | 15 ++++++ tests/Browser/FieldsTest.php | 16 ++++++ 4 files changed, 152 insertions(+) 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')";