tests / feature (8.4) (push) Successful in 1m50s
tests / feature (8.5) (push) Successful in 1m54s
tests / browser (chrome, chromium) (push) Successful in 7m52s
tests / browser (firefox, firefox) (push) Successful in 11m56s
tests / browser (safari, webkit) (push) Successful in 12m35s
The browser plugin runs every call on a page again when its first attempt takes over a second, and on the runner a press can. For most presses that costs nothing. For two kinds it breaks the test: a press that opens a dialog, a sheet, a full-screen view or a modal rail over its own trigger, whose second press can never land, and a press that changes state a second one would change again — a menu trigger, a toggle, a chip, a range picker's day, a paging key, a Save. The bottom sheet with preset heights timed out on WebKit that way after the date picker had on Firefox. Every such press in the browser suite now goes through pressOnce(), 253 of them across sixteen files, not only the ones the runner happened to catch; focus moves inside an open view, Escape, links and plain "set" actions keep the retry, which is harmless for them. Two samples that were still racing the machine: - The standard side sheet's exit is caught half-way with its motion stretched, as every other mid-exit sample is, and fullSpeed() takes the stretch off again before the test times a reopen against the real exit. Under load on Linux WebKit it had failed two runs in five; it passes ten in ten. - The switch-and-checkbox row measures its widths once, so it now waits for the resize to land and the brand face to load before it does. Browser 300 passed on Firefox and WebKitGTK in a Linux container held to two busy cores, and on Chrome, Firefox and WebKit on macOS. Feature 1159 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1206 lines
52 KiB
PHP
1206 lines
52 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>
|
|
<link rel="icon" href="data:,">
|
|
<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 ready(visit('/menu-morph-probe'));
|
|
}
|
|
|
|
function showcase(string $section = 'buttons')
|
|
{
|
|
return ready(visit("/material/{$section}"));
|
|
}
|
|
|
|
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();
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click(MORE);
|
|
|
|
$page->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()");
|
|
|
|
// ArrowUp on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->keys(':focus', 'ArrowUp');
|
|
|
|
$page->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');
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->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.
|
|
$page = visit('/material/menus');
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click(MORE);
|
|
|
|
$page->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');
|
|
|
|
// Opens the menu, then an item closes it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click(MORE)
|
|
->click('#menus [role="menuitem"]:has-text("Download")');
|
|
|
|
$page->assertAttribute(MORE, 'aria-expanded', 'false');
|
|
|
|
// Opens the menu, then a checkbox item toggles: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#menus button:has-text("Sort")')
|
|
->click('#menus [role="menuitemcheckbox"]:has-text("Largest")');
|
|
|
|
$page->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\"]')";
|
|
|
|
$page = showcase();
|
|
|
|
// Opens the split button's menu over the trailing half that triggers it: pressOnce(),
|
|
// tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-split="trailing"] >> nth=0');
|
|
|
|
$page->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();
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click('button[aria-label="New"]');
|
|
|
|
$page->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>
|
|
<link rel="icon" href="data:,">
|
|
<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 = ready(visit('/menu-anchor-probe')->resize(400, 800));
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click('@fab');
|
|
|
|
$page->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()");
|
|
|
|
// ArrowDown on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->keys(':focus', 'ArrowDown');
|
|
|
|
$page->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);
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click('@more');
|
|
|
|
$page->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>
|
|
<link rel="icon" href="data:,">
|
|
<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();
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click('@sort');
|
|
|
|
$page->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. ArrowDown on the trigger opens the menu, same as clicking
|
|
// it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->keys('@sort', 'ArrowDown');
|
|
|
|
$page->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. Each
|
|
// press toggles it, so each is pressed once: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('@sort');
|
|
|
|
$page->assertAttribute('@sort', 'aria-expanded', 'true');
|
|
|
|
pressOnce($page)->click('@sort');
|
|
|
|
$page->assertAttribute('@sort', 'aria-expanded', 'false')
|
|
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
|
|
|
pressOnce($page)->keys('@sort', 'ArrowDown');
|
|
|
|
$page->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);
|
|
|
|
pressOnce($page)->click('@sort');
|
|
|
|
$page->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();
|
|
|
|
// Opens the menu, then a checkbox item toggles: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('@sort')
|
|
->click('[role="menuitemcheckbox"]:has-text("Largest")');
|
|
|
|
$page->assertAttribute('[role="menuitemcheckbox"]:has-text("Largest")', 'aria-checked', 'true')
|
|
->assertScript(SORT_MENU.".matches(':popover-open')")
|
|
->assertAttribute('@sort', 'aria-expanded', 'true');
|
|
|
|
pressOnce($page)->click('[role="menuitemcheckbox"]:has-text("Newest")');
|
|
|
|
$page->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();
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click(NEW_FAB);
|
|
|
|
$page->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'"));
|
|
|
|
// ArrowDown on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->keys(NEW_FAB, 'ArrowDown');
|
|
|
|
$page->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);
|
|
|
|
pressOnce($page)->click(NEW_FAB);
|
|
|
|
$page->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();
|
|
|
|
// Opens the FAB menu, then an item closes it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click(NEW_FAB)
|
|
->click('[role="menuitem"]:has-text("Upload files")');
|
|
|
|
$page->assertSeeIn('#created', 'files')
|
|
->assertAttribute(NEW_FAB, 'aria-expanded', 'false');
|
|
|
|
$page->script("document.querySelector('".NEW_FAB."').focus()");
|
|
|
|
// ArrowDown on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->keys(NEW_FAB, 'ArrowDown');
|
|
|
|
$page->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>
|
|
<link rel="icon" href="data:,">
|
|
<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 = ready(visit('/disabled-fab-probe')->resize(393, 800), alpine: false);
|
|
|
|
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. A counter a retry would double: pressOnce(),
|
|
// tests/Pest.php.
|
|
pressOnce($page)->click('#enabled-fab');
|
|
|
|
$page->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>
|
|
<link rel="icon" href="data:,">
|
|
<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 ready(visit('/group-size-probe'), livewire: false);
|
|
}
|
|
|
|
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";
|
|
|
|
$page = showcase()
|
|
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'true');
|
|
|
|
// Each press toggles the choice: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click("{$group} [aria-label=\"Italic\"]");
|
|
|
|
$page->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'true')
|
|
->assertScript("{$marks} === 'bold, italic'");
|
|
|
|
pressOnce($page)->click("{$group} [aria-label=\"Bold\"]")
|
|
->click("{$group} [aria-label=\"Italic\"]");
|
|
|
|
$page->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'false')
|
|
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'false')
|
|
->assertScript("{$marks} === 'none'");
|
|
});
|
|
|
|
/**
|
|
* The tests below: a submenu opened and closed by keyboard or hover, a menu's filter field, its
|
|
* sheet-at-compact form below 600px, a long menu's scroll and the FAB menu's own scroll behind
|
|
* its fixed close button. `LongMenuProbe` and `SheetMenuProbe` back the last three of those.
|
|
*/
|
|
|
|
/** 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>
|
|
<link rel="icon" href="data:,">
|
|
<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 ready(visit('/long-menu-probe'), livewire: false);
|
|
}
|
|
|
|
function sheetMenuProbe()
|
|
{
|
|
Livewire::component('sheet-menu-probe', SheetMenuProbe::class);
|
|
|
|
Route::middleware('web')->get('/sheet-menu-probe', fn () => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<link rel="icon" href="data:,">
|
|
<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 ready(visit('/sheet-menu-probe')->resize(400, 800));
|
|
}
|
|
|
|
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');
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click($trigger);
|
|
|
|
$page->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');
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->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');
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click($trigger);
|
|
|
|
$page->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.
|
|
pressOnce($page)->click($trigger);
|
|
|
|
$page->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>, 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);
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click($trigger);
|
|
|
|
$page->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. The trigger says which it opens now before it is pressed — and
|
|
// pressed once, as it toggles: pressOnce() in tests/Pest.php.
|
|
$page->resize(900, 800)
|
|
->assertAttribute($trigger, 'aria-haspopup', 'menu');
|
|
|
|
pressOnce($page)->click($trigger);
|
|
|
|
$page->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();
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click('button:has-text("Assign")');
|
|
|
|
$page->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();
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click('button:has-text("Open")');
|
|
|
|
$page->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>
|
|
<link rel="icon" href="data:,">
|
|
<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 ready(visit('/long-fab-menu-probe')->resize(320, 420), livewire: false);
|
|
}
|
|
|
|
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();
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click('button[aria-label="New"]');
|
|
|
|
$page->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");
|
|
});
|
|
|
|
/** 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'));
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click(MORE);
|
|
|
|
$page->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's own retry waits for it,
|
|
// where sleeping for the exit's length inside the page outlived the plugin's 1000ms budget
|
|
// for one script attempt and had the whole expectation run again until it timed out.
|
|
->assertScript('document.querySelector("[data-md-popover-ghost]") === null');
|
|
|
|
// A press outside it.
|
|
pressOnce($page)->click(MORE);
|
|
|
|
$page->assertAttribute(MORE, 'aria-expanded', 'true');
|
|
|
|
$page->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'));
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->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);
|
|
|
|
pressOnce($page)->click(MORE);
|
|
|
|
$page->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');
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->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'));
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->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());
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click('button[aria-label="New"]');
|
|
|
|
$page->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; })()");
|
|
});
|