Files
livewire-material/tests/Feature/Components/FieldTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 925d9a4439 Fold layout.css and components.css into all.css
Plan step 37: all.css replaces the two interim import lists with one
entry for an application that wants everything — the foundation, every
layout stylesheet and every component stylesheet, grouped under the
same block comments components.css used, plus a Layout block. It also
directly imports the three files nothing imported by name before
(layout/spacing.css, layout/visibility.css, components/selection.css),
so every file under components/ and layout/ is now one @import away.

Every test that read a block of components.css or layout.css now reads
the matching block of all.css through one shared helper (allCssBlock(),
in tests/Pest.php so it loads for any test run) instead of repeating
the same substr() search in each file. StylesheetsTest.php's shape,
Tailwind-free and breakpoint checks, previously run twice (once for
the foundation, once for the layout tree), now run once over the whole
tree all.css reaches, since every component and layout stylesheet is
plain CSS after step 36; a new test asserts all.css imports everything
under components/ and layout/ exactly once. The Workbench imports
all.css in place of layout.css and components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 02:49:29 +02:00

301 lines
15 KiB
PHP

<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('hides the trailing button\'s state layer once it is disabled', function () {
// The field's trailing button (clear, copy, reveal) scales its state layer with the field's
// own icon size, which the foundation's fixed classes cannot draw, so it keeps its own
// declarations, keyed on the shared data-md-field-button hook. Disabled must still hide it.
expect(ComponentStylesheet::read('field')->declarations("[data-md-field-button]:disabled::before, [data-md-field-button][aria-disabled='true']::before"))->toBe(['display' => 'none']);
});
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-input')
->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="" data-md-input="data-md-input"\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-password')
->toMatch('/<button(?=[^>]*data-md-field-button)(?=[^>]*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-textarea')
->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('data-md-select')
->toMatch('/<select(?=[^>]*data-md-field-control)[^>]*>/')
->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-file')
->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('<span class="md-visually-hidden" aria-live="polite" aria-atomic="true" x-text="spoken"></span>')
->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/all.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/all.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>');
});
it('gives the one-line field a stylesheet that brings the field and its icons', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/input.css');
expect($css)->toStartWith('/*')
->toContain("@import './field.css';")
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/input.css';");
});
it('gives the password field a stylesheet that brings the field and its icons', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/password.css'))
->toContain("@import './field.css';")
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/password.css';");
});
it('draws what a textarea changes in the field from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/textarea.css');
expect($css)->toContain("@import './field.css';")
->toContain('@layer material.components')
->toContain('textarea[data-md-field-control][data-md-autogrow]')
->toContain('field-sizing: content;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/textarea.css';");
});
it('draws what a select changes in the field from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/select.css');
expect($css)->toContain("@import './field.css';")
->toContain('@supports (appearance: base-select)')
->toContain('select[data-md-field-control]:open')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/select.css';");
});
it('draws the file picker\'s button from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/file.css');
expect($css)->toContain("@import './field.css';")
->toContain("input[type='file'][data-md-field-control]::file-selector-button")
->toContain('background-color: var(--md-sys-color-secondary-container);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/file.css';");
});