windctrl 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,14 +4,15 @@
4
4
 
5
5
  **WindCtrl** is a next-generation styling utility that unifies static Tailwind classes and dynamic inline styles into a single, type-safe interface.
6
6
 
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.
8
9
 
9
10
  ## Features
10
11
 
11
- - 🎨 **Unified API** - Seamlessly blends static Tailwind classes and dynamic inline styles into one cohesive interface.
12
12
  - 🧩 **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.
14
14
  - ⚡ **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).
15
16
  - 🔒 **Type-Safe** - Best-in-class TypeScript support with automatic prop inference.
16
17
  - 📦 **Minimal Overhead** - Ultra-lightweight runtime with only `clsx` and `tailwind-merge` as dependencies.
17
18
 
@@ -24,7 +25,7 @@ npm install windctrl
24
25
  ## Quick Start
25
26
 
26
27
  ```typescript
27
- import { windctrl } from "windctrl";
28
+ import { windctrl, dynamic as d } from "windctrl";
28
29
 
29
30
  const button = windctrl({
30
31
  base: "rounded px-4 py-2 font-medium transition duration-200",
@@ -44,8 +45,7 @@ const button = windctrl({
44
45
  glass: "backdrop-blur-md bg-white/10 border border-white/20 shadow-xl",
45
46
  },
46
47
  dynamic: {
47
- w: (val) =>
48
- typeof val === "number" ? { style: { width: `${val}px` } } : val,
48
+ w: d.px("width"),
49
49
  },
50
50
  defaultVariants: {
51
51
  intent: "primary",
@@ -70,34 +70,33 @@ button({ w: "w-full" });
70
70
 
71
71
  ## Core Concepts
72
72
 
73
- ### Interpolated Variants (Dynamic Props)
74
-
75
- Interpolated variants provide a **Unified API** that bridges static Tailwind classes and dynamic inline styles. A dynamic resolver can return either:
76
-
77
- - a **Tailwind class string** (static utility), or
78
- - an object containing **className and/or style** (inline styles and optional utilities)
73
+ ### Variants
79
74
 
80
- This is **JIT-friendly by design**, as long as the class strings you return are statically enumerable (i.e. appear in your source code).
81
- For truly unbounded values (e.g. pixel sizes), prefer returning style to avoid relying on arbitrary-value class generation.
75
+ Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
82
76
 
83
77
  ```typescript
84
78
  const button = windctrl({
85
- dynamic: {
86
- // Recommended pattern:
87
- // - Numbers -> inline styles (unbounded values)
88
- // - Strings -> Tailwind utilities (must be statically enumerable for JIT)
89
- w: (val) =>
90
- typeof val === "number" ? { style: { width: `${val}px` } } : val,
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",
91
93
  },
92
94
  });
93
95
 
94
96
  // Usage
95
- button({ w: "w-full" }); // -> className includes "w-full" (static utility)
96
- button({ w: 200 }); // -> style includes { width: "200px" } (dynamic value)
97
+ button({ intent: "primary", size: "lg" });
97
98
  ```
98
99
 
99
- > **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.
100
-
101
100
  ### Traits (Stackable States)
102
101
 
103
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`.
@@ -123,55 +122,141 @@ button({ traits: ["loading", "glass"] });
123
122
  button({ traits: { loading: isLoading, glass: true } });
124
123
  ```
125
124
 
126
- ### Scopes (RSC Support)
125
+ ### Slots (Compound Components)
127
126
 
128
- 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.
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.
129
131
 
130
132
  ```typescript
131
133
  const button = windctrl({
132
- scopes: {
133
- header: "text-sm py-1",
134
- footer: "text-xs text-gray-500",
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
+ },
135
158
  },
159
+ defaultVariants: { size: "md" },
136
160
  });
137
161
 
138
162
  // Usage
