Skip to content

Commit 235adb6

Browse files
skovhusclaude
andauthored
fix(selectors): preserve parent pseudo context for nested attribute selectors (#362)
* fix(selectors): preserve parent pseudo context for nested attribute selectors Nested attribute selectors inside pseudo-class blocks (e.g., `&:focus { &[data-x="y"] {} }`) were losing parent context during Stylis form-feed stripping, producing `:is([data-x="y"])` instead of `:focus:is([data-x="y"])`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Improve test case * fix(selectors): preserve sibling combinator in nested pseudo contexts When Stylis resolves nested selectors like `&:hover { & + & {} }`, the `props` array contains the fully flattened form (e.g., `:hover+:hover`) which loses the `& + &` pattern needed by the sibling handler. Skip the resolved props when either raw or resolved selector contains a sibling combinator (`+`/`~`) outside attribute brackets. Addresses review comment about nested self-sibling selectors regressing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e0e123b commit 235adb6

4 files changed

Lines changed: 171 additions & 5 deletions

File tree

src/internal/css-ir.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,27 @@ export function normalizeStylisAstToIR(
193193
if (!stripFormFeedInSelectors || !selectorValue.includes("\f") || !propsArr?.length) {
194194
return selectorRaw;
195195
}
196+
// Stylis resolves nested selectors into `props` (e.g., &:focus { &[data-x] {} }
197+
// becomes props: [":focus[data-x]"]). The raw `value` only has the innermost
198+
// selector with form-feed separators, so stripping \f loses parent context.
199+
// Use `props` when the resolved selectors carry MORE context than selectorRaw
200+
// (i.e., parent pseudo/attribute context was folded in during nesting resolution).
201+
// Avoid using props when they carry LESS info (e.g., `& + &` → props: ["+"]
202+
// loses the trailing `&`).
196203
const stringProps = propsArr.filter((p): p is string => typeof p === "string");
197-
const hasPseudoElement = stringProps.some((p) => p.includes("::"));
198-
if (hasPseudoElement && !selectorRaw.includes("::")) {
199-
return stringProps.map((p) => `&${p}`).join(",");
204+
const resolved = stringProps.map((p) => `&${p}`).join(",");
205+
// When either the raw or resolved selector contains a sibling combinator (`+`/`~`),
206+
// always preserve the raw form — the downstream sibling handler requires the exact
207+
// `& + &` / `& ~ &` pattern. Stylis resolves these into forms like `:hover+:hover`
208+
// that lose the `&` anchors, and can also inject combinators into child selectors
209+
// (e.g., `&:hover` resolved to `&+:hover` inside a `& + &` parent).
210+
// Strip `[...]` to avoid false positives from attribute selectors like `[attr~=val]`.
211+
const hasSiblingCombinator = (s: string) => /[+~]/.test(s.replace(/\[[^\]]*\]/g, ""));
212+
if (hasSiblingCombinator(selectorRaw) || hasSiblingCombinator(resolved)) {
213+
return selectorRaw;
214+
}
215+
if (resolved.length > selectorRaw.length) {
216+
return resolved;
200217
}
201218
return selectorRaw;
202219
})();

