From 36a6377046ba52e30c0e452fbf7725e389ee7abc Mon Sep 17 00:00:00 2001 From: alstjr7375 Date: Mon, 15 Dec 2025 00:00:00 +0900 Subject: [PATCH 1/4] Feat: defineRules type --- packages/css/src/defineRules/types.ts | 405 ++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 packages/css/src/defineRules/types.ts diff --git a/packages/css/src/defineRules/types.ts b/packages/css/src/defineRules/types.ts new file mode 100644 index 0000000..f4a9dd1 --- /dev/null +++ b/packages/css/src/defineRules/types.ts @@ -0,0 +1,405 @@ +import type { + NonNullableString, + CSSProperties, + CSSPropertiesWithVars +} from "@mincho-js/transform-to-vanilla"; + +type DefineRulesCssProperties = { + [Property in keyof CSSProperties]?: + | ReadonlyArray + | Record + | true + | false; +}; +type DefineRulesCustomProperties = Partial< + Record< + Exclude, + Record + > +>; +type DefineRulesProperties = + | DefineRulesCssProperties + | DefineRulesCustomProperties; + +type ShortcutValue< + Properties extends DefineRulesProperties, + Shortcuts, + ShortcutsKey extends keyof Shortcuts +> = + | keyof Properties + | Exclude + | ReadonlyArray>; + +type DefineRulesShortcuts< + Properties extends DefineRulesProperties, + Shortcuts +> = { + [ShortcutsKey in keyof Shortcuts]: ShortcutValue< + Properties, + Shortcuts, + ShortcutsKey + >; +}; + +interface DefineRulesInput< + Properties extends DefineRulesProperties, + Shortcuts extends DefineRulesShortcuts +> { + properties?: Properties; + shortcuts?: Shortcuts; +} + +// == 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 defineRules< + const Properties extends DefineRulesProperties, + const Shortcuts extends DefineRulesShortcuts + >(rules: DefineRulesInput) { + return rules; + } + + describe.concurrent("DefineRulesProperties Type", () => { + it("Array values for CSS properties", () => { + const rules = defineRules({ + 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); + }); + + it("Object values for CSS properties", () => { + const rules = defineRules({ + properties: { + color: { + "indigo-800": "rgb(55, 48, 163)", + "red-500": "rgb(239, 68, 68)" + } + } + }); + assertType<{ + properties?: { + color: { + "indigo-800": string; + "red-500": string; + }; + }; + }>(rules); + }); + + it("Object values with CSSPropertiesWithVars", () => { + const alpha = "--alpha"; + const rules = defineRules({ + properties: { + background: { + red: { + vars: { [alpha]: "1" }, + background: `rgba(255, 0, 0, var(${alpha}))` + } + } + } + }); + assertType<{ + properties?: { + background: { + red: { + vars: { "--alpha": string }; + background: string; + }; + }; + }; + }>(rules); + }); + + it("Boolean values for entire properties", () => { + const rules = defineRules({ + properties: { + border: false, + margin: true + } + }); + assertType<{ + properties?: { + border: false; + margin: true; + }; + }>(rules); + }); + + it("Custom properties with CSSPropertiesWithVars", () => { + const alpha = "--alpha"; + const rules = defineRules({ + properties: { + backgroundOpacity: { + full: { vars: { [alpha]: "1" } }, + half: { vars: { [alpha]: "0.5" } } + } + } + }); + assertType<{ + properties?: { + backgroundOpacity: { + full: { vars: { "--alpha": string } }; + half: { vars: { "--alpha": string } }; + }; + }; + }>(rules); + }); + }); + + describe.concurrent("DefineRulesShortcuts Type", () => { + it("Single property shortcut", () => { + const rules = defineRules({ + 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); + }); + + it("Array shortcut referencing properties", () => { + const rules = defineRules({ + properties: { + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8] + }, + shortcuts: { + px: ["paddingLeft", "paddingRight"] + } + }); + assertType<{ + properties?: { + paddingLeft: readonly [0, 4, 8]; + paddingRight: readonly [0, 4, 8]; + }; + shortcuts?: { + px: readonly ["paddingLeft", "paddingRight"]; + }; + }>(rules); + }); + + it("Shortcut referencing other shortcuts", () => { + const rules = defineRules({ + properties: { + paddingLeft: [0, 4, 8], + paddingRight: [0, 4, 8] + }, + shortcuts: { + pl: "paddingLeft", + pr: "paddingRight", + px: ["pl", "pr"] + } + }); + assertType<{ + properties?: { + paddingLeft: readonly [0, 4, 8]; + paddingRight: readonly [0, 4, 8]; + }; + shortcuts?: { + pl: "paddingLeft"; + pr: "paddingRight"; + px: readonly ["pl", "pr"]; + }; + }>(rules); + }); + + it("Mixed shortcuts with properties and other shortcuts", () => { + const rules = 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"] + } + }); + 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); + }); + }); + + describe.concurrent("Invalid Type Cases", () => { + it("Invalid shortcut reference should error", () => { + defineRules({ + properties: { + paddingLeft: [0, 4, 8] + }, + shortcuts: { + // @ts-expect-error: 'nonExistent' is not a valid property or shortcut + pl: "nonExistent" + } + }); + }); + + it("Shortcut cannot reference itself", () => { + defineRules({ + properties: { + paddingLeft: [0, 4, 8] + }, + shortcuts: { + // @ts-expect-error: shortcut cannot reference itself + pl: "pl" + } + }); + }); + + it("Array shortcut with invalid reference should error", () => { + defineRules({ + 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 = defineRules({ + 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: { + pl: "paddingLeft", + pr: "paddingRight", + pt: "paddingTop", + pb: "paddingBottom", + px: ["pl", "pr"], + py: ["pt", "pb"], + p: ["px", "py"] + } + }); + + 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"]; + }; + }>(rules); + }); + }); + }); +} From 59ea4f82cd8e2eeb6d55100d7de392d4ac9cda9e Mon Sep 17 00:00:00 2001 From: alstjr7375 Date: Fri, 26 Dec 2025 00:00:00 +0900 Subject: [PATCH 2/4] Feat: `defineRules` - DefineRulesComplexCssInput --- packages/css/src/defineRules/types.ts | 260 +++++++++++++++++++++++--- 1 file changed, 231 insertions(+), 29 deletions(-) diff --git a/packages/css/src/defineRules/types.ts b/packages/css/src/defineRules/types.ts index f4a9dd1..4860d27 100644 --- a/packages/css/src/defineRules/types.ts +++ b/packages/css/src/defineRules/types.ts @@ -4,8 +4,10 @@ import type { CSSPropertiesWithVars } from "@mincho-js/transform-to-vanilla"; +type CSSPropertiesKeys = keyof CSSProperties; + type DefineRulesCssProperties = { - [Property in keyof CSSProperties]?: + [Property in CSSPropertiesKeys]?: | ReadonlyArray | Record | true @@ -13,26 +15,27 @@ type DefineRulesCssProperties = { }; type DefineRulesCustomProperties = Partial< Record< - Exclude, + Exclude, Record > >; -type DefineRulesProperties = +export type DefineRulesProperties = | DefineRulesCssProperties | DefineRulesCustomProperties; type ShortcutValue< Properties extends DefineRulesProperties, - Shortcuts, + Shortcuts extends DefineRulesShortcuts, ShortcutsKey extends keyof Shortcuts > = | keyof Properties | Exclude - | ReadonlyArray>; + | ReadonlyArray> + | DefineRulesCssInput; -type DefineRulesShortcuts< +export type DefineRulesShortcuts< Properties extends DefineRulesProperties, - Shortcuts + Shortcuts extends DefineRulesShortcuts > = { [ShortcutsKey in keyof Shortcuts]: ShortcutValue< Properties, @@ -41,7 +44,7 @@ type DefineRulesShortcuts< >; }; -interface DefineRulesInput< +export interface DefineRulesCtx< Properties extends DefineRulesProperties, Shortcuts extends DefineRulesShortcuts > { @@ -49,6 +52,90 @@ interface DefineRulesInput< shortcuts?: Shortcuts; } +type PropertiesInput = { + [Key in keyof Properties]?: ResolvePropertiesValue; +}; + +type ResolvePropertiesValue = + Value extends ReadonlyArray + ? Item + : 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 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 @@ -59,16 +146,22 @@ if (import.meta.vitest) { const { describe, it, assertType } = import.meta.vitest; describe.concurrent("DefineRules Type Test", () => { - function defineRules< + function resolveDefineRules< const Properties extends DefineRulesProperties, const Shortcuts extends DefineRulesShortcuts - >(rules: DefineRulesInput) { - return rules; + >(rules: DefineRulesCtx) { + return { + rules, + _props: rules as unknown as DefineRulesComplexCssInput< + Properties, + Shortcuts + > + }; } describe.concurrent("DefineRulesProperties Type", () => { it("Array values for CSS properties", () => { - const rules = defineRules({ + const { rules, _props } = resolveDefineRules({ properties: { display: ["none", "inline", "block"], paddingLeft: [0, 2, 4, 8, 16, 32, 64] @@ -80,10 +173,21 @@ if (import.meta.vitest) { 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 = defineRules({ + const { rules, _props } = resolveDefineRules({ properties: { color: { "indigo-800": "rgb(55, 48, 163)", @@ -99,11 +203,19 @@ if (import.meta.vitest) { }; }; }>(rules); + + assertType({ + color: "indigo-800" + }); + assertType({ + // @ts-expect-error: invalid value + color: "blue-500" + }); }); it("Object values with CSSPropertiesWithVars", () => { const alpha = "--alpha"; - const rules = defineRules({ + const { rules, _props } = resolveDefineRules({ properties: { background: { red: { @@ -123,10 +235,18 @@ if (import.meta.vitest) { }; }; }>(rules); + + assertType({ + background: "red" + }); + assertType({ + // @ts-expect-error: invalid value + background: "blue" + }); }); it("Boolean values for entire properties", () => { - const rules = defineRules({ + const { rules, _props } = resolveDefineRules({ properties: { border: false, margin: true @@ -138,11 +258,19 @@ if (import.meta.vitest) { 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 = defineRules({ + const { rules, _props } = resolveDefineRules({ properties: { backgroundOpacity: { full: { vars: { [alpha]: "1" } }, @@ -158,12 +286,20 @@ if (import.meta.vitest) { }; }; }>(rules); + + assertType({ + backgroundOpacity: "full" + }); + assertType({ + // @ts-expect-error: invalid value + backgroundOpacity: "quarter" + }); }); }); describe.concurrent("DefineRulesShortcuts Type", () => { it("Single property shortcut", () => { - const rules = defineRules({ + const { rules, _props } = resolveDefineRules({ properties: { paddingLeft: [0, 4, 8], paddingRight: [0, 4, 8] @@ -183,13 +319,24 @@ if (import.meta.vitest) { 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 = defineRules({ + const { rules, _props } = resolveDefineRules({ properties: { paddingLeft: [0, 4, 8], - paddingRight: [0, 4, 8] + paddingRight: [0, 4, 8, 12] }, shortcuts: { px: ["paddingLeft", "paddingRight"] @@ -198,19 +345,31 @@ if (import.meta.vitest) { assertType<{ properties?: { paddingLeft: readonly [0, 4, 8]; - paddingRight: 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 = defineRules({ + const { rules, _props } = resolveDefineRules({ properties: { paddingLeft: [0, 4, 8], - paddingRight: [0, 4, 8] + paddingRight: [0, 4, 8, 12] }, shortcuts: { pl: "paddingLeft", @@ -221,7 +380,7 @@ if (import.meta.vitest) { assertType<{ properties?: { paddingLeft: readonly [0, 4, 8]; - paddingRight: readonly [0, 4, 8]; + paddingRight: readonly [0, 4, 8, 12]; }; shortcuts?: { pl: "paddingLeft"; @@ -229,10 +388,22 @@ if (import.meta.vitest) { 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 = defineRules({ + const { rules } = resolveDefineRules({ properties: { paddingTop: [0, 4, 8], paddingBottom: [0, 4, 8], @@ -267,11 +438,35 @@ if (import.meta.vitest) { }; }>(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"]); + }); }); describe.concurrent("Invalid Type Cases", () => { it("Invalid shortcut reference should error", () => { - defineRules({ + resolveDefineRules({ properties: { paddingLeft: [0, 4, 8] }, @@ -283,7 +478,7 @@ if (import.meta.vitest) { }); it("Shortcut cannot reference itself", () => { - defineRules({ + resolveDefineRules({ properties: { paddingLeft: [0, 4, 8] }, @@ -295,7 +490,7 @@ if (import.meta.vitest) { }); it("Array shortcut with invalid reference should error", () => { - defineRules({ + resolveDefineRules({ properties: { paddingLeft: [0, 4, 8], paddingRight: [0, 4, 8] @@ -311,7 +506,7 @@ if (import.meta.vitest) { describe.concurrent("Complex DefineRules", () => { it("Full featured defineRules", () => { const alpha = "--alpha"; - const rules = defineRules({ + const { rules } = resolveDefineRules({ properties: { // Array values display: ["none", "inline", "block", "flex", "grid"], @@ -350,13 +545,19 @@ if (import.meta.vitest) { 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"] + p: ["px", "py"], + + // Fixed object shortcut + inline: { display: "inline" } } }); @@ -397,6 +598,7 @@ if (import.meta.vitest) { px: readonly ["pl", "pr"]; py: readonly ["pt", "pb"]; p: readonly ["px", "py"]; + inline: { readonly display: "inline" }; }; }>(rules); }); From 7396e4f8870b0959bb1ae99e279f3544def0180f Mon Sep 17 00:00:00 2001 From: alstjr7375 Date: Thu, 29 Jan 2026 00:00:00 +0900 Subject: [PATCH 3/4] Feat: `defineRules` - properties and shorcuts --- .changeset/big-hands-rescue.md | 5 + packages/css/src/defineRules/index.ts | 467 ++++++++++++++++++++++++++ packages/css/src/index.ts | 6 + 3 files changed, 478 insertions(+) create mode 100644 .changeset/big-hands-rescue.md create mode 100644 packages/css/src/defineRules/index.ts diff --git a/.changeset/big-hands-rescue.md b/.changeset/big-hands-rescue.md new file mode 100644 index 0000000..9e575cf --- /dev/null +++ b/.changeset/big-hands-rescue.md @@ -0,0 +1,5 @@ +--- +"@mincho-js/css": minor +--- + +- `defineRules`: Properties and shorcuts diff --git a/packages/css/src/defineRules/index.ts b/packages/css/src/defineRules/index.ts new file mode 100644 index 0000000..8f8ea89 --- /dev/null +++ b/packages/css/src/defineRules/index.ts @@ -0,0 +1,467 @@ +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]; + + // 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; + } + + // TODO: fn shortcut support + // 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("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" + }); + }); + }); + }); +} 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"; From d60103698a0cfe301d13a972de1d2ffdcc024fd2 Mon Sep 17 00:00:00 2001 From: alstjr7375 Date: Sun, 15 Feb 2026 00:00:00 +0900 Subject: [PATCH 4/4] Feat: `defineRules` - funtional properties and shortcuts --- .changeset/big-hands-rescue.md | 2 +- .changeset/six-coins-ask.md | 5 + .vscode/settings.json | 1 + examples/react-babel/tsconfig.app.json | 11 +- packages/css/src/defineRules/index.ts | 85 +++++++++++-- packages/css/src/defineRules/types.ts | 160 ++++++++++++++++++++++--- 6 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 .changeset/six-coins-ask.md diff --git a/.changeset/big-hands-rescue.md b/.changeset/big-hands-rescue.md index 9e575cf..c081c84 100644 --- a/.changeset/big-hands-rescue.md +++ b/.changeset/big-hands-rescue.md @@ -2,4 +2,4 @@ "@mincho-js/css": minor --- -- `defineRules`: Properties and shorcuts +- `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 index 8f8ea89..c01af5b 100644 --- a/packages/css/src/defineRules/index.ts +++ b/packages/css/src/defineRules/index.ts @@ -125,6 +125,17 @@ function applyProperty< ) { 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; @@ -178,13 +189,12 @@ function applyShortcut< return; } - // TODO: fn shortcut support - // if (typeof def === "function") { - // // fn shortcut - // const produced = def(value); - // applyInput(ctx, out, produced, nextStack); - // return; - // } + if (typeof def === "function") { + // fn shortcut + const produced = def(value); + applyInput(ctx, out, produced, nextStack); + return; + } if (isPlainObject(def)) { // fixed style shortcut @@ -295,6 +305,42 @@ if (import.meta.vitest) { 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: { @@ -462,6 +508,31 @@ if (import.meta.vitest) { 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 index 4860d27..1b8e108 100644 --- a/packages/css/src/defineRules/types.ts +++ b/packages/css/src/defineRules/types.ts @@ -6,17 +6,29 @@ import type { type CSSPropertiesKeys = keyof CSSProperties; +type DefineRulesResolver< + Out = CSSPropertiesWithVars | undefined, + Arg = unknown +> = { + bivarianceHack(arg: Arg): Out; +}["bivarianceHack"]; + type DefineRulesCssProperties = { - [Property in CSSPropertiesKeys]?: - | ReadonlyArray - | Record - | true - | false; + [Property in CSSPropertiesKeys]?: DefineRulesCssPropertiesValue< + CSSProperties[Property] + >; }; +type DefineRulesCssPropertiesValue = + | ReadonlyArray + | Record + | DefineRulesResolver + | true + | false; + type DefineRulesCustomProperties = Partial< Record< Exclude, - Record + Record | DefineRulesResolver > >; export type DefineRulesProperties = @@ -31,7 +43,8 @@ type ShortcutValue< | keyof Properties | Exclude | ReadonlyArray> - | DefineRulesCssInput; + | DefineRulesCssInput + | DefineRulesResolver>; export type DefineRulesShortcuts< Properties extends DefineRulesProperties, @@ -59,15 +72,17 @@ type PropertiesInput = { type ResolvePropertiesValue = Value extends ReadonlyArray ? Item - : true extends Value - ? Key extends CSSPropertiesKeys - ? CSSProperties[Key] - : never - : false extends Value - ? never - : Value extends Record - ? StyleObjectKey - : never; + : 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, @@ -86,9 +101,11 @@ type ResolveShortcutValue< Value > = Value extends readonly unknown[] ? ResolveShortcutArrayRef - : Value extends Record - ? boolean - : ResolveShortcutRef; + : Value extends (arg: infer Arg) => unknown + ? Arg + : Value extends Record + ? boolean + : ResolveShortcutRef; type ResolveShortcutArrayRef< Properties extends Record, @@ -295,6 +312,68 @@ if (import.meta.vitest) { 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", () => { @@ -462,6 +541,49 @@ if (import.meta.vitest) { }); 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", () => {