139
- // 1. Wrap the parent with `data-windctrl-scope` and `group/windctrl-scope`
140
- // 2. The button automatically adapts its style based on the parent
141
- <div data-windctrl-scope="header" className="group/windctrl-scope">
142
- <button className={button().className}>Header Button</button>
143
- </div>
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>
144
170
  ```
145
171
 
146
- The scope classes are automatically prefixed with `group-data-[windctrl-scope=...]/windctrl-scope:` to target the parent's data attribute.
172
+ Slots follow the same priority rules as root classes: **Base < Variants < Traits**, with `tailwind-merge` handling conflicts.
147
173
 
148
- ### Variants
174
+ Unlike slot-based APIs that require declaring all slots upfront, WindCtrl allows slots to emerge naturally from variants and traits.
149
175
 
150
- Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
176
+ ### Interpolated Variants (Dynamic Props)
177
+
178
+ Interpolated variants provide a **Unified API** that bridges static Tailwind classes and dynamic inline styles. A dynamic resolver can return either:
179
+
180
+ - a **Tailwind class string** (static utility), or
181
+ - an object containing **className and/or style** (inline styles and optional utilities)
182
+
183
+ This is **JIT-friendly by design**, as long as the class strings you return are statically enumerable (i.e. appear in your source code).
184
+ For truly unbounded values (e.g. pixel sizes), prefer returning style to avoid relying on arbitrary-value class generation.
185
+
186
+ #### Dynamic Presets
187
+
188
+ WindCtrl provides built-in presets for common dynamic patterns:
189
+
190
+ ```typescript
191
+ import { windctrl, dynamic as d } from "windctrl";
192
+
193
+ const box = windctrl({
194
+ dynamic: {
195
+ // d.px() - pixel values (width, height, top, left, etc.)
196
+ w: d.px("width"),
197
+ h: d.px("height"),
198
+
199
+ // d.num() - unitless numbers (zIndex, flexGrow, order, etc.)
200
+ z: d.num("zIndex"),
201
+
202
+ // d.opacity() - opacity values
203
+ fade: d.opacity(),
204
+
205
+ // d.var() - CSS custom properties
206
+ x: d.var("--translate-x", { unit: "px" }),
207
+ },
208
+ });
209
+
210
+ // Usage
211
+ box({ w: 200 }); // -> style: { width: "200px" }
212
+ box({ w: "w-full" }); // -> className: "w-full"
213
+ box({ z: 50 }); // -> style: { zIndex: 50 }
214
+ box({ fade: 0.5 }); // -> style: { opacity: 0.5 }
215
+ box({ x: 10 }); // -> style: { "--translate-x": "10px" }
216
+ ```
217
+
218
+ #### Custom Resolvers
219
+
220
+ You can also write custom resolvers for more complex logic:
151
221
 
152
222
  ```typescript