src/internal/selectors.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,16 @@ function parseSingleSelector(selector: selectorParser.Selector): ParsedSelector
187187
if (!hasNesting || hasCombinator || hasClass || hasId || hasTag || hasUniversal) {
188188
return { kind: "unsupported", reason: "attribute selector" };
189189
}
190-
if (pseudoClasses.length > 0 || pseudoElements.length > 0) {
191-
return { kind: "unsupported", reason: "attribute selector with pseudo" };
190+
if (pseudoElements.length > 0) {
191+
return { kind: "unsupported", reason: "attribute selector with pseudo-element" };
192192
}
193193
const attrStr = attributes.map((a) => a.toString()).join("");
194+
// Combine pseudo-classes with attribute selectors:
195+
// &:focus[data-x="y"] → ":focus:is([data-x="y"])"
196+
if (pseudoClasses.length > 0) {
197+
const pseudoString = buildPseudoString(pseudoClasses);
198+
return { kind: "pseudo", pseudos: [`${pseudoString}:is(${attrStr})`] };
199+
}
194200
return { kind: "pseudo", pseudos: [`:is(${attrStr})`] };
195201
}
196202

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Nested attribute selectors inside pseudo-class selectors
2+
import styled from "styled-components";
3+
4+
const MenuDiv = styled.div`
5+
background-color: #f0f0f0;
6+
padding: 16px;
7+
overscroll-behavior: none;
8+
9+
&:focus,
10+
&:focus-visible {
11+
background-color: #bf4f74;
12+
color: white;
13+
&[data-highlighted="true"] {
14+
background-color: #2e86c1;
15+
}
16+
}
17+
`;
18+
19+
const InteractiveBox = styled.div`
20+
background-color: white;
21+
padding: 12px;
22+
border: 2px solid #ccc;
23+
24+
&:hover {
25+
border-color: #bf4f74;
26+
&[data-muted="true"] {
27+
border-color: #ddd;
28+
opacity: 0.5;
29+
}
30+
}
31+
32+
&:focus {
33+
outline: 2px solid blue;
34+
&[data-no-outline="true"] {
35+
outline: none;
36+
}
37+
}
38+
`;
39+
40+
export function App() {
41+
return (
42+
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}>
43+
<MenuDiv tabIndex={0}>Menu (focus me)</MenuDiv>
44+
<MenuDiv tabIndex={0} data-highlighted="true">
45+
Menu (highlighted on focus)
46+
</MenuDiv>
47+
<InteractiveBox tabIndex={0}>Interactive Box</InteractiveBox>
48+
<InteractiveBox tabIndex={0} data-muted="true">
49+
Interactive Box (muted)
50+
</InteractiveBox>
51+
<InteractiveBox tabIndex={0} data-no-outline="true">
52+
Interactive Box (no outline)
53+
</InteractiveBox>
54+
</div>
55+
);
56+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React from "react";
2+
import * as stylex from "@stylexjs/stylex";
3+
4+
function MenuDiv(
5+
props: { "data-highlighted"?: boolean | string } & Pick<
6+
React.ComponentProps<"div">,
7+
"children" | "tabIndex"
8+
>,
9+
) {
10+
const { children, ...rest } = props;
11+
return (
12+
<div {...rest} sx={styles.menuDiv}>
13+
{children}
14+
</div>
15+
);
16+
}
17+
18+
function InteractiveBox(
19+
props: {
20+
"data-muted"?: boolean | string;
21+
"data-no-outline"?: boolean | string;
22+
} & Pick<React.ComponentProps<"div">, "children" | "tabIndex">,
23+
) {
24+
const { children, ...rest } = props;
25+
return (
26+
<div {...rest} sx={styles.interactiveBox}>
27+
{children}
28+
</div>
29+
);
30+
}
31+
32+
export function App() {
33+
return (
34+
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}>
35+
<MenuDiv tabIndex={0}>Menu (focus me)</MenuDiv>
36+
<MenuDiv tabIndex={0} data-highlighted="true">
37+
Menu (highlighted on focus)
38+
</MenuDiv>
39+
<InteractiveBox tabIndex={0}>Interactive Box</InteractiveBox>
40+
<InteractiveBox tabIndex={0} data-muted="true">
41+
Interactive Box (muted)
42+
</InteractiveBox>
43+
<InteractiveBox tabIndex={0} data-no-outline="true">
44+
Interactive Box (no outline)
45+
</InteractiveBox>
46+
</div>
47+
);
48+
}
49+
50+
const styles = stylex.create({
51+
menuDiv: {
52+
backgroundColor: {
53+
default: "#f0f0f0",
54+
":focus": "#bf4f74",
55+
":focus-visible": "#bf4f74",
56+
':focus:is([data-highlighted="true"])': "#2e86c1",
57+
':focus-visible:is([data-highlighted="true"])': "#2e86c1",
58+
},
59+
padding: 16,
60+
overscrollBehavior: "none",
61+
color: {
62+
default: null,
63+
":focus": "white",
64+
":focus-visible": "white",
65+
},
66+
},
67+
interactiveBox: {
68+
backgroundColor: "white",
69+
padding: 12,
70+
borderWidth: 2,
71+
borderStyle: "solid",
72+
borderColor: {
73+
default: "#ccc",
74+
":hover": "#bf4f74",
75+
':hover:is([data-muted="true"])': "#ddd",
76+
},
77+
opacity: {
78+
default: null,
79+
':hover:is([data-muted="true"])': 0.5,
80+
},
81+
outline: {
82+
default: null,
83+
":focus": "2px solid blue",
84+
':focus:is([data-no-outline="true"])': "none",
85+
},
86+
},
87+
});

0 commit comments

Comments
 (0)