UNPKG

@use-pico/cls

Version:

Type-safe, composable styling system for React, Vue, Svelte, and vanilla JS

668 lines (656 loc) 22.7 kB
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