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>; }; }