diff --git a/README.md b/README.md index 31541bb..5c3487a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ npm install windctrl ## Quick Start ```typescript -import { windctrl } from "windctrl"; +import { windctrl, dynamic as d } from "windctrl"; const button = windctrl({ base: "rounded px-4 py-2 font-medium transition duration-200", @@ -44,8 +44,7 @@ const button = windctrl({ glass: "backdrop-blur-md bg-white/10 border border-white/20 shadow-xl", }, dynamic: { - w: (val) => - typeof val === "number" ? { style: { width: `${val}px` } } : val, + w: d.px("width"), }, defaultVariants: { intent: "primary", @@ -80,12 +79,46 @@ Interpolated variants provide a **Unified API** that bridges static Tailwind cla This is **JIT-friendly by design**, as long as the class strings you return are statically enumerable (i.e. appear in your source code). For truly unbounded values (e.g. pixel sizes), prefer returning style to avoid relying on arbitrary-value class generation. +#### Dynamic Presets + +WindCtrl provides built-in presets for common dynamic patterns: + +```typescript +import { windctrl, dynamic as d } from "windctrl"; + +const box = windctrl({ + dynamic: { + // d.px() - pixel values (width, height, top, left, etc.) + w: d.px("width"), + h: d.px("height"), + + // d.num() - unitless numbers (zIndex, flexGrow, order, etc.) + z: d.num("zIndex"), + + // d.opacity() - opacity values + fade: d.opacity(), + + // d.var() - CSS custom properties + x: d.var("--translate-x", { unit: "px" }), + }, +}); + +// Usage +box({ w: 200 }); // -> style: { width: "200px" } +box({ w: "w-full" }); // -> className: "w-full" +box({ z: 50 }); // -> style: { zIndex: 50 } +box({ fade: 0.5 }); // -> style: { opacity: 0.5 } +box({ x: 10 }); // -> style: { "--translate-x": "10px" } +``` + +#### Custom Resolvers + +You can also write custom resolvers for more complex logic: + ```typescript const button = windctrl({ dynamic: { - // Recommended pattern: - // - Numbers -> inline styles (unbounded values) - // - Strings -> Tailwind utilities (must be statically enumerable for JIT) + // Custom resolver example w: (val) => typeof val === "number" ? { style: { width: `${val}px` } } : val, }, diff --git a/examples/Button.tsx b/examples/Button.tsx index df1fdfa..ae6d802 100644 --- a/examples/Button.tsx +++ b/examples/Button.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { windctrl } from "../src/index"; +import { windctrl, dynamic as d } from "../src/index"; import type { ComponentPropsWithoutRef, ElementType } from "react"; const button = windctrl({ @@ -24,10 +24,8 @@ const button = windctrl({ disabled: "pointer-events-none opacity-50", }, dynamic: { - w: (val) => - typeof val === "number" ? { style: { width: `${val}px` } } : `w-${val}`, - h: (val) => - typeof val === "number" ? { style: { height: `${val}px` } } : `h-${val}`, + w: d.px("width"), + h: d.px("height"), }, defaultVariants: { intent: "primary", diff --git a/src/index.test.ts b/src/index.test.ts index 7887005..f117e5e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { windctrl, wc } from "./"; +import { windctrl, wc, dynamic as d } from "./"; describe("wc", () => { it("should be the same as windctrl", () => { @@ -321,6 +321,144 @@ describe("windctrl", () => { }); }); + describe("Dynamic presets", () => { + describe("d.px()", () => { + it("should output inline style for number (px) and keep className empty", () => { + const box = windctrl({ + dynamic: { + w: d.px("width"), + }, + }); + + const result = box({ w: 123 }); + expect(result.style).toEqual({ width: "123px" }); + expect(result.className).toBe(""); + }); + + it("should pass through className string for string input (Unified API)", () => { + const box = windctrl({ + dynamic: { + w: d.px("width"), + }, + }); + + const result = box({ w: "w-full" }); + expect(result.className).toContain("w-full"); + expect(result.style).toEqual(undefined); + }); + }); + + describe("d.num()", () => { + it("should output inline style for number (unitless) and keep className empty", () => { + const layer = windctrl({ + dynamic: { + z: d.num("zIndex"), + }, + }); + + const result = layer({ z: 999 }); + expect(result.style).toEqual({ zIndex: 999 }); + expect(result.className).toBe(""); + }); + + it("should pass through className string for string input (Unified API)", () => { + const layer = windctrl({ + dynamic: { + z: d.num("zIndex"), + }, + }); + + const result = layer({ z: "z-50" }); + expect(result.className).toContain("z-50"); + expect(result.style).toEqual(undefined); + }); + }); + + describe("d.opacity()", () => { + it("should output inline style for number and keep className empty", () => { + const fade = windctrl({ + dynamic: { + opacity: d.opacity(), + }, + }); + + const result = fade({ opacity: 0.4 }); + expect(result.style).toEqual({ opacity: 0.4 }); + expect(result.className).toBe(""); + }); + + it("should pass through className string for string input (Unified API)", () => { + const fade = windctrl({ + dynamic: { + opacity: d.opacity(), + }, + }); + + const result = fade({ opacity: "opacity-50" }); + expect(result.className).toContain("opacity-50"); + expect(result.style).toEqual(undefined); + }); + }); + + describe("d.var()", () => { + it("should set CSS variable as inline style for number with unit (no className output)", () => { + const card = windctrl({ + dynamic: { + x: d.var("--x", { unit: "px" }), + }, + }); + + const result = card({ x: 12 }); + + // NOTE: CSS custom properties are stored as object keys. + expect(result.style).toEqual({ "--x": "12px" }); + expect(result.className).toBe(""); + }); + + it("should set CSS variable as inline style for string value (no className output)", () => { + const card = windctrl({ + dynamic: { + x: d.var("--x"), + }, + }); + + const result = card({ x: "10%" }); + expect(result.style).toEqual({ "--x": "10%" }); + expect(result.className).toBe(""); + }); + + it("should merge multiple CSS variables via last-one-wins when same variable is set twice", () => { + const card = windctrl({ + dynamic: { + x1: d.var("--x", { unit: "px" }), + x2: d.var("--x", { unit: "px" }), + }, + }); + + const result = card({ x1: 10, x2: 20 }); + + // last one wins + expect(result.style).toEqual({ "--x": "20px" }); + }); + }); + + it("should coexist with other dynamic resolvers (className + style merge)", () => { + const box = windctrl({ + dynamic: { + w: d.px("width"), + opacity: d.opacity(), + // keep an existing custom resolver in the same config + custom: (v) => (v ? "ring-2" : ""), + }, + }); + + const result = box({ w: 100, opacity: 0.5, custom: true }); + + expect(result.style).toEqual({ width: "100px", opacity: 0.5 }); + expect(result.className).toContain("ring-2"); + }); + }); + describe("Scopes", () => { it("should apply scope classes with group-data selector", () => { const button = windctrl({ diff --git a/src/index.ts b/src/index.ts index 1355be3..e2ad33f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,72 @@ type DynamicResolverResult = | string | { className?: string; style?: CSSProperties }; -type DynamicResolver = (value: any) => DynamicResolverResult; +type DynamicResolver = (value: T) => DynamicResolverResult; + +type PxProp = + | "width" + | "height" + | "minWidth" + | "maxWidth" + | "minHeight" + | "maxHeight" + | "top" + | "right" + | "bottom" + | "left"; + +type NumProp = "zIndex" | "flexGrow" | "flexShrink" | "order"; + +type VarUnit = "px" | "%" | "deg" | "ms"; + +function px(prop: PxProp): DynamicResolver { + return (value: number | string): DynamicResolverResult => { + if (typeof value === "number") { + return { style: { [prop]: `${value}px` } }; + } + return value; + }; +} + +function num(prop: NumProp): DynamicResolver { + return (value: number | string): DynamicResolverResult => { + if (typeof value === "number") { + return { style: { [prop]: value } }; + } + return value; + }; +} + +function opacity(): DynamicResolver { + return (value: number | string): DynamicResolverResult => { + if (typeof value === "number") { + return { style: { opacity: value } }; + } + return value; + }; +} + +function cssVar( + name: `--${string}`, + options?: { unit?: VarUnit }, +): DynamicResolver { + return (value: number | string): DynamicResolverResult => { + if (typeof value === "number") { + if (options?.unit) { + return { style: { [name]: `${value}${options.unit}` } }; + } + return { style: { [name]: String(value) } }; + } + return { style: { [name]: value } }; + }; +} + +export const dynamic = { + px, + num, + opacity, + var: cssVar, +}; type Config< TVariants extends Record> = {},