Skip to content

Commit 770d72d

Browse files
authored
Merge pull request #5 from morishxt/feat-slot
Introduce `slots`
2 parents f07f09e + a5c0954 commit 770d72d

3 files changed

Lines changed: 380 additions & 80 deletions

File tree

README.md

Lines changed: 107 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
55
**WindCtrl** is a next-generation styling utility that unifies static Tailwind classes and dynamic inline styles into a single, type-safe interface.
66

7-
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.
7+
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.
8+
All of this is achieved with a minimal runtime footprint and full compatibility with Tailwind's JIT compiler.
89

910
## Features
1011

11-
- 🎨 **Unified API** - Seamlessly blends static Tailwind classes and dynamic inline styles into one cohesive interface.
1212
- 🧩 **Trait System** - Solves combinatorial explosion by treating states as stackable, non-exclusive layers.
13-
- 🎯 **Scoped Styling** - Context-aware styling using data attributes - no React Context required (RSC friendly).
13+
- 🎨 **Unified API** - Seamlessly blends static Tailwind classes and dynamic inline styles into one cohesive interface.
1414
-**JIT Conscious** - Designed for Tailwind JIT: utilities stay as class strings, while truly dynamic values can be expressed as inline styles.
15+
- 🎯 **Scoped Styling** - Context-aware styling using data attributes - no React Context required (RSC friendly).
1516
- 🔒 **Type-Safe** - Best-in-class TypeScript support with automatic prop inference.
1617
- 📦 **Minimal Overhead** - Ultra-lightweight runtime with only `clsx` and `tailwind-merge` as dependencies.
1718

@@ -69,6 +70,109 @@ button({ w: "w-full" });
6970

7071
## Core Concepts
7172

73+
### Variants
74+
75+
Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
76+
77+
```typescript
78+
const button = windctrl({
79+
variants: {
80+
intent: {
81+
primary: "bg-blue-500 text-white hover:bg-blue-600",
82+
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
83+
},
84+
size: {
85+
sm: "text-sm h-8 px-3",
86+
md: "text-base h-10 px-4",
87+
lg: "text-lg h-12 px-6",
88+
},
89+
},
90+
defaultVariants: {
91+
intent: "primary",
92+
size: "md",
93+
},
94+
});
95+
96+
// Usage
97+
button({ intent: "primary", size: "lg" });
98+
```
99+
100+
### Traits (Stackable States)
101+
102+
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`.
103+
104+
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.
105+
106+
When multiple traits generate conflicting utilities, Tailwind’s “last one wins” rule applies (via `tailwind-merge`).
107+
If ordering matters, prefer the **array form** to make precedence explicit.
108+
109+
```typescript
110+
const button = windctrl({
111+
traits: {
112+
loading: "opacity-50 cursor-wait",
113+
glass: "backdrop-blur-md bg-white/10 border border-white/20",
114+
disabled: "pointer-events-none grayscale",
115+
},
116+
});
117+
118+
// Usage - Array form (explicit precedence; recommended when conflicts are possible)
119+
button({ traits: ["loading", "glass"] });
120+
121+
// Usage - Object form (convenient for boolean props; order is not intended to be meaningful)
122+
button({ traits: { loading: isLoading, glass: true } });
123+
```
124+
125+
### Slots (Compound Components)
126+
127+
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.
128+
129+
Slots are completely optional and additive. You can start with a single-root component and introduce slots only when needed.
130+
If a slot is never defined, it simply won't appear in the result.
131+
132+
```typescript
133+
const button = windctrl({
134+
base: {
135+
root: "inline-flex items-center gap-2 rounded px-4 py-2",
136+
slots: {
137+
icon: "shrink-0",
138+
label: "truncate",
139+
},
140+
},
141+
variants: {
142+
size: {
143+
sm: {
144+
root: "h-8 text-sm",
145+
slots: { icon: "h-3 w-3" },
146+
},
147+
md: {
148+
root: "h-10 text-base",
149+
slots: { icon: "h-4 w-4" },
150+
},
151+
},
152+
},
153+
traits: {
154+
loading: {
155+
root: "opacity-70 pointer-events-none",
156+
slots: { icon: "animate-spin" },
157+
},
158+
},
159+
defaultVariants: { size: "md" },
160+
});
161+
162+
// Usage
163+
const { className, slots } = button({ size: "sm", traits: ["loading"] });
164+
165+
// Apply to elements
166+
<button className={className}>
167+
<Icon className={slots?.icon} />
168+
<span className={slots?.label}>Click me</span>
169+
</button>
170+
```
171+
172+
Slots follow the same priority rules as root classes: **Base < Variants < Traits**, with `tailwind-merge` handling conflicts.
173+
174+
Unlike slot-based APIs that require declaring all slots upfront, WindCtrl allows slots to emerge naturally from variants and traits.
175+
72176
### Interpolated Variants (Dynamic Props)
73177

74178
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)
131235

132236
> **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.
133237
134-
### Traits (Stackable States)
135-
136-
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`.
137-
138-
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.
139-
140-
When multiple traits generate conflicting utilities, Tailwind’s “last one wins” rule applies (via `tailwind-merge`).
141-
If ordering matters, prefer the **array form** to make precedence explicit.
142-
143-
```typescript
144-
const button = windctrl({
145-
traits: {
146-
loading: "opacity-50 cursor-wait",
147-
glass: "backdrop-blur-md bg-white/10 border border-white/20",
148-
disabled: "pointer-events-none grayscale",
149-
},
150-
});
151-
152-
// Usage - Array form (explicit precedence; recommended when conflicts are possible)
153-
button({ traits: ["loading", "glass"] });
154-
155-
// Usage - Object form (convenient for boolean props; order is not intended to be meaningful)
156-
button({ traits: { loading: isLoading, glass: true } });
157-
```
158-
159238
### Scopes (RSC Support)
160239

