UNPKG

@oruga-ui/oruga-next

Version:

UI components for Vue.js and CSS framework agnostic

368 lines (315 loc) 12.5 kB
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; }