Input, Select: settle hover/invalid/focus precedence on specificity - #43
Merged
Merged
Conversation
Deploying ui with
|
| Latest commit: |
7ab6821
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://0b5f7b93.ui-2wg.pages.dev |
| Branch Preview URL: | https://fix-input-select-state-prece.ui-2wg.pages.dev |
Both recipes paint hover, invalid and focus into border-color and relied on declaration order to rank them. Panda does not preserve that order: it sorts a recipe's state rules against a fixed table :link :visited :focus-within :focus :focus-visible :hover :active scoring each selector by the first entry it contains, and emitting lowest score first. `_hover` scores highest of the three, and anything the table does not mention — `[data-invalid]`, `:user-invalid` — scores zero, so the emitted order is invalid, focus, hover regardless of how the recipe is written. All three selectors carried the same specificity, so hover won every tie. Five collisions, all border-color: .input hover over [data-invalid] and over :user-invalid .input hover over :is(:focus-visible, [data-focused]) .select__trigger hover over [data-invalid] > & .select__trigger hover over [data-focus-visible] Visibly: clicking into a field left the grey hover border with a blue ring, and a hovered invalid field showed a grey border with a red ring. The Select trigger needed keyboard focus plus a hovering pointer to show it, react-aria setting data-focus-visible on keyboard focus only; its ComboBox arm was already safe because :has(input:focus) happens to carry an extra element's worth of specificity. Writing hover as a raw `&:is(:hover, [data-hovered])` selector does not help — the sort scores raw selectors and condition tokens alike. So rank the states with repeated `&`, which Panda expands to the recipe class written two or three times: same elements matched, higher specificity, and an order the cascade guarantees. Variants are unaffected, landing in a later layer.
microbit-matt-hillsdon
force-pushed
the
fix-input-select-state-precedence
branch
from
August 4, 2026 15:55
56e9ef8 to
7ab6821
Compare
Contributor
Author
|
Looks good in Storybook. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reported downstream: an input focused while the pointer is still over it (i.e.
right after you click into it) keeps the grey hover border instead of going blue,
though the ring is blue either way.
Confirmed, and it is a bit wider than reported.
Cause
Both recipes paint hover, invalid and focus into
border-color, and both reliedon declaration order to rank them — the comments said as much. Panda does not
preserve that order. It sorts a recipe's state rules against a fixed table:
Each selector scores by the first entry it contains, and rules are emitted
lowest-score first.
_hoverscores highest of the three, and anything the tabledoes not mention —
[data-invalid],:user-invalid— scores zero. So whateverthe recipe says, the emitted order is invalid, focus, hover. Every one of these
selectors carried the same specificity, so hover won all the ties.
Five collisions, all on
border-color:.input[data-invalid].input:user-invalid.input:is(:focus-visible, [data-focused]).select__trigger[data-invalid] > &.select__trigger[data-focus-visible]So besides the reported case, a hovered invalid field shows a grey border with a
red ring.
The Select trigger is affected too, which the report had down as measuring
correctly. It needs keyboard focus and a hovering pointer to show — react-aria
sets
data-focus-visibleon keyboard focus only, so clicking a trigger neverlights it. Tab to a trigger, then move the mouse over it. Its ComboBox arm was
already safe by luck:
:has(input:focus)counts asinput:focus, an extraelement's worth of specificity over hover.
No other recipe is affected. Button, Checkbox, GridList, ListBox, Radio and
Toast all combine hover with focus, but their focus rules set
box-shadowandoutlinewhere hover setsbackground, so they never contend.Fix
The suggested one-liner — writing hover as a raw
&:is(:hover, [data-hovered])instead of the
_hovertoken — does not work. I tried it: the sort scores rawselectors and condition tokens alike, and hover is still emitted last. The token
is not what puts it there.
Instead the states are ranked with repeated
&, which Panda expands to therecipe class written more than once:
An element either has the class or it does not, so
.input.inputmatches exactlythe same elements — each repetition only adds specificity. That makes the
hover < invalid < focus ladder something the cascade guarantees rather than
something a sort can undo. Variants are unaffected: they land in a later cascade
layer, so apps restyling through the
variantgroup (classroom's pill) stilloverride freely.
Verifying
tests/state-precedence.test.tsgenerates the stylesheet, parses out the rulesfor both slots, and asserts each state beats the one below on specificity or, on
a tie, on emission order. It has to work on generated CSS: jsdom implements no
cascade, so rendering a component tells you nothing about which rule wins.
Checked that it fails on the unfixed recipes (both cases, on the invalid-vs-hover
tie) and passes with the change. Full suite 57 passing, typecheck clean.
One gap: I could not do a live browser check — localhost is blocked by policy in
my environment and
file://URLs are rejected — so this rests on the generatedCSS and specificity, not on someone seeing a blue border. Worth an eyeball in
Storybook before merging.
Raised by Claude Code; please review the reasoning rather than take it on trust.