windctrl 0.0.1 → 0.1.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 +83 -51
- package/dist/index.d.ts +2 -1
- package/dist/index.js +19 -19
- package/package.json +2 -4
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ It evolves the concept of Variant APIs (like [cva](https://cva.style/)) by intro
|
|
|
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
13
|
- 🎯 **Scoped Styling** - Context-aware styling using data attributes - no React Context required (RSC friendly).
|
|
14
|
-
- ⚡ **JIT
|
|
14
|
+
- ⚡ **JIT Conscious** - Designed for Tailwind JIT: utilities stay as class strings, while truly dynamic values can be expressed as inline styles.
|
|
15
15
|
- 🔒 **Type-Safe** - Best-in-class TypeScript support with automatic prop inference.
|
|
16
16
|
- 📦 **Minimal Overhead** - Ultra-lightweight runtime with only `clsx` and `tailwind-merge` as dependencies.
|
|
17
17
|
|
|
@@ -24,28 +24,28 @@ npm install windctrl
|
|
|
24
24
|
## Quick Start
|
|
25
25
|
|
|
26
26
|
```typescript
|
|
27
|
-
import {
|
|
27
|
+
import { windctrl } from "windctrl";
|
|
28
28
|
|
|
29
|
-
const button =
|
|
30
|
-
base: "rounded px-4 py-2 font-medium transition",
|
|
29
|
+
const button = windctrl({
|
|
30
|
+
base: "rounded px-4 py-2 font-medium transition duration-200",
|
|
31
31
|
variants: {
|
|
32
32
|
intent: {
|
|
33
33
|
primary: "bg-blue-500 text-white hover:bg-blue-600",
|
|
34
|
-
secondary: "bg-gray-
|
|
34
|
+
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
|
35
35
|
},
|
|
36
36
|
size: {
|
|
37
|
-
sm: "text-sm",
|
|
38
|
-
md: "text-base",
|
|
39
|
-
lg: "text-lg",
|
|
37
|
+
sm: "text-sm h-8",
|
|
38
|
+
md: "text-base h-10",
|
|
39
|
+
lg: "text-lg h-12",
|
|
40
40
|
},
|
|
41
41
|
},
|
|
42
42
|
traits: {
|
|
43
|
-
loading: "opacity-
|
|
44
|
-
glass: "backdrop-blur bg-white/10",
|
|
43
|
+
loading: "opacity-70 cursor-wait pointer-events-none",
|
|
44
|
+
glass: "backdrop-blur-md bg-white/10 border border-white/20 shadow-xl",
|
|
45
45
|
},
|
|
46
46
|
dynamic: {
|
|
47
47
|
w: (val) =>
|
|
48
|
-
typeof val === "number" ? { style: { width: `${val}px` } } :
|
|
48
|
+
typeof val === "number" ? { style: { width: `${val}px` } } : val,
|
|
49
49
|
},
|
|
50
50
|
defaultVariants: {
|
|
51
51
|
intent: "primary",
|
|
@@ -53,100 +53,132 @@ const button = windCtrl({
|
|
|
53
53
|
},
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
// Usage
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
});
|
|
56
|
+
// Usage Example
|
|
57
|
+
|
|
58
|
+
// 1. Standard usage
|
|
59
|
+
button({ intent: "primary", size: "lg" });
|
|
60
|
+
|
|
61
|
+
// 2. Using Traits (Stackable states)
|
|
62
|
+
button({ traits: ["glass", "loading"] });
|
|
63
|
+
|
|
64
|
+
// 3. Unified API for dynamic values
|
|
65
|
+
// Pass a number for arbitrary px value (Inline Style)
|
|
66
|
+
button({ w: 350 });
|
|
67
|
+
// Pass a string for Tailwind utility (Static Class)
|
|
68
|
+
button({ w: "w-full" });
|
|
63
69
|
```
|
|
64
70
|
|
|
65
71
|
## Core Concepts
|
|
66
72
|
|
|
67
|
-
###
|
|
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:
|
|
68
76
|
|
|
69
|
-
|
|
77
|
+
- a **Tailwind class string** (static utility), or
|
|
78
|
+
- an object containing **className and/or style** (inline styles and optional utilities)
|
|
79
|
+
|
|
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.
|
|
70
82
|
|
|
71
83
|
```typescript
|
|
72
|
-
const button =
|
|
84
|
+
const button = windctrl({
|
|
73
85
|
dynamic: {
|
|
86
|
+
// Recommended pattern:
|
|
87
|
+
// - Numbers -> inline styles (unbounded values)
|
|
88
|
+
// - Strings -> Tailwind utilities (must be statically enumerable for JIT)
|
|
74
89
|
w: (val) =>
|
|
75
|
-
typeof val === "number" ? { style: { width: `${val}px` } } :
|
|
76
|
-
h: (val) =>
|
|
77
|
-
typeof val === "number" ? { style: { height: `${val}px` } } : `h-${val}`,
|
|
90
|
+
typeof val === "number" ? { style: { width: `${val}px` } } : val,
|
|
78
91
|
},
|
|
79
92
|
});
|
|
80
93
|
|
|
81
94
|
// Usage
|
|
82
|
-
button({ w: "full" }); //
|
|
83
|
-
button({ w: 200 }); //
|
|
84
|
-
button({ w: 200, h: 100 }); // Returns both className and style
|
|
95
|
+
button({ w: "w-full" }); // -> className includes "w-full" (static utility)
|
|
96
|
+
button({ w: 200 }); // -> style includes { width: "200px" } (dynamic value)
|
|
85
97
|
```
|
|
86
98
|
|
|
87
|
-
|
|
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
|
+
### Traits (Stackable States)
|
|
88
102
|
|
|
89
|
-
Traits are
|
|
103
|
+
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`.
|
|
104
|
+
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
When multiple traits generate conflicting utilities, Tailwind’s “last one wins” rule applies (via `tailwind-merge`).
|
|
108
|
+
If ordering matters, prefer the **array form** to make precedence explicit.
|
|
90
109
|
|
|
91
110
|
```typescript
|
|
92
|
-
const button =
|
|
111
|
+
const button = windctrl({
|
|
93
112
|
traits: {
|
|
94
113
|
loading: "opacity-50 cursor-wait",
|
|
95
|
-
glass: "backdrop-blur bg-white/10",
|
|
96
|
-
disabled: "pointer-events-none",
|
|
114
|
+
glass: "backdrop-blur-md bg-white/10 border border-white/20",
|
|
115
|
+
disabled: "pointer-events-none grayscale",
|
|
97
116
|
},
|
|
98
117
|
});
|
|
99
118
|
|
|
100
|
-
// Usage - Array form
|
|
119
|
+
// Usage - Array form (explicit precedence; recommended when conflicts are possible)
|
|
101
120
|
button({ traits: ["loading", "glass"] });
|
|
102
121
|
|
|
103
|
-
// Usage - Object form
|
|
104
|
-
button({ traits: { loading:
|
|
122
|
+
// Usage - Object form (convenient for boolean props; order is not intended to be meaningful)
|
|
123
|
+
button({ traits: { loading: isLoading, glass: true } });
|
|
105
124
|
```
|
|
106
125
|
|
|
107
|
-
### Scopes
|
|
126
|
+
### Scopes (RSC Support)
|
|
108
127
|
|
|
109
|
-
Scopes
|
|
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.
|
|
110
129
|
|
|
111
130
|
```typescript
|
|
112
|
-
const button =
|
|
131
|
+
const button = windctrl({
|
|
113
132
|
scopes: {
|
|
114
|
-
header: "text-sm",
|
|
115
|
-
footer: "text-xs",
|
|
133
|
+
header: "text-sm py-1",
|
|
134
|
+
footer: "text-xs text-gray-500",
|
|
116
135
|
},
|
|
117
136
|
});
|
|
118
137
|
|
|
119
|
-
// Usage
|
|
120
|
-
|
|
121
|
-
|
|
138
|
+
// 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>
|
|
122
143
|
</div>
|
|
123
144
|
```
|
|
124
145
|
|
|
125
|
-
The scope classes are automatically prefixed with `group-data-[scope=...]/
|
|
146
|
+
The scope classes are automatically prefixed with `group-data-[windctrl-scope=...]/windctrl-scope:` to target the parent's data attribute.
|
|
126
147
|
|
|
127
148
|
### Variants
|
|
128
149
|
|
|
129
|
-
Variants
|
|
150
|
+
Variants represent mutually exclusive design choices (e.g., `primary` vs `secondary`). They serve as the foundation of your component's design system.
|
|
130
151
|
|
|
131
152
|
```typescript
|
|
132
|
-
const button =
|
|
153
|
+
const button = windctrl({
|
|
133
154
|
variants: {
|
|
134
155
|
intent: {
|
|
135
|
-
primary: "bg-blue-500",
|
|
136
|
-
secondary: "bg-gray-
|
|
156
|
+
primary: "bg-blue-500 text-white hover:bg-blue-600",
|
|
157
|
+
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
|
|
137
158
|
},
|
|
138
159
|
size: {
|
|
139
|
-
sm: "text-sm",
|
|
140
|
-
md: "text-base",
|
|
141
|
-
lg: "text-lg",
|
|
160
|
+
sm: "text-sm h-8 px-3",
|
|
161
|
+
md: "text-base h-10 px-4",
|
|
162
|
+
lg: "text-lg h-12 px-6",
|
|
142
163
|
},
|
|
143
164
|
},
|
|
165
|
+
defaultVariants: {
|
|
166
|
+
intent: "primary",
|
|
167
|
+
size: "md",
|
|
168
|
+
},
|
|
144
169
|
});
|
|
145
170
|
|
|
146
171
|
// Usage
|
|
147
172
|
button({ intent: "primary", size: "lg" });
|
|
148
173
|
```
|
|
149
174
|
|
|
175
|
+
## Gotchas
|
|
176
|
+
|
|
177
|
+
- **Tailwind JIT:** Tailwind only generates CSS for class names it can statically detect. Avoid constructing class strings dynamically unless you safelist them.
|
|
178
|
+
- **Traits precedence:** If trait order matters, use the array form (`traits: ["a", "b"]`) to make precedence explicit.
|
|
179
|
+
- **SSR/RSC:** Keep dynamic resolvers pure (same input → same output) to avoid hydration mismatches.
|
|
180
|
+
- **Static config:** `windctrl` configuration is treated as static/immutable. Mutating the config object after creation is not supported.
|
|
181
|
+
|
|
150
182
|
## License
|
|
151
183
|
|
|
152
184
|
The MIT License (MIT)
|
package/dist/index.d.ts
CHANGED
|
@@ -26,5 +26,6 @@ type Result = {
|
|
|
26
26
|
className: string;
|
|
27
27
|
style?: CSSProperties;
|
|
28
28
|
};
|
|
29
|
-
export declare function
|
|
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;
|
|
30
|
+
export declare const wc: typeof windctrl;
|
|
30
31
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -18,10 +18,10 @@ function processTraits(traits, propsTraits) {
|
|
|
18
18
|
}
|
|
19
19
|
return [];
|
|
20
20
|
}
|
|
21
|
-
function
|
|
21
|
+
function processDynamicEntries(entries, props) {
|
|
22
22
|
const classNameParts = [];
|
|
23
23
|
const styles = [];
|
|
24
|
-
for (const [key, resolver] of
|
|
24
|
+
for (const [key, resolver] of entries) {
|
|
25
25
|
const value = props[key];
|
|
26
26
|
if (value !== undefined && value !== null) {
|
|
27
27
|
const result = resolver(value);
|
|
@@ -49,12 +49,15 @@ function processScopes(scopes) {
|
|
|
49
49
|
return classesStr
|
|
50
50
|
.split(/\s+/)
|
|
51
51
|
.filter(Boolean)
|
|
52
|
-
.map((cls) => `group-data-[scope=${scopeName}]/
|
|
52
|
+
.map((cls) => `group-data-[windctrl-scope=${scopeName}]/windctrl-scope:${cls}`)
|
|
53
53
|
.join(" ");
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
|
-
export function
|
|
56
|
+
export function windctrl(config) {
|
|
57
57
|
const { base, variants = {}, traits = {}, dynamic = {}, scopes = {}, defaultVariants = {}, } = config;
|
|
58
|
+
const resolvedVariants = Object.entries(variants);
|
|
59
|
+
const resolvedDynamicEntries = Object.entries(dynamic);
|
|
60
|
+
const resolvedScopeClasses = processScopes(scopes);
|
|
58
61
|
return (props = {}) => {
|
|
59
62
|
const classNameParts = [];
|
|
60
63
|
let mergedStyle = {};
|
|
@@ -65,30 +68,26 @@ export function windCtrl(config) {
|
|
|
65
68
|
classNameParts.push(base);
|
|
66
69
|
}
|
|
67
70
|
// 2. Variants (with defaultVariants fallback)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
classNameParts.push(variantOptions[propValue]);
|
|
74
|
-
}
|
|
71
|
+
for (const [variantKey, variantOptions] of resolvedVariants) {
|
|
72
|
+
const propValue = props[variantKey] ??
|
|
73
|
+
defaultVariants[variantKey];
|
|
74
|
+
if (propValue && variantOptions[propValue]) {
|
|
75
|
+
classNameParts.push(variantOptions[propValue]);
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
// 3. Traits (higher priority than variants)
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
classNameParts.push(...traitClasses);
|
|
79
|
+
if (props.traits) {
|
|
80
|
+
classNameParts.push(...processTraits(traits, props.traits));
|
|
81
81
|
}
|
|
82
82
|
// 4. Dynamic (highest priority for className)
|
|
83
|
-
if (
|
|
84
|
-
const dynamicResult =
|
|
83
|
+
if (resolvedDynamicEntries.length) {
|
|
84
|
+
const dynamicResult = processDynamicEntries(resolvedDynamicEntries, props);
|
|
85
85
|
classNameParts.push(...dynamicResult.className);
|
|
86
86
|
mergedStyle = mergeStyles(mergedStyle, dynamicResult.style);
|
|
87
87
|
}
|
|
88
88
|
// 5. Scopes (always applied, but don't conflict with other classes)
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
classNameParts.push(...scopeClasses);
|
|
89
|
+
if (resolvedScopeClasses.length) {
|
|
90
|
+
classNameParts.push(...resolvedScopeClasses);
|
|
92
91
|
}
|
|
93
92
|
const finalClassName = twMerge(clsx(classNameParts));
|
|
94
93
|
const hasStyle = Object.keys(mergedStyle).length > 0;
|
|
@@ -98,3 +97,4 @@ export function windCtrl(config) {
|
|
|
98
97
|
};
|
|
99
98
|
};
|
|
100
99
|
}
|
|
100
|
+
export const wc = windctrl;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "windctrl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.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)",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
],
|
|
35
35
|
"repository": {
|
|
36
36
|
"type": "git",
|
|
37
|
-
"url": "https://github.com/morishxt/windctrl.git"
|
|
37
|
+
"url": "git+https://github.com/morishxt/windctrl.git"
|
|
38
38
|
},
|
|
39
39
|
"bugs": {
|
|
40
40
|
"url": "https://github.com/morishxt/windctrl/issues"
|
|
@@ -42,8 +42,6 @@
|
|
|
42
42
|
"homepage": "https://github.com/morishxt/windctrl#readme",
|
|
43
43
|
"scripts": {
|
|
44
44
|
"test": "vitest",
|
|
45
|
-
"test:watch": "vitest --watch",
|
|
46
|
-
"test:coverage": "vitest --coverage",
|
|
47
45
|
"build": "tsc",
|
|
48
46
|
"dev": "tsc --watch",
|
|
49
47
|
"format": "prettier --write .",
|