Plan step 36, step 0 of the inputs stream: mirrors ActionStylesheetsTest's
five checks over the seventeen inputs components already rewritten, plus
pagination (its four views live under resources/views/pagination/**, not
components/, so it gets its own small versions of the two view-reading
checks). The "no class list" check also recognises the wrapper fields'
`:class="$attributes->get('class')"` (Blade's own prop-forwarding syntax,
the only way a component with no root element of its own hands the caller's
class to its field) and a `'class' => '…'` attribute-bag entry, and exempts
field.css's week-long autofill transition from the motion-token check.
The new test caught three already-rewritten components writing a bare
`md-visually-hidden` class, which a package view no longer does (only the
three interaction classes, or the caller's own class, reach a view): field's
character-count live region, search's result-count live region, and chip's
native checkbox under a bound filter chip. Fixed by giving each its own
data-md-* hook with the same visually-hidden declarations in its stylesheet,
and chip.css was also missing its `tooltip.css` import for the tooltip
chip.blade.php renders.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
301 lines
15 KiB
PHP
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('data-md-field-live')
|
|
->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>');
|
|
});
|
|
|
|
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/components.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/components.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/components.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/components.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/components.css'))->toContain("@import './components/file.css';");
|
|
});
|