Cover six actions/communication behaviours the browser suite missed
Plan step 36's handoff listed six behaviours no browser test exercised yet: - FieldsTest: <x-select>'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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4bf1ca13a3
commit
a29a147f1a
@@ -171,6 +171,20 @@ it('opens a menu from the keyboard, on the last item with ArrowUp', function ()
|
|||||||
->assertScript(focused("textContent.trim().startsWith('Delete')"));
|
->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 () {
|
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
|
// 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.
|
// 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);
|
JS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
class GroupSizeProbe extends Component
|
||||||
|
{
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'BLADE'
|
||||||
|
<div class="grid gap-6 p-4">
|
||||||
|
<x-group name="xs-group" size="xs" :options="[['id' => 'a', 'name' => 'A'], ['id' => 'b', 'name' => 'B']]" />
|
||||||
|
<x-group name="sm-group" size="sm" :options="[['id' => 'a', 'name' => 'A'], ['id' => 'b', 'name' => 'B']]" />
|
||||||
|
</div>
|
||||||
|
BLADE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSizeProbe()
|
||||||
|
{
|
||||||
|
Livewire::component('group-size-probe', GroupSizeProbe::class);
|
||||||
|
|
||||||
|
Route::middleware('web')->get('/group-size-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface">
|
||||||
|
<livewire:group-size-probe />
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
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 () {
|
it('keeps one choice in a single, required selection group', function () {
|
||||||
$group = '#buttons [data-md-selection="single"]';
|
$group = '#buttons [data-md-selection="single"]';
|
||||||
$view = "document.querySelector('{$group}').parentElement.querySelector('code').textContent";
|
$view = "document.querySelector('{$group}').parentElement.querySelector('code').textContent";
|
||||||
|
|||||||
@@ -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]')");
|
->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 () {
|
it('opens a persistent rich tooltip on press', function () {
|
||||||
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
|
$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')");
|
->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 () {
|
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);
|
Livewire::component('rich-tooltip-morph-probe', RichTooltipMorphProbe::class);
|
||||||
|
|
||||||
|
|||||||
@@ -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("(() => { 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)'");
|
->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'");
|
||||||
|
});
|
||||||
|
|||||||
@@ -144,6 +144,22 @@ it('binds a select to Livewire', function () {
|
|||||||
->assertSeeIn('#hours', '1');
|
->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 () {
|
it('keeps a partly ticked checkbox in step with the server', function () {
|
||||||
$all = "document.querySelector('#all')";
|
$all = "document.querySelector('#all')";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user