Skip to content

Commit edf7aa9

Browse files
skovhusclaude
andauthored
feat(selector): support &:has(${Component}) same-file selectors (#370)
* feat(selector): support &:has(${Component}) same-file selectors Transforms `&:has(${StyledIcon})` to `stylex.when.descendant(":is(*)", IconMarker)`, enabling parent components to conditionally style themselves based on the presence of a specific child component. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): avoid shorthand/longhand conflict in selector-has test case The previous test used `padding: 8px 16px` (expands to logical `paddingInline`) with `&:has() { padding-right: 32px }` — the computed-key default lookup doesn't resolve physical longhands from logical shorthands, causing `default: null`. Changed to non-conflicting properties (background + color) instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(computed-keys): resolve logical shorthand base values for physical longhands When a computed-key override targets a physical longhand (e.g. paddingRight) and the base style uses a logical shorthand (e.g. paddingInline from `padding: 8px 16px`), the default value lookup now falls back to the logical shorthand. Previously this produced `default: null`, losing the base padding. Restores the original selector-has test case with shorthand + longhand pattern that exercises this fix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(selector): bail on compound :has() selectors like &:has(${Icon}):hover The loose HAS_COMPONENT_SELECTOR_RE regex matched any selector containing &:has(__SC_EXPR_N__), letting compound forms like &:has(${Icon}):hover bypass the interpolated-pseudo bailout. This caused them to fall through into the descendant-component handler, producing invalid output with unresolved placeholders. Now uses the strict anchored regex so only exact &:has(${Component}) selectors are handled; compound forms correctly bail. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(selector): normalize specificity hacks for :has() detection, bail on unresolvable media Two review fixes: - Use specificity-normalized selector for isHasPattern so &&:has(${Icon}) is correctly detected as a :has() pattern instead of falling through to the descendant-component handler - Add state.markBail() in resolveMediaAndEmitComputedKeys when media query interpolation cannot resolve, preventing lossy transformation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f8d85d2 commit edf7aa9

File tree

9 files changed

+367
-109
lines changed

9 files changed

+367
-109
lines changed

eslint.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export default [
7575
// for valid computed property keys like [stylex.when.siblingBefore(...)].
7676
files: [
7777
"test-cases/selector-componentSiblingCombinator.output.tsx",
78+
"test-cases/selector-has.output.tsx",
7879
"test-cases/selector-siblingMedia.output.tsx",
7980
],
8081
rules: { "stylex/valid-styles": "off" },
@@ -97,5 +98,12 @@ export default [
9798
rules: { "stylex/valid-styles": "off" },
9899
},
99100

101+
{
102+
// stylex.when.descendant(): `:has()` selectors have limited browser support
103+
// but are valid and intentionally emitted by the codemod for same-file component selectors.
104+
files: ["test-cases/selector-has.output.tsx"],
105+
rules: { "stylex/no-lookahead-selectors": "off" },
106+
},
107+
100108
...storybook.configs["flat/recommended"],
101109
];

src/__tests__/transform.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5633,3 +5633,74 @@ export function App() {
56335633
expect(result.code).not.toMatch(/\{\.\.\.stylex\.props\([^)]+\)\}\s*className=/);
56345634
});
56355635
});
5636+
5637+
describe("compound :has() component selectors", () => {
5638+
it("should bail on &:has(${Component}):hover (compound pseudo + has)", () => {
5639+
const source = `
5640+
import styled from "styled-components";
5641+
5642+
const Icon = styled.span\`
5643+
color: blue;
5644+
\`;
5645+
5646+
const Button = styled.button\`
5647+
background: lightgray;
5648+
5649+
&:has(\${Icon}):hover {
5650+
background: lightyellow;
5651+
}
5652+
\`;
5653+
5654+
export const App = () => (
5655+
<div>
5656+
<Button>No icon</Button>
5657+
<Button>With icon <Icon>★</Icon></Button>
5658+
</div>
5659+
);
5660+
`;
5661+
5662+
const result = transformWithWarnings(
5663+
{ source, path: "test.tsx" },
5664+
{ jscodeshift: j, j, stats: () => {}, report: () => {} },
5665+
{ adapter: fixtureAdapter },
5666+
);
5667+
5668+
// Should bail — compound :has + pseudo is not supported
5669+
expect(result.code).toBeNull();
5670+
});
5671+
5672+
it("should handle &:has(${Component}) with specificity hack (&&:has)", () => {
5673+
const source = `
5674+
import styled from "styled-components";
5675+
5676+
const Icon = styled.span\`
5677+
color: blue;
5678+
\`;
5679+
5680+
const Button = styled.button\`
5681+
background: lightgray;
5682+
5683+
&&:has(\${Icon}) {
5684+
background: lightyellow;
5685+
}
5686+
\`;
5687+
5688+
export const App = () => (
5689+
<div>
5690+
<Button>No icon</Button>
5691+
<Button>With icon <Icon>★</Icon></Button>
5692+
</div>
5693+
);
5694+
`;
5695+
5696+
const result = transformWithWarnings(
5697+
{ source, path: "test.tsx" },
5698+
{ jscodeshift: j, j, stats: () => {}, report: () => {} },
5699+
{ adapter: fixtureAdapter },
5700+
);
5701+
5702+
// Should transform (not bail) — && is a specificity hack normalized to &
5703+
expect(result.code).not.toBeNull();
5704+
expect(result.code).toContain("stylex.when.descendant");
5705+
});
5706+
});

src/internal/logger.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ export type WarningType =
9898
| "Unsupported selector: grouped reverse selector references different components"
9999
| "Unsupported selector: computed media query inside cross-component sibling selector"
100100
| "Unsupported selector: computed media query inside sibling selector"
101+
| "Unsupported selector: computed media query inside :has() component selector"
102+
| "Unsupported selector: cross-file :has() component selector not yet supported"
103+
| "Unsupported selector: unresolved interpolation in :has() component selector"
101104
| "Unsupported selector: unknown component selector"
102105
| "Unsupported css`` mixin: after-base mixin style is not a plain object"
103106
| "Unsupported css`` mixin: nested contextual conditions in after-base mixin"

0 commit comments

Comments
 (0)