<x-file> marks its field root data-md-file, and the tonal ::file-selector-button with its hover and disabled states moves from field.css to components/file.css on the state tokens (plan step 36). A browser test pins the field's 56px box and 16px padding and the pill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
292 lines
13 KiB
PHP
292 lines
13 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" />
|
|
|
|
<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");
|
|
});
|
|
|
|
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)'");
|
|
});
|