diff --git a/components/_util/cssinjs/Cache.ts b/components/_util/cssinjs/Cache.ts index 010a0e3f6c..cff14b4752 100644 --- a/components/_util/cssinjs/Cache.ts +++ b/components/_util/cssinjs/Cache.ts @@ -1,11 +1,14 @@ export type KeyType = string | number; type ValueType = [number, any]; // [times, realValue] + const SPLIT = '%'; + class Entity { instanceId: string; constructor(instanceId: string) { this.instanceId = instanceId; } + /** @private Internal cache map. Do not access this directly */ cache = new Map(); diff --git a/components/_util/cssinjs/StyleContext.tsx b/components/_util/cssinjs/StyleContext.tsx index faf49f4b5d..6d062eece2 100644 --- a/components/_util/cssinjs/StyleContext.tsx +++ b/components/_util/cssinjs/StyleContext.tsx @@ -31,7 +31,6 @@ export function createCache() { Array.from(styles).forEach(style => { (style as any)[CSS_IN_JS_INSTANCE] = (style as any)[CSS_IN_JS_INSTANCE] || cssinjsInstanceId; - // Not force move if no head // Not force move if no head if ((style as any)[CSS_IN_JS_INSTANCE] === cssinjsInstanceId) { document.head.insertBefore(style, firstChild); diff --git a/components/_util/cssinjs/extractStyle.ts b/components/_util/cssinjs/extractStyle.ts new file mode 100644 index 0000000000..3fa3411cd9 --- /dev/null +++ b/components/_util/cssinjs/extractStyle.ts @@ -0,0 +1,82 @@ +import type Cache from './Cache'; +import { extract as tokenExtractStyle, TOKEN_PREFIX } from './hooks/useCacheToken'; +import { CSS_VAR_PREFIX, extract as cssVarExtractStyle } from './hooks/useCSSVarRegister'; +import { extract as styleExtractStyle, STYLE_PREFIX } from './hooks/useStyleRegister'; +import { toStyleStr } from './util'; +import { ATTR_CACHE_MAP, serialize as serializeCacheMap } from './util/cacheMapUtil'; + +const ExtractStyleFns = { + [STYLE_PREFIX]: styleExtractStyle, + [TOKEN_PREFIX]: tokenExtractStyle, + [CSS_VAR_PREFIX]: cssVarExtractStyle, +}; + +type ExtractStyleType = keyof typeof ExtractStyleFns; + +function isNotNull(value: T | null): value is T { + return value !== null; +} + +export default function extractStyle( + cache: Cache, + options?: + | boolean + | { + plain?: boolean; + types?: ExtractStyleType | ExtractStyleType[]; + }, +) { + const { plain = false, types = ['style', 'token', 'cssVar'] } = + typeof options === 'boolean' ? { plain: options } : options || {}; + + const matchPrefixRegexp = new RegExp( + `^(${(typeof types === 'string' ? [types] : types).join('|')})%`, + ); + + // prefix with `style` is used for `useStyleRegister` to cache style context + const styleKeys = Array.from(cache.cache.keys()).filter(key => matchPrefixRegexp.test(key)); + + // Common effect styles like animation + const effectStyles: Record = {}; + + // Mapping of cachePath to style hash + const cachePathMap: Record = {}; + + let styleText = ''; + + styleKeys + .map<[number, string] | null>(key => { + const cachePath = key.replace(matchPrefixRegexp, '').replace(/%/g, '|'); + const [prefix] = key.split('%'); + const extractFn = ExtractStyleFns[prefix as keyof typeof ExtractStyleFns]; + const extractedStyle = extractFn(cache.cache.get(key)![1], effectStyles, { + plain, + }); + if (!extractedStyle) { + return null; + } + const [order, styleId, styleStr] = extractedStyle; + if (key.startsWith('style')) { + cachePathMap[cachePath] = styleId; + } + return [order, styleStr]; + }) + .filter(isNotNull) + .sort(([o1], [o2]) => o1 - o2) + .forEach(([, style]) => { + styleText += style; + }); + + // ==================== Fill Cache Path ==================== + styleText += toStyleStr( + `.${ATTR_CACHE_MAP}{content:"${serializeCacheMap(cachePathMap)}";}`, + undefined, + undefined, + { + [ATTR_CACHE_MAP]: ATTR_CACHE_MAP, + }, + plain, + ); + + return styleText; +} diff --git a/components/_util/cssinjs/hooks/useCSSVarRegister.ts b/components/_util/cssinjs/hooks/useCSSVarRegister.ts new file mode 100644 index 0000000000..e2b5d4d764 --- /dev/null +++ b/components/_util/cssinjs/hooks/useCSSVarRegister.ts @@ -0,0 +1,108 @@ +import { removeCSS, updateCSS } from '../../../vc-util/Dom/dynamicCSS'; +import { ATTR_MARK, ATTR_TOKEN, CSS_IN_JS_INSTANCE, useStyleInject } from '../StyleContext'; +import { isClientSide, toStyleStr } from '../util'; +import type { TokenWithCSSVar } from '../util/css-variables'; +import { transformToken } from '../util/css-variables'; +import type { ExtractStyle } from './useGlobalCache'; +import useGlobalCache from './useGlobalCache'; +import { uniqueHash } from './useStyleRegister'; +import type { ComputedRef } from 'vue'; +import { computed } from 'vue'; + +export const CSS_VAR_PREFIX = 'cssVar'; + +type CSSVarCacheValue = Record> = [ + cssVarToken: TokenWithCSSVar, + cssVarStr: string, + styleId: string, + cssVarKey: string, +]; + +const useCSSVarRegister = >( + config: ComputedRef<{ + path: string[]; + key: string; + prefix?: string; + unitless?: Record; + ignore?: Record; + scope?: string; + token: any; + }>, + fn: () => T, +) => { + const styleContext = useStyleInject(); + + const stylePath = computed(() => { + return [ + ...config.value.path, + config.value.key, + config.value.scope || '', + config.value.token?._tokenKey, + ]; + }); + + const cache = useGlobalCache>( + CSS_VAR_PREFIX, + stylePath, + () => { + const originToken = fn(); + const [mergedToken, cssVarsStr] = transformToken(originToken, config.value.key, { + prefix: config.value.prefix, + unitless: config.value.unitless, + ignore: config.value.ignore, + scope: config.value.scope || '', + }); + + const styleId = uniqueHash(stylePath.value, cssVarsStr); + return [mergedToken, cssVarsStr, styleId, config.value.key]; + }, + ([, , styleId]) => { + if (isClientSide) { + removeCSS(styleId, { mark: ATTR_MARK }); + } + }, + ([, cssVarsStr, styleId]) => { + if (!cssVarsStr) { + return; + } + + const style = updateCSS(cssVarsStr, styleId, { + mark: ATTR_MARK, + prepend: 'queue', + attachTo: styleContext.value.container, + priority: -999, + }); + + (style as any)[CSS_IN_JS_INSTANCE] = styleContext.value.cache?.instanceId; + + // Used for `useCacheToken` to remove on batch when token removed + style.setAttribute(ATTR_TOKEN, config.value.key); + }, + ); + + return cache; +}; + +export const extract: ExtractStyle> = (cache, _effectStyles, options) => { + const [, styleStr, styleId, cssVarKey] = cache; + const { plain } = options || {}; + + if (!styleStr) { + return null; + } + + const order = -999; + + // ====================== Style ====================== + // Used for rc-util + const sharedAttrs = { + 'data-vc-order': 'prependQueue', + 'data-vc-priority': `${order}`, + }; + + const styleText = toStyleStr(styleStr, cssVarKey, styleId, sharedAttrs, plain); + + return [order, styleId, styleText]; +}; + +export default useCSSVarRegister; diff --git a/components/_util/cssinjs/hooks/useCacheToken.tsx b/components/_util/cssinjs/hooks/useCacheToken.tsx index f97d146b55..6067cdd62a 100644 --- a/components/_util/cssinjs/hooks/useCacheToken.tsx +++ b/components/_util/cssinjs/hooks/useCacheToken.tsx @@ -1,20 +1,19 @@ import hash from '@emotion/hash'; -import { ATTR_TOKEN, CSS_IN_JS_INSTANCE, useStyleInject } from '../StyleContext'; +import { updateCSS } from '../../../vc-util/Dom/dynamicCSS'; +import { ATTR_MARK, ATTR_TOKEN, CSS_IN_JS_INSTANCE, useStyleInject } from '../StyleContext'; import type Theme from '../theme/Theme'; +import { flattenToken, memoResult, token2key, toStyleStr } from '../util'; +import { transformToken } from '../util/css-variables'; +import type { ExtractStyle } from './useGlobalCache'; import useGlobalCache from './useGlobalCache'; -import { flattenToken, token2key } from '../util'; import type { Ref } from 'vue'; import { ref, computed } from 'vue'; const EMPTY_OVERRIDE = {}; -const isProduction = process.env.NODE_ENV === 'production'; -// nuxt generate when NODE_ENV is prerender -const isPrerender = process.env.NODE_ENV === 'prerender'; - // Generate different prefix to make user selector break in production env. // This helps developer not to do style override directly on the hash id. -const hashPrefix = !isProduction && !isPrerender ? 'css-dev-only-do-not-override' : 'css'; +const hashPrefix = process.env.NODE_ENV !== 'production' ? 'css-dev-only-do-not-override' : 'css'; export interface Option { /** @@ -46,6 +45,22 @@ export interface Option { override: object, theme: Theme, ) => DerivativeToken; + + /** + * Transform token to css variables. + */ + cssVar?: { + /** Prefix for css variables */ + prefix?: string; + /** Tokens that should not be appended with unit */ + unitless?: Record; + /** Tokens that should not be transformed to css variables */ + ignore?: Record; + /** Tokens that preserves origin value */ + preserve?: Record; + /** Key for current theme. Useful for customizing and should be unique */ + key?: string; + }; } const tokenKeys = new Map(); @@ -94,6 +109,7 @@ export const getComputedToken = DerivativeToken, ) => { const derivativeToken = theme.getDerivativeToken(originToken); + // Merge with override let mergedDerivativeToken = { ...derivativeToken, @@ -108,6 +124,16 @@ export const getComputedToken = = [ + token: DerivativeToken & { _tokenKey: string; _themeKey: string }, + hashId: string, + realToken: DerivativeToken & { _tokenKey: string }, + cssVarStr: string, + cssVarKey: string, +]; + /** * Cache theme derivative token as global shared one * @param theme Theme entity @@ -119,21 +145,27 @@ export default function useCacheToken>, tokens: Ref[]>, option: Ref> = ref({}), -) { - const style = useStyleInject(); +): Ref> { + const styleContext = useStyleInject(); // Basic - We do basic cache here - const mergedToken = computed(() => Object.assign({}, ...tokens.value)); + const mergedToken = computed(() => + memoResult(() => Object.assign({}, ...tokens.value), tokens.value), + ); + const tokenStr = computed(() => flattenToken(mergedToken.value)); - const overrideTokenStr = computed(() => flattenToken(option.value.override || EMPTY_OVERRIDE)); + const overrideTokenStr = computed(() => flattenToken(option.value.override ?? EMPTY_OVERRIDE)); - const cachedToken = useGlobalCache<[DerivativeToken & { _tokenKey: string }, string]>( - 'token', + const cssVarStr = computed(() => (option.value.cssVar ? flattenToken(option.value.cssVar) : '')); + + const cachedToken = useGlobalCache>( + TOKEN_PREFIX, computed(() => [ - option.value.salt || '', - theme.value.id, + option.value.salt ?? '', + theme.value?.id, tokenStr.value, overrideTokenStr.value, + cssVarStr.value, ]), () => { const { @@ -141,25 +173,82 @@ export default function useCacheToken { // Remove token will remove all related style - cleanTokenStyle(cache[0]._tokenKey, style.value?.cache.instanceId); + cleanTokenStyle(cache[0]._themeKey, styleContext.value?.cache?.instanceId); + }, + ([token, , , cssVarsStr]) => { + const { cssVar } = option.value; + if (cssVar && cssVarsStr) { + const style = updateCSS(cssVarsStr, hash(`css-variables-${token._themeKey}`), { + mark: ATTR_MARK, + prepend: 'queue', + attachTo: styleContext.value?.container, + priority: -999, + }); + + (style as any)[CSS_IN_JS_INSTANCE] = styleContext.value?.cache?.instanceId; + + // Used for `useCacheToken` to remove on batch when token removed + style.setAttribute(ATTR_TOKEN, token._themeKey); + } }, ); return cachedToken; } + +export const extract: ExtractStyle> = (cache, _effectStyles, options) => { + const [, , realToken, styleStr, cssVarKey] = cache; + const { plain } = options || {}; + + if (!styleStr) { + return null; + } + + const styleId = realToken._tokenKey; + const order = -999; + + // ====================== Style ====================== + // Used for rc-util + const sharedAttrs = { + 'data-vc-order': 'prependQueue', + 'data-vc-priority': `${order}`, + }; + + const styleText = toStyleStr(styleStr, cssVarKey, styleId, sharedAttrs, plain); + + return [order, styleId, styleText]; +}; diff --git a/components/_util/cssinjs/hooks/useCompatibleInsertionEffect.tsx b/components/_util/cssinjs/hooks/useCompatibleInsertionEffect.tsx new file mode 100644 index 0000000000..e3ac2e794f --- /dev/null +++ b/components/_util/cssinjs/hooks/useCompatibleInsertionEffect.tsx @@ -0,0 +1,30 @@ +// import canUseDom from 'rc-util/lib/Dom/canUseDom'; +import useLayoutEffect from '../../../_util/hooks/useLayoutEffect'; +import type { ShallowRef, WatchCallback } from 'vue'; +import { watch } from 'vue'; + +type UseCompatibleInsertionEffect = ( + renderEffect: WatchCallback, + effect: (polyfill?: boolean) => ReturnType, + deps: ShallowRef, +) => void; + +/** + * Polyfill `useInsertionEffect` for React < 18 + * @param renderEffect will be executed in `useMemo`, and do not have callback + * @param effect will be executed in `useLayoutEffect` + * @param deps + */ +const useInsertionEffectPolyfill: UseCompatibleInsertionEffect = (renderEffect, effect, deps) => { + watch(deps, renderEffect, { immediate: true }); + useLayoutEffect(() => effect(true), deps); +}; + +/** + * Compatible `useInsertionEffect` + * will use `useInsertionEffect` if React version >= 18, + * otherwise use `useInsertionEffectPolyfill`. + */ +const useCompatibleInsertionEffect: UseCompatibleInsertionEffect = useInsertionEffectPolyfill; + +export default useCompatibleInsertionEffect; diff --git a/components/_util/cssinjs/hooks/useEffectCleanupRegister.ts b/components/_util/cssinjs/hooks/useEffectCleanupRegister.ts new file mode 100644 index 0000000000..c557e58967 --- /dev/null +++ b/components/_util/cssinjs/hooks/useEffectCleanupRegister.ts @@ -0,0 +1,8 @@ +const useRun = () => { + return function (fn: () => void) { + fn(); + }; +}; +const useEffectCleanupRegister = useRun; + +export default useEffectCleanupRegister; diff --git a/components/_util/cssinjs/hooks/useGlobalCache.tsx b/components/_util/cssinjs/hooks/useGlobalCache.tsx index 4a4940ced1..de2e707558 100644 --- a/components/_util/cssinjs/hooks/useGlobalCache.tsx +++ b/components/_util/cssinjs/hooks/useGlobalCache.tsx @@ -1,58 +1,115 @@ import { useStyleInject } from '../StyleContext'; import type { KeyType } from '../Cache'; +import useCompatibleInsertionEffect from './useCompatibleInsertionEffect'; import useHMR from './useHMR'; import type { ShallowRef, Ref } from 'vue'; -import { onBeforeUnmount, watch, watchEffect, shallowRef } from 'vue'; -export default function useClientCache( +import { onBeforeUnmount, watch, computed } from 'vue'; + +export type ExtractStyle = ( + cache: CacheValue, + effectStyles: Record, + options?: { + plain?: boolean; + }, +) => [order: number, styleId: string, style: string] | null; + +export default function useGlobalCache( prefix: string, keyPath: Ref, cacheFn: () => CacheType, onCacheRemove?: (cache: CacheType, fromHMR: boolean) => void, + // Add additional effect trigger by `useInsertionEffect` + onCacheEffect?: (cachedValue: CacheType) => void, ): ShallowRef { const styleContext = useStyleInject(); - const fullPathStr = shallowRef(''); - const res = shallowRef(); - watchEffect(() => { - fullPathStr.value = [prefix, ...keyPath.value].join('%'); - }); + const globalCache = computed(() => styleContext.value?.cache); + const deps = computed(() => [prefix, ...keyPath.value].join('%')); + const HMRUpdate = useHMR(); - const clearCache = (pathStr: string) => { - styleContext.value.cache.update(pathStr, prevCache => { - const [times = 0, cache] = prevCache || []; - const nextCount = times - 1; - if (nextCount === 0) { - onCacheRemove?.(cache, false); - return null; + + type UpdaterArgs = [times: number, cache: CacheType]; + + const buildCache = (updater?: (data: UpdaterArgs) => UpdaterArgs) => { + globalCache.value.update(deps.value, prevCache => { + const [times = 0, cache] = prevCache || [undefined, undefined]; + + // HMR should always ignore cache since developer may change it + let tmpCache = cache; + if (process.env.NODE_ENV !== 'production' && cache && HMRUpdate) { + onCacheRemove?.(tmpCache, HMRUpdate); + tmpCache = null; } - return [times - 1, cache]; + const mergedCache = tmpCache || cacheFn(); + + const data: UpdaterArgs = [times, mergedCache]; + + // Call updater if need additional logic + return updater ? updater(data) : data; }); }; watch( - fullPathStr, - (newStr, oldStr) => { - if (oldStr) clearCache(oldStr); - // Create cache - styleContext.value.cache.update(newStr, prevCache => { - const [times = 0, cache] = prevCache || []; - - // HMR should always ignore cache since developer may change it - let tmpCache = cache; - if (process.env.NODE_ENV !== 'production' && cache && HMRUpdate) { - onCacheRemove?.(tmpCache, HMRUpdate); - tmpCache = null; - } - const mergedCache = tmpCache || cacheFn(); + deps, + () => { + buildCache(); + }, + { immediate: true }, + ); + + let cacheEntity = globalCache.value.get(deps.value); + + // HMR clean the cache but not trigger `useMemo` again + // Let's fallback of this + // ref https://github.com/ant-design/cssinjs/issues/127 + if (process.env.NODE_ENV !== 'production' && !cacheEntity) { + buildCache(); + cacheEntity = globalCache.value.get(deps.value); + } - return [times + 1, mergedCache]; + const cacheContent = computed( + () => + (globalCache.value.get(deps.value) && globalCache.value.get(deps.value)![1]) || + cacheEntity![1], + ); + + // Remove if no need anymore + useCompatibleInsertionEffect( + () => { + onCacheEffect?.(cacheContent.value); + }, + polyfill => { + // It's bad to call build again in effect. + // But we have to do this since StrictMode will call effect twice + // which will clear cache on the first time. + buildCache(([times, cache]) => { + if (polyfill && times === 0) { + onCacheEffect?.(cacheContent.value); + } + return [times + 1, cache]; }); - res.value = styleContext.value.cache.get(fullPathStr.value)![1]; + + return () => { + globalCache.value.update(deps.value, prevCache => { + const [times = 0, cache] = prevCache || []; + const nextCount = times - 1; + if (nextCount <= 0) { + if (polyfill || !globalCache.value.get(deps.value)) { + onCacheRemove?.(cache, false); + } + return null; + } + + return [times - 1, cache]; + }); + }; }, - { immediate: true }, + deps, ); + onBeforeUnmount(() => { - clearCache(fullPathStr.value); + buildCache(); }); - return res; + + return cacheContent; } diff --git a/components/_util/cssinjs/hooks/useStyleRegister/index.tsx b/components/_util/cssinjs/hooks/useStyleRegister.tsx similarity index 69% rename from components/_util/cssinjs/hooks/useStyleRegister/index.tsx rename to components/_util/cssinjs/hooks/useStyleRegister.tsx index d264d0744e..0a39abbad6 100644 --- a/components/_util/cssinjs/hooks/useStyleRegister/index.tsx +++ b/components/_util/cssinjs/hooks/useStyleRegister.tsx @@ -3,38 +3,30 @@ import type * as CSS from 'csstype'; // @ts-ignore import unitless from '@emotion/unitless'; import { compile, serialize, stringify } from 'stylis'; -import type { Theme, Transformer } from '../..'; -import type Cache from '../../Cache'; -import type Keyframes from '../../Keyframes'; -import type { Linter } from '../../linters'; -import { contentQuotesLinter, hashedAnimationLinter } from '../../linters'; -import type { HashPriority } from '../../StyleContext'; +import type { Theme, Transformer } from '..'; +import type Keyframes from '../Keyframes'; +import type { Linter } from '../linters'; +import { contentQuotesLinter, hashedAnimationLinter } from '../linters'; +import type { HashPriority } from '../StyleContext'; import { useStyleInject, ATTR_CACHE_PATH, ATTR_MARK, ATTR_TOKEN, CSS_IN_JS_INSTANCE, -} from '../../StyleContext'; -import { supportLayer } from '../../util'; -import useGlobalCache from '../useGlobalCache'; -import { removeCSS, updateCSS } from '../../../../vc-util/Dom/dynamicCSS'; +} from '../StyleContext'; +import { isClientSide, supportLayer, toStyleStr } from '../util'; +import { CSS_FILE_STYLE, existPath, getStyleAndHash } from '../util/cacheMapUtil'; +import type { ExtractStyle } from './useGlobalCache'; +import useGlobalCache from './useGlobalCache'; +import { removeCSS, updateCSS } from '../../../vc-util/Dom/dynamicCSS'; import type { Ref } from 'vue'; import { computed } from 'vue'; -import type { VueNode } from '../../../type'; -import canUseDom from '../../../../_util/canUseDom'; - -import { - ATTR_CACHE_MAP, - existPath, - getStyleAndHash, - serialize as serializeCacheMap, -} from './cacheMapUtil'; - -const isClientSide = canUseDom(); +import type { VueNode } from '../../type'; const SKIP_CHECK = '_skip_check_'; const MULTI_VALUE = '_multi_value_'; + export type CSSProperties = Omit, 'animationName'> & { animationName?: CSS.PropertiesFallback['animationName'] | Keyframes; }; @@ -60,6 +52,7 @@ export type CSSInterpolation = InterpolationPrimitive | ArrayCSSInterpolation | export type CSSOthersObject = Record; +// @ts-ignore export interface CSSObject extends CSSPropertiesWithMultiValues, CSSPseudos, CSSOthersObject {} // ============================================================================ @@ -114,16 +107,6 @@ export interface ParseInfo { parentSelectors: string[]; } -// Global effect style will mount once and not removed -// The effect will not save in SSR cache (e.g. keyframes) -const globalEffectStyleKeys = new Set(); - -/** - * @private Test only. Clear the global effect style keys. - */ -export const _cf = - process.env.NODE_ENV !== 'production' ? () => globalEffectStyleKeys.clear() : undefined; - // Parse CSSObject to style content export const parseStyle = ( interpolation: CSSInterpolation, @@ -258,6 +241,7 @@ export const parseStyle = ( styleStr += `${styleName}:${formatValue};`; } + const actualValue = (value as any)?.value ?? value; if ( typeof value === 'object' && @@ -295,7 +279,7 @@ export const parseStyle = ( // ============================================================================ // == Register == // ============================================================================ -function uniqueHash(path: (string | number)[], styleStr: string) { +export function uniqueHash(path: (string | number)[], styleStr: string) { return hash(`${path.join('%')}${styleStr}`); } @@ -303,6 +287,17 @@ function uniqueHash(path: (string | number)[], styleStr: string) { // return null; // } +export const STYLE_PREFIX = 'style'; + +type StyleCacheValue = [ + styleStr: string, + tokenKey: string, + styleId: string, + effectStyle: Record, + clientOnly: boolean | undefined, + order: number, +]; + /** * Register a style to the global style sheet. */ @@ -337,22 +332,14 @@ export default function useStyleRegister( } // const [cacheStyle[0], cacheStyle[1], cacheStyle[2]] - useGlobalCache< - [ - styleStr: string, - tokenKey: string, - styleId: string, - effectStyle: Record, - clientOnly: boolean | undefined, - order: number, - ] - >( - 'style', + useGlobalCache( + STYLE_PREFIX, fullPath, // Create cache if needed () => { - const { path, hashId, layer, nonce, clientOnly, order = 0 } = info.value; + const { path, hashId, layer, clientOnly, order = 0 } = info.value; const cachePath = fullPath.value.join('|'); + // Get style from SSR inline style directly if (existPath(cachePath)) { const [inlineCacheStyleStr, styleHash] = getStyleAndHash(cachePath); @@ -360,8 +347,10 @@ export default function useStyleRegister( return [inlineCacheStyleStr, tokenKey.value, styleHash, {}, clientOnly, order]; } } + + // Generate style const styleObj = styleFn(); - const { hashPriority, container, transformers, linters, cache } = styleContext.value; + const { hashPriority, transformers, linters } = styleContext.value; const [parsedStyle, effectStyle] = parseStyle(styleObj, { hashId, @@ -371,18 +360,32 @@ export default function useStyleRegister( transformers, linters, }); + const styleStr = normalizeStyle(parsedStyle); const styleId = uniqueHash(fullPath.value, styleStr); - if (isMergedClientSide) { + return [styleStr, tokenKey.value, styleId, effectStyle, clientOnly, order]; + }, + + // Remove cache if no need + ([, , styleId], fromHMR) => { + if ((fromHMR || styleContext.value.autoClear) && isClientSide) { + removeCSS(styleId, { mark: ATTR_MARK }); + } + }, + + // Effect: Inject style here + ([styleStr, , styleId, effectStyle]) => { + if (isMergedClientSide && styleStr !== CSS_FILE_STYLE) { const mergedCSSConfig: Parameters[2] = { mark: ATTR_MARK, prepend: 'queue', - attachTo: container, - priority: order, + attachTo: styleContext.value.container, + priority: info.value.order, }; - const nonceStr = typeof nonce === 'function' ? nonce() : nonce; + const nonceStr = + typeof info.value.nonce === 'function' ? info.value.nonce() : info.value.nonce; if (nonceStr) { mergedCSSConfig.csp = { nonce: nonceStr }; @@ -390,45 +393,33 @@ export default function useStyleRegister( const style = updateCSS(styleStr, styleId, mergedCSSConfig); - (style as any)[CSS_IN_JS_INSTANCE] = cache.instanceId; + (style as any)[CSS_IN_JS_INSTANCE] = styleContext.value.cache.instanceId; // Used for `useCacheToken` to remove on batch when token removed style.setAttribute(ATTR_TOKEN, tokenKey.value); - // Dev usage to find which cache path made this easily + // Debug usage. Dev only if (process.env.NODE_ENV !== 'production') { style.setAttribute(ATTR_CACHE_PATH, fullPath.value.join('|')); } // Inject client side effect style Object.keys(effectStyle).forEach(effectKey => { - if (!globalEffectStyleKeys.has(effectKey)) { - globalEffectStyleKeys.add(effectKey); - - // Inject - updateCSS(normalizeStyle(effectStyle[effectKey]), `_effect-${effectKey}`, { - mark: ATTR_MARK, - prepend: 'queue', - attachTo: container, - }); - } + updateCSS( + normalizeStyle(effectStyle[effectKey]), + `_effect-${effectKey}`, + mergedCSSConfig, + ); }); } - - return [styleStr, tokenKey.value, styleId, effectStyle, clientOnly, order]; - }, - // Remove cache if no need - ([, , styleId], fromHMR) => { - if ((fromHMR || styleContext.value.autoClear) && isClientSide) { - removeCSS(styleId, { mark: ATTR_MARK }); - } }, ); return (node: VueNode) => { return node; // let styleNode: VueNode; - // if (!styleContext.ssrInline || isMergedClientSide || !styleContext.defaultCache) { + + // if (!styleContext.value.ssrInline || isMergedClientSide || !styleContext.value.defaultCache) { // styleNode = ; // } else { // styleNode = ( @@ -451,116 +442,43 @@ export default function useStyleRegister( }; } -// ============================================================================ -// == SSR == -// ============================================================================ -export function extractStyle(cache: Cache, plain = false) { - const matchPrefix = `style%`; - - // prefix with `style` is used for `useStyleRegister` to cache style context - const styleKeys = Array.from(cache.cache.keys()).filter(key => key.startsWith(matchPrefix)); - - // Common effect styles like animation - const effectStyles: Record = {}; - - // Mapping of cachePath to style hash - const cachePathMap: Record = {}; - - let styleText = ''; - - function toStyleStr( - style: string, - tokenKey?: string, - styleId?: string, - customizeAttrs: Record = {}, - ) { - const attrs: Record = { - ...customizeAttrs, - [ATTR_TOKEN]: tokenKey, - [ATTR_MARK]: styleId, - }; - - const attrStr = Object.keys(attrs) - .map(attr => { - const val = attrs[attr]; - return val ? `${attr}="${val}"` : null; - }) - .filter(v => v) - .join(' '); - - return plain ? style : ``; - } +export const extract: ExtractStyle = (cache, effectStyles, options) => { + const [styleStr, tokenKey, styleId, effectStyle, clientOnly, order]: StyleCacheValue = cache; + const { plain } = options || {}; - // ====================== Fill Style ====================== - type OrderStyle = [order: number, style: string]; - - const orderStyles: OrderStyle[] = styleKeys - .map(key => { - const cachePath = key.slice(matchPrefix.length).replace(/%/g, '|'); - - const [styleStr, tokenKey, styleId, effectStyle, clientOnly, order]: [ - string, - string, - string, - Record, - boolean, - number, - ] = cache.cache.get(key)![1]; - - // Skip client only style - if (clientOnly) { - return null! as OrderStyle; - } - - // ====================== Style ====================== - // Used for vc-util - const sharedAttrs = { - 'data-vc-order': 'prependQueue', - 'data-vc-priority': `${order}`, - }; + // Skip client only style + if (clientOnly) { + return null; + } - let keyStyleText = toStyleStr(styleStr, tokenKey, styleId, sharedAttrs); + let keyStyleText = styleStr; - // Save cache path with hash mapping - cachePathMap[cachePath] = styleId; + // ====================== Style ====================== + // Used for rc-util + const sharedAttrs = { + 'data-vc-order': 'prependQueue', + 'data-vc-priority': `${order}`, + }; - // =============== Create effect style =============== - if (effectStyle) { - Object.keys(effectStyle).forEach(effectKey => { - // Effect style can be reused - if (!effectStyles[effectKey]) { - effectStyles[effectKey] = true; - keyStyleText += toStyleStr( - normalizeStyle(effectStyle[effectKey]), - tokenKey, - `_effect-${effectKey}`, - sharedAttrs, - ); - } - }); + keyStyleText = toStyleStr(styleStr, tokenKey, styleId, sharedAttrs, plain); + + // =============== Create effect style =============== + if (effectStyle) { + Object.keys(effectStyle).forEach(effectKey => { + // Effect style can be reused + if (!effectStyles[effectKey]) { + effectStyles[effectKey] = true; + const effectStyleStr = normalizeStyle(effectStyle[effectKey]); + keyStyleText += toStyleStr( + effectStyleStr, + tokenKey, + `_effect-${effectKey}`, + sharedAttrs, + plain, + ); } - - const ret: OrderStyle = [order, keyStyleText]; - - return ret; - }) - .filter(o => o); - - orderStyles - .sort((o1, o2) => o1[0] - o2[0]) - .forEach(([, style]) => { - styleText += style; }); + } - // ==================== Fill Cache Path ==================== - styleText += toStyleStr( - `.${ATTR_CACHE_MAP}{content:"${serializeCacheMap(cachePathMap)}";}`, - undefined, - undefined, - { - [ATTR_CACHE_MAP]: ATTR_CACHE_MAP, - }, - ); - - return styleText; -} + return [order, styleId, keyStyleText]; +}; diff --git a/components/_util/cssinjs/index.ts b/components/_util/cssinjs/index.ts index 511045180e..5dc10a654c 100644 --- a/components/_util/cssinjs/index.ts +++ b/components/_util/cssinjs/index.ts @@ -1,51 +1,37 @@ -import useCacheToken from './hooks/useCacheToken'; +import extractStyle from './extractStyle'; +import useCacheToken, { getComputedToken } from './hooks/useCacheToken'; +import useCSSVarRegister from './hooks/useCSSVarRegister'; import type { CSSInterpolation, CSSObject } from './hooks/useStyleRegister'; -import useStyleRegister, { extractStyle } from './hooks/useStyleRegister'; +import useStyleRegister from './hooks/useStyleRegister'; import Keyframes from './Keyframes'; import type { Linter } from './linters'; -import { legacyNotSelectorLinter, logicalPropertiesLinter, parentSelectorLinter } from './linters'; -import type { StyleContextProps, StyleProviderProps } from './StyleContext'; -import { createCache, useStyleInject, useStyleProvider, StyleProvider } from './StyleContext'; +import { + legacyNotSelectorLinter, + logicalPropertiesLinter, + NaNLinter, + parentSelectorLinter, +} from './linters'; +import type { StyleProviderProps } from './StyleContext'; +import { createCache, StyleProvider } from './StyleContext'; import type { DerivativeFunc, TokenType } from './theme'; import { createTheme, Theme } from './theme'; import type { Transformer } from './transformers/interface'; import legacyLogicalPropertiesTransformer from './transformers/legacyLogicalProperties'; import px2remTransformer from './transformers/px2rem'; -import { supportLogicProps, supportWhere } from './util'; +import { supportLogicProps, supportWhere, unit } from './util'; +import { token2CSSVar } from './util/css-variables'; -const cssinjs = { - Theme, - createTheme, - useStyleRegister, - useCacheToken, - createCache, - useStyleInject, - useStyleProvider, - Keyframes, - extractStyle, - - // Transformer - legacyLogicalPropertiesTransformer, - px2remTransformer, - - // Linters - logicalPropertiesLinter, - legacyNotSelectorLinter, - parentSelectorLinter, - - // cssinjs - StyleProvider, -}; export { Theme, createTheme, useStyleRegister, + useCSSVarRegister, useCacheToken, createCache, - useStyleInject, - useStyleProvider, + StyleProvider, Keyframes, extractStyle, + getComputedToken, // Transformer legacyLogicalPropertiesTransformer, @@ -55,9 +41,11 @@ export { logicalPropertiesLinter, legacyNotSelectorLinter, parentSelectorLinter, + NaNLinter, - // cssinjs - StyleProvider, + // util + token2CSSVar, + unit, }; export type { TokenType, @@ -66,12 +54,9 @@ export type { DerivativeFunc, Transformer, Linter, - StyleContextProps, StyleProviderProps, }; export const _experimental = { supportModernCSS: () => supportWhere() && supportLogicProps(), }; - -export default cssinjs; diff --git a/components/_util/cssinjs/linters/NaNLinter.ts b/components/_util/cssinjs/linters/NaNLinter.ts new file mode 100644 index 0000000000..72bded76c1 --- /dev/null +++ b/components/_util/cssinjs/linters/NaNLinter.ts @@ -0,0 +1,10 @@ +import type { Linter } from './interface'; +import { lintWarning } from './utils'; + +const linter: Linter = (key, value, info) => { + if ((typeof value === 'string' && /NaN/g.test(value)) || Number.isNaN(value)) { + lintWarning(`Unexpected 'NaN' in property '${key}: ${value}'.`, info); + } +}; + +export default linter; diff --git a/components/_util/cssinjs/linters/index.ts b/components/_util/cssinjs/linters/index.ts index ae7d8cc9a7..2e31efe53d 100644 --- a/components/_util/cssinjs/linters/index.ts +++ b/components/_util/cssinjs/linters/index.ts @@ -3,4 +3,5 @@ export { default as hashedAnimationLinter } from './hashedAnimationLinter'; export type { Linter } from './interface'; export { default as legacyNotSelectorLinter } from './legacyNotSelectorLinter'; export { default as logicalPropertiesLinter } from './logicalPropertiesLinter'; +export { default as NaNLinter } from './NaNLinter'; export { default as parentSelectorLinter } from './parentSelectorLinter'; diff --git a/components/_util/cssinjs/linters/utils.ts b/components/_util/cssinjs/linters/utils.ts index 5b0853ff2f..83c80cb3ce 100644 --- a/components/_util/cssinjs/linters/utils.ts +++ b/components/_util/cssinjs/linters/utils.ts @@ -6,8 +6,8 @@ export function lintWarning(message: string, info: LinterInfo) { devWarning( false, - `[Ant Design Vue CSS-in-JS] ${path ? `Error in '${path}': ` : ''}${message}${ - parentSelectors.length ? ` Selector info: ${parentSelectors.join(' -> ')}` : '' + `[Ant Design Vue CSS-in-JS] ${path ? `Error in ${path}: ` : ''}${message}${ + parentSelectors.length ? ` Selector: ${parentSelectors.join(' | ')}` : '' }`, ); } diff --git a/components/_util/cssinjs/transformers/legacyLogicalProperties.ts b/components/_util/cssinjs/transformers/legacyLogicalProperties.ts index 58e00c89f4..7a33c1a0c5 100644 --- a/components/_util/cssinjs/transformers/legacyLogicalProperties.ts +++ b/components/_util/cssinjs/transformers/legacyLogicalProperties.ts @@ -1,34 +1,36 @@ import type { CSSObject } from '..'; import type { Transformer } from './interface'; -function splitValues(value: string | number) { +function splitValues(value: string | number): [values: (string | number)[], important: boolean] { if (typeof value === 'number') { - return [value]; + return [[value], false]; } - const splitStyle = String(value).split(/\s+/); + const rawStyle = String(value).trim(); + const importantCells = rawStyle.match(/(.*)(!important)/); + + const splitStyle = (importantCells ? importantCells[1] : rawStyle).trim().split(/\s+/); // Combine styles split in brackets, like `calc(1px + 2px)` let temp = ''; let brackets = 0; - return splitStyle.reduce((list, item) => { - if (item.includes('(')) { - temp += item; - brackets += item.split('(').length - 1; - } else if (item.includes(')')) { - temp += ` ${item}`; - brackets -= item.split(')').length - 1; + return [ + splitStyle.reduce((list, item) => { + if (item.includes('(') || item.includes(')')) { + const left = item.split('(').length - 1; + const right = item.split(')').length - 1; + brackets += left - right; + } if (brackets === 0) { - list.push(temp); + list.push(temp + item); temp = ''; + } else if (brackets > 0) { + temp += item; } - } else if (brackets > 0) { - temp += ` ${item}`; - } else { - list.push(item); - } - return list; - }, []); + return list; + }, []), + !!importantCells, + ]; } type MatchValue = string[] & { @@ -105,8 +107,14 @@ const keyMap: Record = { borderEndEndRadius: ['borderBottomRightRadius'], }; -function skipCheck(value: string | number) { - return { _skip_check_: true, value }; +function wrapImportantAndSkipCheck(value: string | number, important: boolean) { + let parsedValue = value; + + if (important) { + parsedValue = `${parsedValue} !important`; + } + + return { _skip_check_: true, value: parsedValue }; } /** @@ -127,25 +135,28 @@ const transform: Transformer = { const matchValue = keyMap[key]; if (matchValue && (typeof value === 'number' || typeof value === 'string')) { - const values = splitValues(value); + const [values, important] = splitValues(value); if (matchValue.length && matchValue.notSplit) { // not split means always give same value like border matchValue.forEach(matchKey => { - clone[matchKey] = skipCheck(value); + clone[matchKey] = wrapImportantAndSkipCheck(value, important); }); } else if (matchValue.length === 1) { // Handle like `marginBlockStart` => `marginTop` - clone[matchValue[0]] = skipCheck(value); + clone[matchValue[0]] = wrapImportantAndSkipCheck(value, important); } else if (matchValue.length === 2) { // Handle like `marginBlock` => `marginTop` & `marginBottom` matchValue.forEach((matchKey, index) => { - clone[matchKey] = skipCheck(values[index] ?? values[0]); + clone[matchKey] = wrapImportantAndSkipCheck(values[index] ?? values[0], important); }); } else if (matchValue.length === 4) { // Handle like `inset` => `top` & `right` & `bottom` & `left` matchValue.forEach((matchKey, index) => { - clone[matchKey] = skipCheck(values[index] ?? values[index - 2] ?? values[0]); + clone[matchKey] = wrapImportantAndSkipCheck( + values[index] ?? values[index - 2] ?? values[0], + important, + ); }); } else { clone[key] = value; diff --git a/components/_util/cssinjs/transformers/px2rem.ts b/components/_util/cssinjs/transformers/px2rem.ts index 4ada83a7d2..593a107b24 100644 --- a/components/_util/cssinjs/transformers/px2rem.ts +++ b/components/_util/cssinjs/transformers/px2rem.ts @@ -1,6 +1,7 @@ /** * respect https://github.com/cuth/postcss-pxtorem */ +// @ts-ignore import unitless from '@emotion/unitless'; import type { CSSObject } from '..'; import type { Transformer } from './interface'; diff --git a/components/_util/cssinjs/hooks/useStyleRegister/cacheMapUtil.ts b/components/_util/cssinjs/util/cacheMapUtil.ts similarity index 95% rename from components/_util/cssinjs/hooks/useStyleRegister/cacheMapUtil.ts rename to components/_util/cssinjs/util/cacheMapUtil.ts index 69a57c9335..813af183d1 100644 --- a/components/_util/cssinjs/hooks/useStyleRegister/cacheMapUtil.ts +++ b/components/_util/cssinjs/util/cacheMapUtil.ts @@ -1,5 +1,5 @@ -import canUseDom from '../../../../_util/canUseDom'; -import { ATTR_MARK } from '../../StyleContext'; +import canUseDom from '../../canUseDom'; +import { ATTR_MARK } from '../StyleContext'; export const ATTR_CACHE_MAP = 'data-ant-cssinjs-cache-path'; diff --git a/components/_util/cssinjs/util/css-variables.ts b/components/_util/cssinjs/util/css-variables.ts new file mode 100644 index 0000000000..6a3dc6c36e --- /dev/null +++ b/components/_util/cssinjs/util/css-variables.ts @@ -0,0 +1,58 @@ +export const token2CSSVar = (token: string, prefix = '') => { + return `--${prefix ? `${prefix}-` : ''}${token}` + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, '$1-$2') + .replace(/([a-z])([A-Z0-9])/g, '$1-$2') + .toLowerCase(); +}; + +export const serializeCSSVar = >( + cssVars: T, + hashId: string, + options?: { + scope?: string; + }, +) => { + if (!Object.keys(cssVars).length) { + return ''; + } + return `.${hashId}${options?.scope ? `.${options.scope}` : ''}{${Object.entries(cssVars) + .map(([key, value]) => `${key}:${value};`) + .join('')}}`; +}; + +export type TokenWithCSSVar = Record> = { + [key in keyof T]?: string | V; +}; + +export const transformToken = = Record>( + token: T, + themeKey: string, + config?: { + prefix?: string; + ignore?: { + [key in keyof T]?: boolean; + }; + unitless?: { + [key in keyof T]?: boolean; + }; + preserve?: { + [key in keyof T]?: boolean; + }; + scope?: string; + }, +): [TokenWithCSSVar, string] => { + const cssVars: Record = {}; + const result: TokenWithCSSVar = {}; + Object.entries(token).forEach(([key, value]) => { + if (config?.preserve?.[key]) { + result[key as keyof T] = value; + } else if ((typeof value === 'string' || typeof value === 'number') && !config?.ignore?.[key]) { + const cssVar = token2CSSVar(key, config?.prefix); + cssVars[cssVar] = + typeof value === 'number' && !config?.unitless?.[key] ? `${value}px` : String(value); + result[key as keyof T] = `var(${cssVar})`; + } + }); + return [result, serializeCSSVar(cssVars, themeKey, { scope: config?.scope })]; +}; diff --git a/components/_util/cssinjs/util.ts b/components/_util/cssinjs/util/index.ts similarity index 63% rename from components/_util/cssinjs/util.ts rename to components/_util/cssinjs/util/index.ts index f22b226d88..ae59f90e7a 100644 --- a/components/_util/cssinjs/util.ts +++ b/components/_util/cssinjs/util/index.ts @@ -1,12 +1,37 @@ import hash from '@emotion/hash'; -import { removeCSS, updateCSS } from '../../vc-util/Dom/dynamicCSS'; -import canUseDom from '../canUseDom'; +import canUseDom from '../../canUseDom'; +import { removeCSS, updateCSS } from '../../../vc-util/Dom/dynamicCSS'; +import { ATTR_MARK, ATTR_TOKEN } from '../StyleContext'; +import { Theme } from '../theme'; + +// Create a cache for memo concat +type NestWeakMap = WeakMap | T>; +const resultCache: NestWeakMap = new WeakMap(); +const RESULT_VALUE = {}; + +export function memoResult(callback: () => R, deps: T[]): R { + let current: WeakMap = resultCache; + for (let i = 0; i < deps.length; i += 1) { + const dep = deps[i]; + if (!current.has(dep)) { + current.set(dep, new WeakMap()); + } + current = current.get(dep)!; + } -import { Theme } from './theme'; + if (!current.has(RESULT_VALUE)) { + current.set(RESULT_VALUE, callback()); + } + + return current.get(RESULT_VALUE); +} // Create a cache here to avoid always loop generate const flattenTokenCache = new WeakMap(); +/** + * Flatten token to string, this will auto cache the result when token not change + */ export function flattenToken(token: any) { let str = flattenTokenCache.get(token) || ''; @@ -116,3 +141,39 @@ export function supportLogicProps(): boolean { return canLogic!; } + +export const isClientSide = canUseDom(); + +export function unit(num: string | number) { + if (typeof num === 'number') { + return `${num}px`; + } + return num; +} + +export function toStyleStr( + style: string, + tokenKey?: string, + styleId?: string, + customizeAttrs: Record = {}, + plain = false, +) { + if (plain) { + return style; + } + const attrs: Record = { + ...customizeAttrs, + [ATTR_TOKEN]: tokenKey, + [ATTR_MARK]: styleId, + }; + + const attrStr = Object.keys(attrs) + .map(attr => { + const val = attrs[attr]; + return val ? `${attr}="${val}"` : null; + }) + .filter(v => v) + .join(' '); + + return ``; +} diff --git a/components/_util/getScroll.ts b/components/_util/getScroll.ts index ca0b10005d..f3e42f1bc9 100644 --- a/components/_util/getScroll.ts +++ b/components/_util/getScroll.ts @@ -2,32 +2,31 @@ export function isWindow(obj: any): obj is Window { return obj !== null && obj !== undefined && obj === obj.window; } -export default function getScroll( - target: HTMLElement | Window | Document | null, - top: boolean, -): number { +const getScroll = (target: HTMLElement | Window | Document | null): number => { if (typeof window === 'undefined') { return 0; } - const method = top ? 'scrollTop' : 'scrollLeft'; let result = 0; if (isWindow(target)) { - result = target[top ? 'scrollY' : 'scrollX']; + result = target.pageYOffset; } else if (target instanceof Document) { - result = target.documentElement[method]; + result = target.documentElement.scrollTop; } else if (target instanceof HTMLElement) { - result = target[method]; + result = target.scrollTop; } else if (target) { // According to the type inference, the `target` is `never` type. // Since we configured the loose mode type checking, and supports mocking the target with such shape below:: // `{ documentElement: { scrollLeft: 200, scrollTop: 400 } }`, // the program may falls into this branch. // Check the corresponding tests for details. Don't sure what is the real scenario this happens. - result = target[method]; + /* biome-ignore lint/complexity/useLiteralKeys: target is a never type */ /* eslint-disable-next-line dot-notation */ + result = target['scrollTop']; } if (target && !isWindow(target) && typeof result !== 'number') { - result = ((target.ownerDocument ?? target) as any).documentElement?.[method]; + result = (target.ownerDocument ?? (target as Document)).documentElement?.scrollTop; } return result; -} +}; + +export default getScroll; diff --git a/components/_util/hooks/useLayoutEffect.ts b/components/_util/hooks/useLayoutEffect.ts new file mode 100644 index 0000000000..6f17aedaa3 --- /dev/null +++ b/components/_util/hooks/useLayoutEffect.ts @@ -0,0 +1,48 @@ +import type { Ref, ShallowRef } from 'vue'; + +import { shallowRef, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'; + +function useLayoutEffect( + fn: (mount: boolean) => void | VoidFunction, + deps?: Ref | Ref[] | ShallowRef | ShallowRef[], +) { + const firstMount = shallowRef(true); + const cleanupFn = ref(null); + let stopWatch = null; + + stopWatch = watch( + deps, + () => { + nextTick(() => { + if (cleanupFn.value) { + cleanupFn.value(); + } + cleanupFn.value = fn(firstMount.value); + }); + }, + { immediate: true, flush: 'post' }, + ); + + onMounted(() => { + firstMount.value = false; + }); + + onUnmounted(() => { + if (cleanupFn.value) { + cleanupFn.value(); + } + if (stopWatch) { + stopWatch(); + } + }); +} + +export const useLayoutUpdateEffect = (callback, deps) => { + useLayoutEffect(firstMount => { + if (!firstMount) { + return callback(); + } + }, deps); +}; + +export default useLayoutEffect; diff --git a/components/_util/scrollTo.ts b/components/_util/scrollTo.ts index 992d6a930e..59bb1436a5 100644 --- a/components/_util/scrollTo.ts +++ b/components/_util/scrollTo.ts @@ -14,7 +14,7 @@ interface ScrollToOptions { export default function scrollTo(y: number, options: ScrollToOptions = {}) { const { getContainer = () => window, callback, duration = 450 } = options; const container = getContainer(); - const scrollTop = getScroll(container, true); + const scrollTop = getScroll(container); const startTime = Date.now(); const frameFunc = () => { diff --git a/components/affix/index.tsx b/components/affix/index.tsx index 8f4b37710f..95155dd397 100644 --- a/components/affix/index.tsx +++ b/components/affix/index.tsx @@ -27,10 +27,11 @@ import useStyle from './style'; function getDefaultTarget() { return typeof window !== 'undefined' ? window : null; } -enum AffixStatus { - None, - Prepare, -} +const AFFIX_STATUS_NONE = 0; +const AFFIX_STATUS_PREPARE = 1; + +type AffixStatus = typeof AFFIX_STATUS_NONE | typeof AFFIX_STATUS_PREPARE; + export interface AffixState { affixStyle?: CSSProperties; placeholderStyle?: CSSProperties; @@ -82,7 +83,7 @@ const Affix = defineComponent({ const state = reactive({ affixStyle: undefined, placeholderStyle: undefined, - status: AffixStatus.None, + status: AFFIX_STATUS_NONE, lastAffix: false, prevTarget: null, timeout: null, @@ -98,7 +99,12 @@ const Affix = defineComponent({ const measure = () => { const { status, lastAffix } = state; const { target } = props; - if (status !== AffixStatus.Prepare || !fixedNode.value || !placeholderNode.value || !target) { + if ( + status !== AFFIX_STATUS_PREPARE || + !fixedNode.value || + !placeholderNode.value || + !target + ) { return; } @@ -108,7 +114,7 @@ const Affix = defineComponent({ } const newState = { - status: AffixStatus.None, + status: AFFIX_STATUS_NONE, } as AffixState; const placeholderRect = getTargetRect(placeholderNode.value as HTMLElement); @@ -172,7 +178,7 @@ const Affix = defineComponent({ }; const prepareMeasure = () => { Object.assign(state, { - status: AffixStatus.Prepare, + status: AFFIX_STATUS_PREPARE, affixStyle: undefined, placeholderStyle: undefined, }); @@ -253,12 +259,13 @@ const Affix = defineComponent({ }); const { prefixCls } = useConfigInject('affix', props); - const [wrapSSR, hashId] = useStyle(prefixCls); + const [wrapSSR, hashId, cssVarCls] = useStyle(prefixCls); return () => { const { affixStyle, placeholderStyle, status } = state; const className = classNames({ [prefixCls.value]: affixStyle, [hashId.value]: true, + [cssVarCls.value]: true, }); const restProps = omit(props, [ 'prefixCls', diff --git a/components/affix/style/index.ts b/components/affix/style/index.ts index c33c3176ee..be895766d1 100644 --- a/components/affix/style/index.ts +++ b/components/affix/style/index.ts @@ -1,15 +1,21 @@ import type { CSSObject } from '../../_util/cssinjs'; -import type { FullToken, GenerateStyle } from '../../theme/internal'; -import { genComponentStyleHook, mergeToken } from '../../theme/internal'; +import { FullToken, GenerateStyle, genStyleHooks, GetDefaultToken } from '../../theme/internal'; -interface AffixToken extends FullToken<'Affix'> { +export interface ComponentToken { + /** + * @desc 弹出层的 z-index + * @descEN z-index of popup + */ zIndexPopup: number; } +interface AffixToken extends FullToken<'Affix'> { + // +} + // ============================== Shared ============================== const genSharedAffixStyle: GenerateStyle = (token): CSSObject => { const { componentCls } = token; - return { [componentCls]: { position: 'fixed', @@ -18,10 +24,9 @@ const genSharedAffixStyle: GenerateStyle = (token): CSSObject => { }; }; -// ============================== Export ============================== -export default genComponentStyleHook('Affix', token => { - const affixToken = mergeToken(token, { - zIndexPopup: token.zIndexBase + 10, - }); - return [genSharedAffixStyle(affixToken)]; +export const prepareComponentToken: GetDefaultToken<'Affix'> = token => ({ + zIndexPopup: token.zIndexBase + 10, }); + +// ============================== Export ============================== +export default genStyleHooks('Affix', genSharedAffixStyle, prepareComponentToken); diff --git a/components/affix/utils.ts b/components/affix/utils.ts index 62ce50f275..08b46ea1e2 100644 --- a/components/affix/utils.ts +++ b/components/affix/utils.ts @@ -9,8 +9,11 @@ export function getTargetRect(target: BindElement): DOMRect { : ({ top: 0, bottom: window.innerHeight } as DOMRect); } -export function getFixedTop(placeholderRect: DOMRect, targetRect: DOMRect, offsetTop: number) { - if (offsetTop !== undefined && targetRect.top > placeholderRect.top - offsetTop) { +export function getFixedTop(placeholderRect: DOMRect, targetRect: DOMRect, offsetTop?: number) { + if ( + offsetTop !== undefined && + Math.round(targetRect.top) > Math.round(placeholderRect.top) - offsetTop + ) { return `${offsetTop + targetRect.top}px`; } return undefined; @@ -19,9 +22,12 @@ export function getFixedTop(placeholderRect: DOMRect, targetRect: DOMRect, offse export function getFixedBottom( placeholderRect: DOMRect, targetRect: DOMRect, - offsetBottom: number, + offsetBottom?: number, ) { - if (offsetBottom !== undefined && targetRect.bottom < placeholderRect.bottom + offsetBottom) { + if ( + offsetBottom !== undefined && + Math.round(targetRect.bottom) < Math.round(placeholderRect.bottom) + offsetBottom + ) { const targetBottomOffset = window.innerHeight - targetRect.bottom; return `${offsetBottom + targetBottomOffset}px`; } @@ -29,7 +35,7 @@ export function getFixedBottom( } // ======================== Observer ======================== -const TRIGGER_EVENTS = [ +const TRIGGER_EVENTS: (keyof WindowEventMap)[] = [ 'resize', 'scroll', 'touchstart', diff --git a/components/alert/index.tsx b/components/alert/index.tsx index f3dead2ba5..9cd8660c99 100644 --- a/components/alert/index.tsx +++ b/components/alert/index.tsx @@ -70,7 +70,7 @@ const Alert = defineComponent({ props: alertProps(), setup(props, { slots, emit, attrs, expose }) { const { prefixCls, direction } = useConfigInject('alert', props); - const [wrapSSR, hashId] = useStyle(prefixCls); + const [wrapSSR, hashId, cssVarCls] = useStyle(prefixCls); const closing = shallowRef(false); const closed = shallowRef(false); const alertNode = shallowRef(); @@ -134,6 +134,7 @@ const Alert = defineComponent({ [`${prefixClsValue}-closable`]: closable, [`${prefixClsValue}-rtl`]: direction.value === 'rtl', [hashId.value]: true, + [cssVarCls.value]: true, }); const closeIcon = closable ? ( diff --git a/components/alert/style/index.ts b/components/alert/style/index.ts index 172674fe57..ffb6f94f39 100644 --- a/components/alert/style/index.ts +++ b/components/alert/style/index.ts @@ -1,13 +1,29 @@ -import type { CSSInterpolation, CSSObject } from '../../_util/cssinjs'; -import type { FullToken, GenerateStyle } from '../../theme/internal'; -import { genComponentStyleHook, mergeToken } from '../../theme/internal'; +import { CSSObject, unit } from '../../_util/cssinjs'; +import { FullToken, GenerateStyle, genStyleHooks, GetDefaultToken } from '../../theme/internal'; import { resetComponent } from '../../style'; - -export interface ComponentToken {} +import { CSSProperties } from 'vue'; + +export interface ComponentToken { + // Component token here + /** + * @desc 默认内间距 + * @descEN Default padding + */ + defaultPadding: CSSProperties['padding']; + /** + * @desc 带有描述的内间距 + * @descEN Padding with description + */ + withDescriptionPadding: CSSProperties['padding']; + /** + * @desc 带有描述时的图标尺寸 + * @descEN Icon size with description + */ + withDescriptionIconSize: number; +} type AlertToken = FullToken<'Alert'> & { - alertIconSizeLG: number; - alertPaddingHorizontal: number; + // Custom token here }; const genAlertTypeStyle = ( @@ -17,8 +33,8 @@ const genAlertTypeStyle = ( token: AlertToken, alertCls: string, ): CSSObject => ({ - backgroundColor: bgColor, - border: `${token.lineWidth}px ${token.lineType} ${borderColor}`, + background: bgColor, + border: `${unit(token.lineWidth)} ${token.lineType} ${borderColor}`, [`${alertCls}-icon`]: { color: iconColor, }, @@ -35,12 +51,11 @@ export const genBaseStyle: GenerateStyle = (token: AlertToken): CSSO lineHeight, borderRadiusLG: borderRadius, motionEaseInOutCirc, - alertIconSizeLG, + withDescriptionIconSize, colorText, - paddingContentVerticalSM, - alertPaddingHorizontal, - paddingMD, - paddingContentHorizontalLG, + colorTextHeading, + withDescriptionPadding, + defaultPadding, } = token; return { @@ -49,7 +64,7 @@ export const genBaseStyle: GenerateStyle = (token: AlertToken): CSSO position: 'relative', display: 'flex', alignItems: 'center', - padding: `${paddingContentVerticalSM}px ${alertPaddingHorizontal}px`, // Fixed horizontal padding here. + padding: defaultPadding, wordWrap: 'break-word', borderRadius, @@ -67,14 +82,14 @@ export const genBaseStyle: GenerateStyle = (token: AlertToken): CSSO lineHeight: 0, }, - [`&-description`]: { + '&-description': { display: 'none', fontSize, lineHeight, }, '&-message': { - color: colorText, + color: colorTextHeading, }, [`&${componentCls}-motion-leave`]: { @@ -96,24 +111,23 @@ export const genBaseStyle: GenerateStyle = (token: AlertToken): CSSO [`${componentCls}-with-description`]: { alignItems: 'flex-start', - paddingInline: paddingContentHorizontalLG, - paddingBlock: paddingMD, - + padding: withDescriptionPadding, [`${componentCls}-icon`]: { marginInlineEnd: marginSM, - fontSize: alertIconSizeLG, + fontSize: withDescriptionIconSize, lineHeight: 0, }, [`${componentCls}-message`]: { display: 'block', marginBottom: marginXS, - color: colorText, + color: colorTextHeading, fontSize: fontSizeLG, }, [`${componentCls}-description`]: { display: 'block', + color: colorText, }, }, @@ -187,7 +201,7 @@ export const genActionStyle: GenerateStyle = (token: AlertToken): CS return { [componentCls]: { - [`&-action`]: { + '&-action': { marginInlineStart: marginXS, }, @@ -196,7 +210,7 @@ export const genActionStyle: GenerateStyle = (token: AlertToken): CS padding: 0, overflow: 'hidden', fontSize: fontSizeIcon, - lineHeight: `${fontSizeIcon}px`, + lineHeight: unit(fontSizeIcon), backgroundColor: 'transparent', border: 'none', outline: 'none', @@ -222,19 +236,17 @@ export const genActionStyle: GenerateStyle = (token: AlertToken): CS }; }; -export const genAlertStyle: GenerateStyle = (token: AlertToken): CSSInterpolation => [ - genBaseStyle(token), - genTypeStyle(token), - genActionStyle(token), -]; - -export default genComponentStyleHook('Alert', token => { - const { fontSizeHeading3 } = token; - - const alertToken = mergeToken(token, { - alertIconSizeLG: fontSizeHeading3, - alertPaddingHorizontal: 12, // Fixed value here. - }); +export const prepareComponentToken: GetDefaultToken<'Alert'> = token => { + const paddingHorizontal = 12; // Fixed value here. + return { + withDescriptionIconSize: token.fontSizeHeading3, + defaultPadding: `${token.paddingContentVerticalSM}px ${paddingHorizontal}px`, + withDescriptionPadding: `${token.paddingMD}px ${token.paddingContentHorizontalLG}px`, + }; +}; - return [genAlertStyle(alertToken)]; -}); +export default genStyleHooks( + 'Alert', + token => [genBaseStyle(token), genTypeStyle(token), genActionStyle(token)], + prepareComponentToken, +); diff --git a/components/anchor/Anchor.tsx b/components/anchor/Anchor.tsx index 60f9e243d1..d727e3d40b 100644 --- a/components/anchor/Anchor.tsx +++ b/components/anchor/Anchor.tsx @@ -23,6 +23,7 @@ import AnchorLink from './AnchorLink'; import PropTypes from '../_util/vue-types'; import devWarning from '../vc-util/devWarning'; import { arrayType } from '../_util/type'; +import useCSSVarCls from '../config-provider/hooks/useCssVarCls'; export type AnchorDirection = 'vertical' | 'horizontal'; @@ -39,8 +40,7 @@ function getOffsetTop(element: HTMLElement, container: AnchorContainer): number if (rect.width || rect.height) { if (container === window) { - container = element.ownerDocument!.documentElement!; - return rect.top - container.clientTop; + return rect.top - element.ownerDocument!.documentElement!.clientTop; } return rect.top - (container as HTMLElement).getBoundingClientRect().top; } @@ -70,6 +70,7 @@ export const anchorProps = () => ({ targetOffset: Number, items: arrayType(), direction: PropTypes.oneOf(['vertical', 'horizontal'] as AnchorDirection[]).def('vertical'), + replace: Boolean, onChange: Function as PropType<(currentActiveLink: string) => void>, onClick: Function as PropType<(e: MouseEvent, link: { title: any; href: string }) => void>, }); @@ -91,7 +92,7 @@ export default defineComponent({ setup(props, { emit, attrs, slots, expose }) { const { prefixCls, getTargetContainer, direction } = useConfigInject('anchor', props); const anchorDirection = computed(() => props.direction ?? 'vertical'); - + const rootCls = useCSSVarCls(prefixCls); if (process.env.NODE_ENV !== 'production') { devWarning( props.items && typeof slots.default !== 'function', @@ -133,7 +134,7 @@ export default defineComponent({ const target = document.getElementById(sharpLinkMatch[1]); if (target) { const top = getOffsetTop(target, container); - if (top < offsetTop + bounds) { + if (top <= offsetTop + bounds) { linkSections.push({ link, top, @@ -170,7 +171,7 @@ export default defineComponent({ } const container = getContainer.value(); - const scrollTop = getScroll(container, true); + const scrollTop = getScroll(container); const eleOffsetTop = getOffsetTop(targetElement, container); let y = scrollTop + eleOffsetTop; y -= targetOffset !== undefined ? targetOffset : offsetTop || 0; @@ -277,6 +278,7 @@ export default defineComponent({ title={title} customTitleProps={option} v-slots={{ customTitle: slots.customTitle }} + replace={props.replace} > {anchorDirection.value === 'vertical' ? createNestedLink(children) : null} @@ -284,7 +286,7 @@ export default defineComponent({ }) : null; - const [wrapSSR, hashId] = useStyle(prefixCls); + const [wrapSSR, hashId, cssVarCls] = useStyle(prefixCls, rootCls); return () => { const { offsetTop, affix, showInkInFixed } = props; @@ -296,6 +298,8 @@ export default defineComponent({ const wrapperClass = classNames(hashId.value, props.wrapperClass, `${pre}-wrapper`, { [`${pre}-wrapper-horizontal`]: anchorDirection.value === 'horizontal', [`${pre}-rtl`]: direction.value === 'rtl', + [rootCls.value]: true, + [cssVarCls.value]: true, }); const anchorClass = classNames(pre, { diff --git a/components/anchor/AnchorLink.tsx b/components/anchor/AnchorLink.tsx index 5c5afa8737..c45fc7ad5c 100644 --- a/components/anchor/AnchorLink.tsx +++ b/components/anchor/AnchorLink.tsx @@ -13,6 +13,7 @@ export const anchorLinkProps = () => ({ href: String, title: anyType VueNode)>(), target: String, + replace: Boolean, /* private use */ customTitleProps: objectType(), }); @@ -53,6 +54,10 @@ export default defineComponent({ const { href } = props; contextHandleClick(e, { title: mergedTitle, href }); scrollTo(href); + if (props.replace) { + e.preventDefault(); + window.location.replace(href); + } }; watch( diff --git a/components/anchor/style/index.ts b/components/anchor/style/index.ts index 119055aac8..387bf26368 100644 --- a/components/anchor/style/index.ts +++ b/components/anchor/style/index.ts @@ -1,21 +1,55 @@ -import type { CSSObject } from '../../_util/cssinjs'; -import type { FullToken, GenerateStyle } from '../../theme/internal'; -import { genComponentStyleHook, mergeToken } from '../../theme/internal'; +import { unit } from '../../_util/cssinjs'; +import { + FullToken, + GenerateStyle, + genStyleHooks, + GetDefaultToken, + mergeToken, +} from '../../theme/internal'; import { resetComponent, textEllipsis } from '../../style'; -export interface ComponentToken {} +export interface ComponentToken { + /** + * @desc 链接纵向内间距 + * @descEN Vertical padding of link + */ + linkPaddingBlock: number; + /** + * @desc 链接横向内间距 + * @descEN Horizontal padding of link + */ + linkPaddingInlineStart: number; +} +/** + * @desc Anchor 组件的 Token + * @descEN Token for Anchor component + */ interface AnchorToken extends FullToken<'Anchor'> { + /** + * @desc 容器块偏移量 + * @descEN Holder block offset + */ holderOffsetBlock: number; - anchorPaddingBlock: number; - anchorPaddingBlockSecondary: number; - anchorPaddingInline: number; - anchorBallSize: number; - anchorTitleBlock: number; + /** + * @desc 次级锚点块内间距 + * @descEN Secondary anchor block padding + */ + anchorPaddingBlockSecondary: number | string; + /** + * @desc 锚点球大小 + * @descEN Anchor ball size + */ + anchorBallSize: number | string; + /** + * @desc 锚点标题块 + * @descEN Anchor title block + */ + anchorTitleBlock: number | string; } // ============================== Shared ============================== -const genSharedAnchorStyle: GenerateStyle = (token): CSSObject => { +const genSharedAnchorStyle: GenerateStyle = token => { const { componentCls, holderOffsetBlock, @@ -24,26 +58,25 @@ const genSharedAnchorStyle: GenerateStyle = (token): CSSObject => { colorPrimary, lineType, colorSplit, + calc, } = token; return { [`${componentCls}-wrapper`]: { - marginBlockStart: -holderOffsetBlock, + marginBlockStart: calc(holderOffsetBlock).mul(-1).equal(), paddingBlockStart: holderOffsetBlock, // delete overflow: auto // overflow: 'auto', - backgroundColor: 'transparent', - [componentCls]: { ...resetComponent(token), position: 'relative', paddingInlineStart: lineWidthBold, [`${componentCls}-link`]: { - paddingBlock: token.anchorPaddingBlock, - paddingInline: `${token.anchorPaddingInline}px 0`, + paddingBlock: token.linkPaddingBlock, + paddingInline: `${unit(token.linkPaddingInlineStart)} 0`, '&-title': { ...textEllipsis, @@ -73,28 +106,21 @@ const genSharedAnchorStyle: GenerateStyle = (token): CSSObject => { [componentCls]: { '&::before': { position: 'absolute', - left: { - _skip_check_: true, - value: 0, - }, + insetInlineStart: 0, top: 0, height: '100%', - borderInlineStart: `${lineWidthBold}px ${lineType} ${colorSplit}`, + borderInlineStart: `${unit(lineWidthBold)} ${lineType} ${colorSplit}`, content: '" "', }, [`${componentCls}-ink`]: { position: 'absolute', - left: { - _skip_check_: true, - value: 0, - }, + insetInlineStart: 0, display: 'none', transform: 'translateY(-50%)', transition: `top ${motionDurationSlow} ease-in-out`, width: lineWidthBold, backgroundColor: colorPrimary, - [`&${componentCls}-ink-visible`]: { display: 'inline-block', }, @@ -109,7 +135,7 @@ const genSharedAnchorStyle: GenerateStyle = (token): CSSObject => { }; }; -const genSharedAnchorHorizontalStyle: GenerateStyle = (token): CSSObject => { +const genSharedAnchorHorizontalStyle: GenerateStyle = token => { const { componentCls, motionDurationSlow, lineWidthBold, colorPrimary } = token; return { @@ -127,7 +153,7 @@ const genSharedAnchorHorizontalStyle: GenerateStyle = (token): CSSO value: 0, }, bottom: 0, - borderBottom: `1px ${token.lineType} ${token.colorSplit}`, + borderBottom: `${unit(token.lineWidth)} ${token.lineType} ${token.colorSplit}`, content: '" "', }, @@ -157,17 +183,23 @@ const genSharedAnchorHorizontalStyle: GenerateStyle = (token): CSSO }; }; -// ============================== Export ============================== -export default genComponentStyleHook('Anchor', token => { - const { fontSize, fontSizeLG, padding, paddingXXS } = token; - - const anchorToken = mergeToken(token, { - holderOffsetBlock: paddingXXS, - anchorPaddingBlock: paddingXXS, - anchorPaddingBlockSecondary: paddingXXS / 2, - anchorPaddingInline: padding, - anchorTitleBlock: (fontSize / 14) * 3, - anchorBallSize: fontSizeLG / 2, - }); - return [genSharedAnchorStyle(anchorToken), genSharedAnchorHorizontalStyle(anchorToken)]; +export const prepareComponentToken: GetDefaultToken<'Anchor'> = token => ({ + linkPaddingBlock: token.paddingXXS, + linkPaddingInlineStart: token.padding, }); + +// ============================== Export ============================== +export default genStyleHooks( + 'Anchor', + token => { + const { fontSize, fontSizeLG, paddingXXS, calc } = token; + const anchorToken = mergeToken(token, { + holderOffsetBlock: paddingXXS, + anchorPaddingBlockSecondary: calc(paddingXXS).div(2).equal(), + anchorTitleBlock: calc(fontSize).div(14).mul(3).equal(), + anchorBallSize: calc(fontSizeLG).div(2).equal(), + }); + return [genSharedAnchorStyle(anchorToken), genSharedAnchorHorizontalStyle(anchorToken)]; + }, + prepareComponentToken, +); diff --git a/components/badge/style/index.ts b/components/badge/style/index.ts index 7589799b61..aa7e1a2ef5 100644 --- a/components/badge/style/index.ts +++ b/components/badge/style/index.ts @@ -4,6 +4,8 @@ import type { FullToken, GenerateStyle } from '../../theme/internal'; import { genComponentStyleHook, mergeToken } from '../../theme/internal'; import { genPresetColor, resetComponent } from '../../style'; +export interface ComponentToken {} + interface BadgeToken extends FullToken<'Badge'> { badgeFontHeight: number; badgeZIndex: number | string; diff --git a/components/breadcrumb/style/index.ts b/components/breadcrumb/style/index.ts index 9d3b24d9cf..d19010fba7 100644 --- a/components/breadcrumb/style/index.ts +++ b/components/breadcrumb/style/index.ts @@ -3,6 +3,8 @@ import type { FullToken, GenerateStyle } from '../../theme/internal'; import { genComponentStyleHook, mergeToken } from '../../theme/internal'; import { genFocusStyle, resetComponent } from '../../style'; +export interface ComponentToken {} + interface BreadcrumbToken extends FullToken<'Breadcrumb'> { breadcrumbBaseColor: string; breadcrumbFontSize: number; diff --git a/components/button/button-group.tsx b/components/button/button-group.tsx index 1403382652..a97f14ad99 100644 --- a/components/button/button-group.tsx +++ b/components/button/button-group.tsx @@ -45,7 +45,11 @@ export default defineComponent({ break; default: // eslint-disable-next-line no-console - devWarning(!size, 'Button.Group', 'Invalid prop `size`.'); + devWarning( + !size || ['large', 'small', 'middle'].includes(size), + 'Button.Group', + 'Invalid prop `size`.', + ); } return { [`${prefixCls.value}`]: true, diff --git a/components/button/button.tsx b/components/button/button.tsx index 32fdf9e21d..e71f278053 100644 --- a/components/button/button.tsx +++ b/components/button/button.tsx @@ -45,7 +45,7 @@ export default defineComponent({ // emits: ['click', 'mousedown'], setup(props, { slots, attrs, emit, expose }) { const { prefixCls, autoInsertSpaceInButton, direction, size } = useConfigInject('btn', props); - const [wrapSSR, hashId] = useStyle(prefixCls); + const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls); const groupSizeContext = GroupSizeContext.useInject(); const disabledContext = useInjectDisabled(); const mergedDisabled = computed(() => props.disabled ?? disabledContext.value); @@ -95,6 +95,7 @@ export default defineComponent({ compactItemClassnames.value, { [hashId.value]: true, + [cssVarCls.value]: true, [`${pre}`]: true, [`${pre}-${shape}`]: shape !== 'default' && shape, [`${pre}-${type}`]: type, @@ -216,7 +217,7 @@ export default defineComponent({ ); if (href !== undefined) { - return wrapSSR( + return wrapCSSVar( {iconNode} {kids} @@ -239,7 +240,7 @@ export default defineComponent({ ); } - return wrapSSR(buttonNode); + return wrapCSSVar(buttonNode); }; }, }); diff --git a/components/button/style/compactCmp.ts b/components/button/style/compactCmp.ts new file mode 100644 index 0000000000..c19b37f7b2 --- /dev/null +++ b/components/button/style/compactCmp.ts @@ -0,0 +1,72 @@ +// Style as inline component +import type { ButtonToken } from './token'; +import { prepareComponentToken, prepareToken } from './token'; +import { genCompactItemStyle } from '../../style/compact-item'; +import { genCompactItemVerticalStyle } from '../../style/compact-item-vertical'; +import type { GenerateStyle } from '../../theme/internal'; +import { genSubStyleComponent } from '../../theme/internal'; +import type { CSSObject } from '../../_util/cssinjs'; +import { unit } from '../../_util/cssinjs'; + +const genButtonCompactStyle: GenerateStyle = token => { + const { componentCls, calc } = token; + + return { + [componentCls]: { + // Special styles for Primary Button + [`&-compact-item${componentCls}-primary`]: { + [`&:not([disabled]) + ${componentCls}-compact-item${componentCls}-primary:not([disabled])`]: + { + position: 'relative', + + '&:before': { + position: 'absolute', + top: calc(token.lineWidth).mul(-1).equal(), + insetInlineStart: calc(token.lineWidth).mul(-1).equal(), + display: 'inline-block', + width: token.lineWidth, + height: `calc(100% + ${unit(token.lineWidth)} * 2)`, + backgroundColor: token.colorPrimaryHover, + content: '""', + }, + }, + }, + // Special styles for Primary Button + '&-compact-vertical-item': { + [`&${componentCls}-primary`]: { + [`&:not([disabled]) + ${componentCls}-compact-vertical-item${componentCls}-primary:not([disabled])`]: + { + position: 'relative', + + '&:before': { + position: 'absolute', + top: calc(token.lineWidth).mul(-1).equal(), + insetInlineStart: calc(token.lineWidth).mul(-1).equal(), + display: 'inline-block', + width: `calc(100% + ${unit(token.lineWidth)} * 2)`, + height: token.lineWidth, + backgroundColor: token.colorPrimaryHover, + content: '""', + }, + }, + }, + }, + }, + }; +}; + +// ============================== Export ============================== +export default genSubStyleComponent( + ['Button', 'compact'], + token => { + const buttonToken = prepareToken(token); + + return [ + // Space Compact + genCompactItemStyle(buttonToken), + genCompactItemVerticalStyle(buttonToken), + genButtonCompactStyle(buttonToken), + ] as CSSObject[]; + }, + prepareComponentToken, +); diff --git a/components/button/style/group.ts b/components/button/style/group.ts index 0bc094bc65..066d3d7119 100644 --- a/components/button/style/group.ts +++ b/components/button/style/group.ts @@ -1,4 +1,5 @@ -import type { ButtonToken } from '.'; +import type { CSSObject } from '../../_util/cssinjs'; +import type { ButtonToken } from './token'; import type { GenerateStyle } from '../../theme/internal'; const genButtonBorderStyle = (buttonTypeCls: string, borderColor: string) => ({ @@ -22,8 +23,8 @@ const genButtonBorderStyle = (buttonTypeCls: string, borderColor: string) => ({ }, }); -const genGroupStyle: GenerateStyle = token => { - const { componentCls, fontSize, lineWidth, colorPrimaryHover, colorErrorHover } = token; +const genGroupStyle: GenerateStyle = token => { + const { componentCls, fontSize, lineWidth, groupBorderColor, colorErrorHover } = token; return { [`${componentCls}-group`]: [ @@ -41,7 +42,7 @@ const genGroupStyle: GenerateStyle = token => { }, '&:not(:first-child)': { - marginInlineStart: -lineWidth, + marginInlineStart: token.calc(lineWidth).mul(-1).equal(), [`&, & > ${componentCls}`]: { borderStartStartRadius: 0, @@ -71,7 +72,7 @@ const genGroupStyle: GenerateStyle = token => { }, // Border Color - genButtonBorderStyle(`${componentCls}-primary`, colorPrimaryHover), + genButtonBorderStyle(`${componentCls}-primary`, groupBorderColor), genButtonBorderStyle(`${componentCls}-danger`, colorErrorHover), ], }; diff --git a/components/button/style/index.ts b/components/button/style/index.ts index 20dfe069b6..d2aa11c1bd 100644 --- a/components/button/style/index.ts +++ b/components/button/style/index.ts @@ -1,51 +1,59 @@ -import type { CSSInterpolation, CSSObject } from '../../_util/cssinjs'; -import type { FullToken, GenerateStyle } from '../../theme/internal'; -import { genComponentStyleHook, mergeToken } from '../../theme/internal'; -import genGroupStyle from './group'; -import { genFocusStyle } from '../../style'; -import { genCompactItemStyle } from '../../style/compact-item'; -import { genCompactItemVerticalStyle } from '../../style/compact-item-vertical'; +import type { CSSObject } from '../../_util/cssinjs'; +import { unit } from '../../_util/cssinjs'; -/** Component only token. Which will handle additional calculation of alias token */ -export interface ComponentToken {} +import { genFocusStyle } from '../../style'; +import type { GenerateStyle } from '../../theme/internal'; +import { genStyleHooks, mergeToken } from '../../theme/internal'; +import genGroupStyle from './group'; +import type { ButtonToken, ComponentToken } from './token'; +import { prepareComponentToken, prepareToken } from './token'; -export interface ButtonToken extends FullToken<'Button'> { - // FIXME: should be removed - colorOutlineDefault: string; - buttonPaddingHorizontal: number; -} +export type { ComponentToken }; // ============================== Shared ============================== const genSharedButtonStyle: GenerateStyle = (token): CSSObject => { - const { componentCls, iconCls } = token; + const { componentCls, iconCls, fontWeight } = token; return { [componentCls]: { outline: 'none', position: 'relative', display: 'inline-block', - fontWeight: 400, + fontWeight, whiteSpace: 'nowrap', textAlign: 'center', backgroundImage: 'none', - backgroundColor: 'transparent', - border: `${token.lineWidth}px ${token.lineType} transparent`, + background: 'transparent', + border: `${unit(token.lineWidth)} ${token.lineType} transparent`, cursor: 'pointer', transition: `all ${token.motionDurationMid} ${token.motionEaseInOut}`, userSelect: 'none', touchAction: 'manipulation', - lineHeight: token.lineHeight, color: token.colorText, + '&:disabled > *': { + pointerEvents: 'none', + }, + '> span': { display: 'inline-block', }, + [`${componentCls}-icon`]: { + lineHeight: 0, + }, + // Leave a space between icon and text. [`> ${iconCls} + span, > span + ${iconCls}`]: { marginInlineStart: token.marginXS, }, + [`&:not(${componentCls}-icon-only) > ${componentCls}-icon`]: { + [`&${componentCls}-loading-icon, &:not(:last-child)`]: { + marginInlineEnd: token.marginXS, + }, + }, + '> a': { color: 'currentColor', }, @@ -54,54 +62,29 @@ const genSharedButtonStyle: GenerateStyle = (token): CSS ...genFocusStyle(token), }, + [`&${componentCls}-two-chinese-chars::first-letter`]: { + letterSpacing: '0.34em', + }, + + [`&${componentCls}-two-chinese-chars > *:not(${iconCls})`]: { + marginInlineEnd: '-0.34em', + letterSpacing: '0.34em', + }, + // make `btn-icon-only` not too narrow [`&-icon-only${componentCls}-compact-item`]: { flex: 'none', }, - // Special styles for Primary Button - [`&-compact-item${componentCls}-primary`]: { - [`&:not([disabled]) + ${componentCls}-compact-item${componentCls}-primary:not([disabled])`]: - { - position: 'relative', - - '&:before': { - position: 'absolute', - top: -token.lineWidth, - insetInlineStart: -token.lineWidth, - display: 'inline-block', - width: token.lineWidth, - height: `calc(100% + ${token.lineWidth * 2}px)`, - backgroundColor: token.colorPrimaryHover, - content: '""', - }, - }, - }, - // Special styles for Primary Button - '&-compact-vertical-item': { - [`&${componentCls}-primary`]: { - [`&:not([disabled]) + ${componentCls}-compact-vertical-item${componentCls}-primary:not([disabled])`]: - { - position: 'relative', - - '&:before': { - position: 'absolute', - top: -token.lineWidth, - insetInlineStart: -token.lineWidth, - display: 'inline-block', - width: `calc(100% + ${token.lineWidth * 2}px)`, - height: token.lineWidth, - backgroundColor: token.colorPrimaryHover, - content: '""', - }, - }, - }, - }, }, - }; + } as CSSObject; }; -const genHoverActiveButtonStyle = (hoverStyle: CSSObject, activeStyle: CSSObject): CSSObject => ({ - '&:not(:disabled)': { +const genHoverActiveButtonStyle = ( + btnCls: string, + hoverStyle: CSSObject, + activeStyle: CSSObject, +): CSSObject => ({ + [`&:not(:disabled):not(${btnCls}-disabled)`]: { '&:hover': hoverStyle, '&:active': activeStyle, }, @@ -117,21 +100,22 @@ const genCircleButtonStyle: GenerateStyle = token => ({ const genRoundButtonStyle: GenerateStyle = token => ({ borderRadius: token.controlHeight, - paddingInlineStart: token.controlHeight / 2, - paddingInlineEnd: token.controlHeight / 2, + paddingInlineStart: token.calc(token.controlHeight).div(2).equal(), + paddingInlineEnd: token.calc(token.controlHeight).div(2).equal(), }); // =============================== Type =============================== const genDisabledStyle: GenerateStyle = token => ({ cursor: 'not-allowed', - borderColor: token.colorBorder, + borderColor: token.borderColorDisabled, color: token.colorTextDisabled, - backgroundColor: token.colorBgContainerDisabled, + background: token.colorBgContainerDisabled, boxShadow: 'none', }); const genGhostButtonStyle = ( btnCls: string, + background: string, textColor: string | false, borderColor: string | false, textColorDisabled: string | false, @@ -141,17 +125,18 @@ const genGhostButtonStyle = ( ): CSSObject => ({ [`&${btnCls}-background-ghost`]: { color: textColor || undefined, - backgroundColor: 'transparent', + background, borderColor: borderColor || undefined, boxShadow: 'none', ...genHoverActiveButtonStyle( + btnCls, { - backgroundColor: 'transparent', + background, ...hoverStyle, }, { - backgroundColor: 'transparent', + background, ...activeStyle, }, ), @@ -165,7 +150,7 @@ const genGhostButtonStyle = ( }); const genSolidDisabledButtonStyle: GenerateStyle = token => ({ - '&:disabled': { + [`&:disabled, &${token.componentCls}-disabled`]: { ...genDisabledStyle(token), }, }); @@ -175,7 +160,7 @@ const genSolidButtonStyle: GenerateStyle = token => ({ }); const genPureDisabledButtonStyle: GenerateStyle = token => ({ - '&:disabled': { + [`&:disabled, &${token.componentCls}-disabled`]: { cursor: 'not-allowed', color: token.colorTextDisabled, }, @@ -185,12 +170,14 @@ const genPureDisabledButtonStyle: GenerateStyle = token const genDefaultButtonStyle: GenerateStyle = token => ({ ...genSolidButtonStyle(token), - backgroundColor: token.colorBgContainer, - borderColor: token.colorBorder, + background: token.defaultBg, + borderColor: token.defaultBorderColor, + color: token.defaultColor, - boxShadow: `0 ${token.controlOutlineWidth}px 0 ${token.controlTmpOutline}`, + boxShadow: token.defaultShadow, ...genHoverActiveButtonStyle( + token.componentCls, { color: token.colorPrimaryHover, borderColor: token.colorPrimaryHover, @@ -203,8 +190,9 @@ const genDefaultButtonStyle: GenerateStyle = token => ({ ...genGhostButtonStyle( token.componentCls, - token.colorBgContainer, - token.colorBgContainer, + token.ghostBg, + token.defaultGhostColor, + token.defaultGhostBorderColor, token.colorTextDisabled, token.colorBorder, ), @@ -214,6 +202,7 @@ const genDefaultButtonStyle: GenerateStyle = token => ({ borderColor: token.colorError, ...genHoverActiveButtonStyle( + token.componentCls, { color: token.colorErrorHover, borderColor: token.colorErrorBorderHover, @@ -226,6 +215,7 @@ const genDefaultButtonStyle: GenerateStyle = token => ({ ...genGhostButtonStyle( token.componentCls, + token.ghostBg, token.colorError, token.colorError, token.colorTextDisabled, @@ -239,24 +229,26 @@ const genDefaultButtonStyle: GenerateStyle = token => ({ const genPrimaryButtonStyle: GenerateStyle = token => ({ ...genSolidButtonStyle(token), - color: token.colorTextLightSolid, - backgroundColor: token.colorPrimary, + color: token.primaryColor, + background: token.colorPrimary, - boxShadow: `0 ${token.controlOutlineWidth}px 0 ${token.controlOutline}`, + boxShadow: token.primaryShadow, ...genHoverActiveButtonStyle( + token.componentCls, { color: token.colorTextLightSolid, - backgroundColor: token.colorPrimaryHover, + background: token.colorPrimaryHover, }, { color: token.colorTextLightSolid, - backgroundColor: token.colorPrimaryActive, + background: token.colorPrimaryActive, }, ), ...genGhostButtonStyle( token.componentCls, + token.ghostBg, token.colorPrimary, token.colorPrimary, token.colorTextDisabled, @@ -272,20 +264,23 @@ const genPrimaryButtonStyle: GenerateStyle = token => ({ ), [`&${token.componentCls}-dangerous`]: { - backgroundColor: token.colorError, - boxShadow: `0 ${token.controlOutlineWidth}px 0 ${token.colorErrorOutline}`, + background: token.colorError, + boxShadow: token.dangerShadow, + color: token.dangerColor, ...genHoverActiveButtonStyle( + token.componentCls, { - backgroundColor: token.colorErrorHover, + background: token.colorErrorHover, }, { - backgroundColor: token.colorErrorActive, + background: token.colorErrorActive, }, ), ...genGhostButtonStyle( token.componentCls, + token.ghostBg, token.colorError, token.colorError, token.colorTextDisabled, @@ -314,8 +309,10 @@ const genLinkButtonStyle: GenerateStyle = token => ({ color: token.colorLink, ...genHoverActiveButtonStyle( + token.componentCls, { color: token.colorLinkHover, + background: token.linkHoverBg, }, { color: token.colorLinkActive, @@ -328,6 +325,7 @@ const genLinkButtonStyle: GenerateStyle = token => ({ color: token.colorError, ...genHoverActiveButtonStyle( + token.componentCls, { color: token.colorErrorHover, }, @@ -343,13 +341,14 @@ const genLinkButtonStyle: GenerateStyle = token => ({ // Type: Text const genTextButtonStyle: GenerateStyle = token => ({ ...genHoverActiveButtonStyle( + token.componentCls, { color: token.colorText, - backgroundColor: token.colorBgTextHover, + background: token.textHoverBg, }, { color: token.colorText, - backgroundColor: token.colorBgTextActive, + background: token.colorBgTextActive, }, ), @@ -360,26 +359,19 @@ const genTextButtonStyle: GenerateStyle = token => ({ ...genPureDisabledButtonStyle(token), ...genHoverActiveButtonStyle( + token.componentCls, { color: token.colorErrorHover, - backgroundColor: token.colorErrorBg, + background: token.colorErrorBg, }, { color: token.colorErrorHover, - backgroundColor: token.colorErrorBg, + background: token.colorErrorBg, }, ), }, }); -// Href and Disabled -const genDisabledButtonStyle: GenerateStyle = token => ({ - ...genDisabledStyle(token), - [`&${token.componentCls}:hover`]: { - ...genDisabledStyle(token), - }, -}); - const genTypeButtonStyle: GenerateStyle = token => { const { componentCls } = token; @@ -389,26 +381,30 @@ const genTypeButtonStyle: GenerateStyle = token => { [`${componentCls}-dashed`]: genDashedButtonStyle(token), [`${componentCls}-link`]: genLinkButtonStyle(token), [`${componentCls}-text`]: genTextButtonStyle(token), - [`${componentCls}-disabled`]: genDisabledButtonStyle(token), + [`${componentCls}-ghost`]: genGhostButtonStyle( + token.componentCls, + token.ghostBg, + token.colorBgContainer, + token.colorBgContainer, + token.colorTextDisabled, + token.colorBorder, + ), }; }; // =============================== Size =============================== -const genSizeButtonStyle = (token: ButtonToken, sizePrefixCls: string = ''): CSSInterpolation => { +const genSizeButtonStyle = (token: ButtonToken, sizePrefixCls: string = '') => { const { componentCls, - iconCls, controlHeight, fontSize, lineHeight, - lineWidth, borderRadius, buttonPaddingHorizontal, + iconCls, + buttonPaddingVertical, } = token; - const paddingVertical = Math.max(0, (controlHeight - fontSize * lineHeight) / 2 - lineWidth); - const paddingHorizontal = buttonPaddingHorizontal - lineWidth; - const iconOnlyCls = `${componentCls}-icon-only`; return [ @@ -416,8 +412,9 @@ const genSizeButtonStyle = (token: ButtonToken, sizePrefixCls: string = ''): CSS { [`${componentCls}${sizePrefixCls}`]: { fontSize, + lineHeight, height: controlHeight, - padding: `${paddingVertical}px ${paddingHorizontal}px`, + padding: `${unit(buttonPaddingVertical!)} ${unit(buttonPaddingHorizontal!)}`, borderRadius, [`&${iconOnlyCls}`]: { @@ -427,8 +424,8 @@ const genSizeButtonStyle = (token: ButtonToken, sizePrefixCls: string = ''): CSS [`&${componentCls}-round`]: { width: 'auto', }, - '> span': { - transform: 'scale(1.143)', // 14px -> 16px + [iconCls]: { + fontSize: token.buttonIconOnlyFontSize, }, }, @@ -441,10 +438,6 @@ const genSizeButtonStyle = (token: ButtonToken, sizePrefixCls: string = ''): CSS [`${componentCls}-loading-icon`]: { transition: `width ${token.motionDurationSlow} ${token.motionEaseInOut}, opacity ${token.motionDurationSlow} ${token.motionEaseInOut}`, }, - - [`&:not(${iconOnlyCls}) ${componentCls}-loading-icon > ${iconCls}`]: { - marginInlineEnd: token.marginXS, - }, }, }, @@ -458,14 +451,24 @@ const genSizeButtonStyle = (token: ButtonToken, sizePrefixCls: string = ''): CSS ]; }; -const genSizeBaseButtonStyle: GenerateStyle = token => genSizeButtonStyle(token); +const genSizeBaseButtonStyle: GenerateStyle = token => + genSizeButtonStyle( + mergeToken(token, { + fontSize: token.contentFontSize, + lineHeight: token.contentLineHeight, + }), + ); const genSizeSmallButtonStyle: GenerateStyle = token => { const smallToken = mergeToken(token, { controlHeight: token.controlHeightSM, + fontSize: token.contentFontSizeSM, + lineHeight: token.contentLineHeightSM, padding: token.paddingXS, - buttonPaddingHorizontal: 8, // Fixed padding + buttonPaddingHorizontal: token.paddingInlineSM, + buttonPaddingVertical: token.paddingBlockSM, borderRadius: token.borderRadiusSM, + buttonIconOnlyFontSize: token.onlyIconSizeSM, }); return genSizeButtonStyle(smallToken, `${token.componentCls}-sm`); @@ -474,8 +477,12 @@ const genSizeSmallButtonStyle: GenerateStyle = token => { const genSizeLargeButtonStyle: GenerateStyle = token => { const largeToken = mergeToken(token, { controlHeight: token.controlHeightLG, - fontSize: token.fontSizeLG, + fontSize: token.contentFontSizeLG, + lineHeight: token.contentLineHeightLG, + buttonPaddingHorizontal: token.paddingInlineLG, + buttonPaddingVertical: token.paddingBlockLG, borderRadius: token.borderRadiusLG, + buttonIconOnlyFontSize: token.onlyIconSizeLG, }); return genSizeButtonStyle(largeToken, `${token.componentCls}-lg`); @@ -493,33 +500,37 @@ const genBlockButtonStyle: GenerateStyle = token => { }; // ============================== Export ============================== -export default genComponentStyleHook('Button', token => { - const { controlTmpOutline, paddingContentHorizontal } = token; - const buttonToken = mergeToken(token, { - colorOutlineDefault: controlTmpOutline, - buttonPaddingHorizontal: paddingContentHorizontal, - }); +export default genStyleHooks( + 'Button', + token => { + const buttonToken = prepareToken(token); - return [ - // Shared - genSharedButtonStyle(buttonToken), + return [ + // Shared + genSharedButtonStyle(buttonToken), - // Size - genSizeSmallButtonStyle(buttonToken), - genSizeBaseButtonStyle(buttonToken), - genSizeLargeButtonStyle(buttonToken), + // Size + genSizeSmallButtonStyle(buttonToken), + genSizeBaseButtonStyle(buttonToken), + genSizeLargeButtonStyle(buttonToken), - // Block - genBlockButtonStyle(buttonToken), + // Block + genBlockButtonStyle(buttonToken), - // Group (type, ghost, danger, disabled, loading) - genTypeButtonStyle(buttonToken), + // Group (type, ghost, danger, loading) + genTypeButtonStyle(buttonToken), - // Button Group - genGroupStyle(buttonToken), - - // Space Compact - genCompactItemStyle(token, { focus: false }), - genCompactItemVerticalStyle(token), - ]; -}); + // Button Group + genGroupStyle(buttonToken), + ]; + }, + prepareComponentToken, + { + unitless: { + fontWeight: true, + contentLineHeight: true, + contentLineHeightSM: true, + contentLineHeightLG: true, + }, + }, +); diff --git a/components/button/style/token.ts b/components/button/style/token.ts new file mode 100644 index 0000000000..c075a55d5a --- /dev/null +++ b/components/button/style/token.ts @@ -0,0 +1,234 @@ +import type { CSSProperties } from 'vue'; +import type { FullToken, GetDefaultToken } from '../../theme/internal'; +import { getLineHeight, mergeToken } from '../../theme/internal'; +import type { GenStyleFn } from '../../theme/util/genComponentStyleHook'; + +/** Component only token. Which will handle additional calculation of alias token */ +export interface ComponentToken { + /** + * @desc 文字字重 + * @descEN Font weight of text + */ + fontWeight: CSSProperties['fontWeight']; + /** + * @desc 默认按钮阴影 + * @descEN Shadow of default button + */ + defaultShadow: string; + /** + * @desc 主要按钮阴影 + * @descEN Shadow of primary button + */ + primaryShadow: string; + /** + * @desc 危险按钮阴影 + * @descEN Shadow of danger button + */ + dangerShadow: string; + /** + * @desc 主要按钮文本颜色 + * @descEN Text color of primary button + */ + primaryColor: string; + /** + * @desc 默认按钮文本颜色 + * @descEN Text color of default button + */ + defaultColor: string; + /** + * @desc 默认按钮背景色 + * @descEN Background color of default button + */ + defaultBg: string; + /** + * @desc 默认按钮边框颜色 + * @descEN Border color of default button + */ + defaultBorderColor: string; + /** + * @desc 危险按钮文本颜色 + * @descEN Text color of danger button + */ + dangerColor: string; + /** + * @desc 禁用状态边框颜色 + * @descEN Border color of disabled button + */ + borderColorDisabled: string; + /** + * @desc 默认幽灵按钮文本颜色 + * @descEN Text color of default ghost button + */ + defaultGhostColor: string; + /** + * @desc 幽灵按钮背景色 + * @descEN Background color of ghost button + */ + ghostBg: string; + /** + * @desc 默认幽灵按钮边框颜色 + * @descEN Border color of default ghost button + */ + defaultGhostBorderColor: string; + /** + * @desc 按钮横向内间距 + * @descEN Horizontal padding of button + */ + paddingInline: CSSProperties['paddingInline']; + /** + * @desc 大号按钮横向内间距 + * @descEN Horizontal padding of large button + */ + paddingInlineLG: CSSProperties['paddingInline']; + /** + * @desc 小号按钮横向内间距 + * @descEN Horizontal padding of small button + */ + paddingInlineSM: CSSProperties['paddingInline']; + /** + * @desc 按钮横向内间距 + * @descEN Horizontal padding of button + */ + paddingBlock: CSSProperties['paddingInline']; + /** + * @desc 大号按钮横向内间距 + * @descEN Horizontal padding of large button + */ + paddingBlockLG: CSSProperties['paddingInline']; + /** + * @desc 小号按钮横向内间距 + * @descEN Horizontal padding of small button + */ + paddingBlockSM: CSSProperties['paddingInline']; + /** + * @desc 只有图标的按钮图标尺寸 + * @descEN Icon size of button which only contains icon + */ + onlyIconSize: number; + /** + * @desc 大号只有图标的按钮图标尺寸 + * @descEN Icon size of large button which only contains icon + */ + onlyIconSizeLG: number; + /** + * @desc 小号只有图标的按钮图标尺寸 + * @descEN Icon size of small button which only contains icon + */ + onlyIconSizeSM: number; + /** + * @desc 按钮组边框颜色 + * @descEN Border color of button group + */ + groupBorderColor: string; + /** + * @desc 链接按钮悬浮态背景色 + * @descEN Background color of link button when hover + */ + linkHoverBg: string; + /** + * @desc 文本按钮悬浮态背景色 + * @descEN Background color of text button when hover + */ + textHoverBg: string; + /** + * @desc 按钮内容字体大小 + * @descEN Font size of button content + */ + contentFontSize: number; + /** + * @desc 大号按钮内容字体大小 + * @descEN Font size of large button content + */ + contentFontSizeLG: number; + /** + * @desc 小号按钮内容字体大小 + * @descEN Font size of small button content + */ + contentFontSizeSM: number; + /** + * @desc 按钮内容字体行高 + * @descEN Line height of button content + */ + contentLineHeight: number; + /** + * @desc 大号按钮内容字体行高 + * @descEN Line height of large button content + */ + contentLineHeightLG: number; + /** + * @desc 小号按钮内容字体行高 + * @descEN Line height of small button content + */ + contentLineHeightSM: number; +} + +export interface ButtonToken extends FullToken<'Button'> { + buttonPaddingHorizontal: CSSProperties['paddingInline']; + buttonPaddingVertical: CSSProperties['paddingBlock']; + buttonIconOnlyFontSize: number; +} + +export const prepareToken: (token: Parameters>[0]) => ButtonToken = token => { + const { paddingInline, onlyIconSize, paddingBlock } = token; + + const buttonToken = mergeToken(token, { + buttonPaddingHorizontal: paddingInline, + buttonPaddingVertical: paddingBlock, + buttonIconOnlyFontSize: onlyIconSize, + }); + + return buttonToken; +}; + +export const prepareComponentToken: GetDefaultToken<'Button'> = token => { + const contentFontSize = token.contentFontSize ?? token.fontSize; + const contentFontSizeSM = token.contentFontSizeSM ?? token.fontSize; + const contentFontSizeLG = token.contentFontSizeLG ?? token.fontSizeLG; + const contentLineHeight = token.contentLineHeight ?? getLineHeight(contentFontSize); + const contentLineHeightSM = token.contentLineHeightSM ?? getLineHeight(contentFontSizeSM); + const contentLineHeightLG = token.contentLineHeightLG ?? getLineHeight(contentFontSizeLG); + + return { + fontWeight: 400, + defaultShadow: `0 ${token.controlOutlineWidth}px 0 ${token.controlTmpOutline}`, + primaryShadow: `0 ${token.controlOutlineWidth}px 0 ${token.controlOutline}`, + dangerShadow: `0 ${token.controlOutlineWidth}px 0 ${token.colorErrorOutline}`, + primaryColor: token.colorTextLightSolid, + dangerColor: token.colorTextLightSolid, + borderColorDisabled: token.colorBorder, + defaultGhostColor: token.colorBgContainer, + ghostBg: 'transparent', + defaultGhostBorderColor: token.colorBgContainer, + paddingInline: token.paddingContentHorizontal - token.lineWidth, + paddingInlineLG: token.paddingContentHorizontal - token.lineWidth, + paddingInlineSM: 8 - token.lineWidth, + onlyIconSize: token.fontSizeLG, + onlyIconSizeSM: token.fontSizeLG - 2, + onlyIconSizeLG: token.fontSizeLG + 2, + groupBorderColor: token.colorPrimaryHover, + linkHoverBg: 'transparent', + textHoverBg: token.colorBgTextHover, + defaultColor: token.colorText, + defaultBg: token.colorBgContainer, + defaultBorderColor: token.colorBorder, + defaultBorderColorDisabled: token.colorBorder, + contentFontSize, + contentFontSizeSM, + contentFontSizeLG, + contentLineHeight, + contentLineHeightSM, + contentLineHeightLG, + paddingBlock: Math.max( + (token.controlHeight - contentFontSize * contentLineHeight) / 2 - token.lineWidth, + 0, + ), + paddingBlockSM: Math.max( + (token.controlHeightSM - contentFontSizeSM * contentLineHeightSM) / 2 - token.lineWidth, + 0, + ), + paddingBlockLG: Math.max( + (token.controlHeightLG - contentFontSizeLG * contentLineHeightLG) / 2 - token.lineWidth, + 0, + ), + }; +}; diff --git a/components/config-provider/context.ts b/components/config-provider/context.ts index f219075952..cf69fc73b9 100644 --- a/components/config-provider/context.ts +++ b/components/config-provider/context.ts @@ -57,6 +57,18 @@ export interface ThemeConfig { algorithm?: MappingAlgorithm | MappingAlgorithm[]; hashed?: boolean; inherit?: boolean; + cssVar?: + | { + /** + * Prefix for css variable, default to `antd`. + */ + prefix?: string; + /** + * Unique key for theme, should be set manually < react@18. + */ + key?: string; + } + | boolean; } export const configProviderProps = () => ({ diff --git a/components/config-provider/hooks/useCssVarCls.ts b/components/config-provider/hooks/useCssVarCls.ts new file mode 100644 index 0000000000..b3e60fd8c1 --- /dev/null +++ b/components/config-provider/hooks/useCssVarCls.ts @@ -0,0 +1,16 @@ +import { useToken } from '../../theme/internal'; +import type { Ref } from 'vue'; +import { computed } from 'vue'; + +/** + * This hook is only for cssVar to add root className for components. + * If root ClassName is needed, this hook could be refactored with `-root` + * @param prefixCls + */ +const useCSSVarCls = (prefixCls: Ref) => { + const [, , , , cssVar] = useToken(); + + return computed(() => (cssVar.value ? `${prefixCls.value}-css-var` : '')); +}; + +export default useCSSVarCls; diff --git a/components/config-provider/hooks/useSize.ts b/components/config-provider/hooks/useSize.ts new file mode 100644 index 0000000000..85fac8d982 --- /dev/null +++ b/components/config-provider/hooks/useSize.ts @@ -0,0 +1,32 @@ +import type { SizeType } from '../SizeContext'; +import { useInjectSize } from '../SizeContext'; +import type { Ref } from 'vue'; +import { computed, shallowRef, watch } from 'vue'; + +const useSize = (customSize?: T | ((ctxSize: SizeType) => T)): Ref => { + const size = useInjectSize(); + + const mergedSize = shallowRef(null); + + watch( + computed(() => { + return [customSize, size.value]; + }), + () => { + if (!customSize) { + mergedSize.value = size.value as T; + } + if (typeof customSize === 'string') { + mergedSize.value = customSize ?? (size.value as T); + } + if (customSize instanceof Function) { + mergedSize.value = customSize(size.value) as T; + } + }, + { immediate: true }, + ); + + return mergedSize; +}; + +export default useSize; diff --git a/components/config-provider/hooks/useTheme.ts b/components/config-provider/hooks/useTheme.ts index 0ed451193c..027a1a6941 100644 --- a/components/config-provider/hooks/useTheme.ts +++ b/components/config-provider/hooks/useTheme.ts @@ -2,13 +2,26 @@ import type { ThemeConfig } from '../context'; import { defaultConfig } from '../../theme/internal'; import type { Ref } from 'vue'; import { computed } from 'vue'; - +import devWarning from '../../vc-util/warning'; +const themeKey = 'antdvtheme'; export default function useTheme(theme?: Ref, parentTheme?: Ref) { const themeConfig = computed(() => theme?.value || {}); const parentThemeConfig = computed(() => themeConfig.value.inherit === false || !parentTheme?.value ? defaultConfig : parentTheme.value, ); + if (process.env.NODE_ENV !== 'production') { + const cssVarEnabled = themeConfig.value.cssVar || parentThemeConfig.value.cssVar; + const validKey = !!( + (typeof themeConfig.value.cssVar === 'object' && themeConfig.value.cssVar?.key) || + themeKey + ); + devWarning( + !cssVarEnabled || validKey, + '[Ant Design Vue ConfigProvider] Missing key in `cssVar` config. Please set `cssVar.key` manually in each ConfigProvider inside `cssVar` enabled ConfigProvider.', + ); + } + const mergedTheme = computed(() => { if (!theme?.value) { return parentTheme?.value; @@ -26,6 +39,17 @@ export default function useTheme(theme?: Ref, parentTheme?: Ref, parentTheme?: Ref { + return 'themekey' + uid++; +}; + +export default useThemeKey; diff --git a/components/config-provider/index.tsx b/components/config-provider/index.tsx index 5b09ec16b8..5167540fc8 100644 --- a/components/config-provider/index.tsx +++ b/components/config-provider/index.tsx @@ -15,7 +15,7 @@ import type { ValidateMessages } from '../form/interface'; import useStyle from './style'; import useTheme from './hooks/useTheme'; import defaultSeedToken from '../theme/themes/seed'; -import type { ConfigProviderInnerProps, ConfigProviderProps, Theme } from './context'; +import type { ConfigProviderInnerProps, ConfigProviderProps, Theme, ThemeConfig } from './context'; import { useConfigContextProvider, useConfigContextInject, @@ -26,7 +26,7 @@ import { import { useProviderSize } from './SizeContext'; import { useProviderDisabled } from './DisabledContext'; import { createTheme } from '../_util/cssinjs'; -import { DesignTokenProvider } from '../theme/internal'; +import { defaultTheme, DesignTokenProvider } from '../theme/context'; export type { ConfigProviderProps, @@ -226,19 +226,47 @@ const ConfigProvider = defineComponent({ // ================================ Dynamic theme ================================ const memoTheme = computed(() => { - const { algorithm, token, ...rest } = mergedTheme.value || {}; + const { algorithm, token, components, cssVar, ...rest } = mergedTheme.value || {}; const themeObj = algorithm && (!Array.isArray(algorithm) || algorithm.length > 0) ? createTheme(algorithm) - : undefined; + : defaultTheme; + + const parsedComponents: any = {}; + Object.entries(components || {}).forEach(([componentName, componentToken]) => { + const parsedToken: typeof componentToken & { theme?: typeof defaultTheme } = { + ...componentToken, + }; + if ('algorithm' in parsedToken) { + if (parsedToken.algorithm === true) { + parsedToken.theme = themeObj; + } else if ( + Array.isArray(parsedToken.algorithm) || + typeof parsedToken.algorithm === 'function' + ) { + parsedToken.theme = createTheme(parsedToken.algorithm as any); + } + delete parsedToken.algorithm; + } + parsedComponents[componentName] = parsedToken; + }); + + const mergedToken = { + ...defaultSeedToken, + ...token, + }; + return { ...rest, theme: themeObj, - token: { - ...defaultSeedToken, - ...token, + token: mergedToken, + components: parsedComponents, + override: { + override: mergedToken, + ...parsedComponents, }, + cssVar: cssVar as Exclude, }; }); const validateMessagesRef = computed(() => { diff --git a/components/config-provider/style/index.ts b/components/config-provider/style/index.ts index 77ed478a06..c3533b2789 100644 --- a/components/config-provider/style/index.ts +++ b/components/config-provider/style/index.ts @@ -1,3 +1,4 @@ +import type { CSSObject } from '../../_util/cssinjs'; import { useStyleRegister } from '../../_util/cssinjs'; import { resetIcon } from '../../style'; import { useToken } from '../../theme/internal'; @@ -13,16 +14,17 @@ const useStyle = (iconPrefixCls: Ref) => { hashId: '', path: ['ant-design-icons', iconPrefixCls.value], })), - () => [ - { - [`.${iconPrefixCls.value}`]: { - ...resetIcon(), - [`.${iconPrefixCls.value} .${iconPrefixCls.value}-icon`]: { - display: 'block', + () => + [ + { + [`.${iconPrefixCls.value}`]: { + ...resetIcon(), + [`.${iconPrefixCls.value} .${iconPrefixCls.value}-icon`]: { + display: 'block', + }, }, }, - }, - ], + ] as CSSObject[], ); }; diff --git a/components/date-picker/style/index.ts b/components/date-picker/style/index.ts index 4e92852fd3..5675e67362 100644 --- a/components/date-picker/style/index.ts +++ b/components/date-picker/style/index.ts @@ -22,6 +22,8 @@ import type { TokenWithCommonCls } from '../../theme/util/genComponentStyleHook' import { resetComponent, roundedArrow, textEllipsis } from '../../style'; import { genCompactItemStyle } from '../../style/compact-item'; +export interface ComponentToken {} + export interface ComponentToken { presetsWidth: number; presetsMaxWidth: number; diff --git a/components/descriptions/style/index.ts b/components/descriptions/style/index.ts index 037edb550c..c4289b7de9 100644 --- a/components/descriptions/style/index.ts +++ b/components/descriptions/style/index.ts @@ -3,6 +3,8 @@ import type { FullToken, GenerateStyle } from '../../theme/internal'; import { genComponentStyleHook, mergeToken } from '../../theme/internal'; import { resetComponent, textEllipsis } from '../../style'; +export interface ComponentToken {} + interface DescriptionsToken extends FullToken<'Descriptions'> { descriptionsTitleMarginBottom: number; descriptionsExtraColor: string; diff --git a/components/float-button/BackTop.tsx b/components/float-button/BackTop.tsx index 7229059752..38dbee0e72 100644 --- a/components/float-button/BackTop.tsx +++ b/components/float-button/BackTop.tsx @@ -60,7 +60,7 @@ const BackTop = defineComponent({ const handleScroll = throttleByAnimationFrame((e: Event | { target: any }) => { const { visibilityHeight } = props; - const scrollTop = getScroll(e.target, true); + const scrollTop = getScroll(e.target); state.visible = scrollTop >= visibilityHeight; }); diff --git a/components/form/style/index.ts b/components/form/style/index.ts index dad4aa62f0..394a3db899 100644 --- a/components/form/style/index.ts +++ b/components/form/style/index.ts @@ -5,6 +5,8 @@ import { genComponentStyleHook, mergeToken } from '../../theme/internal'; import { resetComponent } from '../../style'; import genFormValidateMotionStyle from './explain'; +export interface ComponentToken {} + export interface FormToken extends FullToken<'Form'> { formItemCls: string; rootPrefixCls: string; diff --git a/components/grid/style/index.ts b/components/grid/style/index.ts index 923e01837f..d9df3e2fa8 100644 --- a/components/grid/style/index.ts +++ b/components/grid/style/index.ts @@ -2,6 +2,8 @@ import type { CSSObject } from '../../_util/cssinjs'; import type { FullToken, GenerateStyle } from '../../theme/internal'; import { genComponentStyleHook, mergeToken } from '../../theme/internal'; +export interface ComponentToken {} + interface GridRowToken extends FullToken<'Grid'> {} interface GridColToken extends FullToken<'Grid'> { diff --git a/components/index.ts b/components/index.ts index 3f86413a3a..d9d945c939 100644 --- a/components/index.ts +++ b/components/index.ts @@ -2,7 +2,7 @@ import type { App } from 'vue'; import * as components from './components'; import { default as version } from './version'; -import cssinjs from './_util/cssinjs'; +import * as cssinjs from './_util/cssinjs'; export * from './components'; export * from './_util/cssinjs'; diff --git a/components/input/style/index.ts b/components/input/style/index.ts index 7653f78707..dec232d664 100644 --- a/components/input/style/index.ts +++ b/components/input/style/index.ts @@ -5,6 +5,8 @@ import type { GlobalToken } from '../../theme/interface'; import { clearFix, resetComponent } from '../../style'; import { genCompactItemStyle } from '../../style/compact-item'; +export interface ComponentToken {} + export type InputToken> = T & { inputAffixPadding: number; inputPaddingVertical: number; diff --git a/components/page-header/style/index.ts b/components/page-header/style/index.ts index 2ed3537790..99717577df 100644 --- a/components/page-header/style/index.ts +++ b/components/page-header/style/index.ts @@ -4,6 +4,8 @@ import { genComponentStyleHook, mergeToken } from '../../theme/internal'; import { resetComponent, textEllipsis } from '../../style'; import { operationUnit } from '../../style'; +export interface ComponentToken {} + interface PageHeaderToken extends FullToken<'PageHeader'> { pageHeaderPadding: number; pageHeaderPaddingVertical: number; diff --git a/components/pagination/style/index.tsx b/components/pagination/style/index.tsx index 9f94de9abf..56d6201343 100644 --- a/components/pagination/style/index.tsx +++ b/components/pagination/style/index.tsx @@ -9,6 +9,8 @@ import type { FullToken, GenerateStyle } from '../../theme/internal'; import { genComponentStyleHook, mergeToken } from '../../theme/internal'; import { genFocusOutline, genFocusStyle, resetComponent } from '../../style'; +export interface ComponentToken {} + interface PaginationToken extends InputToken> { paginationItemSize: number; paginationFontFamily: string; diff --git a/components/spin/Progress.tsx b/components/spin/Progress.tsx new file mode 100644 index 0000000000..ea77331396 --- /dev/null +++ b/components/spin/Progress.tsx @@ -0,0 +1,101 @@ +import { defineComponent, ref, computed, watchEffect } from 'vue'; + +export interface ProgressProps { + prefixCls: string; + percent: number; +} + +const viewSize = 100; +const borderWidth = viewSize / 5; +const radius = viewSize / 2 - borderWidth / 2; +const circumference = radius * 2 * Math.PI; +const position = 50; + +const CustomCircle = defineComponent({ + compatConfig: { MODE: 3 }, + inheritAttrs: false, + props: { + dotClassName: String, + style: Object, + hasCircleCls: Boolean, + }, + setup(props) { + const cStyle = computed(() => props.style || {}); + + return () => ( + + ); + }, +}); + +export default defineComponent({ + compatConfig: { MODE: 3 }, + name: 'Progress', + inheritAttrs: false, + props: { + percent: Number, + prefixCls: String, + }, + setup(props) { + const dotClassName = `${props.prefixCls}-dot`; + const holderClassName = `${dotClassName}-holder`; + const hideClassName = `${holderClassName}-hidden`; + + const render = ref(false); + + // ==================== Visible ===================== + watchEffect(() => { + if (props.percent !== 0) { + render.value = true; + } + }); + + // ==================== Progress ==================== + const safePtg = computed(() => Math.max(Math.min(props.percent, 100), 0)); + + const circleStyle = computed(() => ({ + strokeDashoffset: `${circumference / 4}`, + strokeDasharray: `${(circumference * safePtg.value) / 100} ${ + (circumference * (100 - safePtg.value)) / 100 + }`, + })); + + // ===================== Render ===================== + return () => { + if (!render.value) { + return null; + } + + return ( + + + + + + + ); + }; + }, +}); diff --git a/components/spin/Spin.tsx b/components/spin/Spin.tsx index 94155fd398..0e97d01df1 100644 --- a/components/spin/Spin.tsx +++ b/components/spin/Spin.tsx @@ -1,11 +1,22 @@ import type { VNode, ExtractPropTypes, PropType } from 'vue'; -import { onBeforeUnmount, cloneVNode, isVNode, defineComponent, shallowRef, watch } from 'vue'; +import { + onBeforeUnmount, + cloneVNode, + isVNode, + defineComponent, + shallowRef, + watch, + computed, +} from 'vue'; import { debounce } from 'throttle-debounce'; import PropTypes from '../_util/vue-types'; import { filterEmpty, getPropsSlot } from '../_util/props-util'; import initDefaultProps from '../_util/props-util/initDefaultProps'; import useStyle from './style'; import useConfigInject from '../config-provider/hooks/useConfigInject'; +import useCSSVarCls from '../config-provider/hooks/useCssVarCls'; +import Progress from './Progress'; +import usePercent from './usePercent'; export type SpinSize = 'small' | 'default' | 'large'; export const spinProps = () => ({ @@ -16,6 +27,8 @@ export const spinProps = () => ({ tip: PropTypes.any, delay: Number, indicator: PropTypes.any, + fullscreen: Boolean, + percent: [Number, String] as PropType, }); export type SpinProps = Partial>>; @@ -40,11 +53,16 @@ export default defineComponent({ size: 'default', spinning: true, wrapperClassName: '', + fullscreen: false, }), setup(props, { attrs, slots }) { const { prefixCls, size, direction } = useConfigInject('spin', props); - const [wrapSSR, hashId] = useStyle(prefixCls); + const rootCls = useCSSVarCls(prefixCls); + const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls, rootCls); const sSpinning = shallowRef(props.spinning && !shouldDelay(props.spinning, props.delay)); + + const mergedPercent = computed(() => usePercent(sSpinning.value, props.percent)); + let updateSpinning: any; watch( [() => props.spinning, () => props.delay], @@ -63,6 +81,7 @@ export default defineComponent({ onBeforeUnmount(() => { updateSpinning?.cancel(); }); + return () => { const { class: cls, ...divProps } = attrs; const { tip = slots.tip?.() } = props; @@ -78,8 +97,11 @@ export default defineComponent({ [cls as string]: !!cls, }; - function renderIndicator(prefixCls: string) { + function renderIndicator(prefixCls: string, percent: number) { const dotClassName = `${prefixCls}-dot`; + const holderClassName = `${dotClassName}-holder`; + const hideClassName = `${holderClassName}-hidden`; + let indicator = getPropsSlot(slots, props, 'indicator'); // should not be render default indicator when indicator value is null if (indicator === null) { @@ -89,43 +111,87 @@ export default defineComponent({ indicator = indicator.length === 1 ? indicator[0] : indicator; } if (isVNode(indicator)) { - return cloneVNode(indicator, { class: dotClassName }); + return cloneVNode(indicator, { class: dotClassName, percent }); } if (defaultIndicator && isVNode(defaultIndicator())) { - return cloneVNode(defaultIndicator(), { class: dotClassName }); + return cloneVNode(defaultIndicator(), { class: dotClassName, percent }); } return ( - - - - - - + <> + 0 && hideClassName]}> + + {[1, 2, 3, 4].map(i => ( + + ))} + + + {props.percent && } + ); } const spinElement = ( -
- {renderIndicator(prefixCls.value)} - {tip ?
{tip}
: null} +
+ {renderIndicator(prefixCls.value, mergedPercent.value.value)} + {tip ? ( +
+ {tip} +
+ ) : null}
); - if (children && filterEmpty(children).length) { + if (children && filterEmpty(children).length && !props.fullscreen) { const containerClassName = { [`${prefixCls.value}-container`]: true, [`${prefixCls.value}-blur`]: sSpinning.value, + [rootCls.value]: true, + [cssVarCls.value]: true, + [hashId.value]: true, }; - return wrapSSR( -
- {sSpinning.value &&
{spinElement}
} + return wrapCSSVar( +
+ {sSpinning.value && spinElement}
{children}
, ); } - return wrapSSR(spinElement); + + if (props.fullscreen) { + return wrapCSSVar( +
+ {spinElement} +
, + ); + } + + return wrapCSSVar(spinElement); }; }, }); diff --git a/components/spin/demo/custom-indicator.vue b/components/spin/demo/custom-indicator.vue index 566b6cefa4..cb1f2697f7 100644 --- a/components/spin/demo/custom-indicator.vue +++ b/components/spin/demo/custom-indicator.vue @@ -17,15 +17,38 @@ Use custom loading indicator. diff --git a/components/spin/demo/fullscreen.vue b/components/spin/demo/fullscreen.vue new file mode 100644 index 0000000000..2674935348 --- /dev/null +++ b/components/spin/demo/fullscreen.vue @@ -0,0 +1,51 @@ + +--- +order: 8 +title: + zh-CN: 全屏 + en-US: fullscreen +--- + +## zh-CN + +`fullscreen` 属性非常适合创建流畅的页面加载器。它添加了半透明覆盖层,并在其中心放置了一个旋转加载符号。 + +## en-US + +The `fullscreen` mode is perfect for creating page loaders. It adds a dimmed overlay with a centered spinner. + + + + + + diff --git a/components/spin/demo/index.vue b/components/spin/demo/index.vue index 2a1a556717..af8724d55a 100644 --- a/components/spin/demo/index.vue +++ b/components/spin/demo/index.vue @@ -7,6 +7,8 @@ + + + diff --git a/components/spin/demo/tip.vue b/components/spin/demo/tip.vue index c284e0952b..12a7acda69 100644 --- a/components/spin/demo/tip.vue +++ b/components/spin/demo/tip.vue @@ -1,6 +1,6 @@ --- -order: 4 +order: 4 title: zh-CN: 自定义描述文案 en-US: Customized description @@ -17,10 +17,23 @@ Customized description content. diff --git a/components/spin/index.en-US.md b/components/spin/index.en-US.md index 525e9bfd65..264a3106e6 100644 --- a/components/spin/index.en-US.md +++ b/components/spin/index.en-US.md @@ -17,7 +17,9 @@ When part of the page is waiting for asynchronous data or during a rendering pro | Property | Description | Type | Default Value | Version | | --- | --- | --- | --- | --- | | delay | specifies a delay in milliseconds for loading state (prevent flush) | number (milliseconds) | - | | +| fullscreen | Display a backdrop with the `Spin` component | boolean | false | | | indicator | vue node of the spinning indicator | vNode \|slot | - | | +| percent | The progress percentage, when set to `auto`, it will be an indeterminate progress | number \| 'auto' | - | | | size | size of Spin, options: `small`, `default` and `large` | string | `default` | | | spinning | whether Spin is visible | boolean | true | | | tip | customize description content when Spin has children | string \| slot | - | slot 3.0 | diff --git a/components/spin/index.zh-CN.md b/components/spin/index.zh-CN.md index f3537f6c82..d7df71d113 100644 --- a/components/spin/index.zh-CN.md +++ b/components/spin/index.zh-CN.md @@ -18,7 +18,9 @@ coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*i43_ToFrL8YAAA | 参数 | 说明 | 类型 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | delay | 延迟显示加载效果的时间(防止闪烁) | number (毫秒) | - | | +| fullscreen | 显示带有`Spin`组件的背景 | boolean | false | | | indicator | 加载指示符 | vNode \| slot | - | | +| percent | 展示进度,当设置`percent="auto"`时会预估一个永远不会停止的进度 | number \| 'auto' | - | | | size | 组件大小,可选值为 `small` `default` `large` | string | `default` | | | spinning | 是否为加载中状态 | boolean | true | | | tip | 当作为包裹元素时,可以自定义描述文案 | string \| slot | - | slot 3.0 | diff --git a/components/spin/style/index.ts b/components/spin/style/index.ts index 2eccec181a..1aba8430f8 100644 --- a/components/spin/style/index.ts +++ b/components/spin/style/index.ts @@ -1,18 +1,34 @@ import type { CSSObject } from '../../_util/cssinjs'; import { Keyframes } from '../../_util/cssinjs'; -import type { FullToken, GenerateStyle } from '../../theme/internal'; -import { genComponentStyleHook, mergeToken } from '../../theme/internal'; +import type { FullToken, GenerateStyle, GetDefaultToken } from '../../theme/internal'; +import { genStyleHooks, mergeToken } from '../../theme/internal'; import { resetComponent } from '../../style'; export interface ComponentToken { - contentHeight: number; + /** + * @desc 内容区域高度 + * @descEN Height of content area + */ + contentHeight: number | string; + /** + * @desc 加载图标尺寸 + * @descEN Loading icon size + */ + dotSize: number; + /** + * @desc 小号加载图标尺寸 + * @descEN Small loading icon size + */ + dotSizeSM: number; + /** + * @desc 大号加载图标尺寸 + * @descEN Large loading icon size + */ + dotSizeLG: number; } interface SpinToken extends FullToken<'Spin'> { spinDotDefault: string; - spinDotSize: number; - spinDotSizeSM: number; - spinDotSizeLG: number; } const antSpinMove = new Keyframes('antSpinMove', { @@ -23,219 +39,309 @@ const antRotate = new Keyframes('antRotate', { to: { transform: 'rotate(405deg)' }, }); -const genSpinStyle: GenerateStyle = (token: SpinToken): CSSObject => ({ - [`${token.componentCls}`]: { - ...resetComponent(token), - position: 'absolute', - display: 'none', - color: token.colorPrimary, - textAlign: 'center', - verticalAlign: 'middle', - opacity: 0, - transition: `transform ${token.motionDurationSlow} ${token.motionEaseInOutCirc}`, - - '&-spinning': { - position: 'static', - display: 'inline-block', - opacity: 1, - }, +const genSpinStyle: GenerateStyle = (token: SpinToken): CSSObject => { + const { componentCls, calc } = token; + return { + [componentCls]: { + ...resetComponent(token), + position: 'absolute', + display: 'none', + color: token.colorPrimary, + fontSize: 0, + textAlign: 'center', + verticalAlign: 'middle', + opacity: 0, + transition: `transform ${token.motionDurationSlow} ${token.motionEaseInOutCirc}`, - '&-nested-loading': { - position: 'relative', - [`> div > ${token.componentCls}`]: { - position: 'absolute', - top: 0, - insetInlineStart: 0, - zIndex: 4, - display: 'block', - width: '100%', - height: '100%', - maxHeight: token.contentHeight, + '&-spinning': { + position: 'relative', + display: 'inline-block', + opacity: 1, + }, - [`${token.componentCls}-dot`]: { - position: 'absolute', - top: '50%', - insetInlineStart: '50%', - margin: -token.spinDotSize / 2, + [`${componentCls}-text`]: { + fontSize: token.fontSize, + paddingTop: calc(calc(token.dotSize).sub(token.fontSize)).div(2).add(2).equal(), + }, + + '&-fullscreen': { + position: 'fixed', + width: '100vw', + height: '100vh', + backgroundColor: token.colorBgMask, + zIndex: token.zIndexPopupBase, + inset: 0, + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + justifyContent: 'center', + opacity: 0, + visibility: 'hidden', + transition: `all ${token.motionDurationMid}`, + '&-show': { + opacity: 1, + visibility: 'visible', + }, + + [componentCls]: { + [`${componentCls}-dot-holder`]: { + color: token.colorWhite, + }, + [`${componentCls}-text`]: { + color: token.colorTextLightSolid, + }, }, + }, - [`${token.componentCls}-text`]: { + '&-nested-loading': { + position: 'relative', + [`> ${componentCls}`]: { position: 'absolute', - top: '50%', + top: 0, + insetInlineStart: 0, + zIndex: 4, + display: 'block', width: '100%', - paddingTop: (token.spinDotSize - token.fontSize) / 2 + 2, - textShadow: `0 1px 2px ${token.colorBgContainer}`, // FIXME: shadow - }, + height: '100%', + maxHeight: token.contentHeight, + [`${componentCls}-dot`]: { + position: 'absolute', + top: '50%', + insetInlineStart: '50%', + margin: calc(token.dotSize).mul(-1).div(2).equal(), + }, - [`&${token.componentCls}-show-text ${token.componentCls}-dot`]: { - marginTop: -(token.spinDotSize / 2) - 10, - }, + [`${componentCls}-text`]: { + position: 'absolute', + top: '50%', + width: '100%', + // paddingTop: (token.spinDotSize - token.fontSize) / 2 + 2, + textShadow: `0 1px 2px ${token.colorBgContainer}`, // FIXME: shadow + }, - '&-sm': { - [`${token.componentCls}-dot`]: { - margin: -token.spinDotSizeSM / 2, + [`&${componentCls}-show-text ${componentCls}-dot`]: { + marginTop: calc(token.dotSize).div(2).mul(-1).sub(10).equal(), }, - [`${token.componentCls}-text`]: { - paddingTop: (token.spinDotSizeSM - token.fontSize) / 2 + 2, + + '&-sm': { + [`${componentCls}-dot`]: { + margin: calc(token.dotSizeSM).mul(-1).div(2).equal(), + }, + [`${componentCls}-text`]: { + paddingTop: calc(calc(token.dotSizeSM).sub(token.fontSize)).div(2).add(2).equal(), + }, + [`&${componentCls}-show-text ${componentCls}-dot`]: { + marginTop: calc(token.dotSizeSM).div(2).mul(-1).sub(10).equal(), + }, }, - [`&${token.componentCls}-show-text ${token.componentCls}-dot`]: { - marginTop: -(token.spinDotSizeSM / 2) - 10, + + '&-lg': { + [`${componentCls}-dot`]: { + margin: calc(token.dotSizeLG).mul(-1).div(2).equal(), + }, + [`${componentCls}-text`]: { + paddingTop: calc(calc(token.dotSizeLG).sub(token.fontSize)).div(2).add(2).equal(), + }, + [`&${componentCls}-show-text ${componentCls}-dot`]: { + marginTop: calc(token.dotSizeLG).div(2).mul(-1).sub(10).equal(), + }, }, }, - '&-lg': { - [`${token.componentCls}-dot`]: { - margin: -(token.spinDotSizeLG / 2), - }, - [`${token.componentCls}-text`]: { - paddingTop: (token.spinDotSizeLG - token.fontSize) / 2 + 2, + [`${componentCls}-container`]: { + position: 'relative', + transition: `opacity ${token.motionDurationSlow}`, + + '&::after': { + position: 'absolute', + top: 0, + insetInlineEnd: 0, + bottom: 0, + insetInlineStart: 0, + zIndex: 10, + width: '100%', + height: '100%', + background: token.colorBgContainer, + opacity: 0, + transition: `all ${token.motionDurationSlow}`, + content: '""', + pointerEvents: 'none', }, - [`&${token.componentCls}-show-text ${token.componentCls}-dot`]: { - marginTop: -(token.spinDotSizeLG / 2) - 10, + }, + + [`${componentCls}-blur`]: { + clear: 'both', + opacity: 0.5, + userSelect: 'none', + pointerEvents: 'none', + + [`&::after`]: { + opacity: 0.4, + pointerEvents: 'auto', }, }, }, - [`${token.componentCls}-container`]: { - position: 'relative', - transition: `opacity ${token.motionDurationSlow}`, + // tip + // ------------------------------ + [`&-tip`]: { + color: token.spinDotDefault, + }, - '&::after': { - position: 'absolute', - top: 0, - insetInlineEnd: 0, - bottom: 0, - insetInlineStart: 0, - zIndex: 10, - width: '100%', - height: '100%', - background: token.colorBgContainer, + // holder + // ------------------------------ + [`${componentCls}-dot-holder`]: { + width: '1em', + height: '1em', + fontSize: token.dotSize, + display: 'inline-block', + transition: `transform ${token.motionDurationSlow} ease, opacity ${token.motionDurationSlow} ease`, + transformOrigin: '50% 50%', + lineHeight: 1, + color: token.colorPrimary, + + '&-hidden': { + transform: 'scale(0.3)', opacity: 0, - transition: `all ${token.motionDurationSlow}`, - content: '""', - pointerEvents: 'none', }, }, - [`${token.componentCls}-blur`]: { - clear: 'both', - opacity: 0.5, - userSelect: 'none', - pointerEvents: 'none', - - [`&::after`]: { - opacity: 0.4, - pointerEvents: 'auto', - }, + // progress + // ------------------------------ + [`${componentCls}-dot-progress`]: { + position: 'absolute', + inset: 0, }, - }, - // tip - // ------------------------------ - [`&-tip`]: { - color: token.spinDotDefault, - }, + // dots + // ------------------------------ + [`${componentCls}-dot`]: { + position: 'relative', + display: 'inline-block', + fontSize: token.dotSize, + width: '1em', + height: '1em', - // dots - // ------------------------------ - [`${token.componentCls}-dot`]: { - position: 'relative', - display: 'inline-block', - fontSize: token.spinDotSize, - width: '1em', - height: '1em', + '&-item': { + position: 'absolute', + display: 'block', + width: calc(token.dotSize).sub(calc(token.marginXXS).div(2)).div(2).equal(), + height: calc(token.dotSize).sub(calc(token.marginXXS).div(2)).div(2).equal(), + backgroundColor: token.colorPrimary, + borderRadius: '100%', + transform: 'scale(0.75)', + transformOrigin: '50% 50%', + opacity: 0.3, + animationName: antSpinMove, + animationDuration: '1s', + animationIterationCount: 'infinite', + animationTimingFunction: 'linear', + animationDirection: 'alternate', - '&-item': { - position: 'absolute', - display: 'block', - width: (token.spinDotSize - token.marginXXS / 2) / 2, - height: (token.spinDotSize - token.marginXXS / 2) / 2, - backgroundColor: token.colorPrimary, - borderRadius: '100%', - transform: 'scale(0.75)', - transformOrigin: '50% 50%', - opacity: 0.3, - animationName: antSpinMove, - animationDuration: '1s', - animationIterationCount: 'infinite', - animationTimingFunction: 'linear', - animationDirection: 'alternate', - - '&:nth-child(1)': { - top: 0, - insetInlineStart: 0, - }, + '&:nth-child(1)': { + top: 0, + insetInlineStart: 0, + }, - '&:nth-child(2)': { - top: 0, - insetInlineEnd: 0, - animationDelay: '0.4s', + '&:nth-child(2)': { + top: 0, + insetInlineEnd: 0, + animationDelay: '0.4s', + }, + + '&:nth-child(3)': { + insetInlineEnd: 0, + bottom: 0, + animationDelay: '0.8s', + }, + + '&:nth-child(4)': { + bottom: 0, + insetInlineStart: 0, + animationDelay: '1.2s', + }, }, - '&:nth-child(3)': { - insetInlineEnd: 0, - bottom: 0, - animationDelay: '0.8s', + '&-spin': { + transform: 'rotate(45deg)', + animationName: antRotate, + animationDuration: '1.2s', + animationIterationCount: 'infinite', + animationTimingFunction: 'linear', }, - '&:nth-child(4)': { - bottom: 0, - insetInlineStart: 0, - animationDelay: '1.2s', + '&-circle': { + strokeLinecap: 'round', + transition: ['stroke-dashoffset', 'stroke-dasharray', 'stroke', 'stroke-width', 'opacity'] + .map(item => `${item} ${token.motionDurationSlow} ease`) + .join(','), + fillOpacity: 0, + stroke: 'currentcolor', }, - }, - '&-spin': { - transform: 'rotate(45deg)', - animationName: antRotate, - animationDuration: '1.2s', - animationIterationCount: 'infinite', - animationTimingFunction: 'linear', + '&-circle-bg': { + stroke: token.colorFillSecondary, + }, }, - }, - // Sizes - // ------------------------------ + // Sizes + // ------------------------------ - // small - [`&-sm ${token.componentCls}-dot`]: { - fontSize: token.spinDotSizeSM, - - i: { - width: (token.spinDotSizeSM - token.marginXXS / 2) / 2, - height: (token.spinDotSizeSM - token.marginXXS / 2) / 2, + [`&-sm ${componentCls}-dot`]: { + '&, &-holder': { + fontSize: token.dotSizeSM, + }, + }, + // small + [`&-sm ${componentCls}-dot-holder`]: { + i: { + width: calc(calc(token.dotSizeSM).sub(calc(token.marginXXS).div(2))) + .div(2) + .equal(), + height: calc(calc(token.dotSizeSM).sub(calc(token.marginXXS).div(2))) + .div(2) + .equal(), + }, }, - }, - // large - [`&-lg ${token.componentCls}-dot`]: { - fontSize: token.spinDotSizeLG, + // large + [`&-lg ${componentCls}-dot`]: { + '&, &-holder': { + fontSize: token.dotSizeLG, + }, + }, + [`&-lg ${componentCls}-dot-holder`]: { + i: { + width: calc(calc(token.dotSizeLG).sub(token.marginXXS)).div(2).equal(), + height: calc(calc(token.dotSizeLG).sub(token.marginXXS)).div(2).equal(), + }, + }, - i: { - width: (token.spinDotSizeLG - token.marginXXS) / 2, - height: (token.spinDotSizeLG - token.marginXXS) / 2, + [`&${componentCls}-show-text ${componentCls}-text`]: { + display: 'block', }, }, + }; +}; - [`&${token.componentCls}-show-text ${token.componentCls}-text`]: { - display: 'block', - }, - }, -}); +export const prepareComponentToken: GetDefaultToken<'Spin'> = token => { + const { controlHeightLG, controlHeight } = token; + return { + contentHeight: 400, + dotSize: controlHeightLG / 2, + dotSizeSM: controlHeightLG * 0.35, + dotSizeLG: controlHeight, + }; +}; // ============================== Export ============================== -export default genComponentStyleHook( +export default genStyleHooks( 'Spin', token => { const spinToken = mergeToken(token, { spinDotDefault: token.colorTextDescription, - spinDotSize: token.controlHeightLG / 2, - spinDotSizeSM: token.controlHeightLG * 0.35, - spinDotSizeLG: token.controlHeight, }); return [genSpinStyle(spinToken)]; }, - { - contentHeight: 400, - }, + prepareComponentToken, ); diff --git a/components/spin/usePercent.ts b/components/spin/usePercent.ts new file mode 100644 index 0000000000..39432e8d93 --- /dev/null +++ b/components/spin/usePercent.ts @@ -0,0 +1,46 @@ +import { ref, computed, watchEffect } from 'vue'; + +const AUTO_INTERVAL = 200; +const STEP_BUCKETS: [limit: number, stepPtg: number][] = [ + [30, 0.05], + [70, 0.03], + [96, 0.01], +]; + +export default function usePercent(spinning: boolean, percent?: number | 'auto') { + const mockPercent = ref(0); + const mockIntervalRef = ref | null>(null); + + const isAuto = ref(percent === 'auto'); + + watchEffect(() => { + // 清除现有定时器 + if (mockIntervalRef.value || !isAuto.value || !spinning) { + clearInterval(mockIntervalRef.value); + mockIntervalRef.value = null; + } + + if (isAuto.value && spinning) { + mockPercent.value = 0; + + mockIntervalRef.value = setInterval(() => { + mockPercent.value = calculateNextPercent(mockPercent.value); + }, AUTO_INTERVAL); + } + }); + + return computed(() => (isAuto.value ? mockPercent.value : +percent)); +} + +function calculateNextPercent(prev: number): number { + const restPTG = 100 - prev; + + for (let i = 0; i < STEP_BUCKETS.length; i += 1) { + const [limit, stepPtg] = STEP_BUCKETS[i]; + if (prev <= limit) { + return prev + restPTG * stepPtg; + } + } + + return prev; +} diff --git a/components/statistic/style/index.tsx b/components/statistic/style/index.tsx index d70e31b0c2..b180819264 100644 --- a/components/statistic/style/index.tsx +++ b/components/statistic/style/index.tsx @@ -3,6 +3,8 @@ import type { FullToken, GenerateStyle } from '../../theme/internal'; import { genComponentStyleHook, mergeToken } from '../../theme/internal'; import { resetComponent } from '../../style'; +export interface ComponentToken {} + interface StatisticToken extends FullToken<'Statistic'> { statisticTitleFontSize: number; statisticContentFontSize: number; diff --git a/components/switch/demo/basic.vue b/components/switch/demo/basic.vue index af7d01376c..076d7b4ea1 100644 --- a/components/switch/demo/basic.vue +++ b/components/switch/demo/basic.vue @@ -19,7 +19,8 @@ The most basic usage. + diff --git a/components/switch/demo/size.vue b/components/switch/demo/size.vue index 7bc9687582..145345d71b 100644 --- a/components/switch/demo/size.vue +++ b/components/switch/demo/size.vue @@ -27,6 +27,6 @@ title: import { reactive } from 'vue'; const state = reactive({ checked1: true, - checked2: false, + checked2: true, }); diff --git a/components/switch/demo/text.vue b/components/switch/demo/text.vue index e1e8a64504..5173471343 100644 --- a/components/switch/demo/text.vue +++ b/components/switch/demo/text.vue @@ -18,7 +18,7 @@ With text and icon.