Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const button = windCtrl({

// Usage
button({ w: "w-full" }); // -> className includes "w-full" (static utility)
button({ w: 200 }); // -> style includes { width: "200px" } (dynamic value)
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.
Expand Down Expand Up @@ -187,6 +187,7 @@ button({ intent: "primary", size: "lg" });
- **Tailwind JIT:** Tailwind only generates CSS for class names it can statically detect. Avoid constructing class strings dynamically unless you safelist them.
- **Traits precedence:** If trait order matters, use the array form (`traits: ["a", "b"]`) to make precedence explicit.
- **SSR/RSC:** Keep dynamic resolvers pure (same input → same output) to avoid hydration mismatches.
- **Static config:** `windCtrl` configuration is treated as static/immutable. Mutating the config object after creation is not supported.

## License

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/windctrl.test.ts → src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { windCtrl } from "../index";
import { windCtrl } from "./";

describe("windCtrl", () => {
describe("Base classes", () => {
Expand Down
51 changes: 30 additions & 21 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,15 @@ function processTraits<TTraits extends Record<string, ClassValue>>(
return [];
}

function processDynamic<TDynamic extends Record<string, DynamicResolver>>(
dynamic: TDynamic,
props: Props<{}, {}, TDynamic>,
function processDynamicEntries(
entries: [string, DynamicResolver][],
props: Record<string, any>,
): { className: ClassValue[]; style: CSSProperties } {
const classNameParts: ClassValue[] = [];
const styles: CSSProperties[] = [];

for (const [key, resolver] of Object.entries(dynamic)) {
const value = props[key as keyof TDynamic];
for (const [key, resolver] of entries) {
const value = props[key];
if (value !== undefined && value !== null) {
const result = resolver(value);
if (typeof result === "string") {
Expand Down Expand Up @@ -137,6 +137,16 @@ export function windCtrl<
defaultVariants = {},
} = config;

const resolvedVariants = Object.entries(variants) as [
string,
Record<string, ClassValue>,
][];
const resolvedDynamicEntries = Object.entries(dynamic) as [
string,
DynamicResolver,
][];
const resolvedScopeClasses = processScopes(scopes);

return (props = {} as Props<TVariants, TTraits, TDynamic>) => {
const classNameParts: ClassValue[] = [];
let mergedStyle: CSSProperties = {};
Expand All @@ -150,34 +160,33 @@ export function windCtrl<
}

// 2. Variants (with defaultVariants fallback)
if (variants) {
for (const [variantKey, variantOptions] of Object.entries(variants)) {
const propValue =
props[variantKey as keyof typeof props] ??
defaultVariants[variantKey as keyof typeof defaultVariants];
if (propValue && variantOptions[propValue as string]) {
classNameParts.push(variantOptions[propValue as string]);
}
for (const [variantKey, variantOptions] of resolvedVariants) {
const propValue =
props[variantKey as keyof typeof props] ??
defaultVariants[variantKey as keyof typeof defaultVariants];
if (propValue && variantOptions[propValue as string]) {
classNameParts.push(variantOptions[propValue as string]);
}
}

// 3. Traits (higher priority than variants)
if (traits && props.traits) {
const traitClasses = processTraits(traits, props.traits);
classNameParts.push(...traitClasses);
if (props.traits) {
classNameParts.push(...processTraits(traits, props.traits));
}

// 4. Dynamic (highest priority for className)
if (dynamic) {
const dynamicResult = processDynamic(dynamic, props);
if (resolvedDynamicEntries.length) {
const dynamicResult = processDynamicEntries(
resolvedDynamicEntries,
props,
);
classNameParts.push(...dynamicResult.className);
mergedStyle = mergeStyles(mergedStyle, dynamicResult.style);
}

// 5. Scopes (always applied, but don't conflict with other classes)
if (scopes) {
const scopeClasses = processScopes(scopes);
classNameParts.push(...scopeClasses);
if (resolvedScopeClasses.length) {
classNameParts.push(...resolvedScopeClasses);
}

const finalClassName = twMerge(clsx(classNameParts));
Expand Down