From 029c78d301c8ca4522940b9a63078e3612e4d687 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Thu, 17 Sep 2026 05:29:29 +0200 Subject: [PATCH] Keep a focused field's focus edge under the pointer The hover rule's `:not(:has([data-md-field-control]:disabled))` weighs as much as its argument, so hover weighed (0,5,0) against focus's (0,2,0), and the error's hover (0,6,0) against the error's focus (0,4,0): a focused field with the pointer on it drew a 2px on-surface edge instead of primary, a field in error on-error-container instead of error, in both variants. A customizable select with its menu open lost the same way, since focus is then on an option in the top layer and only select.css's `:open` rule (0,3,1) draws the focus edge. M3 layers focus over hover. The disabled condition is now weightless inside `:where()`, still keeping hover off a disabled field, and the focus rule gains the field ancestor: focus (0,3,0) outweighs hover (0,2,0), the error's focus (0,4,0) the error's hover (0,3,0), and the open select both. An open select in error drew primary, not error, and now draws error. A browser test hovers and clicks an outlined, a filled and an invalid field and opens a select and a select in error, comparing each edge with its role; it fails without the change (the error select without select.css's rule). Co-Authored-By: Claude Opus 5 (1M context) --- UPGRADE.md | 6 +++ resources/css/components/field.css | 12 +++-- resources/css/components/select.css | 11 +++- tests/Browser/FieldsTest.php | 83 +++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 6 deletions(-) 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]')";