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('/