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",