vueless
Version:
Vue Styleless UI Component Library, powered by Tailwind CSS.
316 lines (256 loc) • 9.52 kB
text/typescript
import { ref, watch, getCurrentInstance, toValue, useAttrs, computed } from "vue";
import { isEqual } from "lodash-es";
import { cx, cva, setColor, vuelessConfig, getMergedConfig } from "../utils/ui.ts";
import {
CVA_CONFIG_KEY,
SYSTEM_CONFIG_KEY,
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,
MutatedProps,
UnknownObject,
PrimaryColors,
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 firstClassKey = Object.keys(defaultConfig || {})[0];
const config = ref({}) as Ref<ComponentConfigFull<T>>;
watch(
() => props.config,
(newVal, oldVal) => {
if (isEqual(newVal, oldVal)) return;
const propsConfig = props.config as ComponentConfigFull<T>;
config.value = getMergedConfig({
defaultConfig,
globalConfig,
propsConfig,
unstyled: Boolean(vuelessConfig.unstyled),
}) as ComponentConfigFull<T>;
},
{ deep: true, immediate: true },
);
/**
* Get classes by a 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 PrimaryColors;
const isNestedComponent = Boolean(getNestedComponent(value));
let classes = "";
if (typeof value === "object" && isCVA(value)) {
classes = cva(value)({
...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;
});
}
/**
* 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 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 = 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"] || {},
unstyled: Boolean(vuelessConfig.unstyled),
}),
...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;
if (config.value[firstKey] === undefined) {
// eslint-disable-next-line no-console
console.warn(`[vueless] Missing ${firstKey} extend key.`);
}
extendsKeyConfig = getMergedConfig({
defaultConfig: config.value[firstKey] || {},
globalConfig: globalConfig[firstKey],
propsConfig: propsConfig[firstKey],
unstyled: Boolean(vuelessConfig.unstyled),
}) 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),
);
}