Add M3 text fields, selects and selection controls
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m4s
tests / lint (push) Successful in 59s
tests / browser (chrome, chromium) (push) Successful in 2m54s
tests / browser (firefox, firefox) (push) Successful in 3m3s
tests / browser (safari, webkit) (push) Successful in 4m13s
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m4s
tests / lint (push) Successful in 59s
tests / browser (chrome, chromium) (push) Successful in 2m54s
tests / browser (firefox, firefox) (push) Successful in 3m3s
tests / browser (safari, webkit) (push) Successful in 4m13s
Outlined and filled text fields (input, password, auto-growing textarea, native select with the customizable select menu, file), checkbox with an indeterminate state, radio buttons, the M3 switch and form, ported from ReStride and extended. The first half of Phase 6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad724662ca
commit
7f92f1cb29
@@ -0,0 +1,185 @@
|
||||
<?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-pressed', 'true')
|
||||
->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");
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
it('draws an outlined field with a notched label unless told otherwise', function () {
|
||||
$html = (string) $this->blade('<x-input label="Share name" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-variant="outlined"')
|
||||
->toContain('<legend><span>Share name</span></legend>')
|
||||
->toMatch('/<label for="(field-[0-9a-f]{12})" class="field-label">Share name<\/label>/')
|
||||
->toContain('placeholder=" "')
|
||||
->toContain('class="field-control"');
|
||||
});
|
||||
|
||||
it('draws a filled field on request, or by the application\'s default', function () {
|
||||
expect((string) $this->blade('<x-input label="Name" variant="filled" />'))->toContain('data-variant="filled"');
|
||||
|
||||
config(['livewire-material.fields.variant' => 'filled']);
|
||||
|
||||
expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-variant="filled"')
|
||||
->and((string) $this->blade('<x-input label="Name" variant="outlined" />'))->toContain('data-variant="outlined"')
|
||||
->and((string) $this->blade('<x-input label="Name" variant="glass" />'))->toContain('data-variant="filled"');
|
||||
});
|
||||
|
||||
it('ties the label, the hint and the control together', function () {
|
||||
$html = (string) $this->blade('<x-input id="share-name" label="Share name" hint="Recipients see this name" wire:model="name" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<label for="share-name" class="field-label">')
|
||||
->toContain('id="share-name"')
|
||||
->toContain('aria-describedby="share-name-support"')
|
||||
->toContain('<p id="share-name-support" class="field-support">Recipients see this name</p>')
|
||||
->toContain('wire:model="name"')
|
||||
->not->toContain('aria-invalid');
|
||||
});
|
||||
|
||||
it('replaces the hint with the errors for its model', function () {
|
||||
$html = (string) $this->withViewErrors(['email' => ['Enter a valid email address.']])
|
||||
->blade('<x-input id="email" label="Email" hint="We never share it" wire:model.live="email" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-invalid')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('aria-describedby="email-support"')
|
||||
->toContain('Enter a valid email address.')
|
||||
->not->toContain('We never share it');
|
||||
});
|
||||
|
||||
it('puts icons, affixes and the size on the field, and every other attribute on the input', function () {
|
||||
$html = (string) $this->blade('<x-input label="Price" icon="payments" icon-right="info" prefix="CHF" suffix="per month" size="sm" type="number" min="0" class="w-40" mono readonly />');
|
||||
|
||||
expect($html)
|
||||
->toContain('class="field w-40"')
|
||||
->toContain('data-size="sm"')
|
||||
->toContain('data-mono')
|
||||
->toContain('data-readonly')
|
||||
->toContain('<span class="field-affix">CHF</span>')
|
||||
->toContain('<span class="field-affix">per month</span>')
|
||||
->toContain('type="number"')
|
||||
->toContain('min="0"')
|
||||
->and(substr_count($html, '<svg'))->toBe(2);
|
||||
});
|
||||
|
||||
it('offers to clear and to copy the value', function () {
|
||||
$html = (string) $this->blade('<x-input label="Link" clearable copyable />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-field-clear')
|
||||
->toContain('aria-label="Clear"')
|
||||
->toContain('data-field-copy')
|
||||
->toContain('navigator.clipboard.writeText')
|
||||
->toContain('aria-label="Copy"');
|
||||
});
|
||||
|
||||
it('lets a password be shown and hidden again', function () {
|
||||
$html = (string) $this->blade('<x-password label="Password" autocomplete="current-password" type="text" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('type="password"')
|
||||
->not->toContain('type="text"')
|
||||
->toContain('x-bind:type="shown ? \'text\' : \'password\'"')
|
||||
->toContain('data-field-reveal')
|
||||
->toContain('aria-pressed="false"')
|
||||
->toContain('autocomplete="current-password"');
|
||||
});
|
||||
|
||||
it('grows a textarea from its rows up to its maximum, or leaves it to be resized', function () {
|
||||
expect((string) $this->blade('<x-textarea label="Message" rows="2" max-rows="6" />'))
|
||||
->toContain('rows="2"')
|
||||
->toContain('data-autogrow')
|
||||
->toContain('--field-rows: 2;')
|
||||
->toContain('--field-max-rows: 6;')
|
||||
->and((string) $this->blade('<x-textarea label="Message" :autogrow="false" />'))
|
||||
->toContain('rows="3"')
|
||||
->not->toContain('data-autogrow');
|
||||
});
|
||||
|
||||
it('draws a native select whose label always floats', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-select label="Expires" wire:model="hours" placeholder="Choose…" option-value="hours" option-label="label" :options="[
|
||||
['hours' => 1, 'label' => '1 hour'],
|
||||
['hours' => 720, 'label' => '30 days', 'disabled' => true],
|
||||
]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-floated')
|
||||
->toContain('<select')
|
||||
->toContain('wire:model="hours"')
|
||||
->toContain('<option value="">Choose…</option>')
|
||||
->toContain('<option value="1" >1 hour</option>')
|
||||
->toContain('<option value="720" disabled>30 days</option>')
|
||||
->toContain('field-arrow');
|
||||
});
|
||||
|
||||
it('takes a select\'s options from its slot as well', function () {
|
||||
expect((string) $this->blade('<x-select label="Size"><option value="s">Small</option></x-select>'))
|
||||
->toContain('<option value="s">Small</option>');
|
||||
});
|
||||
|
||||
it('collects a file field\'s own errors and each file\'s', function () {
|
||||
$html = (string) $this->withViewErrors([
|
||||
'photos' => ['Choose at most 10 photos.'],
|
||||
'photos.0' => ['photo.jpg is larger than 2 GB.'],
|
||||
])->blade('<x-file label="Photos" wire:model="photos" multiple />');
|
||||
|
||||
expect($html)
|
||||
->toContain('type="file"')
|
||||
->toContain('multiple')
|
||||
->toContain('data-floated')
|
||||
->toContain('Choose at most 10 photos.')
|
||||
->toContain('photo.jpg is larger than 2 GB.');
|
||||
});
|
||||
|
||||
it('lays out a form with its actions under an optional divider', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-form wire:submit="save" separator>
|
||||
<x-input label="Name" />
|
||||
<x-slot:actions class="justify-between"><button>Save</button></x-slot:actions>
|
||||
</x-form>
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('<form class="grid grid-cols-1 auto-rows-min gap-4" wire:submit="save">')
|
||||
->toContain('role="separator"')
|
||||
->toContain('justify-between')
|
||||
->toContain('<button>Save</button>')
|
||||
->and((string) $this->blade('<x-form><x-slot:actions><button>Save</button></x-slot:actions></x-form>'))
|
||||
->not->toContain('role="separator"');
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
it('draws a checkbox whose row is its label', function () {
|
||||
$html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<div class="min-w-0 mt-2">')
|
||||
->toContain('<label for="notify" data-selection')
|
||||
->toContain('data-checkbox')
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('wire:model="notify"')
|
||||
->toContain('data-check')
|
||||
->toContain('data-mixed')
|
||||
->toContain('Notify me')
|
||||
->toContain('On every download')
|
||||
->not->toContain('data-indeterminate');
|
||||
});
|
||||
|
||||
it('marks a checkbox that is partly ticked', function () {
|
||||
expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-indeterminate');
|
||||
});
|
||||
|
||||
it('puts a checkbox at the end of its row on request', function () {
|
||||
expect((string) $this->blade('<x-checkbox label="Show" right />'))->toContain('flex-row-reverse justify-between');
|
||||
});
|
||||
|
||||
it('shows a checkbox\'s errors', function () {
|
||||
$html = (string) $this->withViewErrors(['terms' => ['Accept the terms to continue.']])
|
||||
->blade('<x-checkbox id="terms" label="I accept the terms" wire:model="terms" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-invalid')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('aria-describedby="terms-support"')
|
||||
->toContain('Accept the terms to continue.');
|
||||
});
|
||||
|
||||
it('draws radio buttons in a fieldset named by its question', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-radio label="Who can open it" wire:model.live="audience" :options="[
|
||||
['id' => 'anyone', 'name' => 'Anyone with the link'],
|
||||
['id' => 'password', 'name' => 'Anyone with the password', 'hint' => 'Share it separately'],
|
||||
['id' => 'team', 'name' => 'My team', 'disabled' => true],
|
||||
]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('<fieldset')
|
||||
->toContain('Who can open it</legend>')
|
||||
->and(substr_count($html, 'type="radio"'))->toBe(3)
|
||||
->and(substr_count($html, 'name="audience"'))->toBe(3)
|
||||
->and(substr_count($html, 'wire:model.live="audience"'))->toBe(3)
|
||||
->and($html)->toContain('value="password"')
|
||||
->toContain('Share it separately')
|
||||
->toMatch('/value="team"\s+disabled/');
|
||||
});
|
||||
|
||||
it('names unbound radios after their group and checks the given value', function () {
|
||||
$html = (string) $this->blade('<x-radio name="theme" value="dark" inline :options="[[\'id\' => \'light\', \'name\' => \'Light\'], [\'id\' => \'dark\', \'name\' => \'Dark\']]" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('sm:flex')
|
||||
->and(substr_count($html, 'name="theme"'))->toBe(2)
|
||||
->and($html)->toMatch('/value="dark"\s+checked/')
|
||||
->not->toMatch('/value="light"\s+checked/');
|
||||
});
|
||||
|
||||
it('shows the errors of a radio group', function () {
|
||||
expect((string) $this->withViewErrors(['audience' => ['Choose who can open it.']])
|
||||
->blade('<x-radio wire:model="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
|
||||
->toContain('data-invalid')
|
||||
->toContain('Choose who can open it.');
|
||||
});
|
||||
|
||||
it('draws a switch on a checkbox with the switch role', function () {
|
||||
$html = (string) $this->blade('<x-toggle id="public" label="Public link" wire:model.live="public" right />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<label for="public" data-selection')
|
||||
->toContain('data-switch')
|
||||
->toContain('role="switch"')
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('wire:model.live="public"')
|
||||
->toContain('data-handle')
|
||||
->toContain('flex-row-reverse')
|
||||
->not->toContain('data-icons');
|
||||
});
|
||||
|
||||
it('puts icons on a switch\'s handle', function () {
|
||||
expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-icons="both"')->toContain('data-on')->toContain('data-off')
|
||||
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-icons="selected"');
|
||||
});
|
||||
Reference in New Issue
Block a user