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>
1673 lines
76 KiB
PHP
1673 lines
76 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\Blade;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
class OverlayProbe extends Component
|
|
{
|
|
public bool $confirming = false;
|
|
|
|
public ?int $deletingId = null;
|
|
|
|
public int $renders = 0;
|
|
|
|
public function touch(): void
|
|
{
|
|
$this->renders++;
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div style="display: flex; flex-direction: column; gap: var(--md-sys-measurement-space200); padding: var(--md-sys-measurement-space200);">
|
|
<p>confirming: <span id="confirming">{{ var_export($confirming, true) }}</span></p>
|
|
<p>deleting: <span id="deleting">{{ var_export($deletingId, true) }}</span></p>
|
|
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
|
|
|
<x-button label="Confirm" wire:click="$set('confirming', true)" />
|
|
<x-button label="Delete 7" wire:click="$set('deletingId', 7)" />
|
|
|
|
<x-modal wire:model="confirming" title="Are you sure?">
|
|
<x-slot:actions>
|
|
<x-button label="Re-render" wire:click="touch" />
|
|
</x-slot:actions>
|
|
</x-modal>
|
|
|
|
<x-modal wire:model="deletingId" title="Delete share?" />
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
class CollapseProbe extends Component
|
|
{
|
|
public bool $fineTuning = false;
|
|
|
|
public int $renders = 0;
|
|
|
|
public function touch(): void
|
|
{
|
|
$this->renders++;
|
|
}
|
|
|
|
public function expand(): void
|
|
{
|
|
$this->fineTuning = true;
|
|
}
|
|
|
|
public function collapse(): void
|
|
{
|
|
$this->fineTuning = false;
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div style="display: flex; flex-direction: column; gap: var(--md-sys-measurement-space200); padding: var(--md-sys-measurement-space200);">
|
|
<p>fine tuning: <span id="fine-tuning">{{ var_export($fineTuning, true) }}</span></p>
|
|
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
|
|
|
<x-button label="Re-render" wire:click="touch" />
|
|
<x-button label="Open from the server" wire:click="expand" />
|
|
<x-button label="Close from the server" wire:click="collapse" />
|
|
|
|
<x-collapse id="fine-tuning-collapse" title="Fine-tuning" wire:model="fineTuning">Zones and paces.</x-collapse>
|
|
|
|
<div x-data="{ advanced: false }">
|
|
<p>advanced: <span id="advanced" x-text="advanced"></span></p>
|
|
<x-button label="Close from Alpine" x-on:click="advanced = false" />
|
|
<x-collapse id="advanced-collapse" title="Advanced" x-model="advanced">Everything else.</x-collapse>
|
|
</div>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
function collapseProbe()
|
|
{
|
|
Livewire::component('collapse-probe', CollapseProbe::class);
|
|
|
|
Route::middleware('web')->get('/collapse-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:collapse-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return ready(visit('/collapse-probe'));
|
|
}
|
|
|
|
function overlayProbe()
|
|
{
|
|
Livewire::component('overlay-probe', OverlayProbe::class);
|
|
|
|
Route::middleware('web')->get('/overlay-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:overlay-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return ready(visit('/overlay-probe'));
|
|
}
|
|
|
|
function containment()
|
|
{
|
|
return ready(visit('/material/containment'));
|
|
}
|
|
|
|
it('writes back what closed means: false for a flag, null for an id', function () {
|
|
$page = overlayProbe();
|
|
|
|
$page->click('button:has-text("Confirm")')
|
|
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))");
|
|
|
|
$page->click('dialog[open] button:has-text("Re-render")')
|
|
->assertSeeIn('#renders', '1')
|
|
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))");
|
|
|
|
$page->keys('dialog[open] button:has-text("Re-render")', 'Escape')
|
|
->assertScript("! [...document.querySelectorAll('dialog')].some((d) => d.open)")
|
|
->assertSeeIn('#confirming', 'false');
|
|
|
|
$page->click('button:has-text("Delete 7")')
|
|
->assertSeeIn('#deleting', '7')
|
|
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Delete share?'))");
|
|
|
|
$page->script("document.querySelector('dialog[open]').dispatchEvent(new Event('cancel', { cancelable: true }))");
|
|
|
|
$page->assertSeeIn('#deleting', 'NULL');
|
|
});
|
|
|
|
it('closes a showcase dialog on Escape and gives focus back to its trigger', function () {
|
|
// Opened from the keyboard: Safari does not focus a button on click, so after a click there
|
|
// is nothing for the dialog to hand focus back to.
|
|
$page = containment();
|
|
|
|
$page->script("[...document.querySelectorAll('#containment button')].find((b) => b.textContent.trim() === 'Basic dialog').focus()");
|
|
|
|
$page->keys(':focus', 'Enter')
|
|
->assertScript("document.querySelector('#containment dialog').open");
|
|
|
|
$page->keys('#containment dialog[open] button:has-text("Cancel")', 'Escape')
|
|
->assertScript("! document.querySelector('#containment dialog').open")
|
|
->assertScript("document.activeElement.textContent.trim() === 'Basic dialog'");
|
|
});
|
|
|
|
it('slides a side sheet in, traps the page, and closes on the scrim', function () {
|
|
$sheet = "document.querySelector('#containment aside[role=\"dialog\"]')";
|
|
|
|
$page = containment()
|
|
->click('#containment button:has-text("Side sheet")')
|
|
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
|
|
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null");
|
|
|
|
// This sheet's own scrim: the standard side sheet above it on the page is a `[data-md-drawer]` too.
|
|
$page->script("{$sheet}.parentElement.querySelector(':scope > [aria-hidden=\"true\"]').click()");
|
|
|
|
$page->assertScript("getComputedStyle({$sheet}).display === 'none'")
|
|
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null");
|
|
});
|
|
|
|
it('dismisses a bottom sheet dragged down past a quarter of its height', function () {
|
|
$sheet = "document.querySelector('#containment section[role=\"dialog\"]')";
|
|
|
|
$page = containment()
|
|
->click('#containment button:has(> span:text-is("Bottom sheet"))')
|
|
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
|
|
->wait(0.5);
|
|
|
|
$page->script(<<<JS
|
|
(() => {
|
|
const handle = {$sheet}.querySelector('[data-md-bottom-sheet-handle]');
|
|
const box = handle.getBoundingClientRect();
|
|
const x = box.x + box.width / 2, y = box.y + box.height / 2;
|
|
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, button: 0 }));
|
|
window.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y + 400 }));
|
|
window.dispatchEvent(new PointerEvent('pointerup', { clientX: x, clientY: y + 400 }));
|
|
})()
|
|
JS);
|
|
|
|
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
|
|
});
|
|
|
|
class NestedDialogProbe extends Component
|
|
{
|
|
public bool $outer = false;
|
|
|
|
public bool $inner = false;
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<x-button label="Open outer" wire:click="$set('outer', true)" />
|
|
|
|
<x-modal wire:model="outer" title="Outer">
|
|
{{-- Short, and the trigger for the inner dialog right after it: both stay in
|
|
view without scrolling, so opening the inner dialog is not itself a
|
|
scroll of the outer one's body — the very thing this test rules out. --}}
|
|
<p>Outer body, short enough to fit without scrolling.</p>
|
|
|
|
<x-button label="Open inner" wire:click="$set('inner', true)" />
|
|
|
|
<x-modal wire:model="inner" title="Inner">
|
|
<p>Inner body, line one of several, independent of the outer dialog it opened from and its own paragraphs so the stack genuinely overflows the box regardless of the window's height.</p>
|
|
<p>Inner body, line two, long enough on its own to take real vertical room in the scrolling body.</p>
|
|
<p>Inner body, line three, long enough on its own to take real vertical room in the scrolling body.</p>
|
|
<p>Inner body, line four, long enough on its own to take real vertical room in the scrolling body.</p>
|
|
<p>Inner body, line five, long enough on its own to take real vertical room in the scrolling body.</p>
|
|
<p>Inner body, line six, long enough on its own to take real vertical room in the scrolling body.</p>
|
|
</x-modal>
|
|
</x-modal>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
function nestedDialogProbe()
|
|
{
|
|
Livewire::component('nested-dialog-probe', NestedDialogProbe::class);
|
|
|
|
Route::middleware('web')->get('/nested-dialog-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:nested-dialog-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return ready(visit('/nested-dialog-probe'));
|
|
}
|
|
|
|
it('marks a scrolling dialog\'s divider at the edge with more content, and a nested dialog keeps its own marks', function () {
|
|
// Short and narrow so the probes' paragraphs genuinely overflow their body.
|
|
$page = nestedDialogProbe()->resize(320, 300);
|
|
|
|
$page->click('button:has-text("Open outer")');
|
|
|
|
// Matched by each dialog's own title element, not its full (recursive) textContent: the
|
|
// outer dialog's textContent also contains the nested inner dialog's text, "Inner" included,
|
|
// so a substring match on the whole dialog would find the outer one for both.
|
|
$outer = "[...document.querySelectorAll('dialog[open]')].find((d) => d.querySelector('[data-md-modal-title]')?.textContent === 'Outer')";
|
|
$outerHead = "{$outer}.querySelector('[data-md-modal-head]')";
|
|
$outerBody = "{$outer}.querySelector('[data-md-modal-body]')";
|
|
|
|
// Scrolled to the top: nothing hidden above, so the head's rule stays off.
|
|
$page->assertScript("getComputedStyle({$outerHead}, '::after').opacity === '0'");
|
|
|
|
$page->click('button:has-text("Open inner")');
|
|
|
|
$inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.querySelector('[data-md-modal-title]')?.textContent === 'Inner')";
|
|
$innerHead = "{$inner}.querySelector('[data-md-modal-head]')";
|
|
$innerBody = "{$inner}.querySelector('[data-md-modal-body]')";
|
|
|
|
$page->assertScript("getComputedStyle({$innerHead}, '::after').opacity === '0'");
|
|
|
|
$page->script("{$innerBody}.scrollTop = {$innerBody}.scrollHeight");
|
|
|
|
// The scroll listener that marks the dialog runs off the native `scroll` event, which fires
|
|
// asynchronously after the assignment above.
|
|
$page->wait(0.2);
|
|
|
|
// The inner dialog's own body scrolled to its end marks its own head; the outer dialog, whose
|
|
// body was never touched, keeps its own mark off — each dialog watches only its own scroll.
|
|
$page->assertScript("getComputedStyle({$innerHead}, '::after').opacity === '1'")
|
|
->assertScript("getComputedStyle({$outerHead}, '::after').opacity === '0'");
|
|
});
|
|
|
|
it('shows a full-screen dialog\'s 56px phone bar below 600px, and the basic dialog above it', function () {
|
|
// Opened wide, before narrowing: the standard side sheet example further down the page opens
|
|
// by default too, and below 840px its scrim (correctly) covers the whole viewport and traps
|
|
// the page — narrowing first would leave nothing else clickable. The bar's visibility is pure
|
|
// CSS on the fullscreen flag, so which comes first makes no difference to what is asserted.
|
|
$page = containment();
|
|
|
|
$page->click('#containment button:has-text("Full screen on a phone")');
|
|
|
|
$page->resize(400, 800);
|
|
|
|
$dialog = "document.querySelector('#containment dialog[open]')";
|
|
$bar = "{$dialog}.querySelector('[data-md-modal-bar]')";
|
|
|
|
$page->assertScript("getComputedStyle({$bar}).display !== 'none'")
|
|
->assertScript("Math.round({$bar}.getBoundingClientRect().height) === 56");
|
|
|
|
$page->resize(900, 800)
|
|
->assertScript("getComputedStyle({$bar}).display === 'none'");
|
|
});
|
|
|
|
it('draws separator dividers whatever the scroll, on a dialog that fits without scrolling', function () {
|
|
$page = containment();
|
|
|
|
$page->click('#containment button:has-text("Always divided (separator)")');
|
|
|
|
$dialog = "document.querySelector('#containment dialog[open]')";
|
|
$head = "{$dialog}.querySelector('[data-md-modal-head]')";
|
|
$actions = "{$dialog}.querySelector('[data-md-modal-actions]')";
|
|
$body = "{$dialog}.querySelector('[data-md-modal-body]')";
|
|
|
|
// The body fits without scrolling, so an unmarked dialog would show neither rule.
|
|
$page->assertScript("{$body}.scrollHeight <= {$body}.clientHeight + 1")
|
|
->assertScript("getComputedStyle({$head}, '::after').opacity === '1'")
|
|
->assertScript("getComputedStyle({$actions}, '::before').opacity === '1'");
|
|
});
|
|
|
|
it('opens a dialog with no fade under reduced motion, but a real one without it', function () {
|
|
$dialog = "document.querySelector('#containment dialog[open]')";
|
|
|
|
// Without reduced motion: the spring is a real, non-zero transition.
|
|
$normal = containment()
|
|
->assertScript("! window.matchMedia('(prefers-reduced-motion: reduce)').matches");
|
|
|
|
$normal->click('#containment button:has-text("Basic dialog")');
|
|
|
|
$normal->assertScript("parseFloat(getComputedStyle({$dialog}).transitionDuration) > 0");
|
|
|
|
// With reduced motion (the plugin's own emulation, `visit(..., ['reducedMotion' => 'reduce'])`):
|
|
// tokens/motion.css takes the same spring's duration to zero, so there is no transition to run,
|
|
// and the dialog is at its final opacity at once rather than mid-fade.
|
|
$reduced = ready(visit('/material/containment', ['reducedMotion' => 'reduce']))
|
|
->assertScript("window.matchMedia('(prefers-reduced-motion: reduce)').matches");
|
|
|
|
$reduced->click('#containment button:has-text("Basic dialog")');
|
|
|
|
$reduced->assertScript("parseFloat(getComputedStyle({$dialog}).transitionDuration) === 0")
|
|
->assertScript("getComputedStyle({$dialog}).opacity === '1'");
|
|
});
|
|
|
|
class TextOnlyDialogProbe extends Component
|
|
{
|
|
public bool $open = false;
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<x-button label="Open" wire:click="$set('open', true)" />
|
|
|
|
<x-modal wire:model="open" title="Read this">
|
|
<p>Line one of a body with nothing else focusable inside it, so the dialog hands its first focus to the scrolling body itself: line two, line three, line four, line five, line six, line seven, line eight, line nine, line ten, line eleven, line twelve, line thirteen, line fourteen, line fifteen.</p>
|
|
</x-modal>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
function textOnlyDialogProbe()
|
|
{
|
|
Livewire::component('text-only-dialog-probe', TextOnlyDialogProbe::class);
|
|
|
|
Route::middleware('web')->get('/text-only-dialog-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:text-only-dialog-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return ready(visit('/text-only-dialog-probe'));
|
|
}
|
|
|
|
it('gives the scrolling body an inset focus ring when a text-only dialog opens from the keyboard', function () {
|
|
// A short, narrow window so the paragraph genuinely overflows the body before the dialog
|
|
// opens: showModal() picks its initial focus once, at open, from what is focusable then.
|
|
$page = textOnlyDialogProbe()->resize(320, 300);
|
|
|
|
$page->script("document.querySelector('button').focus()");
|
|
|
|
$page->keys(':focus', 'Enter');
|
|
|
|
$body = "document.querySelector('dialog[open] [data-md-modal-body]')";
|
|
|
|
$page->assertScript("document.activeElement.matches('[data-md-modal-body]')")
|
|
->assertScript("getComputedStyle({$body}).outlineStyle === 'solid'")
|
|
->assertScript("getComputedStyle({$body}).outlineWidth === '3px'")
|
|
->assertScript("getComputedStyle({$body}).outlineOffset === '-3px'");
|
|
|
|
// Outside Chrome the body holds the focus through a tabindex materialShowModal() gives it; a
|
|
// Livewire render keeps it (the body is wire:ignore.self), so the focus stays in the dialog.
|
|
$page->script('window.eval("Livewire.first().$refresh()")');
|
|
|
|
$page->assertScript("document.querySelector('dialog[open]') !== null")
|
|
->assertScript("document.activeElement.matches('[data-md-modal-body]')");
|
|
});
|
|
|
|
it('cycles a bottom sheet\'s preset heights from its handle, announcing each, and closes from the last', function () {
|
|
$page = containment();
|
|
|
|
$page->click('#containment button:has-text("Bottom sheet with preset heights")');
|
|
|
|
$sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')";
|
|
$handle = "{$sheet}.querySelector('[data-md-bottom-sheet-grip]')";
|
|
$announce = "{$sheet}.querySelector('[data-md-bottom-sheet-announce]')";
|
|
|
|
$page->assertScript("getComputedStyle({$sheet}).display !== 'none'")
|
|
->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'")
|
|
// Settled, not still sliding in: a grip pressed while the entry is still under way would
|
|
// read as unstable on a loaded runner, the way opening it does elsewhere in this file.
|
|
->assertScript(settled($sheet, subtree: true));
|
|
|
|
$gripSelector = '#containment [data-md-bottom-sheet-panel][data-md-preset] [data-md-bottom-sheet-grip]';
|
|
|
|
$page->click($gripSelector);
|
|
|
|
$page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '90dvh'")
|
|
->assertScript("{$announce}.textContent.trim() === 'Height 3 of 3'")
|
|
// The stop is written before the sheet has grown to it, so wait for the growth as well as
|
|
// for the entry above: the grip travels with the sheet's edge, and a press that lands
|
|
// while it is moving never reads as stable.
|
|
->assertScript(settled($sheet, subtree: true));
|
|
|
|
// From the last stop, activating the handle closes the sheet, as a handle with no stops does.
|
|
$page->click($gripSelector);
|
|
|
|
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
|
|
});
|
|
|
|
it('settles a dragged bottom sheet on the nearest preset height', function () {
|
|
$page = containment();
|
|
|
|
$page->click('#containment button:has-text("Bottom sheet with preset heights")');
|
|
|
|
$sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')";
|
|
|
|
$page->assertScript("getComputedStyle({$sheet}).display !== 'none'")
|
|
->wait(0.5);
|
|
|
|
// Drag up from the 50dvh stop a little way — not as far as 90dvh — and release: it settles
|
|
// back on 50dvh, the nearest stop, rather than creep on to the next one.
|
|
$page->script(<<<JS
|
|
(() => {
|
|
const handle = {$sheet}.querySelector('[data-md-bottom-sheet-handle]');
|
|
const box = handle.getBoundingClientRect();
|
|
const x = box.x + box.width / 2, y = box.y + box.height / 2;
|
|
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, button: 0 }));
|
|
window.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y - 40 }));
|
|
window.dispatchEvent(new PointerEvent('pointerup', { clientX: x, clientY: y - 40 }));
|
|
})()
|
|
JS);
|
|
|
|
$page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'");
|
|
});
|
|
|
|
it('is a standard side sheet from 840px, without a scrim or a focus trap, and the modal one below', function () {
|
|
$page = containment()->resize(1000, 800);
|
|
|
|
$sheet = "document.querySelector('#containment aside[data-md-side]')";
|
|
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-drawer-scrim]')";
|
|
$toggle = '#containment button:has-text("Show or hide the filters")';
|
|
|
|
// From 840px it opens co-planar (the example starts `open: true`): no scrim shown, and the
|
|
// rest of the page stays interactive.
|
|
$page->assertScript("getComputedStyle({$sheet}).position === 'relative'")
|
|
->assertScript("getComputedStyle({$scrim}).display === 'none'")
|
|
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null")
|
|
->assertScript("! {$sheet}.hasAttribute('inert')");
|
|
|
|
$page->click($toggle);
|
|
|
|
$page->assertScript("getComputedStyle({$sheet}).opacity === '0'");
|
|
|
|
// Below 840px the same sheet becomes the modal one: fixed, with a scrim, and the page inert.
|
|
$page->resize(700, 800)
|
|
->click($toggle);
|
|
|
|
$page->assertScript("getComputedStyle({$sheet}).position === 'fixed'")
|
|
->assertScript("getComputedStyle({$scrim}).display !== 'none'")
|
|
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null");
|
|
});
|
|
|
|
it('gives a closing standard side sheet\'s room back to the content beside it as it leaves, from 840px', function () {
|
|
$sheet = "document.querySelector('#containment aside[data-md-side]')";
|
|
$root = "{$sheet}.parentElement";
|
|
$column = "{$root}.previousElementSibling";
|
|
$toggle = "Array.from(document.querySelectorAll('#containment button')).find((button) => button.textContent.trim() === 'Show or hide the filters')";
|
|
|
|
// The example starts open, and stands open from the first frame Alpine draws: full 400px, fully
|
|
// shown, nothing on it animating.
|
|
$page = containment()->resize(1000, 800)
|
|
->assertScript("{$root}.hasAttribute('data-md-open') && Math.round({$root}.getBoundingClientRect().width) === 400 && getComputedStyle({$sheet}).opacity === '1'")
|
|
->assertScript(settled($root, subtree: true))
|
|
// Only once the view has settled, two frames after Alpine starts, which WebKit can still
|
|
// be waiting for here: until then the sheet has no transitions, on purpose, and closes at
|
|
// once.
|
|
->assertScript("{$root}.hasAttribute('data-md-drawer-settled')");
|
|
|
|
// Closed and sampled in one round trip: the sheet's root caught part-way between its width
|
|
// and none, still in the layout, while the column beside it has grown part of the way — not
|
|
// the whole sheet and its gap handed back in one jump at the end.
|
|
$midExit = $page->script(caughtMidExit(
|
|
<<<JS
|
|
const root = {$root}
|
|
const column = {$column}
|
|
const open = { root: root.getBoundingClientRect().width, column: column.getBoundingClientRect().width, row: root.parentElement.getBoundingClientRect().width }
|
|
{$toggle}.click()
|
|
JS,
|
|
<<<'JS'
|
|
(() => {
|
|
const width = root.getBoundingClientRect().width
|
|
const columnWidth = column.getBoundingClientRect().width
|
|
|
|
return ! root.hasAttribute('data-md-drawer-collapsed') && getComputedStyle(root).display !== 'none' && width > 1 && width < open.root - 1 && columnWidth > open.column + 1 && columnWidth < open.row - 1
|
|
})()
|
|
JS,
|
|
));
|
|
|
|
expect($midExit)->toBeTrue();
|
|
|
|
// Settled: out of the layout, and the column is the whole row, gap included.
|
|
$page->assertScript("{$root}.hasAttribute('data-md-drawer-collapsed') && getComputedStyle({$root}).display === 'none'")
|
|
->assertScript("Math.abs({$column}.getBoundingClientRect().width - {$root}.parentElement.getBoundingClientRect().width) < 1");
|
|
|
|
// Reopened part-way through its exit, it ends open, not collapsed. Each script stays well under
|
|
// the browser plugin's one-second call timeout, past which it runs the script a second time.
|
|
$page->script("{$toggle}.click()");
|
|
$page->wait(0.6)
|
|
->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.getAnimations({ subtree: true }).length === 0");
|
|
|
|
$page->script(<<<JS
|
|
(async () => {
|
|
{$toggle}.click()
|
|
await new Promise((resolve) => setTimeout(resolve, 60))
|
|
{$toggle}.click()
|
|
})()
|
|
JS);
|
|
|
|
$page->wait(0.9)
|
|
->assertScript("{$root}.hasAttribute('data-md-open') && ! {$root}.hasAttribute('data-md-drawer-collapsed') && Math.abs({$root}.getBoundingClientRect().width - 400) < 1 && getComputedStyle({$sheet}).opacity === '1'");
|
|
});
|
|
|
|
/**
|
|
* Layers stacked every way a page stacks them: a modal sheet holding a menu, a select, a searchable
|
|
* choice and a sheet of its own, which opens a dialog and a second sheet rendered elsewhere on the
|
|
* page; the dialog holding a menu, a select, a searchable choice and a sheet.
|
|
*/
|
|
function layersProbe(): mixed
|
|
{
|
|
Route::middleware('web')->get('/layers-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);">
|
|
<main>
|
|
<div x-data="{ open: false }">
|
|
<button type="button" id="open-sheet" x-on:click="open = true">Open the session</button>
|
|
<x-drawer id="sheet" title="Session">
|
|
<button type="button" id="open-dialog" x-on:click="$dispatch('open-probe-dialog')">Set back</button>
|
|
<button type="button" id="open-beside" x-on:click="$dispatch('open-probe-beside')">Swap</button>
|
|
<x-menu label="More">
|
|
<x-slot:trigger><button type="button" id="open-menu">More</button></x-slot:trigger>
|
|
<x-menu-item label="Copy" />
|
|
<x-menu-item label="Move" />
|
|
</x-menu>
|
|
<x-select id="sheet-select" label="Expires" :options="[['id' => '1', 'name' => '1 hour'], ['id' => '24', 'name' => '1 day']]" />
|
|
<x-choices id="sheet-choices" label="Sport" searchable :options="[['id' => 'run', 'name' => 'Run'], ['id' => 'ride', 'name' => 'Ride']]" />
|
|
<div x-data="{ open: false }">
|
|
<button type="button" id="open-inner" x-on:click="open = true">Notes</button>
|
|
<x-drawer id="inner-sheet" title="Notes"><input id="inner-field" aria-label="Note" /></x-drawer>
|
|
</div>
|
|
</x-drawer>
|
|
</div>
|
|
|
|
<div x-data="{ open: false }" x-on:open-probe-beside.window="open = true">
|
|
<x-drawer id="beside-sheet" title="Swap"><input id="beside-field" aria-label="Swap with" /></x-drawer>
|
|
</div>
|
|
</main>
|
|
|
|
<div x-data="{ open: false }" x-on:open-probe-dialog.window="open = true">
|
|
<x-modal id="dialog" title="Set back the plan">
|
|
<input id="dialog-first" aria-label="Weeks" />
|
|
<input id="dialog-second" aria-label="Reason" />
|
|
<x-menu label="Dialog menu">
|
|
<x-slot:trigger><button type="button" id="open-dialog-menu">Options</button></x-slot:trigger>
|
|
<x-menu-item label="Keep" />
|
|
</x-menu>
|
|
<x-select id="dialog-select" label="Expires" :options="[['id' => '1', 'name' => '1 hour'], ['id' => '24', 'name' => '1 day']]" />
|
|
<x-choices id="dialog-choices" label="Sport" searchable :options="[['id' => 'run', 'name' => 'Run'], ['id' => 'ride', 'name' => 'Ride']]" />
|
|
<div x-data="{ open: false }">
|
|
<button type="button" id="open-dialog-sheet" x-on:click="open = true">Details</button>
|
|
<x-drawer id="dialog-sheet" title="Details"><input id="dialog-sheet-field" aria-label="Detail" /></x-drawer>
|
|
</div>
|
|
</x-modal>
|
|
</div>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return ready(visit('/layers-probe')->resize(1000, 800), livewire: false);
|
|
}
|
|
|
|
function sheetOpen(string $id): string
|
|
{
|
|
return "document.getElementById('{$id}').closest('[data-md-drawer]').hasAttribute('data-md-open')";
|
|
}
|
|
|
|
function hiddenFromAssistiveTech(string $id): string
|
|
{
|
|
return "(document.getElementById('{$id}').closest('[aria-hidden=\"true\"]') !== null)";
|
|
}
|
|
|
|
it('closes only a dialog opened over a modal sheet on Escape, readable and tabbable while it is open', function () {
|
|
$page = layersProbe();
|
|
|
|
$page->click('#open-sheet')->assertScript(sheetOpen('sheet'));
|
|
$page->click('#open-dialog')->assertScript("document.getElementById('dialog').open");
|
|
|
|
// Rendered outside the sheet, the dialog is not inside what the sheet hides, and the sheet's
|
|
// focus trap does not take the Tab back to the sheet.
|
|
$page->assertScript('! '.hiddenFromAssistiveTech('dialog'));
|
|
$page->click('#dialog-first');
|
|
$page->keys('#dialog-first', 'Tab')->assertScript("document.activeElement.id === 'dialog-second'");
|
|
|
|
$page->keys('#dialog-second', 'Escape')
|
|
->assertScript("! document.getElementById('dialog').open")
|
|
->assertScript(sheetOpen('sheet'))
|
|
// Back under the sheet, the dialog's part of the page is hidden again, and shown once the
|
|
// sheet closes too.
|
|
->assertScript(hiddenFromAssistiveTech('dialog'));
|
|
|
|
$page->script("document.getElementById('open-dialog').focus()");
|
|
$page->keys('#open-dialog', 'Escape')
|
|
->assertScript('! '.sheetOpen('sheet'))
|
|
->assertScript('! '.hiddenFromAssistiveTech('dialog'))
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('closes only the menu, select list or searchable choice open inside a modal sheet on Escape', function () {
|
|
$page = layersProbe();
|
|
|
|
$page->click('#open-sheet')->assertScript(sheetOpen('sheet'));
|
|
|
|
$page->click('#open-menu')->assertScript("document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')");
|
|
$page->keys(':focus', 'Escape')
|
|
->assertScript("! document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')")
|
|
->assertScript(sheetOpen('sheet'));
|
|
|
|
$page->click('#sheet-choices')->assertScript("document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')");
|
|
$page->keys('#sheet-choices', 'Escape')
|
|
->assertScript("! document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')")
|
|
->assertScript(sheetOpen('sheet'));
|
|
|
|
// A customizable select's list, where the browser has one: elsewhere there is no list of the
|
|
// page's own open over the sheet.
|
|
if ($page->script("CSS.supports('appearance', 'base-select')") === true) {
|
|
$page->click('#sheet-select')->assertScript("document.getElementById('sheet-select').matches(':open')");
|
|
$page->keys(':focus', 'Escape')
|
|
->assertScript("! document.getElementById('sheet-select').matches(':open')")
|
|
->assertScript(sheetOpen('sheet'));
|
|
}
|
|
|
|
$page->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('closes only the sheet on top on Escape, inside the first or beside it, and keeps the one beside it readable', function () {
|
|
$page = layersProbe();
|
|
|
|
$page->click('#open-sheet')->assertScript(sheetOpen('sheet'));
|
|
|
|
$page->click('#open-inner')->assertScript(sheetOpen('inner-sheet'));
|
|
$page->click('#inner-field');
|
|
$page->keys('#inner-field', 'Escape')
|
|
->assertScript('! '.sheetOpen('inner-sheet'))
|
|
->assertScript(sheetOpen('sheet'));
|
|
|
|
$page->click('#open-beside')->assertScript(sheetOpen('beside-sheet'))
|
|
->assertScript('! '.hiddenFromAssistiveTech('beside-sheet'));
|
|
$page->click('#beside-field');
|
|
$page->keys('#beside-field', 'Escape')
|
|
->assertScript('! '.sheetOpen('beside-sheet'))
|
|
->assertScript(sheetOpen('sheet'))
|
|
->assertScript(hiddenFromAssistiveTech('beside-sheet'))
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('closes only the sheet, searchable choice, menu or select list open inside a dialog on Escape', function () {
|
|
$page = layersProbe();
|
|
|
|
$page->script("window.dispatchEvent(new CustomEvent('open-probe-dialog'))");
|
|
$page->assertScript("document.getElementById('dialog').open");
|
|
|
|
$page->click('#open-dialog-sheet')->assertScript(sheetOpen('dialog-sheet'));
|
|
$page->click('#dialog-sheet-field');
|
|
$page->keys('#dialog-sheet-field', 'Escape')
|
|
->assertScript('! '.sheetOpen('dialog-sheet'))
|
|
->assertScript("document.getElementById('dialog').open");
|
|
|
|
$page->click('#dialog-choices')->assertScript("document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')");
|
|
$page->keys('#dialog-choices', 'Escape')
|
|
->assertScript("! document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')")
|
|
->assertScript("document.getElementById('dialog').open");
|
|
|
|
$page->click('#open-dialog-menu')->assertScript("document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')");
|
|
$page->keys(':focus', 'Escape')
|
|
->assertScript("! document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')")
|
|
->assertScript("document.getElementById('dialog').open");
|
|
|
|
if ($page->script("CSS.supports('appearance', 'base-select')") === true) {
|
|
$page->click('#dialog-select')->assertScript("document.getElementById('dialog-select').matches(':open')");
|
|
$page->keys(':focus', 'Escape')
|
|
->assertScript("! document.getElementById('dialog-select').matches(':open')")
|
|
->assertScript("document.getElementById('dialog').open");
|
|
}
|
|
|
|
$page->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
it('draws a standard side sheet that starts open standing open, without growing it in on load', function () {
|
|
// Slow tokens, so a load-time entry would still be running when the page is first read.
|
|
Route::middleware('web')->get('/standard-sheet-load-probe', fn () => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<link rel="icon" href="data:,">
|
|
<x-theme-script />
|
|
@vite(config('livewire-material.showcase.vite'))
|
|
@livewireStyles
|
|
<style>:root { --md-sys-motion-spatial-default-duration: 3000ms; --md-sys-motion-effects-default-duration: 3000ms; }</style>
|
|
</head>
|
|
<body>
|
|
<x-row align="stretch" gap="space300" x-data="{ open: true }">
|
|
<x-stack style="flex: 1 1 0%; min-width: 0;">
|
|
<button type="button" id="toggle-sheet" x-on:click="open = ! open">Show or hide the filters</button>
|
|
</x-stack>
|
|
<x-drawer standard title="Filters">Filter the list</x-drawer>
|
|
</x-row>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
$root = "document.querySelector('[data-md-drawer]')";
|
|
|
|
$page = visit('/standard-sheet-load-probe')->resize(1000, 800)->waitForEvent('networkidle')
|
|
->assertScript("typeof window.Alpine !== 'undefined' && {$root}.hasAttribute('data-md-drawer-settled')")
|
|
->assertScript("{$root}.hasAttribute('data-md-open') && Math.abs({$root}.getBoundingClientRect().width - 400) < 1")
|
|
->assertScript("{$root}.getAnimations({ subtree: true }).length === 0")
|
|
->assertScript("getComputedStyle({$root}.querySelector('[data-md-drawer-sheet]')).opacity === '1'");
|
|
|
|
// Settled, it still moves when someone closes it.
|
|
$page->script("document.getElementById('toggle-sheet').click()");
|
|
$page->assertScript("{$root}.getAnimations({ subtree: true }).length > 0");
|
|
});
|
|
|
|
it('sticks a standard side sheet under the top safe area and a scaffold\'s sticky app bar, clear of the bottom one', function () {
|
|
Route::middleware('web')->get('/standard-sheet-safe-probe/{bar}', fn (string $bar) => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<link rel="icon" href="data:,">
|
|
<x-theme-script />
|
|
@vite(config('livewire-material.showcase.vite'))
|
|
@livewireStyles
|
|
<style>:root { --material-safe-top: 47px; --material-safe-bottom: 34px; }</style>
|
|
</head>
|
|
<body>
|
|
<x-scaffold :destinations="[['title' => 'Plans', 'icon' => 'event', 'url' => '/standard-sheet-safe-probe/none', 'active' => true]]">
|
|
@if ($bar !== 'none')
|
|
<x-slot:top><x-app-bar title="Plan" :variant="$bar" /></x-slot:top>
|
|
@endif
|
|
|
|
<x-row align="stretch" gap="space300" x-data="{ open: true }">
|
|
<x-stack style="flex: 1 1 0%; min-width: 0;"><p style="block-size: 3000px">The plan</p></x-stack>
|
|
<x-drawer standard title="Filters" id="safe-sheet">Filter the list</x-drawer>
|
|
</x-row>
|
|
</x-scaffold>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE, ['bar' => $bar]));
|
|
|
|
$sheet = "document.getElementById('safe-sheet')";
|
|
$top = fn (string $element): string => "Math.round({$element}.getBoundingClientRect().top)";
|
|
|
|
// Where the sheet sticks once the page has scrolled: below the status bar alone, below a small
|
|
// bar's 64px row and the safe area it pads itself with, and below the 64px a large bar
|
|
// collapses to; its foot at the window's, with the bottom inset inside its padding.
|
|
foreach (['none' => 47, 'small' => 111, 'large' => 64] as $bar => $expected) {
|
|
$page = visit("/standard-sheet-safe-probe/{$bar}")->resize(1000, 800)->waitForEvent('networkidle')
|
|
->assertScript("typeof window.Alpine !== 'undefined' && {$sheet}.parentElement.hasAttribute('data-md-drawer-settled')");
|
|
|
|
$page->script('window.scrollTo(0, 600)');
|
|
|
|
$page->assertScript("{$top($sheet.'.parentElement')} === {$expected}")
|
|
->assertScript("Math.round({$sheet}.getBoundingClientRect().bottom) === 800")
|
|
->assertScript("{$top($sheet.".querySelector('[data-md-drawer-close]')")} >= {$expected}")
|
|
->assertScript("getComputedStyle({$sheet}).paddingBottom === '58px'");
|
|
|
|
if ($bar !== 'none') {
|
|
$page->assertScript("Math.round(document.querySelector('[data-md-app-bar-row]').getBoundingClientRect().bottom) <= {$expected}");
|
|
}
|
|
}
|
|
});
|
|
|
|
class StandardSheetsProbe extends Component
|
|
{
|
|
public bool $filters = false;
|
|
|
|
public ?int $session = 7;
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<x-row id="sheets-row" align="stretch" gap="space300" x-data="{ open: false }">
|
|
<x-stack id="sheets-column" style="flex: 1 1 0%; min-width: 0;"><p>The session list</p></x-stack>
|
|
<x-drawer standard wire:model="filters" title="Filters">Narrow the list down</x-drawer>
|
|
<x-drawer standard wire:model="session" title="Session">The open session</x-drawer>
|
|
<x-drawer standard title="Notes">Nobody opened these</x-drawer>
|
|
</x-row>
|
|
<script>
|
|
window.firstPaint = {
|
|
column: document.getElementById('sheets-column').getBoundingClientRect().width,
|
|
alpine: typeof window.Alpine,
|
|
};
|
|
</script>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
it('draws standard side sheets as the server renders them from the first paint, closed ones out of the row and an open one standing', function () {
|
|
Livewire::component('standard-sheets-probe', StandardSheetsProbe::class);
|
|
|
|
Route::middleware('web')->get('/standard-sheets-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>
|
|
<livewire:standard-sheets-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
// Playwright's window is 1280px wide from the first paint, in the band where the sheets stand
|
|
// in the row. The column measured before Alpine starts is the column once it has settled: two
|
|
// closed sheets (one bound to Livewire, one to Alpine) take no gap, and the open one its width.
|
|
$page = visit('/standard-sheets-probe')->waitForEvent('networkidle')
|
|
->assertScript("window.eval('window.firstPaint.alpine') === 'undefined'")
|
|
->assertScript("[...document.querySelectorAll('[data-md-drawer]')].every((root) => root.hasAttribute('data-md-drawer-settled'))");
|
|
|
|
$settled = $page->script("document.getElementById('sheets-column').getBoundingClientRect().width");
|
|
|
|
expect(abs($page->script('window.firstPaint.column') - $settled))->toBeLessThan(1)
|
|
->and($page->script("Math.round([...document.querySelectorAll('[data-md-drawer]')][1].getBoundingClientRect().width)"))->toBe(400);
|
|
});
|
|
|
|
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
|
|
$page = containment();
|
|
|
|
$row = '#containment [data-md-card][data-md-list-row]:not([data-md-list-actionable])';
|
|
|
|
$page->script("window.__opened = 0; document.querySelector('{$row} [data-md-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
|
|
|
|
$page->click("{$row} p")
|
|
->assertScript('window.__opened === 1');
|
|
|
|
$page->click("{$row} button:has-text(\"Copy link\")")
|
|
->assertScript('window.__opened === 1');
|
|
});
|
|
|
|
it('makes a directly actionable card the one tab stop, answering Enter', function () {
|
|
$card = '#containment [data-md-card][data-md-list-actionable]';
|
|
|
|
$page = containment();
|
|
|
|
$page->script("window.__opened = 0; document.querySelector('{$card} [data-md-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
|
|
|
|
$page->script("document.querySelector('{$card}').focus()");
|
|
|
|
$page->assertScript("document.activeElement.matches('{$card}')");
|
|
|
|
$page->keys($card, 'Enter')
|
|
->assertScript('window.__opened === 1');
|
|
});
|
|
|
|
it('binds a collapse to a Livewire property both ways', function () {
|
|
$collapse = "document.querySelector('#fine-tuning-collapse')";
|
|
|
|
$page = collapseProbe()
|
|
->assertScript("{$collapse}.open === false");
|
|
|
|
$page->click('#fine-tuning-collapse summary')
|
|
->assertScript("{$collapse}.open === true")
|
|
->click('button:has-text("Re-render")')
|
|
->assertSeeIn('#renders', '1')
|
|
->assertSeeIn('#fine-tuning', 'true')
|
|
->assertScript("{$collapse}.open === true");
|
|
|
|
$page->click('button:has-text("Close from the server")')
|
|
->assertSeeIn('#fine-tuning', 'false')
|
|
->assertScript("{$collapse}.open === false");
|
|
|
|
$page->click('button:has-text("Open from the server")')
|
|
->assertSeeIn('#fine-tuning', 'true')
|
|
->assertScript("{$collapse}.open === true");
|
|
});
|
|
|
|
it('binds a collapse to an Alpine property both ways', function () {
|
|
$collapse = "document.querySelector('#advanced-collapse')";
|
|
|
|
$page = collapseProbe()
|
|
->assertScript("{$collapse}.open === false");
|
|
|
|
$page->click('#advanced-collapse summary')
|
|
->assertScript("{$collapse}.open === true")
|
|
->assertSeeIn('#advanced', 'true');
|
|
|
|
$page->click('button:has-text("Close from Alpine")')
|
|
->assertSeeIn('#advanced', 'false')
|
|
->assertScript("{$collapse}.open === false");
|
|
});
|
|
|
|
/**
|
|
* The side sheet and the bottom sheet close the same way — a fade on the scrim, a slide on the
|
|
* sheet itself — differing only in the selector each renders under, the click that opens it, the
|
|
* scrim's own hook attribute, the dimension it slides across, the `translate` component that
|
|
* carries the offset (a single value for the side sheet's horizontal slide, the second of a
|
|
* `translate: x y` pair for the bottom sheet's vertical one) and whether that offset is read
|
|
* signed: the side sheet's can slide either way (a right-to-left page), so its mid-slide sample
|
|
* takes `Math.abs()`; the bottom sheet only ever slides down, so its own stays signed.
|
|
*/
|
|
dataset('containment sheets', [
|
|
'side sheet' => [
|
|
"document.querySelector('#containment aside[role=\"dialog\"]')",
|
|
'#containment button:has-text("Side sheet")',
|
|
'data-md-drawer-scrim',
|
|
'width',
|
|
'',
|
|
true,
|
|
],
|
|
'bottom sheet' => [
|
|
"document.querySelector('#containment section[role=\"dialog\"]')",
|
|
'#containment button:has(> span:text-is("Bottom sheet"))',
|
|
'data-md-bottom-sheet-scrim',
|
|
'height',
|
|
".split(' ')[1]",
|
|
false,
|
|
],
|
|
]);
|
|
|
|
it('fades the sheet\'s scrim out on close, rather than making it vanish', function (string $sheet, string $trigger, string $scrimAttr) {
|
|
$scrim = "{$sheet}.parentElement.querySelector(':scope > [{$scrimAttr}]')";
|
|
|
|
$page = containment()
|
|
->click($trigger)
|
|
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
|
|
->assertScript("getComputedStyle({$scrim}).opacity === '1'")
|
|
// Settled, not still fading in: an engine reads a transition's end value until its next
|
|
// refresh tick, so an opacity of 1 alone does not say the entry has run — and a close
|
|
// sampled from there has nothing left to fade.
|
|
->assertScript(settled($scrim));
|
|
|
|
slowMotion($page);
|
|
|
|
// Triggering the close and sampling for a mid-fade opacity in the same round trip: a round
|
|
// trip apiece for a separate `script` and `assertScript` already costs real time, easily as
|
|
// much as the fade itself, so a click, then a later separate read, can just as easily land
|
|
// before the fade starts or after it ends. This samples once a frame, in the page itself, for
|
|
// whether it was ever caught strictly between fully shown and fully hidden, rather than simply
|
|
// gone at once (caughtMidExit() in tests/Pest.php).
|
|
$midFade = $page->script(caughtMidExit(
|
|
"{$scrim}.click()",
|
|
"(() => { const opacity = parseFloat(getComputedStyle({$scrim}).opacity); return opacity > 0.02 && opacity < 0.98 })()",
|
|
));
|
|
|
|
expect($midFade)->toBeTrue();
|
|
|
|
$page->assertScript("getComputedStyle({$scrim}).display === 'none'");
|
|
})->with('containment sheets');
|
|
|
|
it('slides the sheet out on close, rather than making it vanish', function (string $sheet, string $trigger, string $scrimAttr, string $dimension, string $axis, bool $absolute) {
|
|
$scrim = "{$sheet}.parentElement.querySelector(':scope > [{$scrimAttr}]')";
|
|
$offset = "(parseFloat(getComputedStyle({$sheet}).translate{$axis}) || 0)";
|
|
$sample = $absolute ? "Math.abs(parseFloat(style.translate{$axis}) || 0)" : "(parseFloat(style.translate{$axis}) || 0)";
|
|
|
|
$page = containment()
|
|
->click($trigger)
|
|
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
|
|
->assertScript("Math.abs({$offset}) < 0.5")
|
|
// Settled, not still sliding in: see the scrim's fade above.
|
|
->assertScript(settled($sheet));
|
|
|
|
slowMotion($page);
|
|
|
|
// Sampled in the page, in the same round trip as the close, as the scrim's fade is above: the
|
|
// sheet must be caught still displayed and part of the way to its closed offset (one sheet's
|
|
// own size past its edge), not gone at once — which is what Firefox showed while the exit
|
|
// leaned on `allow-discrete` holding `display`. The sheet closes on the emphasized-accelerate
|
|
// easing, which stays close to its start for a good part of its run, so it takes a few of the
|
|
// stretched exit's frames before the offset has moved past a pixel.
|
|
$midSlide = $page->script(caughtMidExit(
|
|
<<<JS
|
|
const size = {$sheet}.getBoundingClientRect().{$dimension}
|
|
{$scrim}.click()
|
|
JS,
|
|
<<<JS
|
|
(() => {
|
|
const style = getComputedStyle({$sheet})
|
|
const offset = {$sample}
|
|
|
|
return style.display !== 'none' && offset > 1 && offset < size - 1
|
|
})()
|
|
JS,
|
|
));
|
|
|
|
expect($midSlide)->toBeTrue();
|
|
|
|
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
|
|
})->with('containment sheets');
|
|
|
|
it('slides the side sheet in from its own edge in a right-to-left page', function () {
|
|
Route::middleware('web')->get('/drawer-rtl-probe', fn () => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html dir="rtl">
|
|
<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);">
|
|
<div x-data="{ open: false }" style="padding: var(--md-sys-measurement-space200);">
|
|
<x-button label="Open" x-on:click="open = true" />
|
|
<x-drawer title="Filters">Narrow the list down.</x-drawer>
|
|
</div>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
$page = ready(visit('/drawer-rtl-probe'), livewire: false);
|
|
|
|
$sheet = "document.querySelector('aside[data-md-side]')";
|
|
|
|
$page->click('button:has-text("Open")');
|
|
|
|
// `side="end"` (the default) is the trailing edge — the *left* of a right-to-left page — so it
|
|
// slides in from off the left, not the right (the CSS mirror reads `[dir='rtl']` rather than
|
|
// `:dir(rtl)`, because Vite's own minifier rewrites that to a `:lang()` list): caught well
|
|
// before the 500ms spatial transition ends, it is still off-screen on the left.
|
|
$page->wait(0.05);
|
|
|
|
$page->assertScript("getComputedStyle({$sheet}).display !== 'none'")
|
|
->assertScript("{$sheet}.getBoundingClientRect().left < 0");
|
|
|
|
// And settles flush against that same left edge, not the right.
|
|
$page->wait(0.6)
|
|
->assertScript("Math.round({$sheet}.getBoundingClientRect().left) === 0");
|
|
});
|
|
|
|
class NestedFullscreenDialogProbe extends Component
|
|
{
|
|
public bool $settings = false;
|
|
|
|
public bool $confirm = false;
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<x-button label="Open settings" wire:click="$set('settings', true)" />
|
|
|
|
<x-modal wire:model="settings" title="Share settings" fullscreen>
|
|
A form that needs the room.
|
|
|
|
<x-button label="Delete the share" danger wire:click="$set('confirm', true)" />
|
|
|
|
<x-modal wire:model="confirm" title="Delete this share?">
|
|
Recipients lose access at once.
|
|
<x-slot:actions><x-button label="Delete" danger x-on:click="close()" /></x-slot:actions>
|
|
</x-modal>
|
|
</x-modal>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
it('keeps a basic dialog\'s own title and padding when it is nested inside a full-screen dialog at 400px', function () {
|
|
Livewire::component('nested-fullscreen-dialog-probe', NestedFullscreenDialogProbe::class);
|
|
|
|
Route::middleware('web')->get('/nested-fullscreen-dialog-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:nested-fullscreen-dialog-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
$page = ready(visit('/nested-fullscreen-dialog-probe')->resize(400, 800));
|
|
|
|
$page->click('button:has-text("Open settings")');
|
|
|
|
$outerBar = "document.querySelector('dialog[data-md-fullscreen][open] [data-md-modal-bar]')";
|
|
|
|
// The rule hiding a full-screen dialog's own head below 600px, so the phone bar alone names
|
|
// it, is scoped to that dialog's own box — confirmed here, since the nested basic dialog below
|
|
// must keep its own head regardless.
|
|
$page->assertScript("getComputedStyle({$outerBar}).display !== 'none'");
|
|
|
|
$page->click('button:has-text("Delete the share")');
|
|
|
|
$inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.querySelector('[data-md-modal-title]')?.textContent === 'Delete this share?')";
|
|
$innerHead = "{$inner}.querySelector('[data-md-modal-head]')";
|
|
$innerTitle = "{$inner}.querySelector('[data-md-modal-title]')";
|
|
|
|
$page->assertScript("getComputedStyle({$innerHead}).display !== 'none'")
|
|
->assertScript("getComputedStyle({$innerTitle}).display !== 'none'")
|
|
->assertScript("parseFloat(getComputedStyle({$innerHead}).paddingTop) > 0")
|
|
->assertScript("parseFloat(getComputedStyle({$innerHead}).paddingInlineStart) > 0");
|
|
});
|
|
|
|
it('shows the 16% dragged state layer on the showcase card being dragged', function () {
|
|
$page = containment();
|
|
|
|
$card = "[...document.querySelectorAll('#containment [data-md-card]')].find((c) => c.textContent.includes('Elevation 4 and the 16% state layer'))";
|
|
|
|
$page->assertScript("getComputedStyle({$card}, '::before').opacity === '0'");
|
|
|
|
$page->click('#containment button:has-text("Pick it up or put it down")');
|
|
|
|
$page->assertScript("{$card}.hasAttribute('data-md-dragged')")
|
|
->assertScript("getComputedStyle({$card}, '::before').opacity === '0.16'");
|
|
|
|
$page->click('#containment button:has-text("Pick it up or put it down")');
|
|
|
|
$page->assertScript("! {$card}.hasAttribute('data-md-dragged')")
|
|
->assertScript("getComputedStyle({$card}, '::before').opacity === '0'");
|
|
});
|
|
|
|
it('keeps a selected segmented row\'s fill under the hover and focus tint', function () {
|
|
Route::middleware('web')->get('/segmented-selected-row-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);">
|
|
<div style="padding: var(--md-sys-measurement-space200); max-width: 400px;">
|
|
<x-list segmented label="Rows">
|
|
<x-list-item title="Selected" link="#segmented-selected-row-probe" :selected="true" />
|
|
<x-list-item title="Plain" link="#segmented-selected-row-probe" />
|
|
</x-list>
|
|
</div>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
$page = ready(visit('/segmented-selected-row-probe'), livewire: false);
|
|
|
|
$selected = "document.querySelector('[data-md-selected]')";
|
|
$plain = "document.querySelector('[data-md-list-item]:not([data-md-selected])')";
|
|
|
|
$restBackground = $page->script("getComputedStyle({$selected}).backgroundColor");
|
|
|
|
$page->hover('[data-md-selected]');
|
|
$selectedHoverBackground = $page->script("getComputedStyle({$selected}).backgroundColor");
|
|
|
|
$page->hover('[data-md-list-item]:not([data-md-selected])');
|
|
$plainHoverBackground = $page->script("getComputedStyle({$plain}).backgroundColor");
|
|
|
|
// The hover tint is mixed *over* the row's own fill (`--md-list-row-fill`, list-item.css):
|
|
// the selected row's secondary-container stays under it, so its hover colour differs from
|
|
// both its own resting colour and a plain row's hover, which has no fill to keep.
|
|
expect($selectedHoverBackground)->not->toBe($restBackground)
|
|
->and($selectedHoverBackground)->not->toBe($plainHoverBackground);
|
|
|
|
$page->script("document.querySelector('[data-md-selected] [data-md-list-open]').focus()");
|
|
|
|
$selectedFocusBackground = $page->script("getComputedStyle({$selected}).backgroundColor");
|
|
|
|
expect($selectedFocusBackground)->not->toBe($restBackground)
|
|
->and($selectedFocusBackground)->not->toBe($selectedHoverBackground);
|
|
});
|
|
|
|
/**
|
|
* An element's background and ink, "background / color", once the transitions a change started on
|
|
* it have run: a row moves its background on the effects springs.
|
|
*/
|
|
function settledPaint(string $element): string
|
|
{
|
|
return "(async () => { const element = {$element}; await Promise.allSettled(element.getAnimations().map((animation) => animation.finished)); const style = getComputedStyle(element); return style.backgroundColor + ' / ' + style.color })()";
|
|
}
|
|
|
|
it('fills a selected row written by hand in secondary-container in both themes, but not a card', function () {
|
|
Route::middleware('web')->get('/hand-made-selected-row-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);">
|
|
<div style="padding: var(--md-sys-measurement-space200); max-width: 400px;">
|
|
<p id="swatch" style="background-color: var(--md-sys-color-secondary-container); color: var(--md-sys-color-on-secondary-container);">The role pair</p>
|
|
<ul>
|
|
<li id="selected-row" data-md-list-row data-md-selected><a href="#selected" data-md-list-open>Selected</a></li>
|
|
<li id="plain-row" data-md-list-row><a href="#plain" data-md-list-open>Plain</a></li>
|
|
</ul>
|
|
<x-table>
|
|
<tbody>
|
|
<tr id="selected-table-row" data-md-selected><td>Selected, opening nothing</td></tr>
|
|
</tbody>
|
|
</x-table>
|
|
<x-card id="selected-card" data-md-list-row data-md-selected>
|
|
<a href="#card" data-md-list-open>A card</a>
|
|
</x-card>
|
|
</div>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
$page = ready(visit('/hand-made-selected-row-probe'), livewire: false);
|
|
|
|
$swatch = settledPaint("document.getElementById('swatch')");
|
|
$row = settledPaint("document.getElementById('selected-row')");
|
|
$tableRow = settledPaint("document.getElementById('selected-table-row')");
|
|
$light = $page->script($swatch);
|
|
|
|
// The role pair itself, on an `<li>` and on a plain `<tr>` in a table; a card keeps its own
|
|
// container, and a row that is not selected has no fill.
|
|
expect($page->script($row))->toBe($light)
|
|
->and($page->script($tableRow))->toBe($light)
|
|
->and($page->script("getComputedStyle(document.getElementById('plain-row')).backgroundColor"))->toBe('rgba(0, 0, 0, 0)')
|
|
->and($page->script("getComputedStyle(document.getElementById('selected-card')).backgroundColor"))->not->toBe(explode(' / ', $light)[0]);
|
|
|
|
$page->script("document.documentElement.setAttribute('data-theme', 'dark')");
|
|
$dark = $page->script($swatch);
|
|
|
|
expect($dark)->not->toBe($light)
|
|
->and($page->script($row))->toBe($dark)
|
|
->and($page->script($tableRow))->toBe($dark);
|
|
|
|
// The hover tint is laid over the fill, so it is neither the fill nor a plain row's hover.
|
|
$page->hover('#selected-row');
|
|
$selectedHover = explode(' / ', $page->script($row))[0];
|
|
|
|
$page->hover('#plain-row');
|
|
$plainHover = explode(' / ', $page->script(settledPaint("document.getElementById('plain-row')")))[0];
|
|
|
|
expect($selectedHover)->not->toBe(explode(' / ', $dark)[0])
|
|
->and($selectedHover)->not->toBe($plainHover)
|
|
->and($page->script($row))->toBe($dark);
|
|
});
|
|
|
|
/**
|
|
* Collapses to watch move: one on its own with a paragraph under it, one bound to Alpine, and a
|
|
* `name` group of two with the first open. Each body is tall enough for a height caught part-way
|
|
* to be told from both ends.
|
|
*/
|
|
function collapseMotionProbe(string $durations = ''): mixed
|
|
{
|
|
Route::middleware('web')->get('/collapse-motion-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-collapse id="plain" title="How long do links last?">
|
|
<p style="block-size: 240px">Until the expiry the sender chose, at most thirty days.</p>
|
|
</x-collapse>
|
|
<p id="below">Under the collapse.</p>
|
|
|
|
<div x-data="{ advanced: false }">
|
|
<x-collapse id="bound" title="Advanced" x-model="advanced">
|
|
<p style="block-size: 240px">Everything else.</p>
|
|
</x-collapse>
|
|
<button type="button" id="open-bound" x-on:click="advanced = true">Open from Alpine</button>
|
|
<button type="button" id="close-bound" x-on:click="advanced = false">Close from Alpine</button>
|
|
<output id="advanced" x-text="advanced"></output>
|
|
</div>
|
|
|
|
<x-collapse id="first" title="First" name="faq" open>
|
|
<p style="block-size: 240px">The first answer.</p>
|
|
</x-collapse>
|
|
<x-collapse id="second" title="Second" name="faq">
|
|
<p style="block-size: 240px">The second answer.</p>
|
|
</x-collapse>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
$page = ready(visit('/collapse-motion-probe'), livewire: false);
|
|
|
|
if ($durations !== '') {
|
|
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-spatial-fast-duration: {$durations}; }</style>')");
|
|
}
|
|
|
|
return $page;
|
|
}
|
|
|
|
/**
|
|
* Runs `$act` in the page and samples `$details` every frame until its animation has run: whether
|
|
* its height was ever caught strictly between where it started and where it ended, what `open` and
|
|
* the closing mark said as it began, whether the chevron was caught turning, and how it ended. In one round trip, because a separate read
|
|
* costs about as long as the 350ms spring.
|
|
*/
|
|
function collapseMotion(mixed $page, string $act, string $details): array
|
|
{
|
|
return $page->script(<<<JS
|
|
(async () => {
|
|
const details = document.querySelector('{$details}')
|
|
const chevron = details.querySelector('[data-md-collapse-chevron]')
|
|
const below = details.nextElementSibling
|
|
const start = details.getBoundingClientRect().height
|
|
const belowStart = below?.getBoundingClientRect().top ?? 0
|
|
const heights = []
|
|
const belows = []
|
|
|
|
{$act}
|
|
|
|
// A binding reaches the collapse from Alpine's own flush, a microtask on; a task later
|
|
// both routes have begun, and the close's attribute and `open` hold for its whole run.
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
const first = { open: details.open, closing: details.hasAttribute('data-md-collapse-closing') }
|
|
let chevronTurning = false
|
|
|
|
for (let i = 0; i < 120; i++) {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
heights.push(details.getBoundingClientRect().height)
|
|
belows.push(below?.getBoundingClientRect().top ?? 0)
|
|
const rotate = getComputedStyle(chevron).rotate
|
|
chevronTurning ||= rotate !== 'none' && rotate !== '180deg' && rotate !== '0deg'
|
|
if (i > 5 && details.getAnimations().length === 0) break
|
|
}
|
|
|
|
const end = details.getBoundingClientRect().height
|
|
const low = Math.min(start, end)
|
|
const high = Math.max(start, end)
|
|
|
|
return {
|
|
between: heights.some((height) => height > low + 2 && height < high - 2),
|
|
belowMoved: belows.some((top) => top > Math.min(belowStart, belows.at(-1)) + 2 && top < Math.max(belowStart, belows.at(-1)) - 2),
|
|
first,
|
|
chevronTurning,
|
|
start,
|
|
end,
|
|
open: details.open,
|
|
closing: details.hasAttribute('data-md-collapse-closing'),
|
|
style: details.getAttribute('style'),
|
|
animations: details.getAnimations().length,
|
|
}
|
|
})()
|
|
JS);
|
|
}
|
|
|
|
it('eases a collapse open and shut in every engine, moving what is under it', function () {
|
|
$page = collapseMotionProbe();
|
|
|
|
$opened = collapseMotion($page, "details.querySelector('summary').click()", '#plain');
|
|
|
|
expect($opened['first']['open'])->toBeTrue()
|
|
->and($opened['between'])->toBeTrue()
|
|
->and($opened['belowMoved'])->toBeTrue()
|
|
->and($opened['open'])->toBeTrue()
|
|
->and($opened['end'])->toBeGreaterThan($opened['start'] + 200)
|
|
->and($opened['style'])->toBeNull()
|
|
->and($opened['animations'])->toBe(0);
|
|
|
|
$closed = collapseMotion($page, "details.querySelector('summary').click()", '#plain');
|
|
|
|
// Still open while the height goes, so there is content to clip; the chevron turns back at once.
|
|
expect($closed['first'])->toBe(['open' => true, 'closing' => true])
|
|
->and($closed['chevronTurning'])->toBeTrue()
|
|
->and($closed['between'])->toBeTrue()
|
|
->and($closed['belowMoved'])->toBeTrue()
|
|
->and($closed['open'])->toBeFalse()
|
|
->and($closed['closing'])->toBeFalse()
|
|
->and($closed['end'])->toBe($opened['start'])
|
|
->and($closed['style'])->toBeNull()
|
|
->and($closed['animations'])->toBe(0);
|
|
|
|
// Clipped while it moves, so the content never shows past the edge that is moving.
|
|
$clipped = $page->script(<<<'JS'
|
|
(async () => {
|
|
const details = document.querySelector('#plain')
|
|
details.querySelector('summary').click()
|
|
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
const overflow = getComputedStyle(details).overflowY
|
|
await Promise.all(details.getAnimations().map((animation) => animation.finished))
|
|
return overflow
|
|
})()
|
|
JS);
|
|
|
|
expect($clipped)->toBe('clip');
|
|
|
|
// From the keyboard: Enter on the summary is the same press, taken over the same way.
|
|
$page->script("document.querySelector('#plain summary').focus()");
|
|
$page->keys('#plain summary', 'Enter')
|
|
->assertScript("document.querySelector('#plain').open === false && document.querySelector('#plain').getAnimations().length === 0");
|
|
});
|
|
|
|
it('eases a collapse bound to Alpine open and shut, keeping the binding in step', function () {
|
|
$page = collapseMotionProbe();
|
|
|
|
$opened = collapseMotion($page, "document.querySelector('#open-bound').click()", '#bound');
|
|
|
|
expect($opened['between'])->toBeTrue()
|
|
->and($opened['open'])->toBeTrue();
|
|
|
|
$page->assertSeeIn('#advanced', 'true');
|
|
|
|
$closed = collapseMotion($page, "document.querySelector('#close-bound').click()", '#bound');
|
|
|
|
expect($closed['first']['closing'])->toBeTrue()
|
|
->and($closed['between'])->toBeTrue()
|
|
->and($closed['open'])->toBeFalse()
|
|
->and($closed['end'])->toBe($opened['start']);
|
|
|
|
$page->assertSeeIn('#advanced', 'false');
|
|
|
|
// A press on the summary tells the binding when the section has closed, and never loops.
|
|
$page->click('#bound summary')
|
|
->assertSeeIn('#advanced', 'true')
|
|
->assertScript("document.querySelector('#bound').open === true");
|
|
});
|
|
|
|
it('turns a collapse round from the height it has reached, ending as the last press asked', function () {
|
|
$page = collapseMotionProbe('1500ms');
|
|
|
|
// Two seconds in the page, so it is run once: see onceInPage() in tests/Pest.php.
|
|
$result = $page->script(onceInPage(<<<'JS'
|
|
(async () => {
|
|
const details = document.querySelector('#plain')
|
|
const summary = details.querySelector('summary')
|
|
const closed = details.getBoundingClientRect().height
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
summary.click()
|
|
await wait(300)
|
|
const midway = details.getBoundingClientRect().height
|
|
summary.click()
|
|
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
const turned = details.getBoundingClientRect().height
|
|
|
|
await wait(1700)
|
|
|
|
return { closed, midway, turned, end: details.getBoundingClientRect().height, open: details.open, style: details.getAttribute('style') }
|
|
})()
|
|
JS));
|
|
|
|
expect($result['midway'])->toBeGreaterThan($result['closed'] + 2)
|
|
// Turned round from where it was, not from either end.
|
|
->and(abs($result['turned'] - $result['midway']))->toBeLessThan(40)
|
|
->and($result['open'])->toBeFalse()
|
|
->and($result['end'])->toBe($result['closed'])
|
|
->and($result['style'])->toBeNull();
|
|
});
|
|
|
|
it('closes the open member of a name group on the same spring as the one opening', function () {
|
|
$page = collapseMotionProbe();
|
|
|
|
$first = "document.querySelector('#first')";
|
|
|
|
$result = $page->script(onceInPage(<<<'JS'
|
|
(async () => {
|
|
const first = document.querySelector('#first')
|
|
const second = document.querySelector('#second')
|
|
const start = first.getBoundingClientRect().height
|
|
let caught = false
|
|
|
|
second.querySelector('summary').click()
|
|
const stillOpen = first.open
|
|
|
|
for (let i = 0; i < 120; i++) {
|
|
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
const height = first.getBoundingClientRect().height
|
|
caught ||= first.open && height > 60 && height < start - 2
|
|
if (i > 5 && first.getAnimations().length === 0 && second.getAnimations().length === 0) break
|
|
}
|
|
|
|
return { stillOpen, caught, first: first.open, second: second.open, name: first.getAttribute('name') }
|
|
})()
|
|
JS));
|
|
|
|
expect($result)->toBe(['stillOpen' => true, 'caught' => true, 'first' => false, 'second' => true, 'name' => 'faq']);
|
|
|
|
// The group still keeps one open: the browser's own exclusivity, with the name back.
|
|
$page->script("document.querySelector('#first').open = true");
|
|
$page->assertScript("{$first}.open === true && document.querySelector('#second').open === false");
|
|
});
|
|
|
|
it('opens and closes a collapse at once under reduced motion, leaving nothing behind', function () {
|
|
$page = collapseMotionProbe('0ms');
|
|
|
|
$result = $page->script(<<<'JS'
|
|
(() => {
|
|
const details = document.querySelector('#plain')
|
|
details.querySelector('summary').click()
|
|
const opened = { open: details.open, animations: details.getAnimations().length }
|
|
details.querySelector('summary').click()
|
|
return { opened, open: details.open, animations: details.getAnimations().length, closing: details.hasAttribute('data-md-collapse-closing'), style: details.getAttribute('style') }
|
|
})()
|
|
JS);
|
|
|
|
expect($result)->toBe(['opened' => ['open' => true, 'animations' => 0], 'open' => false, 'animations' => 0, 'closing' => false, 'style' => null]);
|
|
});
|
|
|
|
class OpenedLayersProbe extends Component
|
|
{
|
|
public ?int $workout = null;
|
|
|
|
public bool $setback = false;
|
|
|
|
public bool $about = false;
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->workout = request()->has('workout') ? (int) request('workout') : null;
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<p>The week's sessions</p>
|
|
<button type="button" id="open-setback" wire:click="$set('setback', true)">Set back the plan</button>
|
|
<button type="button" id="open-about" wire:click="$set('about', true)">About expiry</button>
|
|
<x-drawer wire:model="workout" title="Long run">
|
|
<input id="sheet-note" aria-label="Note" />
|
|
</x-drawer>
|
|
<x-modal wire:model="setback" title="Set back the plan" fullscreen>
|
|
<input id="dialog-weeks" aria-label="Weeks" />
|
|
</x-modal>
|
|
<x-modal wire:model="about" title="Expiry">
|
|
<x-rich-tooltip text="Recipients lose access after this time.">
|
|
<x-button id="about-help" icon="help" aria-label="What expiry means" />
|
|
</x-rich-tooltip>
|
|
</x-modal>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
/** Whether the plain tooltip of the ✕ in the sheet or the full-screen dialog's bar is up. */
|
|
function closeTooltipOpen(string $layer): string
|
|
{
|
|
return "document.querySelector('{$layer} button[aria-label=\"Close\"] [data-md-tooltip]').matches(':popover-open')";
|
|
}
|
|
|
|
it('shows no tooltip on the control a sheet or a dialog focuses as it opens, but does once Tab reaches it', function () {
|
|
Livewire::component('opened-layers-probe', OpenedLayersProbe::class);
|
|
|
|
Route::middleware('web')->get('/opened-layers-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:opened-layers-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
$sheet = '[data-md-drawer-sheet]';
|
|
$dialog = 'dialog[data-md-modal]';
|
|
|
|
// A phone, where the sheet is modal and traps the focus, which it moves to its ✕ on open: here
|
|
// on a page loaded with the sheet open, as a link to a session opens it.
|
|
$page = ready(visit('/opened-layers-probe?workout=7', ['viewport' => ['width' => 393, 'height' => 800]]), livewire: false)
|
|
->assertScript("document.activeElement.matches('{$sheet} button[aria-label=\"Close\"]')")
|
|
->wait(0.3)
|
|
->assertScript('! '.closeTooltipOpen($sheet));
|
|
|
|
// Tab from the sheet's last field wraps round to its ✕, and a keyboard's focus shows its label.
|
|
$page->keys('#sheet-note', 'Tab')
|
|
->assertScript("document.activeElement.matches('{$sheet} button[aria-label=\"Close\"]')")
|
|
->assertScript(closeTooltipOpen($sheet));
|
|
|
|
// A full-screen dialog opened from the keyboard moves the focus to its bar's ✕ in showModal().
|
|
$page = ready(visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]]), alpine: false);
|
|
|
|
$page->keys('#open-setback', 'Enter')
|
|
->assertScript("document.querySelector('{$dialog}').open && document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')")
|
|
->wait(0.3)
|
|
->assertScript('! '.closeTooltipOpen($dialog));
|
|
|
|
$page->keys('#dialog-weeks', 'Tab')
|
|
->assertScript("document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')")
|
|
->assertScript(closeTooltipOpen($dialog));
|
|
|
|
// A rich tooltip's trigger as the first control of a dialog opened from the keyboard.
|
|
$page = ready(visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]]), alpine: false);
|
|
|
|
$page->keys('#open-about', 'Enter')
|
|
->assertScript("document.activeElement.id === 'about-help'")
|
|
->wait(0.3)
|
|
->assertScript("! document.querySelector('[data-md-rich-tooltip-bubble]').matches(':popover-open')");
|
|
});
|