diff --git a/.changeset/big-hands-rescue.md b/.changeset/big-hands-rescue.md new file mode 100644 index 0000000..c081c84 --- /dev/null +++ b/.changeset/big-hands-rescue.md @@ -0,0 +1,5 @@ +--- +"@mincho-js/css": minor +--- + +- `defineRules`: Properties and shortcuts diff --git a/.changeset/six-coins-ask.md b/.changeset/six-coins-ask.md new file mode 100644 index 0000000..d2a9b88 --- /dev/null +++ b/.changeset/six-coins-ask.md @@ -0,0 +1,5 @@ +--- +"@mincho-js/css": minor +--- + +- `defineRules` - functional properties and shortcuts diff --git a/.vscode/settings.json b/.vscode/settings.json index b4ac2d5..078a7d2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,6 +28,7 @@ "vue" ], "cSpell.words": [ + "bivariance", "Classable", "codegen", "elif", diff --git a/examples/react-babel/tsconfig.app.json b/examples/react-babel/tsconfig.app.json index 121dd58..175c922 100644 --- a/examples/react-babel/tsconfig.app.json +++ b/examples/react-babel/tsconfig.app.json @@ -2,11 +2,7 @@ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, - "lib": [ - "ES2020", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, @@ -18,6 +14,7 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", + "types": ["vitest/importMeta", "vite/client"], /* Linting */ "strict": true, @@ -36,7 +33,5 @@ "tsBuildInfoFile": "./.cache/typescript/tsbuildinfo-app" }, - "include": [ - "src" - ] + "include": ["src"] } diff --git a/packages/css/src/defineRules/index.ts b/packages/css/src/defineRules/index.ts new file mode 100644 index 0000000..c01af5b --- /dev/null +++ b/packages/css/src/defineRules/index.ts @@ -0,0 +1,538 @@ +import type { CSSProperties } from "@mincho-js/transform-to-vanilla"; +import type { + DefineRulesComplexCssInput, + DefineRulesCtx, + DefineRulesProperties, + DefineRulesShortcuts +} from "./types.js"; + +// == Define Rules ============================================================= +export function defineRules< + const Properties extends DefineRulesProperties, + const Shortcuts extends DefineRulesShortcuts +>(config: DefineRulesCtx) { + type DefineProperties = DefineRulesComplexCssInput; + function cssRaw(args: DefineProperties): CSSProperties { + const out: CSSProperties = {}; + applyInput(config, out, args, []); + return out; + } + + const css = Object.assign({}, { raw: cssRaw }); + return { css }; +} + +// == Define Rules Impl ======================================================== +function applyInput< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +>( + ctx: DefineRulesCtx, + out: CSSProperties, + input: unknown, + stack: string[] +) { + if (input == null || input === false) return; + + if (typeof input === "string") { + applyFixedStyle(ctx, out, input, stack); + return; + } + + if (Array.isArray(input)) { + applyArray(ctx, out, input, stack); + return; + } + + if (isPlainObject(input)) { + applyObject(ctx, out, input, stack); + return; + } + + throw new Error(`Unsupported css() argument: ${String(input)}`); +} + +function applyFixedStyle< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +>( + ctx: DefineRulesCtx, + out: CSSProperties, + inlineStyle: string, + stack: string[] +) { + if (hasOwn(ctx.shortcuts, inlineStyle)) { + applyShortcut(ctx, out, inlineStyle, undefined, stack); + return; + } + throw new Error(`Unknown fixed style: "${inlineStyle}"`); +} + +function applyArray< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +>( + ctx: DefineRulesCtx, + out: CSSProperties, + arr: readonly unknown[], + stack: string[] +) { + for (const item of arr) { + applyInput(ctx, out, item, stack); + } +} + +function applyObject< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +>( + ctx: DefineRulesCtx, + out: CSSProperties, + obj: Record, + stack: string[] +) { + for (const [k, v] of Object.entries(obj)) { + applyEntry(ctx, out, k, v, stack); + } +} + +function applyEntry< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +>( + ctx: DefineRulesCtx, + out: CSSProperties, + key: string, + value: unknown, + stack: string[] +) { + if (hasOwn(ctx.shortcuts, key)) { + applyShortcut(ctx, out, key, value, stack); + return; + } + + applyProperty(ctx, out, key, value); +} + +function applyProperty< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +>( + ctx: DefineRulesCtx, + out: CSSProperties, + prop: string, + value: unknown +) { + const propDef = ctx.properties?.[prop as keyof Properties]; + + if (typeof propDef === "function") { + const result = propDef(value); + if (isPlainObject(result)) { + Object.assign(out, result); + return; + } else { + (out as Record)[prop] = result; + return; + } + } + + // just assign => last one wins + if (isPlainObject(propDef) === false) { + (out as Record)[prop] = value; + return; + } + + const mapped = propDef[value as string]; + + // Style object value => assign all + if (isPlainObject(mapped)) { + Object.assign(out, mapped); + return; + } + + // Mapped value => assign mapped value + (out as Record)[prop] = mapped ?? value; +} + +function applyShortcut< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +>( + ctx: DefineRulesCtx, + out: CSSProperties, + name: string, + value: unknown, + stack: string[] +) { + if (stack.includes(name)) { + throw new Error( + `Circular shortcut reference: ${[...stack, name].join(" -> ")}` + ); + } + + const def = ctx.shortcuts?.[name as keyof Shortcuts]; + if (def == null) return; + + const nextStack = stack.concat(name); + + if (typeof def === "string") { + // single alias: pl -> paddingLeft + applyEntry(ctx, out, def, value, nextStack); + return; + } + + if (Array.isArray(def)) { + // multi alias: px -> [pl, pr] + for (const alias of def) { + applyEntry(ctx, out, alias, value, nextStack); + } + return; + } + + if (typeof def === "function") { + // fn shortcut + const produced = def(value); + applyInput(ctx, out, produced, nextStack); + return; + } + + if (isPlainObject(def)) { + // fixed style shortcut + // - "inline" token (no value) => apply + // - { inline: true } => apply + // - { inline: false } => do not apply + if (value === undefined || value === true) { + applyInput(ctx, out, def, nextStack); + return; + } + if (!value) return; + applyInput(ctx, out, def, nextStack); + return; + } + + throw new Error(`Unsupported shortcut definition for "${name}"`); +} + +// == Utils ==================================================================== +function isPlainObject(v: unknown): v is Record { + return v != null && typeof v === "object" && !Array.isArray(v); +} + +function hasOwn(obj: object | undefined, key: PropertyKey): boolean { + return obj != null && Object.prototype.hasOwnProperty.call(obj, key); +} + +// == Tests ==================================================================== +// Ignore errors when compiling to CommonJS. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore error TS1343: The 'import.meta' meta-property is only allowed when the '--module' option is 'es2020', 'es2022', 'esnext', 'system', 'node16', or 'nodenext'. +if (import.meta.vitest) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore error TS1343: The 'import.meta' meta-property is only allowed when the '--module' option is 'es2020', 'es2022', 'esnext', 'system', 'node16', or 'nodenext'. + const { describe, it, expect } = import.meta.vitest; + + describe("defineRules", () => { + describe.concurrent("DefineRules Properties", () => { + it("Array values for CSS properties", () => { + const { css } = defineRules({ + properties: { + display: ["none", "inline", "block"], + paddingLeft: [0, 2, 4, 8, 16, 32, 64] + } + }); + + expect( + css.raw({ + display: "inline", + paddingLeft: 4 + }) + ).toEqual({ + display: "inline", + paddingLeft: 4 + }); + }); + + it("Object values for CSS properties", () => { + const { css } = defineRules({ + properties: { + color: { + "indigo-800": "rgb(55, 48, 163)", + "red-500": "rgb(239, 68, 68)" + } + } + }); + + expect( + css.raw({ + color: "indigo-800" + }) + ).toEqual({ + color: "rgb(55, 48, 163)" + }); + }); + + it("Object values with CSSPropertiesWithVars", () => { + const alpha = "--alpha"; + const { css } = defineRules({ + properties: { + background: { + red: { + vars: { [alpha]: "1" }, + background: `rgba(255, 0, 0, var(${alpha}))` + } + } + } + }); + + expect( + css.raw({ + background: "red" + }) + ).toEqual({ + vars: { "--alpha": "1" }, + background: `rgba(255, 0, 0, var(--alpha))` + }); + }); + + it("Boolean values for entire properties", () => { + const { css } = defineRules({ + properties: { + border: false, + margin: true + } + }); + + expect(css.raw({ margin: 8 })).toEqual({ margin: 8 }); + }); + + it("Function values for CSS properties", () => { + const { css } = defineRules({ + properties: { + color(arg: "primary" | "secondary") { + if (arg === "primary") { + return "blue"; + } else { + return "gray"; + } + }, + otherColor(arg: "primary" | "secondary") { + if (arg === "primary") { + return { color: "red" } as const; + } else { + return { color: "green" } as const; + } + } + } + }); + + expect( + css.raw({ + color: "primary" + }) + ).toEqual({ + color: "blue" + }); + expect( + css.raw({ + otherColor: "secondary" + }) + ).toEqual({ + color: "green" + }); + }); + + it("Last one wins for properties", () => { + const { css } = defineRules({ + properties: { + color: true + } + }); + + expect(css.raw([{ color: "red" }, { color: "blue" }])).toEqual({ + color: "blue" + }); + }); + }); + + describe.concurrent("DefineRules Shortcuts", () => { + it("Single property shortcut", () => { + const { css } = defineRules({ + properties: { + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8] + }, + shortcuts: { + pl: "paddingLeft", + pr: "paddingRight" + } + }); + + expect(css.raw({ pl: 4, pr: 8 })).toEqual({ + paddingLeft: 4, + paddingRight: 8 + }); + expect(css.raw({ pl: 4, paddingLeft: 8 })).toEqual({ + paddingLeft: 8 + }); + expect(css.raw({ paddingLeft: 8, pl: 4 })).toEqual({ + paddingLeft: 4 + }); + }); + + it("Array shortcut referencing properties", () => { + const { css } = defineRules({ + properties: { + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8, 12] + }, + shortcuts: { + px: ["paddingLeft", "paddingRight"] + } + }); + + expect(css.raw({ px: 4 })).toEqual({ + paddingLeft: 4, + paddingRight: 4 + }); + expect(css.raw({ px: 4, paddingRight: 8 })).toEqual({ + paddingLeft: 4, + paddingRight: 8 + }); + expect(css.raw({ paddingRight: 4, px: 8 })).toEqual({ + paddingLeft: 8, + paddingRight: 8 + }); + }); + + it("Shortcut referencing other shortcuts", () => { + const { css } = defineRules({ + properties: { + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8, 12] + }, + shortcuts: { + pl: "paddingLeft", + pr: "paddingRight", + px: ["pl", "pr"] + } + }); + + expect(css.raw({ px: 4 })).toEqual({ + paddingLeft: 4, + paddingRight: 4 + }); + expect(css.raw({ px: 4, paddingRight: 8 })).toEqual({ + paddingLeft: 4, + paddingRight: 8 + }); + expect(css.raw({ paddingRight: 4, px: 8 })).toEqual({ + paddingLeft: 8, + paddingRight: 8 + }); + }); + + it("Mixed shortcuts with properties and other shortcuts", () => { + const { css } = defineRules({ + properties: { + paddingTop: [0, 4, 8], + paddingBottom: [0, 4, 8], + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8] + }, + shortcuts: { + pt: "paddingTop", + pb: "paddingBottom", + pl: "paddingLeft", + pr: "paddingRight", + py: ["pt", "pb"], + px: ["pl", "pr"], + p: ["py", "px"] + } + }); + + expect(css.raw({ p: 4 })).toEqual({ + paddingTop: 4, + paddingBottom: 4, + paddingLeft: 4, + paddingRight: 4 + }); + expect(css.raw({ p: 4, paddingLeft: 8 })).toEqual({ + paddingTop: 4, + paddingBottom: 4, + paddingLeft: 8, + paddingRight: 4 + }); + expect(css.raw({ paddingLeft: 4, p: 8 })).toEqual({ + paddingTop: 8, + paddingBottom: 8, + paddingLeft: 8, + paddingRight: 8 + }); + expect(css.raw({ p: 4, px: 8, pl: 0 })).toEqual({ + paddingTop: 4, + paddingBottom: 4, + paddingLeft: 0, + paddingRight: 8 + }); + }); + + it("Fixed object shortcut", () => { + const { css } = defineRules({ + properties: { + display: ["none", "inline", "block"] + }, + shortcuts: { + inline: { display: "inline" } + } + }); + + expect(css.raw({ inline: true })).toEqual({ + display: "inline" + }); + expect(css.raw(["inline"])).toEqual({ + display: "inline" + }); + expect(css.raw("inline")).toEqual({ + display: "inline" + }); + expect(css.raw({ inline: true, display: "none" })).toEqual({ + display: "none" + }); + expect(css.raw(["inline", { display: "none" }])).toEqual({ + display: "none" + }); + expect(css.raw([{ display: "none", inline: true }])).toEqual({ + display: "inline" + }); + expect(css.raw([{ display: "none" }, "inline"])).toEqual({ + display: "inline" + }); + }); + + it("Function shortcut", () => { + const { css } = defineRules({ + properties: { + display: ["none", "inline", "block"], + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8] + }, + shortcuts: { + px: ["paddingLeft", "paddingRight"], + center(arg: "none" | "inline" | "block") { + return { + display: arg, + px: 4 + } as const; + } + } + }); + + expect(css.raw({ center: "inline" })).toEqual({ + display: "inline", + paddingLeft: 4, + paddingRight: 4 + }); + }); + }); + }); +} diff --git a/packages/css/src/defineRules/types.ts b/packages/css/src/defineRules/types.ts new file mode 100644 index 0000000..1b8e108 --- /dev/null +++ b/packages/css/src/defineRules/types.ts @@ -0,0 +1,729 @@ +import type { + NonNullableString, + CSSProperties, + CSSPropertiesWithVars +} from "@mincho-js/transform-to-vanilla"; + +type CSSPropertiesKeys = keyof CSSProperties; + +type DefineRulesResolver< + Out = CSSPropertiesWithVars | undefined, + Arg = unknown +> = { + bivarianceHack(arg: Arg): Out; +}["bivarianceHack"]; + +type DefineRulesCssProperties = { + [Property in CSSPropertiesKeys]?: DefineRulesCssPropertiesValue< + CSSProperties[Property] + >; +}; +type DefineRulesCssPropertiesValue = + | ReadonlyArray + | Record + | DefineRulesResolver + | true + | false; + +type DefineRulesCustomProperties = Partial< + Record< + Exclude, + Record | DefineRulesResolver + > +>; +export type DefineRulesProperties = + | DefineRulesCssProperties + | DefineRulesCustomProperties; + +type ShortcutValue< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts, + ShortcutsKey extends keyof Shortcuts +> = + | keyof Properties + | Exclude + | ReadonlyArray> + | DefineRulesCssInput + | DefineRulesResolver>; + +export type DefineRulesShortcuts< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +> = { + [ShortcutsKey in keyof Shortcuts]: ShortcutValue< + Properties, + Shortcuts, + ShortcutsKey + >; +}; + +export interface DefineRulesCtx< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +> { + properties?: Properties; + shortcuts?: Shortcuts; +} + +type PropertiesInput = { + [Key in keyof Properties]?: ResolvePropertiesValue; +}; + +type ResolvePropertiesValue = + Value extends ReadonlyArray + ? Item + : Value extends (arg: infer Arg) => unknown + ? Arg + : true extends Value + ? Key extends CSSPropertiesKeys + ? CSSProperties[Key] + : never + : false extends Value + ? never + : Value extends Record + ? StyleObjectKey + : never; + +type ShortcutsInput< + Properties extends Record, + Shortcuts extends Record +> = { + [Key in keyof Shortcuts]?: ResolveShortcutValue< + Properties, + Shortcuts, + Shortcuts[Key] + >; +}; + +type ResolveShortcutValue< + Properties extends Record, + Shortcuts extends Record, + Value +> = Value extends readonly unknown[] + ? ResolveShortcutArrayRef + : Value extends (arg: infer Arg) => unknown + ? Arg + : Value extends Record + ? boolean + : ResolveShortcutRef; + +type ResolveShortcutArrayRef< + Properties extends Record, + Shortcuts extends Record, + Targets extends readonly unknown[] +> = Targets extends readonly [infer H, ...infer R] + ? ResolveShortcutRef & + ResolveShortcutArrayRef + : unknown; + +type ResolveShortcutRef< + Properties extends Record, + Shortcuts extends Record, + Ref +> = [Ref] extends [keyof Properties] + ? Properties[Ref] + : [Ref] extends [keyof Shortcuts] + ? ShortcutsInput[Ref] + : never; + +export type DefineRulesCssInput< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +> = PropertiesInput & + ShortcutsInput, Shortcuts>; + +export type DefineRulesInlineCssInput< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts, + CssInput = DefineRulesCssInput +> = keyof { + [Key in keyof CssInput as boolean extends CssInput[Key] + ? Key + : never]: CssInput[Key]; +}; + +export type DefineRulesComplexCssInput< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +> = + | DefineRulesCssInput + | DefineRulesInlineCssInput + | Array< + | DefineRulesCssInput + | DefineRulesInlineCssInput + >; + +// == Tests ==================================================================== +// Ignore errors when compiling to CommonJS. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore error TS1343: The 'import.meta' meta-property is only allowed when the '--module' option is 'es2020', 'es2022', 'esnext', 'system', 'node16', or 'nodenext'. +if (import.meta.vitest) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore error TS1343: The 'import.meta' meta-property is only allowed when the '--module' option is 'es2020', 'es2022', 'esnext', 'system', 'node16', or 'nodenext'. + const { describe, it, assertType } = import.meta.vitest; + + describe.concurrent("DefineRules Type Test", () => { + function resolveDefineRules< + const Properties extends DefineRulesProperties, + const Shortcuts extends DefineRulesShortcuts + >(rules: DefineRulesCtx) { + return { + rules, + _props: rules as unknown as DefineRulesComplexCssInput< + Properties, + Shortcuts + > + }; + } + + describe.concurrent("DefineRulesProperties Type", () => { + it("Array values for CSS properties", () => { + const { rules, _props } = resolveDefineRules({ + properties: { + display: ["none", "inline", "block"], + paddingLeft: [0, 2, 4, 8, 16, 32, 64] + } + }); + assertType<{ + properties?: { + readonly display: readonly ["none", "inline", "block"]; + readonly paddingLeft: readonly [0, 2, 4, 8, 16, 32, 64]; + }; + }>(rules); + + assertType({ + display: "inline", + paddingLeft: 4 + }); + assertType({ + // @ts-expect-error: invalid value + display: "flex", + // @ts-expect-error: invalid value + paddingLeft: 5 + }); + }); + + it("Object values for CSS properties", () => { + const { rules, _props } = resolveDefineRules({ + properties: { + color: { + "indigo-800": "rgb(55, 48, 163)", + "red-500": "rgb(239, 68, 68)" + } + } + }); + assertType<{ + properties?: { + color: { + "indigo-800": string; + "red-500": string; + }; + }; + }>(rules); + + assertType({ + color: "indigo-800" + }); + assertType({ + // @ts-expect-error: invalid value + color: "blue-500" + }); + }); + + it("Object values with CSSPropertiesWithVars", () => { + const alpha = "--alpha"; + const { rules, _props } = resolveDefineRules({ + properties: { + background: { + red: { + vars: { [alpha]: "1" }, + background: `rgba(255, 0, 0, var(${alpha}))` + } + } + } + }); + assertType<{ + properties?: { + background: { + red: { + vars: { "--alpha": string }; + background: string; + }; + }; + }; + }>(rules); + + assertType({ + background: "red" + }); + assertType({ + // @ts-expect-error: invalid value + background: "blue" + }); + }); + + it("Boolean values for entire properties", () => { + const { rules, _props } = resolveDefineRules({ + properties: { + border: false, + margin: true + } + }); + assertType<{ + properties?: { + border: false; + margin: true; + }; + }>(rules); + + assertType({ + margin: "inherit" + }); + // @ts-expect-error: `border` is false, so it should not accept any value. + assertType({ + border: "1px solid black" + }); + }); + + it("Custom properties with CSSPropertiesWithVars", () => { + const alpha = "--alpha"; + const { rules, _props } = resolveDefineRules({ + properties: { + backgroundOpacity: { + full: { vars: { [alpha]: "1" } }, + half: { vars: { [alpha]: "0.5" } } + } + } + }); + assertType<{ + properties?: { + backgroundOpacity: { + full: { vars: { "--alpha": string } }; + half: { vars: { "--alpha": string } }; + }; + }; + }>(rules); + + assertType({ + backgroundOpacity: "full" + }); + assertType({ + // @ts-expect-error: invalid value + backgroundOpacity: "quarter" + }); + }); + + it("Function values return CSS properties", () => { + const { rules, _props } = resolveDefineRules({ + properties: { + color(arg: "primary" | "secondary") { + if (arg === "primary") { + return { color: "blue" } as const; + } else { + return { color: "gray" } as const; + } + } + } + }); + assertType<{ + properties?: { + color: (arg: "primary" | "secondary") => + | { + color: string; + } + | undefined; + }; + }>(rules); + + assertType({ + color: "primary" + }); + }); + + it("Function values return Style objects", () => { + const { rules, _props } = resolveDefineRules({ + properties: { + color(arg: "primary" | "secondary") { + if (arg === "primary") { + return "blue"; + } else { + return "gray"; + } + }, + otherColor(arg: "primary" | "secondary") { + if (arg === "primary") { + return { color: "red" } as const; + } else { + return { color: "green" } as const; + } + } + } + }); + assertType<{ + properties?: { + color: (arg: "primary" | "secondary") => "blue" | "gray"; + otherColor: (arg: "primary" | "secondary") => + | { + color: string; + } + | undefined; + }; + }>(rules); + + assertType({ + color: "primary" + }); + }); + }); + + describe.concurrent("DefineRulesShortcuts Type", () => { + it("Single property shortcut", () => { + const { rules, _props } = resolveDefineRules({ + properties: { + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8] + }, + shortcuts: { + pl: "paddingLeft", + pr: "paddingRight" + } + }); + assertType<{ + properties?: { + paddingLeft: readonly [0, 4, 8]; + paddingRight: readonly [0, 4, 8]; + }; + shortcuts?: { + pl: "paddingLeft"; + pr: "paddingRight"; + }; + }>(rules); + + assertType({ + pl: 4, + pr: 8 + }); + assertType({ + // @ts-expect-error: invalid value + pl: 5, + // @ts-expect-error: invalid value + pr: 9 + }); + }); + + it("Array shortcut referencing properties", () => { + const { rules, _props } = resolveDefineRules({ + properties: { + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8, 12] + }, + shortcuts: { + px: ["paddingLeft", "paddingRight"] + } + }); + assertType<{ + properties?: { + paddingLeft: readonly [0, 4, 8]; + paddingRight: readonly [0, 4, 8, 12]; + }; + shortcuts?: { + px: readonly ["paddingLeft", "paddingRight"]; + }; + }>(rules); + + assertType({ + px: 4 + }); + assertType({ + // @ts-expect-error: invalid value + px: 5 + }); + assertType({ + // @ts-expect-error: invalid value + px: 12 + }); + }); + + it("Shortcut referencing other shortcuts", () => { + const { rules, _props } = resolveDefineRules({ + properties: { + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8, 12] + }, + shortcuts: { + pl: "paddingLeft", + pr: "paddingRight", + px: ["pl", "pr"] + } + }); + assertType<{ + properties?: { + paddingLeft: readonly [0, 4, 8]; + paddingRight: readonly [0, 4, 8, 12]; + }; + shortcuts?: { + pl: "paddingLeft"; + pr: "paddingRight"; + px: readonly ["pl", "pr"]; + }; + }>(rules); + + assertType({ + px: 4 + }); + assertType({ + // @ts-expect-error: invalid value + px: 5 + }); + assertType({ + // @ts-expect-error: invalid value + px: 12 + }); + }); + + it("Mixed shortcuts with properties and other shortcuts", () => { + const { rules } = resolveDefineRules({ + properties: { + paddingTop: [0, 4, 8], + paddingBottom: [0, 4, 8], + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8] + }, + shortcuts: { + pt: "paddingTop", + pb: "paddingBottom", + pl: "paddingLeft", + pr: "paddingRight", + py: ["pt", "pb"], + px: ["pl", "pr"], + p: ["py", "px"] + } + }); + assertType<{ + properties?: { + paddingTop: readonly [0, 4, 8]; + paddingBottom: readonly [0, 4, 8]; + paddingLeft: readonly [0, 4, 8]; + paddingRight: readonly [0, 4, 8]; + }; + shortcuts?: { + pt: "paddingTop"; + pb: "paddingBottom"; + pl: "paddingLeft"; + pr: "paddingRight"; + py: readonly ["pt", "pb"]; + px: readonly ["pl", "pr"]; + p: readonly ["py", "px"]; + }; + }>(rules); + }); + + it("Fixed object shortcut", () => { + const { rules, _props } = resolveDefineRules({ + properties: { + display: ["none", "inline", "block"] + }, + shortcuts: { + inline: { display: "inline" } + } + }); + assertType<{ + properties?: { + display: readonly ["none", "inline", "block"]; + }; + shortcuts?: { + inline: { readonly display: "inline" }; + }; + }>(rules); + + assertType({ + inline: true + }); + assertType(["inline"]); + }); + + it("Function shortcut", () => { + const { rules, _props } = resolveDefineRules({ + properties: { + display: ["none", "inline", "block"], + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8] + }, + shortcuts: { + px: ["paddingLeft", "paddingRight"], + center(arg: "none" | "inline" | "block") { + return { + display: arg, + px: 4 + } as const; + } + } + }); + assertType<{ + properties?: { + display: readonly ["none", "inline", "block"]; + paddingLeft: readonly [0, 4, 8]; + paddingRight: readonly [0, 4, 8]; + }; + shortcuts?: { + px: readonly ["paddingLeft", "paddingRight"]; + center: (arg: "none" | "inline" | "block") => + | { + display: "none" | "inline" | "block"; + px: number; + } + | undefined; + }; + }>(rules); + + assertType({ + center: "inline" + }); + assertType({ + // @ts-expect-error: invalid value + center: "flex" + }); + }); + }); + + describe.concurrent("Invalid Type Cases", () => { + it("Invalid shortcut reference should error", () => { + resolveDefineRules({ + properties: { + paddingLeft: [0, 4, 8] + }, + shortcuts: { + // @ts-expect-error: 'nonExistent' is not a valid property or shortcut + pl: "nonExistent" + } + }); + }); + + it("Shortcut cannot reference itself", () => { + resolveDefineRules({ + properties: { + paddingLeft: [0, 4, 8] + }, + shortcuts: { + // @ts-expect-error: shortcut cannot reference itself + pl: "pl" + } + }); + }); + + it("Array shortcut with invalid reference should error", () => { + resolveDefineRules({ + properties: { + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8] + }, + shortcuts: { + // @ts-expect-error: 'invalid' is not a valid property or shortcut + px: ["paddingLeft", "invalid"] + } + }); + }); + }); + + describe.concurrent("Complex DefineRules", () => { + it("Full featured defineRules", () => { + const alpha = "--alpha"; + const { rules } = resolveDefineRules({ + properties: { + // Array values + display: ["none", "inline", "block", "flex", "grid"], + paddingLeft: [0, 2, 4, 8, 16, 32, 64], + paddingRight: [0, 2, 4, 8, 16, 32, 64], + paddingTop: [0, 2, 4, 8, 16, 32, 64], + paddingBottom: [0, 2, 4, 8, 16, 32, 64], + + // Object values + color: { + "indigo-800": "rgb(55, 48, 163)", + "red-500": "rgb(239, 68, 68)", + "blue-500": "rgb(59, 130, 246)" + }, + + // CSSPropertiesWithVars + background: { + red: { + vars: { [alpha]: "1" }, + background: `rgba(255, 0, 0, var(${alpha}))` + }, + blue: { + vars: { [alpha]: "1" }, + background: `rgba(0, 0, 255, var(${alpha}))` + } + }, + + // Custom properties + backgroundOpacity: { + full: { vars: { [alpha]: "1" } }, + half: { vars: { [alpha]: "0.5" } }, + quarter: { vars: { [alpha]: "0.25" } } + }, + + // Boolean + border: false + }, + shortcuts: { + // Single property shortcuts + pl: "paddingLeft", + pr: "paddingRight", + pt: "paddingTop", + pb: "paddingBottom", + + // Multiple property shortcuts + px: ["pl", "pr"], + py: ["pt", "pb"], + p: ["px", "py"], + + // Fixed object shortcut + inline: { display: "inline" } + } + }); + + assertType<{ + properties?: { + display: readonly ["none", "inline", "block", "flex", "grid"]; + paddingLeft: readonly [0, 2, 4, 8, 16, 32, 64]; + paddingRight: readonly [0, 2, 4, 8, 16, 32, 64]; + paddingTop: readonly [0, 2, 4, 8, 16, 32, 64]; + paddingBottom: readonly [0, 2, 4, 8, 16, 32, 64]; + color: { + "indigo-800": string; + "red-500": string; + "blue-500": string; + }; + background: { + red: { + vars: { "--alpha": string }; + background: string; + }; + blue: { + vars: { "--alpha": string }; + background: string; + }; + }; + backgroundOpacity: { + full: { vars: { "--alpha": string } }; + half: { vars: { "--alpha": string } }; + quarter: { vars: { "--alpha": string } }; + }; + border: false; + }; + shortcuts?: { + pl: "paddingLeft"; + pr: "paddingRight"; + pt: "paddingTop"; + pb: "paddingBottom"; + px: readonly ["pl", "pr"]; + py: readonly ["pt", "pb"]; + p: readonly ["px", "py"]; + inline: { readonly display: "inline" }; + }; + }>(rules); + }); + }); + }); +} diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts index dc33f1b..d6a7f27 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -60,3 +60,9 @@ export type { } from "./theme/types.js"; export { cx } from "./classname/index.js"; export type { ClassValue } from "./classname/index.js"; +export { defineRules } from "./defineRules/index.js"; +export type { + DefineRulesCtx, + DefineRulesCssInput, + DefineRulesComplexCssInput +} from "./defineRules/types.js";