Files
livewire-material/tests/Feature/Components/FieldTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 0341f9d4ca 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
2026-09-14 14:08:22 +02:00

239 lines
12 KiB
PHP

<?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-md-field')
->toContain('data-md-variant="outlined"')
->toContain('<legend><span>Share name</span></legend>')
->toMatch('/<label for="(field-[0-9a-f]{12})" data-md-field-label>Share name<\/label>/')
->toContain('placeholder=" "')
->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-md-variant="filled"');
config(['livewire-material.fields.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" data-md-field-label>')
->toContain('id="share-name"')
->toContain('aria-describedby="share-name-support"')
->toMatch('/<p id="share-name-support" data-md-field-support\s*>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-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');
});
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" />'))
->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('data-md-field-error')
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" icon-right="info" wire:model="email" />'))
->not->toContain('data-md-field-error')
->and((string) $this->blade('<x-input id="email" label="Email" />'))
->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)
->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);
});
it('offers to clear and to copy the value', function () {
$html = (string) $this->blade('<x-input label="Link" clearable copyable />');
expect($html)
->toContain('data-md-field-clear')
->toContain('aria-label="Clear"')
->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"');
});
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-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')
->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-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-md-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-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('data-md-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-md-floated')
->toContain('Choose at most 10 photos.')
->toContain('photo.jpg is larger than 2 GB.');
});
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('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>')
->toContain('aria-live="polite"')
->toContain('Shown on your profile');
expect((string) $this->blade('<x-textarea id="note" label="Note" maxlength="140" 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('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 >= 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-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 () {
$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 data-md-form wire:submit="save">')
->toContain('role="separator"')
->toContain('<div data-md-form-actions class="justify-between">')
->not->toContain('grid-cols-1')
->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"');
});
it('draws a form\'s column and its actions from its stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/form.css');
expect($css)->toContain('@layer material.components')
->toContain('grid-template-columns: minmax(0, 1fr);')
->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>');
});