Skip to content

Input, Select: settle hover/invalid/focus precedence on specificity - #43

Merged
microbit-matt-hillsdon merged 1 commit into
mainfrom
fix-input-select-state-precedence
Aug 4, 2026
Merged

Input, Select: settle hover/invalid/focus precedence on specificity#43
microbit-matt-hillsdon merged 1 commit into
mainfrom
fix-input-select-state-precedence

Conversation

@microbit-matt-hillsdon

Copy link
Copy Markdown
Contributor

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 relied
on 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:

:link  :visited  :focus-within  :focus  :focus-visible  :hover  :active

Each selector scores by the first entry it contains, and rules are emitted
lowest-score first. _hover scores highest of the three, and anything the table
does not mention — [data-invalid], :user-invalid — scores zero. So whatever
the 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:

slot hover beat
.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-visible on keyboard focus only, so clicking a trigger never
lights it. Tab to a trigger, then move the mouse over it. Its ComboBox arm was
already safe by luck: :has(input:focus) counts as input:focus, an extra
element'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-shadow and
outline where hover sets background, so they never contend.

Fix

The suggested one-liner — writing hover as a raw &:is(:hover, [data-hovered])
instead of the _hover token — does not work. I tried it: the sort scores raw
selectors 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 the
recipe class written more than once:

.input:is(:hover, [data-hovered])                    0-2-0
.input.input:is([data-invalid], :user-invalid)        0-3-0
.input.input.input:is(:focus-visible, [data-focused]) 0-4-0

An element either has the class or it does not, so .input.input matches exactly
the 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 variant group (classroom's pill) still
override freely.

Verifying

tests/state-precedence.test.ts generates the stylesheet, parses out the rules
for 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 generated
CSS 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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploying ui with  Cloudflare Pages  Cloudflare Pages

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

View logs

Comment thread packages/ui/tests/state-precedence.test.ts Outdated
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
microbit-matt-hillsdon force-pushed the fix-input-select-state-precedence branch from 56e9ef8 to 7ab6821 Compare August 4, 2026 15:55
@microbit-matt-hillsdon

Copy link
Copy Markdown
Contributor Author

Looks good in Storybook.

@microbit-matt-hillsdon
microbit-matt-hillsdon merged commit fa62ca2 into main Aug 4, 2026
2 checks passed
@microbit-matt-hillsdon
microbit-matt-hillsdon deleted the fix-input-select-state-precedence branch August 4, 2026 15:58
@microbit-matt-hillsdon
microbit-matt-hillsdon restored the fix-input-select-state-precedence branch August 4, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant