diff --git a/README.md b/README.md
index 31541bb..f7b5697 100644
--- a/README.md
+++ b/README.md
@@ -4,14 +4,15 @@
**WindCtrl** is a next-generation styling utility that unifies static Tailwind classes and dynamic inline styles into a single, type-safe interface.
-It evolves the concept of Variant APIs (like [cva](https://cva.style/)) by introducing **Stackable Traits** to solve combinatorial explosion and **Interpolated Variants** for seamless dynamic value handling—all while maintaining a minimal runtime footprint optimized for Tailwind's JIT compiler.
+It builds on existing variant APIs (like [cva](https://cva.style/)) and introduces **Stackable Traits** to avoid combinatorial explosion, as well as **Interpolated Variants** for seamless dynamic styling.
+All of this is achieved with a minimal runtime footprint and full compatibility with Tailwind's JIT compiler.
## Features
-- 🎨 **Unified API** - Seamlessly blends static Tailwind classes and dynamic inline styles into one cohesive interface.
- 🧩 **Trait System** - Solves combinatorial explosion by treating states as stackable, non-exclusive layers.
-- 🎯 **Scoped Styling** - Context-aware styling using data attributes - no React Context required (RSC friendly).
+- 🎨 **Unified API** - Seamlessly blends static Tailwind classes and dynamic inline styles into one cohesive interface.
- ⚡ **JIT Conscious** - Designed for Tailwind JIT: utilities stay as class strings, while truly dynamic values can be expressed as inline styles.
+- 🎯 **Scoped Styling** - Context-aware styling using data attributes - no React Context required (RSC friendly).
- 🔒 **Type-Safe** - Best-in-class TypeScript support with automatic prop inference.
- 📦 **Minimal Overhead** - Ultra-lightweight runtime with only `clsx` and `tailwind-merge` as dependencies.
@@ -24,7 +25,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 +45,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",
@@ -70,34 +70,33 @@ button({ w: "w-full" });
## Core Concepts
-### Interpolated Variants (Dynamic Props)
-
-Interpolated variants provide a **Unified API** that bridges static Tailwind classes and dynamic inline styles. A dynamic resolver can return either:
-
-- a **Tailwind class string** (static utility), or
-- an object containing **className and/or style** (inline styles and optional utilities)
+### Variants
-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.
+Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
```typescript
const button = windctrl({
- dynamic: {
- // Recommended pattern:
- // - Numbers -> inline styles (unbounded values)
- // - Strings -> Tailwind utilities (must be statically enumerable for JIT)
- w: (val) =>
- typeof val === "number" ? { style: { width: `${val}px` } } : val,
+ variants: {
+ intent: {
+ primary: "bg-blue-500 text-white hover:bg-blue-600",
+ secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
+ },
+ size: {
+ sm: "text-sm h-8 px-3",
+ md: "text-base h-10 px-4",
+ lg: "text-lg h-12 px-6",
+ },
+ },
+ defaultVariants: {
+ intent: "primary",
+ size: "md",
},
});
// Usage
-button({ w: "w-full" }); // -> className includes "w-full" (static utility)
-button({ w: 200 }); // -> style includes { width: "200px" } (dynamic value)
+button({ intent: "primary", size: "lg" });
```
-> **Note on Tailwind JIT**: Tailwind only generates CSS for class names it can statically detect in your source. Avoid constructing class strings dynamically (e.g. "`w-`" + `size`) unless you safelist them in your Tailwind config.
-
### Traits (Stackable States)
Traits are non-exclusive, stackable layers of state. Unlike `variants` (which are mutually exclusive), multiple traits can be active simultaneously. This declarative approach solves the "combinatorial explosion" problem often seen with `compoundVariants`.
@@ -123,55 +122,141 @@ button({ traits: ["loading", "glass"] });
button({ traits: { loading: isLoading, glass: true } });
```
-### Scopes (RSC Support)
+### Slots (Compound Components)
-Scopes enable **context-aware styling** without relying on React Context or client-side JavaScript. This makes them fully compatible with React Server Components (RSC). They utilize Tailwind's group modifier logic under the hood.
+Slots allow you to define styles for **sub-elements** (e.g., icon, label) within a single component definition. Each slot returns its own class string, enabling clean compound component patterns.
+
+Slots are completely optional and additive. You can start with a single-root component and introduce slots only when needed.
+If a slot is never defined, it simply won't appear in the result.
```typescript
const button = windctrl({
- scopes: {
- header: "text-sm py-1",
- footer: "text-xs text-gray-500",
+ base: {
+ root: "inline-flex items-center gap-2 rounded px-4 py-2",
+ slots: {
+ icon: "shrink-0",
+ label: "truncate",
+ },
+ },
+ variants: {
+ size: {
+ sm: {
+ root: "h-8 text-sm",
+ slots: { icon: "h-3 w-3" },
+ },
+ md: {
+ root: "h-10 text-base",
+ slots: { icon: "h-4 w-4" },
+ },
+ },
+ },
+ traits: {
+ loading: {
+ root: "opacity-70 pointer-events-none",
+ slots: { icon: "animate-spin" },
+ },
},
+ defaultVariants: { size: "md" },
});
// Usage
-// 1. Wrap the parent with `data-windctrl-scope` and `group/windctrl-scope`
-// 2. The button automatically adapts its style based on the parent
-
-
-
+const { className, slots } = button({ size: "sm", traits: ["loading"] });
+
+// Apply to elements
+
```
-The scope classes are automatically prefixed with `group-data-[windctrl-scope=...]/windctrl-scope:` to target the parent's data attribute.
+Slots follow the same priority rules as root classes: **Base < Variants < Traits**, with `tailwind-merge` handling conflicts.
-### Variants
+Unlike slot-based APIs that require declaring all slots upfront, WindCtrl allows slots to emerge naturally from variants and traits.
-Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
+### Interpolated Variants (Dynamic Props)
+
+Interpolated variants provide a **Unified API** that bridges static Tailwind classes and dynamic inline styles. A dynamic resolver can return either:
+
+- a **Tailwind class string** (static utility), or
+- an object containing **className and/or style** (inline styles and optional utilities)
+
+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({
- variants: {
- intent: {
- primary: "bg-blue-500 text-white hover:bg-blue-600",
- secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
- },
- size: {
- sm: "text-sm h-8 px-3",
- md: "text-base h-10 px-4",
- lg: "text-lg h-12 px-6",
- },
+ dynamic: {
+ // Custom resolver example
+ w: (val) =>
+ typeof val === "number" ? { style: { width: `${val}px` } } : val,
},
- defaultVariants: {
- intent: "primary",
- size: "md",
+});
+
+// Usage
+button({ w: "w-full" }); // -> className includes "w-full" (static utility)
+button({ w: 200 }); // -> style includes { width: "200px" } (dynamic value)
+```
+
+> **Note on Tailwind JIT**: Tailwind only generates CSS for class names it can statically detect in your source. Avoid constructing class strings dynamically (e.g. "`w-`" + `size`) unless you safelist them in your Tailwind config.
+
+### Scopes (RSC Support)
+
+Scopes enable **context-aware styling** without relying on React Context or client-side JavaScript. This makes them fully compatible with React Server Components (RSC). They utilize Tailwind's group modifier logic under the hood.
+
+```typescript
+const button = windctrl({
+ scopes: {
+ header: "text-sm py-1",
+ footer: "text-xs text-gray-500",
},
});
// Usage
-button({ intent: "primary", size: "lg" });
+// 1. Wrap the parent with `data-windctrl-scope` and `group/windctrl-scope`
+// 2. The button automatically adapts its style based on the parent
+
+
+
```
+The scope classes are automatically prefixed with `group-data-[windctrl-scope=...]/windctrl-scope:` to target the parent's data attribute.
+
## Gotchas
- **Tailwind JIT:** Tailwind only generates CSS for class names it can statically detect. Avoid constructing class strings dynamically unless you safelist them.
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..7e7a825 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({
@@ -606,4 +744,144 @@ describe("windctrl", () => {
expect(result2.style).toEqual({ width: "100px" });
});
});
+
+ describe("Slots", () => {
+ it("should return slots as class strings and keep root as className/style", () => {
+ const button = windctrl({
+ base: {
+ root: "rounded",
+ slots: {
+ icon: "shrink-0",
+ label: "truncate",
+ },
+ },
+ });
+
+ const result = button();
+
+ // root stays on className/style
+ expect(result.className).toContain("rounded");
+ expect(result.style).toEqual(undefined);
+
+ // slots exist as strings
+ expect(result.slots?.icon).toContain("shrink-0");
+ expect(result.slots?.label).toContain("truncate");
+ });
+
+ it("should apply variant slot classes based on prop value (and keep root variants working)", () => {
+ const button = windctrl({
+ base: {
+ root: "inline-flex",
+ slots: { icon: "shrink-0" },
+ },
+ variants: {
+ size: {
+ sm: {
+ root: "h-8",
+ slots: { icon: "h-3 w-3" },
+ },
+ md: {
+ root: "h-10",
+ slots: { icon: "h-4 w-4" },
+ },
+ },
+ },
+ defaultVariants: { size: "md" },
+ });
+
+ const sm = button({ size: "sm" });
+ expect(sm.className).toContain("inline-flex");
+ expect(sm.className).toContain("h-8");
+ expect(sm.slots?.icon).toContain("h-3");
+ expect(sm.slots?.icon).toContain("w-3");
+
+ const fallback = button({});
+ expect(fallback.className).toContain("h-10");
+ expect(fallback.slots?.icon).toContain("h-4");
+ expect(fallback.slots?.icon).toContain("w-4");
+ });
+
+ it("should apply trait slot classes (array form) and merge with base/variants", () => {
+ const button = windctrl({
+ base: {
+ root: "inline-flex",
+ slots: { icon: "shrink-0" },
+ },
+ variants: {
+ size: {
+ sm: { slots: { icon: "h-3 w-3" } },
+ },
+ },
+ traits: {
+ loading: {
+ root: "opacity-70",
+ slots: { icon: "animate-spin" },
+ },
+ },
+ });
+
+ const result = button({ size: "sm", traits: ["loading"] });
+
+ // root gets trait too
+ expect(result.className).toContain("opacity-70");
+
+ // icon gets base + variant + trait
+ expect(result.slots?.icon).toContain("shrink-0");
+ expect(result.slots?.icon).toContain("h-3");
+ expect(result.slots?.icon).toContain("w-3");
+ expect(result.slots?.icon).toContain("animate-spin");
+ });
+
+ it("should let Traits override Variants on slots when Tailwind classes conflict (via twMerge)", () => {
+ const button = windctrl({
+ variants: {
+ intent: {
+ primary: { slots: { icon: "text-blue-500" } },
+ },
+ },
+ traits: {
+ dangerIcon: { slots: { icon: "text-red-500" } },
+ },
+ });
+
+ const result = button({ intent: "primary", traits: ["dangerIcon"] });
+
+ // last one wins: Traits > Variants
+ expect(result.slots?.icon).toContain("text-red-500");
+ expect(result.slots?.icon).not.toContain("text-blue-500");
+ });
+
+ it("should ignore invalid trait keys for slots gracefully (same behavior as root traits)", () => {
+ const button = windctrl({
+ traits: {
+ loading: { slots: { icon: "animate-spin" } },
+ },
+ });
+
+ const result = button({ traits: ["invalid-trait" as any] });
+
+ expect(result.slots?.icon).toBe(undefined);
+ });
+
+ it("should not include slots when slots are not configured", () => {
+ const button = windctrl({
+ base: "rounded",
+ variants: {
+ size: {
+ sm: "text-sm",
+ },
+ },
+ traits: {
+ loading: "opacity-50",
+ },
+ });
+
+ const result = button({ size: "sm", traits: ["loading"] });
+
+ expect(result.className).toContain("rounded");
+ expect(result.className).toContain("text-sm");
+ expect(result.className).toContain("opacity-50");
+ expect((result as any).slots).toBe(undefined);
+ });
+ });
});
diff --git a/src/index.ts b/src/index.ts
index 1355be3..04bce82 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,15 +7,86 @@ 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 SlotAwareObject = {
+ root?: ClassValue;
+ slots?: Record;
+};
+type SlotAwareValue = ClassValue | SlotAwareObject;
type Config<
- TVariants extends Record> = {},
- TTraits extends Record = {},
+ TVariants extends Record> = {},
+ TTraits extends Record = {},
TDynamic extends Record = {},
TScopes extends Record = {},
> = {
- base?: ClassValue;
+ base?: SlotAwareValue;
variants?: TVariants;
traits?: TTraits;
dynamic?: TDynamic;
@@ -28,8 +99,8 @@ type Config<
};
type Props<
- TVariants extends Record> = {},
- TTraits extends Record = {},
+ TVariants extends Record> = {},
+ TTraits extends Record = {},
TDynamic extends Record = {},
> = {
[K in keyof TVariants]?: keyof TVariants[K] extends string
@@ -45,34 +116,98 @@ type Props<
[K in keyof TDynamic]?: Parameters[0];
};
-type Result = {
+type SlotsOfValue = V extends { slots?: infer S }
+ ? S extends Record
+ ? keyof S
+ : never
+ : never;
+
+type VariantOptionValues =
+ T extends Record> ? V : never;
+
+type TraitValues = T extends Record ? V : never;
+
+type SlotKeys<
+ TBase,
+ TVariants extends Record>,
+ TTraits extends Record,
+> = Extract<
+ | SlotsOfValue
+ | SlotsOfValue>
+ | SlotsOfValue>,
+ string
+>;
+
+type Result = {
className: string;
style?: CSSProperties;
+ slots?: Partial>;
};
function mergeStyles(...styles: (CSSProperties | undefined)[]): CSSProperties {
return Object.assign({}, ...styles.filter(Boolean));
}
-function processTraits>(
+function isSlotAwareValue(value: unknown): value is SlotAwareObject {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ return false;
+ }
+ const obj = value as Record;
+ const hasRoot = "root" in obj;
+ const hasSlots =
+ "slots" in obj && typeof obj.slots === "object" && obj.slots !== null;
+ return hasRoot || hasSlots;
+}
+
+function addSlotClasses(
+ slotParts: Record,
+ slots: Record,
+): void {
+ for (const [slotName, slotClasses] of Object.entries(slots)) {
+ if (!slotParts[slotName]) {
+ slotParts[slotName] = [];
+ }
+ slotParts[slotName].push(slotClasses);
+ }
+}
+
+function processTraits>(
traits: TTraits,
propsTraits?: Props<{}, TTraits>["traits"],
+ slotParts?: Record,
): ClassValue[] {
if (!propsTraits) return [];
- if (Array.isArray(propsTraits)) {
- return propsTraits
- .filter((key) => key in traits)
- .map((key) => traits[key as keyof TTraits]);
- }
+ const rootClasses: ClassValue[] = [];
+
+ const processTraitKey = (key: string) => {
+ if (!(key in traits)) return;
+ const traitValue = traits[key as keyof TTraits];
+ if (isSlotAwareValue(traitValue)) {
+ if (traitValue.root) {
+ rootClasses.push(traitValue.root);
+ }
+ if (traitValue.slots && slotParts) {
+ addSlotClasses(slotParts, traitValue.slots);
+ }
+ } else {
+ rootClasses.push(traitValue);
+ }
+ };
- if (typeof propsTraits === "object") {
- return Object.entries(propsTraits)
- .filter(([key, value]) => value && key in traits)
- .map(([key]) => traits[key as keyof TTraits]);
+ if (Array.isArray(propsTraits)) {
+ for (const key of propsTraits) {
+ processTraitKey(key);
+ }
+ } else if (typeof propsTraits === "object") {
+ for (const [key, value] of Object.entries(propsTraits)) {
+ if (value) {
+ processTraitKey(key);
+ }
+ }
}
- return [];
+ return rootClasses;
}
function processDynamicEntries(
@@ -124,13 +259,15 @@ function processScopes>(
}
export function windctrl<
- TVariants extends Record> = {},
- TTraits extends Record = {},
+ TVariants extends Record> = {},
+ TTraits extends Record = {},
TDynamic extends Record = {},
TScopes extends Record = {},
>(
config: Config,
-): (props?: Props) => Result {
+): (
+ props?: Props,
+) => Result> {
const {
base,
variants = {} as TVariants,
@@ -142,7 +279,7 @@ export function windctrl<
const resolvedVariants = Object.entries(variants) as [
string,
- Record,
+ Record,
][];
const resolvedDynamicEntries = Object.entries(dynamic) as [
string,
@@ -153,13 +290,23 @@ export function windctrl<
return (props = {} as Props) => {
const classNameParts: ClassValue[] = [];
let mergedStyle: CSSProperties = {};
+ const slotParts: Record = {};
// Priority order: Base < Variants < Traits < Dynamic
// (Higher priority classes are added later, so tailwind-merge will keep them)
// 1. Base classes (lowest priority)
if (base) {
- classNameParts.push(base);
+ if (isSlotAwareValue(base)) {
+ if (base.root) {
+ classNameParts.push(base.root);
+ }
+ if (base.slots) {
+ addSlotClasses(slotParts, base.slots);
+ }
+ } else {
+ classNameParts.push(base);
+ }
}
// 2. Variants (with defaultVariants fallback)
@@ -168,13 +315,23 @@ export function windctrl<
props[variantKey as keyof typeof props] ??
defaultVariants[variantKey as keyof typeof defaultVariants];
if (propValue && variantOptions[propValue as string]) {
- classNameParts.push(variantOptions[propValue as string]);
+ const optionValue = variantOptions[propValue as string];
+ if (isSlotAwareValue(optionValue)) {
+ if (optionValue.root) {
+ classNameParts.push(optionValue.root);
+ }
+ if (optionValue.slots) {
+ addSlotClasses(slotParts, optionValue.slots);
+ }
+ } else {
+ classNameParts.push(optionValue);
+ }
}
}
// 3. Traits (higher priority than variants)
if (props.traits) {
- classNameParts.push(...processTraits(traits, props.traits));
+ classNameParts.push(...processTraits(traits, props.traits, slotParts));
}
// 4. Dynamic (highest priority for className)
@@ -196,10 +353,26 @@ export function windctrl<
const hasStyle = Object.keys(mergedStyle).length > 0;
+ let finalSlots: Record | undefined;
+ const slotNames = Object.keys(slotParts);
+ if (slotNames.length > 0) {
+ finalSlots = {};
+ for (const slotName of slotNames) {
+ const merged = twMerge(clsx(slotParts[slotName]));
+ if (merged) {
+ finalSlots[slotName] = merged;
+ }
+ }
+ if (Object.keys(finalSlots).length === 0) {
+ finalSlots = undefined;
+ }
+ }
+
return {
className: finalClassName,
...(hasStyle && { style: mergedStyle }),
- };
+ ...(finalSlots && { slots: finalSlots }),
+ } as Result>;
};
}