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>
523 lines
26 KiB
PHP
523 lines
26 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\Blade;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
class BarsProbe extends Component
|
|
{
|
|
public string $tab = 'files';
|
|
|
|
public string $deferredTab = 'files';
|
|
|
|
public int $deferredUpdates = 0;
|
|
|
|
// Only runs while the server processes a request that carries $deferredTab along — never on
|
|
// the click alone, since a plain wire:model (no `.live`) only changes it locally until then.
|
|
public function updatedDeferredTab(): void
|
|
{
|
|
$this->deferredUpdates++;
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<x-app-bar variant="medium" title="Recipients" id="bar">
|
|
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" /></x-slot:navigation>
|
|
<x-slot:actions><x-theme-toggle id="toggle" /></x-slot:actions>
|
|
</x-app-bar>
|
|
|
|
<div style="display: flex; flex-direction: column; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
|
|
<p>tab: <span id="tab">{{ $tab }}</span></p>
|
|
|
|
<x-tabs id="share" wire:model.live="tab" :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People'], ['name' => 'locked', 'label' => 'Locked', 'disabled' => true], ['name' => 'activity', 'label' => 'Activity']]">
|
|
<x-tab name="files">Three files.</x-tab>
|
|
<x-tab name="people">Anna and Ben.</x-tab>
|
|
<x-tab name="activity">Two downloads.</x-tab>
|
|
</x-tabs>
|
|
|
|
<p>deferred tab: <span id="deferred-tab">{{ $deferredTab }}</span>, updates: <span id="deferred-updates">{{ $deferredUpdates }}</span></p>
|
|
|
|
<x-tabs id="deferred" wire:model="deferredTab" :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People']]">
|
|
<x-tab name="files">Deferred files.</x-tab>
|
|
<x-tab name="people">Deferred people.</x-tab>
|
|
</x-tabs>
|
|
|
|
<x-toolbar label="Formatting">
|
|
<button type="button" id="bold">B</button>
|
|
<button type="button" id="italic">I</button>
|
|
<button type="button" id="underline">U</button>
|
|
</x-toolbar>
|
|
|
|
<x-theme-toggle mode="cycle" id="cycle" />
|
|
<x-theme-toggle mode="picker" />
|
|
|
|
<x-section-nav :items="[['title' => 'Profile', 'url' => '#profile', 'active' => true], ['title' => 'Security', 'url' => '#security'], ['title' => 'Advanced notification settings', 'url' => '#notifications'], ['title' => 'Appearance', 'url' => '#appearance']]" no-wire-navigate />
|
|
|
|
<div style="height: 200vh"></div>
|
|
</div>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
function barsProbe()
|
|
{
|
|
Livewire::component('bars-probe', BarsProbe::class);
|
|
|
|
Route::middleware('web')->get('/bars-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:bars-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return ready(visit('/bars-probe'));
|
|
}
|
|
|
|
it('moves between tabs with the arrow keys, skipping disabled ones, and tells Livewire', function () {
|
|
$page = barsProbe()
|
|
->click('#share-people')
|
|
->assertSeeIn('#tab', 'people')
|
|
->assertSee('Anna and Ben.')
|
|
->assertAttribute('#share-people', 'tabindex', '0')
|
|
->assertAttribute('#share-files', 'tabindex', '-1');
|
|
|
|
// Each arrow key sends its own wire:model.live request and moves one tab further: pressOnce(),
|
|
// tests/Pest.php.
|
|
pressOnce($page)->keys('#share-people', 'ArrowRight');
|
|
|
|
$page->assertSeeIn('#tab', 'activity')
|
|
->assertScript("document.activeElement.id === 'share-activity'")
|
|
->assertScript("getComputedStyle(document.querySelector('#share-activity [data-md-tab-indicator]')).opacity === '1'");
|
|
|
|
$page->keys('#share-activity', 'Home')
|
|
->assertSeeIn('#tab', 'files')
|
|
->assertScript("document.activeElement.id === 'share-files'");
|
|
|
|
pressOnce($page)->keys('#share-files', 'ArrowLeft');
|
|
|
|
$page->assertSeeIn('#tab', 'activity');
|
|
});
|
|
|
|
it('sends a request per click with wire:model.live, but nothing until then with plain wire:model', function () {
|
|
// updatedDeferredTab() on the probe only runs while the server processes a request that
|
|
// carries $deferredTab with it — never from the click alone — so its counter (not a browser
|
|
// network hook, which a plain property commit does not reliably fire) tells whether a click
|
|
// reached the server at all.
|
|
$page = barsProbe();
|
|
|
|
// Plain wire:model is entangled locally: the click switches the panel and its aria-selected at
|
|
// once, but the property itself only reaches the server on the next request from anything else.
|
|
$page->click('#deferred-people')
|
|
->assertScript("document.querySelector('#deferred-people').getAttribute('aria-selected') === 'true'")
|
|
->assertSee('Deferred people.')
|
|
->assertSeeIn('#deferred-tab', 'files')
|
|
->assertSeeIn('#deferred-updates', '0');
|
|
|
|
// wire:model.live sends its own request, which carries the deferred property above along with
|
|
// it — the one request updates both.
|
|
$page->click('#share-people')
|
|
->assertSeeIn('#tab', 'people')
|
|
->assertSeeIn('#deferred-tab', 'people')
|
|
->assertSeeIn('#deferred-updates', '1')
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('collapses a medium app bar into its row as the page scrolls, and fades its small title in', function () {
|
|
$headline = "getComputedStyle(document.querySelector('#bar [data-md-app-bar-row] [data-md-app-bar-headline]')).opacity";
|
|
|
|
$page = barsProbe()
|
|
->assertScript("! document.querySelector('#bar').hasAttribute('data-md-collapsed')")
|
|
->assertScript("{$headline} === '0'");
|
|
|
|
$page->script('window.scrollTo(0, 600)');
|
|
|
|
$page->assertScript("document.querySelector('#bar').hasAttribute('data-md-collapsed') && document.querySelector('#bar').hasAttribute('data-md-scrolled')")
|
|
->assertScript("Math.round(document.querySelector('#bar [data-md-app-bar-row]').getBoundingClientRect().top) === 0")
|
|
->assertScript("document.querySelector('#bar').getBoundingClientRect().bottom <= 65")
|
|
->assertScript("{$headline} === '1'");
|
|
|
|
$page->script('window.scrollTo(0, 0)');
|
|
|
|
$page->assertScript("! document.querySelector('#bar').hasAttribute('data-md-collapsed')");
|
|
});
|
|
|
|
it('moves focus along a toolbar with the arrow keys', function () {
|
|
barsProbe()
|
|
->keys('#bold', 'ArrowRight')
|
|
->assertScript("document.activeElement.id === 'italic'")
|
|
->keys('#italic', 'End')
|
|
->assertScript("document.activeElement.id === 'underline'")
|
|
->keys('#underline', 'ArrowRight')
|
|
->assertScript("document.activeElement.id === 'bold'");
|
|
});
|
|
|
|
it('switches the theme from a toggle, a cycle and a picker', function () {
|
|
$page = barsProbe()
|
|
->click('label:has(input[name="material-theme"][value="light"])')
|
|
->assertScript("document.documentElement.dataset.theme === 'light'")
|
|
->assertAttribute('#toggle', 'aria-pressed', 'false');
|
|
|
|
// A theme toggle flips on every press: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#toggle');
|
|
|
|
$page->assertScript("document.documentElement.dataset.theme === 'dark'")
|
|
->assertAttribute('#toggle', 'aria-pressed', 'true')
|
|
->assertScript('document.querySelector(\'input[name="material-theme"][value="dark"]\').checked');
|
|
|
|
// A cycling toggle a retry would cycle twice: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#cycle');
|
|
|
|
$page->assertScript("document.documentElement.dataset.themeChoice === 'system'");
|
|
|
|
pressOnce($page)->click('#cycle');
|
|
|
|
$page->assertScript("document.documentElement.dataset.themeChoice === 'light'");
|
|
});
|
|
|
|
it('turns section tabs into a picker on a phone', function () {
|
|
$page = barsProbe()
|
|
->resize(400, 800)
|
|
->assertScript("getComputedStyle(document.querySelector('[data-md-section-nav] nav')).display === 'none'");
|
|
|
|
// Opens the picker menu over the button that triggers it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-section-nav-picker] button');
|
|
|
|
$page->assertScript("document.querySelector('[data-md-section-nav-picker] [popover]').matches(':popover-open')")
|
|
// A menu of places: the current section is the page, not a checked choice.
|
|
->assertAttribute('[data-md-section-nav-picker] [role="menuitem"][href="#profile"]', 'aria-current', 'page')
|
|
->assertScript("! document.querySelector('[data-md-section-nav-picker] [role=\"menuitem\"][href=\"#security\"]').hasAttribute('aria-current')");
|
|
});
|
|
|
|
it('shares the row width equally between the section nav\'s items, whatever their label', function () {
|
|
$widths = "[...document.querySelectorAll('[data-md-section-nav] [data-md-tabs-bar] > li')].map((li) => Math.round(li.getBoundingClientRect().width))";
|
|
|
|
barsProbe()
|
|
->assertScript("{$widths}.length === 4")
|
|
->assertScript("new Set({$widths}).size === 1")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
/**
|
|
* Plan step 46, M3 § Tabs: "Labels: single row by default; may wrap to a max second line if needed
|
|
* with truncation". Four sections are fixed tabs; at a 600px window a long title no longer fits its
|
|
* share, so it takes a second line (SealShare's "Two-Factor Auth" showed an ellipsis on one) inside
|
|
* its tab, the row's tabs keep one height, and the chosen tab's indicator stays on the divider.
|
|
*/
|
|
it('wraps a long section title to a second line in a fixed row, keeping the tabs one height and the indicator on the divider', function () {
|
|
$tabs = "[...document.querySelectorAll('[data-md-section-nav] [data-md-tab]')]";
|
|
$long = "document.querySelector('[data-md-section-nav] [data-md-tab][href=\"#notifications\"]')";
|
|
|
|
barsProbe()
|
|
->resize(600, 800)
|
|
->assertScript("getComputedStyle(document.querySelector('[data-md-section-nav] nav')).display !== 'none'")
|
|
->assertScript("{$tabs}.length === 4 && ! document.querySelector('[data-md-section-nav] [data-md-tabs-bar]').hasAttribute('data-md-scrollable')")
|
|
// Two lines of the label's own line height, not one truncated row.
|
|
->assertScript("(() => { const text = {$long}.querySelector('[data-md-tab-text]'); return Math.round(text.getBoundingClientRect().height / parseFloat(getComputedStyle(text).lineHeight)) === 2; })()")
|
|
// Inside its tab, both ways.
|
|
->assertScript("(() => { const tab = {$long}.getBoundingClientRect(); const text = {$long}.querySelector('[data-md-tab-text]').getBoundingClientRect(); return text.left >= tab.left - 0.5 && text.right <= tab.right + 0.5 && text.top >= tab.top - 0.5 && text.bottom <= tab.bottom + 0.5; })()")
|
|
// One height for the row, at least M3's 48px.
|
|
->assertScript("new Set({$tabs}.map((tab) => Math.round(tab.getBoundingClientRect().height))).size === 1 && {$tabs}[0].getBoundingClientRect().height >= 48")
|
|
// The chosen tab's indicator: shown, under its content, on the divider, within its tab.
|
|
->assertScript(<<<'JS'
|
|
(() => {
|
|
const tab = document.querySelector('[data-md-section-nav] [data-md-tab][aria-current="page"]');
|
|
const bar = tab.closest('[data-md-tabs-bar]');
|
|
const indicator = tab.querySelector('[data-md-tab-indicator]');
|
|
const box = tab.getBoundingClientRect();
|
|
const content = tab.querySelector('[data-md-tab-content]').getBoundingClientRect();
|
|
const line = indicator.getBoundingClientRect();
|
|
const divider = bar.getBoundingClientRect().bottom - parseFloat(getComputedStyle(bar).borderBottomWidth);
|
|
|
|
return getComputedStyle(indicator).opacity === '1'
|
|
&& line.top >= content.bottom - 0.5
|
|
&& Math.abs(line.bottom - divider) <= 1
|
|
&& line.left >= box.left - 0.5 && line.right <= box.right + 0.5;
|
|
})()
|
|
JS)
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
class AppBarOverflowProbe extends Component
|
|
{
|
|
public int $stars = 0;
|
|
|
|
public function star(): void
|
|
{
|
|
$this->stars++;
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<p>stars: <span id="stars">{{ $stars }}</span></p>
|
|
|
|
<x-app-bar title="Shares" id="bar" :actions="[
|
|
['label' => 'Search', 'icon' => 'search'],
|
|
['label' => 'Share', 'icon' => 'share'],
|
|
['label' => 'Star', 'icon' => 'star', 'wire:click' => 'star'],
|
|
['label' => 'Archive', 'icon' => 'archive'],
|
|
['label' => 'Delete', 'icon' => 'delete'],
|
|
]" />
|
|
|
|
<div style="height: 200vh"></div>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
function appBarOverflowProbe()
|
|
{
|
|
Livewire::component('app-bar-overflow-probe', AppBarOverflowProbe::class);
|
|
|
|
Route::middleware('web')->get('/app-bar-overflow-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:app-bar-overflow-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return ready(visit('/app-bar-overflow-probe'));
|
|
}
|
|
|
|
it('shows the app bar overflow at exactly 599 and 600px', function () {
|
|
$visible = "[...document.querySelectorAll('#bar [data-md-app-bar-action]')].filter((el) => getComputedStyle(el).display !== 'none').length";
|
|
|
|
appBarOverflowProbe()->resize(599, 800)
|
|
// Two icon buttons below 600px — the kept action and the overflow button.
|
|
->assertScript("{$visible} === 1")
|
|
->assertScript("getComputedStyle(document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"]')).display !== 'none'")
|
|
->assertScript("getComputedStyle(document.querySelector('#bar [data-md-app-bar-overflow=\"medium\"]')).display === 'none'")
|
|
->resize(600, 800)
|
|
// Four from 600px — three kept actions and the overflow button.
|
|
->assertScript("{$visible} === 3")
|
|
->assertScript("getComputedStyle(document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"]')).display === 'none'")
|
|
->assertScript("getComputedStyle(document.querySelector('#bar [data-md-app-bar-overflow=\"medium\"]')).display !== 'none'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('moves the keyboard through an app bar\'s overflow menu, and closes it once the width no longer shows it', function () {
|
|
$page = appBarOverflowProbe()->resize(599, 800);
|
|
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button');
|
|
|
|
$page->assertScript("document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')")
|
|
->keys(':focus', 'ArrowDown')
|
|
->assertScript("document.activeElement.textContent.includes('Star')")
|
|
->keys(':focus', 'ArrowDown')
|
|
->assertScript("document.activeElement.textContent.includes('Archive')")
|
|
->keys(':focus', 'Escape')
|
|
->assertScript("! document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')");
|
|
|
|
// Past the reopen guard of the Escape above (menu.js), so the click below is on its own.
|
|
$page->wait(0.3);
|
|
|
|
// Reopen it, then resize across 600px: the compact menu's trigger is no longer shown, so the
|
|
// menu it opened closes rather than staying open anchored to nothing.
|
|
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
|
|
pressOnce($page)->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button');
|
|
|
|
$page->assertScript("document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')")
|
|
->resize(900, 800)
|
|
->assertScript("! document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('runs a wire:click action from either the icon button or its overflow menu item', function () {
|
|
$page = appBarOverflowProbe()->resize(900, 800);
|
|
|
|
// A counter a retry would double: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#bar [data-md-app-bar-action] [aria-label="Star"]');
|
|
|
|
$page->assertSeeIn('#stars', '1');
|
|
|
|
$page->resize(599, 800);
|
|
|
|
// Opens the overflow menu, then a counter a retry would double: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button')
|
|
->click('[role="menuitem"]:has-text("Star")');
|
|
|
|
$page->assertSeeIn('#stars', '2');
|
|
});
|
|
|
|
function toolbarScaffoldProbe()
|
|
{
|
|
Route::middleware('web')->get('/toolbar-scaffold-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);">
|
|
<x-scaffold :destinations="[
|
|
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/toolbar-scaffold-probe', 'active' => true],
|
|
['title' => 'Sent', 'icon' => 'send', 'url' => '/toolbar-scaffold-probe'],
|
|
]">
|
|
<x-toolbar variant="docked" rounded place="bottom" label="Formatting" id="toolbar">
|
|
<x-button id="bold-btn" icon="format_bold" tooltip="Bold" />
|
|
<x-divider vertical />
|
|
<x-button id="italic-btn" icon="format_italic" tooltip="Italic" />
|
|
<x-button id="underline-btn" icon="format_underlined" tooltip="Underline" disabled />
|
|
</x-toolbar>
|
|
|
|
<div style="height: 200vh"></div>
|
|
</x-scaffold>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return ready(visit('/toolbar-scaffold-probe'));
|
|
}
|
|
|
|
it('keeps a docked toolbar clear of the navigation bar below medium', function () {
|
|
toolbarScaffoldProbe()->resize(599, 800)
|
|
->assertScript("getComputedStyle(document.querySelector('[data-md-scaffold-bar]')).display !== 'none'")
|
|
// A docked toolbar at place="bottom" sits above --material-bottom-bar, never on top of it.
|
|
->assertScript("document.querySelector('#toolbar').getBoundingClientRect().bottom <= document.querySelector('[data-md-scaffold-bar]').getBoundingClientRect().top")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('lifts a snackbar above a docked toolbar that sits on the navigation bar', function () {
|
|
$page = toolbarScaffoldProbe()->resize(599, 800);
|
|
|
|
$page->script("materialToast('Link copied', { timeout: 10000 })");
|
|
$page->wait(1);
|
|
|
|
// M3 nudges a snackbar "upward to avoid overlapping FABs/docked toolbars": the toolbar publishes
|
|
// --material-bottom-toolbar on the scaffold's root, where it sees the bar under it.
|
|
$page->assertScript("document.querySelector('[data-md-toast-snackbar]').getBoundingClientRect().bottom <= document.querySelector('#toolbar').getBoundingClientRect().top")
|
|
->assertScript("document.querySelector('#toolbar').getBoundingClientRect().bottom <= document.querySelector('[data-md-scaffold-bar]').getBoundingClientRect().top")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('lifts a snackbar above a floating toolbar placed at the bottom, without a scaffold', function () {
|
|
Route::middleware('web')->get('/floating-toolbar-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>
|
|
<x-toolbar place="bottom" label="Main" id="floating">
|
|
<x-button icon="upload" aria-label="Upload" />
|
|
</x-toolbar>
|
|
<x-toast />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
$page = ready(visit('/floating-toolbar-probe')->resize(393, 800));
|
|
|
|
$page->script("materialToast('Link copied', { timeout: 10000 })");
|
|
$page->wait(1);
|
|
|
|
// 16px apart: the snackbar's own margin above what the toolbar covers.
|
|
$page->assertScript("Math.round(document.querySelector('#floating').getBoundingClientRect().top - document.querySelector('[data-md-toast-snackbar]').getBoundingClientRect().bottom) === 16")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('rounds a docked toolbar and its divider from 840px, clear of the window\'s edges', function () {
|
|
$page = toolbarScaffoldProbe()->resize(800, 800);
|
|
|
|
$page->assertScript("getComputedStyle(document.querySelector('#toolbar')).borderRadius === '0px'")
|
|
->assertScript("Math.round(document.querySelector('#toolbar').getBoundingClientRect().left) === 0")
|
|
->assertScript("Math.round(document.querySelector('#toolbar').getBoundingClientRect().right) === Math.round(window.innerWidth)");
|
|
|
|
$page->resize(1024, 800)
|
|
->assertScript("getComputedStyle(document.querySelector('#toolbar')).borderRadius !== '0px'")
|
|
->assertScript("document.querySelector('#toolbar').getBoundingClientRect().left >= 16")
|
|
->assertScript("window.innerWidth - document.querySelector('#toolbar').getBoundingClientRect().right >= 16")
|
|
->assertScript("window.innerHeight - document.querySelector('#toolbar').getBoundingClientRect().bottom >= 16")
|
|
->assertScript("getComputedStyle(document.querySelector('#toolbar [role=\"separator\"]')).height === '40px'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('draws a disabled icon button inside a toolbar disabled, not in the toolbar\'s primary recolour', function () {
|
|
// toolbar.css recolours a standard (non-vibrant) toolbar's icon buttons to primary, but its
|
|
// selector excludes :disabled and [aria-disabled] so button.css's own disabled colour still
|
|
// wins there (resources/css/components/toolbar.css). A scratch element resolves each formula
|
|
// to a real rgb string, so the comparison does not have to guess the browser's serialization.
|
|
$primary = "(() => { const d = document.createElement('span'); d.style.color = 'var(--md-sys-color-primary)'; document.body.appendChild(d); const v = getComputedStyle(d).color; d.remove(); return v; })()";
|
|
$disabledColor = "(() => { const d = document.createElement('span'); d.style.color = 'color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)'; document.body.appendChild(d); const v = getComputedStyle(d).color; d.remove(); return v; })()";
|
|
|
|
toolbarScaffoldProbe()->resize(1024, 800)
|
|
->assertScript("getComputedStyle(document.querySelector('#bold-btn')).color === {$primary}")
|
|
->assertScript("getComputedStyle(document.querySelector('#underline-btn')).color !== {$primary}")
|
|
->assertScript("getComputedStyle(document.querySelector('#underline-btn')).color === {$disabledColor}")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
function tabsFirstPaintProbe(): void
|
|
{
|
|
Route::middleware('web')->get('/tabs-first-paint-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>
|
|
<x-tabs selected="people" :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People'], ['name' => 'activity', 'label' => 'Activity']]">
|
|
<x-tab name="files">Files panel</x-tab>
|
|
<x-tab name="people">People panel</x-tab>
|
|
<x-tab name="activity">Activity panel</x-tab>
|
|
</x-tabs>
|
|
|
|
<script>
|
|
// Runs while the document is still being parsed, before Alpine (further down
|
|
// the page) exists — what the first paint shows.
|
|
window.firstPaint = [...document.querySelectorAll('[role="tabpanel"]')].map((panel) => getComputedStyle(panel).display)
|
|
</script>
|
|
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
}
|
|
|
|
it('never flashes a tab panel before Alpine boots', function () {
|
|
tabsFirstPaintProbe();
|
|
|
|
// Only the chosen ("people") panel is on screen at the moment the inline script right after
|
|
// the panels runs — the server's own `style="display: none"`, not Alpine's x-show, is what
|
|
// kept the other two off screen up to here.
|
|
$page = visit('/tabs-first-paint-probe')
|
|
->assertScript("window.eval('window.firstPaint.join(\",\")') === 'none,block,none'");
|
|
|
|
ready($page, livewire: false)
|
|
->assertNoJavaScriptErrors();
|
|
});
|