Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
},
Expand Down
8 changes: 3 additions & 5 deletions examples/Button.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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",
Expand Down
140 changes: 139 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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({
Expand Down
67 changes: 66 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,72 @@ type DynamicResolverResult =
| string
| { className?: string; style?: CSSProperties };

type DynamicResolver = (value: any) => DynamicResolverResult;
type DynamicResolver<T = any> = (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<number | string> {
return (value: number | string): DynamicResolverResult => {
if (typeof value === "number") {
return { style: { [prop]: `${value}px` } };
}
return value;
};
}

function num(prop: NumProp): DynamicResolver<number | string> {
return (value: number | string): DynamicResolverResult => {
if (typeof value === "number") {
return { style: { [prop]: value } };
}
return value;
};
}

function opacity(): DynamicResolver<number | string> {
return (value: number | string): DynamicResolverResult => {
if (typeof value === "number") {
return { style: { opacity: value } };
}
return value;
};
}

function cssVar(
name: `--${string}`,
options?: { unit?: VarUnit },
): DynamicResolver<number | string> {
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<string, Record<string, ClassValue>> = {},
Expand Down