diff --git a/UPGRADE.md b/UPGRADE.md index 51b1fe60..150c13bc 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -35,6 +35,12 @@ cut) with a width and height but no viewBox, and the package dropped the size, so they fitted only at exactly 24px and were cut off or misplaced at any other — the 20 cut every small button draws from included. Nothing to change. +- **A focused text field keeps its focus edge under the pointer.** The hover rule outweighed focus, + so a focused field the pointer rested on drew a 2px `on-surface` edge instead of `primary`, and + a field in error `on-error-container` instead of `error` — in the filled variant too, and on a + customizable `` while its menu is open. An open select in error now draws `error` + rather than `primary`. A test that read the hover colour off a focused field reads `primary` + now. ## From 2.0.0 to 2.1.0 diff --git a/resources/css/components/field.css b/resources/css/components/field.css index 054ed8b6..e9177e91 100644 --- a/resources/css/components/field.css +++ b/resources/css/components/field.css @@ -318,14 +318,18 @@ /* Hover only where a pointer can hover, and never on a disabled field: M3 gives a disabled control no state layer at all, and the disabled edge below is inherited, so a declaration - here would beat it whatever the selector weighs. */ + here would beat it whatever the selector weighs. M3 layers focus over hover, so a focused + field keeps its focus edge under the pointer: the condition is weightless (`:where()`, where + `:not(:has(…))` would weigh as much as its argument), and each state drawn over hover + outweighs it — focus (0,3,0) over hover (0,2,0), the error's focus (0,4,0) over the error's + hover (0,3,0), and select.css's open select (0,3,1) over both. */ @media (hover: hover) { - [data-md-field]:not(:has([data-md-field-control]:disabled)) [data-md-field-box]:hover { + :where([data-md-field]:not(:has([data-md-field-control]:disabled))) [data-md-field-box]:hover { --field-edge: var(--md-sys-color-on-surface); } } - [data-md-field-box]:focus-within { + [data-md-field] [data-md-field-box]:focus-within { --field-edge: var(--md-sys-color-primary); --field-ink: var(--md-sys-color-primary); } @@ -340,7 +344,7 @@ } @media (hover: hover) { - [data-md-field][data-md-invalid]:not(:has([data-md-field-control]:disabled)) [data-md-field-box]:hover { + :where([data-md-field]:not(:has([data-md-field-control]:disabled)))[data-md-invalid] [data-md-field-box]:hover { --field-edge: var(--md-sys-color-on-error-container); } } diff --git a/resources/css/components/select.css b/resources/css/components/select.css index 6b024754..1529ce0b 100644 --- a/resources/css/components/select.css +++ b/resources/css/components/select.css @@ -39,8 +39,10 @@ /* Where the browser has a customizable select, the closed select is its own button: without the border, padding and arrow that button brings, it is the same field as before. While its list is open the field reads as focused — - said from `:open`, because focus inside the list is in the top layer — and - the arrow turns over, as the searchable choices' does. */ + said from `:open`, because focus inside the list is in the top layer — in + the error colour when the field is in error, as a focused one is — and the + arrow turns over, as the searchable choices' does. Both outweigh field.css's + hover, so the pointer left on the field does not take the focus edge away. */ @supports (appearance: base-select) { select[data-md-field-control] { appearance: base-select; @@ -68,6 +70,11 @@ } } + [data-md-field][data-md-invalid] [data-md-field-box]:has(select[data-md-field-control]:open) { + --field-edge: var(--md-sys-color-error); + --field-ink: var(--md-sys-color-error); + } + [data-md-field]:has(select[data-md-field-control]:open) [data-md-field-arrow] { rotate: 180deg; } diff --git a/tests/Browser/FieldsTest.php b/tests/Browser/FieldsTest.php index 23f330fc..c23f4b83 100644 --- a/tests/Browser/FieldsTest.php +++ b/tests/Browser/FieldsTest.php @@ -2,6 +2,8 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Route; +use Illuminate\Support\MessageBag; +use Illuminate\Support\ViewErrorBag; use Livewire\Component; use Livewire\Livewire; @@ -246,6 +248,87 @@ it('lights an enabled field\'s outline on hover, and never a disabled one\'s', f ->assertScript("/rgba?\\(.*,\\s*0?\\.\\d+\\)|color\\(srgb .* \\/ 0?\\.\\d+\\)/.test('{$disabled}')"); }); +/** + * An outlined, a filled and an invalid field and a select, beside the three roles their edges take, + * with nothing that moves them after the first paint. + */ +function fieldStateProbe(): mixed +{ + Route::middleware('web')->get('/field-state-probe', function () { + view()->share('errors', (new ViewErrorBag)->put('default', new MessageBag(['broken' => ['Say what it is.'], 'broken-choice' => ['Choose one.']]))); + + return Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + +
+

primary

+

on-surface

+

error

+ + + + + +
+ @livewireScripts + + + BLADE); + }); + + return visit('/field-state-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'"); +} + +/** A field's edge, "width colour", once its colour transition has run: `Top` outlined, `Bottom` filled. */ +function settledEdge(string $id, string $side = 'Top'): string +{ + return "(async () => { const outline = document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-outline]'); await Promise.allSettled(outline.getAnimations().map((animation) => animation.finished)); const style = getComputedStyle(outline); return style.border{$side}Width + ' ' + style.border{$side}Color })()"; +} + +it('keeps a focused field\'s focus edge under the pointer, as M3 layers focus over hover', function () { + $page = fieldStateProbe(); + $role = fn (string $id): string => $page->script("getComputedStyle(document.getElementById('{$id}')).color"); + + // Hovered alone, the edge still answers the pointer. + $page->hover('[data-md-field]:has(#outlined-field) [data-md-field-box]'); + + expect($page->script(settledEdge('outlined-field')))->toBe('1px '.$role('on-surface')); + + // A click focuses the field and leaves the pointer over it. + $page->click('#outlined-field'); + + expect($page->script(settledEdge('outlined-field')))->toBe('2px '.$role('primary')); + + $page->click('#filled-field'); + + expect($page->script(settledEdge('filled-field', 'Bottom')))->toBe('2px '.$role('primary')); + + $page->click('#invalid-field'); + + expect($page->script(settledEdge('invalid-field')))->toBe('2px '.$role('error')); + + // A customizable select reads as focused while its menu is open, though focus is then on an + // option in the top layer: in primary under the pointer, and in error when the field is. + if ($page->script("CSS.supports('appearance', 'base-select')") === true) { + $page->click('#select-field')->assertScript("document.getElementById('select-field').matches(':open')"); + + expect($page->script(settledEdge('select-field')))->toBe('2px '.$role('primary')); + + $page->keys('#select-field', 'Escape')->assertScript("! document.getElementById('select-field').matches(':open')"); + + $page->click('#invalid-select-field')->assertScript("document.getElementById('invalid-select-field').matches(':open')"); + + expect($page->script(settledEdge('invalid-select-field')))->toBe('2px '.$role('error')); + } +}); + 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]')";