Files
livewire-material/tests/Browser/FieldsTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 f86afc0542 Draw the checkbox and the selection controls' row without Tailwind
<x-checkbox> renders data-md-checkbox, its box, tick and dash, and its
row, text and errors as data-md-* attributes; the 18px tick takes a size
prop and a label-less box md-touch-target (plan step 36). selection.css
moves into material.components as the row, text and state layer the
three controls share, and each control's drawing goes to its own file:
checkbox.css, radio.css, toggle.css, on the state tokens. The radio and
the switch take the renamed shared hooks now and lose their layout
classes in their own commits. field.js follows data-md-indeterminate.

Renaming the switch's data-handle also stops selection.css matching the
slider's handles, which it drew 24px above the track.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 14:34:00 +02:00

315 lines
14 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
class FieldProbe extends Component
{
public string $name = '';
public string $hours = '24';
/** @var list<string> */
public array $files = [];
public string $audience = 'anyone';
public bool $public = false;
public string $message = '';
public function save(): void
{
$this->validate(['name' => 'required']);
}
public function render(): string
{
return <<<'BLADE'
<div class="grid max-w-md gap-4 p-4">
<p>name: <span id="name">{{ $name }}</span></p>
<p>hours: <span id="hours">{{ $hours }}</span></p>
<p>files: <span id="files">{{ implode(',', $files) }}</span></p>
<p>audience: <span id="audience">{{ $audience }}</span></p>
<p>public: <span id="public-state">{{ var_export($public, true) }}</span></p>
<x-input id="name-field" label="Share name" wire:model.live="name" clearable copyable />
<x-password id="password-field" label="Password" />
<x-select id="hours-field" label="Expires" wire:model.live="hours" :options="[['id' => '1', 'name' => '1 hour'], ['id' => '24', 'name' => '1 day']]" />
<x-checkbox id="all" label="All files" :indeterminate="count($files) > 0 && count($files) < 3" />
@foreach (['a', 'b', 'c'] as $file)
<x-checkbox id="file-{{ $file }}" :label="'File '.$file" value="{{ $file }}" wire:model.live="files" wire:key="file-{{ $file }}" />
@endforeach
<x-radio label="Audience" wire:model.live="audience" :options="[['id' => 'anyone', 'name' => 'Anyone'], ['id' => 'team', 'name' => 'Team']]" />
<x-toggle id="public" label="Public" wire:model.live="public" />
<x-textarea id="message" label="Message" rows="2" max-rows="4" wire:model="message" />
<x-input id="bio-field" label="Bio" maxlength="10" counter />
<x-input id="off-field" label="Off" value="Not editable" disabled />
<x-input id="plain-name-field" label="Name, plain" wire:model="name" />
<x-file id="upload-field" label="Upload" />
<div style="display: flex; gap: 48px; padding: 24px">
<x-checkbox id="bare-check" aria-label="Select every row" />
<x-toggle id="bare-switch" aria-label="Bare switch" />
<x-radio name="bare-radio" :options="[['id' => 'only', 'name' => '']]" />
</div>
<x-button label="Save" wire:click="save" />
</div>
BLADE;
}
}
function fieldProbe()
{
Livewire::component('field-probe', FieldProbe::class);
Route::middleware('web')->get('/field-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:field-probe />
<x-toast />
@livewireScripts
</body>
</html>
BLADE));
return visit('/field-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('floats the label while the field has focus or holds a value', function () {
$label = "getComputedStyle(document.querySelector('label[for=\"name-field\"]')).fontSize";
$page = fieldProbe()->assertScript("{$label} === '16px'");
$page->click('#name-field')->assertScript("{$label} === '12px'");
$page->type('#name-field', 'Holiday')
->assertSeeIn('#name', 'Holiday');
$page->click('#password-field')->assertScript("{$label} === '12px'");
});
it('clears a field and tells Livewire', function () {
fieldProbe()
->type('#name-field', 'Holiday')
->assertSeeIn('#name', 'Holiday')
->click('[data-md-field-clear]')
->assertScript("document.querySelector('#name-field').value === ''")
->assertScript("document.querySelector('#name').textContent === ''")
->assertScript("document.activeElement.id === 'name-field'");
});
it('copies a field\'s value and says so', function () {
$page = fieldProbe()->type('#name-field', 'Holiday');
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
$page->click('[data-md-field-copy]')
->assertScript("window.copied === 'Holiday'")
->assertSee('Copied to the clipboard');
});
it('shows and hides a password', function () {
fieldProbe()
->type('#password-field', 'secret')
->click('[data-md-field-reveal]')
->assertScript("document.querySelector('#password-field').type === 'text'")
->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password')
->click('[data-md-field-reveal]')
->assertScript("document.querySelector('#password-field').type === 'password'");
});
it('shows the server\'s error in place of the hint', function () {
fieldProbe()
->click('button:has-text("Save")')
->assertSee('The name field is required.')
->assertAttribute('#name-field', 'aria-invalid', 'true')
->assertScript("document.querySelector('#name-field').closest('[data-md-field]').hasAttribute('data-md-invalid')");
});
it('binds a select to Livewire', function () {
fieldProbe()
->select('#hours-field', '1')
->assertSeeIn('#hours', '1');
});
it('keeps a partly ticked checkbox in step with the server', function () {
$all = "document.querySelector('#all')";
$page = fieldProbe()->assertScript("! {$all}.indeterminate");
$page->click('label[for="file-a"]')
->assertSeeIn('#files', 'a')
->assertScript("{$all}.hasAttribute('data-md-indeterminate') && {$all}.indeterminate");
$page->click('label[for="file-b"]')
->click('label[for="file-c"]')
->assertSeeIn('#files', 'a,b,c')
->assertScript("! {$all}.hasAttribute('data-md-indeterminate') && ! {$all}.indeterminate");
});
it('moves between radio buttons with the arrow keys', function () {
fieldProbe()
->keys('input[type="radio"][value="anyone"]', 'ArrowDown')
->assertSeeIn('#audience', 'team')
->assertScript("document.querySelector('input[type=\"radio\"][value=\"team\"]').checked");
});
it('turns a switch on from the keyboard and from its label', function () {
$page = fieldProbe()
->keys('#public', 'Space')
->assertSeeIn('#public-state', 'true');
$page->click('label[for="public"]')
->assertSeeIn('#public-state', 'false')
->assertAttribute('#public', 'role', 'switch');
});
it('grows a textarea with its text up to its maximum', function () {
$height = "document.querySelector('#message').getBoundingClientRect().height";
$page = fieldProbe();
$rows = $page->script("({$height})");
$page->type('#message', "one\ntwo\nthree")
->assertScript("({$height}) > {$rows}");
$three = $page->script("({$height})");
$page->type('#message', "one\ntwo\nthree\nfour\nfive\nsix\nseven")
->assertScript("Math.abs(({$height}) - ({$three} + 24)) < 2")
->assertScript("document.querySelector('#message').scrollHeight > document.querySelector('#message').clientHeight");
});
it('pairs the server\'s error with a trailing error icon in the error colour', function () {
$icon = "document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-error]')";
fieldProbe()
->assertScript("{$icon} === null")
->click('button:has-text("Save")')
->assertSee('The name field is required.')
// A field that trails with buttons of its own gives the icon's place to them.
->assertScript("document.querySelector('[data-md-field]:has(#name-field)').hasAttribute('data-md-invalid') && document.querySelector('[data-md-field]:has(#name-field) [data-md-field-error]') === null")
->assertScript("{$icon}.getAttribute('aria-label') === 'Error' && {$icon}.getAttribute('role') === 'img'")
->assertScript("{$icon}.getBoundingClientRect().width === 24")
->assertScript("getComputedStyle({$icon}).color === getComputedStyle(document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-support]')).color");
});
it('lights an enabled field\'s outline on hover, and never a disabled one\'s', function () {
$edge = fn (string $id): string => "getComputedStyle(document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-outline]')).borderTopColor";
$page = fieldProbe();
$resting = $page->script("({$edge('name-field')})");
$disabled = $page->script("({$edge('off-field')})");
$page->hover('[data-md-field]:has(#name-field) [data-md-field-box]')
->assertScript("({$edge('name-field')}) !== '{$resting}'");
$page->hover('[data-md-field]:has(#off-field) [data-md-field-box]')
->assertScript("({$edge('off-field')}) === '{$disabled}'")
// The disabled edge is on-surface at 12%, which is translucent.
->assertScript("/rgba?\\(.*,\\s*0?\\.\\d+\\)|color\\(srgb .* \\/ 0?\\.\\d+\\)/.test('{$disabled}')");
});
it('counts the characters as they are typed, marks a count past the maximum and says it once typing stops', function () {
$counter = "document.querySelector('[data-md-field]:has(#bio-field) [data-md-field-counter]')";
$page = fieldProbe()
->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '0/10'")
->type('#bio-field', 'Hello')
->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '5/10'")
->assertScript("! {$counter}.hasAttribute('data-md-over')");
$within = $page->script("getComputedStyle({$counter}).color");
// maxlength stops typing at the maximum, but a value put in from elsewhere can pass it.
$page->script("(() => { const input = document.querySelector('#bio-field'); input.value = 'Hello there!'; input.dispatchEvent(new Event('input', { bubbles: true })); })()");
$page->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '12/10'")
->assertScript("{$counter}.hasAttribute('data-md-over')")
->assertScript("getComputedStyle({$counter}).color !== '{$within}'")
->assertScript("{$counter}.querySelector('[aria-live]').textContent === ''")
->wait(1.3)
->assertScript("{$counter}.querySelector('[aria-live=\\'polite\\']').textContent === 'Character count, 12/10'");
});
function fieldWidthProbe(int $width)
{
Route::middleware('web')->get('/field-width-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
<style>.probe-narrow { max-width: 200px; }</style>
</head>
<body>
<div style="width: 1400px">
<x-input id="bounded" label="Bounded" />
<x-input id="narrow" label="Narrow" class="probe-narrow" />
<x-input id="full" label="Full" full />
</div>
</body>
</html>
BLADE));
return visit('/field-width-probe')->resize($width, 800)->waitForEvent('networkidle');
}
it('bounds a field to 40rem from 600px, unless the caller\'s width rule or full says otherwise', function () {
$width = fn (string $id): string => "document.querySelector('[data-md-field]:has(#{$id})').getBoundingClientRect().width";
fieldWidthProbe(599)
->assertScript("({$width('bounded')}) === 1400")
->assertScript("({$width('narrow')}) === 200")
->assertScript("({$width('full')}) === 1400");
fieldWidthProbe(600)
->assertScript("({$width('bounded')}) === 640")
->assertScript("({$width('narrow')}) === 200")
->assertScript("({$width('full')}) === 1400");
});
it('draws the field at M3\'s geometry and the file picker\'s button as a tonal pill', function () {
$box = fn (string $id): string => "document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-box]').getBoundingClientRect()";
fieldProbe()
->assertScript("({$box('name-field')}).height === 56")
// The control starts after the box's 16px padding.
->assertScript("document.querySelector('#name-field').getBoundingClientRect().left - ({$box('name-field')}).left === 16")
->assertScript("getComputedStyle(document.querySelector('#upload-field'), '::file-selector-button').height === '32px'")
->assertScript("getComputedStyle(document.querySelector('#upload-field'), '::file-selector-button').borderTopLeftRadius !== '0px'")
->assertScript("getComputedStyle(document.querySelector('#upload-field')).color === 'rgba(0, 0, 0, 0)'");
});
it('lets a selection control without a label be pressed at the edge of its 48px target', function () {
// A press 22px from the centre, past the drawn box but inside M3's 48px, lands on the control.
$pressAtEdge = fn (string $input, string $dx, string $dy): string => "(() => {
const input = document.querySelector('{$input}');
input.scrollIntoView({ block: 'center' });
const box = input.parentElement.getBoundingClientRect();
const target = document.elementFromPoint(box.left + box.width / 2 + {$dx}, box.top + box.height / 2 + {$dy});
target.click();
return input.checked;
})()";
fieldProbe()
->assertScript($pressAtEdge('#bare-check', '22', '0'))
->assertScript($pressAtEdge('#bare-switch', '0', '22'))
->assertScript($pressAtEdge('input[name=\"bare-radio\"]', '0', '-22'));
});