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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## v0.2.2

- Introduce `wcn()` https://github.com/morishxt/windctrl/pull/10
- Extract the props type from a windctrl instance https://github.com/morishxt/windctrl/pull/9
- Refactoring https://github.com/morishxt/windctrl/pull/12

## v0.2.1

- Improve types related `slots` https://github.com/morishxt/windctrl/pull/8
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,61 @@ const button = windctrl({

The scope classes are automatically prefixed with `group-data-[windctrl-scope=...]/windctrl-scope:` to target the parent's data attribute.

## Merging External `className` Safely (`wcn()`)

WindCtrl resolves Tailwind class conflicts **inside** `windctrl()` using `tailwind-merge`.
However, in real applications you often need to merge **additional `className` values** at the component boundary.

A simple string concat can reintroduce conflicts:

```tsx
// ⚠️ Can cause subtle Tailwind conflicts (e.g. p-2 vs p-4)
className={`${result.className} ${className}`}
```

WindCtrl exports a small helper for this use case:

```tsx
import { wcn } from "windctrl";

// ✅ Conflict-safe merge
className={wcn(result.className, className)}
```

`wcn()` is equivalent to `twMerge(clsx(...))` and matches WindCtrl’s internal conflict resolution behavior.
This keeps the “last one wins” behavior consistent across both generated and user-supplied classes.

## Type Helpers (`StyleProps`)

When building reusable components, you often want to expose the exact style-related props inferred from a `windctrl()` definition.

WindCtrl exports a small type helper for this purpose:

```typescript
import type { StyleProps } from "windctrl";
```

`StyleProps<typeof styles>` extracts all variant, trait, and dynamic props from a WindCtrl instance — similar to `VariantProps` in cva.

```typescript
const button = windctrl({ ... });

type ButtonProps<T extends ElementType = "button"> = {
as?: T;
} & Omit<ComponentPropsWithoutRef<T>, keyof StyleProps<typeof button>>
& StyleProps<typeof button>;
```

This lets you:

- Avoid manually duplicating variant/trait prop definitions
- Keep component props automatically in sync with styling config
- Refactor styles without touching component typings

> `StyleProps` is optional - you can always define props manually if you prefer.

> `wcProps` is provided as an alias of `StyleProps` for convenience.

## Gotchas

- **Tailwind JIT:** Tailwind only generates CSS for class names it can statically detect. Avoid constructing class strings dynamically unless you safelist them.
Expand Down
14 changes: 4 additions & 10 deletions examples/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { windctrl, dynamic as d } from "../src/index";
import { windctrl, dynamic as d, wcn, type StyleProps } from "../src/index";
import type { ComponentPropsWithoutRef, ElementType } from "react";

const button = windctrl({
Expand Down Expand Up @@ -39,14 +39,8 @@ const button = windctrl({

type ButtonProps<T extends ElementType = "button"> = {
as?: T;
intent?: "primary" | "secondary" | "destructive" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
traits?:
| Array<"loading" | "glass" | "disabled">
| { loading?: boolean; glass?: boolean; disabled?: boolean };
w?: string | number;
h?: string | number;
} & ComponentPropsWithoutRef<T>;
} & Omit<ComponentPropsWithoutRef<T>, keyof StyleProps<typeof button>> &
StyleProps<typeof button>;

export function Button<T extends ElementType = "button">({
as,
Expand All @@ -71,7 +65,7 @@ export function Button<T extends ElementType = "button">({

return (
<Component
className={`${buttonClassName} ${className || ""}`}
className={wcn(buttonClassName, className)}
style={{ ...buttonStyle, ...style }}
{...props}
>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "windctrl",
"version": "0.2.1",
"version": "0.2.2",
"description": "Advanced variant API for Tailwind CSS with stackable traits and interpolated dynamic styles.",
"license": "MIT",
"author": "Masaki Morishita (@morishxt)",
Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/base.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { windctrl } from "../";

describe("windctrl", () => {
describe("Base classes", () => {
it("should apply base classes when provided", () => {
const button = windctrl({
base: "rounded px-4 py-2",
});

const result = button();
expect(result.className).toContain("rounded");
expect(result.className).toContain("px-4");
expect(result.className).toContain("py-2");
expect(result.style).toEqual(undefined);
});

it("should work without base classes", () => {
const button = windctrl({});

const result = button({});
expect(result.className).toBe("");
expect(result.style).toEqual(undefined);
});
});
});
235 changes: 235 additions & 0 deletions src/__tests__/dynamic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { describe, it, expect } from "vitest";
import { windctrl, dynamic as d } from "../";

describe("windctrl", () => {
describe("Dynamic (Interpolated Variants)", () => {
it("should apply className when dynamic resolver returns string", () => {
const button = windctrl({
dynamic: {
w: (val) => (typeof val === "number" ? `w-[${val}px]` : `w-${val}`),
},
});

const result = button({ w: "full" });
expect(result.className).toContain("w-full");
expect(result.style).toEqual(undefined);
});

it("should apply style when dynamic resolver returns object with style", () => {
const button = windctrl({
dynamic: {
w: (val) =>
typeof val === "number"
? { style: { width: `${val}px` } }
: `w-${val}`,
},
});

const result = button({ w: 200 });
expect(result.style).toEqual({ width: "200px" });
});

it("should merge className and style when dynamic resolver returns both", () => {
const button = windctrl({
base: "rounded",
dynamic: {
color: (val) => ({
className: `text-${val}`,
style: { color: val },
}),
},
});

const result = button({ color: "red" });
expect(result.className).toContain("text-red");
expect(result.style).toEqual({ color: "red" });
});

it("should handle multiple dynamic props", () => {
const button = windctrl({
dynamic: {
w: (val) =>
typeof val === "number"
? { style: { width: `${val}px` } }
: `w-${val}`,
h: (val) =>
typeof val === "number"
? { style: { height: `${val}px` } }
: `h-${val}`,
},
});

const result = button({ w: 100, h: 200 });
expect(result.style).toEqual({ width: "100px", height: "200px" });
});

it("should handle mixed dynamic props (string and number)", () => {
const button = windctrl({
dynamic: {
w: (val) =>
typeof val === "number"
? { style: { width: `${val}px` } }
: `w-${val}`,
},
});

const stringResult = button({ w: "full" });
expect(stringResult.className).toContain("w-full");
expect(stringResult.style).toEqual(undefined);

const numberResult = button({ w: 300 });
expect(numberResult.style).toEqual({ width: "300px" });
});

it("should resolve style conflicts with last one wins for dynamic styles", () => {
const box = windctrl({
dynamic: {
w1: () => ({ style: { width: "100px" } }),
w2: () => ({ style: { width: "200px" } }),
},
});

const result = box({ w1: true as any, w2: true as any });

expect(result.style).toEqual({ width: "200px" });
});
});

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");
});
});
});
Loading