Plan step 38 (last batch), plan step 42's target for tests/: every Tailwind utility class in a tests/Browser/*.php probe's Blade string or Livewire component render() (~60 class attributes across 14 files) becomes an inline style built from --md-sys-color-*/--md-sys-shape-*/ --md-sys-measurement-* tokens or a literal px value for an arbitrary demo size — PickingTest's clip box, every probe's <body class="bg-surface">, the grid/stack/row wrapper divs, the carousel item's coloured filler. No test's assertions, selectors or expected text change; these are layout containers around the components under test, not anything a test reads. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
286 lines
13 KiB
PHP
286 lines
13 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\Blade;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
class ChipProbe extends Component
|
|
{
|
|
/** @var list<string> */
|
|
public array $kinds = ['photos'];
|
|
|
|
/** @var list<string> */
|
|
public array $tags = ['php', 'js', 'css'];
|
|
|
|
public function onlyVideo(): void
|
|
{
|
|
$this->kinds = ['video'];
|
|
}
|
|
|
|
public function removeTag(string $tag): void
|
|
{
|
|
$this->tags = array_values(array_diff($this->tags, [$tag]));
|
|
}
|
|
|
|
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>kinds: <span id="kinds">{{ implode(',', $kinds) }}</span></p>
|
|
|
|
<x-chip-set label="Kinds">
|
|
@foreach (['photos' => 'Photos', 'documents' => 'Documents', 'video' => 'Video'] as $value => $name)
|
|
<x-chip type="filter" :label="$name" :value="$value" wire:model.live="kinds" wire:key="kind-{{ $value }}" />
|
|
@endforeach
|
|
</x-chip-set>
|
|
|
|
<x-button label="Only video" wire:click="onlyVideo" />
|
|
|
|
<p>tags: <span id="tags">{{ implode(',', $tags) }}</span></p>
|
|
|
|
<x-chip-set label="Tags">
|
|
@foreach ($tags as $tag)
|
|
<x-chip type="input" :label="$tag" removable wire:remove="removeTag('{{ $tag }}')" wire:key="tag-{{ $tag }}" />
|
|
@endforeach
|
|
</x-chip-set>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
}
|
|
|
|
function chipProbe()
|
|
{
|
|
Livewire::component('chip-probe', ChipProbe::class);
|
|
|
|
Route::middleware('web')->get('/chip-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:chip-probe />
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return visit('/chip-probe')->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
}
|
|
|
|
function chipShowcase()
|
|
{
|
|
return visit('/material/chips')->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
|
}
|
|
|
|
/**
|
|
* The script for a showcase filter chip's checkbox, found by its value.
|
|
*/
|
|
function filterInput(string $value): string
|
|
{
|
|
return "document.querySelector('#chips input[type=\"checkbox\"][value=\"{$value}\"]')";
|
|
}
|
|
|
|
it('toggles a filter chip with a click and with Space, and grows its check in', function () {
|
|
$check = fn (string $value): string => filterInput($value).".parentElement.querySelector('[data-md-chip-check]').parentElement.getBoundingClientRect().width";
|
|
|
|
$page = chipShowcase()->assertNoJavaScriptErrors();
|
|
|
|
$page->click('#chips label:has-text("Documents")')
|
|
->assertScript(filterInput('documents').'.checked')
|
|
->assertScript("Math.round({$check('documents')}) === 18")
|
|
->assertScript('getComputedStyle('.filterInput('documents').".parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
|
|
->assertScript("window.eval(\"Alpine.\$data(document.querySelector('#chips input[value=documents]')).kinds.join(',')\") === 'photos,documents'");
|
|
|
|
// A key press first, so the browser is in keyboard modality; focused directly, not tabbed to,
|
|
// because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
|
|
$page->keys('#content', 'Tab');
|
|
$page->script(filterInput('archives').'.focus()');
|
|
$page->keys(':focus', 'Space')
|
|
->assertScript(filterInput('archives').'.checked')
|
|
->assertScript("Math.round({$check('archives')}) === 18");
|
|
|
|
$page->keys(':focus', 'Space')
|
|
->assertScript('! '.filterInput('archives').'.checked')
|
|
->assertScript("Math.round({$check('archives')}) === 0");
|
|
});
|
|
|
|
it('binds filter chips to a Livewire array, and a server change reaches them after a morph', function () {
|
|
$input = fn (string $value): string => "document.querySelector('input[value=\"{$value}\"]')";
|
|
|
|
$page = chipProbe()->assertNoJavaScriptErrors();
|
|
|
|
$page->assertScript("{$input('photos')}.checked && ! {$input('documents')}.checked");
|
|
|
|
$page->click('label:has-text("Documents")')
|
|
->assertSeeIn('#kinds', 'photos,documents');
|
|
|
|
$page->click('button:has-text("Only video")')
|
|
->assertSeeIn('#kinds', 'video')
|
|
->assertScript("! {$input('photos')}.checked && ! {$input('documents')}.checked && {$input('video')}.checked")
|
|
->assertScript("getComputedStyle({$input('video')}.parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'")
|
|
->assertScript("getComputedStyle({$input('photos')}.parentElement).backgroundColor === 'rgba(0, 0, 0, 0)'");
|
|
});
|
|
|
|
it('removes a Livewire input chip with its button and with Delete, focusing the chip after it', function () {
|
|
$page = chipProbe();
|
|
|
|
$page->click('button[aria-label="Remove js"]')
|
|
->assertSeeIn('#tags', 'php,css')
|
|
->assertScript("document.querySelector('button[aria-label=\"Remove js\"]') === null");
|
|
|
|
$page->script("document.querySelector('button[aria-label=\"Remove php\"]').focus()");
|
|
|
|
$page->keys(':focus', 'Delete')
|
|
->assertScript("document.querySelector('#tags').textContent === 'css'")
|
|
->assertScript("document.querySelector('button[aria-label=\"Remove php\"]') === null")
|
|
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove css'");
|
|
});
|
|
|
|
it('removes an Alpine input chip with Backspace, focusing the chip before it', function () {
|
|
$remove = fn (string $who): string => "document.querySelector('#chips button[aria-label=\"Remove {$who}@example.com\"]')";
|
|
|
|
$page = chipShowcase();
|
|
|
|
// Named in the browser: the chips come from x-for, so the server never knew their labels.
|
|
$page->assertScript("{$remove('ben')} !== null");
|
|
$page->script("{$remove('ben')}.focus()");
|
|
|
|
$page->keys(':focus', 'Backspace')
|
|
->assertScript("{$remove('ben')} === null")
|
|
->assertScript("{$remove('anna')} !== null && {$remove('chiara')} !== null")
|
|
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove anna@example.com'");
|
|
});
|
|
|
|
it('scrolls a chip set sideways, fading the edge it can still scroll towards', function () {
|
|
// The scrolling row, inside the component that also holds its scroll buttons.
|
|
$row = "document.querySelector('#chips [data-md-chip-set-scroller] > [data-md-chip-set-row]')";
|
|
|
|
$page = chipShowcase();
|
|
|
|
$page->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
|
|
->assertScript("{$row}.hasAttribute('data-md-scroll-end') && ! {$row}.hasAttribute('data-md-scroll-start')")
|
|
->assertScript("getComputedStyle({$row}).flexWrap === 'nowrap'");
|
|
|
|
$page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()");
|
|
|
|
$page->assertScript("Math.abs({$row}.scrollLeft) > 0")
|
|
->assertScript("{$row}.hasAttribute('data-md-scroll-start')")
|
|
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '24px'");
|
|
});
|
|
|
|
it('draws the chip 32px tall and catches presses over 48px, the chip and its remove button alike', function () {
|
|
$page = chipProbe();
|
|
|
|
$page->assertScript("document.querySelector('[data-md-chip=\"input\"]').getBoundingClientRect().height === 32")
|
|
// The remove button is an 18px icon whose target reaches 15px past it on every side.
|
|
->assertScript("(() => {
|
|
const button = document.querySelector('button[aria-label=\"Remove css\"]');
|
|
const box = button.getBoundingClientRect();
|
|
const after = getComputedStyle(button, '::after');
|
|
|
|
return box.width === 18 && box.height === 18 && after.position === 'absolute' && after.top === '-15px' && after.left === '-15px';
|
|
})()");
|
|
|
|
// A press 22px out from the icon's centre, over the chip's 48px strip, still removes the chip.
|
|
$page->script("(() => {
|
|
const button = document.querySelector('button[aria-label=\"Remove css\"]');
|
|
const box = button.getBoundingClientRect();
|
|
|
|
document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2 + 22).click();
|
|
})()");
|
|
|
|
$page->assertSeeIn('#tags', 'php,js')
|
|
->assertScript("document.querySelector('button[aria-label=\"Remove css\"]') === null");
|
|
});
|
|
|
|
it('draws a selected filter chip without its outline, and puts an unselected one\'s label 16px in', function () {
|
|
$input = fn (string $value): string => "document.querySelector('input[value=\"{$value}\"]')";
|
|
|
|
$page = chipProbe();
|
|
|
|
$page->assertScript("getComputedStyle({$input('photos')}.parentElement).borderTopColor === 'rgba(0, 0, 0, 0)'")
|
|
->assertScript("getComputedStyle({$input('documents')}.parentElement).borderTopWidth === '1px'")
|
|
->assertScript("getComputedStyle({$input('documents')}.parentElement).borderTopColor !== 'rgba(0, 0, 0, 0)'")
|
|
// 1px border, 7px padding, the empty check's 8px margin: Compose's 16px before the label.
|
|
->assertScript("Math.round({$input('documents')}.parentElement.querySelector('[data-md-chip-label]').getBoundingClientRect().left - {$input('documents')}.parentElement.getBoundingClientRect().left) === 16");
|
|
});
|
|
|
|
function chipScrollProbe(string $dir)
|
|
{
|
|
Route::middleware('web')->get('/chip-scroll-probe', fn () => Blade::render(<<<'BLADE'
|
|
<!DOCTYPE html>
|
|
<html dir="{{ request('dir') }}">
|
|
<head>
|
|
<x-theme-script />
|
|
@vite(config('livewire-material.showcase.vite'))
|
|
@livewireStyles
|
|
</head>
|
|
<body>
|
|
<div style="width: 320px; padding: 24px">
|
|
<x-chip-set aria-label="Sort" scroll>
|
|
@foreach (['Newest', 'Oldest', 'Largest', 'Smallest', 'Shared', 'Starred', 'Unopened', 'Expired'] as $sort)
|
|
<x-chip type="filter" :label="$sort" :value="$sort" name="sort[]" />
|
|
@endforeach
|
|
</x-chip-set>
|
|
</div>
|
|
@livewireScripts
|
|
</body>
|
|
</html>
|
|
BLADE));
|
|
|
|
return visit("/chip-scroll-probe?dir={$dir}")->waitForEvent('networkidle')
|
|
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
|
|
}
|
|
|
|
it('shows a scroll button only on the edge the row can still scroll towards, in either direction', function (string $dir) {
|
|
$row = "document.querySelector('[data-md-chip-set-row]')";
|
|
$shown = fn (string $edge): string => "getComputedStyle(document.querySelector('[data-md-chip-scroll=\"{$edge}\"]')).display === 'grid'";
|
|
|
|
$page = chipScrollProbe($dir)
|
|
->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
|
|
->assertScript("! ({$shown('start')}) && ({$shown('end')})");
|
|
|
|
$page->click('[data-md-chip-scroll="end"]')
|
|
->wait(0.8)
|
|
->assertScript("Math.abs({$row}.scrollLeft) > 0")
|
|
->assertScript("({$shown('start')})");
|
|
|
|
$page->script("{$row}.scrollTo({ left: ({$row}.scrollWidth) * (getComputedStyle({$row}).direction === 'rtl' ? -1 : 1), behavior: 'instant' })");
|
|
|
|
$page->wait(0.2)
|
|
->assertScript("({$shown('start')}) && ! ({$shown('end')})")
|
|
// The end button sits over the end edge: on the right in LTR, on the left in RTL.
|
|
->assertScript("(() => { const start = document.querySelector('[data-md-chip-scroll=\"start\"]').getBoundingClientRect(); const box = {$row}.parentElement.getBoundingClientRect(); return '{$dir}' === 'rtl' ? Math.abs(start.right - box.right) < 1 : Math.abs(start.left - box.left) < 1; })()");
|
|
})->with(['ltr', 'rtl']);
|
|
|
|
it('walks a chip set with the arrow keys as one tab stop, and scrolls the focused chip clear of the buttons', function () {
|
|
$inputs = "[...document.querySelectorAll('[data-md-chip-set-row] input')]";
|
|
|
|
$page = chipScrollProbe('ltr')
|
|
->assertScript("{$inputs}.filter((input) => input.tabIndex === 0).length === 1");
|
|
|
|
$page->script("{$inputs}[0].focus()");
|
|
|
|
$page->keys(':focus', 'ArrowRight')
|
|
->assertScript("document.activeElement === {$inputs}[1]")
|
|
->assertScript("{$inputs}.filter((input) => input.tabIndex === 0).map((input) => input.value).join() === 'Oldest'");
|
|
|
|
$page->keys(':focus', 'End')
|
|
->assertScript("document.activeElement === {$inputs}.at(-1)")
|
|
->wait(0.8)
|
|
// The focused chip stands clear of the start button and its fade.
|
|
->assertScript("(() => { const chip = document.activeElement.closest('[data-md-chip]').getBoundingClientRect(); const button = document.querySelector('[data-md-chip-scroll=\"start\"]').getBoundingClientRect(); return getComputedStyle(document.querySelector('[data-md-chip-scroll=\"start\"]')).display === 'grid' && chip.left >= button.right; })()");
|
|
|
|
$page->keys(':focus', 'Home')
|
|
->assertScript("document.activeElement === {$inputs}[0]");
|
|
|
|
$page->keys(':focus', 'ArrowLeft')
|
|
->assertScript("document.activeElement === {$inputs}.at(-1)");
|
|
});
|