diff --git a/.changeset/sharp-ears-wear.md b/.changeset/sharp-ears-wear.md new file mode 100644 index 00000000..4ad32178 --- /dev/null +++ b/.changeset/sharp-ears-wear.md @@ -0,0 +1,9 @@ +--- +"@mincho-js/css": patch +--- + +## New +- Add `VariantStyle` type for constrained variant styles + +## Changes +- Allow nested selector in `globalCss` function diff --git a/packages/css/src/css/index.ts b/packages/css/src/css/index.ts index 1198e104..7df1ba3f 100644 --- a/packages/css/src/css/index.ts +++ b/packages/css/src/css/index.ts @@ -16,10 +16,91 @@ import { className, getDebugName } from "../utils.js"; // == Global CSS =============================================================== export function globalCss(selector: string, rule: GlobalCSSRule) { - gStyle(selector, transform(rule) as GlobalStyleRule); + const transformedStyle = transform({ + selectors: { + [selector]: { + ...rule + } + } + }) as CSSRule; + + const { selectors, ...atRuleStyles } = transformedStyle; + if (selectors !== undefined) { + Object.entries(selectors).forEach(([selector, styles]) => { + gStyle(selector, styles as GlobalStyleRule); + }); + } + + if (atRuleStyles !== undefined) { + const otherStyles = hoistSelectors(atRuleStyles); + Object.entries(otherStyles.selectors).forEach(([atRule, atRuleStyles]) => { + gStyle(atRule, atRuleStyles as GlobalStyleRule); + }); + } } export const globalStyle = globalCss; +// TODO: Make more type-safe +type UnknownObject = Record; +type CSSRuleMap = Record; +interface HoistResult { + selectors: CSSRuleMap; +} + +function hoistSelectors(input: CSSRule): HoistResult { + const result: HoistResult = { + selectors: {} + }; + + function processAtRules(obj: UnknownObject, path: string[] = []) { + for (const key in obj) { + if (key === "selectors") { + // Hoist each selector when selectors are found + const selectors = obj[key] as CSSRuleMap; + for (const selector in selectors) { + if (!result.selectors[selector]) { + result.selectors[selector] = {}; + } + + // Create nested object structure based on current path + let current = result.selectors[selector] as UnknownObject; + for (let i = 0; i < path.length; i += 2) { + const atRule = path[i]; + const condition = path[i + 1]; + + if (!current[atRule]) { + current[atRule] = {}; + } + const atRuleObj = current[atRule] as UnknownObject; + if (!atRuleObj[condition]) { + atRuleObj[condition] = {}; + } + + current = atRuleObj[condition] as UnknownObject; + } + + // Copy style properties + Object.assign(current, selectors[selector]); + } + } else if (typeof obj[key] === "object" && obj[key] !== null) { + // at-rule found (e.g: @media, @supports) + const atRules = obj[key] as UnknownObject; + for (const condition in atRules) { + // Add current at-rule and condition to path and recursively call + processAtRules(atRules[condition] as UnknownObject, [ + ...path, + key, + condition + ]); + } + } + } + } + + processAtRules(input as UnknownObject); + return result; +} + // == CSS ====================================================================== export function css(style: ComplexCSSRule, debugId?: string) { return vStyle(transform(style), debugId); diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts index f747da71..772abd7f 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -36,6 +36,7 @@ export { export { rules, recipe } from "./rules/index.js"; export { createRuntimeFn } from "./rules/createRuntimeFn.js"; export type { + VariantStyle, RulesVariants, RecipeVariants, RuntimeFn, diff --git a/packages/css/src/rules/index.ts b/packages/css/src/rules/index.ts index d0b348b3..382f25ab 100644 --- a/packages/css/src/rules/index.ts +++ b/packages/css/src/rules/index.ts @@ -26,7 +26,8 @@ import type { PropTarget, PropVars, Serializable, - VariantStringMap + VariantStringMap, + VariantStyle } from "./types.js"; import { mapValues, @@ -264,16 +265,16 @@ if (import.meta.vitest) { color: { brand: { color: "#FFFFA0" }, accent: { color: "#FFE4B5" } - }, + } satisfies VariantStyle<"brand" | "accent">, size: { small: { padding: 12 }, medium: { padding: 16 }, large: { padding: 24 } - }, + } satisfies VariantStyle<"small" | "medium" | "large">, outlined: { true: { border: "1px solid black" }, false: { border: "1px solid transparent" } - } + } satisfies VariantStyle<"true" | "false"> } } as const; const result = rules(variants, debugId); diff --git a/packages/css/src/rules/types.ts b/packages/css/src/rules/types.ts index a340fe9f..23012747 100644 --- a/packages/css/src/rules/types.ts +++ b/packages/css/src/rules/types.ts @@ -31,6 +31,14 @@ export type Serializable = type RecipeStyleRule = ComplexCSSRule | string; +export type VariantStyle< + VariantNames extends string, + CssRule extends RecipeStyleRule = RecipeStyleRule +> = { + [VariantName in VariantNames]: CssRule; +}; + +// Same of VariantMap but for fast type checking export type VariantDefinitions = Record; type BooleanMap = T extends "true" | "false" ? boolean : T; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..ad4a4cca --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: ["packages/*/vite.config.ts"], + } +}); diff --git a/vitest.workspace.ts b/vitest.workspace.ts deleted file mode 100644 index b7d8641f..00000000 --- a/vitest.workspace.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineWorkspace } from "vitest/config"; - -export default defineWorkspace(["packages/*"]);