161240
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({
178257

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

181-
### Variants
182-
183-
Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
184-
185-
```typescript
186-
const button = windctrl({
187-
variants: {
188-
intent: {
189-
primary: "bg-blue-500 text-white hover:bg-blue-600",
190-
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
191-
},
192-
size: {
193-
sm: "text-sm h-8 px-3",
194-
md: "text-base h-10 px-4",
195-
lg: "text-lg h-12 px-6",
196-
},
197-
},
198-
defaultVariants: {
199-
intent: "primary",
200-
size: "md",
201-
},
202-
});
203-
204-
// Usage
205-
button({ intent: "primary", size: "lg" });
206-
```
207-
208260
## Gotchas
209261

210262
- **Tailwind JIT:** Tailwind only generates CSS for class names it can statically detect. Avoid constructing class strings dynamically unless you safelist them.

src/index.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,4 +744,144 @@ describe("windctrl", () => {
744744
expect(result2.style).toEqual({ width: "100px" });
745745
});
746746
});
747+
748+
describe("Slots", () => {
749+
it("should return slots as class strings and keep root as className/style", () => {
750+
const button = windctrl({
751+
base: {
752+
root: "rounded",
753+
slots: {
754+
icon: "shrink-0",
755+
label: "truncate",
756+
},
757+
},
758+
});
759+
760+
const result = button();
761+
762+
// root stays on className/style
763+
expect(result.className).toContain("rounded");
764+
expect(result.style).toEqual(undefined);
765+
766+
// slots exist as strings
767+
expect(result.slots?.icon).toContain("shrink-0");
768+
expect(result.slots?.label).toContain("truncate");
769+
});
770+
771+
it("should apply variant slot classes based on prop value (and keep root variants working)", () => {
772+
const button = windctrl({
773+
base: {
774+
root: "inline-flex",
775+
slots: { icon: "shrink-0" },
776+
},
777+
variants: {
778+
size: {
779+
sm: {
780+
root: "h-8",
781+
slots: { icon: "h-3 w-3" },
782+
},
783+
md: {
784+
root: "h-10",
785+
slots: { icon: "h-4 w-4" },
786+
},
787+
},
788+
},
789+
defaultVariants: { size: "md" },
790+
});
791+
792+
const sm = button({ size: "sm" });
793+
expect(sm.className).toContain("inline-flex");
794+
expect(sm.className).toContain("h-8");
795+
expect(sm.slots?.icon).toContain("h-3");
796+
expect(sm.slots?.icon).toContain("w-3");
797+
798+
const fallback = button({});
799+
expect(fallback.className).toContain("h-10");
800+
expect(fallback.slots?.icon).toContain("h-4");
801+
expect(fallback.slots?.icon).toContain("w-4");
802+
});
803+
804+
it("should apply trait slot classes (array form) and merge with base/variants", () => {
805+
const button = windctrl({
806+
base: {
807+
root: "inline-flex",
808+
slots: { icon: "shrink-0" },
809+
},
810+
variants: {
811+
size: {
812+
sm: { slots: { icon: "h-3 w-3" } },
813+
},
814+
},
815+
traits: {
816+
loading: {
817+
root: "opacity-70",
818+
slots: { icon: "animate-spin" },
819+
},
820+
},
821+
});
822+
823+
const result = button({ size: "sm", traits: ["loading"] });
824+
825+
// root gets trait too
826+
expect(result.className).toContain("opacity-70");
827+
828+
// icon gets base + variant + trait
829+
expect(result.slots?.icon).toContain("shrink-0");
830+
expect(result.slots?.icon).toContain("h-3");
831+
expect(result.slots?.icon).toContain("w-3");
832+
expect(result.slots?.icon).toContain("animate-spin");
833+
});
834+
835+
it("should let Traits override Variants on slots when Tailwind classes conflict (via twMerge)", () => {
836+
const button = windctrl({
837+
variants: {
838+
intent: {
839+
primary: { slots: { icon: "text-blue-500" } },
840+
},
841+
},
842+
traits: {
843+
dangerIcon: { slots: { icon: "text-red-500" } },
844+
},
845+
});
846+
847+
const result = button({ intent: "primary", traits: ["dangerIcon"] });
848+
849+
// last one wins: Traits > Variants
850+
expect(result.slots?.icon).toContain("text-red-500");
851+
expect(result.slots?.icon).not.toContain("text-blue-500");
852+
});
853+
854+
it("should ignore invalid trait keys for slots gracefully (same behavior as root traits)", () => {
855+
const button = windctrl({
856+
traits: {
857+
loading: { slots: { icon: "animate-spin" } },
858+
},
859+
});
860+
861+
const result = button({ traits: ["invalid-trait" as any] });
862+
863+
expect(result.slots?.icon).toBe(undefined);
864+
});
865+
866+
it("should not include slots when slots are not configured", () => {
867+
const button = windctrl({
868+
base: "rounded",
869+
variants: {
870+
size: {
871+
sm: "text-sm",
872+
},
873+
},
874+
traits: {
875+
loading: "opacity-50",
876+
},
877+
});
878+
879+
const result = button({ size: "sm", traits: ["loading"] });
880+
881+
expect(result.className).toContain("rounded");
882+
expect(result.className).toContain("text-sm");
883+
expect(result.className).toContain("opacity-50");
884+
expect((result as any).slots).toBe(undefined);
885+
});
886+
});
747887
});

0 commit comments

Comments
 (0)