@use-pico/cls
Version:
Type-safe, composable styling system for React, Vue, Svelte, and vanilla JS
668 lines (656 loc) • 22.7 kB
JavaScript
import { twMerge } from 'tailwind-merge';
import { jsx } from 'react/jsx-runtime';
import { createContext, useContext } from 'react';
const what = () => ({
what: {
css: (classes) => ({
class: classes,
}),
token: (tokens) => ({
token: tokens,
}),
both: (classes, tokens) => ({
class: classes,
token: tokens,
}),
slot: (slot) => slot,
variant: (variant) => variant,
},
override: {
root: (slot, override = true) => ({
match: undefined,
slot,
override,
}),
rule: (match, slot, override = true) => ({
match,
slot,
override,
}),
token: (token) => token,
},
def: {
root: (slot, override = false) => ({
match: undefined,
slot,
override,
}),
rule: (match, slot, override = false) => ({
match,
slot,
override,
}),
token: (token) => token,
defaults: (defaults) => defaults,
},
});
/**
* Combines two What objects by merging their class and token arrays
*/
function combineWhat(internal, user) {
if (!internal && !user) {
return undefined;
}
if (!internal) {
return user;
}
if (!user) {
return internal;
}
// Combine class arrays
const internalClasses = "class" in internal ? internal.class : [];
const userClasses = "class" in user ? user.class : [];
const combinedClasses = [
...(Array.isArray(internalClasses)
? internalClasses
: [
internalClasses,
]),
...(Array.isArray(userClasses)
? userClasses
: [
userClasses,
]),
];
// Combine token arrays
const internalTokens = "token" in internal ? internal.token : [];
const userTokens = "token" in user ? user.token : [];
const combinedTokens = [
...(Array.isArray(internalTokens)
? internalTokens
: [
internalTokens,
]),
...(Array.isArray(userTokens)
? userTokens
: [
userTokens,
]),
];
// Create result based on what we have
if (combinedClasses.length > 0 && combinedTokens.length > 0) {
return {
class: combinedClasses,
token: combinedTokens,
};
}
else if (combinedClasses.length > 0) {
return {
class: combinedClasses,
};
}
else if (combinedTokens.length > 0) {
return {
token: combinedTokens,
};
}
return undefined;
}
/**
* merge(user, internal)
*
* Merges two CreateConfig objects of the same contract type.
* - Field-level precedence: user wins over internal (variant, slot, override, token)
* - Shallow merge per field to match cls.create() semantics
* - Slots are combined by appending What objects, not overriding them
*/
function merge(userFn, internalFn) {
const $user = userFn?.(what());
const $internal = internalFn?.(what());
return () => ({
...($internal ?? {}),
...($user ?? {}),
variant: {
...$internal?.variant,
...$user?.variant,
},
slot: (() => {
const internalSlot = $internal?.slot;
const userSlot = $user?.slot;
if (!internalSlot && !userSlot) {
return undefined;
}
if (!internalSlot) {
return userSlot;
}
if (!userSlot) {
return internalSlot;
}
// Combine slots by merging What objects for each slot
const combinedSlot = {};
const allSlotKeys = new Set([
...Object.keys(internalSlot),
...Object.keys(userSlot),
]);
for (const slotKey of allSlotKeys) {
combinedSlot[slotKey] = combineWhat(internalSlot[slotKey], userSlot[slotKey]);
}
return combinedSlot;
})(),
override: {
...$internal?.override,
...$user?.override,
},
token: {
...$internal?.token,
...$user?.token,
},
});
}
const tvc = twMerge;
function cls(contract, definitionFn) {
const whatUtil = what();
const definition = definitionFn(whatUtil);
// Set the definition on the contract for inheritance
contract["~definition"] = definition;
// Build inheritance chain (base -> child order)
const layers = [];
let current = contract;
let currentDef = definition;
while (current && currentDef) {
layers.unshift({
contract: current,
definition: currentDef,
});
current = current["~use"];
currentDef = current?.["~definition"];
}
// Collect all slots
const allSlots = new Set();
for (const { contract: c } of layers) {
for (const slot of c.slot) {
allSlots.add(slot);
}
}
// Merge defaults and rules from ALL layers in inheritance order
const defaultVariant = {};
const rules = [];
// Process layers in inheritance order (base first, child last)
for (const { definition: d } of layers) {
// Merge defaults (child overrides base)
Object.assign(defaultVariant, d.defaults);
// Collect rules (all rules from all layers)
rules.push(...d.rules);
}
// Build token index with proper inheritance order
const tokens = {};
// Apply token definitions in inheritance order (base first, child last)
for (const { definition: d } of layers) {
for (const [k, v] of Object.entries(d.token)) {
tokens[k] = v;
}
}
// Helper function to resolve a single What<T> object recursively
const resolveWhat = (what, tokenTable, resolvedTokens = new Set()) => {
const result = [];
// Handle WhatClass (has 'class' property)
if ("class" in what && what.class) {
result.push(what.class);
}
// Handle WhatToken (has 'token' property) - recursive resolution
if ("token" in what && what.token) {
for (const tokenKey of what.token) {
// Check for circular dependencies
if (resolvedTokens.has(tokenKey)) {
throw new Error(`Circular dependency detected in token references: ${Array.from(resolvedTokens).join(" -> ")} -> ${tokenKey}`);
}
if (!tokenTable[tokenKey]) {
continue;
}
// Add to resolved set to prevent cycles
resolvedTokens.add(tokenKey);
// Recursively resolve the token definition
const resolved = resolveWhat(tokenTable[tokenKey], tokenTable, resolvedTokens);
result.push(...resolved);
// Remove from resolved set for other branches
resolvedTokens.delete(tokenKey);
}
}
return result;
};
// TODO May be simplified as it's only calling internal function
const applyWhat = (acc, what, tokenTable) => {
if (!what) {
return acc;
}
acc.push(...resolveWhat(what, tokenTable));
return acc;
};
const matches = (variant, ruleMatch) => {
if (!ruleMatch) {
return true;
}
for (const [k, v] of Object.entries(ruleMatch)) {
if (variant[k] !== v) {
return false;
}
}
return true;
};
// Public API
return {
create(userConfigFn, internalConfigFn) {
const config = merge(userConfigFn, internalConfigFn)();
const effectiveVariant = {
...defaultVariant,
...(config.variant ?? {}),
};
// Apply token overrides
const tokenTable = {
...tokens,
};
for (const [key, values] of Object.entries(config.token ?? {})) {
tokenTable[key] = values;
}
const cache = {};
const resultCache = new Map();
const computeKey = (slot, call) => {
if (!call) {
return `${slot}|__no_config__`;
}
try {
return `${slot}|${JSON.stringify(call(whatUtil))}`;
}
catch {
return `${slot}|__non_serializable__`;
}
};
const handler = {
get(_, slotName) {
if (slotName in cache) {
return cache[slotName];
}
const slotFn = (call) => {
const key = computeKey(slotName, call);
const cached = resultCache.get(key);
if (cached !== undefined) {
return cached;
}
const local = call?.(whatUtil);
const localConfig = local
? {
variant: local.variant,
slot: local.slot,
override: local.override,
token: local.token,
}
: undefined;
const localEffective = {
...effectiveVariant,
...(localConfig?.variant ?? {}),
};
const localTokens = {
...tokenTable,
};
for (const [key, values] of Object.entries(localConfig?.token ?? {})) {
localTokens[key] = values;
}
let acc = [];
// Apply rules
for (const rule of rules) {
if (!matches(localEffective, rule.match)) {
continue;
}
const slotMap = rule.slot ?? {};
const what = slotMap[slotName];
if (!what) {
continue;
}
if (rule.override === true) {
acc = [];
}
acc = applyWhat(acc, what, localTokens);
}
// Apply slot configurations (append to rules)
if (localConfig?.slot?.[slotName]) {
acc = applyWhat(acc, localConfig.slot[slotName], localTokens);
}
if (config.slot?.[slotName]) {
acc = applyWhat(acc, config.slot[slotName], localTokens);
}
// Apply overrides (clear and replace)
if (localConfig?.override?.[slotName]) {
acc = [];
acc = applyWhat(acc, localConfig.override[slotName], localTokens);
}
if (config.override?.[slotName]) {
acc = [];
acc = applyWhat(acc, config.override[slotName], localTokens);
}
const out = tvc(acc);
resultCache.set(key, out);
return out;
};
cache[slotName] = slotFn;
return cache[slotName];
},
ownKeys() {
return Array.from(allSlots);
},
getOwnPropertyDescriptor() {
return {
enumerable: true,
configurable: true,
};
},
};
return new Proxy({}, handler);
},
extend(childContract, childDefinitionFn) {
childContract["~use"] = contract;
// Don't set ~definition here - it will be set when cls() is called
const parentTokens = contract.tokens;
const childTokens = childContract.tokens;
const mergedTokens = Array.from(new Set([
...parentTokens,
...childTokens,
]));
const mergedContract = {
...childContract,
tokens: mergedTokens,
};
return cls(mergedContract, childDefinitionFn);
},
use(sub) {
return sub;
},
cls(userConfigFn, internalConfigFn) {
return merge(userConfigFn, internalConfigFn);
},
contract,
definition,
};
}
/**
* Recursive proxy; used to hack the type system.
*/
const proxyOf$1 = new Proxy(() => proxyOf$1, {
get: () => proxyOf$1,
});
/**
* This is previous implementation of "cls" with much simpler features, API, but still a bit more polished,
* than "tva" (tailwind-variants) :).
*
* - Inheritance works
* - Automatic inference works
* - Type checking works
* - Just token support is not implemented
*/
function clx({ use, slot, variant, match = [], defaults, }) {
/**
* Output is a factory method used to call at a component level (or whatever place you want).
*/
return (values, cls) => {
const slots = new Proxy({}, {
get(_, key) {
return (override, $cls) => {
/**
* Output classes,
*/
const $classes = [];
/**
* Type "use" (extension) for later use.
*/
const $use = use;
/**
* Compute current variants from:
* - use (extension)
* - local default values
* - values provided by the component call
* - override provided at the class name computation
*/
const $values = {
...$use?.()?.["~config"].defaults,
...defaults,
...values,
...override,
};
/**
* Push classes from the extension first as they may be overridden.
*/
$classes.push($use?.($values)?.slots[key]?.($values));
/**
* Push classes from slot as this is the base set of class names.
*/
$classes.push(Array.isArray(slot[key])
? slot[key]
: [
slot[key],
]);
/**
* Push all present variant values for this slot.
*/
for (const [k, v] of Object.entries($values)) {
const slotValue = variant?.[k]?.[v]?.[key];
if (!slotValue) {
continue;
}
$classes.push(slotValue);
}
/**
* Resolve all matching rules and push their classes.
*/
for (const rule of match) {
Object.entries(rule.if).every(([entry, value]) => value === $values[entry]) && $classes.push(rule.do?.[key]);
}
/**
* Push all overriding classes from the component call.
*/
$classes.push(cls?.[key]);
/**
* Push all overriding classes from the class name computation.
*/
$classes.push($cls);
return twMerge($classes);
};
},
});
return {
/**
* Proxy all calls to the slots to compute class names.
*
* Because there is a strict type checking, this should be safe to use; if you break types,
* this may fail at runtime.
*/
slots,
/**
* This is a configuration used internally
*/
"~config": {
defaults: {
...use?.()?.["~config"].defaults,
...defaults,
},
values: {
...use?.()?.["~config"].defaults,
...defaults,
...values,
},
},
/**
* Used for inheritance and type checking.
*/
"~type": proxyOf$1(),
};
};
}
/**
* Type-only function used to properly construct and infer the result Contract type.
*
* This function serves as a type assertion helper that ensures the provided contract
* parameter matches the expected Contract type structure with proper generic constraints.
* It returns the contract unchanged at runtime but provides TypeScript with the correct
* type information for the Contract<TTokenContract, TSlotContract, TVariantContract, any> type.
*
* @template TTokenContract - The token contract type extending TokenContract
* @template TSlotContract - The slot contract type extending SlotContract
* @template TVariantContract - The variant contract type extending VariantContract
* @template TContract - The full contract type extending Contract with the above generics
* @param contract - The contract object to be type-asserted
* @returns The same contract object with proper type inference
*
* @example
* ```typescript
* const myContract = contract({
* tokens: { color: "red" },
* slots: { content: "div" },
* variants: { size: ["small", "large"] }
* });
* // TypeScript now knows myContract has the proper Contract type
* ```
*/
const contract = (contract) => contract;
/**
* Context for providing a cls instance to child components.
* This allows components to inherit from a parent cls instance.
*/
const ClsContext = createContext(undefined);
/**
* Provider component for cls context.
*
* @param children - React children
* @param value - The cls instance to provide to child components
*
* @example
* ```tsx
* // Provide a cls instance
* <ClsProvider value={ButtonCls}>
* <App />
* </ClsProvider>
* ```
*/
function ClsProvider({ children, value, }) {
return jsx(ClsContext.Provider, { value: value, children: children });
}
/**
* Hook to access the cls context.
*
* @returns The current cls instance or undefined if not provided
*
* @example
* ```tsx
* function MyComponent() {
* const clsInstance = useClsContext();
*
* if (clsInstance) {
* // Use the cls instance from context
* const classes = clsInstance.create({ variant: { size: "md" } });
* }
* }
* ```
*/
function useClsContext() {
return useContext(ClsContext);
}
/**
* Hook to get the cls instance from context.
*
* @returns The cls instance from context or undefined if not provided
*
* @example
* ```tsx
* function Button({ variant, size }) {
* const contextCls = useClsContext();
*
* if (contextCls) {
* const classes = contextCls.create({ variant: { variant, size } });
* // Use the cls instance from context
* }
* }
* ```
*/
function useClsFromContext() {
return useClsContext();
}
function useCls(clsInstance, userConfigFn, internalConfigFn) {
// Get context cls instance
const contextCls = useClsContext();
// Merge context tokens with internal config (flat-only)
let mergedInternalConfig = internalConfigFn;
if (contextCls?.definition?.token) {
mergedInternalConfig = (props) => {
const config = internalConfigFn?.(props) ?? {};
return {
...config,
token: {
...contextCls.definition.token,
...config.token, // Internal tokens win over context tokens
},
};
};
}
// Simple implementation - creates classes on every render
// For performance optimization, consider memoizing the config objects
return clsInstance.create(userConfigFn, mergedInternalConfig);
}
/**
* Recursive proxy; used to hack the type system.
* This creates an infinite chain of proxies that allows for complex type manipulation
* without actually creating real objects at runtime.
*/
const proxyOf = new Proxy(() => proxyOf, {
get: () => proxyOf,
});
/**
* Higher-Order Component that attaches a cls instance to a component.
*
* This allows users to access the cls instance directly from the component,
* e.g., `ModernButton.cls` will be the typed cls instance.
*
* @template TCls - The cls instance type
* @template TProps - The component props type
* @param Component - The component to wrap
* @param clsInstance - The cls instance to attach
* @returns The wrapped component with the cls instance attached
*
* @example
* ```tsx
* // Define your cls
* const ButtonCls = cls(contract, definition);
*
* // Define your component
* const Button: FC<ButtonProps> = ({ children, ...props }) => {
* // component implementation
* };
*
* // Attach cls to component
* const ModernButton = withCls(Button, ButtonCls);
*
* // Now you can use it like:
* // <ModernButton>Click me</ModernButton>
* // And access the cls: ModernButton.cls
* ```
*/
function withCls(Component, clsInstance) {
// Create the wrapped value with phantom properties
const WrappedComponent = Component;
const proxy = proxyOf();
// Attach the cls instance
WrappedComponent.cls = clsInstance;
WrappedComponent["~slots"] = proxy;
WrappedComponent["~contract"] = clsInstance.contract;
WrappedComponent["~definition"] = clsInstance.definition;
return WrappedComponent;
}
export { ClsProvider, cls, clx, contract, merge, tvc, useCls, useClsContext, withCls };
//# sourceMappingURL=index.js.map