Draw the text field's chrome without Tailwind

<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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 14:08:22 +02:00
co-authored by Claude Opus 5
parent a6e5a593d5
commit 0341f9d4ca
24 changed files with 478 additions and 322 deletions
+70 -40
View File
@@ -4,31 +4,33 @@ it('draws an outlined field with a notched label unless told otherwise', functio
$html = (string) $this->blade('<x-input label="Share name" />');
expect($html)
->toContain('data-variant="outlined"')
->toContain('data-md-field')
->toContain('data-md-variant="outlined"')
->toContain('<legend><span>Share name</span></legend>')
->toMatch('/<label for="(field-[0-9a-f]{12})" class="field-label">Share name<\/label>/')
->toMatch('/<label for="(field-[0-9a-f]{12})" data-md-field-label>Share name<\/label>/')
->toContain('placeholder=" "')
->toContain('class="field-control"');
->toContain('data-md-field-control')
->not->toContain('class=');
});
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"');
expect((string) $this->blade('<x-input label="Name" variant="filled" />'))->toContain('data-md-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"');
expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-md-variant="filled"')
->and((string) $this->blade('<x-input label="Name" variant="outlined" />'))->toContain('data-md-variant="outlined"')
->and((string) $this->blade('<x-input label="Name" variant="glass" />'))->toContain('data-md-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('<label for="share-name" data-md-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>')
->toMatch('/<p id="share-name-support" data-md-field-support\s*>Recipients see this name<\/p>/')
->toContain('wire:model="name"')
->not->toContain('aria-invalid');
});
@@ -38,8 +40,9 @@ it('replaces the hint with the errors for its model', function () {
->blade('<x-input id="email" label="Email" hint="We never share it" wire:model.live="email" />');
expect($html)
->toContain('data-invalid')
->toContain('data-md-invalid')
->toContain('aria-invalid="true"')
->toContain('role="alert"')
->toContain('aria-describedby="email-support"')
->toContain('Enter a valid email address.')
->not->toContain('We never share it');
@@ -49,28 +52,26 @@ it('pairs the error colours with a trailing error icon', function () {
$errors = ['email' => ['Enter a valid email address.']];
expect((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" wire:model="email" />'))
->toContain('field-trailing field-error')
->toContain('aria-label="Error"')
->toMatch('/<svg(?=[^>]*data-md-field-trailing)(?=[^>]*data-md-field-error)(?=[^>]*aria-label="Error")(?=[^>]*role="img")[^>]*>/')
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" size="xs" wire:model="email" />'))
->not->toContain('field-error')
->not->toContain('data-md-field-error')
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" icon-right="info" wire:model="email" />'))
->not->toContain('field-error')
->not->toContain('data-md-field-error')
->and((string) $this->blade('<x-input id="email" label="Email" />'))
->not->toContain('field-error');
->not->toContain('data-md-field-error');
});
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>')
->toMatch('/<div\s+data-md-field\s+data-md-variant="outlined"\s+data-md-size="sm"\s+data-md-mono\s+class="w-40" data-md-readonly=""\s*>/')
->toContain('<span data-md-field-affix>CHF</span>')
->toContain('<span data-md-field-affix>per month</span>')
->toContain('type="number"')
->toContain('min="0"')
// An sm field's icons are 20px, drawn from the 20 cut.
->and(substr_count($html, '--md-icon-size: 20px'))->toBe(2)
->and(substr_count($html, '<svg'))->toBe(2);
});
@@ -78,9 +79,12 @@ 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('data-md-field-clear')
->toContain('aria-label="Clear"')
->toContain('data-field-copy')
->toContain('data-md-field-copy')
->toContain("\$el.closest('[data-md-field]').querySelector('[data-md-field-control]')")
// A labelled field is md: its icons are 24px.
->toContain('--md-icon-size: 24px')
->toContain('navigator.clipboard.writeText')
->toContain('aria-label="Copy"');
});
@@ -92,7 +96,7 @@ it('lets a password be shown and hidden again', function () {
->toContain('type="password"')
->not->toContain('type="text"')
->toContain('x-bind:type="shown ? \'text\' : \'password\'"')
->toContain('data-field-reveal')
->toContain('data-md-field-reveal')
->toContain('aria-label="Show password"')
// The flipping label already says the state; aria-pressed would say it again, inverted.
->not->toContain('aria-pressed')
@@ -102,12 +106,12 @@ it('lets a password be shown and hidden again', function () {
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('data-md-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');
->not->toContain('data-md-autogrow');
});
it('draws a native select whose label always floats', function () {
@@ -119,13 +123,13 @@ it('draws a native select whose label always floats', function () {
BLADE);
expect($html)
->toContain('data-floated')
->toContain('data-md-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');
->toContain('data-md-field-arrow');
});
it('takes a select\'s options from its slot as well', function () {
@@ -142,7 +146,7 @@ it('collects a file field\'s own errors and each file\'s', function () {
expect($html)
->toContain('type="file"')
->toContain('multiple')
->toContain('data-floated')
->toContain('data-md-floated')
->toContain('Choose at most 10 photos.')
->toContain('photo.jpg is larger than 2 GB.');
});
@@ -151,8 +155,10 @@ it('counts the characters of a field that has a maximum', function () {
$html = (string) $this->blade('<x-input id="bio" label="Bio" hint="Shown on your profile" maxlength="60" counter />');
expect($html)
->toContain('class="field-support-row"')
->toContain('class="field-counter"')
->toContain('data-md-field-support-row')
->toContain('data-md-field-counter')
->toContain('x-bind:data-md-over="used > max"')
->toContain('class="md-visually-hidden"')
->toContain('maxlength="60"')
->toContain('max: 60,')
->toContain('>0/60</span>')
@@ -160,24 +166,24 @@ it('counts the characters of a field that has a maximum', function () {
->toContain('Shown on your profile');
expect((string) $this->blade('<x-textarea id="note" label="Note" maxlength="140" counter />'))
->toContain('class="field-counter"')
->toContain('data-md-field-counter')
->toContain('>0/140</span>');
});
it('shows no counter without `counter` or without a maximum to count against', function () {
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('field-counter')
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('field-counter')
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('field-counter');
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('data-md-field-counter')
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('data-md-field-counter')
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('data-md-field-counter');
});
it('bounds a field\'s width from medium unless it is told to fill its pane', function () {
expect(file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))
->toContain('@media (width >= 37.5rem)')
->toMatch('/\.field:not\(\[data-full\]\)\s*\{\s*max-width: 40rem;/');
->toContain('@media (width >= 600px)')
->toMatch('/\[data-md-field\]:not\(\[data-md-full\]\)\s*\{\s*max-width: 40rem;/');
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-full')
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-full')
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-full');
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-md-full')
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-md-full')
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-md-full');
});
it('lays out a form with its actions under an optional divider', function () {
@@ -206,3 +212,27 @@ it('draws a form\'s column and its actions from its stylesheet', function () {
->toContain('gap: var(--md-sys-measurement-space200);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/form.css';");
});
it('keeps the field\'s chrome in its own stylesheet in the components layer', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css');
expect($css)->toContain('@layer material.components')
->toContain("@import './icon.css';")
->toContain('[data-md-field-box]')
->not->toMatch('/(?<![\\w-])\\.field\\b/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/field.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('field.css');
});
it('takes a custom control marked as the field\'s control, and a hint class', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-field id="expiry" label="Expiry" hint="In the future" hint-class="md-ink-primary">
<input id="expiry" data-md-field-control value="2030-01-01" />
</x-field>
BLADE);
expect($html)
->toContain('<input id="expiry" data-md-field-control value="2030-01-01" />')
->toMatch('/<p id="expiry-support" data-md-field-support\s+class="md-ink-primary"\s*>In the future<\/p>/')
->toContain('<fieldset aria-hidden="true" data-md-field-outline>');
});