tests / feature (8.4) (push) Successful in 1m49s
tests / feature (8.5) (push) Successful in 1m51s
tests / browser (chrome, chromium) (push) Successful in 7m34s
tests / browser (firefox, firefox) (push) Failing after 14m19s
tests / browser (safari, webkit) (push) Failing after 13m16s
On a slow machine Escape could close the full-screen search and have it open again for good. A close hands focus back to the input — Escape does, and so does the view's focus trap as it lets go — and `focused()` told that return from someone coming to search by a 250ms wall-clock window. Both returns run on frames, and a runner painting a few frames a second took longer than that, so the returning focus opened the view again. A `returning` flag now covers the close until the hand-back has actually run, on the same frame, and the constant is gone. `hold()` times the full-screen layout from the exit's own duration token, the one Alpine's x-transition holds `display` for, instead of waiting a frame or two for the exit's transitions to appear: `getAnimations()` is empty both before an engine creates them and after they end, and a loaded engine can leave a second between frames. The browser tests were racing the same slow machine, reproduced in a Linux container like the runner, with its Playwright Firefox and WebKitGTK and two cores kept busy: - The browser plugin retries every script and action with a one-second attempt until the budget runs out. An in-page sleep longer than that only ever passed on the last attempt, which is where the 47-50s tests came from, and a script that clicks was run again against a page that had moved on. `onceInPage()` runs such a script once however often it is retried; the long sleeps are plain retried conditions now. - Under load WebKitGTK paints no frame while a tight setTimeout loop runs, so a sample loop saw the start value and then nothing. `caughtMidExit()` samples on animation frames, for 2.5s. - "No animations running" is also true before an opening transition exists, so a close could be sampled from a scrim at 4% opacity. `settled()` waits two frames before it asks. The workflow no longer uploads failure screenshots: Gitea's artifact service timed out on every attempt, two minutes per red run, and the job logs are readable without it. Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit on macOS, and on Firefox and WebKitGTK in the Linux container under load, twice each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1114 lines
49 KiB
PHP
1114 lines
49 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()
|
|
->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>
|
|
<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));
|
|
|
|
$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>
|
|
<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();
|
|
|
|
$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>
|
|
<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.
|
|
$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>
|
|
<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";
|
|
|
|
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'");
|
|
});
|
|
|
|
/**
|
|
* 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')
|
|
->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>, 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. `aria-haspopup` is how the trigger says what it opens now, and
|
|
// the menu writes it in the same handler as that close — so waiting for it is waiting for the
|
|
// component to have heard of the new width. A press that lands before the media query's change
|
|
// event is delivered, which a loaded runner leaves room for, is closed again by it a moment on.
|
|
$page->resize(900, 800)
|
|
->assertAttribute($trigger, 'aria-haspopup', 'menu')
|
|
->click($trigger)
|
|
->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>
|
|
<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()
|
|
->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");
|
|
});
|
|
|
|
/** 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'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.
|
|
$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; })()");
|
|
});
|