diff --git a/README.md b/README.md index 5c3487a..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. @@ -69,6 +70,109 @@ button({ w: "w-full" }); ## Core Concepts +### Variants + +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({ + 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({ intent: "primary", size: "lg" }); +``` + +### 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`. + +Traits are **non-exclusive, stackable modifiers**. Unlike variants (mutually exclusive design choices), multiple traits can be active at the same time. This is a practical way to model boolean-like component states (e.g. `loading`, `disabled`, `glass`) without exploding compoundVariants. + +When multiple traits generate conflicting utilities, Tailwind’s “last one wins” rule applies (via `tailwind-merge`). +If ordering matters, prefer the **array form** to make precedence explicit. + +```typescript +const button = windctrl({ + traits: { + loading: "opacity-50 cursor-wait", + glass: "backdrop-blur-md bg-white/10 border border-white/20", + disabled: "pointer-events-none grayscale", + }, +}); + +// Usage - Array form (explicit precedence; recommended when conflicts are possible) +button({ traits: ["loading", "glass"] }); + +// Usage - Object form (convenient for boolean props; order is not intended to be meaningful) +button({ traits: { loading: isLoading, glass: true } }); +``` + +### Slots (Compound Components) + +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({ + 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 +const { className, slots } = button({ size: "sm", traits: ["loading"] }); + +// Apply to elements + +``` + +Slots follow the same priority rules as root classes: **Base < Variants < Traits**, with `tailwind-merge` handling conflicts. + +Unlike slot-based APIs that require declaring all slots upfront, WindCtrl allows slots to emerge naturally from variants and traits. + ### 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: @@ -131,31 +235,6 @@ 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. -### 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`. - -Traits are **non-exclusive, stackable modifiers**. Unlike variants (mutually exclusive design choices), multiple traits can be active at the same time. This is a practical way to model boolean-like component states (e.g. `loading`, `disabled`, `glass`) without exploding compoundVariants. - -When multiple traits generate conflicting utilities, Tailwind’s “last one wins” rule applies (via `tailwind-merge`). -If ordering matters, prefer the **array form** to make precedence explicit. - -```typescript -const button = windctrl({ - traits: { - loading: "opacity-50 cursor-wait", - glass: "backdrop-blur-md bg-white/10 border border-white/20", - disabled: "pointer-events-none grayscale", - }, -}); - -// Usage - Array form (explicit precedence; recommended when conflicts are possible) -button({ traits: ["loading", "glass"] }); - -// Usage - Object form (convenient for boolean props; order is not intended to be meaningful) -button({ traits: { loading: isLoading, glass: true } }); -``` - ### 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. @@ -178,33 +257,6 @@ const button = windctrl({ The scope classes are automatically prefixed with `group-data-[windctrl-scope=...]/windctrl-scope:` to target the parent's data attribute. -### Variants - -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({ - 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({ intent: "primary", size: "lg" }); -``` - ## 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/src/index.test.ts b/src/index.test.ts index f117e5e..7e7a825 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -744,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 e2ad33f..04bce82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,13 +74,19 @@ export const dynamic = { 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; @@ -93,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 @@ -110,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( @@ -189,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, @@ -207,7 +279,7 @@ export function windctrl< const resolvedVariants = Object.entries(variants) as [ string, - Record, + Record, ][]; const resolvedDynamicEntries = Object.entries(dynamic) as [ string, @@ -218,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) @@ -233,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) @@ -261,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>; }; }