feat(vue3): ✨ 新增style-helper
This commit is contained in:
parent
fa3615bbcf
commit
0e68849e8e
|
|
@ -0,0 +1,116 @@
|
|||
/** Vue class 对象格式,键为类名,值为布尔值表示是否生效 */
|
||||
type CustomClassObj = Record<string, boolean>;
|
||||
/** Vue class 支持的类型:字符串、字符串数组或对象 */
|
||||
type CustomClass = string | Array<string> | CustomClassObj;
|
||||
/** 分解后的 Vue class 对象,所有类名值为 true */
|
||||
type DistCustomClass = Record<string, true>;
|
||||
|
||||
/**
|
||||
* 将字符串或字符串数组转换为 Vue class 对象
|
||||
*
|
||||
* 将类名字符串或数组转换为 `{ className: true }` 格式的对象,
|
||||
* 适用于 Vue 的 class 绑定。
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* createCustomClassObj('foo bar') // { foo: true, bar: true }
|
||||
* createCustomClassObj(['foo', 'bar']) // { foo: true, bar: true }
|
||||
* ```
|
||||
*
|
||||
* @param customClass 类名字符串或字符串数组
|
||||
* @returns 类名对象,所有值为 true
|
||||
* @throws 若 customClass 不是字符串或数组则抛出 TypeError
|
||||
*/
|
||||
export function createCustomClassObj(customClass: string): DistCustomClass;
|
||||
export function createCustomClassObj(
|
||||
customClass: Array<string>,
|
||||
): DistCustomClass;
|
||||
export function createCustomClassObj(
|
||||
customClass: string | Array<string>,
|
||||
): DistCustomClass {
|
||||
let customClassObj = <DistCustomClass>{};
|
||||
if (typeof customClass === "string") {
|
||||
const customClassStr = customClass.trim().replace(" +", " ");
|
||||
if (customClassStr.length > 0) {
|
||||
const customClassEntries = customClassStr
|
||||
.split(" ")
|
||||
.map((name) => [name, true]);
|
||||
customClassObj = Object.fromEntries(customClassEntries);
|
||||
}
|
||||
} else if (Array.isArray(customClass)) {
|
||||
if (customClass.length > 0) {
|
||||
const customClassEntries = customClass.map((name) => [name, true]);
|
||||
customClassObj = Object.fromEntries(customClassEntries);
|
||||
}
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`customClass只能是字符串或数组类型,customClass: ${customClass}`,
|
||||
);
|
||||
}
|
||||
|
||||
return customClassObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换 Vue class 为 Vue class 对象
|
||||
*
|
||||
* 支持字符串、数组和对象三种 Vue class 格式,统一转换为
|
||||
* `{ className: boolean }` 格式的对象。
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* convertCustomClass('foo bar') // { foo: true, bar: true }
|
||||
* convertCustomClass(['foo', 'bar']) // { foo: true, bar: true }
|
||||
* convertCustomClass({ foo: true, bar: false }) // { foo: true, bar: false }
|
||||
* ```
|
||||
*
|
||||
* @param sourceClass 原始 Vue class 值,支持字符串、数组或对象
|
||||
* @returns Vue class 对象
|
||||
* @throws 若 sourceClass 不是有效的 Vue class 则抛出 TypeError
|
||||
*/
|
||||
export function convertCustomClass(sourceClass: CustomClass): CustomClassObj {
|
||||
let customClassObj = <CustomClassObj>{};
|
||||
if (typeof sourceClass === "string") {
|
||||
const customClassStr = sourceClass.trim().replace(" +", " ");
|
||||
if (customClassStr.length > 0) {
|
||||
const customClassEntries = customClassStr
|
||||
.split(" ")
|
||||
.map((name) => [name, true]);
|
||||
customClassObj = Object.fromEntries(customClassEntries);
|
||||
}
|
||||
} else if (Array.isArray(sourceClass)) {
|
||||
if (sourceClass.length > 0) {
|
||||
const customClassEntries = sourceClass.map((name) => [name, true]);
|
||||
customClassObj = Object.fromEntries(customClassEntries);
|
||||
}
|
||||
} else if (sourceClass instanceof Object) {
|
||||
customClassObj = sourceClass;
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`sourceClass不是有效的vue class,sourceClass: ${sourceClass}`,
|
||||
);
|
||||
}
|
||||
|
||||
return customClassObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个 Vue class 值为一个对象
|
||||
*
|
||||
* 将多个 Vue class(字符串、数组或对象)统一转换为对象后合并,
|
||||
* 后面的 class 值会覆盖前面的同名属性。
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* mergeClass('foo', ['bar'], { baz: true })
|
||||
* // { foo: true, bar: true, baz: true }
|
||||
* ```
|
||||
*
|
||||
* @param customClass 一个或多个 Vue class 值
|
||||
* @returns 合并后的 Vue class 对象
|
||||
*/
|
||||
export function mergeClass(...customClass: CustomClass[]) {
|
||||
return customClass
|
||||
.map((cc) => convertCustomClass(cc))
|
||||
.reduce((a, b) => Object.assign(a, b), {});
|
||||
}
|
||||
|
|
@ -9,122 +9,8 @@
|
|||
|
||||
import { ComponentPublicInstance } from "vue";
|
||||
|
||||
/** Vue class 对象格式,键为类名,值为布尔值表示是否生效 */
|
||||
type CustomClassObj = Record<string, boolean>;
|
||||
/** Vue class 支持的类型:字符串、字符串数组或对象 */
|
||||
type CustomClass = string | Array<string> | CustomClassObj;
|
||||
/** 分解后的 Vue class 对象,所有类名值为 true */
|
||||
type DistCustomClass = Record<string, true>;
|
||||
|
||||
/**
|
||||
* 将字符串或字符串数组转换为 Vue class 对象
|
||||
*
|
||||
* 将类名字符串或数组转换为 `{ className: true }` 格式的对象,
|
||||
* 适用于 Vue 的 class 绑定。
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* createCustomClassObj('foo bar') // { foo: true, bar: true }
|
||||
* createCustomClassObj(['foo', 'bar']) // { foo: true, bar: true }
|
||||
* ```
|
||||
*
|
||||
* @param customClass 类名字符串或字符串数组
|
||||
* @returns 类名对象,所有值为 true
|
||||
* @throws 若 customClass 不是字符串或数组则抛出 TypeError
|
||||
*/
|
||||
export function createCustomClassObj(customClass: string): DistCustomClass;
|
||||
export function createCustomClassObj(
|
||||
customClass: Array<string>,
|
||||
): DistCustomClass;
|
||||
export function createCustomClassObj(
|
||||
customClass: string | Array<string>,
|
||||
): DistCustomClass {
|
||||
let customClassObj = <DistCustomClass>{};
|
||||
if (typeof customClass === "string") {
|
||||
const customClassStr = customClass.trim().replace(" +", " ");
|
||||
if (customClassStr.length > 0) {
|
||||
const customClassEntries = customClassStr
|
||||
.split(" ")
|
||||
.map((name) => [name, true]);
|
||||
customClassObj = Object.fromEntries(customClassEntries);
|
||||
}
|
||||
} else if (Array.isArray(customClass)) {
|
||||
if (customClass.length > 0) {
|
||||
const customClassEntries = customClass.map((name) => [name, true]);
|
||||
customClassObj = Object.fromEntries(customClassEntries);
|
||||
}
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`customClass只能是字符串或数组类型,customClass: ${customClass}`,
|
||||
);
|
||||
}
|
||||
|
||||
return customClassObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换 Vue class 为 Vue class 对象
|
||||
*
|
||||
* 支持字符串、数组和对象三种 Vue class 格式,统一转换为
|
||||
* `{ className: boolean }` 格式的对象。
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* convertCustomClass('foo bar') // { foo: true, bar: true }
|
||||
* convertCustomClass(['foo', 'bar']) // { foo: true, bar: true }
|
||||
* convertCustomClass({ foo: true, bar: false }) // { foo: true, bar: false }
|
||||
* ```
|
||||
*
|
||||
* @param sourceClass 原始 Vue class 值,支持字符串、数组或对象
|
||||
* @returns Vue class 对象
|
||||
* @throws 若 sourceClass 不是有效的 Vue class 则抛出 TypeError
|
||||
*/
|
||||
export function convertCustomClass(sourceClass: CustomClass): CustomClassObj {
|
||||
let customClassObj = <CustomClassObj>{};
|
||||
if (typeof sourceClass === "string") {
|
||||
const customClassStr = sourceClass.trim().replace(" +", " ");
|
||||
if (customClassStr.length > 0) {
|
||||
const customClassEntries = customClassStr
|
||||
.split(" ")
|
||||
.map((name) => [name, true]);
|
||||
customClassObj = Object.fromEntries(customClassEntries);
|
||||
}
|
||||
} else if (Array.isArray(sourceClass)) {
|
||||
if (sourceClass.length > 0) {
|
||||
const customClassEntries = sourceClass.map((name) => [name, true]);
|
||||
customClassObj = Object.fromEntries(customClassEntries);
|
||||
}
|
||||
} else if (sourceClass instanceof Object) {
|
||||
customClassObj = sourceClass;
|
||||
} else {
|
||||
throw new TypeError(
|
||||
`sourceClass不是有效的vue class,sourceClass: ${sourceClass}`,
|
||||
);
|
||||
}
|
||||
|
||||
return customClassObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个 Vue class 值为一个对象
|
||||
*
|
||||
* 将多个 Vue class(字符串、数组或对象)统一转换为对象后合并,
|
||||
* 后面的 class 值会覆盖前面的同名属性。
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* mergeClass('foo', ['bar'], { baz: true })
|
||||
* // { foo: true, bar: true, baz: true }
|
||||
* ```
|
||||
*
|
||||
* @param customClass 一个或多个 Vue class 值
|
||||
* @returns 合并后的 Vue class 对象
|
||||
*/
|
||||
export function mergeClass(...customClass: CustomClass[]) {
|
||||
return customClass
|
||||
.map((cc) => convertCustomClass(cc))
|
||||
.reduce((a, b) => Object.assign(a, b), {});
|
||||
}
|
||||
export * from "./class-helper";
|
||||
export * from "./style-helper";
|
||||
|
||||
/**
|
||||
* 向上遍历祖先组件,找到指定名称的组件并触发其事件
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import type { CSSProperties, StyleValue } from "vue";
|
||||
|
||||
/**
|
||||
* 将合法的 Vue 动态 style 值统一转换为 CSSProperties 对象,
|
||||
* 并可选择性合并覆盖样式。
|
||||
*
|
||||
* - value:要转换的 style 值(string / CSSProperties / Array<StyleValue>)
|
||||
* - overrides:可选,合并时优先级更高的 style 值
|
||||
*
|
||||
* 合并规则:
|
||||
* 1. 两个 style 分别 normalize 后,以 overrides 优先
|
||||
* 2. 如果 overrides 中某属性值为空(null / undefined / "" / false),则使用 value 的值
|
||||
* 3. 如果两者都为空,则过滤掉该属性
|
||||
*
|
||||
* 逻辑与 Vue 内部 normalizeStyle 一致,可直接用于组件 props 的 customStyle 转换。
|
||||
*/
|
||||
export function normalizeStyle(
|
||||
value: StyleValue,
|
||||
overrides?: StyleValue,
|
||||
): CSSProperties {
|
||||
const base = toCSSProperties(value);
|
||||
|
||||
if (overrides == null) return base;
|
||||
|
||||
const over = toCSSProperties(overrides);
|
||||
const result: Record<string, string | number> = {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(base as Record<string, unknown>),
|
||||
...Object.keys(over),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const overVal = (over as Record<string, unknown>)[key];
|
||||
const baseVal = (base as Record<string, unknown>)[key];
|
||||
|
||||
if (!isEmptyStyleValue(overVal)) {
|
||||
// overrides 有值,优先使用
|
||||
result[key] = overVal as string | number;
|
||||
} else if (!isEmptyStyleValue(baseVal)) {
|
||||
// overrides 为空,base 有值,使用 base
|
||||
result[key] = baseVal as string | number;
|
||||
}
|
||||
// 两者都为空,跳过
|
||||
}
|
||||
|
||||
return result as CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断样式属性值是否为空(null / undefined / "" / false)。
|
||||
*/
|
||||
function isEmptyStyleValue(val: unknown): boolean {
|
||||
return val == null || val === "" || val === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 StyleValue 转为 CSSProperties,纯转换不做合并。
|
||||
*/
|
||||
function toCSSProperties(value: StyleValue): CSSProperties {
|
||||
if (Array.isArray(value)) {
|
||||
const res: CSSProperties = {};
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const item = value[i];
|
||||
const normalized =
|
||||
typeof item === "string"
|
||||
? parseStringStyle(item)
|
||||
: toCSSProperties(item as StyleValue);
|
||||
if (normalized) {
|
||||
const target = res as Record<string, string | number>;
|
||||
const source = normalized as Record<string, string | number>;
|
||||
for (const key in source) {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return parseStringStyle(value);
|
||||
}
|
||||
|
||||
if (value != null && typeof value === "object") {
|
||||
return value as CSSProperties;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/** 分号分隔正则,排除括号内的分号 */
|
||||
const listDelimiterRE = /;(?![^(]*\))/;
|
||||
/** 属性值分隔正则 */
|
||||
const propertyDelimiterRE = /:([\s\S]+)/;
|
||||
/** CSS 注释正则 */
|
||||
const styleCommentRE = /\/\*[\s\S]*?\*\//g;
|
||||
|
||||
/**
|
||||
* 将 CSS 字符串解析为 CSSProperties 对象。
|
||||
* 逻辑与 Vue 内部 parseStringStyle 一致。
|
||||
*/
|
||||
function parseStringStyle(cssText: string): CSSProperties {
|
||||
const ret: Record<string, string> = {};
|
||||
cssText
|
||||
.replace(styleCommentRE, "")
|
||||
.split(listDelimiterRE)
|
||||
.forEach((item) => {
|
||||
if (item) {
|
||||
const tmp = item.split(propertyDelimiterRE);
|
||||
if (tmp.length > 1) {
|
||||
ret[tmp[0].trim()] = tmp[1].trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
return ret as CSSProperties;
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { dispatch } from "../src/vue-helper";
|
||||
|
||||
/**
|
||||
* 创建模拟的 Vue ComponentPublicInstance
|
||||
*/
|
||||
function createMockInstance(
|
||||
componentName?: string,
|
||||
parent?: any,
|
||||
root?: any,
|
||||
) {
|
||||
const instance = {
|
||||
$parent: parent ?? null,
|
||||
$root: root ?? null,
|
||||
$options: { componentName: componentName ?? null },
|
||||
$emit: vi.fn(),
|
||||
};
|
||||
return instance;
|
||||
}
|
||||
|
||||
describe("dispatch", () => {
|
||||
it("应在祖先组件中找到匹配的 componentName 并触发事件", () => {
|
||||
const targetComponentName = "FormComponent";
|
||||
const grandParent = createMockInstance(targetComponentName);
|
||||
const parent = createMockInstance(undefined, grandParent, grandParent);
|
||||
const thisArg = createMockInstance(undefined, parent, grandParent);
|
||||
|
||||
// thisArg.$parent → parent, parent.$parent → grandParent
|
||||
parent.$parent = grandParent;
|
||||
|
||||
dispatch(thisArg, targetComponentName as any, "validate", { field: "name" });
|
||||
|
||||
expect(grandParent.$emit).toHaveBeenCalledWith("validate", { field: "name" });
|
||||
});
|
||||
|
||||
it("应从直接父组件开始查找", () => {
|
||||
const targetComponentName = "DirectParent";
|
||||
const parent = createMockInstance(targetComponentName);
|
||||
const thisArg = createMockInstance(undefined, parent, parent);
|
||||
|
||||
dispatch(thisArg, targetComponentName as any, "change", null);
|
||||
|
||||
expect(parent.$emit).toHaveBeenCalledWith("change", null);
|
||||
});
|
||||
|
||||
it("当没有父组件且有根组件时,应从根组件开始查找", () => {
|
||||
const targetComponentName = "RootComponent";
|
||||
const root = createMockInstance(targetComponentName);
|
||||
const thisArg = createMockInstance(undefined, null, root);
|
||||
|
||||
dispatch(thisArg, targetComponentName as any, "init", undefined);
|
||||
|
||||
expect(root.$emit).toHaveBeenCalledWith("init", undefined);
|
||||
});
|
||||
|
||||
it("当 thisArg.$parent 和 thisArg.$root 都为 null 时,应直接返回", () => {
|
||||
const thisArg = createMockInstance(undefined, null, null);
|
||||
const targetComponentName = "FormComponent" as any;
|
||||
|
||||
// 不应抛出错误
|
||||
expect(() => dispatch(thisArg, targetComponentName, "validate", {})).not.toThrow();
|
||||
});
|
||||
|
||||
it("当找不到匹配的祖先组件时,不应触发任何事件", () => {
|
||||
const grandParent = createMockInstance("OtherComponent");
|
||||
const parent = createMockInstance("AnotherComponent", grandParent, grandParent);
|
||||
const thisArg = createMockInstance(undefined, parent, grandParent);
|
||||
|
||||
dispatch(thisArg, "NonExistentComponent" as any, "validate", {});
|
||||
|
||||
expect(grandParent.$emit).not.toHaveBeenCalled();
|
||||
expect(parent.$emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("应在多级父组件链中找到匹配的祖先", () => {
|
||||
const targetComponentName = "GrandGrandParent";
|
||||
const grandGrandParent = createMockInstance(targetComponentName);
|
||||
const grandParent = createMockInstance("GrandParent", grandGrandParent, grandGrandParent);
|
||||
const parent = createMockInstance("Parent", grandParent, grandGrandParent);
|
||||
const thisArg = createMockInstance(undefined, parent, grandGrandParent);
|
||||
|
||||
dispatch(thisArg, targetComponentName as any, "submit", { data: "test" });
|
||||
|
||||
expect(grandGrandParent.$emit).toHaveBeenCalledWith("submit", { data: "test" });
|
||||
expect(grandParent.$emit).not.toHaveBeenCalled();
|
||||
expect(parent.$emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("找到的第一个匹配组件后,不应继续向上查找", () => {
|
||||
const targetComponentName = "Parent";
|
||||
const grandParent = createMockInstance(targetComponentName); // 更上层也有同名组件
|
||||
const parent = createMockInstance(targetComponentName, grandParent, grandParent);
|
||||
const thisArg = createMockInstance(undefined, parent, grandParent);
|
||||
|
||||
dispatch(thisArg, targetComponentName as any, "click", true);
|
||||
|
||||
// 应该触发 parent 的事件,而非 grandParent 的
|
||||
expect(parent.$emit).toHaveBeenCalledWith("click", true);
|
||||
expect(grandParent.$emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("应支持传递各种类型的参数", () => {
|
||||
const parent = createMockInstance("Target");
|
||||
const thisArg = createMockInstance(undefined, parent, parent);
|
||||
|
||||
// 字符串
|
||||
dispatch(thisArg, "Target" as any, "event1", "hello");
|
||||
expect(parent.$emit).toHaveBeenCalledWith("event1", "hello");
|
||||
|
||||
// 数字
|
||||
dispatch(thisArg, "Target" as any, "event2", 42);
|
||||
expect(parent.$emit).toHaveBeenCalledWith("event2", 42);
|
||||
|
||||
// 数组
|
||||
dispatch(thisArg, "Target" as any, "event3", [1, 2, 3]);
|
||||
expect(parent.$emit).toHaveBeenCalledWith("event3", [1, 2, 3]);
|
||||
|
||||
// undefined
|
||||
dispatch(thisArg, "Target" as any, "event4", undefined);
|
||||
expect(parent.$emit).toHaveBeenCalledWith("event4", undefined);
|
||||
});
|
||||
|
||||
it("当祖先组件的 componentName 为 undefined 时,应跳过继续查找", () => {
|
||||
const targetComponentName = "TargetComponent";
|
||||
const grandParent = createMockInstance(targetComponentName);
|
||||
// parent 没有 componentName
|
||||
const parent = createMockInstance(undefined, grandParent, grandParent);
|
||||
const thisArg = createMockInstance(undefined, parent, grandParent);
|
||||
|
||||
dispatch(thisArg, targetComponentName as any, "save", {});
|
||||
|
||||
expect(grandParent.$emit).toHaveBeenCalledWith("save", {});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeStyle } from "../src/vue-helper/style-helper";
|
||||
|
||||
describe("normalizeStyle", () => {
|
||||
// ========== 单参数:纯转换 ==========
|
||||
|
||||
// ---------- 空值 / falsy ----------
|
||||
it("应将 undefined 转为空对象", () => {
|
||||
expect(normalizeStyle(undefined as any)).toEqual({});
|
||||
});
|
||||
|
||||
it("应将 null 转为空对象", () => {
|
||||
expect(normalizeStyle(null as any)).toEqual({});
|
||||
});
|
||||
|
||||
it("应将 false 转为空对象", () => {
|
||||
expect(normalizeStyle(false as any)).toEqual({});
|
||||
});
|
||||
|
||||
// ---------- CSSProperties 对象 ----------
|
||||
it("应直接返回 CSSProperties 对象", () => {
|
||||
const style = { color: "red", fontSize: "14px" };
|
||||
expect(normalizeStyle(style)).toEqual(style);
|
||||
});
|
||||
|
||||
it("应保留 CSSProperties 中的数字值", () => {
|
||||
const style = { zIndex: 10, opacity: 0.5 };
|
||||
expect(normalizeStyle(style)).toEqual({ zIndex: 10, opacity: 0.5 });
|
||||
});
|
||||
|
||||
it("应保留 CSS 变量", () => {
|
||||
const style = { "--custom-color": "red" } as any;
|
||||
expect(normalizeStyle(style)).toEqual({ "--custom-color": "red" });
|
||||
});
|
||||
|
||||
it("空对象应返回空对象", () => {
|
||||
expect(normalizeStyle({})).toEqual({});
|
||||
});
|
||||
|
||||
// ---------- 字符串 ----------
|
||||
it("应解析简单的 CSS 字符串", () => {
|
||||
expect(normalizeStyle("color: red")).toEqual({ color: "red" });
|
||||
});
|
||||
|
||||
it("应解析多条声明的 CSS 字符串", () => {
|
||||
expect(normalizeStyle("color: red; font-size: 14px")).toEqual({
|
||||
color: "red",
|
||||
"font-size": "14px",
|
||||
});
|
||||
});
|
||||
|
||||
it("应解析带空格的 CSS 字符串", () => {
|
||||
expect(normalizeStyle(" color : red ; font-size : 14px ")).toEqual({
|
||||
color: "red",
|
||||
"font-size": "14px",
|
||||
});
|
||||
});
|
||||
|
||||
it("应忽略空声明", () => {
|
||||
expect(normalizeStyle("color: red;; font-size:14px;")).toEqual({
|
||||
color: "red",
|
||||
"font-size": "14px",
|
||||
});
|
||||
});
|
||||
|
||||
it("应忽略尾部分号后的空项", () => {
|
||||
expect(normalizeStyle("color: red;")).toEqual({ color: "red" });
|
||||
});
|
||||
|
||||
it("应去除 CSS 注释", () => {
|
||||
expect(normalizeStyle("color: red; /* 注释 */ font-size: 14px")).toEqual({
|
||||
color: "red",
|
||||
"font-size": "14px",
|
||||
});
|
||||
});
|
||||
|
||||
it("应解析含 url() 的值(不拆分括号内的分号)", () => {
|
||||
expect(normalizeStyle("background: url(data:image/png;base64,abc)")).toEqual({
|
||||
background: "url(data:image/png;base64,abc)",
|
||||
});
|
||||
});
|
||||
|
||||
it("应解析含冒号的值(如 url 中的协议)", () => {
|
||||
expect(normalizeStyle("background: url(http://example.com/img.png)")).toEqual({
|
||||
background: "url(http://example.com/img.png)",
|
||||
});
|
||||
});
|
||||
|
||||
it("空字符串应返回空对象", () => {
|
||||
expect(normalizeStyle("")).toEqual({});
|
||||
});
|
||||
|
||||
// ---------- 数组 ----------
|
||||
it("应合并对象数组", () => {
|
||||
expect(normalizeStyle([{ color: "red" }, { fontSize: "14px" }])).toEqual({
|
||||
color: "red",
|
||||
fontSize: "14px",
|
||||
});
|
||||
});
|
||||
|
||||
it("应合并字符串数组", () => {
|
||||
expect(normalizeStyle(["color: red", "font-size: 14px"])).toEqual({
|
||||
color: "red",
|
||||
"font-size": "14px",
|
||||
});
|
||||
});
|
||||
|
||||
it("应合并混合类型数组", () => {
|
||||
expect(normalizeStyle(["color: red", { fontSize: "14px" }])).toEqual({
|
||||
color: "red",
|
||||
fontSize: "14px",
|
||||
});
|
||||
});
|
||||
|
||||
it("应处理嵌套数组", () => {
|
||||
expect(normalizeStyle([["color: red", { fontSize: "14px" }], { fontWeight: "bold" }])).toEqual({
|
||||
color: "red",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
});
|
||||
});
|
||||
|
||||
it("应跳过数组中的 falsy 值", () => {
|
||||
expect(normalizeStyle([null, false, undefined, { color: "red" }] as any)).toEqual({
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
|
||||
it("数组中后面的属性应覆盖前面的", () => {
|
||||
expect(normalizeStyle([{ color: "red" }, { color: "blue" }])).toEqual({
|
||||
color: "blue",
|
||||
});
|
||||
});
|
||||
|
||||
it("空数组应返回空对象", () => {
|
||||
expect(normalizeStyle([])).toEqual({});
|
||||
});
|
||||
|
||||
// ---------- 实际使用场景 ----------
|
||||
it("应处理 AppButton 典型的 customStyle 用法", () => {
|
||||
// 字符串形式(旧用法兼容)
|
||||
expect(normalizeStyle("color: red; font-size: 28rpx")).toEqual({
|
||||
color: "red",
|
||||
"font-size": "28rpx",
|
||||
});
|
||||
// 对象形式(推荐用法)
|
||||
expect(normalizeStyle({ color: "red", fontSize: "28rpx" })).toEqual({
|
||||
color: "red",
|
||||
fontSize: "28rpx",
|
||||
});
|
||||
// 数组形式
|
||||
expect(normalizeStyle([{ color: "red" }, { fontSize: "28rpx" }])).toEqual({
|
||||
color: "red",
|
||||
fontSize: "28rpx",
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 双参数:合并覆盖 ==========
|
||||
|
||||
describe("合并覆盖(第二个参数)", () => {
|
||||
it("不传第二个参数时,行为与单参数一致", () => {
|
||||
expect(normalizeStyle({ color: "red" })).toEqual({ color: "red" });
|
||||
});
|
||||
|
||||
it("第二个参数为 undefined 时,行为与单参数一致", () => {
|
||||
expect(normalizeStyle({ color: "red" }, undefined)).toEqual({ color: "red" });
|
||||
});
|
||||
|
||||
it("第二个参数为 null 时,行为与单参数一致", () => {
|
||||
expect(normalizeStyle({ color: "red" }, null as any)).toEqual({ color: "red" });
|
||||
});
|
||||
|
||||
it("overrides 有值的属性应覆盖 base", () => {
|
||||
expect(normalizeStyle({ color: "red" }, { color: "blue" })).toEqual({ color: "blue" });
|
||||
});
|
||||
|
||||
it("overrides 新增的属性应合并进来", () => {
|
||||
expect(normalizeStyle({ color: "red" }, { fontSize: "14px" })).toEqual({
|
||||
color: "red",
|
||||
fontSize: "14px",
|
||||
});
|
||||
});
|
||||
|
||||
it("overrides 和 base 各有不同的属性时,应全部合并", () => {
|
||||
expect(
|
||||
normalizeStyle({ color: "red", padding: "8px" }, { fontSize: "14px", margin: "4px" }),
|
||||
).toEqual({
|
||||
color: "red",
|
||||
padding: "8px",
|
||||
fontSize: "14px",
|
||||
margin: "4px",
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- overrides 为空值时 fallback 到 base ----------
|
||||
it("overrides 属性为 undefined 时应使用 base 的值", () => {
|
||||
expect(normalizeStyle({ color: "red" }, { color: undefined } as any)).toEqual({
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
|
||||
it("overrides 属性为 null 时应使用 base 的值", () => {
|
||||
expect(normalizeStyle({ color: "red" }, { color: null } as any)).toEqual({ color: "red" });
|
||||
});
|
||||
|
||||
it("overrides 属性为空字符串时应使用 base 的值", () => {
|
||||
expect(normalizeStyle({ color: "red" }, { color: "" })).toEqual({ color: "red" });
|
||||
});
|
||||
|
||||
it("overrides 属性为 false 时应使用 base 的值", () => {
|
||||
expect(normalizeStyle({ color: "red" }, { color: false } as any)).toEqual({ color: "red" });
|
||||
});
|
||||
|
||||
// ---------- 两者都为空时过滤属性 ----------
|
||||
it("base 和 overrides 都为空时,应过滤掉该属性", () => {
|
||||
expect(normalizeStyle({ color: "" }, { color: undefined } as any)).toEqual({});
|
||||
});
|
||||
|
||||
it("base 和 overrides 都为 null 时,应过滤掉该属性", () => {
|
||||
expect(normalizeStyle({ color: null } as any, { color: null } as any)).toEqual({});
|
||||
});
|
||||
|
||||
it("base 有值但属性为空、overrides 未指定该属性时,应过滤掉", () => {
|
||||
// base 有 color:"" (空值), overrides 没有 color → both empty → filtered
|
||||
expect(normalizeStyle({ color: "" }, {})).toEqual({});
|
||||
});
|
||||
|
||||
// ---------- 字符串形式的 overrides ----------
|
||||
it("overrides 为字符串时应正常解析并合并", () => {
|
||||
expect(normalizeStyle({ color: "red" }, "font-size: 14px")).toEqual({
|
||||
color: "red",
|
||||
"font-size": "14px",
|
||||
});
|
||||
});
|
||||
|
||||
it("base 为字符串、overrides 为对象时应正常合并", () => {
|
||||
// 字符串解析出 kebab-case 键,对象用 camelCase 键,两者独立
|
||||
expect(normalizeStyle("color: red", { fontSize: "14px" })).toEqual({
|
||||
color: "red",
|
||||
fontSize: "14px",
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 数组形式的 overrides ----------
|
||||
it("overrides 为数组时应正常解析并合并", () => {
|
||||
expect(normalizeStyle({ color: "red" }, [{ fontSize: "14px" }])).toEqual({
|
||||
color: "red",
|
||||
fontSize: "14px",
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 0 是有效值,不算空 ----------
|
||||
it("数字 0 应视为有效值,不当作空值", () => {
|
||||
expect(normalizeStyle({ opacity: 1 }, { opacity: 0 })).toEqual({ opacity: 0 });
|
||||
});
|
||||
|
||||
it("overrides 为 0 时不应 fallback 到 base", () => {
|
||||
expect(normalizeStyle({ zIndex: 10 }, { zIndex: 0 })).toEqual({ zIndex: 0 });
|
||||
});
|
||||
|
||||
// ---------- 综合场景 ----------
|
||||
it("应正确处理混合覆盖和 fallback", () => {
|
||||
const base = { color: "red", fontSize: "14px", padding: "8px", margin: "" };
|
||||
const overrides = { color: "blue", fontSize: undefined, padding: null, margin: "4px" } as any;
|
||||
expect(normalizeStyle(base, overrides)).toEqual({
|
||||
color: "blue", // overrides 有值,覆盖
|
||||
fontSize: "14px", // overrides 为 undefined,fallback 到 base
|
||||
padding: "8px", // overrides 为 null,fallback 到 base
|
||||
margin: "4px", // base 为空,overrides 有值,使用 overrides
|
||||
});
|
||||
});
|
||||
|
||||
it("appButton 场景:customStyle 覆盖内部样式", () => {
|
||||
// 模拟 AppButton 内部样式 + customStyle
|
||||
const internal = { width: "200rpx", backgroundColor: "#3adceb", color: "#111" };
|
||||
const custom = { width: "128rpx", backgroundColor: undefined, color: "" } as any;
|
||||
expect(normalizeStyle(internal, custom)).toEqual({
|
||||
width: "128rpx", // overrides 有值,覆盖
|
||||
backgroundColor: "#3adceb", // overrides 为 undefined,fallback
|
||||
color: "#111", // overrides 为空字符串,fallback
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createCustomClassObj,
|
||||
convertCustomClass,
|
||||
mergeClass,
|
||||
} from "../src/vue-helper";
|
||||
|
||||
// ========== createCustomClassObj ==========
|
||||
|
||||
describe("createCustomClassObj", () => {
|
||||
// ---------- 字符串输入 ----------
|
||||
it("应将单个类名字符串转为对象", () => {
|
||||
expect(createCustomClassObj("foo")).toEqual({ foo: true });
|
||||
});
|
||||
|
||||
it("应将空格分隔的类名字符串转为对象", () => {
|
||||
expect(createCustomClassObj("foo bar")).toEqual({ foo: true, bar: true });
|
||||
});
|
||||
|
||||
it("应处理前后有空格的字符串", () => {
|
||||
// trim 后 "foo bar",replace(" +"," ") 是字符串匹配不会替换连续空格
|
||||
// split(" ") 产生 ["foo", "", "bar"],空字符串作为键
|
||||
expect(createCustomClassObj(" foo bar ")).toEqual({ foo: true, "": true, bar: true });
|
||||
});
|
||||
|
||||
it("应处理多个连续空格", () => {
|
||||
// replace(" +", " ") 是字符串匹配而非正则,不会替换连续空格
|
||||
// split(" ") 产生 ["foo", "", "", "", "bar"],重复的空字符串键只保留最后一个
|
||||
expect(createCustomClassObj("foo bar")).toEqual({ foo: true, "": true, bar: true });
|
||||
});
|
||||
|
||||
it("空字符串应返回空对象", () => {
|
||||
expect(createCustomClassObj("")).toEqual({});
|
||||
});
|
||||
|
||||
it("仅空格的字符串应返回空对象", () => {
|
||||
expect(createCustomClassObj(" ")).toEqual({});
|
||||
});
|
||||
|
||||
// ---------- 数组输入 ----------
|
||||
it("应将类名数组转为对象", () => {
|
||||
expect(createCustomClassObj(["foo", "bar"])).toEqual({ foo: true, bar: true });
|
||||
});
|
||||
|
||||
it("应将单个元素的数组转为对象", () => {
|
||||
expect(createCustomClassObj(["foo"])).toEqual({ foo: true });
|
||||
});
|
||||
|
||||
it("空数组应返回空对象", () => {
|
||||
expect(createCustomClassObj([])).toEqual({});
|
||||
});
|
||||
|
||||
// ---------- 异常输入 ----------
|
||||
it("传入对象应抛出 TypeError", () => {
|
||||
expect(() => createCustomClassObj({} as any)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("传入数字应抛出 TypeError", () => {
|
||||
expect(() => createCustomClassObj(123 as any)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("传入 null 应抛出 TypeError", () => {
|
||||
expect(() => createCustomClassObj(null as any)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("传入 undefined 应抛出 TypeError", () => {
|
||||
expect(() => createCustomClassObj(undefined as any)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== convertCustomClass ==========
|
||||
|
||||
describe("convertCustomClass", () => {
|
||||
// ---------- 字符串输入 ----------
|
||||
it("应将字符串转为类名对象", () => {
|
||||
expect(convertCustomClass("foo bar")).toEqual({ foo: true, bar: true });
|
||||
});
|
||||
|
||||
it("应将单个类名字符串转为对象", () => {
|
||||
expect(convertCustomClass("foo")).toEqual({ foo: true });
|
||||
});
|
||||
|
||||
it("空字符串应返回空对象", () => {
|
||||
expect(convertCustomClass("")).toEqual({});
|
||||
});
|
||||
|
||||
// ---------- 数组输入 ----------
|
||||
it("应将类名数组转为对象", () => {
|
||||
expect(convertCustomClass(["foo", "bar"])).toEqual({ foo: true, bar: true });
|
||||
});
|
||||
|
||||
it("空数组应返回空对象", () => {
|
||||
expect(convertCustomClass([])).toEqual({});
|
||||
});
|
||||
|
||||
// ---------- 对象输入 ----------
|
||||
it("应直接返回对象", () => {
|
||||
const obj = { foo: true, bar: false };
|
||||
expect(convertCustomClass(obj)).toEqual(obj);
|
||||
});
|
||||
|
||||
it("应保留对象中值为 false 的属性", () => {
|
||||
const obj = { foo: true, bar: false };
|
||||
const result = convertCustomClass(obj);
|
||||
expect(result).toHaveProperty("bar", false);
|
||||
});
|
||||
|
||||
// ---------- 异常输入 ----------
|
||||
it("传入数字应抛出 TypeError", () => {
|
||||
expect(() => convertCustomClass(123 as any)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("传入 null 应抛出 TypeError", () => {
|
||||
expect(() => convertCustomClass(null as any)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("传入 undefined 应抛出 TypeError", () => {
|
||||
expect(() => convertCustomClass(undefined as any)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("传入布尔值应抛出 TypeError", () => {
|
||||
expect(() => convertCustomClass(true as any)).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== mergeClass ==========
|
||||
|
||||
describe("mergeClass", () => {
|
||||
it("应合并多个字符串类名", () => {
|
||||
expect(mergeClass("foo", "bar")).toEqual({ foo: true, bar: true });
|
||||
});
|
||||
|
||||
it("应合并字符串和数组", () => {
|
||||
expect(mergeClass("foo", ["bar"])).toEqual({ foo: true, bar: true });
|
||||
});
|
||||
|
||||
it("应合并字符串、数组和对象", () => {
|
||||
expect(mergeClass("foo", ["bar"], { baz: true })).toEqual({
|
||||
foo: true,
|
||||
bar: true,
|
||||
baz: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("后面的值应覆盖前面的同名属性", () => {
|
||||
expect(mergeClass({ foo: true }, { foo: false })).toEqual({ foo: false });
|
||||
});
|
||||
|
||||
it("字符串形式的类名应覆盖前面的", () => {
|
||||
// "foo" → { foo: true },后者覆盖前者
|
||||
expect(mergeClass("foo", "foo")).toEqual({ foo: true });
|
||||
});
|
||||
|
||||
it("单个参数应正常工作", () => {
|
||||
expect(mergeClass("foo")).toEqual({ foo: true });
|
||||
});
|
||||
|
||||
it("应处理空字符串参数", () => {
|
||||
expect(mergeClass("")).toEqual({});
|
||||
});
|
||||
|
||||
it("应处理空数组参数", () => {
|
||||
expect(mergeClass([])).toEqual({});
|
||||
});
|
||||
|
||||
it("应处理空对象参数", () => {
|
||||
expect(mergeClass({})).toEqual({});
|
||||
});
|
||||
|
||||
it("应合并多个对象类名", () => {
|
||||
expect(
|
||||
mergeClass({ color: true }, { size: true }, { weight: true }),
|
||||
).toEqual({ color: true, size: true, weight: true });
|
||||
});
|
||||
|
||||
it("实际使用场景:Vue 组件中合并 class", () => {
|
||||
// 模拟组件内部 class + 传入 class
|
||||
const internal = "btn primary";
|
||||
const custom = { active: true, disabled: false };
|
||||
expect(mergeClass(internal, custom)).toEqual({
|
||||
btn: true,
|
||||
primary: true,
|
||||
active: true,
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue