vueless
Version:
Vue Styleless UI Component Library, powered by Tailwind CSS.
344 lines (275 loc) • 10.5 kB
text/typescript
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));
}