vueless
Version:
Vue Styleless UI Component Library, powered by Tailwind CSS.
316 lines (256 loc) • 9.61 kB
text/typescript
import { ref, watch, watchEffect, getCurrentInstance, toValue, useAttrs, computed } from "vue";
import { isEqual } from "lodash-es";
import { cx, cva, setColor, getColor, vuelessConfig, getMergedConfig } from "../utils/ui.ts";
import { isCSR } from "../utils/helper.ts";
import {
STRATEGY_TYPE,
CVA_CONFIG_KEY,
SYSTEM_CONFIG_KEY,
DEFAULT_BASE_CLASSES,
EXTENDS_PATTERN_REG_EXP,
NESTED_COMPONENT_PATTERN_REG_EXP,
} from "../constants.js";
import type { Ref, ComputedRef } from "vue";
import type {
CVA,
UseUI,
Defaults,
KeyAttrs,
KeysAttrs,
Strategies,
BrandColors,
MutatedProps,
UnknownObject,
ComponentNames,
NestedComponent,
ComponentConfigFull,
VuelessComponentInstance,
} from "../types.ts";
/**
* 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 default 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 vuelessStrategy = Object.values(STRATEGY_TYPE).includes(vuelessConfig.strategy || "")
? (vuelessConfig.strategy as Strategies)
: (STRATEGY_TYPE.merge as Strategies);
const firstClassKey = Object.keys(defaultConfig || {})[0];
const config = ref({}) as Ref<ComponentConfigFull<T>>;
watchEffect(() => {
const propsConfig = props.config as ComponentConfigFull<T>;
config.value = getMergedConfig({
defaultConfig,
globalConfig,
propsConfig,
vuelessStrategy,
}) as ComponentConfigFull<T>;
});
/**
* Get classes by given key (including CVA if config set).
*/
function getClasses(key: string, mutatedProps?: MutatedProps) {
return computed(() => {
const mutatedPropsValue = toValue(mutatedProps);
const value = (config.value as ComponentConfigFull<T>)[key];
const color = (toValue(mutatedProps || {}).color || props.color) as BrandColors;
const isTopLevelKey = (topLevelClassKey || firstClassKey) === key;
const isNestedComponent = Boolean(getNestedComponent(value));
let classes = "";
if (typeof value === "object" && isCVA(value)) {
classes = cva(value)({
...props,
...mutatedPropsValue,
...(color ? { color: getColor(color) } : {}),
});
}
if (typeof value === "string") {
classes = value;
}
if (isTopLevelKey && !isNestedComponent) {
classes = cx([DEFAULT_BASE_CLASSES, vuelessConfig.baseClasses, classes]);
}
classes = classes
.replaceAll(EXTENDS_PATTERN_REG_EXP, "")
.replace(NESTED_COMPONENT_PATTERN_REG_EXP, "");
return color && !isNestedComponent ? setColor(classes, color) : classes;
});
}
/**
* Returns an object where:
* – key: elementKey
* – value: reactive object of string element attributes (with classes).
*/
function getKeysAttrs(mutatedProps?: MutatedProps) {
const keysAttrs: KeysAttrs<T> = {};
for (const key in config.value) {
if (isSystemKey(key)) continue;
keysAttrs[`${key}Attrs`] = getAttrs(key, getClasses(key, mutatedProps));
}
return keysAttrs;
}
/**
* Get an element attributes for a given key.
*/
function getAttrs(configKey: string, classes: ComputedRef<string>) {
const vuelessAttrs = ref({} as KeyAttrs);
const attrs = useAttrs() as KeyAttrs;
const reactiveProps = computed(() => ({ ...props }));
const reactiveClass = computed(() => attrs.class);
watch([config, reactiveProps, classes, reactiveClass], updateVuelessAttrs, { immediate: true });
/**
* Updating Vueless attributes.
*/
function updateVuelessAttrs(newVal: unknown, oldVal: unknown) {
if (isEqual(newVal, oldVal)) return;
let keyConfig: NestedComponent = {};
if (typeof config.value[configKey] === "object") {
keyConfig = config.value[configKey] as NestedComponent;
}
const isDev = isCSR && import.meta.env?.DEV;
const isTopLevelKey = (topLevelClassKey || firstClassKey) === configKey;
const extendsClasses = getExtendsClasses(configKey);
const extendsKeyConfig = getExtendsKeyConfig(configKey);
const extendsKeyNestedComponent = getNestedComponent(extendsKeyConfig);
const keyNestedComponent = getNestedComponent(config.value[configKey]);
const nestedComponent = extendsKeyNestedComponent || keyNestedComponent || componentName;
const commonAttrs: KeyAttrs = {
...(isTopLevelKey ? attrs : {}),
"vl-component": isDev ? attrs["vl-component"] || componentName || null : null,
"vl-key": isDev ? attrs["vl-key"] || configKey || null : null,
"vl-child-component": isDev && attrs["vl-component"] ? nestedComponent : null,
"vl-child-key": isDev && attrs["vl-component"] ? configKey : null,
};
/* Delete value key to prevent v-model overwrite. */
delete commonAttrs.value;
vuelessAttrs.value = {
...commonAttrs,
class: cx([...extendsClasses, toValue(classes), commonAttrs.class]),
config: getMergedConfig({
defaultConfig: extendsKeyConfig,
globalConfig: keyConfig,
propsConfig: attrs["config"] || {},
}),
...getDefaults({
...(extendsKeyConfig.defaults || {}),
...(keyConfig.defaults || {}),
}),
};
}
/**
* Recursively get extends classes.
*/
function getExtendsClasses(configKey: string) {
let extendsClasses: string[] = [];
const extendsKeys = getExtendsKeys(config.value[configKey]);
if (extendsKeys.length) {
extendsKeys.forEach((key) => {
if (key === configKey) return;
extendsClasses = [
...extendsClasses,
...getExtendsClasses(key),
toValue(getClasses(key, mutatedProps)),
];
});
}
return extendsClasses;
}
/**
* Merge extends nested component configs.
* TODO: Add ability to merge multiple keys in one (now works for merging only 1 first key).
*/
function getExtendsKeyConfig(configKey: string) {
let extendsKeyConfig: NestedComponent = {};
const propsConfig = props.config as ComponentConfigFull<T>;
const extendsKeys = getExtendsKeys(config.value[configKey]);
if (extendsKeys.length) {
const [firstKey] = extendsKeys;
extendsKeyConfig = getMergedConfig({
defaultConfig: config.value[firstKey],
globalConfig: globalConfig[firstKey],
propsConfig: propsConfig[firstKey],
}) as NestedComponent;
}
return extendsKeyConfig;
}
/**
* Get component prop default value.
* Conditionally set props default value for nested components based on parent component prop value.
* For example, set icon size for the nested component based on the size of the parent component.
* Use an object where key = parent component prop value, value = nested component prop value.
* */
function getDefaults(defaultAttrs: NestedComponent["defaults"]) {
const defaults: Defaults = {};
for (const key in defaultAttrs) {
defaults[key] =
typeof defaultAttrs[key] === "object"
? defaultAttrs[key][String(props[key])]
: defaultAttrs[key];
}
return defaults;
}
return vuelessAttrs;
}
/**
* 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 {
const isExactKey = Object.values(SYSTEM_CONFIG_KEY).some((value) => value === key);
return isExactKey || key.toLowerCase().includes(SYSTEM_CONFIG_KEY.transition.toLowerCase());
}
/**
* Check is config contains default CVA keys.
*/
function isCVA(config?: UnknownObject | string): boolean {
if (typeof config !== "object") {
return false;
}
return Object.values(CVA_CONFIG_KEY).some((value) =>
Object.keys(config).some((key) => key === value),
);
}