Skip to content

Commit f07f09e

Browse files
authored
Merge pull request #6 from morishxt/dynamic-presets
Preset functions for `dynamic`
2 parents 7dd2788 + 69b88f4 commit f07f09e

4 files changed

Lines changed: 247 additions & 13 deletions

File tree

README.md

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ npm install windctrl
2424
## Quick Start
2525

2626
```typescript
27-
import { windctrl } from "windctrl";
27+
import { windctrl, dynamic as d } from "windctrl";
2828

2929
const button = windctrl({
3030
base: "rounded px-4 py-2 font-medium transition duration-200",
@@ -44,8 +44,7 @@ const button = windctrl({
4444
glass: "backdrop-blur-md bg-white/10 border border-white/20 shadow-xl",
4545
},
4646
dynamic: {
47-
w: (val) =>
48-
typeof val === "number" ? { style: { width: `${val}px` } } : val,
47+
w: d.px("width"),
4948
},
5049
defaultVariants: {
5150
intent: "primary",
@@ -80,12 +79,46 @@ Interpolated variants provide a **Unified API** that bridges static Tailwind cla
8079
This is **JIT-friendly by design**, as long as the class strings you return are statically enumerable (i.e. appear in your source code).
8180
For truly unbounded values (e.g. pixel sizes), prefer returning style to avoid relying on arbitrary-value class generation.
8281

82+
#### Dynamic Presets
83+
84+
WindCtrl provides built-in presets for common dynamic patterns:
85+
86+
```typescript
87+
import { windctrl, dynamic as d } from "windctrl";
88+
89+
const box = windctrl({
90+
dynamic: {
91+
// d.px() - pixel values (width, height, top, left, etc.)
92+
w: d.px("width"),
93+
h: d.px("height"),
94+
95+
// d.num() - unitless numbers (zIndex, flexGrow, order, etc.)
96+
z: d.num("zIndex"),
97+
98+
// d.opacity() - opacity values
99+
fade: d.opacity(),
100+
101+
// d.var() - CSS custom properties
102+
x: d.var("--translate-x", { unit: "px" }),
103+
},
104+
});
105+
106+
// Usage
107+
box({ w: 200 }); // -> style: { width: "200px" }
108+
box({ w: "w-full" }); // -> className: "w-full"
109+
box({ z: 50 }); // -> style: { zIndex: 50 }
110+
box({ fade: 0.5 }); // -> style: { opacity: 0.5 }
111+
box({ x: 10 }); // -> style: { "--translate-x": "10px" }
112+
```
113+
114+
#### Custom Resolvers
115+
116+
You can also write custom resolvers for more complex logic:
117+
83118
```typescript
84119
const button = windctrl({
85120
dynamic: {
86-
// Recommended pattern:
87-
// - Numbers -> inline styles (unbounded values)
88-
// - Strings -> Tailwind utilities (must be statically enumerable for JIT)
121+
// Custom resolver example
89122
w: (val) =>
90123
typeof val === "number" ? { style: { width: `${val}px` } } : val,
91124
},

examples/Button.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { windctrl } from "../src/index";
2+
import { windctrl, dynamic as d } from "../src/index";
33
import type { ComponentPropsWithoutRef, ElementType } from "react";
44

55
const button = windctrl({
@@ -24,10 +24,8 @@ const button = windctrl({
2424
disabled: "pointer-events-none opacity-50",
2525
},
2626
dynamic: {
27-
w: (val) =>
28-
typeof val === "number" ? { style: { width: `${val}px` } } : `w-${val}`,
29-
h: (val) =>
30-
typeof val === "number" ? { style: { height: `${val}px` } } : `h-${val}`,
27+
w: d.px("width"),
28+
h: d.px("height"),
3129
},
3230
defaultVariants: {
3331
intent: "primary",

src/index.test.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { windctrl, wc } from "./";
2+
import { windctrl, wc, dynamic as d } from "./";
33

44
describe("wc", () => {
55
it("should be the same as windctrl", () => {
@@ -321,6 +321,144 @@ describe("windctrl", () => {
321321
});
322322
});
323323

324+
describe("Dynamic presets", () => {
325+
describe("d.px()", () => {
326+
it("should output inline style for number (px) and keep className empty", () => {
327+
const box = windctrl({
328+
dynamic: {
329+
w: d.px("width"),
330+
},
331+
});
332+
333+
const result = box({ w: 123 });
334+
expect(result.style).toEqual({ width: "123px" });
335+
expect(result.className).toBe("");
336+
});
337+
338+
it("should pass through className string for string input (Unified API)", () => {
339+
const box = windctrl({
340+
dynamic: {
341+
w: d.px("width"),
342+
},
343+
});
344+
345+
const result = box({ w: "w-full" });
346+
expect(result.className).toContain("w-full");
347+
expect(result.style).toEqual(undefined);
348+
});
349+
});
350+
351+
describe("d.num()", () => {
352+
it("should output inline style for number (unitless) and keep className empty", () => {
353+
const layer = windctrl({
354+
dynamic: {
355+
z: d.num("zIndex"),
356+
},
357+
});
358+
359+
const result = layer({ z: 999 });
360+
expect(result.style).toEqual({ zIndex: 999 });
361+
expect(result.className).toBe("");
362+
});
363+
364+
it("should pass through className string for string input (Unified API)", () => {
365+
const layer = windctrl({
366+
dynamic: {
367+
z: d.num("zIndex"),
368+
},
369+
});
370+
371+
const result = layer({ z: "z-50" });
372+
expect(result.className).toContain("z-50");
373+
expect(result.style).toEqual(undefined);
374+
});
375+
});
376+
377+
describe("d.opacity()", () => {
378+
it("should output inline style for number and keep className empty", () => {
379+
const fade = windctrl({
380+
dynamic: {
381+
opacity: d.opacity(),
382+
},
383+
});
384+
385+
const result = fade({ opacity: 0.4 });
386+
expect(result.style).toEqual({ opacity: 0.4 });
387+
expect(result.className).toBe("");
388+
});
389+
390+
it("should pass through className string for string input (Unified API)", () => {
391+
const fade = windctrl({
392+
dynamic: {
393+
opacity: d.opacity(),
394+
},
395+
});
396+
397+
const result = fade({ opacity: "opacity-50" });
398+
expect(result.className).toContain("opacity-50");
399+
expect(result.style).toEqual(undefined);
400+
});
401+
});
402+
403+
describe("d.var()", () => {
404+
it("should set CSS variable as inline style for number with unit (no className output)", () => {
405+
const card = windctrl({
406+
dynamic: {
407+
x: d.var("--x", { unit: "px" }),
408+
},
409+
});
410+
411+
const result = card({ x: 12 });
412+
413+
// NOTE: CSS custom properties are stored as object keys.
414+
expect(result.style).toEqual({ "--x": "12px" });
415+
expect(result.className).toBe("");
416+
});
417+
418+
it("should set CSS variable as inline style for string value (no className output)", () => {
419+
const card = windctrl({
420+
dynamic: {
421+
x: d.var("--x"),
422+
},
423+
});
424+
425+
const result = card({ x: "10%" });
426+
expect(result.style).toEqual({ "--x": "10%" });
427+
expect(result.className).toBe("");
428+
});
429+
430+
it("should merge multiple CSS variables via last-one-wins when same variable is set twice", () => {
431+
const card = windctrl({
432+
dynamic: {
433+
x1: d.var("--x", { unit: "px" }),
434+
x2: d.var("--x", { unit: "px" }),
435+
},
436+
});
437+
438+
const result = card({ x1: 10, x2: 20 });
439+
440+
// last one wins
441+
expect(result.style).toEqual({ "--x": "20px" });
442+
});
443+
});
444+
445+
it("should coexist with other dynamic resolvers (className + style merge)", () => {
446+
const box = windctrl({
447+
dynamic: {
448+
w: d.px("width"),
449+
opacity: d.opacity(),
450+
// keep an existing custom resolver in the same config
451+
custom: (v) => (v ? "ring-2" : ""),
452+
},
453+
});
454+
455+
const result = box({ w: 100, opacity: 0.5, custom: true });
456+
457+
expect(result.style).toEqual({ width: "100px", opacity: 0.5 });
458+
expect(result.className).toContain("ring-2");
459+
});
460+
});
461+
324462
describe("Scopes", () => {
325463
it("should apply scope classes with group-data selector", () => {
326464
const button = windctrl({

src/index.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,72 @@ type DynamicResolverResult =
77
| string
88
| { className?: string; style?: CSSProperties };
99

10-
type DynamicResolver = (value: any) => DynamicResolverResult;
10+
type DynamicResolver<T = any> = (value: T) => DynamicResolverResult;
11+
12+
type PxProp =
13+
| "width"
14+
| "height"
15+
| "minWidth"
16+
| "maxWidth"
17+
| "minHeight"
18+
| "maxHeight"
19+
| "top"
20+
| "right"
21+
| "bottom"
22+
| "left";
23+
24+
type NumProp = "zIndex" | "flexGrow" | "flexShrink" | "order";
25+
26+
type VarUnit = "px" | "%" | "deg" | "ms";
27+
28+
function px(prop: PxProp): DynamicResolver<number | string> {
29+
return (value: number | string): DynamicResolverResult => {
30+
if (typeof value === "number") {
31+
return { style: { [prop]: `${value}px` } };
32+
}
33+
return value;
34+
};
35+
}
36+
37+
function num(prop: NumProp): DynamicResolver<number | string> {
38+
return (value: number | string): DynamicResolverResult => {
39+
if (typeof value === "number") {
40+
return { style: { [prop]: value } };
41+
}
42+
return value;
43+
};
44+
}
45+
46+
function opacity(): DynamicResolver<number | string> {
47+
return (value: number | string): DynamicResolverResult => {
48+
if (typeof value === "number") {
49+
return { style: { opacity: value } };
50+
}
51+
return value;
52+
};
53+
}
54+
55+
function cssVar(
56+
name: `--${string}`,
57+
options?: { unit?: VarUnit },
58+
): DynamicResolver<number | string> {
59+
return (value: number | string): DynamicResolverResult => {
60+
if (typeof value === "number") {
61+
if (options?.unit) {
62+
return { style: { [name]: `${value}${options.unit}` } };
63+
}
64+
return { style: { [name]: String(value) } };
65+
}
66+
return { style: { [name]: value } };
67+
};
68+
}
69+
70+
export const dynamic = {
71+
px,
72+
num,
73+
opacity,
74+
var: cssVar,
75+
};
1176

1277
type Config<
1378
TVariants extends Record<string, Record<string, ClassValue>> = {},

0 commit comments

Comments
 (0)