UNPKG

vueless

Version:

Vue Styleless UI Component Library, powered by Tailwind CSS.

344 lines (275 loc) 10.5 kB
import { ref, watch, getCurrentInstance, toValue, useAttrs, computed } from "vue"; import { cx, cva, setColor, vuelessConfig, getMergedConfig } from "../utils/ui"; import { CVA_CONFIG_KEY, SYSTEM_CONFIG_KEY, EXTENDS_PATTERN_REG_EXP, NESTED_COMPONENT_PATTERN_REG_EXP, } from "../constants"; import type { Ref } from "vue"; import type { CVA, UseUI, KeyAttrs, KeysAttrs, StateColors, MutatedProps, UnknownObject, ComponentNames, NestedComponent, ConfigDerivedData, ComponentDefaults, ComponentConfigFull, VuelessComponentInstance, } from "../types"; /* Pre-computed Set for O(1) system key lookups instead of O(n) array scan. */ const CVA_KEY_SET = new Set(Object.values(CVA_CONFIG_KEY)); const SYSTEM_KEY_SET = new Set(Object.values(SYSTEM_CONFIG_KEY)); const TRANSITION_KEY = SYSTEM_CONFIG_KEY.transition; /** * Merging component configs in a given sequence (bigger number = bigger priority): * 1. Default component config * 2. Custom global component config (/vueless.config.{js,ts}) * 3. Component config (:config="{...}" props) * 4. Component classes (class="...") */ export function useUI<T>(defaultConfig: T, mutatedProps?: MutatedProps, topLevelClassKey?: string) { const { type, props, parent } = getCurrentInstance() as VuelessComponentInstance; const componentName = type?.internal ? (parent?.type.__name as ComponentNames) : (type.__name as ComponentNames); const globalConfig = (vuelessConfig.components?.[componentName] || {}) as ComponentConfigFull<T>; const firstClassKey = Object.keys(defaultConfig || {})[0]; const config = ref({}) as Ref<ComponentConfigFull<T>>; const isDev = import.meta.env?.DEV || __VUELESS_DEV__; const isUnstyled = Boolean(vuelessConfig.unstyled); /* Hoist shared reactive primitives — create once, share across all keys. */ const attrs = useAttrs() as KeyAttrs; const reactiveAttrsClass = computed(() => attrs.class); /** * Reactive wrapper for props — created once per component instead of once per key. * Spreads props to create a shallow copy that triggers reactivity on any prop change. */ const reactiveProps = computed(() => ({ ...props })); /* Cache for CVA resolver functions — only recreated when config changes. */ const cvaCache = new Map<string, ReturnType<typeof cva>>(); watch( () => props.config, (newVal, oldVal) => { if (newVal === oldVal) return; const propsConfig = props.config as ComponentConfigFull<T>; config.value = getMergedConfig({ defaultConfig, globalConfig, propsConfig, unstyled: isUnstyled, }) as ComponentConfigFull<T>; /* Invalidate CVA cache when config changes. */ cvaCache.clear(); }, { deep: true, immediate: true }, ); /** * Pre-computed data per config key that only changes when config changes. * Avoids recomputing extends configs, nested component info, and merged configs on every prop change. */ const configDerivedData = computed(() => { const data: ConfigDerivedData = {}; for (const key in config.value) { if (isSystemKey(key)) continue; const keyConfig: NestedComponent = typeof config.value[key] === "object" ? (config.value[key] as NestedComponent) : {}; const extendsKeyConfig = computeExtendsKeyConfig(key); const extendsKeyNestedComponent = getNestedComponent(extendsKeyConfig); const keyNestedComponent = getNestedComponent(config.value[key]); const nestedComponent = extendsKeyNestedComponent || keyNestedComponent || componentName; const mergedNestedConfig = getMergedConfig({ defaultConfig: extendsKeyConfig, globalConfig: keyConfig, propsConfig: attrs["config"] || {}, unstyled: isUnstyled, }); const mergedDefaults: ComponentDefaults = {}; const defaultAttrs = { ...(extendsKeyConfig.defaults || {}), ...(keyConfig.defaults || {}), }; for (const defaultKey in defaultAttrs) { mergedDefaults[defaultKey] = typeof defaultAttrs[defaultKey] === "object" ? defaultAttrs[defaultKey][String(props[defaultKey])] : defaultAttrs[defaultKey]; } data[key] = { keyConfig, extendsClasses: computeExtendsClasses(key), extendsKeyConfig, nestedComponent, mergedNestedConfig, mergedDefaults, }; } return data; }); /** * Compute classes for a given key directly (not as a computed ref). * Used inside watchers to avoid creating orphaned computed properties. */ function computeClassesForKey(key: string) { const mutatedPropsValue = toValue(mutatedProps); const value = (config.value as ComponentConfigFull<T>)[key]; const color = (toValue(mutatedProps || {}).color || props.color) as StateColors; const isNestedComponent = Boolean(getNestedComponent(value)); let classes = ""; if (typeof value === "object" && isCVA(value)) { let cvaFn = cvaCache.get(key); if (!cvaFn) { cvaFn = cva(value); cvaCache.set(key, cvaFn); } classes = cvaFn({ ...props, ...mutatedPropsValue, ...(color ? { color } : {}), }); } if (typeof value === "string") { classes = value; } classes = classes .replaceAll(EXTENDS_PATTERN_REG_EXP, "") .replace(NESTED_COMPONENT_PATTERN_REG_EXP, ""); return color && !isNestedComponent ? setColor(classes, color) : classes; } /** * Recursively compute extends classes directly (no orphaned computed refs). */ function computeExtendsClasses(configKey: string): string[] { const extendsKeys = getExtendsKeys(config.value[configKey]); if (!extendsKeys.length) return []; const result: string[] = []; for (const key of extendsKeys) { if (key === configKey) continue; result.push(...computeExtendsClasses(key), computeClassesForKey(key)); } return result; } /** * Merge extends nested component configs. * TODO: Add ability to merge multiple keys in one (now works for merging only 1 first key). */ function computeExtendsKeyConfig(configKey: string): NestedComponent { const propsConfig = props.config as ComponentConfigFull<T>; const extendsKeys = getExtendsKeys(config.value[configKey]); if (!extendsKeys.length) return {}; const [firstKey] = extendsKeys; if (config.value[firstKey] === undefined) { // eslint-disable-next-line no-console console.warn(`[vueless] Missing ${firstKey} extend key.`); } return getMergedConfig({ defaultConfig: config.value[firstKey] || {}, globalConfig: globalConfig[firstKey], propsConfig: propsConfig[firstKey], unstyled: isUnstyled, }) as NestedComponent; } /** * Returns an object where: * – key: elementKey * – value: reactive object of string element attributes (with classes). */ function getKeysAttrs(mutatedProps?: MutatedProps) { const keysAttrs: KeysAttrs<T> = {}; const attrsRefs: Record<string, Ref<KeyAttrs>> = {}; for (const key in config.value) { if (isSystemKey(key)) continue; const vuelessAttrs = ref({} as KeyAttrs); attrsRefs[key] = vuelessAttrs; keysAttrs[`${key}Attrs`] = vuelessAttrs; } /** * Single consolidated watcher instead of N per-key watchers. * Watches: config (for config changes), reactiveProps (for prop changes), * mutatedProps (for slot/computed prop changes), reactiveAttrsClass (for class attr changes). */ watch( [config, reactiveProps, mutatedProps || (() => undefined), reactiveAttrsClass], () => { const derived = configDerivedData.value; for (const key in attrsRefs) { const data = derived[key]; if (!data) continue; const isTopLevelKey = (topLevelClassKey || firstClassKey) === key; const classes = computeClassesForKey(key); const commonAttrs: KeyAttrs = { ...(isTopLevelKey ? attrs : {}), "vl-component": isDev ? attrs["vl-component"] || componentName || null : null, "vl-key": isDev ? attrs["vl-key"] || key || null : null, "vl-child-component": isDev && attrs["vl-component"] ? data.nestedComponent : null, "vl-child-key": isDev && attrs["vl-component"] ? key : null, }; /* Delete value key to prevent v-model overwrite. */ delete commonAttrs.value; attrsRefs[key].value = { ...commonAttrs, class: cx([...data.extendsClasses, classes, commonAttrs.class]), config: data.mergedNestedConfig, ...data.mergedDefaults, }; } }, { immediate: true }, ); return keysAttrs; } /** * Get data test attribute value if exist. */ function getDataTest(suffix?: string) { if (!props.dataTest) { return null; } return suffix ? `${props.dataTest}-${suffix}` : props.dataTest; } return { config, getDataTest, ...getKeysAttrs(mutatedProps) } as UseUI<T>; } /** * Return base classes. */ function getBaseClasses(value?: string | CVA | NestedComponent) { return typeof value === "object" ? value.base || "" : value || ""; } /** * Retrieves extends keys from patterns: * Example: `{>someKey} {>someOtherKey}` >>> `["someKey", "someOtherKey"]`. */ function getExtendsKeys(configItemValue?: string | CVA | NestedComponent): string[] { const values = getBaseClasses(configItemValue); const matches = values.match(EXTENDS_PATTERN_REG_EXP); return matches ? matches.map((pattern) => pattern.slice(2, -1)) : []; } /** * Check is config key contains component name and returns it. */ function getNestedComponent(value?: string | CVA | NestedComponent) { const classes = getBaseClasses(value); const match = classes.match(NESTED_COMPONENT_PATTERN_REG_EXP); return match ? match[1] : ""; } /** * Check is config key not contains classes or CVA config object. */ function isSystemKey(key: string): boolean { return SYSTEM_KEY_SET.has(key) || key.toLowerCase().includes(TRANSITION_KEY); } /** * Check is config contains default CVA keys. */ function isCVA(config?: UnknownObject | string): boolean { if (typeof config !== "object") { return false; } const keys = Object.keys(config); return keys.some((key) => CVA_KEY_SET.has(key)); }