Shift+M and Shift+Y reach the month and year dropdowns, which is M3's date picker keyboard table; Home and End go to the ends of an open combobox list, and stay the field's own caret keys when it is closed. The password reveal drops aria-pressed and keeps only its flipping label, so a screen reader hears the state once rather than twice and inverted. The radio's `inline` now says that M3 cautions against a row and wants an option chosen on load. Plan step 20, findings IN-22, IN-23, IN-28 and IN-29. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
186 lines
6.9 KiB
PHP
186 lines
6.9 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-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-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-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-field-reveal]')
|
|
->assertScript("document.querySelector('#password-field').type === 'text'")
|
|
->assertAttribute('[data-field-reveal]', 'aria-label', 'Hide password')
|
|
->click('[data-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('.field').hasAttribute('data-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");
|
|
});
|