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:
co-authored by
Claude Opus 5
parent
a6e5a593d5
commit
0341f9d4ca
@@ -183,7 +183,7 @@ it('opens the docked picker under its field and walks the grid from the keyboard
|
||||
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'")
|
||||
->assertAttribute('#expires-field', 'aria-expanded', 'true')
|
||||
->assertScript("(() => { const field = document.querySelector('#expires-field').closest('.field-box').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()")
|
||||
->assertScript("(() => { const field = document.querySelector('#expires-field').closest('[data-md-field-box]').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()")
|
||||
->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14'));
|
||||
@@ -395,15 +395,15 @@ it('opens a docked picker as a modal one on a compact window', function () {
|
||||
|
||||
it('empties a date, or both ends of a range, with its clear button', function () {
|
||||
$page = dateProbe()
|
||||
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('.field').querySelector('[data-field-clear]')).display !== 'none'");
|
||||
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('[data-md-field]').querySelector('[data-md-field-clear]')).display !== 'none'");
|
||||
|
||||
$page->click('[data-datepicker]:has(#expires-field) [data-field-clear]')
|
||||
$page->click('[data-datepicker]:has(#expires-field) [data-md-field-clear]')
|
||||
->assertScript("document.querySelector('#expires').textContent === ''")
|
||||
->assertValue('#expires-field', '')
|
||||
->assertScript("document.activeElement.id === 'expires-field'")
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'");
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-md-field-clear]')).display === 'none'");
|
||||
|
||||
$page->click('[data-datepicker]:has(#trip-field) [data-field-clear]')
|
||||
$page->click('[data-datepicker]:has(#trip-field) [data-md-field-clear]')
|
||||
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
||||
->assertValue('#trip-field', '');
|
||||
});
|
||||
|
||||
@@ -47,6 +47,9 @@ class FieldProbe extends Component
|
||||
<x-radio label="Audience" wire:model.live="audience" :options="[['id' => 'anyone', 'name' => 'Anyone'], ['id' => 'team', 'name' => 'Team']]" />
|
||||
<x-toggle id="public" label="Public" wire:model.live="public" />
|
||||
<x-textarea id="message" label="Message" rows="2" max-rows="4" wire:model="message" />
|
||||
<x-input id="bio-field" label="Bio" maxlength="10" counter />
|
||||
<x-input id="off-field" label="Off" value="Not editable" disabled />
|
||||
<x-input id="plain-name-field" label="Name, plain" wire:model="name" />
|
||||
|
||||
<x-button label="Save" wire:click="save" />
|
||||
</div>
|
||||
@@ -95,7 +98,7 @@ it('clears a field and tells Livewire', function () {
|
||||
fieldProbe()
|
||||
->type('#name-field', 'Holiday')
|
||||
->assertSeeIn('#name', 'Holiday')
|
||||
->click('[data-field-clear]')
|
||||
->click('[data-md-field-clear]')
|
||||
->assertScript("document.querySelector('#name-field').value === ''")
|
||||
->assertScript("document.querySelector('#name').textContent === ''")
|
||||
->assertScript("document.activeElement.id === 'name-field'");
|
||||
@@ -106,7 +109,7 @@ it('copies a field\'s value and says so', function () {
|
||||
|
||||
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
||||
|
||||
$page->click('[data-field-copy]')
|
||||
$page->click('[data-md-field-copy]')
|
||||
->assertScript("window.copied === 'Holiday'")
|
||||
->assertSee('Copied to the clipboard');
|
||||
});
|
||||
@@ -114,10 +117,10 @@ it('copies a field\'s value and says so', function () {
|
||||
it('shows and hides a password', function () {
|
||||
fieldProbe()
|
||||
->type('#password-field', 'secret')
|
||||
->click('[data-field-reveal]')
|
||||
->click('[data-md-field-reveal]')
|
||||
->assertScript("document.querySelector('#password-field').type === 'text'")
|
||||
->assertAttribute('[data-field-reveal]', 'aria-label', 'Hide password')
|
||||
->click('[data-field-reveal]')
|
||||
->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password')
|
||||
->click('[data-md-field-reveal]')
|
||||
->assertScript("document.querySelector('#password-field').type === 'password'");
|
||||
});
|
||||
|
||||
@@ -126,7 +129,7 @@ it('shows the server\'s error in place of the hint', function () {
|
||||
->click('button:has-text("Save")')
|
||||
->assertSee('The name field is required.')
|
||||
->assertAttribute('#name-field', 'aria-invalid', 'true')
|
||||
->assertScript("document.querySelector('#name-field').closest('.field').hasAttribute('data-invalid')");
|
||||
->assertScript("document.querySelector('#name-field').closest('[data-md-field]').hasAttribute('data-md-invalid')");
|
||||
});
|
||||
|
||||
it('binds a select to Livewire', function () {
|
||||
@@ -183,3 +186,93 @@ it('grows a textarea with its text up to its maximum', function () {
|
||||
->assertScript("Math.abs(({$height}) - ({$three} + 24)) < 2")
|
||||
->assertScript("document.querySelector('#message').scrollHeight > document.querySelector('#message').clientHeight");
|
||||
});
|
||||
|
||||
it('pairs the server\'s error with a trailing error icon in the error colour', function () {
|
||||
$icon = "document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-error]')";
|
||||
|
||||
fieldProbe()
|
||||
->assertScript("{$icon} === null")
|
||||
->click('button:has-text("Save")')
|
||||
->assertSee('The name field is required.')
|
||||
// A field that trails with buttons of its own gives the icon's place to them.
|
||||
->assertScript("document.querySelector('[data-md-field]:has(#name-field)').hasAttribute('data-md-invalid') && document.querySelector('[data-md-field]:has(#name-field) [data-md-field-error]') === null")
|
||||
->assertScript("{$icon}.getAttribute('aria-label') === 'Error' && {$icon}.getAttribute('role') === 'img'")
|
||||
->assertScript("{$icon}.getBoundingClientRect().width === 24")
|
||||
->assertScript("getComputedStyle({$icon}).color === getComputedStyle(document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-support]')).color");
|
||||
});
|
||||
|
||||
it('lights an enabled field\'s outline on hover, and never a disabled one\'s', function () {
|
||||
$edge = fn (string $id): string => "getComputedStyle(document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-outline]')).borderTopColor";
|
||||
|
||||
$page = fieldProbe();
|
||||
|
||||
$resting = $page->script("({$edge('name-field')})");
|
||||
$disabled = $page->script("({$edge('off-field')})");
|
||||
|
||||
$page->hover('[data-md-field]:has(#name-field) [data-md-field-box]')
|
||||
->assertScript("({$edge('name-field')}) !== '{$resting}'");
|
||||
|
||||
$page->hover('[data-md-field]:has(#off-field) [data-md-field-box]')
|
||||
->assertScript("({$edge('off-field')}) === '{$disabled}'")
|
||||
// The disabled edge is on-surface at 12%, which is translucent.
|
||||
->assertScript("/rgba?\\(.*,\\s*0?\\.\\d+\\)|color\\(srgb .* \\/ 0?\\.\\d+\\)/.test('{$disabled}')");
|
||||
});
|
||||
|
||||
it('counts the characters as they are typed, marks a count past the maximum and says it once typing stops', function () {
|
||||
$counter = "document.querySelector('[data-md-field]:has(#bio-field) [data-md-field-counter]')";
|
||||
|
||||
$page = fieldProbe()
|
||||
->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '0/10'")
|
||||
->type('#bio-field', 'Hello')
|
||||
->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '5/10'")
|
||||
->assertScript("! {$counter}.hasAttribute('data-md-over')");
|
||||
|
||||
$within = $page->script("getComputedStyle({$counter}).color");
|
||||
|
||||
// maxlength stops typing at the maximum, but a value put in from elsewhere can pass it.
|
||||
$page->script("(() => { const input = document.querySelector('#bio-field'); input.value = 'Hello there!'; input.dispatchEvent(new Event('input', { bubbles: true })); })()");
|
||||
|
||||
$page->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '12/10'")
|
||||
->assertScript("{$counter}.hasAttribute('data-md-over')")
|
||||
->assertScript("getComputedStyle({$counter}).color !== '{$within}'")
|
||||
->assertScript("{$counter}.querySelector('[aria-live]').textContent === ''")
|
||||
->wait(1.3)
|
||||
->assertScript("{$counter}.querySelector('[aria-live=\\'polite\\']').textContent === 'Character count, 12/10'");
|
||||
});
|
||||
|
||||
function fieldWidthProbe(int $width)
|
||||
{
|
||||
Route::middleware('web')->get('/field-width-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
<style>.probe-narrow { max-width: 200px; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="width: 1400px">
|
||||
<x-input id="bounded" label="Bounded" />
|
||||
<x-input id="narrow" label="Narrow" class="probe-narrow" />
|
||||
<x-input id="full" label="Full" full />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/field-width-probe')->resize($width, 800)->waitForEvent('networkidle');
|
||||
}
|
||||
|
||||
it('bounds a field to 40rem from 600px, unless the caller\'s width rule or full says otherwise', function () {
|
||||
$width = fn (string $id): string => "document.querySelector('[data-md-field]:has(#{$id})').getBoundingClientRect().width";
|
||||
|
||||
fieldWidthProbe(599)
|
||||
->assertScript("({$width('bounded')}) === 1400")
|
||||
->assertScript("({$width('narrow')}) === 200")
|
||||
->assertScript("({$width('full')}) === 1400");
|
||||
|
||||
fieldWidthProbe(600)
|
||||
->assertScript("({$width('bounded')}) === 640")
|
||||
->assertScript("({$width('narrow')}) === 200")
|
||||
->assertScript("({$width('full')}) === 1400");
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ it('draws a searchable combobox with a popover list', function () {
|
||||
->toContain('popover="manual"')
|
||||
->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
|
||||
->toContain('x-modelable="value"')
|
||||
->toContain('field-arrow')
|
||||
->toContain('data-md-field-arrow')
|
||||
// WAI-ARIA's combobox keyboard, Home and End included.
|
||||
->toContain('x-on:keydown.home="jump($event, false)"')
|
||||
->toContain('x-on:keydown.end="jump($event, true)"')
|
||||
|
||||
@@ -21,7 +21,7 @@ it('draws a docked date field whose combobox controls a popover picker', functio
|
||||
|
||||
expect($html)
|
||||
->toContain('data-datepicker')
|
||||
->toContain('<label for="expires" class="field-label">Expires on</label>')
|
||||
->toContain('<label for="expires" data-md-field-label>Expires on</label>')
|
||||
->toContain('role="combobox"')
|
||||
->toContain('aria-haspopup="dialog"')
|
||||
->toContain('aria-controls="expires-picker"')
|
||||
@@ -155,8 +155,8 @@ it('puts class on the root and every other attribute on the text field', functio
|
||||
|
||||
expect($html)
|
||||
->toContain('class="w-60"')
|
||||
->toContain('data-variant="filled"')
|
||||
->toContain('data-size="sm"')
|
||||
->toContain('data-md-variant="filled"')
|
||||
->toContain('data-md-size="sm"')
|
||||
->toMatch('/<input\s[^>]*required[^>]*id="due"/s')
|
||||
->toMatch('/<input\s[^>]*disabled[^>]*id="due"/s')
|
||||
->and(datepickerConfig($html)['disabled'])->toBeTrue();
|
||||
@@ -225,14 +225,14 @@ it('replaces the hint with the errors for the property and its start and end', f
|
||||
->not->toContain('Pick a day')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('data-datepicker-error')
|
||||
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
|
||||
->and(substr_count($html, 'data-md-invalid=""'))->toBe(2);
|
||||
});
|
||||
|
||||
it('offers a clear button on request', function () {
|
||||
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
|
||||
->toContain('data-field-clear')
|
||||
->toContain('data-md-field-clear')
|
||||
->toContain('x-on:click="clear()"')
|
||||
->toContain('aria-label="Clear"')
|
||||
->and((string) $this->blade('<x-datepicker label="Expires" />'))
|
||||
->not->toContain('data-field-clear');
|
||||
->not->toContain('data-md-field-clear');
|
||||
});
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
|
||||
@@ -19,14 +19,14 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
|
||||
$html = (string) $this->blade('<x-timepicker id="starts" label="Starts at" hint="In your time zone" value="14:30" format="24" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<label for="starts" class="field-label">Starts at</label>')
|
||||
->toContain('<label for="starts" data-md-field-label>Starts at</label>')
|
||||
->toMatch('/<input[^>]*id="starts"[^>]*readonly/s')
|
||||
->toContain('value="14:30"')
|
||||
->toContain('aria-haspopup="dialog"')
|
||||
->toContain('aria-controls="starts-dialog"')
|
||||
->toContain('aria-expanded="false"')
|
||||
->toContain('aria-describedby="starts-support"')
|
||||
->toContain('<p id="starts-support" class="field-support">In your time zone</p>')
|
||||
->toMatch('/<p id="starts-support" data-md-field-support\s*>In your time zone<\/p>/')
|
||||
->toContain('x-on:keydown.enter.prevent="show()"')
|
||||
->toContain('data-timepicker-open')
|
||||
->toContain('aria-label="Choose time"')
|
||||
@@ -38,7 +38,7 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
|
||||
->toContain('x-modelable="value"')
|
||||
->toContain("materialTimepicker( '14:30' ,")
|
||||
->not->toContain('aria-invalid="true"')
|
||||
->not->toContain('data-invalid');
|
||||
->not->toContain('data-md-invalid');
|
||||
});
|
||||
|
||||
it('draws the dial as a slider, with twelve numbers per ring and M3\'s 24-hour inner ring', function () {
|
||||
@@ -153,7 +153,7 @@ it('shows the errors for its property in place of the hint', function () {
|
||||
});
|
||||
|
||||
expect(Livewire::test('timepicker-errors')->html())
|
||||
->toContain('data-invalid')
|
||||
->toContain('data-md-invalid')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('aria-describedby="starts-support"')
|
||||
->toContain('Choose a time during opening hours.')
|
||||
@@ -165,13 +165,13 @@ it('puts the field props on the field, its attributes on the input, and posts a
|
||||
|
||||
expect($html)
|
||||
->toMatch('/^<div\s+class="w-60"/')
|
||||
->toContain('data-variant="filled"')
|
||||
->toContain('data-size="sm"')
|
||||
->toContain('data-md-variant="filled"')
|
||||
->toContain('data-md-size="sm"')
|
||||
->toContain('data-timepicker-field')
|
||||
->toContain('required')
|
||||
->toContain('placeholder="hh:mm"')
|
||||
->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />')
|
||||
->toContain('data-field-clear')
|
||||
->toContain('data-md-field-clear')
|
||||
->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s')
|
||||
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ it('renders components that use others under a prefix', function () {
|
||||
expect(Blade::render('<x-m::drawer title="Share" with-close-button separator><x-m::input label="Name" clearable /></x-m::drawer>'))
|
||||
->toContain('aria-label="Close"')
|
||||
->toContain('role="separator"')
|
||||
->toContain('data-field-clear');
|
||||
->toContain('data-md-field-clear');
|
||||
});
|
||||
|
||||
it('is not shadowed by an application component of the same name', function () {
|
||||
|
||||
Reference in New Issue
Block a user