<x-field> renders data-md-field with its props and parts as data-md-* attributes (box, control, label, outline, support, counter, trailing buttons), and field.css moves into material.components on tokens: px geometry, spacing and state tokens, the 600px breakpoint (plan step 36). Its icons take a size prop, 24/20/16 by the field's size. Every control the field wraps now marks itself data-md-field-control, so the inputs, the pickers, choices, field.js, menu.css's select and listbox rules and timepicker.css follow the renamed hooks. Browser tests cover the error icon, the disabled field's hover, the counter and the width bound from 600px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
279 lines
12 KiB
PHP
279 lines
12 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-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-indeterminate') && {$all}.indeterminate");
|
|
|
|
$page->click('label[for="file-b"]')
|
|
->click('label[for="file-c"]')
|
|
->assertSeeIn('#files', 'a,b,c')
|
|
->assertScript("! {$all}.hasAttribute('data-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");
|
|
});
|