@oruga-ui/oruga-next
Version:
UI components for Vue.js and CSS framework agnostic
368 lines (315 loc) • 12.5 kB
text/typescript
import {
ref,
watch,
isRef,
toValue,
getCurrentInstance,
effectScope,
onScopeDispose,
getCurrentScope,
type MaybeRefOrGetter,
type Ref,
type ComponentInternalInstance,
type EffectScope,
} from "vue";
import { getConfig } from "@/utils/config";
import {
isDefined,
blankIfUndefined,
getValueByPath,
isTrueish,
} from "@/utils/helpers";
import type { ClassBinding, ComponentClass, TransformFunction } from "@/types";
// named tuple as prop definition
type ComputedClass = readonly [
className: string,
defaultClass: string,
suffix?: MaybeRefOrGetter<string | undefined> | null,
apply?: MaybeRefOrGetter<boolean> | null,
];
/** Helper function to get all active classes from a class binding list */
export const getActiveClasses = (
classes: MaybeRefOrGetter<ClassBinding[]>,
): string[] => {
const values = toValue(classes);
if (!values) return [];
return values.flatMap((bind) =>
Object.keys(bind)
.filter((key) => key && bind[key])
.flatMap((v) => v.split(" ")),
);
};
type DefineClassesOptions = {
/**
* Pass a custom effect scope.
* By default a new effect scope is created.
* An error will be thrown if no current scope or a custom scope is given.
* @default effectScope()
*/
scope?: EffectScope;
/**
* Pass a custom props object which will be watched on additionaly to the current component instance props.
* this will recompute the class bind property when the class property change.
* @default vm.proxy?.$props
*/
props?: Record<string, any>;
/**
* Pass a custom component instance.
* By default the current component instance is used.
*/
vm?: ComponentInternalInstance;
};
export function defineClasses(
...args: [...ComputedClass[], DefineClassesOptions]
): Ref<ClassBinding[]>;
export function defineClasses(
...args: [...ComputedClass[]]
): Ref<ClassBinding[]>;
/**
* Calculate dynamic classes based on class definitions
*/
export function defineClasses(
...args: ComputedClass[] | [...ComputedClass[], DefineClassesOptions]
): Ref<ClassBinding[]> {
// extract last argument if its the option object
const options = Array.isArray(args[args.length - 1])
? undefined
: (args[args.length - 1] as DefineClassesOptions);
// get class defintion list based on options are given or not
const classDefinitions = (
Array.isArray(args[args.length - 1]) ? args : args.slice(0, -1)
) as ComputedClass[];
// getting a hold of the internal instance of the component in setup()
const vm = options?.vm || getCurrentInstance();
if (!vm)
throw new Error(
"defineClasses must be called within a component setup function.",
);
// check if there is no current active effect scope given
if (!getCurrentScope() && !options?.scope)
throw new Error(
"defineClasses must be called within a current active effect scope.",
);
// create an effect scope object to capture reactive effects
const scope = options?.scope || effectScope();
// check if there is a current active effect scope
if (getCurrentScope())
// Registers a dispose callback on the current active effect scope.
// The callback will be invoked when the associated effect scope is stopped.
onScopeDispose(() => {
// stop all effects when appropriate
if (scope) scope.stop();
});
// reactive classes container
const classes = ref<ClassBinding[]>([]);
classes.value = classDefinitions.map((defintion, index) => {
const className = defintion[0];
const defaultClass = defintion[1];
const suffix = defintion[2];
const apply = defintion[3];
function getClassBind(): ClassBinding {
// compute class based on definition parameter
const computedClass = computeClass(
vm!,
className,
defaultClass,
toValue(suffix) || undefined,
);
// if apply is not defined or true
const applied = !isDefined(apply) || toValue(apply);
// return class bind property
return { [computedClass]: applied };
}
// run all watcher and computed properties in an active effect scope
scope.run(() => {
// recompute the class bind property when the class property change
watch(
[
() => vm.proxy?.$props[className],
() => (options?.props ? options?.props[className] : null),
],
() => {
// recompute the class bind property
const classBind = getClassBind();
// update class binding property by class index
classes.value[index] = classBind;
},
);
// if suffix is defined, watch suffix changed and recalculate class
if (isDefined(suffix) && isRef(suffix)) {
watch(suffix, (value, oldValue) => {
// only recompute when value has really changed
if (value === oldValue) return;
// recompute the class bind property
const classBind = getClassBind();
// update class binding property by class index
classes.value[index] = classBind;
});
}
// if apply is defined, watch apply changed and update apply state (no need of recalculation here)
if (isDefined(apply) && isRef(apply)) {
watch(apply, (applied, oldValue) => {
// only change apply when value has really changed
if (applied === oldValue) return;
// get class binding property by class index
const classBind = classes.value[index];
// update the apply class binding state
Object.keys(classBind).forEach(
(key) => (classBind[key] = applied),
);
// update the class binding property by class index
classes.value[index] = classBind;
});
}
});
// return computed class based on parameter
return getClassBind();
});
// return reactive classes
return classes;
}
/**
* Compute a class by a field name
*/
function computeClass(
vm: ComponentInternalInstance,
field: string,
defaultValue: string,
suffix = "",
): string {
// get component instance props
const props = getProps(vm);
const componentKey: string = vm.proxy?.$options.configField;
if (!componentKey)
throw new Error("component must define the 'configField' option.");
// get the component config if it's not overridden by current instance
const config = isTrueish(props.override) ? {} : getConfig();
// --- Override Definition ---
// define instance override
const instanceOverride: boolean = isTrueish(props.override);
// define config override
const configOverride =
// do not have to check if config is already overridden
instanceOverride ||
// check root config override property
getValueByPath(config, "override") ||
// check component field config override property
getValueByPath(config, `${componentKey}.${field}.override`) ||
// check component config override property
getValueByPath(config, `${componentKey}.override`);
// --- Class Definition ---
let instanceClassString: string | undefined = undefined;
let configClassString: string | undefined = undefined;
let defaultClassString: string | undefined = undefined;
// procsess instance class definition if available
const instanceClass: ComponentClass | undefined =
// get instance class definition
getValueByPath(props, field);
// compile instance class
instanceClassString = compileClass(instanceClass, props, suffix);
if (!instanceOverride) {
if (!configOverride) {
// process default class definition if not overridden by instance or config
defaultClassString = applySuffix(defaultValue, suffix);
}
// process config class definition if not overriden by instance
const configClass: ComponentClass | undefined =
// get config class definition
getValueByPath(config, `${componentKey}.${field}.class`) ||
getValueByPath(config, `${componentKey}.${field}`);
// compile config class
configClassString = compileClass(configClass, props, suffix);
}
// --- Define Applied Classes ---
// add default classes if available
// add config classes if available
// add instance classes if available
let appliedClasses = [
defaultClassString ?? "",
configClassString ?? "",
instanceClassString ?? "",
]
.join(" ")
.trim()
.replace(/\s\s+/g, " ");
// --- Tranform Classes ---
// get component config transform function
let transformClasses: TransformFunction | undefined = getValueByPath(
config,
`${componentKey}.transformClasses`,
);
if (!transformClasses)
// get root config transform function
transformClasses = getValueByPath(config, "transformClasses");
// apply transform function if available
if (typeof transformClasses === "function")
appliedClasses = transformClasses(appliedClasses);
return appliedClasses;
}
/** Compile a component class definition into a string. */
function compileClass(
classDefinition: ComponentClass | undefined,
props: ReturnType<typeof getProps>,
suffix: string,
): string {
// if definiton is undefined return empty class string
if (typeof classDefinition === "undefined") return "";
let classBinding: ClassBinding | ClassBinding[];
if (typeof classDefinition === "function")
// call class definition function
classBinding = classDefinition(suffix, props) ?? "";
else classBinding = classDefinition;
let classString = "";
if (Array.isArray(classBinding)) {
classString = classBinding
// transform the classBinding into a string
.map(processClassBinding)
// join all classes into one string
.join(" ");
} else if (classBinding) {
// transform the classBinding into a string
classString = processClassBinding(classBinding);
}
// if suffix is not already applied by the classFunction
if (typeof classDefinition !== "function")
// apply suffix to the class string
classString = applySuffix(classString, suffix);
return classString;
}
/** Transform a classBinding object into a string. */
function processClassBinding(classBinding: ClassBinding): string {
if (typeof classBinding === "string") return classBinding;
if (typeof classBinding === "object")
return (
Object.keys(classBinding)
// filter by the truthiness of the data property
.filter((key) => classBinding[key])
// join all classes into one string
.join(" ")
);
return "";
}
/** Add a suffix to each word of an input string. */
function applySuffix(value: string, suffix: string): string {
return blankIfUndefined(value)
.split(" ")
.map((cls) => {
if (cls.includes("{*}")) {
return cls.replace(/\{\*\}/g, blankIfUndefined(suffix));
} else {
return cls + blankIfUndefined(suffix);
}
})
.join(" ");
}
/** Get all props form an component instance. */
function getProps(vm: ComponentInternalInstance): Record<string, any> {
let props = vm.proxy?.$props || {};
// get all props which ends with "Props", these are compressed parent props
// append these parent props as root level prop
props = Object.keys(props)
.filter((key) => key.endsWith("Props"))
.map((key) => props[key])
.reduce((a, b) => ({ ...a, ...b }), props);
return props;
}