diff --git a/README.md b/README.md index 26eeed6..a531604 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# @ant-design/cssinjs-util +# @ant-design/cssinjs-utils A cssinjs util library to support Ant Design (antd) and its ecosystem libraries. ## Install ``` bash -npm i @ant-design/cssinjs-util --save +npm i @ant-design/cssinjs-utils --save ``` ## Usage diff --git a/docs/demos/genStyleUtils.md b/docs/demos/genStyleUtils.md new file mode 100644 index 0000000..0a9ff6f --- /dev/null +++ b/docs/demos/genStyleUtils.md @@ -0,0 +1,108 @@ +--- +title: genStyleUtils +nav: + title: API + path: /genStyleUtils +--- +# `genStyleUtils` 使用文档 + +`genStyleUtils` 提供了用于在 `antd` 生态开发中,生成和管理样式的实用工具函数集。 + +## 入参介绍 + +### `genStyleUtils(getConfigProviderContext?, getThemeProviderContext?)` +- `config`: 可选,配置 + - `useCSP`: 使用 CSP 的钩子函数 + - `usePrefix`: 使用样式前缀的钩子函数 + - `useToken`: 使用 token 的钩子函数 +- `CompTokenMap`: 范型参数,表示组件 token 映射 +- `DesignTokenn`: 范型参数,表示设计 token +- `AliasToken`: 范型参数,表示别名 token +> 使用建议:为了更好的获得 TS 类型支持,建议您在使用 `genStyleUtils` 的时候传入范型参数 `CompTokenMap` `DesignTokenn` `AliasToken` + +## 如何使用 +``` typescript +import React from 'react'; +import { genStyleUtils } from '@ant-design/cssinjs-utils'; + +// Step1: 定义组件 Token 映射 +interface YourCompTokenMap { + Button?: {}; + Avatar?: {}; + // ... +} + +// Step2: 定义设计 Token +interface YourDesignTokenn { + color?: string; +} + +// Step3: 定义别名 Token +interface YourAliasToken { + colorFillContentHover?: string; +} + +// Step4: 使用 `genStyleUtils` 生成工具函数集 +const { + genStyleHooks, + genComponentStyleHook, + genSubStyleComponent, +} = genStyleUtils({ + useCSP: () => { + // ... + }, + usePrefix: () => { + // ... + }, + useToken: () => { + // ... + }, +}); +``` + +## 工具介绍 + +### `genStyleHooks(component, styleFn, getDefaultToken?, options?)` + +- `component`: 组件名称 `ComponentName` 或组件名称数组 `[ComponentName, ComponentName]`。 +- `styleFn`: 根据标记和样式信息生成 CSS 插值的函数。 +- `getDefaultToken`: 可选,用于检索默认标记的函数或值。 +- `options`: 可选,包含额外的配置选项如 `resetStyle`、`resetFont`、`deprecatedTokens`、`clientOnly` 等。 + +### `genComponentStyleHook(component, styleFn, getDefaultToken?, options?)` + +- `component`: 组件名称 `ComponentName` 或组件名称数组 `[ComponentName, ComponentName]`。 +- `styleFn`: 根据标记和样式信息生成 CSS 插值的函数。 +- `getDefaultToken`: 可选,用于检索默认标记的函数或值。 +- `options`: 可选,包含额外的配置选项如 `resetStyle`、`resetFont`、`deprecatedTokens`、`clientOnly` 等。 + +### `genSubStyleComponent(component, styleFn, getDefaultToken?, options?)` + +- `component`: 组件名称 `ComponentName` 或组件名称数组 `[ComponentName, ComponentName]`。 +- `styleFn`: 根据标记和样式信息生成 CSS 插值的函数。 +- `getDefaultToken`: 可选,用于检索默认标记的函数或值。 +- `options`: 可选,包含额外的配置选项如 `resetStyle`、`resetFont`、`deprecatedTokens`、`clientOnly` 等。 + +## 示例用法 + +### `genStyleHooks` + +```javascript +const useStyle = genStyleHooks('Button', styleFn, getDefaultToken, { resetStyle: true }); +const [wrapStyle, hashId] = useStyle('button'); +``` + +### `genComponentStyleHook` + +```javascript +const useStyle = genComponentStyleHook('Button', styleFn, getDefaultToken, { clientOnly: true }); +const [wrapStyle, hashId] = useStyle('button'); +``` + +### `genSubStyleComponent` + +```javascript +const SubButtonStyle = genSubStyleComponent('Button', styleFn, getDefaultToken, { resetFont: true }); + +() => ; +``` diff --git a/docs/examples/.gitkeep b/docs/examples/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/jest.config.js b/jest.config.js index 5328c18..03edf96 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,4 @@ module.exports = { - setupFiles: ['./tests/setup.js'], + testEnvironment: 'jsdom', + setupFiles: ['./tests/setup.ts'], }; diff --git a/package.json b/package.json index d705a2d..3a8e3f7 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@ant-design/cssinjs-util", + "name": "@ant-design/cssinjs-utils", "version": "1.0.0", "description": "A cssinjs util library to support Ant Design (antd) and its ecosystem libraries.", "keywords": [ @@ -30,7 +30,7 @@ "start": "dumi dev", "compile": "father build", "prepublishOnly": "npm run compile && np --yolo --no-publish", - "lint": "eslint src/ docs/examples/ --ext .tsx,.ts,.jsx,.js", + "lint": "eslint src/ --ext .tsx,.ts,.jsx,.js", "test": "rc-test", "coverage": "rc-test --coverage" }, @@ -38,7 +38,6 @@ "@testing-library/jest-dom": "^6.1.4", "@rc-component/father-plugin": "^1.0.1", "@testing-library/react": "^15.0.4", - "@types/classnames": "^2.2.10", "@types/jest": "^29.5.2", "@types/node": "^20.11.6", "@types/react": "^18.0.0", @@ -57,9 +56,8 @@ "typescript": "^5.1.6" }, "dependencies": { - "@babel/runtime": "^7.23.2", "@ant-design/cssinjs": "^1.21.0", - "classnames": "^2.3.2", + "@babel/runtime": "^7.23.2", "rc-util": "^5.38.0" }, "peerDependencies": { diff --git a/src/_util/hooks/useUniqueMemo.tsx b/src/_util/hooks/useUniqueMemo.tsx new file mode 100644 index 0000000..a757c48 --- /dev/null +++ b/src/_util/hooks/useUniqueMemo.tsx @@ -0,0 +1,97 @@ +import React from 'react'; + +const BEAT_LIMIT = 1000 * 60 * 10; + +/** + * A helper class to map keys to values. + * It supports both primitive keys and object keys. + */ +class ArrayKeyMap { + map = new Map(); + + // Use WeakMap to avoid memory leak + objectIDMap = new WeakMap(); + + nextID = 0; + + lastAccessBeat = new Map(); + + // We will clean up the cache when reach the limit + accessBeat = 0; + + set(keys: React.DependencyList, value: any) { + // New set will trigger clear + this.clear(); + + // Set logic + const compositeKey = this.getCompositeKey(keys); + this.map.set(compositeKey, value); + this.lastAccessBeat.set(compositeKey, Date.now()); + } + + get(keys: React.DependencyList) { + const compositeKey = this.getCompositeKey(keys); + + const cache = this.map.get(compositeKey); + this.lastAccessBeat.set(compositeKey, Date.now()); + this.accessBeat += 1; + + return cache; + } + + getCompositeKey(keys: React.DependencyList) { + const ids = keys.map((key) => { + if (key && typeof key === 'object') { + return `obj_${this.getObjectID(key)}`; + } + return `${typeof key}_${key}`; + }); + return ids.join('|'); + } + + getObjectID(obj: object) { + if (this.objectIDMap.has(obj)) { + return this.objectIDMap.get(obj); + } + const id = this.nextID; + this.objectIDMap.set(obj, id); + + this.nextID += 1; + + return id; + } + + clear() { + if (this.accessBeat > 10000) { + const now = Date.now(); + + this.lastAccessBeat.forEach((beat, key) => { + if (now - beat > BEAT_LIMIT) { + this.map.delete(key); + this.lastAccessBeat.delete(key); + } + }); + + this.accessBeat = 0; + } + } +} + +const uniqueMap = new ArrayKeyMap(); + +/** + * Like `useMemo`, but this hook result will be shared across all instances. + */ +function useUniqueMemo(memoFn: () => T, deps: React.DependencyList) { + return React.useMemo(() => { + const cachedValue = uniqueMap.get(deps); + if (cachedValue) { + return cachedValue as T; + } + const newValue = memoFn(); + uniqueMap.set(deps, newValue); + return newValue; + }, deps); +} + +export default useUniqueMemo; diff --git a/src/hooks/useCSP.ts b/src/hooks/useCSP.ts new file mode 100644 index 0000000..51b33fa --- /dev/null +++ b/src/hooks/useCSP.ts @@ -0,0 +1,10 @@ +export type UseCSP = () => { + nonce?: string; +}; + +/** + * Provide a default hook since not everyone need config this. + */ +const useDefaultCSP: UseCSP = () => ({}); + +export default useDefaultCSP; diff --git a/src/hooks/usePrefix.ts b/src/hooks/usePrefix.ts new file mode 100644 index 0000000..57b0a71 --- /dev/null +++ b/src/hooks/usePrefix.ts @@ -0,0 +1,11 @@ +export type UsePrefix = () => { + /** + * All the component use `@ant-design/cssinjs-utils` should have same `rootPrefixCls`. + */ + rootPrefixCls: string; + /** + * `iconPrefixCls` comes from the setting of `@ant-design/icons`. + * Here maybe little coupling but everyone need use this. + */ + iconPrefixCls: string; +}; diff --git a/src/hooks/useToken.ts b/src/hooks/useToken.ts new file mode 100644 index 0000000..ef8d61b --- /dev/null +++ b/src/hooks/useToken.ts @@ -0,0 +1,38 @@ +import type { Theme, TokenType } from '@ant-design/cssinjs'; + +import type { OverrideTokenMap, TokenMap } from '../interface'; + +export type TokenMapWithTheme< + CompTokenMap extends TokenMap, + DesignToken extends TokenType, + AliasToken extends TokenType, +> = { + [key in keyof OverrideTokenMap]?: OverrideTokenMap[key] & { + theme?: Theme; + }; + }; + +export interface UseTokenReturn< + CompTokenMap extends TokenMap, + DesignToken extends TokenType, + AliasToken extends TokenType, +> { + token: OverrideTokenMap; + realToken?: OverrideTokenMap; + /** Just merge `token` & `override` at top to save perf */ + override: { override: OverrideTokenMap }; + theme?: Theme; + components?: TokenMapWithTheme; + hashId?: string; + hashed?: string | boolean; + cssVar?: { + prefix?: string; + key?: string; + }; +} + +export type UseToken< + CompTokenMap extends TokenMap, + DesignToken extends TokenType, + AliasToken extends TokenType, +> = () => UseTokenReturn; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b672922..263d37b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,4 @@ -console.log('Hello world!'); \ No newline at end of file +export { default as genStyleUtils } from './util/genStyleUtils'; + +export { default as genCalc } from './util/calc'; +export type { default as AbstractCalculator } from './util/calc/calculator'; \ No newline at end of file diff --git a/src/interface/.gitkeep b/src/interface/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/interface/components.ts b/src/interface/components.ts new file mode 100644 index 0000000..a8cba50 --- /dev/null +++ b/src/interface/components.ts @@ -0,0 +1,14 @@ +import type { TokenType } from '@ant-design/cssinjs'; + +export type TokenMap = Record; + +export type TokenMapKey = Extract; + +export type OverrideTokenMap = Partial; + +export type GlobalTokenWithComponent> = CompTokenMap & + CompTokenMap[C]; + +export type ComponentToken> = Exclude[C], undefined>; + +export type ComponentTokenKey> = keyof ComponentToken; \ No newline at end of file diff --git a/src/interface/index.ts b/src/interface/index.ts new file mode 100644 index 0000000..8aedfd5 --- /dev/null +++ b/src/interface/index.ts @@ -0,0 +1,10 @@ +export type { + OverrideTokenMap, + TokenMap, + TokenMapKey, + GlobalTokenWithComponent, + ComponentToken, + ComponentTokenKey, +} from './components'; + +export type UseComponentStyleResult = [(node: React.ReactNode) => React.ReactElement, string]; diff --git a/src/util/calc/CSSCalculator.ts b/src/util/calc/CSSCalculator.ts new file mode 100644 index 0000000..7c25fcc --- /dev/null +++ b/src/util/calc/CSSCalculator.ts @@ -0,0 +1,110 @@ +import AbstractCalculator from './calculator'; + +const CALC_UNIT = 'CALC_UNIT'; + +const regexp = new RegExp(CALC_UNIT, 'g'); + +function unit(value: string | number) { + if (typeof value === 'number') { + return `${value}${CALC_UNIT}`; + } + return value; +} + +export default class CSSCalculator extends AbstractCalculator { + result: string = ''; + + unitlessCssVar: Set; + + lowPriority?: boolean; + + constructor( + num: number | string | AbstractCalculator, + unitlessCssVar: Set, + ) { + super(); + + const numType = typeof num; + + this.unitlessCssVar = unitlessCssVar; + + if (num instanceof CSSCalculator) { + this.result = `(${num.result})`; + } else if (numType === 'number') { + this.result = unit(num as number); + } else if (numType === 'string') { + this.result = num as string; + } + } + + add(num: number | string | AbstractCalculator): this { + if (num instanceof CSSCalculator) { + this.result = `${this.result} + ${num.getResult()}`; + } else if (typeof num === 'number' || typeof num === 'string') { + this.result = `${this.result} + ${unit(num)}`; + } + this.lowPriority = true; + return this; + } + + sub(num: number | string | AbstractCalculator): this { + if (num instanceof CSSCalculator) { + this.result = `${this.result} - ${num.getResult()}`; + } else if (typeof num === 'number' || typeof num === 'string') { + this.result = `${this.result} - ${unit(num)}`; + } + this.lowPriority = true; + return this; + } + + mul(num: number | string | AbstractCalculator): this { + if (this.lowPriority) { + this.result = `(${this.result})`; + } + if (num instanceof CSSCalculator) { + this.result = `${this.result} * ${num.getResult(true)}`; + } else if (typeof num === 'number' || typeof num === 'string') { + this.result = `${this.result} * ${num}`; + } + this.lowPriority = false; + return this; + } + + div(num: number | string | AbstractCalculator): this { + if (this.lowPriority) { + this.result = `(${this.result})`; + } + if (num instanceof CSSCalculator) { + this.result = `${this.result} / ${num.getResult(true)}`; + } else if (typeof num === 'number' || typeof num === 'string') { + this.result = `${this.result} / ${num}`; + } + this.lowPriority = false; + return this; + } + + getResult(force?: boolean): string { + return this.lowPriority || force ? `(${this.result})` : this.result; + } + + equal(options?: { unit?: boolean }): string { + const { unit: cssUnit } = options || {}; + + let mergedUnit: boolean = true; + if (typeof cssUnit === 'boolean') { + mergedUnit = cssUnit; + } else if ( + Array.from(this.unitlessCssVar).some((cssVar) => + this.result.includes(cssVar), + ) + ) { + mergedUnit = false; + } + + this.result = this.result.replace(regexp, mergedUnit ? 'px' : ''); + if (typeof this.lowPriority !== 'undefined') { + return `calc(${this.result})`; + } + return this.result; + } +} diff --git a/src/util/calc/NumCalculator.ts b/src/util/calc/NumCalculator.ts new file mode 100644 index 0000000..44538e3 --- /dev/null +++ b/src/util/calc/NumCalculator.ts @@ -0,0 +1,54 @@ +import AbstractCalculator from './calculator'; + +export default class NumCalculator extends AbstractCalculator { + result: number = 0; + + constructor(num: number | string | AbstractCalculator) { + super(); + if (num instanceof NumCalculator) { + this.result = num.result; + } else if (typeof num === 'number') { + this.result = num; + } + } + + add(num: number | string | AbstractCalculator): this { + if (num instanceof NumCalculator) { + this.result += num.result; + } else if (typeof num === 'number') { + this.result += num; + } + return this; + } + + sub(num: number | string | AbstractCalculator): this { + if (num instanceof NumCalculator) { + this.result -= num.result; + } else if (typeof num === 'number') { + this.result -= num; + } + return this; + } + + mul(num: number | string | AbstractCalculator): this { + if (num instanceof NumCalculator) { + this.result *= num.result; + } else if (typeof num === 'number') { + this.result *= num; + } + return this; + } + + div(num: number | string | AbstractCalculator): this { + if (num instanceof NumCalculator) { + this.result /= num.result; + } else if (typeof num === 'number') { + this.result /= num; + } + return this; + } + + equal(): number { + return this.result; + } +} diff --git a/src/util/calc/calculator.ts b/src/util/calc/calculator.ts new file mode 100644 index 0000000..cfd19ab --- /dev/null +++ b/src/util/calc/calculator.ts @@ -0,0 +1,33 @@ +abstract class AbstractCalculator { + /** + * @descCN 计算两数的和,例如:1 + 2 + * @descEN Calculate the sum of two numbers, e.g. 1 + 2 + */ + abstract add(num: number | string | AbstractCalculator): this; + + /** + * @descCN 计算两数的差,例如:1 - 2 + * @descEN Calculate the difference between two numbers, e.g. 1 - 2 + */ + abstract sub(num: number | string | AbstractCalculator): this; + + /** + * @descCN 计算两数的积,例如:1 * 2 + * @descEN Calculate the product of two numbers, e.g. 1 * 2 + */ + abstract mul(num: number | string | AbstractCalculator): this; + + /** + * @descCN 计算两数的商,例如:1 / 2 + * @descEN Calculate the quotient of two numbers, e.g. 1 / 2 + */ + abstract div(num: number | string | AbstractCalculator): this; + + /** + * @descCN 获取计算结果 + * @descEN Get the calculation result + */ + abstract equal(options?: { unit?: boolean }): string | number; +} + +export default AbstractCalculator; diff --git a/src/util/calc/index.ts b/src/util/calc/index.ts new file mode 100644 index 0000000..e6704c9 --- /dev/null +++ b/src/util/calc/index.ts @@ -0,0 +1,12 @@ +import type AbstractCalculator from './calculator'; +import CSSCalculator from './CSSCalculator'; +import NumCalculator from './NumCalculator'; + +const genCalc = (type: 'css' | 'js', unitlessCssVar: Set) => { + const Calculator = type === 'css' ? CSSCalculator : NumCalculator; + + return (num: number | string | AbstractCalculator) => + new Calculator(num, unitlessCssVar); +}; + +export default genCalc; diff --git a/src/util/genStyleUtils.tsx b/src/util/genStyleUtils.tsx new file mode 100644 index 0000000..fc7e15c --- /dev/null +++ b/src/util/genStyleUtils.tsx @@ -0,0 +1,515 @@ +import type { ComponentType, FC, ReactElement } from 'react'; +import React from 'react'; + +import type { + CSSInterpolation, + CSSObject, + TokenType, +} from '@ant-design/cssinjs'; +import { + token2CSSVar, + useCSSVarRegister, + useStyleRegister, +} from '@ant-design/cssinjs'; + +import type { + ComponentTokenKey, + GlobalTokenWithComponent, + OverrideTokenMap, + TokenMap, + TokenMapKey, + UseComponentStyleResult, +} from '../interface'; + +import type AbstractCalculator from './calc/calculator'; + +import genCalc from './calc'; +import getCompVarPrefix from './getCompVarPrefix'; +import getComponentToken from './getComponentToken'; +import getDefaultComponentToken from './getDefaultComponentToken'; +import genMaxMin from './maxmin'; +import statisticToken, { merge as mergeToken } from './statistic'; + +import useUniqueMemo from '../_util/hooks/useUniqueMemo'; +import useDefaultCSP, { type UseCSP } from '../hooks/useCSP'; +import { type UsePrefix } from '../hooks/usePrefix'; +import { type UseToken } from '../hooks/useToken'; + +export interface StyleInfo { + hashId: string; + prefixCls: string; + rootPrefixCls: string; + iconPrefixCls: string; +} + +export type CSSUtil = { + calc: (number: any) => AbstractCalculator; + max: (...values: (number | string)[]) => number | string; + min: (...values: (number | string)[]) => number | string; +}; + +export type TokenWithCommonCls = T & { + /** Wrap component class with `.` prefix */ + componentCls: string; + /** Origin prefix which do not have `.` prefix */ + prefixCls: string; + /** Wrap icon class with `.` prefix */ + iconCls: string; + /** Wrap ant prefixCls class with `.` prefix */ + antCls: string; +} & CSSUtil; + +export type FullToken< + CompTokenMap extends TokenMap, + C extends TokenMapKey, +> = TokenWithCommonCls>; + +export type GenStyleFn< + CompTokenMap extends TokenMap, + C extends TokenMapKey, +> = (token: FullToken, info: StyleInfo) => CSSInterpolation; + +export type GetDefaultTokenFn< + CompTokenMap extends TokenMap, + C extends TokenMapKey, +> = (token: Partial) => CompTokenMap[C]; + +export type GetDefaultToken< + CompTokenMap extends TokenMap, + C extends TokenMapKey, +> = null | CompTokenMap[C] | GetDefaultTokenFn; + +export interface SubStyleComponentProps { + prefixCls: string; + rootCls?: string; +} + +export type CSSVarRegisterProps = { + rootCls: string; + component: string; + cssVar: { + prefix?: string; + key?: string; + }; +}; + +export type GetResetStyles = (token: OverrideTokenMap) => CSSInterpolation; + +export default function genStyleUtils< + CompTokenMap extends TokenMap, + DesignToken extends TokenType, + AliasToken extends TokenType, +>( + config: { + usePrefix: UsePrefix; + useToken: UseToken; + useCSP?: UseCSP; + getResetStyles?: GetResetStyles, + } +) { + // Dependency inversion for preparing basic config. + const { + useCSP = useDefaultCSP, + useToken, + usePrefix, + getResetStyles, + } = config; + + function genStyleHooks>( + component: C | [C, string], + styleFn: GenStyleFn, + getDefaultToken?: GetDefaultToken, + options?: { + resetStyle?: boolean; + resetFont?: boolean; + deprecatedTokens?: [ + ComponentTokenKey, + ComponentTokenKey, + ][]; + /** + * Component tokens that do not need unit. + */ + unitless?: { + [key in ComponentTokenKey]: boolean; + }; + /** + * Only use component style in client side. Ignore in SSR. + */ + clientOnly?: boolean; + /** + * Set order of component style. + * @default -999 + */ + order?: number; + /** + * Whether generate styles + * @default true + */ + injectStyle?: boolean; + }, + ) { + const componentName = Array.isArray(component) ? component[0] : component; + + function prefixToken(key: string) { + return `${String(componentName)}${key + .slice(0, 1) + .toUpperCase()}${key.slice(1)}`; + } + + // Fill unitless + const originUnitless = options?.unitless || {}; + const compUnitless: any = { + ...originUnitless, + [prefixToken('zIndexPopup')]: true, + }; + Object.keys(originUnitless).forEach((key) => { + compUnitless[prefixToken(key)] = + originUnitless[key as keyof ComponentTokenKey]; + }); + + // Options + const mergedOptions = { + ...options, + unitless: compUnitless, + prefixToken, + }; + + // Hooks + const useStyle = genComponentStyleHook( + component, + styleFn, + getDefaultToken, + mergedOptions, + ); + + const useCSSVar = genCSSVarRegister( + componentName, + getDefaultToken, + mergedOptions, + ); + + return (prefixCls: string, rootCls: string = prefixCls) => { + const [, hashId] = useStyle(prefixCls, rootCls); + const [wrapCSSVar, cssVarCls] = useCSSVar(rootCls); + + return [wrapCSSVar, hashId, cssVarCls] as const; + }; + } + + function genCSSVarRegister>( + component: C, + getDefaultToken: GetDefaultToken | undefined, + options: { + unitless?: { + [key in ComponentTokenKey]: boolean; + }; + ignore?: { + [key in keyof AliasToken]?: boolean; + }; + deprecatedTokens?: [ + ComponentTokenKey, + ComponentTokenKey, + ][]; + injectStyle?: boolean; + prefixToken: (key: string) => string; + }, + ) { + const { + unitless: compUnitless, + injectStyle = true, + prefixToken, + ignore, + } = options; + + const CSSVarRegister: FC = ({ + rootCls, + cssVar = {}, + }) => { + const { realToken } = useToken(); + useCSSVarRegister( + { + path: [component], + prefix: cssVar.prefix, + key: cssVar.key!, + unitless: compUnitless, + ignore, + token: realToken, + scope: rootCls, + }, + () => { + const defaultToken = getDefaultComponentToken( + component, + realToken, + getDefaultToken, + ); + const componentToken = getComponentToken( + component, + realToken, + defaultToken, + { + deprecatedTokens: options?.deprecatedTokens, + }, + ); + Object.keys(defaultToken).forEach((key) => { + componentToken[prefixToken(key)] = componentToken[key]; + delete componentToken[key]; + }); + return componentToken; + }, + ); + return null; + }; + + const useCSSVar = (rootCls: string) => { + const { cssVar } = useToken(); + + return [ + (node: ReactElement): ReactElement => + injectStyle && cssVar ? ( + <> + + {node} + + ) : ( + node + ), + cssVar?.key, + ] as const; + }; + + return useCSSVar; + } + + function genComponentStyleHook>( + componentName: C | [C, string], + styleFn: GenStyleFn, + getDefaultToken?: GetDefaultToken, + options: { + resetStyle?: boolean; + resetFont?: boolean; + // Deprecated token key map [["oldTokenKey", "newTokenKey"], ["oldTokenKey", "newTokenKey"]] + deprecatedTokens?: [ + ComponentTokenKey, + ComponentTokenKey, + ][]; + /** + * Only use component style in client side. Ignore in SSR. + */ + clientOnly?: boolean; + /** + * Set order of component style. Default is -999. + */ + order?: number; + injectStyle?: boolean; + unitless?: { + [key in ComponentTokenKey]: boolean; + }; + genCommonStyle?: ( + token: OverrideTokenMap, + componentPrefixCls: string, + rootCls?: string, + resetFont?: boolean, + ) => CSSObject; + } = {}, + ) { + const cells = ( + Array.isArray(componentName) + ? componentName + : [componentName, componentName] + ) as [C, string]; + + const [component] = cells; + const concatComponent = cells.join('-'); + + // Return new style hook + return ( + prefixCls: string, + rootCls: string = prefixCls, + ): UseComponentStyleResult => { + const { theme, realToken, hashId, token, cssVar } = useToken(); + + const { rootPrefixCls, iconPrefixCls } = usePrefix(); + const csp = useCSP(); + + const type = cssVar ? 'css' : 'js'; + + // Use unique memo to share the result across all instances + const calc = useUniqueMemo(() => { + const unitlessCssVar = new Set(); + if (cssVar) { + Object.keys(options.unitless || {}).forEach((key) => { + // Some component proxy the AliasToken (e.g. Image) and some not (e.g. Modal) + // We should both pass in `unitlessCssVar` to make sure the CSSVar can be unitless. + unitlessCssVar.add(token2CSSVar(key, cssVar.prefix)); + unitlessCssVar.add( + token2CSSVar(key, getCompVarPrefix(component, cssVar.prefix)), + ); + }); + } + + return genCalc(type, unitlessCssVar); + }, [type, component, cssVar?.prefix]); + + const { max, min } = genMaxMin(type); + + // Shared config + const sharedConfig: Omit[0], 'path'> = + { + theme, + token, + hashId, + nonce: () => csp.nonce!, + clientOnly: options.clientOnly, + layer: { + name: 'antd', + }, + + // antd is always at top of styles + order: options.order || -999, + }; + + // Generate style for all need reset tags. + useStyleRegister( + { ...sharedConfig, clientOnly: false, path: ['Shared', rootPrefixCls] }, + () => getResetStyles?.(token) ?? [], + ); + + const wrapSSR = useStyleRegister( + { ...sharedConfig, path: [concatComponent, prefixCls, iconPrefixCls] }, + () => { + if (options.injectStyle === false) { + return []; + } + + const { token: proxyToken, flush } = statisticToken(token); + + const defaultComponentToken = getDefaultComponentToken< + CompTokenMap, + C + >(component, realToken, getDefaultToken) ?? {}; + + const componentCls = `.${prefixCls}`; + const componentToken = getComponentToken( + component, + realToken, + defaultComponentToken, + { + deprecatedTokens: options.deprecatedTokens, + }, + ); + + if (cssVar) { + Object.keys(defaultComponentToken).forEach((key) => { + defaultComponentToken[key] = `var(${token2CSSVar( + key, + getCompVarPrefix(component, cssVar.prefix), + )})`; + }); + } + const mergedToken = mergeToken( + proxyToken, + { + componentCls, + prefixCls, + iconCls: !!iconPrefixCls.length ? '' : `.${iconPrefixCls}`, + antCls: !!rootPrefixCls.length ? '' : `.${rootPrefixCls}`, + calc, + // @ts-ignore + max, + // @ts-ignore + min, + }, + cssVar ? defaultComponentToken : componentToken, + ) as FullToken; + + const styleInterpolation = styleFn(mergedToken, { + hashId, + prefixCls, + rootPrefixCls, + iconPrefixCls, + }); + flush(component, componentToken); + return [ + options.resetStyle === false + ? null + : options?.genCommonStyle?.( + mergedToken, + prefixCls, + rootCls, + options.resetFont, + ) ?? {}, + styleInterpolation, + ]; + }, + ); + + return [wrapSSR, hashId]; + }; + } + + function genSubStyleComponent>( + componentName: C | [C, string], + styleFn: GenStyleFn, + getDefaultToken?: GetDefaultToken, + options: { + resetStyle?: boolean; + resetFont?: boolean; + // Deprecated token key map [["oldTokenKey", "newTokenKey"], ["oldTokenKey", "newTokenKey"]] + deprecatedTokens?: [ + ComponentTokenKey, + ComponentTokenKey, + ][]; + /** + * Only use component style in client side. Ignore in SSR. + */ + clientOnly?: boolean; + /** + * Set order of component style. Default is -999. + */ + order?: number; + injectStyle?: boolean; + unitless?: { + [key in ComponentTokenKey]: boolean; + }; + } = {}, + ) { + const useStyle = genComponentStyleHook( + componentName, + styleFn, + getDefaultToken, + { + resetStyle: false, + + // Sub Style should default after root one + order: -998, + ...options, + }, + ); + + const StyledComponent: ComponentType = ({ + prefixCls, + rootCls = prefixCls, + }: SubStyleComponentProps) => { + useStyle(prefixCls, rootCls); + return null; + }; + + if (process.env.NODE_ENV !== 'production') { + StyledComponent.displayName = `SubStyle_${String( + Array.isArray(componentName) ? componentName.join('.') : componentName, + )}`; + } + + return StyledComponent; + } + + return { + genStyleHooks, + genSubStyleComponent, + genComponentStyleHook, + }; +} diff --git a/src/util/getCompVarPrefix.ts b/src/util/getCompVarPrefix.ts new file mode 100644 index 0000000..c18d338 --- /dev/null +++ b/src/util/getCompVarPrefix.ts @@ -0,0 +1,9 @@ +const getCompVarPrefix = (component: string, prefix?: string) => + `${[ + prefix, + component.replace(/([A-Z]+)([A-Z][a-z]+)/g, '$1-$2').replace(/([a-z])([A-Z])/g, '$1-$2'), + ] + .filter(Boolean) + .join('-')}`; + +export default getCompVarPrefix; \ No newline at end of file diff --git a/src/util/getComponentToken.ts b/src/util/getComponentToken.ts new file mode 100644 index 0000000..c9604f1 --- /dev/null +++ b/src/util/getComponentToken.ts @@ -0,0 +1,47 @@ +import { warning } from 'rc-util'; +import type { + TokenMap, + TokenMapKey, + ComponentTokenKey, + ComponentToken, + OverrideTokenMap, +} from '../interface'; + +export default function getComponentToken>( + component: C, + token: OverrideTokenMap, + defaultToken: CompTokenMap[C], + options?: { + deprecatedTokens?: [ComponentTokenKey, ComponentTokenKey][]; + }, +) { + const customToken = { ...(token[component] as ComponentToken) }; + if (options?.deprecatedTokens) { + const { deprecatedTokens } = options; + deprecatedTokens.forEach(([oldTokenKey, newTokenKey]) => { + if (process.env.NODE_ENV !== 'production') { + warning( + !customToken?.[oldTokenKey], + `Component Token \`${String( + oldTokenKey, + )}\` of ${String(component)} is deprecated. Please use \`${String(newTokenKey)}\` instead.`, + ); + } + + // Should wrap with `if` clause, or there will be `undefined` in object. + if (customToken?.[oldTokenKey] || customToken?.[newTokenKey]) { + customToken[newTokenKey] ??= customToken?.[oldTokenKey]; + } + }); + } + const mergedToken: any = { ...defaultToken, ...customToken }; + + // Remove same value as global token to minimize size + Object.keys(mergedToken).forEach((key) => { + if (mergedToken[key] === token[key as keyof CompTokenMap]) { + delete mergedToken[key]; + } + }); + + return mergedToken; +}; \ No newline at end of file diff --git a/src/util/getDefaultComponentToken.ts b/src/util/getDefaultComponentToken.ts new file mode 100644 index 0000000..90a1e53 --- /dev/null +++ b/src/util/getDefaultComponentToken.ts @@ -0,0 +1,14 @@ +import { merge as mergeToken } from './statistic'; +import type { GetDefaultToken, GetDefaultTokenFn } from './genStyleUtils'; +import type { OverrideTokenMap, TokenMap, TokenMapKey } from '../interface'; + +export default function getDefaultComponentToken>( + component: C, + token: OverrideTokenMap, + getDefaultToken: GetDefaultToken, +): any { + if (typeof getDefaultToken === 'function') { + return (getDefaultToken as GetDefaultTokenFn)(mergeToken(token, token[component] ?? {})); + } + return getDefaultToken ?? {}; +}; \ No newline at end of file diff --git a/src/util/maxmin.ts b/src/util/maxmin.ts new file mode 100644 index 0000000..44769b4 --- /dev/null +++ b/src/util/maxmin.ts @@ -0,0 +1,14 @@ +import { unit } from '@ant-design/cssinjs'; + +export default function genMaxMin(type: 'css' | 'js') { + if (type === 'js') { + return { + max: Math.max, + min: Math.min, + }; + } + return { + max: (...args: (string | number)[]) => `max(${args.map((value) => unit(value)).join(',')})`, + min: (...args: (string | number)[]) => `min(${args.map((value) => unit(value)).join(',')})`, + }; +} diff --git a/src/util/statistic.ts b/src/util/statistic.ts new file mode 100644 index 0000000..d04f8c3 --- /dev/null +++ b/src/util/statistic.ts @@ -0,0 +1,84 @@ +import type { TokenMap } from '../interface'; + +declare const CSSINJS_STATISTIC: any; + +const enableStatistic = + process.env.NODE_ENV !== 'production' || typeof CSSINJS_STATISTIC !== 'undefined'; +let recording = true; + +/** + * This function will do as `Object.assign` in production. But will use Object.defineProperty:get to + * pass all value access in development. To support statistic field usage with alias token. + */ +export function merge(...objs: Partial[]): CompTokenMap { + /* istanbul ignore next */ + if (!enableStatistic) { + return Object.assign({}, ...objs); + } + + recording = false; + + const ret = {} as CompTokenMap; + + objs.forEach((obj) => { + const keys = Object.keys(obj); + + keys.forEach((key) => { + Object.defineProperty(ret, key, { + configurable: true, + enumerable: true, + get: () => (obj)[key], + }); + }); + }); + + recording = true; + return ret; +} + +/** @internal Internal Usage. Not use in your production. */ +export const statistic: Record< + string, + { global: string[]; component: Record } +> = {}; + +/** @internal Internal Usage. Not use in your production. */ +export const _statistic_build_: typeof statistic = {}; + +/* istanbul ignore next */ +function noop() {} + +/** Statistic token usage case. Should use `merge` function if you do not want spread record. */ +const statisticToken = (token: CompTokenMap) => { + let tokenKeys: Set | undefined; + let proxy = token; + let flush: (componentName: string, componentToken: Record) => void = + noop; + + if (enableStatistic && typeof Proxy !== 'undefined') { + tokenKeys = new Set(); + + proxy = new Proxy(token, { + get(obj: any, prop: any) { + if (recording) { + tokenKeys!.add(prop); + } + return obj[prop]; + }, + }); + + flush = (componentName, componentToken) => { + statistic[componentName] = { + global: Array.from(tokenKeys!), + component: { + ...statistic[componentName]?.component, + ...componentToken, + }, + }; + }; + } + + return { token: proxy, keys: tokenKeys, flush }; +}; + +export default statisticToken; diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/calc.test.tsx b/tests/calc.test.tsx new file mode 100644 index 0000000..7571ea3 --- /dev/null +++ b/tests/calc.test.tsx @@ -0,0 +1,151 @@ +import type { AbstractCalculator } from '../src'; +import { genCalc } from '../src'; + +describe('calculator', () => { + const cases: [ + ( + calc: (num: number | AbstractCalculator) => AbstractCalculator, + ) => string | number, + { js: number; css: string }, + ][] = [ + [ + // 1 + 1 + (calc) => calc(1).add(1).equal(), + { + js: 2, + css: 'calc(1px + 1px)', + }, + ], + [ + // (1 + 1) * 4 + (calc) => calc(1).add(1).mul(4).equal(), + { + js: 8, + css: 'calc((1px + 1px) * 4)', + }, + ], + [ + // (2 + 4) / 2 - 2 + (calc) => calc(2).add(4).div(2).sub(2).equal(), + { + js: 1, + css: 'calc((2px + 4px) / 2 - 2px)', + }, + ], + [ + // Bad case + // (2 + 4) / (3 - 2) - 2 + (calc) => calc(2).add(4).div(calc(3).sub(2)).sub(2).equal(), + { + js: 4, + css: 'calc((2px + 4px) / (3px - 2px) - 2px)', + }, + ], + [ + // Bad case + // 2 * (2 + 3) + (calc) => calc(2).mul(calc(2).add(3)).equal(), + { + js: 10, + css: 'calc(2px * (2px + 3px))', + }, + ], + [ + // (1 + 2) * 3 + (calc) => calc(calc(1).add(2)).mul(3).equal(), + { + js: 9, + css: 'calc((1px + 2px) * 3)', + }, + ], + [ + // 1 + (2 - 1) + (calc) => calc(1).add(calc(2).sub(1)).equal(), + { + js: 2, + css: 'calc(1px + (2px - 1px))', + }, + ], + [ + // 1 + 2 * 2 + (calc) => calc(1).add(calc(2).mul(2)).equal(), + { + js: 5, + css: 'calc(1px + 2px * 2)', + }, + ], + [ + // 5 - (2 - 1) + (calc) => calc(5).sub(calc(2).sub(1)).equal(), + { + js: 4, + css: 'calc(5px - (2px - 1px))', + }, + ], + [ + // 2 * 6 / 3 + (calc) => calc(2).mul(6).div(3).equal(), + { + js: 4, + css: 'calc(2px * 6 / 3)', + }, + ], + [ + // 6 / 3 * 2 + (calc) => calc(6).div(3).mul(2).equal(), + { + js: 4, + css: 'calc(6px / 3 * 2)', + }, + ], + [ + // Bad case + // 6 / (3 * 2) + (calc) => calc(6).div(calc(3).mul(2)).equal(), + { + js: 1, + css: 'calc(6px / (3px * 2))', + }, + ], + [ + // 6 + (calc) => calc(6).equal(), + { + js: 6, + css: '6px', + }, + ], + [ + // 1000 + 100 without unit + (calc) => calc(1000).add(100).equal({ unit: false }), + { + js: 1100, + css: 'calc(1000 + 100)', + }, + ], + ]; + + cases.forEach(([exp, { js, css }], index) => { + it(`js calc ${index + 1}`, () => { + expect(exp(genCalc('js', new Set()))).toBe(js); + }); + + it(`css calc ${index + 1}`, () => { + expect(exp(genCalc('css', new Set()))).toBe(css); + }); + }); + + it('css calc should work with string', () => { + const calc = genCalc('css', new Set()); + expect(calc('var(--var1)').add('var(--var2)').equal()).toBe( + 'calc(var(--var1) + var(--var2))', + ); + }); + + it('css calc var should skip zIndex', () => { + const calc = genCalc('css', new Set(['--ant-z-index'])); + expect(calc('var(--ant-z-index)').add(93).equal()).toBe( + 'calc(var(--ant-z-index) + 93)', + ); + }); +}); diff --git a/tests/genStyleUtils.test.tsx b/tests/genStyleUtils.test.tsx new file mode 100644 index 0000000..b10556f --- /dev/null +++ b/tests/genStyleUtils.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { render, renderHook } from '@testing-library/react'; + +import { genStyleUtils } from '../src' +import type { CSSVarRegisterProps, SubStyleComponentProps } from '../src/util/genStyleUtils'; + +describe('genStyleUtils', () => { + const mockConfig = { + usePrefix: jest.fn().mockReturnValue({ + rootPrefixCls: 'ant', + iconPrefixCls: 'anticon', + }), + useToken: jest.fn().mockReturnValue({ + theme: {}, + realToken: {}, + hashId: 'hash', + token: {}, + cssVar: {}, + }), + useCSP: jest.fn().mockReturnValue({ nonce: 'nonce' }), + getResetStyles: jest.fn().mockReturnValue([]), + }; + + const { genStyleHooks, genSubStyleComponent, genComponentStyleHook } = genStyleUtils(mockConfig); + + describe('genStyleHooks', () => { + it('should generate style hooks', () => { + const component = 'TestComponent'; + const styleFn = jest.fn(); + const getDefaultToken = jest.fn(); + const hooks = genStyleHooks(component, styleFn, getDefaultToken); + + expect(hooks).toBeInstanceOf(Function); + + const { + result: { current } + } = renderHook(() => hooks('test-prefix')); + expect(current).toBeInstanceOf(Array); + expect(current).toHaveLength(3); + }); + }); + + describe('genSubStyleComponent', () => { + it('should generate sub style component', () => { + const component = 'TestComponent'; + const styleFn = jest.fn(); + const getDefaultToken = jest.fn(); + const StyledComponent = genSubStyleComponent(component, styleFn, getDefaultToken); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('genComponentStyleHook', () => { + it('should generate component style hook', () => { + const component = 'TestComponent'; + const styleFn = jest.fn(); + const getDefaultToken = jest.fn(); + const hook = genComponentStyleHook(component, styleFn, getDefaultToken); + + const TestComponent: React.FC = ({ prefixCls, rootCls }) => { + hook(prefixCls, rootCls); + return
Test
; + }; + + const { getByTestId } = render(); + expect(getByTestId('test-component')).toHaveTextContent('Test'); + }); + }); + + describe('CSSVarRegister', () => { + it('should render CSSVarRegister component', () => { + const CSSVarRegister: React.FC = ({ + rootCls, + cssVar = {}, + }) => { + return
{cssVar.prefix}
; + }; + + const { getByTestId } = render(); + expect(getByTestId('test-root')).toHaveTextContent('test-prefix'); + }); + }); +}); diff --git a/tests/setup.js b/tests/setup.js deleted file mode 100644 index e69de29..0000000 diff --git a/docs/demos/.gitkeep b/tests/setup.ts similarity index 100% rename from docs/demos/.gitkeep rename to tests/setup.ts diff --git a/tests/setupAfterEnv.ts b/tests/setupAfterEnv.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/tests/setupAfterEnv.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/tsconfig.json b/tsconfig.json index e8b898e..83aa99a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "allowSyntheticDefaultImports": true, "paths": { "@/*": ["src/*"], - "@@/*": [".dumi/tmp/*"] + "@@/*": [".dumi/tmp/*"], + "@ant-design/cssinjs-utils": ["src/index.ts"] } } }