/** * @description 表示以空格分隔的 CSS 类名字串。 */ export type ClassString = string /** * @description 表示按顺序保存的 CSS 类名数组。 */ export type ClassArray = string[] /** * @description 表示以类名为键、以启用状态为值的 CSS 类名对象。 * * 该表示允许保留值为 `false` 的键,以表达已知但当前未启用的类名状态。 */ export type ClassObject = Record /** * @description 表示 CSS 类名的三种公共表达形式:字符串、数组或对象。 */ export type ClassUnion = ClassString | ClassArray | ClassObject /** * @description 规范化类名字串,使其适合作为统一的字符串表示。 * * 该函数会将 `.` 视为分隔符,将连续空白折叠为一个空格,并移除首尾空白。 * * @example * ``` * // Expect: 'mobius-base mobius-theme--light' * const example1 = neatenClassString('mobius-base mobius-theme--light') * * // Expect: 'mobius-base mobius-theme--light' * const example2 = neatenClassString('.mobius-base.mobius-theme--light') * * // Expect: 'mobius-base mobius-theme--light' * const example3 = neatenClassString(' .mobius-base mobius-theme--light ') * ``` */ export const neatenClassString = (str: string): ClassString => { const classString = str.replaceAll(".", " ").replaceAll(/\s+/g, " ").trim() return classString } /** * @description 将类名字串转换为类名数组。 * * 输入会先经过 `neatenClassString` 规范化,再按空格拆分,并移除空项。 * * @example * ``` * // Expect: ['mobius-base', 'mobius-theme--light'] * const example1 = classStringToClassArray('mobius-base mobius-theme--light') * * // Expect: ['mobius-base', 'mobius-theme--light'] * const example2 = classStringToClassArray('.mobius-base.mobius-theme--light') * * // Expect: ['mobius-base', 'mobius-theme--light'] * const example3 = classStringToClassArray(' .mobius-base mobius-theme--light ') * ``` */ export const classStringToClassArray = (str: ClassString): ClassArray => { const classArray = neatenClassString(str) .split(" ") .filter((s) => s.length !== 0) return classArray } /** * @description 将类名数组转换为布尔对象表示。 * * 数组中的每个非空类名都会映射为值为 `true` 的对象键。 * * @example * ``` * // Expect: { 'mobius-base': true, 'mobius-theme--light': true } * const example1 = classArrayToClassObject(['mobius-base', 'mobius-theme--light']) * * // Expect: { 'mobius-base': true } * const example2 = classArrayToClassObject(['mobius-base', '']) * * // Expect: {} * const example3 = classArrayToClassObject([]) * ``` */ export const classArrayToClassObject = (arr: ClassArray): ClassObject => { const classObject: ClassObject = {} arr .filter((s) => s.length !== 0) .forEach((s) => { classObject[s] = true }) return classObject } /** * @description 将类名字串直接转换为布尔对象表示。 * * 该函数会先把字符串拆分为类名数组,再将每个类名映射为值为 `true` 的对象键。 * * @example * ``` * // Expect: { 'mobius-base': true, 'mobius-theme--light': true } * const example1 = classStringToClassObject('mobius-base mobius-theme--light') * * // Expect: { 'mobius-base': true, 'mobius-theme--light': true } * const example2 = classStringToClassObject('.mobius-base.mobius-theme--light') * ``` */ export const classStringToClassObject = (str: ClassString): ClassObject => { const classArray = classStringToClassArray(str) const classObject = classArrayToClassObject(classArray) return classObject } /** * @description 将布尔对象表示转换为类名数组。 * * 值为 `false` 的类名和空类名会被忽略。 * * @example * ``` * // Expect: ['active', 'primary'] * const example1 = classObjectToClassArray({ active: true, disabled: false, primary: true }) * * // Expect: ['active'] * const example2 = classObjectToClassArray({ '': true, active: true, disabled: false }) * ``` */ export const classObjectToClassArray = (obj: ClassObject): ClassArray => { const classArray: string[] = [] Object.entries(obj) .filter(([key, value]) => key.length !== 0 && value) .forEach(([key, _]) => { classArray.push(key) }) return classArray } /** * @description 将类名数组按空格连接为类名字串。 * * 空字符串项会被过滤,以避免产生多余空格。 * * @example * ``` * // Expect: 'mobius-base mobius-theme--light' * const example1 = classArrayToClassString(['mobius-base', 'mobius-theme--light']) * * // Expect: 'mobius-base mobius-theme--light' * const example2 = classArrayToClassString(['mobius-base', '', 'mobius-theme--light']) * ``` */ export const classArrayToClassString = (arr: ClassArray): ClassString => { const classString = arr.filter((s) => s.length !== 0).join(" ") return classString } /** * @description 将布尔对象表示转换为类名字串。 * * 该函数会忽略值为 `false` 的类名和空类名,并保留其余类名的迭代顺序。 * * @example * ``` * // Expect: 'active primary' * const example1 = classObjectToClassString({ active: true, disabled: false, primary: true }) * * // Expect: 'active' * const example2 = classObjectToClassString({ '': true, active: true, disabled: false }) * ``` */ export const classObjectToClassString = (obj: ClassObject): ClassString => { const classArray = classObjectToClassArray(obj) const classString = classArrayToClassString(classArray) return classString } /** * @description 将任意公共类名表示转换为字符串表示。 * * 如果输入本身已经是字符串,则原样返回;数组和对象会分别经过对应的序列化流程。 * * @example * ``` * // Expect: ' .button active ' * const example1 = toClassString(' .button active ') * * // Expect: 'button active' * const example2 = toClassString(['button', 'active']) * * // Expect: 'button active' * const example3 = toClassString({ button: true, active: true, disabled: false }) * ``` */ export const toClassString = (tar: ClassUnion): ClassString => { if (typeof tar === "string") { return tar } else if (Array.isArray(tar)) { return classArrayToClassString(tar) } else { return classObjectToClassString(tar) } } /** * @description 将任意公共类名表示转换为数组表示。 * * 如果输入本身已经是数组,则会在过滤空类名后返回一个浅拷贝,以避免调用方共享同一数组实例。 * * @example * ``` * // Expect: ['button', 'active'] * const example1 = toClassArray('button active') * * // Expect: ['button', 'active'] * const example2 = toClassArray(['button', 'active']) * * // Expect: ['button', 'active'] * const example3 = toClassArray({ button: true, disabled: false, active: true }) * ``` */ export const toClassArray = (tar: ClassUnion): ClassArray => { if (typeof tar === "string") { return classStringToClassArray(tar) } else if (Array.isArray(tar)) { return tar.filter((s) => s.length !== 0) } else { return classObjectToClassArray(tar) } } /** * @description 将任意公共类名表示转换为对象表示。 * * 如果输入本身已经是对象,则会在过滤空类名后返回一个浅拷贝,以避免外部直接共享内部结果。 * * @example * ``` * // Expect: { button: true, active: true } * const example1 = toClassObject('button active') * * // Expect: { button: true, active: true } * const example2 = toClassObject(['button', 'active']) * * // Expect: { button: true, active: false } * const example3 = toClassObject({ button: true, active: false, '': true }) * ``` */ export const toClassObject = (tar: ClassUnion): ClassObject => { if (typeof tar === "string") { return classStringToClassObject(tar) } else if (Array.isArray(tar)) { return classArrayToClassObject(tar) } else { const classObject: ClassObject = {} Object.entries(tar).forEach(([key, value]) => { if (key.length !== 0) { classObject[key] = value } }) return classObject } } /** * @description 按目标值的外部表示,将类名集合格式化为相同形态。 * * 该函数适合在内部统一按对象进行计算后,再把结果还原为调用方原本使用的表示。 * * @example * ``` * // Expect: 'button active' * const example1 = formatClassToTarget('', ['button', 'active']) * * // Expect: ['button', 'active'] * const example2 = formatClassToTarget([], 'button active') * * // Expect: { button: true, active: true } * const example3 = formatClassToTarget({}, ['button', 'active']) * ``` */ export const formatClassToTarget = (target: T, cls: ClassUnion): T => { if (typeof target === "string") { return toClassString(cls) as T } else if (Array.isArray(target)) { return toClassArray(cls) as T } else { return toClassObject(cls) as T } } /** * @description 为类名集合中的每一项补上指定前缀。 * * 已经带有该前缀的类名会保持不变。 * * @example * ``` * // Expect: 'pm-button pm-active' * const example1 = prefixClassWith('pm-', 'button active') * * // Expect: ['pm-button', 'pm-active'] * const example2 = prefixClassWith('pm-', ['button', 'pm-active']) * * // Expect: { 'pm-button': true, 'pm-active': true } * const example3 = prefixClassWith('pm-', { button: true, 'pm-active': true }) * ``` */ export const prefixClassWith = (prefix: string, cls: T): T => { if (typeof cls === "string") { const classArray = classStringToClassArray(cls).map((item) => item.startsWith(prefix) ? item : `${prefix}${item}`, ) const classString = classArrayToClassString(classArray) return classString as T } else if (Array.isArray(cls)) { const classArray = cls .filter((item) => item.length !== 0) .map((item) => (item.startsWith(prefix) ? item : `${prefix}${item}`)) return classArray as T } else { const classObject: ClassObject = {} Object.entries(cls).forEach(([key, value]) => { if (key.length === 0) { return } const _key = key.startsWith(prefix) ? key : `${prefix}${key}` classObject[_key] = value === true }) return classObject as T } } /** * @description 从类名集合中的每一项移除指定前缀。 * * 不带该前缀的类名会保持原样。 * * @example * ``` * // Expect: 'button active plain' * const example1 = removePrefixOfClass('pm-', 'pm-button pm-active plain') * * // Expect: ['button', 'plain'] * const example2 = removePrefixOfClass('pm-', ['pm-button', 'plain']) * * // Expect: { button: true, plain: false } * const example3 = removePrefixOfClass('pm-', { 'pm-button': true, plain: false, 'pm-': true }) * ``` */ export const removePrefixOfClass = (prefix: string, cls: T): T => { if (typeof cls === "string") { const classArray = classStringToClassArray(cls).map((item) => { if (item.startsWith(prefix)) { return item.slice(prefix.length) } return item }) const classString = classArrayToClassString(classArray) return classString as T } else if (Array.isArray(cls)) { const classArray = cls .filter((item) => item.length !== 0) .map((item) => { if (item.startsWith(prefix)) { return item.slice(prefix.length) } return item }) const filteredClassArray = classArray.filter((item) => item.length !== 0) return filteredClassArray as T } else { const classObject: ClassObject = {} Object.entries(cls).forEach(([key, value]) => { if (key.length === 0) { return } const _key = key.startsWith(prefix) ? key.slice(prefix.length) : key if (_key.length !== 0) { classObject[_key] = value === true } }) return classObject as T } } /** * @description 向目标类名集合中加入新的类名。 * * 当目标与新增项都包含同名类时,以新增项转换后的对象表示为准。 * * @example * ``` * // Expect: 'button active primary' * const example1 = addClass('button', 'active primary') * * // Expect: ['button', 'active', 'primary'] * const example2 = addClass(['button'], { active: true, primary: true }) * ``` */ export const addClass = (target: T, added: ClassUnion): T => { const targetClassObj = toClassObject(target) const addedClassObj = toClassObject(added) const resClassObj = { ...targetClassObj, ...addedClassObj } const result = formatClassToTarget(target, resClassObj) return result } /** * @description 从目标类名集合中移除指定类名。 * * 该函数会把待移除项标记为 `false`,再按目标形态输出结果。 * * @example * ``` * // Expect: 'button primary' * const example1 = removeClass('button active primary', 'active') * * // Expect: { button: true, active: false } * const example2 = removeClass({ button: true, active: true }, ['active']) * ``` */ export const removeClass = (target: T, removed: ClassUnion): T => { const targetClassObj = toClassObject(target) const removedClassObj = toClassObject(removed) Object.keys(removedClassObj).forEach((key) => { removedClassObj[key] = false }) const resClassObj = { ...targetClassObj, ...removedClassObj } const result = formatClassToTarget(target, resClassObj) return result } /** * @description 切换目标类名集合中指定类名的启用状态。 * * 已存在的类名会被关闭,不存在的类名会被开启。 * * @example * ``` * // Expect: 'button primary' * const example1 = toggleClass('button active', 'active primary') * * // Expect: { button: true, primary: true } * const example2 = toggleClass({ button: true, active: true }, ['active', 'primary']) * ``` */ export const toggleClass = (target: T, toggled: ClassUnion): T => { const targetClassObj = toClassObject(target) const toggledClassArr = toClassArray(toggled) toggledClassArr.forEach((cls) => { // oxlint-disable-next-line strict-boolean-expressions targetClassObj[cls] = !targetClassObj[cls] }) const result = formatClassToTarget(target, targetClassObj) return result } /** * @description 替换目标类名集合中的类名。 * * `replaced` 支持三种形式: * - 字符串:仅移除对应类名。 * - 字符串数组:逐项移除对应类名。 * - 元组数组:按 `[from, to]` 的形式逐项替换类名。 * - 对象:按键值对执行替换,值为空字符串时表示删除。 * * @example * ``` * // Expect: 'button selected' * const example1 = replaceClass('button active', [['active', 'selected']]) * * // Expect: ['button', 'selected'] * const example2 = replaceClass(['button', 'active'], { active: 'selected' }) * * // Expect: { button: true, active: false } * const example3 = replaceClass({ button: true, active: true }, 'active') * ``` */ export const replaceClass = ( target: T, replaced: Record | string | string[] | Array<[string, string]>, ): T => { if (typeof replaced === "string") { return removeClass(target, replaced) } const targetClassObj = toClassObject(target) if (Array.isArray(replaced)) { for (const item of replaced) { if (typeof item === "string") { const fromValue = targetClassObj[item] if (fromValue !== undefined) { delete targetClassObj[item] } continue } const [from, to] = item const fromValue = targetClassObj[from] if (fromValue !== undefined) { if (to !== "") { targetClassObj[to] = fromValue } delete targetClassObj[from] } } return formatClassToTarget(target, targetClassObj) } for (const [from, rawTo] of Object.entries(replaced)) { if (typeof rawTo !== "string") { continue } const fromValue = targetClassObj[from] if (fromValue !== undefined) { if (rawTo !== "") { targetClassObj[rawTo] = fromValue } delete targetClassObj[from] } } return formatClassToTarget(target, targetClassObj) } /** * @description 判断目标类名集合是否完整包含另一组类名。 * * 只有当 `contained` 中的每个类名都存在于 `target` 中时,才会返回 `true`。 * * @example * ``` * // Expect: true * const example1 = containClass('button active', 'button active primary') * * // Expect: false * const example2 = containClass(['button', 'missing'], { button: true, active: true }) * ``` */ export const containClass = (contained: ClassUnion, target: ClassUnion): boolean => { const containedClassArr = toClassArray(contained) const targetClassArr = toClassArray(target) return containedClassArr.every((item) => targetClassArr.includes(item)) }