From 7ab68217dd080e73f54a95864e0735e8825f2694 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Tue, 4 Aug 2026 16:50:54 +0100 Subject: [PATCH] Input, Select: settle hover/invalid/focus precedence on specificity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/Input.recipe.ts | 17 +++++++++++++---- packages/ui/src/Select.recipe.ts | 11 +++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/Input.recipe.ts b/packages/ui/src/Input.recipe.ts index 37932dd..d68c28f 100644 --- a/packages/ui/src/Input.recipe.ts +++ b/packages/ui/src/Input.recipe.ts @@ -17,8 +17,17 @@ const transitionCommon = * * Focus matches both native `:focus-visible` (plain inputs; browsers treat any * focus in a text field as focus-visible) and react-aria's `data-focused` - * (inputs inside RAC TextField). Focus is declared after invalid so a focused - * invalid field shows the focus ring, as in Chakra. + * (inputs inside RAC TextField). + * + * Hover, invalid and focus all set `borderColor`, so their precedence has to be + * hover < invalid < focus. Declaration order will not buy that: Panda sorts a + * recipe's state rules itself, ranking selectors against a fixed + * link/visited/focus/hover/active table, which puts `_hover` *after* focus and + * after anything the table doesn't mention (`[data-invalid]`). Equal-specificity + * rules then leave hover winning. So the ladder is spelled with repeated `&` + * instead — `&&` and `&&&` emit `.input.input` and `.input.input.input`, making + * precedence specificity rather than order, which nothing downstream can + * resort. Variants still override freely; they land in a later cascade layer. * * Registered in the base preset (base-preset.ts), which also has the * `staticCss` entry that keeps the runtime-prop size variants generated. @@ -39,11 +48,11 @@ export const input = defineRecipe({ bg: "inherit", color: "inherit", _hover: { borderColor: "gray.300" }, - "&[data-invalid], &:user-invalid": { + "&&:is([data-invalid], :user-invalid)": { borderColor: "danger.500", boxShadow: "0 0 0 1px token(colors.danger.500)", }, - "&:is(:focus-visible, [data-focused])": { + "&&&:is(:focus-visible, [data-focused])": { zIndex: 1, borderColor: "focusBorder", boxShadow: "0 0 0 1px token(colors.focusBorder)", diff --git a/packages/ui/src/Select.recipe.ts b/packages/ui/src/Select.recipe.ts index e6fabb9..10abdec 100644 --- a/packages/ui/src/Select.recipe.ts +++ b/packages/ui/src/Select.recipe.ts @@ -82,9 +82,12 @@ export const select = defineSlotRecipe({ // `> &` rather than a descendant selector, so an app's own invalid form // wrapper cannot paint every control inside it red. // - // Declared after hover and before focus so red beats a hover tint and - // the focus ring beats red, as in the input recipe. - "[data-invalid] > &": { + // Doubled `&` for the same reason as the input recipe: hover, invalid and + // focus all set `borderColor`, and Panda sorts state rules by its own + // pseudo-class table rather than declaration order, so hover would win + // these ties. The repeated `&` makes the hover < invalid < focus ladder a + // matter of specificity instead. + "[data-invalid] > &&": { borderColor: "danger.500", boxShadow: "0 0 0 1px token(colors.danger.500)", }, @@ -98,7 +101,7 @@ export const select = defineSlotRecipe({ // focus moves to an option (aria-activedescendant) — which strips RAC's // attribute for as long as the list has an active option, real focus // never having left. Select's trigger holds no input, so it can't match. - "&[data-focus-visible], &:has(input:focus)": { + "&&&[data-focus-visible], &&&:has(input:focus)": { boxShadow: "0 0 0 1px token(colors.focusBorder)", borderColor: "focusBorder", outline: "2px solid transparent",