Plan step 39 (part 4). With Tailwind gone the Workbench's Vite build still turns every `:dir(rtl)` into a long `:lang()` list (its own CSS minifier, not Tailwind's doing — confirmed by rebuilding and grepping the output). Every RTL mirror rule across the eleven stylesheets that had one now selects `:is([dir='rtl'], [dir='rtl'] *)` instead, matching `:dir(rtl)`'s own specificity (one pseudo-class) and its inherited "this element or a descendant of one carrying the attribute" reach; the built CSS now keeps the selector as written (no `:lang(` or `:dir(` left). RTL browser tests drop the `lang="ar"` workaround the old rewrite needed (ContainmentTest's side-sheet probe, LayoutTest's shared layoutPage() helper) and set only dir="rtl"; CarouselTest's probe already did. Feature tests asserting the selector's literal text (Progress, Overlay, Menu, ListDetail, Icon, Carousel) updated to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
807 lines
35 KiB
PHP
807 lines
35 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>
|
|
<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 visit('/collapse-probe')->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
}
|
|
|
|
function overlayProbe()
|
|
{
|
|
Livewire::component('overlay-probe', OverlayProbe::class);
|
|
|
|
Route::middleware('web')->get('/overlay-probe', fn () => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<x-theme-script />
|
|
@vite(config('livewire-material.showcase.vite'))
|
|
@livewireStyles
|
|
</head>
|
|
<body style="background-color: var(--md-sys-color-surface);">
|
|
<livewire:overlay-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return visit('/overlay-probe')->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
}
|
|
|
|
function containment()
|
|
{
|
|
return visit('/material/containment')->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
}
|
|
|
|
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>
|
|
<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 visit('/nested-dialog-probe')->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
}
|
|
|
|
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 = visit('/material/containment', ['reducedMotion' => 'reduce'])->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
|
|
->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>
|
|
<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 visit('/text-only-dialog-probe')->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
}
|
|
|
|
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'");
|
|
});
|
|
|
|
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'");
|
|
|
|
$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'");
|
|
|
|
// 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('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");
|
|
});
|
|
|
|
it('fades the side sheet\'s scrim out on close, rather than making it vanish', function () {
|
|
$sheet = "document.querySelector('#containment aside[role=\"dialog\"]')";
|
|
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-drawer-scrim]')";
|
|
|
|
$page = containment()
|
|
->click('#containment button:has-text("Side sheet")')
|
|
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
|
|
->assertScript("getComputedStyle({$scrim}).opacity === '1'");
|
|
|
|
// 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 200ms 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 every few ms, in the page itself,
|
|
// for whether it was ever caught strictly between fully shown and fully hidden, rather than
|
|
// simply gone at once.
|
|
$midFade = $page->script(<<<JS
|
|
(async () => {
|
|
{$scrim}.click()
|
|
|
|
for (let i = 0; i < 60; i++) {
|
|
const opacity = parseFloat(getComputedStyle({$scrim}).opacity)
|
|
if (opacity > 0.02 && opacity < 0.98) return true
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
}
|
|
return false
|
|
})()
|
|
JS);
|
|
|
|
expect($midFade)->toBeTrue();
|
|
|
|
$page->assertScript("getComputedStyle({$scrim}).display === 'none'");
|
|
});
|
|
|
|
it('fades the bottom sheet\'s scrim out on close, rather than making it vanish', function () {
|
|
$sheet = "document.querySelector('#containment section[role=\"dialog\"]')";
|
|
$scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-bottom-sheet-scrim]')";
|
|
|
|
$page = containment()
|
|
->click('#containment button:has(> span:text-is("Bottom sheet"))')
|
|
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
|
|
->assertScript("getComputedStyle({$scrim}).opacity === '1'");
|
|
|
|
$midFade = $page->script(<<<JS
|
|
(async () => {
|
|
{$scrim}.click()
|
|
|
|
for (let i = 0; i < 60; i++) {
|
|
const opacity = parseFloat(getComputedStyle({$scrim}).opacity)
|
|
if (opacity > 0.02 && opacity < 0.98) return true
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
}
|
|
return false
|
|
})()
|
|
JS);
|
|
|
|
expect($midFade)->toBeTrue();
|
|
|
|
$page->assertScript("getComputedStyle({$scrim}).display === 'none'");
|
|
});
|
|
|
|
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>
|
|
<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 = visit('/drawer-rtl-probe')->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
|
|
|
|
$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)`, plan step 39 — Vite's own minifier rewrote that to a `:lang()` list, same as
|
|
// Tailwind's build did): 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>
|
|
<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 = visit('/nested-fullscreen-dialog-probe')->resize(400, 800)->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
|
|
$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>
|
|
<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 = visit('/segmented-selected-row-probe')->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
|
|
|
|
$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);
|
|
});
|