153
223
  const button = windctrl({
154
- variants: {
155
- intent: {
156
- primary: "bg-blue-500 text-white hover:bg-blue-600",
157
- secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
158
- },
159
- size: {
160
- sm: "text-sm h-8 px-3",
161
- md: "text-base h-10 px-4",
162
- lg: "text-lg h-12 px-6",
163
- },
224
+ dynamic: {
225
+ // Custom resolver example
226
+ w: (val) =>
227
+ typeof val === "number" ? { style: { width: `${val}px` } } : val,
164
228
  },
165
- defaultVariants: {
166
- intent: "primary",
167
- size: "md",
229
+ });
230
+
231
+ // Usage
232
+ button({ w: "w-full" }); // -> className includes "w-full" (static utility)
233
+ button({ w: 200 }); // -> style includes { width: "200px" } (dynamic value)
234
+ ```
235
+
236
+ > **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.
237
+
238
+ ### Scopes (RSC Support)
239
+
240
+ 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.
241
+
242
+ ```typescript
243
+ const button = windctrl({
244
+ scopes: {
245
+ header: "text-sm py-1",
246
+ footer: "text-xs text-gray-500",
168
247
  },
169
248
  });
170
249
 
171
250
  // Usage
172
- button({ intent: "primary", size: "lg" });
251
+ // 1. Wrap the parent with `data-windctrl-scope` and `group/windctrl-scope`
252
+ // 2. The button automatically adapts its style based on the parent
253
+ <div data-windctrl-scope="header" className="group/windctrl-scope">
254
+ <button className={button().className}>Header Button</button>
255
+ </div>
173
256
  ```
174
257
 
258
+ The scope classes are automatically prefixed with `group-data-[windctrl-scope=...]/windctrl-scope:` to target the parent's data attribute.
259
+
175
260
  ## Gotchas
176
261
 
177
262
  - **Tailwind JIT:** Tailwind only generates CSS for class names it can statically detect. Avoid constructing class strings dynamically unless you safelist them.
package/dist/index.d.ts CHANGED
@@ -4,9 +4,29 @@ type DynamicResolverResult = string | {
4
4
  className?: string;
5
5
  style?: CSSProperties;
6
6
  };
7
- type DynamicResolver = (value: any) => DynamicResolverResult;
8
- type Config<TVariants extends Record<string, Record<string, ClassValue>> = {}, TTraits extends Record<string, ClassValue> = {}, TDynamic extends Record<string, DynamicResolver> = {}, TScopes extends Record<string, ClassValue> = {}> = {
9
- base?: ClassValue;
7
+ type DynamicResolver<T = any> = (value: T) => DynamicResolverResult;
8
+ type PxProp = "width" | "height" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" | "top" | "right" | "bottom" | "left";
9
+ type NumProp = "zIndex" | "flexGrow" | "flexShrink" | "order";
10
+ type VarUnit = "px" | "%" | "deg" | "ms";
11
+ declare function px(prop: PxProp): DynamicResolver<number | string>;
12
+ declare function num(prop: NumProp): DynamicResolver<number | string>;
13
+ declare function opacity(): DynamicResolver<number | string>;
14
+ declare function cssVar(name: `--${string}`, options?: {
15
+ unit?: VarUnit;
16
+ }): DynamicResolver<number | string>;
17
+ export declare const dynamic: {
18
+ px: typeof px;
19
+ num: typeof num;
20
+ opacity: typeof opacity;
21
+ var: typeof cssVar;
22
+ };
23
+ type SlotAwareObject = {
24
+ root?: ClassValue;
25
+ slots?: Record<string, ClassValue>;
26
+ };
27
+ type SlotAwareValue = ClassValue | SlotAwareObject;
28
+ type Config<TVariants extends Record<string, Record<string, SlotAwareValue>> = {}, TTraits extends Record<string, SlotAwareValue> = {}, TDynamic extends Record<string, DynamicResolver> = {}, TScopes extends Record<string, ClassValue> = {}> = {
29
+ base?: SlotAwareValue;
10
30
  variants?: TVariants;
11
31
  traits?: TTraits;
12
32
  dynamic?: TDynamic;
@@ -15,17 +35,24 @@ type Config<TVariants extends Record<string, Record<string, ClassValue>> = {}, T
15
35
  [K in keyof TVariants]?: keyof TVariants[K] extends string ? keyof TVariants[K] : never;
16
36
  };
17
37
  };
18
- type Props<TVariants extends Record<string, Record<string, ClassValue>> = {}, TTraits extends Record<string, ClassValue> = {}, TDynamic extends Record<string, DynamicResolver> = {}> = {
38
+ type Props<TVariants extends Record<string, Record<string, SlotAwareValue>> = {}, TTraits extends Record<string, SlotAwareValue> = {}, TDynamic extends Record<string, DynamicResolver> = {}> = {
19
39
  [K in keyof TVariants]?: keyof TVariants[K] extends string ? keyof TVariants[K] : never;
20
40
  } & {
21
41
  traits?: Array<keyof TTraits extends string ? keyof TTraits : never> | Partial<Record<keyof TTraits extends string ? keyof TTraits : never, boolean>>;
22
42
  } & {
23
43
  [K in keyof TDynamic]?: Parameters<TDynamic[K]>[0];
24
44
  };
25
- type Result = {
45
+ type SlotsOfValue<V> = V extends {
46
+ slots?: infer S;
47
+ } ? S extends Record<string, any> ? keyof S : never : never;
48
+ type VariantOptionValues<T> = T extends Record<string, Record<string, infer V>> ? V : never;
49
+ type TraitValues<T> = T extends Record<string, infer V> ? V : never;
50
+ type SlotKeys<TBase, TVariants extends Record<string, Record<string, any>>, TTraits extends Record<string, any>> = Extract<SlotsOfValue<TBase> | SlotsOfValue<VariantOptionValues<TVariants>> | SlotsOfValue<TraitValues<TTraits>>, string>;
51
+ type Result<TSlotKeys extends string = never> = {
26
52
  className: string;
27
53
  style?: CSSProperties;
54
+ slots?: Partial<Record<TSlotKeys, string>>;
28
55
  };
29
- export declare function windctrl<TVariants extends Record<string, Record<string, ClassValue>> = {}, TTraits extends Record<string, ClassValue> = {}, TDynamic extends Record<string, DynamicResolver> = {}, TScopes extends Record<string, ClassValue> = {}>(config: Config<TVariants, TTraits, TDynamic, TScopes>): (props?: Props<TVariants, TTraits, TDynamic>) => Result;
56
+ export declare function windctrl<TVariants extends Record<string, Record<string, SlotAwareValue>> = {}, TTraits extends Record<string, SlotAwareValue> = {}, TDynamic extends Record<string, DynamicResolver> = {}, TScopes extends Record<string, ClassValue> = {}>(config: Config<TVariants, TTraits, TDynamic, TScopes>): (props?: Props<TVariants, TTraits, TDynamic>) => Result<SlotKeys<typeof config.base, TVariants, TTraits>>;
30
57
  export declare const wc: typeof windctrl;
31
58
  export {};
package/dist/index.js CHANGED
@@ -1,22 +1,99 @@
1
1
  import { clsx } from "clsx";
2
2
  import { twMerge } from "tailwind-merge";
3
+ function px(prop) {
4
+ return (value) => {
5
+ if (typeof value === "number") {
6
+ return { style: { [prop]: `${value}px` } };
7
+ }
8
+ return value;
9
+ };
10
+ }
11
+ function num(prop) {
12
+ return (value) => {
13
+ if (typeof value === "number") {
14
+ return { style: { [prop]: value } };
15
+ }
16
+ return value;
17
+ };
18
+ }
19
+ function opacity() {
20
+ return (value) => {
21
+ if (typeof value === "number") {
22
+ return { style: { opacity: value } };
23
+ }
24
+ return value;
25
+ };
26
+ }
27
+ function cssVar(name, options) {
28
+ return (value) => {
29
+ if (typeof value === "number") {
30
+ if (options?.unit) {
31
+ return { style: { [name]: `${value}${options.unit}` } };
32
+ }
33
+ return { style: { [name]: String(value) } };
34
+ }
35
+ return { style: { [name]: value } };
36
+ };
37
+ }
38
+ export const dynamic = {
39
+ px,
40
+ num,
41
+ opacity,
42
+ var: cssVar,
43
+ };
3
44
  function mergeStyles(...styles) {
4
45
  return Object.assign({}, ...styles.filter(Boolean));
5
46
  }
6
- function processTraits(traits, propsTraits) {
47
+ function isSlotAwareValue(value) {
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
49
+ return false;
50
+ }
51
+ const obj = value;
52
+ const hasRoot = "root" in obj;
53
+ const hasSlots = "slots" in obj && typeof obj.slots === "object" && obj.slots !== null;
54
+ return hasRoot || hasSlots;
55
+ }
56
+ function addSlotClasses(slotParts, slots) {
57
+ for (const [slotName, slotClasses] of Object.entries(slots)) {
58
+ if (!slotParts[slotName]) {
59
+ slotParts[slotName] = [];
60
+ }
61
+ slotParts[slotName].push(slotClasses);
62
+ }
63
+ }
64
+ function processTraits(traits, propsTraits, slotParts) {
7
65
  if (!propsTraits)
8
66
  return [];
67
+ const rootClasses = [];
68
+ const processTraitKey = (key) => {
69
+ if (!(key in traits))
70
+ return;
71
+ const traitValue = traits[key];
72
+ if (isSlotAwareValue(traitValue)) {
73
+ if (traitValue.root) {
74
+ rootClasses.push(traitValue.root);
75
+ }
76
+ if (traitValue.slots && slotParts) {
77
+ addSlotClasses(slotParts, traitValue.slots);
78
+ }
79
+ }
80
+ else {
81
+ rootClasses.push(traitValue);
82
+ }
83
+ };
9
84
  if (Array.isArray(propsTraits)) {
10
- return propsTraits
11
- .filter((key) => key in traits)
12
- .map((key) => traits[key]);
85
+ for (const key of propsTraits) {
86
+ processTraitKey(key);
87
+ }
13
88
  }
14
- if (typeof propsTraits === "object") {
15
- return Object.entries(propsTraits)
16
- .filter(([key, value]) => value && key in traits)
17
- .map(([key]) => traits[key]);
89
+ else if (typeof propsTraits === "object") {
90
+ for (const [key, value] of Object.entries(propsTraits)) {
91
+ if (value) {
92
+ processTraitKey(key);
93
+ }
94
+ }
18
95
  }
19
- return [];
96
+ return rootClasses;
20
97
  }
21
98
  function processDynamicEntries(entries, props) {
22
99
  const classNameParts = [];
@@ -61,23 +138,45 @@ export function windctrl(config) {
61
138
  return (props = {}) => {
62
139
  const classNameParts = [];
63
140
  let mergedStyle = {};
141
+ const slotParts = {};
64
142
  // Priority order: Base < Variants < Traits < Dynamic
65
143
  // (Higher priority classes are added later, so tailwind-merge will keep them)
66
144
  // 1. Base classes (lowest priority)
67
145
  if (base) {
68
- classNameParts.push(base);
146
+ if (isSlotAwareValue(base)) {
147
+ if (base.root) {
148
+ classNameParts.push(base.root);
149
+ }
150
+ if (base.slots) {
151
+ addSlotClasses(slotParts, base.slots);
152
+ }
153
+ }
154
+ else {
155
+ classNameParts.push(base);
156
+ }
69
157
  }
70
158
  // 2. Variants (with defaultVariants fallback)
71
159
  for (const [variantKey, variantOptions] of resolvedVariants) {
72
160
  const propValue = props[variantKey] ??
73
161
  defaultVariants[variantKey];
74
162
  if (propValue && variantOptions[propValue]) {
75
- classNameParts.push(variantOptions[propValue]);
163
+ const optionValue = variantOptions[propValue];
164
+ if (isSlotAwareValue(optionValue)) {
165
+ if (optionValue.root) {
166
+ classNameParts.push(optionValue.root);
167
+ }
168
+ if (optionValue.slots) {
169
+ addSlotClasses(slotParts, optionValue.slots);
170
+ }
171
+ }
172
+ else {
173
+ classNameParts.push(optionValue);
174
+ }
76
175
  }
77
176
  }
78
177
  // 3. Traits (higher priority than variants)
79
178
  if (props.traits) {
80
- classNameParts.push(...processTraits(traits, props.traits));
179
+ classNameParts.push(...processTraits(traits, props.traits, slotParts));
81
180
  }
82
181
  // 4. Dynamic (highest priority for className)
83
182
  if (resolvedDynamicEntries.length) {
@@ -91,9 +190,27 @@ export function windctrl(config) {
91
190
  }
92
191
  const finalClassName = twMerge(clsx(classNameParts));
93
192
  const hasStyle = Object.keys(mergedStyle).length > 0;
193
+ let finalSlots;
194
+ const slotNames = Object.keys(slotParts);
195
+ if (slotNames.length > 0) {
196
+ const out = {};
197
+ for (const slotName of slotNames) {
198
+ const parts = slotParts[slotName];
199
+ if (!parts)
200
+ continue;
201
+ const merged = twMerge(clsx(parts));
202
+ if (merged) {
203
+ out[slotName] = merged;
204
+ }
205
+ }
206
+ if (Object.keys(out).length > 0) {
207
+ finalSlots = out;
208
+ }
209
+ }
94
210
  return {
95
211
  className: finalClassName,
96
212
  ...(hasStyle && { style: mergedStyle }),
213
+ ...(finalSlots && { slots: finalSlots }),
97
214
  };
98
215
  };
99
216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "windctrl",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Advanced variant API for Tailwind CSS with stackable traits and interpolated dynamic styles.",
5
5
  "license": "MIT",
6
6
  "author": "Masaki Morishita (@morishxt)",