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

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:
Andreas Reinhold / reini
2026-09-13 07:53:14 +02:00
co-authored by Claude Opus 5
parent ad724662ca
commit 7f92f1cb29
24 changed files with 2296 additions and 1 deletions
+149
View File
@@ -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"');
});