Skip to content

Commit df91f14

Browse files
committed
Fix: Nested Global CSS using hoisting selectors
1 parent 2a6fe64 commit df91f14

File tree

7 files changed

+112
-8
lines changed

7 files changed

+112
-8
lines changed

.changeset/sharp-ears-wear.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@mincho-js/css": patch
3+
---
4+
5+
## New
6+
- Add `VariantStyle` type for constrained variant styles
7+
8+
## Changes
9+
- Allow nested selector in `globalCss` function

packages/css/src/css/index.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,91 @@ import { className, getDebugName } from "../utils.js";
1616

1717
// == Global CSS ===============================================================
1818
export function globalCss(selector: string, rule: GlobalCSSRule) {
19-
gStyle(selector, transform(rule) as GlobalStyleRule);
19+
const transformedStyle = transform({
20+
selectors: {
21+
[selector]: {
22+
...rule
23+
}
24+
}
25+
}) as CSSRule;
26+
27+
const { selectors, ...atRuleStyles } = transformedStyle;
28+
if (selectors !== undefined) {
29+
Object.entries(selectors).forEach(([selector, styles]) => {
30+
gStyle(selector, styles as GlobalStyleRule);
31+
});
32+
}
33+
34+
if (atRuleStyles !== undefined) {
35+
const otherStyles = hoistSelectors(atRuleStyles);
36+
Object.entries(otherStyles.selectors).forEach(([atRule, atRuleStyles]) => {
37+
gStyle(atRule, atRuleStyles as GlobalStyleRule);
38+
});
39+
}
2040
}
2141
export const globalStyle = globalCss;
2242

43+
// TODO: Make more type-safe
44+
type UnknownObject = Record<string, unknown>;
45+
type CSSRuleMap = Record<string, CSSRule>;
46+
interface HoistResult {
47+
selectors: CSSRuleMap;
48+
}
49+
50+
function hoistSelectors(input: CSSRule): HoistResult {
51+
const result: HoistResult = {
52+
selectors: {}
53+
};
54+
55+
function processAtRules(obj: UnknownObject, path: string[] = []) {
56+
for (const key in obj) {
57+
if (key === "selectors") {
58+
// Hoist each selector when selectors are found
59+
const selectors = obj[key] as CSSRuleMap;
60+
for (const selector in selectors) {
61+
if (!result.selectors[selector]) {
62+
result.selectors[selector] = {};
63+
}
64+
65+
// Create nested object structure based on current path
66+
let current = result.selectors[selector] as UnknownObject;
67+
for (let i = 0; i < path.length; i += 2) {
68+
const atRule = path[i];
69+
const condition = path[i + 1];
70+
71+
if (!current[atRule]) {
72+
current[atRule] = {};
73+
}
74+
const atRuleObj = current[atRule] as UnknownObject;
75+
if (!atRuleObj[condition]) {
76+
atRuleObj[condition] = {};
77+
}
78+
79+
current = atRuleObj[condition] as UnknownObject;
80+
}
81+
82+
// Copy style properties
83+
Object.assign(current, selectors[selector]);
84+
}
85+
} else if (typeof obj[key] === "object" && obj[key] !== null) {
86+
// at-rule found (e.g: @media, @supports)
87+
const atRules = obj[key] as UnknownObject;
88+
for (const condition in atRules) {
89+
// Add current at-rule and condition to path and recursively call
90+
processAtRules(atRules[condition] as UnknownObject, [
91+
...path,
92+
key,
93+
condition
94+
]);
95+
}
96+
}
97+
}
98+
}
99+
100+
processAtRules(input as UnknownObject);
101+
return result;
102+
}
103+
23104
// == CSS ======================================================================
24105
export function css(style: ComplexCSSRule, debugId?: string) {
25106
return vStyle(transform(style), debugId);

packages/css/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export {
3636
export { rules, recipe } from "./rules/index.js";
3737
export { createRuntimeFn } from "./rules/createRuntimeFn.js";
3838
export type {
39+
VariantStyle,
3940
RulesVariants,
4041
RecipeVariants,
4142
RuntimeFn,

packages/css/src/rules/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ import type {
2626
PropTarget,
2727
PropVars,
2828
Serializable,
29-
VariantStringMap
29+
VariantStringMap,
30+
VariantStyle
3031
} from "./types.js";
3132
import {
3233
mapValues,
@@ -264,16 +265,16 @@ if (import.meta.vitest) {
264265
color: {
265266
brand: { color: "#FFFFA0" },
266267
accent: { color: "#FFE4B5" }
267-
},
268+
} satisfies VariantStyle<"brand" | "accent">,
268269
size: {
269270
small: { padding: 12 },
270271
medium: { padding: 16 },
271272
large: { padding: 24 }
272-
},
273+
} satisfies VariantStyle<"small" | "medium" | "large">,
273274
outlined: {
274275
true: { border: "1px solid black" },
275276
false: { border: "1px solid transparent" }
276-
}
277+
} satisfies VariantStyle<"true" | "false">
277278
}
278279
} as const;
279280
const result = rules(variants, debugId);

packages/css/src/rules/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ export type Serializable =
3131

3232
type RecipeStyleRule = ComplexCSSRule | string;
3333

34+
export type VariantStyle<
35+
VariantNames extends string,
36+
CssRule extends RecipeStyleRule = RecipeStyleRule
37+
> = {
38+
[VariantName in VariantNames]: CssRule;
39+
};
40+
41+
// Same of VariantMap but for fast type checking
3442
export type VariantDefinitions = Record<string, RecipeStyleRule>;
3543

3644
type BooleanMap<T> = T extends "true" | "false" ? boolean : T;

vitest.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
projects: ["packages/*/vite.config.ts"],
6+
}
7+
});

vitest.workspace.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.

0 commit comments

Comments
 (0)