`<x-button fab>` is an extended FAB below `medium` and a filled button from there, but a disabled one was still drawn as the FAB below `medium`: the compact FAB's rule set no state of its own, so the button stood greyed out and fixed over the content. M3 never disables a FAB: "if its action is unavailable, remove the FAB entirely". A `fab` given `disabled` (a `<button disabled>`, or a link's `aria-disabled="true"`) now renders `data-md-unavailable`, and button.css draws it `display: none` below `medium`, which also takes it out of the accessibility tree and the Tab order; from `medium` it is the disabled filled button it was. The mark comes from the prop, not from `:disabled`, because a `spinner` puts `disabled` on its button while the action runs, and that FAB is busy rather than unavailable: hidden, it would vanish instead of showing its loading indicator. The docblocks and the skill's button table say so. A browser test renders an enabled fab with a slow spinner action, a disabled one and a disabled link on a 393px window: the disabled two are not rendered, the busy one stays on screen with its indicator, and at 600px both disabled ones are the disabled button again; it fails without the change in Chrome, Firefox and Safari (and in Chrome with `:disabled` alone as the condition, on the busy FAB). A feature test reads the mark and the rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1125 lines
50 KiB
PHP
1125 lines
50 KiB
PHP
<?php
|
||
|
||
use Illuminate\Support\Facades\Blade;
|
||
use Illuminate\Support\Facades\Route;
|
||
use Livewire\Component;
|
||
use Livewire\Livewire;
|
||
|
||
const MORE = '#menus [aria-label="More"]';
|
||
|
||
class MenuAnchorProbe extends Component
|
||
{
|
||
public int $renders = 0;
|
||
|
||
public function touch(): void
|
||
{
|
||
$this->renders++;
|
||
}
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div style="display: grid; justify-items: start; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
|
||
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||
|
||
<x-menu label="Share actions">
|
||
<x-slot:trigger>
|
||
<x-button icon="more_vert" tooltip="More" data-test="more" />
|
||
</x-slot:trigger>
|
||
|
||
<x-menu-item label="Copy link" icon="content_copy" />
|
||
<x-menu-item label="Download" icon="download" />
|
||
</x-menu>
|
||
|
||
<x-menu label="Create">
|
||
<x-slot:trigger>
|
||
<x-button fab icon="add" label="New plan" data-test="fab" />
|
||
</x-slot:trigger>
|
||
|
||
<x-menu-item label="Running plan" icon="directions_run" />
|
||
<x-menu-item label="Cycling plan" icon="directions_bike" />
|
||
</x-menu>
|
||
</div>
|
||
BLADE;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A Livewire component that renders again while its menus are open: `touch` from outside, a
|
||
* `keep-open` item's action, or a FAB menu item's action.
|
||
*/
|
||
class MenuMorphProbe extends Component
|
||
{
|
||
public int $renders = 0;
|
||
|
||
public string $sort = 'newest';
|
||
|
||
public string $created = '';
|
||
|
||
public function touch(): void
|
||
{
|
||
$this->renders++;
|
||
}
|
||
|
||
public function sortBy(string $sort): void
|
||
{
|
||
$this->sort = $sort;
|
||
}
|
||
|
||
public function create(string $kind): void
|
||
{
|
||
$this->created = $kind;
|
||
}
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div style="padding: var(--md-sys-measurement-space200);">
|
||
<p id="outside">renders: <span id="renders">{{ $renders }}</span>, created: <span id="created">{{ $created }}</span></p>
|
||
|
||
<x-menu label="Sort">
|
||
<x-slot:trigger>
|
||
<x-button label="Sort" data-test="sort" />
|
||
</x-slot:trigger>
|
||
|
||
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="sortBy('newest')" keep-open />
|
||
<x-menu-item label="Largest" :selected="$sort === 'largest'" wire:click="sortBy('largest')" keep-open />
|
||
</x-menu>
|
||
|
||
<div style="position: fixed; right: 16px; bottom: 16px">
|
||
<x-fab-menu label="New">
|
||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="create('files')" />
|
||
<x-fab-menu-item label="Paste text" icon="content_paste" wire:click="create('text')" />
|
||
</x-fab-menu>
|
||
</div>
|
||
</div>
|
||
BLADE;
|
||
}
|
||
}
|
||
|
||
const SORT_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"Sort\"]')";
|
||
|
||
const FAB_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"New\"]')";
|
||
|
||
const NEW_FAB = 'button[aria-label="New"]';
|
||
|
||
function menuMorphProbe()
|
||
{
|
||
Livewire::component('menu-morph-probe', MenuMorphProbe::class);
|
||
|
||
Route::middleware('web')->get('/menu-morph-probe', fn () => Blade::render(<<<'BLADE'
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body style="background-color: var(--md-sys-color-surface);">
|
||
<livewire:menu-morph-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE));
|
||
|
||
return visit('/menu-morph-probe')->waitForEvent('networkidle')
|
||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||
}
|
||
|
||
function showcase(string $section = 'buttons')
|
||
{
|
||
return visit("/material/{$section}")->waitForEvent('networkidle')
|
||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||
}
|
||
|
||
function focused(string $expression): string
|
||
{
|
||
return "document.activeElement.{$expression}";
|
||
}
|
||
|
||
it('opens a menu on the first item and walks it with the keyboard', function () {
|
||
$page = showcase('menus')
|
||
->assertNoJavaScriptErrors()
|
||
->click(MORE)
|
||
->assertAttribute(MORE, 'aria-expanded', 'true')
|
||
->assertScript(focused("textContent.trim().startsWith('Copy link')"));
|
||
|
||
$page->keys(':focus', ['ArrowDown', 'ArrowDown'])
|
||
->assertScript(focused("textContent.trim().startsWith('Rename')"));
|
||
|
||
$page->keys(':focus', 'End')
|
||
->assertScript(focused("textContent.trim().startsWith('Delete')"));
|
||
|
||
$page->keys(':focus', 'ArrowDown')
|
||
->assertScript(focused("textContent.trim().startsWith('Copy link')"));
|
||
|
||
$page->keys(':focus', 'd')
|
||
->assertScript(focused("textContent.trim().startsWith('Download')"));
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertAttribute(MORE, 'aria-expanded', 'false')
|
||
->assertScript(focused("getAttribute('aria-label') === 'More'"));
|
||
});
|
||
|
||
it('opens a menu from the keyboard, on the last item with ArrowUp', function () {
|
||
$page = showcase('menus');
|
||
|
||
$page->script("document.querySelector('".MORE."').focus()");
|
||
|
||
$page->keys(':focus', 'ArrowUp')
|
||
->assertAttribute(MORE, 'aria-expanded', 'true')
|
||
->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.
|
||
visit('/material/menus')
|
||
->click(MORE)
|
||
->assertAttribute(MORE, 'aria-expanded', 'true');
|
||
});
|
||
|
||
it('closes a menu when an item is chosen, but not one that keeps it open', function () {
|
||
$page = showcase('menus');
|
||
|
||
$page->click(MORE)
|
||
->click('#menus [role="menuitem"]:has-text("Download")')
|
||
->assertAttribute(MORE, 'aria-expanded', 'false');
|
||
|
||
$page->click('#menus button:has-text("Sort")')
|
||
->click('#menus [role="menuitemcheckbox"]:has-text("Largest")')
|
||
->assertScript("document.querySelector('#menus [role=\"menu\"][aria-label=\"Sort\"]').matches(':popover-open')");
|
||
});
|
||
|
||
it('shows a tooltip on keyboard focus and hides it on Escape', function () {
|
||
$tooltip = "document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]')";
|
||
|
||
$page = showcase();
|
||
|
||
// A key press first, so the browser is in keyboard modality, as a keyboard user would be —
|
||
// Firefox only counts a scripted focus as :focus-visible after one. Then focused directly
|
||
// rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order
|
||
// unless full keyboard access is on.
|
||
$page->keys('#content', 'Tab');
|
||
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
|
||
$page->assertScript(focused("getAttribute('aria-label') === 'Tonal'"))
|
||
->assertScript("{$tooltip}.matches(':popover-open')");
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript("! {$tooltip}.matches(':popover-open')");
|
||
});
|
||
|
||
it('moves a connected group\'s choice with the arrow keys', function () {
|
||
$checked = fn (string $value): string => "document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"{$value}\"]').checked";
|
||
|
||
$page = showcase();
|
||
|
||
$page->script("document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"system\"]').focus()");
|
||
|
||
$page->keys(':focus', 'ArrowLeft')
|
||
->assertScript($checked('dark'))
|
||
->assertScript("(el => getComputedStyle(el).borderTopLeftRadius === (el.offsetHeight / 2) + 'px')(document.querySelector('#buttons input[value=\"dark\"]').parentElement)");
|
||
});
|
||
|
||
it('rounds a split button\'s trailing half while its menu is open', function () {
|
||
$trailing = "document.querySelector('[data-md-split=\"trailing\"]')";
|
||
|
||
showcase()
|
||
->click('[data-md-split="trailing"] >> nth=0')
|
||
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'")
|
||
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === ({$trailing}.offsetHeight / 2) + 'px'");
|
||
});
|
||
|
||
it('keeps a connected segment\'s small inner corners, which a 9999px outer corner would scale away', function () {
|
||
// When a box's radii add up to more than a side, CSS shrinks every radius by the same
|
||
// factor: a full corner written as 9999px drew the 8px inner corners square. Every radius
|
||
// in a connected group or a split button stays within half its height, so none is scaled.
|
||
showcase()
|
||
->assertScript("[...document.querySelectorAll('[data-md-button-group=\"connected\"] > *, [data-md-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })")
|
||
->assertScript("(el => getComputedStyle(el).borderTopRightRadius === '8px')(document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"light\"]').parentElement)");
|
||
});
|
||
|
||
it('turns the FAB into a close button while its menu is open', function () {
|
||
$fab = "document.querySelector('button[aria-label=\"New\"]')";
|
||
|
||
$page = showcase()
|
||
->click('button[aria-label="New"]')
|
||
->assertScript("{$fab}.getAttribute('aria-expanded') === 'true'")
|
||
->assertScript(focused("textContent.trim() === 'Upload files'"));
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript("{$fab}.getAttribute('aria-expanded') === 'false'")
|
||
->assertScript(focused("getAttribute('aria-label') === 'New'"));
|
||
});
|
||
|
||
it('animates the loading indicator in the browser', function () {
|
||
$clock = "document.querySelector('#buttons [role=\"progressbar\"] svg').getCurrentTime()";
|
||
|
||
showcase()->assertScript("{$clock} > 0.1");
|
||
});
|
||
|
||
it('draws the loading indicator at the size it is given, container and shape in proportion', function () {
|
||
$box = fn (string $selector): string => "document.querySelector('#buttons {$selector}').getBoundingClientRect()";
|
||
|
||
showcase()
|
||
->assertScript("(({ width, height }) => width === 96 && height === 96)({$box('[aria-label=\"Uploading\"]')})")
|
||
->assertScript("(({ width, height }) => width === 96 && height === 96)({$box('[aria-label=\"Uploading\"] > [data-md-loading-animated]')})")
|
||
->assertScript("(({ width, height }) => width === 32 && height === 32)({$box('[data-md-loading][data-md-contained][style]')})");
|
||
});
|
||
|
||
/** A script giving the rects of a menu button (`control`) and of the menu it opens (`menu`). */
|
||
function menuAgainst(string $test, string $label): string
|
||
{
|
||
return "(() => { const control = document.querySelector('[data-test=\"{$test}\"]').getBoundingClientRect(); const menu = document.querySelector('[role=\"menu\"][aria-label=\"{$label}\"]').getBoundingClientRect(); return { control, menu }; })()";
|
||
}
|
||
|
||
it('hangs a menu on its menu button, even when the button is fixed to a corner of the window', function () {
|
||
Livewire::component('menu-anchor-probe', MenuAnchorProbe::class);
|
||
|
||
Route::middleware('web')->get('/menu-anchor-probe', fn () => Blade::render(<<<'BLADE'
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body style="background-color: var(--md-sys-color-surface);">
|
||
<livewire:menu-anchor-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE));
|
||
|
||
$placed = menuAgainst('fab', 'Create');
|
||
$above = "(({ control, menu }) => getComputedStyle(document.querySelector('[data-test=\"fab\"]')).position === 'fixed' && menu.bottom <= control.top && control.top - menu.bottom <= 16 && Math.abs(menu.right - control.right) <= 16 && menu.left >= 0 && menu.top >= 0)({$placed})";
|
||
|
||
$page = visit('/menu-anchor-probe')->resize(400, 800)->waitForEvent('networkidle')
|
||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||
|
||
$page->click('@fab')
|
||
->assertAttribute('@fab', 'aria-expanded', 'true')
|
||
->assertScript($above);
|
||
|
||
$page->keys(':focus', 'Escape')->assertAttribute('@fab', 'aria-expanded', 'false');
|
||
|
||
// A Livewire render names the anchor afresh, on the wrapper again. Opened from the keyboard: a
|
||
// press this soon after the menu closed is taken for the light-dismiss press and ignored.
|
||
$page->script('window.eval("Livewire.first().touch()")');
|
||
|
||
$page->assertSeeIn('#renders', '1')
|
||
->script("document.querySelector('[data-test=\"fab\"]').focus()");
|
||
|
||
$page->keys(':focus', 'ArrowDown')
|
||
->assertAttribute('@fab', 'aria-expanded', 'true')
|
||
->assertScript($above);
|
||
|
||
// A button that also anchors its own tooltip keeps it, and the menu hangs under the button.
|
||
$page->resize(1024, 800)
|
||
->click('@more')
|
||
->assertAttribute('@more', 'aria-expanded', 'true')
|
||
->assertScript("(() => { const names = getComputedStyle(document.querySelector('[data-test=\"more\"]')).getPropertyValue('anchor-name'); return names.includes('--material-button-') && names.includes('--material-menu-'); })()")
|
||
->assertScript('(({ control, menu }) => menu.top >= control.bottom && menu.top - control.bottom <= 16 && Math.abs(menu.left - control.left) <= 16)('.menuAgainst('more', 'Share actions').')');
|
||
});
|
||
|
||
it('paints a group\'s hint and a menu item\'s icon in the colour their classes name', function () {
|
||
Route::middleware('web')->get('/colour-class-probe', fn () => Blade::render(<<<'BLADE'
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
</head>
|
||
<body style="background-color: var(--md-sys-color-surface);">
|
||
<span id="error-ink" class="md-ink-error">Reference</span>
|
||
<div id="terrain">
|
||
<x-group name="terrain" hint="No elevation data here" hint-class="md-ink-error" :options="[['id' => 'flat', 'name' => 'Flat']]" />
|
||
</div>
|
||
<x-menu-item id="run" label="Running plan" icon="directions_run" icon-class="md-ink-error" />
|
||
<x-menu-item id="chosen" label="Cycling plan" icon="directions_bike" icon-class="md-ink-error" :selected="true" />
|
||
<x-menu-item id="off" label="Swimming plan" icon="pool" icon-class="md-ink-error" disabled />
|
||
</body>
|
||
</html>
|
||
BLADE));
|
||
|
||
$ink = fn (string $element): string => "getComputedStyle({$element}).color";
|
||
$error = $ink("document.querySelector('#error-ink')");
|
||
|
||
visit('/colour-class-probe')->waitForEvent('networkidle')
|
||
->assertScript($ink("document.querySelector('#terrain p')")." === {$error}")
|
||
->assertScript($ink("document.querySelector('#run svg')")." === {$error}")
|
||
->assertScript($ink("document.querySelector('#chosen svg')")." === {$error}")
|
||
->assertScript($ink("document.querySelector('#off svg')")." !== {$error}");
|
||
});
|
||
|
||
it('keeps a menu open while the component around it renders, and closes it cleanly after', function () {
|
||
$page = menuMorphProbe()->assertNoJavaScriptErrors();
|
||
|
||
$page->click('@sort')
|
||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||
|
||
$page->script('window.eval("Livewire.first().touch()")');
|
||
|
||
$page->assertSeeIn('#renders', '1')
|
||
->assertScript(SORT_MENU.".matches(':popover-open')")
|
||
->assertAttribute('@sort', 'aria-expanded', 'true')
|
||
->assertScript("document.querySelector('[data-test=\"sort\"]').getAttribute('aria-controls') === ".SORT_MENU.'.id')
|
||
->assertScript(focused("textContent.trim().startsWith('Newest')"));
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||
->assertScript('! '.SORT_MENU.".matches(':popover-open')")
|
||
->assertScript(focused("dataset.test === 'sort'"));
|
||
|
||
// Opened from the keyboard: a press this soon after the menu closed is taken for the
|
||
// light-dismiss press and ignored.
|
||
$page->keys('@sort', 'ArrowDown')
|
||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||
|
||
$page->script('window.eval("Livewire.first().touch()")');
|
||
|
||
$page->assertSeeIn('#renders', '2')
|
||
->click('#outside')
|
||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||
});
|
||
|
||
it('closes a menu on a second press of its menu button, and after the component renders', function () {
|
||
$page = menuMorphProbe();
|
||
|
||
// The press closes the menu before its click reaches the button: the guard against that click
|
||
// opening it again once waited for the queued toggle event, which comes after the click.
|
||
$page->click('@sort')
|
||
->assertAttribute('@sort', 'aria-expanded', 'true')
|
||
->click('@sort')
|
||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||
|
||
$page->keys('@sort', 'ArrowDown')
|
||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||
|
||
$page->script('window.eval("Livewire.first().touch()")');
|
||
|
||
// Past the reopen guard of the close above, so the second press is on its own.
|
||
$page->assertSeeIn('#renders', '1')
|
||
->wait(0.3);
|
||
|
||
$page->click('@sort')
|
||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||
});
|
||
|
||
it('keeps a menu open while a keep-open item\'s action runs', function () {
|
||
$page = menuMorphProbe();
|
||
|
||
$page->click('@sort')
|
||
->click('[role="menuitemcheckbox"]:has-text("Largest")')
|
||
->assertAttribute('[role="menuitemcheckbox"]:has-text("Largest")', 'aria-checked', 'true')
|
||
->assertScript(SORT_MENU.".matches(':popover-open')")
|
||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||
|
||
$page->click('[role="menuitemcheckbox"]:has-text("Newest")')
|
||
->assertAttribute('[role="menuitemcheckbox"]:has-text("Newest")', 'aria-checked', 'true')
|
||
->assertScript(SORT_MENU.".matches(':popover-open')");
|
||
});
|
||
|
||
it('keeps a FAB menu open while the component around it renders, and closes it cleanly after', function () {
|
||
$page = menuMorphProbe();
|
||
|
||
$page->click(NEW_FAB)
|
||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
|
||
|
||
$page->script('window.eval("Livewire.first().touch()")');
|
||
|
||
$page->assertSeeIn('#renders', '1')
|
||
->assertScript(FAB_MENU.".matches(':popover-open')")
|
||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
|
||
->assertScript("document.querySelector('".NEW_FAB."').getAttribute('aria-controls') === ".FAB_MENU.'.id');
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||
->assertScript(focused("getAttribute('aria-label') === 'New'"));
|
||
|
||
$page->keys(NEW_FAB, 'ArrowDown')
|
||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
|
||
|
||
$page->script('window.eval("Livewire.first().touch()")');
|
||
|
||
// Past the reopen guard of the Escape above, so the second press is on its own.
|
||
$page->assertSeeIn('#renders', '2')
|
||
->wait(0.3);
|
||
|
||
$page->click(NEW_FAB)
|
||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||
->assertScript('! '.FAB_MENU.".matches(':popover-open')");
|
||
});
|
||
|
||
it('closes a FAB menu when an item\'s action runs, and opens and closes it cleanly after', function () {
|
||
$page = menuMorphProbe();
|
||
|
||
$page->click(NEW_FAB)
|
||
->click('[role="menuitem"]:has-text("Upload files")')
|
||
->assertSeeIn('#created', 'files')
|
||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false');
|
||
|
||
$page->script("document.querySelector('".NEW_FAB."').focus()");
|
||
|
||
$page->keys(NEW_FAB, 'ArrowDown')
|
||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
|
||
->assertScript(focused("textContent.trim() === 'Upload files'"));
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||
->assertScript('! '.FAB_MENU.".matches(':popover-open')")
|
||
->assertScript(focused("getAttribute('aria-label') === 'New'"));
|
||
});
|
||
|
||
it('collapses an extended FAB to a FAB while the page scrolls down, and extends it on the way back', function () {
|
||
$fab = "document.querySelector('#buttons [data-md-collapse-on-scroll]')";
|
||
|
||
// Reduced motion takes the spring's duration to zero, so the width is final at once.
|
||
$page = visit('/material/buttons', ['reducedMotion' => 'reduce'])
|
||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'")
|
||
->assertScript("! {$fab}.hasAttribute('data-md-collapsed') && {$fab}.getBoundingClientRect().width > 56");
|
||
|
||
$page->script('window.scrollTo(0, 600)');
|
||
|
||
$page->assertScript("{$fab}.hasAttribute('data-md-collapsed')")
|
||
->assertScript("Math.round({$fab}.getBoundingClientRect().width) === 56")
|
||
// Clipped, not removed: the collapsed FAB keeps the extended one's name.
|
||
->assertScript("{$fab}.textContent.trim() === 'Compose'");
|
||
|
||
$page->script('window.scrollTo(0, 200)');
|
||
|
||
$page->assertScript("! {$fab}.hasAttribute('data-md-collapsed')")
|
||
->assertScript("{$fab}.getBoundingClientRect().width > 56");
|
||
});
|
||
|
||
class DisabledFabProbe extends Component
|
||
{
|
||
public int $created = 0;
|
||
|
||
public function create(): void
|
||
{
|
||
usleep(800_000);
|
||
|
||
$this->created++;
|
||
}
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div>
|
||
<p>created: <span id="created">{{ $created }}</span></p>
|
||
<x-button id="enabled-fab" fab icon="add" label="New plan" wire:click="create" spinner />
|
||
<x-button id="disabled-fab" fab icon="add" label="New route" disabled />
|
||
<x-button id="disabled-link-fab" fab icon="add" label="New course" link="/courses/new" disabled />
|
||
</div>
|
||
BLADE;
|
||
}
|
||
}
|
||
|
||
it('hides a disabled fab button below 600px, as M3 removes a FAB whose action is unavailable, and keeps the disabled button from 600px', function () {
|
||
Livewire::component('disabled-fab-probe', DisabledFabProbe::class);
|
||
|
||
Route::middleware('web')->get('/disabled-fab-probe', fn () => Blade::render(<<<'BLADE'
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body style="background-color: var(--md-sys-color-surface);">
|
||
<livewire:disabled-fab-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE));
|
||
|
||
// [rendered, fixed as a FAB]: `display: none` draws nothing and takes the button out of the
|
||
// accessibility tree and the Tab order with it.
|
||
$state = fn (string $id): string => "(() => {
|
||
const button = document.getElementById('{$id}');
|
||
|
||
return [getComputedStyle(button).display !== 'none' && button.checkVisibility(), getComputedStyle(button).position === 'fixed'];
|
||
})()";
|
||
|
||
$page = visit('/disabled-fab-probe')->resize(393, 800)->waitForEvent('networkidle')
|
||
->assertScript("document.readyState === 'complete' && typeof window.Livewire !== 'undefined'");
|
||
|
||
expect($page->script($state('enabled-fab')))->toBe([true, true])
|
||
->and($page->script($state('disabled-fab')))->toBe([false, true])
|
||
->and($page->script($state('disabled-link-fab')))->toBe([false, true]);
|
||
|
||
// A spinner disables its button while the action runs; that FAB is busy, not unavailable, and
|
||
// stays on screen with its loading indicator.
|
||
$page->click('#enabled-fab')
|
||
->assertScript("(() => { const button = document.getElementById('enabled-fab'); return button.hasAttribute('data-loading') && button.disabled && button.checkVisibility() && button.querySelector('[data-md-button-spinner]').checkVisibility(); })()")
|
||
->assertSeeIn('#created', '1');
|
||
|
||
$page->resize(600, 800)->assertScript("getComputedStyle(document.getElementById('enabled-fab')).position !== 'fixed'");
|
||
|
||
expect($page->script($state('disabled-fab')))->toBe([true, false])
|
||
->and($page->script($state('disabled-link-fab')))->toBe([true, false])
|
||
// The disabled filled button it is from medium: on-surface at 10% behind 38% ink.
|
||
->and($page->script("getComputedStyle(document.getElementById('disabled-fab')).backgroundColor"))->not->toBe($page->script("getComputedStyle(document.getElementById('enabled-fab')).backgroundColor"));
|
||
});
|
||
|
||
it('takes a press on a small connected segment at its 48px edge, past what it draws', function () {
|
||
// M3: "XS and S connected button groups have a 48dp target area and a 48dp minimum width".
|
||
showcase()->assertScript(<<<'JS'
|
||
(() => {
|
||
const segment = document.querySelector('#buttons input[name="showcase-theme"][value="light"]').parentElement
|
||
segment.scrollIntoView({ block: 'center' })
|
||
const box = segment.getBoundingClientRect()
|
||
const x = box.left + box.width / 2
|
||
return box.height === 40 && box.width >= 48
|
||
&& document.elementFromPoint(x, box.top - 3) === segment
|
||
&& document.elementFromPoint(x, box.bottom + 3) === segment
|
||
})()
|
||
JS);
|
||
});
|
||
|
||
class GroupSizeProbe extends Component
|
||
{
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div style="display: grid; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
|
||
<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 style="background-color: var(--md-sys-color-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 () {
|
||
$group = '#buttons [data-md-selection="single"]';
|
||
$view = "document.querySelector('{$group}').parentElement.querySelector('code').textContent";
|
||
|
||
$page = showcase();
|
||
|
||
$page->assertAttribute("{$group} button:has-text(\"Week\")", 'aria-pressed', 'true')
|
||
->click("{$group} button:has-text(\"Day\")")
|
||
->assertAttribute("{$group} button:has-text(\"Day\")", 'aria-pressed', 'true')
|
||
->assertAttribute("{$group} button:has-text(\"Week\")", 'aria-pressed', 'false')
|
||
->assertScript("{$view} === 'day'");
|
||
|
||
// Required: pressing the chosen button again leaves it chosen.
|
||
$page->click("{$group} button:has-text(\"Day\")")
|
||
->assertAttribute("{$group} button:has-text(\"Day\")", 'aria-pressed', 'true')
|
||
->assertScript("{$view} === 'day'");
|
||
});
|
||
|
||
it('toggles each choice of a multi selection group, down to none', function () {
|
||
$group = '#buttons [data-md-selection="multi"]';
|
||
$marks = "document.querySelector('{$group}').parentElement.querySelectorAll('code')[1].textContent";
|
||
|
||
showcase()
|
||
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'true')
|
||
->click("{$group} [aria-label=\"Italic\"]")
|
||
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'true')
|
||
->assertScript("{$marks} === 'bold, italic'")
|
||
->click("{$group} [aria-label=\"Bold\"]")
|
||
->click("{$group} [aria-label=\"Italic\"]")
|
||
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'false')
|
||
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'false')
|
||
->assertScript("{$marks} === 'none'");
|
||
});
|
||
|
||
/**
|
||
* Browser tests owed from docs/plans/material-3-browser-tests.md § Actions (Phase E), written on
|
||
* the data-md-* hooks of plan step 36's actions stream: submenu keyboard and hover-open, the
|
||
* filtering menu, the sheet-at-compact list, a long menu's scroll, and the FAB menu's own scroll
|
||
* behind its fixed close button. Written but not run — the actions group's Chromium run happens
|
||
* once every stream in the group has landed.
|
||
*/
|
||
|
||
/** A Livewire component whose menu has more items than the popover's 288px cap can show. */
|
||
class LongMenuProbe extends Component
|
||
{
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div style="padding: 4rem">
|
||
<x-menu label="Long list">
|
||
<x-slot:trigger><button>Open</button></x-slot:trigger>
|
||
|
||
@for ($i = 1; $i <= 20; $i++)
|
||
<x-menu-item label="Item {{ $i }}" />
|
||
@endfor
|
||
</x-menu>
|
||
</div>
|
||
BLADE;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A Livewire component whose `sheet-at-compact` menu also filters, so the compact window's field
|
||
* can be typed into and a render around an open sheet can be checked for a focus kept in it.
|
||
*/
|
||
class SheetMenuProbe extends Component
|
||
{
|
||
public int $renders = 0;
|
||
|
||
public function touch(): void
|
||
{
|
||
$this->renders++;
|
||
}
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div>
|
||
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||
|
||
<x-menu label="Assign to" filter="Find a person" sheet-at-compact>
|
||
<x-slot:trigger><button>Assign</button></x-slot:trigger>
|
||
|
||
<x-menu-item label="Ada Lovelace" />
|
||
<x-menu-item label="Grace Hopper" />
|
||
</x-menu>
|
||
</div>
|
||
BLADE;
|
||
}
|
||
}
|
||
|
||
function longMenuProbe()
|
||
{
|
||
Livewire::component('long-menu-probe', LongMenuProbe::class);
|
||
|
||
Route::middleware('web')->get('/long-menu-probe', fn () => Blade::render(<<<'BLADE'
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body style="background-color: var(--md-sys-color-surface);">
|
||
<livewire:long-menu-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE));
|
||
|
||
return visit('/long-menu-probe')->waitForEvent('networkidle')
|
||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
|
||
}
|
||
|
||
function sheetMenuProbe()
|
||
{
|
||
Livewire::component('sheet-menu-probe', SheetMenuProbe::class);
|
||
|
||
Route::middleware('web')->get('/sheet-menu-probe', fn () => Blade::render(<<<'BLADE'
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body style="background-color: var(--md-sys-color-surface);">
|
||
<livewire:sheet-menu-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE));
|
||
|
||
return visit('/sheet-menu-probe')->resize(400, 800)->waitForEvent('networkidle')
|
||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||
}
|
||
|
||
it('opens a submenu with Right, Enter or Space, and closes it with Left or Escape, back on the item', function () {
|
||
$trigger = '#menus button:has-text("Share")';
|
||
$sendTo = '#menus [role="menuitem"]:has-text("Send to")';
|
||
|
||
$page = showcase('menus')
|
||
->click($trigger)
|
||
->assertAttribute($trigger, 'aria-expanded', 'true')
|
||
->assertScript(focused("textContent.trim().startsWith('Copy link')"));
|
||
|
||
$page->keys(':focus', 'ArrowDown')
|
||
->assertScript(focused("textContent.trim().startsWith('Send to')"))
|
||
->assertAttribute($sendTo, 'aria-expanded', 'false');
|
||
|
||
$page->keys(':focus', 'ArrowRight')
|
||
->assertAttribute($sendTo, 'aria-expanded', 'true')
|
||
->assertScript(focused("textContent.trim().startsWith('A person')"));
|
||
|
||
$page->keys(':focus', 'ArrowLeft')
|
||
->assertAttribute($sendTo, 'aria-expanded', 'false')
|
||
->assertScript(focused("textContent.trim().startsWith('Send to')"));
|
||
|
||
// Enter and Space, the button's own activation keys, open it exactly as Right does.
|
||
$page->keys(':focus', 'Enter')
|
||
->assertAttribute($sendTo, 'aria-expanded', 'true')
|
||
->assertScript(focused("textContent.trim().startsWith('A person')"));
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertAttribute($sendTo, 'aria-expanded', 'false')
|
||
->assertScript(focused("textContent.trim().startsWith('Send to')"))
|
||
// Escape closed only the submenu: the outer menu is still open.
|
||
->assertAttribute($trigger, 'aria-expanded', 'true');
|
||
});
|
||
|
||
it('opens a submenu on hover for a fine pointer, and closes it once the pointer truly leaves', function () {
|
||
$trigger = '#menus button:has-text("Share")';
|
||
$sendTo = '#menus [role="menuitem"]:has-text("Send to")';
|
||
// "Delete" is an item in five menus on the page; only the open one's is visible.
|
||
$delete = '#menus [role="menuitem"]:has-text("Delete"):visible';
|
||
|
||
$page = showcase('menus')->click($trigger);
|
||
|
||
$page->hover($sendTo)
|
||
->wait(0.3)
|
||
->assertAttribute($sendTo, 'aria-expanded', 'true');
|
||
|
||
// The submenu is a DOM child of the item's own wrapper, so leaving for elsewhere in the list
|
||
// is what closes it — crossing into it would not be a leave at all.
|
||
$page->hover($delete)
|
||
->wait(0.4)
|
||
->assertAttribute($sendTo, 'aria-expanded', 'false');
|
||
});
|
||
|
||
it('filters a menu\'s items as its field is typed into, moves the highlight with arrows, and clears on reopen', function () {
|
||
$trigger = '#menus button:has-text("Assign to")';
|
||
$field = '#menus [aria-label="Find a person"]';
|
||
// Scoped to this menu's own filtered list: every other menu on the page has "menuitem"s too,
|
||
// and :has-text() is a Playwright locator extension — invalid inside a real
|
||
// document.querySelector, which the id lookups below need to be.
|
||
$list = "[...document.querySelectorAll('#menus [role=\"menu\"][aria-label=\"Assign to\"] [role=\"menuitem\"]')]";
|
||
$ada = "{$list}.find((item) => item.textContent.trim().startsWith('Ada Lovelace')).id";
|
||
$grace = "{$list}.find((item) => item.textContent.trim().startsWith('Grace Hopper')).id";
|
||
|
||
$page = showcase('menus')
|
||
->click($trigger)
|
||
->assertScript(focused("getAttribute('aria-label') === 'Find a person'"));
|
||
|
||
// Ada Lovelace and Grace Hopper are both "Engineering"; the rest are Research or Networks.
|
||
$page->type($field, 'engineering')
|
||
->assertScript("{$list}.filter((item) => !item.hidden).length === 2")
|
||
->assertScript("document.querySelector('{$field}').getAttribute('aria-activedescendant') === ({$ada})");
|
||
|
||
$page->keys($field, 'ArrowDown')
|
||
->assertScript("document.querySelector('{$field}').getAttribute('aria-activedescendant') === ({$grace})");
|
||
|
||
$page->keys($field, 'Enter')
|
||
->assertAttribute($trigger, 'aria-expanded', 'false');
|
||
|
||
// Closing clears the query, so a reopen shows the whole list again.
|
||
$page->click($trigger)
|
||
->assertScript("document.querySelector('{$field}').value === ''")
|
||
->assertScript("{$list}.every((item) => !item.hidden)");
|
||
});
|
||
|
||
it('opens a sheet-at-compact menu below 600px, focused on its first item, and the popover from 600px', function () {
|
||
$trigger = '#menus button:has-text("Photo")';
|
||
// The sheet is <x-bottom-sheet>, rewritten (plan step 36) but still x-show-driven — its CSS
|
||
// now animates open/close through @starting-style and transition-behavior: allow-discrete
|
||
// rather than Alpine's x-transition classes, but x-show still owns the element's display, so
|
||
// its openness shows as visibility, not the Popover API's :popover-open.
|
||
$dialog = '[role="dialog"][aria-label="Photo actions"]';
|
||
$addToAlbum = '#menus [role="menuitem"]:has-text("Add to album")';
|
||
|
||
$page = showcase('menus')->resize(400, 800);
|
||
|
||
$page->click($trigger)
|
||
->assertAttribute($trigger, 'aria-haspopup', 'dialog')
|
||
->assertScript(focused("textContent.trim().startsWith('Set as wallpaper')"))
|
||
->assertVisible($dialog);
|
||
|
||
// A submenu opens in place, under its item, instead of beside it as the popover's would.
|
||
$page->keys(':focus', 'ArrowDown')
|
||
->keys(':focus', 'ArrowRight')
|
||
->assertScript(focused("textContent.trim().startsWith('Holidays')"));
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript(focused("textContent.trim().startsWith('Add to album')"))
|
||
->assertAttribute($addToAlbum, 'aria-expanded', 'false');
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertAttribute($trigger, 'aria-expanded', 'false')
|
||
->assertMissing($dialog)
|
||
->assertScript(focused("textContent.trim() === 'Photo'"));
|
||
|
||
// From 600px the trigger opens the popover instead, and a resize across it while one is open
|
||
// closes whichever was shown.
|
||
$page->resize(900, 800)
|
||
->click($trigger)
|
||
->assertAttribute($trigger, 'aria-haspopup', 'menu')
|
||
->assertAttribute($trigger, 'aria-expanded', 'true');
|
||
|
||
$page->resize(400, 800)
|
||
->assertAttribute($trigger, 'aria-expanded', 'false');
|
||
});
|
||
|
||
it('filters in the sheet too, and a Livewire render keeps it open with the field\'s focus', function () {
|
||
$page = sheetMenuProbe();
|
||
|
||
$page->click('button:has-text("Assign")')
|
||
->assertScript(focused("getAttribute('aria-label') === 'Find a person'"));
|
||
|
||
$page->type(':focus', 'grace')
|
||
->assertScript("[...document.querySelectorAll('[role=\"dialog\"] [role=\"menuitem\"]')].filter((item) => !item.hidden).length === 1");
|
||
|
||
$page->script('window.eval("Livewire.first().touch()")');
|
||
|
||
// The sheet is <x-bottom-sheet>, still x-show-driven (see the note above), so open shows as
|
||
// visible, not :popover-open.
|
||
$page->assertSeeIn('#renders', '1')
|
||
->assertVisible('[role="dialog"][aria-label="Assign to"]')
|
||
->assertScript(focused("getAttribute('aria-label') === 'Find a person'"))
|
||
->assertScript(focused("value === 'grace'"));
|
||
});
|
||
|
||
it('scrolls a long menu and keeps the item the keyboard reaches inside its visible box', function () {
|
||
$popover = "document.querySelector('[role=\"menu\"][aria-label=\"Long list\"]')";
|
||
|
||
$page = longMenuProbe()
|
||
->click('button:has-text("Open")')
|
||
->assertScript("{$popover}.scrollHeight > {$popover}.clientHeight");
|
||
|
||
$page->keys(':focus', 'End')
|
||
->assertScript(focused("textContent.trim() === 'Item 20'"))
|
||
->assertScript("(() => { const item = document.activeElement.getBoundingClientRect(); const box = {$popover}.getBoundingClientRect(); return item.top >= box.top - 1 && item.bottom <= box.bottom + 1; })()");
|
||
});
|
||
|
||
class LongFabMenuProbe extends Component
|
||
{
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div style="position: fixed; right: 16px; bottom: 16px">
|
||
<x-fab-menu label="New">
|
||
@for ($i = 1; $i <= 8; $i++)
|
||
<x-fab-menu-item label="Item {{ $i }}" icon="star" />
|
||
@endfor
|
||
</x-fab-menu>
|
||
</div>
|
||
BLADE;
|
||
}
|
||
}
|
||
|
||
function longFabMenuProbe()
|
||
{
|
||
Livewire::component('long-fab-menu-probe', LongFabMenuProbe::class);
|
||
|
||
Route::middleware('web')->get('/long-fab-menu-probe', fn () => Blade::render(<<<'BLADE'
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body style="background-color: var(--md-sys-color-surface);">
|
||
<livewire:long-fab-menu-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE));
|
||
|
||
return visit('/long-fab-menu-probe')->resize(320, 420)->waitForEvent('networkidle')
|
||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
|
||
}
|
||
|
||
it('scrolls the FAB menu\'s items on a short window, behind the close button, which stays put', function () {
|
||
$trigger = "document.querySelector('button[aria-label=\"New\"]')";
|
||
$list = "document.querySelector('[role=\"menu\"][aria-label=\"New\"]')";
|
||
|
||
$page = longFabMenuProbe()
|
||
->click('button[aria-label="New"]')
|
||
->assertAttribute('button[aria-label="New"]', 'aria-expanded', 'true')
|
||
->assertScript("{$list}.scrollHeight > {$list}.clientHeight");
|
||
|
||
$page->script("window.__fabTop = {$trigger}.getBoundingClientRect().top; {$list}.scrollTop = 40");
|
||
|
||
// The close button is not inside the scrolling list, so scrolling it never moves the button.
|
||
$page->assertScript("{$trigger}.getBoundingClientRect().top === window.__fabTop");
|
||
});
|
||
|
||
/**
|
||
* Slows the page's motion tokens, so a popover's exit copy (resources/js/popover-exit.js) is
|
||
* still on screen after a separate round trip to the browser: a key press or a click already
|
||
* costs about as long as a 150–350ms exit, and the copy moves on the tokens as the popover would.
|
||
*/
|
||
function slowMotion(mixed $page, string $duration = '1500ms'): mixed
|
||
{
|
||
$page->script(<<<JS
|
||
document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-effects-fast-duration: {$duration}; --md-sys-motion-effects-default-duration: {$duration}; --md-sys-motion-spatial-fast-duration: {$duration}; --md-sys-motion-spatial-default-duration: {$duration}; }</style>')
|
||
JS);
|
||
|
||
return $page;
|
||
}
|
||
|
||
/** The exit copy on screen: shown, inert, hidden from assistive technology, with no ids of its own. */
|
||
const EXIT_COPY = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); return copy !== null && copy.matches(':popover-open') && copy.inert && copy.getAttribute('aria-hidden') === 'true' && ! copy.hasAttribute('id') && copy.querySelector('[id], [popover]:not([data-md-popover-ghost])') === null; })()";
|
||
|
||
/** Whether the exit copy is caught part-way to its closed opacity. */
|
||
const EXIT_COPY_FADING = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); const opacity = copy && parseFloat(getComputedStyle(copy).opacity); return opacity > 0.02 && opacity < 0.98; })()";
|
||
|
||
it('fades a menu out after the browser has closed it, on Escape or a press outside, in every engine', function () {
|
||
$menu = 'document.getElementById(document.querySelector(\''.MORE.'\').getAttribute(\'aria-controls\'))';
|
||
|
||
$page = slowMotion(showcase('menus'))
|
||
->click(MORE)
|
||
->assertAttribute(MORE, 'aria-expanded', 'true');
|
||
|
||
// Escape: the browser's own light dismiss, which no script can hold open.
|
||
$page->keys(':focus', 'Escape')
|
||
->assertAttribute(MORE, 'aria-expanded', 'false')
|
||
->assertScript("! {$menu}.matches(':popover-open')")
|
||
->assertScript(focused("getAttribute('aria-label') === 'More'"))
|
||
->assertScript(EXIT_COPY)
|
||
->assertScript(EXIT_COPY_FADING)
|
||
// The copy is decoration: Alpine starts nothing inside it.
|
||
->assertScript("window.eval(\"[...document.querySelectorAll('[data-md-popover-ghost], [data-md-popover-ghost] *')].every((element) => element._x_dataStack === undefined)\")")
|
||
// Gone once the slowest of its transitions has run.
|
||
->assertScript('new Promise((resolve) => setTimeout(() => resolve(document.querySelector("[data-md-popover-ghost]") === null), 1800))');
|
||
|
||
// A press outside it.
|
||
$page->click(MORE)
|
||
->assertAttribute(MORE, 'aria-expanded', 'true')
|
||
->click('#menus')
|
||
->assertAttribute(MORE, 'aria-expanded', 'false')
|
||
->assertScript(EXIT_COPY)
|
||
->assertScript(EXIT_COPY_FADING)
|
||
->assertNoJavaScriptErrors();
|
||
});
|
||
|
||
it('takes a menu\'s exit copy away when the menu opens again part-way through it', function () {
|
||
$page = slowMotion(showcase('menus'))
|
||
->click(MORE);
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript(EXIT_COPY)
|
||
// Past menu.js's reopen guard (250ms), which takes a press this soon after a light dismiss
|
||
// for the press that dismissed it; the copy is still fading.
|
||
->wait(0.3)
|
||
->assertScript(EXIT_COPY);
|
||
|
||
$page->click(MORE)
|
||
->assertAttribute(MORE, 'aria-expanded', 'true')
|
||
->assertScript("document.querySelector('[data-md-popover-ghost]') === null")
|
||
->assertScript('document.getElementById(document.querySelector(\''.MORE.'\').getAttribute(\'aria-controls\')).matches(\':popover-open\')');
|
||
});
|
||
|
||
it('leaves no exit copy under reduced motion, where every duration token is zero', function () {
|
||
$page = slowMotion(showcase('menus'), '0ms')
|
||
->click(MORE);
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertAttribute(MORE, 'aria-expanded', 'false')
|
||
->assertScript('new Promise((resolve) => { let seen = false; const look = () => { seen ||= document.querySelector("[data-md-popover-ghost]") !== null; }; const timer = setInterval(look, 5); setTimeout(() => { clearInterval(timer); resolve(! seen); }, 300); })');
|
||
});
|
||
|
||
it('fades a submenu out on its own, while its menu stays open', function () {
|
||
$trigger = '#menus button:has-text("Share")';
|
||
$sendTo = '#menus [role="menuitem"]:has-text("Send to")';
|
||
|
||
$page = slowMotion(showcase('menus'))
|
||
->click($trigger);
|
||
|
||
$page->keys(':focus', 'ArrowDown');
|
||
$page->keys(':focus', 'ArrowRight')
|
||
->assertAttribute($sendTo, 'aria-expanded', 'true');
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertAttribute($sendTo, 'aria-expanded', 'false')
|
||
->assertAttribute($trigger, 'aria-expanded', 'true')
|
||
->assertScript(EXIT_COPY)
|
||
->assertScript("document.querySelectorAll('[data-md-popover-ghost]').length === 1 && document.querySelector('[data-md-popover-ghost]').hasAttribute('data-md-submenu')")
|
||
->assertScript(EXIT_COPY_FADING);
|
||
});
|
||
|
||
it('fades a tooltip out after Escape hides it', function () {
|
||
$page = slowMotion(showcase());
|
||
|
||
$page->keys('#content', 'Tab');
|
||
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
|
||
$page->assertScript("document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]').matches(':popover-open')");
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript("! document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]').matches(':popover-open')")
|
||
->assertScript(EXIT_COPY)
|
||
->assertScript("document.querySelector('[data-md-popover-ghost]').hasAttribute('data-md-tooltip')")
|
||
->assertScript(EXIT_COPY_FADING);
|
||
});
|
||
|
||
it('sinks a FAB menu\'s items back after the menu has closed', function () {
|
||
$page = slowMotion(showcase())
|
||
->click('button[aria-label="New"]')
|
||
->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'true'");
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'false'")
|
||
->assertScript(EXIT_COPY)
|
||
// The first item part-way down its 8px sink and its fade.
|
||
->assertScript("(() => { const item = document.querySelector('[data-md-popover-ghost] [data-md-fab-menu-item]'); const style = getComputedStyle(item); const drop = parseFloat(style.translate.split(' ')[1] ?? '0'); const opacity = parseFloat(style.opacity); return drop > 0.1 && drop < 7.9 && opacity > 0.02 && opacity < 0.98; })()");
|
||
});
|