windctrl 0.0.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/LICENSE +20 -0
- package/README.md +154 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +100 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
This software is released under the MIT license:
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Masaki Morishita (@morishxt)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.))
|
package/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# WindCtrl
|
|
2
|
+
|
|
3
|
+
> Advanced variant API for Tailwind CSS with stackable traits and interpolated dynamic styles.
|
|
4
|
+
|
|
5
|
+
**WindCtrl** is a next-generation styling utility that unifies static Tailwind classes and dynamic inline styles into a single, type-safe interface.
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- 🎨 **Unified API** - Seamlessly blends static Tailwind classes and dynamic inline styles into one cohesive interface.
|
|
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).
|
|
14
|
+
- ⚡ **JIT Optimized** - Prevents CSS bundle bloat by intelligently routing arbitrary values to inline styles.
|
|
15
|
+
- 🔒 **Type-Safe** - Best-in-class TypeScript support with automatic prop inference.
|
|
16
|
+
- 📦 **Minimal Overhead** - Ultra-lightweight runtime with only `clsx` and `tailwind-merge` as dependencies.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install windctrl
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { windCtrl } from "windctrl";
|
|
28
|
+
|
|
29
|
+
const button = windCtrl({
|
|
30
|
+
base: "rounded px-4 py-2 font-medium transition",
|
|
31
|
+
variants: {
|
|
32
|
+
intent: {
|
|
33
|
+
primary: "bg-blue-500 text-white hover:bg-blue-600",
|
|
34
|
+
secondary: "bg-gray-500 text-gray-900 hover:bg-gray-600",
|
|
35
|
+
},
|
|
36
|
+
size: {
|
|
37
|
+
sm: "text-sm",
|
|
38
|
+
md: "text-base",
|
|
39
|
+
lg: "text-lg",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
traits: {
|
|
43
|
+
loading: "opacity-50 cursor-wait",
|
|
44
|
+
glass: "backdrop-blur bg-white/10",
|
|
45
|
+
},
|
|
46
|
+
dynamic: {
|
|
47
|
+
w: (val) =>
|
|
48
|
+
typeof val === "number" ? { style: { width: `${val}px` } } : `w-${val}`,
|
|
49
|
+
},
|
|
50
|
+
defaultVariants: {
|
|
51
|
+
intent: "primary",
|
|
52
|
+
size: "md",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Usage
|
|
57
|
+
const { className, style } = button({
|
|
58
|
+
intent: "primary",
|
|
59
|
+
size: "lg",
|
|
60
|
+
traits: ["loading", "glass"],
|
|
61
|
+
w: 200,
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Core Concepts
|
|
66
|
+
|
|
67
|
+
### Dynamic Props (Interpolated Variants)
|
|
68
|
+
|
|
69
|
+
Dynamic props allow you to pass arbitrary values that can resolve to either Tailwind classes or inline styles, bridging the gap between static classes and dynamic styles.
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
const button = windCtrl({
|
|
73
|
+
dynamic: {
|
|
74
|
+
w: (val) =>
|
|
75
|
+
typeof val === "number" ? { style: { width: `${val}px` } } : `w-${val}`,
|
|
76
|
+
h: (val) =>
|
|
77
|
+
typeof val === "number" ? { style: { height: `${val}px` } } : `h-${val}`,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Usage
|
|
82
|
+
button({ w: "full" }); // Returns className: "w-full"
|
|
83
|
+
button({ w: 200 }); // Returns style: { width: "200px" }
|
|
84
|
+
button({ w: 200, h: 100 }); // Returns both className and style
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Traits
|
|
88
|
+
|
|
89
|
+
Traits are stackable, non-exclusive states. Unlike variants, multiple traits can be active simultaneously, solving the combinatorial explosion problem of `compoundVariants`.
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const button = windCtrl({
|
|
93
|
+
traits: {
|
|
94
|
+
loading: "opacity-50 cursor-wait",
|
|
95
|
+
glass: "backdrop-blur bg-white/10",
|
|
96
|
+
disabled: "pointer-events-none",
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Usage - Array form
|
|
101
|
+
button({ traits: ["loading", "glass"] });
|
|
102
|
+
|
|
103
|
+
// Usage - Object form
|
|
104
|
+
button({ traits: { loading: true, glass: true, disabled: false } });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Scopes
|
|
108
|
+
|
|
109
|
+
Scopes provide context-aware styling without React Context, making it fully compatible with Server Components (RSC).
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
const button = windCtrl({
|
|
113
|
+
scopes: {
|
|
114
|
+
header: "text-sm",
|
|
115
|
+
footer: "text-xs",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Usage - Wrap elements with data-scope attribute
|
|
120
|
+
<div data-scope="header" className="group/wind-scope">
|
|
121
|
+
<button className={button({}).className}>Header Button</button>
|
|
122
|
+
</div>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The scope classes are automatically prefixed with `group-data-[scope=...]/wind-scope:` to work with Tailwind's group modifier.
|
|
126
|
+
|
|
127
|
+
### Variants
|
|
128
|
+
|
|
129
|
+
Variants are mutually exclusive options. Each variant dimension can have multiple options, but only one option per dimension can be active at a time.
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
const button = windCtrl({
|
|
133
|
+
variants: {
|
|
134
|
+
intent: {
|
|
135
|
+
primary: "bg-blue-500",
|
|
136
|
+
secondary: "bg-gray-500",
|
|
137
|
+
},
|
|
138
|
+
size: {
|
|
139
|
+
sm: "text-sm",
|
|
140
|
+
md: "text-base",
|
|
141
|
+
lg: "text-lg",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Usage
|
|
147
|
+
button({ intent: "primary", size: "lg" });
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
The MIT License (MIT)
|
|
153
|
+
|
|
154
|
+
Copyright (c) 2025 Masaki Morishita
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ClassValue } from "clsx";
|
|
2
|
+
type CSSProperties = Record<string, string | number>;
|
|
3
|
+
type DynamicResolverResult = string | {
|
|
4
|
+
className?: string;
|
|
5
|
+
style?: CSSProperties;
|
|
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;
|
|
10
|
+
variants?: TVariants;
|
|
11
|
+
traits?: TTraits;
|
|
12
|
+
dynamic?: TDynamic;
|
|
13
|
+
scopes?: TScopes;
|
|
14
|
+
defaultVariants?: {
|
|
15
|
+
[K in keyof TVariants]?: keyof TVariants[K] extends string ? keyof TVariants[K] : never;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
type Props<TVariants extends Record<string, Record<string, ClassValue>> = {}, TTraits extends Record<string, ClassValue> = {}, TDynamic extends Record<string, DynamicResolver> = {}> = {
|
|
19
|
+
[K in keyof TVariants]?: keyof TVariants[K] extends string ? keyof TVariants[K] : never;
|
|
20
|
+
} & {
|
|
21
|
+
traits?: Array<keyof TTraits extends string ? keyof TTraits : never> | Partial<Record<keyof TTraits extends string ? keyof TTraits : never, boolean>>;
|
|
22
|
+
} & {
|
|
23
|
+
[K in keyof TDynamic]?: Parameters<TDynamic[K]>[0];
|
|
24
|
+
};
|
|
25
|
+
type Result = {
|
|
26
|
+
className: string;
|
|
27
|
+
style?: CSSProperties;
|
|
28
|
+
};
|
|
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 {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
function mergeStyles(...styles) {
|
|
4
|
+
return Object.assign({}, ...styles.filter(Boolean));
|
|
5
|
+
}
|
|
6
|
+
function processTraits(traits, propsTraits) {
|
|
7
|
+
if (!propsTraits)
|
|
8
|
+
return [];
|
|
9
|
+
if (Array.isArray(propsTraits)) {
|
|
10
|
+
return propsTraits
|
|
11
|
+
.filter((key) => key in traits)
|
|
12
|
+
.map((key) => traits[key]);
|
|
13
|
+
}
|
|
14
|
+
if (typeof propsTraits === "object") {
|
|
15
|
+
return Object.entries(propsTraits)
|
|
16
|
+
.filter(([key, value]) => value && key in traits)
|
|
17
|
+
.map(([key]) => traits[key]);
|
|
18
|
+
}
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
function processDynamic(dynamic, props) {
|
|
22
|
+
const classNameParts = [];
|
|
23
|
+
const styles = [];
|
|
24
|
+
for (const [key, resolver] of Object.entries(dynamic)) {
|
|
25
|
+
const value = props[key];
|
|
26
|
+
if (value !== undefined && value !== null) {
|
|
27
|
+
const result = resolver(value);
|
|
28
|
+
if (typeof result === "string") {
|
|
29
|
+
classNameParts.push(result);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
if (result.className) {
|
|
33
|
+
classNameParts.push(result.className);
|
|
34
|
+
}
|
|
35
|
+
if (result.style) {
|
|
36
|
+
styles.push(result.style);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
className: classNameParts,
|
|
43
|
+
style: mergeStyles(...styles),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function processScopes(scopes) {
|
|
47
|
+
return Object.entries(scopes).map(([scopeName, scopeClasses]) => {
|
|
48
|
+
const classesStr = typeof scopeClasses === "string" ? scopeClasses : clsx(scopeClasses);
|
|
49
|
+
return classesStr
|
|
50
|
+
.split(/\s+/)
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map((cls) => `group-data-[scope=${scopeName}]/wind-scope:${cls}`)
|
|
53
|
+
.join(" ");
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function windCtrl(config) {
|
|
57
|
+
const { base, variants = {}, traits = {}, dynamic = {}, scopes = {}, defaultVariants = {}, } = config;
|
|
58
|
+
return (props = {}) => {
|
|
59
|
+
const classNameParts = [];
|
|
60
|
+
let mergedStyle = {};
|
|
61
|
+
// Priority order: Base < Variants < Traits < Dynamic
|
|
62
|
+
// (Higher priority classes are added later, so tailwind-merge will keep them)
|
|
63
|
+
// 1. Base classes (lowest priority)
|
|
64
|
+
if (base) {
|
|
65
|
+
classNameParts.push(base);
|
|
66
|
+
}
|
|
67
|
+
// 2. Variants (with defaultVariants fallback)
|
|
68
|
+
if (variants) {
|
|
69
|
+
for (const [variantKey, variantOptions] of Object.entries(variants)) {
|
|
70
|
+
const propValue = props[variantKey] ??
|
|
71
|
+
defaultVariants[variantKey];
|
|
72
|
+
if (propValue && variantOptions[propValue]) {
|
|
73
|
+
classNameParts.push(variantOptions[propValue]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// 3. Traits (higher priority than variants)
|
|
78
|
+
if (traits && props.traits) {
|
|
79
|
+
const traitClasses = processTraits(traits, props.traits);
|
|
80
|
+
classNameParts.push(...traitClasses);
|
|
81
|
+
}
|
|
82
|
+
// 4. Dynamic (highest priority for className)
|
|
83
|
+
if (dynamic) {
|
|
84
|
+
const dynamicResult = processDynamic(dynamic, props);
|
|
85
|
+
classNameParts.push(...dynamicResult.className);
|
|
86
|
+
mergedStyle = mergeStyles(mergedStyle, dynamicResult.style);
|
|
87
|
+
}
|
|
88
|
+
// 5. Scopes (always applied, but don't conflict with other classes)
|
|
89
|
+
if (scopes) {
|
|
90
|
+
const scopeClasses = processScopes(scopes);
|
|
91
|
+
classNameParts.push(...scopeClasses);
|
|
92
|
+
}
|
|
93
|
+
const finalClassName = twMerge(clsx(classNameParts));
|
|
94
|
+
const hasStyle = Object.keys(mergedStyle).length > 0;
|
|
95
|
+
return {
|
|
96
|
+
className: finalClassName,
|
|
97
|
+
...(hasStyle && { style: mergedStyle }),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "windctrl",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Advanced variant API for Tailwind CSS with stackable traits and interpolated dynamic styles.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Masaki Morishita (@morishxt)",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"tailwind",
|
|
24
|
+
"tailwindcss",
|
|
25
|
+
"variants",
|
|
26
|
+
"cva",
|
|
27
|
+
"styling",
|
|
28
|
+
"clsx",
|
|
29
|
+
"tailwind-merge",
|
|
30
|
+
"rsc",
|
|
31
|
+
"css-in-js",
|
|
32
|
+
"traits",
|
|
33
|
+
"interpolation"
|
|
34
|
+
],
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/morishxt/windctrl.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/morishxt/windctrl/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/morishxt/windctrl#readme",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "vitest",
|
|
45
|
+
"test:watch": "vitest --watch",
|
|
46
|
+
"test:coverage": "vitest --coverage",
|
|
47
|
+
"build": "tsc",
|
|
48
|
+
"dev": "tsc --watch",
|
|
49
|
+
"format": "prettier --write .",
|
|
50
|
+
"format:check": "prettier --check .",
|
|
51
|
+
"prepublishOnly": "npm run build && npm test"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^25.0.3",
|
|
55
|
+
"@types/react": "^19.2.7",
|
|
56
|
+
"prettier": "^3.7.4",
|
|
57
|
+
"typescript": "^5.9.3",
|
|
58
|
+
"vitest": "^4.0.16"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"clsx": "^2.1.1",
|
|
62
|
+
"tailwind-merge": "^3.4.0"
|
|
63
|
+
},
|
|
64
|
+
"peerDependencies": {
|
|
65
|
+
"tailwindcss": "^4.0.0"
|
|
66
|
+
}
|
|
67
|
+
}
|