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
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user