From 0341f9d4cabd11a00ad0909baf58969961581d68 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:08:22 +0200 Subject: [PATCH] Draw the text field's chrome without Tailwind 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 +- resources/css/components.css | 1 + resources/css/components/field.css | 317 +++++++++--------- resources/css/components/menu.css | 46 +-- resources/css/components/timepicker.css | 2 +- resources/css/tailwind.css | 1 - resources/js/field.js | 4 +- resources/views/components/choices.blade.php | 10 +- .../views/components/datepicker.blade.php | 30 +- resources/views/components/field.blade.php | 63 ++-- resources/views/components/file.blade.php | 2 +- resources/views/components/input.blade.php | 25 +- resources/views/components/password.blade.php | 12 +- resources/views/components/select.blade.php | 5 +- resources/views/components/textarea.blade.php | 6 +- .../views/components/timepicker.blade.php | 15 +- .../views/showcase/sections/fields.blade.php | 4 +- tests/Browser/DatepickerTest.php | 10 +- tests/Browser/FieldsTest.php | 105 +++++- tests/Feature/Components/ChoicesTest.php | 2 +- tests/Feature/Components/DatepickerTest.php | 12 +- tests/Feature/Components/FieldTest.php | 110 +++--- tests/Feature/Components/TimepickerTest.php | 14 +- tests/Feature/InternalComponentsTest.php | 2 +- 24 files changed, 478 insertions(+), 322 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 56713dd2..99e21c1e 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -629,7 +629,7 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire - `counter` (on `` and ``) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops. - ``: native `, diff --git a/resources/views/components/timepicker.blade.php b/resources/views/components/timepicker.blade.php index 81de4e7a..0098650e 100644 --- a/resources/views/components/timepicker.blade.php +++ b/resources/views/components/timepicker.blade.php @@ -135,6 +135,7 @@ $inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']); $disabled = (bool) $attributes->get('disabled'); $described = $messages !== [] || filled($hint); + $fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24; @endphp
- + @endif @@ -184,12 +186,13 @@ type="button" tabindex="-1" x-on:click="show()" - class="field-trailing field-button" + data-md-field-trailing + data-md-field-button aria-label="{{ __('Choose time') }}" data-timepicker-open @disabled($disabled) > - + diff --git a/resources/views/showcase/sections/fields.blade.php b/resources/views/showcase/sections/fields.blade.php index 717cb59e..de789876 100644 --- a/resources/views/showcase/sections/fields.blade.php +++ b/resources/views/showcase/sections/fields.blade.php @@ -26,13 +26,13 @@
- +
- +
diff --git a/tests/Browser/DatepickerTest.php b/tests/Browser/DatepickerTest.php index aab70af2..79ad1712 100644 --- a/tests/Browser/DatepickerTest.php +++ b/tests/Browser/DatepickerTest.php @@ -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', ''); }); diff --git a/tests/Browser/FieldsTest.php b/tests/Browser/FieldsTest.php index b3e6fe50..6532f1f6 100644 --- a/tests/Browser/FieldsTest.php +++ b/tests/Browser/FieldsTest.php @@ -47,6 +47,9 @@ class FieldProbe extends Component + + +
@@ -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' + + + + + @vite(config('livewire-material.showcase.vite')) + + + +
+ + + +
+ + + 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"); +}); diff --git a/tests/Feature/Components/ChoicesTest.php b/tests/Feature/Components/ChoicesTest.php index 310832c5..d2234a4e 100644 --- a/tests/Feature/Components/ChoicesTest.php +++ b/tests/Feature/Components/ChoicesTest.php @@ -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)"') diff --git a/tests/Feature/Components/DatepickerTest.php b/tests/Feature/Components/DatepickerTest.php index 70dbec43..34d663a3 100644 --- a/tests/Feature/Components/DatepickerTest.php +++ b/tests/Feature/Components/DatepickerTest.php @@ -21,7 +21,7 @@ it('draws a docked date field whose combobox controls a popover picker', functio expect($html) ->toContain('data-datepicker') - ->toContain('') + ->toContain('') ->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('/]*required[^>]*id="due"/s') ->toMatch('/]*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('')) - ->toContain('data-field-clear') + ->toContain('data-md-field-clear') ->toContain('x-on:click="clear()"') ->toContain('aria-label="Clear"') ->and((string) $this->blade('')) - ->not->toContain('data-field-clear'); + ->not->toContain('data-md-field-clear'); }); diff --git a/tests/Feature/Components/FieldTest.php b/tests/Feature/Components/FieldTest.php index 25d945a3..1997bc12 100644 --- a/tests/Feature/Components/FieldTest.php +++ b/tests/Feature/Components/FieldTest.php @@ -4,31 +4,33 @@ it('draws an outlined field with a notched label unless told otherwise', functio $html = (string) $this->blade(''); expect($html) - ->toContain('data-variant="outlined"') + ->toContain('data-md-field') + ->toContain('data-md-variant="outlined"') ->toContain('Share name') - ->toMatch('/