diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b2abedf11..ff2cbddba1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -492,7 +492,7 @@ jobs: run: npx lessc --js ./dist/antd.variable.less - name: lessc component - run: npx lessc --js ./es/button/style/index.less + run: npx lessc --js ./es/input/style/index.less - name: lessc mixins run: npx lessc --js ./es/style/mixins/index.less diff --git a/components/_util/theme/default.tsx b/components/_util/theme/default.tsx new file mode 100644 index 0000000000..4d29fe0fee --- /dev/null +++ b/components/_util/theme/default.tsx @@ -0,0 +1,33 @@ +import { TinyColor } from '@ctrl/tinycolor'; +import type { DesignToken } from '.'; + +const defaultDesignToken: DesignToken = { + primaryColor: '#1890ff', + errorColor: '#ff4d4f', + + // https://github.com/ant-design/ant-design/issues/20210 + lineHeight: 1.5715, + + borderWidth: 1, + borderStyle: 'solid', + borderRadius: 2, + borderColor: new TinyColor({ h: 0, s: 0, v: 85 }).toHexString(), + + easeInOut: `cubic-bezier(0.645, 0.045, 0.355, 1)`, + + fontSize: 14, + textColor: new TinyColor('#000').setAlpha(0.85).toRgbString(), + textColorDisabled: new TinyColor('#000').setAlpha(0.25).toRgbString(), + + height: 32, + + padding: 16, + margin: 16, + + componentBackground: '#fff', + componentBackgroundDisabled: new TinyColor({ h: 0, s: 0, v: 96 }).toHexString(), + + duration: '0.3s', +}; + +export default defaultDesignToken; diff --git a/components/_util/theme/index.tsx b/components/_util/theme/index.tsx new file mode 100644 index 0000000000..2f89796bf0 --- /dev/null +++ b/components/_util/theme/index.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { generate } from '@ant-design/colors'; +import { CSSObject, Theme, useCacheToken, useStyleRegister } from '@ant-design/cssinjs'; +import defaultDesignToken from './default'; +import version from '../../version'; +import { ConfigContext } from '../../config-provider'; + +export interface DesignToken { + primaryColor: string; + errorColor: string; + lineHeight: number; + borderWidth: number; + borderStyle: string; + borderRadius: number; + borderColor: string; + easeInOut: string; + + fontSize: number; + textColor: string; + textColorDisabled: string; + + height: number; + + padding: number; + margin: number; + + componentBackground: string; + componentBackgroundDisabled: string; + + duration: string; +} + +/** This is temporary token definition since final token definition is not ready yet. */ +export interface DerivativeToken extends DesignToken { + primaryHoverColor: string; + primaryActiveColor: string; + errorHoverColor: string; + errorActiveColor: string; + + linkColor: string; + fontSizeSM: number; + fontSizeLG: number; + heightSM: number; + heightLG: number; + paddingXS: number; + marginXS: number; +} + +export { useStyleRegister }; + +// =============================== Derivative =============================== +function derivative(designToken: DesignToken): DerivativeToken { + const primaryColors = generate(designToken.primaryColor); + const errorColors = generate(designToken.errorColor); + + return { + ...designToken, + + primaryHoverColor: primaryColors[4], + primaryActiveColor: primaryColors[6], + + errorHoverColor: errorColors[4], + errorActiveColor: errorColors[6], + + linkColor: designToken.primaryColor, + fontSizeSM: designToken.fontSize - 2, + fontSizeLG: designToken.fontSize + 2, + heightSM: designToken.height * 0.75, + heightLG: designToken.height * 1.25, + paddingXS: designToken.padding * 0.5, + marginXS: designToken.margin * 0.5, + }; +} + +// ================================ Context ================================= +export const ThemeContext = React.createContext( + new Theme(derivative), +); + +export const DesignTokenContext = React.createContext<{ + token: Partial; + hashed?: string | boolean; +}>({ + token: defaultDesignToken, +}); + +// ================================== Hook ================================== +export function useToken() { + const { iconPrefixCls } = React.useContext(ConfigContext); + const { token: rootDesignToken, hashed } = React.useContext(DesignTokenContext); + const theme = React.useContext(ThemeContext); + + const salt = `${version}-${hashed || ''}`; + + const [token, hashId] = useCacheToken(theme, [defaultDesignToken, rootDesignToken], { + salt, + }); + return [theme, token, iconPrefixCls, hashed ? hashId : '']; +} + +// ================================== Util ================================== +export function withPrefix( + style: CSSObject, + prefixCls: string, + additionalClsList: string[] = [], +): CSSObject { + const fullClsList = [prefixCls, ...additionalClsList].filter(cls => cls).map(cls => `.${cls}`); + + return { + [fullClsList.join('')]: style, + }; +} diff --git a/components/button/style/index.less b/components/button/_style/index.less similarity index 100% rename from components/button/style/index.less rename to components/button/_style/index.less diff --git a/components/button/_style/index.tsx b/components/button/_style/index.tsx new file mode 100644 index 0000000000..3a3ab0de59 --- /dev/null +++ b/components/button/_style/index.tsx @@ -0,0 +1,2 @@ +import '../../style/index.less'; +import './index.less'; diff --git a/components/button/style/mixin.less b/components/button/_style/mixin.less similarity index 100% rename from components/button/style/mixin.less rename to components/button/_style/mixin.less diff --git a/components/button/style/rtl.less b/components/button/_style/rtl.less similarity index 100% rename from components/button/style/rtl.less rename to components/button/_style/rtl.less diff --git a/components/button/button.tsx b/components/button/button.tsx index ee2622326f..d30f738966 100644 --- a/components/button/button.tsx +++ b/components/button/button.tsx @@ -12,6 +12,9 @@ import SizeContext, { SizeType } from '../config-provider/SizeContext'; import LoadingIcon from './LoadingIcon'; import { cloneElement } from '../_util/reactNode'; +// CSSINJS +import useStyle from './style'; + const rxTwoCNChar = /^[\u4e00-\u9fa5]{2}$/; const isTwoCNChar = rxTwoCNChar.test.bind(rxTwoCNChar); function isString(str: any) { @@ -151,10 +154,15 @@ const InternalButton: React.ForwardRefRenderFunction = (pr ...rest } = props; + const { getPrefixCls, autoInsertSpaceInButton, direction } = React.useContext(ConfigContext); + const prefixCls = getPrefixCls('btn', customizePrefixCls); + + // Style + const wrapSSR = useStyle(prefixCls); + const size = React.useContext(SizeContext); const [innerLoading, setLoading] = React.useState(!!loading); const [hasTwoCNChar, setHasTwoCNChar] = React.useState(false); - const { getPrefixCls, autoInsertSpaceInButton, direction } = React.useContext(ConfigContext); const buttonRef = (ref as any) || React.createRef(); const isNeedInserted = () => @@ -225,7 +233,6 @@ const InternalButton: React.ForwardRefRenderFunction = (pr "`link` or `text` button can't be a `ghost` button.", ); - const prefixCls = getPrefixCls('btn', customizePrefixCls); const autoInsertSpace = autoInsertSpaceInButton !== false; const sizeClassNameMap = { large: 'lg', small: 'sm', middle: undefined }; @@ -265,15 +272,15 @@ const InternalButton: React.ForwardRefRenderFunction = (pr const linkButtonRestProps = omit(rest as AnchorButtonProps & { navigate: any }, ['navigate']); if (linkButtonRestProps.href !== undefined) { - return ( + return wrapSSR( {iconNode} {kids} - + , ); } - const buttonNode = ( + let buttonNode = ( ); - if (isUnborderedButtonType(type)) { - return buttonNode; + if (!isUnborderedButtonType(type)) { + buttonNode = {buttonNode}; } - return {buttonNode}; + return wrapSSR(buttonNode); }; const Button = React.forwardRef(InternalButton) as CompoundedComponent; diff --git a/components/button/style/index.tsx b/components/button/style/index.tsx index 3a3ab0de59..44e02c4766 100644 --- a/components/button/style/index.tsx +++ b/components/button/style/index.tsx @@ -1,2 +1,391 @@ -import '../../style/index.less'; -import './index.less'; +// deps-lint-skip-all +import { CSSInterpolation, CSSObject } from '@ant-design/cssinjs'; +import { TinyColor } from '@ctrl/tinycolor'; +import { DerivativeToken, useStyleRegister, useToken, withPrefix } from '../../_util/theme'; + +// ============================== Shared ============================== +const genSharedButtonStyle = ( + prefixCls: string, + iconPrefixCls: string, + token: DerivativeToken, +): CSSObject => ({ + outline: 'none', + position: 'relative', + display: 'inline-block', + fontWeight: 400, + whiteSpace: 'nowrap', + textAlign: 'center', + backgroundImage: 'none', + backgroundColor: 'transparent', + border: `${token.borderWidth}px ${token.borderStyle} transparent`, + cursor: 'pointer', + transition: `all ${token.duration} ${token.easeInOut}`, + userSelect: 'none', + touchAction: 'manipulation', + lineHeight: token.lineHeight, + color: token.textColor, + + '> span': { + display: 'inline-block', + }, + + // Leave a space between icon and text. + [`> .${iconPrefixCls} + span, > span + .${iconPrefixCls}`]: { + marginInlineStart: token.marginXS, + }, + + [`&.${prefixCls}-block`]: { + width: '100%', + }, +}); + +const genHoverActiveButtonStyle = (hoverStyle: CSSObject, activeStyle: CSSObject): CSSObject => ({ + '&:not(:disabled)': { + '&:hover, &:focus': hoverStyle, + '&:active': activeStyle, + }, +}); + +// ============================== Shape =============================== +const genCircleButtonStyle = (token: DerivativeToken): CSSObject => ({ + minWidth: token.height, + paddingLeft: 0, + paddingRight: 0, + borderRadius: '50%', +}); + +const genRoundButtonStyle = (token: DerivativeToken): CSSObject => ({ + borderRadius: token.height, + paddingLeft: token.height / 2, + paddingRight: token.height / 2, + width: 'auto', +}); + +// =============================== Type =============================== +const genGhostButtonStyle = ( + prefixCls: string, + textColor: string | false, + borderColor: string | false, + textColorDisabled: string | false, + borderColorDisabled: string | false, +): CSSObject => ({ + [`&.${prefixCls}-background-ghost`]: { + color: textColor || undefined, + backgroundColor: 'transparent', + borderColor: borderColor || undefined, + + '&:disabled': { + cursor: 'not-allowed', + color: textColorDisabled || undefined, + borderColor: borderColorDisabled || undefined, + }, + }, +}); + +const genSolidDisabledButtonStyle = (token: DerivativeToken): CSSObject => ({ + '&:disabled': { + cursor: 'not-allowed', + borderColor: token.borderColor, + color: token.textColorDisabled, + backgroundColor: token.componentBackgroundDisabled, + boxShadow: 'none', + }, +}); + +const genSolidButtonStyle = (token: DerivativeToken): CSSObject => ({ + borderRadius: token.borderRadius, + + ...genSolidDisabledButtonStyle(token), +}); + +const genPureDisabledButtonStyle = (token: DerivativeToken): CSSObject => ({ + '&:disabled': { + cursor: 'not-allowed', + color: token.textColorDisabled, + }, +}); + +// Type: Default +const genDefaultButtonStyle = (prefixCls: string, token: DerivativeToken): CSSObject => ({ + ...genSolidButtonStyle(token), + + backgroundColor: token.componentBackground, + borderColor: token.borderColor, + + boxShadow: '0 2px 0 rgba(0, 0, 0, 0.015)', + + ...genHoverActiveButtonStyle( + { + color: token.primaryHoverColor, + borderColor: token.primaryHoverColor, + }, + { + color: token.primaryActiveColor, + borderColor: token.primaryActiveColor, + }, + ), + + ...genGhostButtonStyle( + prefixCls, + token.componentBackground, + token.componentBackground, + token.textColorDisabled, + token.borderColor, + ), + + [`&.${prefixCls}-dangerous`]: { + color: token.errorColor, + borderColor: token.errorColor, + + ...genHoverActiveButtonStyle( + { + color: token.errorHoverColor, + borderColor: token.errorHoverColor, + }, + { + color: token.errorActiveColor, + borderColor: token.errorActiveColor, + }, + ), + + ...genGhostButtonStyle( + prefixCls, + token.errorColor, + token.errorColor, + token.textColorDisabled, + token.borderColor, + ), + ...genSolidDisabledButtonStyle(token), + }, +}); + +// Type: Primary +const genPrimaryButtonStyle = (prefixCls: string, token: DerivativeToken): CSSObject => ({ + ...genSolidButtonStyle(token), + + color: '#FFF', + backgroundColor: token.primaryColor, + + boxShadow: '0 2px 0 rgba(0, 0, 0, 0.045)', + + ...genHoverActiveButtonStyle( + { + backgroundColor: token.primaryHoverColor, + }, + { + backgroundColor: token.primaryActiveColor, + }, + ), + + ...genGhostButtonStyle( + prefixCls, + token.primaryColor, + token.primaryColor, + token.textColorDisabled, + token.borderColor, + ), + + [`&.${prefixCls}-dangerous`]: { + backgroundColor: token.errorColor, + + ...genHoverActiveButtonStyle( + { + backgroundColor: token.errorHoverColor, + }, + { + backgroundColor: token.errorActiveColor, + }, + ), + + ...genGhostButtonStyle( + prefixCls, + token.errorColor, + token.errorColor, + token.textColorDisabled, + token.borderColor, + ), + ...genSolidDisabledButtonStyle(token), + }, +}); + +// Type: Dashed +const genDashedButtonStyle = (prefixCls: string, token: DerivativeToken): CSSObject => ({ + ...genDefaultButtonStyle(prefixCls, token), + + borderStyle: 'dashed', +}); + +// Type: Link +const genLinkButtonStyle = (prefixCls: string, token: DerivativeToken): CSSObject => ({ + color: token.linkColor, + + ...genHoverActiveButtonStyle( + { + color: token.primaryHoverColor, + }, + { + color: token.primaryActiveColor, + }, + ), + + ...genPureDisabledButtonStyle(token), + + [`&.${prefixCls}-dangerous`]: { + color: token.errorColor, + + ...genHoverActiveButtonStyle( + { + color: token.errorHoverColor, + }, + { + color: token.errorActiveColor, + }, + ), + + ...genPureDisabledButtonStyle(token), + }, +}); + +// Type: Text +const genTextButtonStyle = (prefixCls: string, token: DerivativeToken): CSSObject => { + const backgroundColor = new TinyColor({ r: 0, g: 0, b: 0, a: 0.018 }); + + return { + ...genHoverActiveButtonStyle( + { + backgroundColor: backgroundColor.toRgbString(), + }, + { + backgroundColor: backgroundColor + .clone() + .setAlpha(backgroundColor.getAlpha() * 1.5) + .toRgbString(), + }, + ), + + ...genPureDisabledButtonStyle(token), + + [`&.${prefixCls}-dangerous`]: { + color: token.errorColor, + + ...genPureDisabledButtonStyle(token), + }, + }; +}; + +const genTypeButtonStyle = (prefixCls: string, token: DerivativeToken): CSSInterpolation => [ + withPrefix(genDefaultButtonStyle(prefixCls, token), `${prefixCls}-default`, []), + withPrefix(genPrimaryButtonStyle(prefixCls, token), `${prefixCls}-primary`, []), + withPrefix(genDashedButtonStyle(prefixCls, token), `${prefixCls}-dashed`, []), + withPrefix(genLinkButtonStyle(prefixCls, token), `${prefixCls}-link`, []), + withPrefix(genTextButtonStyle(prefixCls, token), `${prefixCls}-text`, []), +]; + +// =============================== Size =============================== +const genSizeButtonStyle = ( + prefixCls: string, + iconPrefixCls: string, + sizePrefixCls: string, + token: DerivativeToken, +): CSSInterpolation => { + const paddingVertical = Math.max( + 0, + (token.height - token.fontSize * token.lineHeight) / 2 - token.borderWidth, + ); + const paddingHorizontal = token.padding - token.borderWidth; + + const iconOnlyCls = `.${prefixCls}-icon-only`; + + return [ + // Size + withPrefix( + { + fontSize: token.fontSize, + height: token.height, + padding: `${paddingVertical}px ${paddingHorizontal}px`, + + [`&${iconOnlyCls}`]: { + width: token.height, + paddingLeft: 0, + paddingRight: 0, + + '> span': { + transform: 'scale(1.143)', // 14px -> 16px + }, + }, + + // Loading + [`&.${prefixCls}-loading`]: { + opacity: 0.65, + cursor: 'default', + }, + + [`.${prefixCls}-loading-icon`]: { + transition: `width ${token.duration} ${token.easeInOut}, opacity ${token.duration} ${token.easeInOut}`, + }, + + [`&:not(${iconOnlyCls}) .${prefixCls}-loading-icon > .${iconPrefixCls}`]: { + marginInlineEnd: token.marginXS, + }, + }, + prefixCls, + [sizePrefixCls], + ), + + // Shape - patch prefixCls again to override solid border radius style + withPrefix(genCircleButtonStyle(token), `${prefixCls}-circle`, [prefixCls, sizePrefixCls]), + withPrefix(genRoundButtonStyle(token), `${prefixCls}-round`, [prefixCls, sizePrefixCls]), + ]; +}; + +const genSizeBaseButtonStyle = ( + prefixCls: string, + iconPrefixCls: string, + token: DerivativeToken, +): CSSInterpolation => genSizeButtonStyle(prefixCls, iconPrefixCls, '', token); + +const genSizeSmallButtonStyle = ( + prefixCls: string, + iconPrefixCls: string, + token: DerivativeToken, +): CSSInterpolation => { + const largeToken: DerivativeToken = { + ...token, + height: token.heightSM, + padding: token.paddingXS, + }; + + return genSizeButtonStyle(prefixCls, iconPrefixCls, `${prefixCls}-sm`, largeToken); +}; + +const genSizeLargeButtonStyle = ( + prefixCls: string, + iconPrefixCls: string, + token: DerivativeToken, +): CSSInterpolation => { + const largeToken: DerivativeToken = { + ...token, + height: token.heightLG, + fontSize: token.fontSizeLG, + }; + + return genSizeButtonStyle(prefixCls, iconPrefixCls, `${prefixCls}-lg`, largeToken); +}; + +// ============================== Export ============================== +export default function useStyle(prefixCls: string) { + const [theme, token, iconPrefixCls, hashId] = useToken(); + + return useStyleRegister({ theme, token, hashId, path: [prefixCls] }, () => [ + // Shared + withPrefix(genSharedButtonStyle(prefixCls, iconPrefixCls, token), prefixCls), + + // Size + genSizeSmallButtonStyle(prefixCls, iconPrefixCls, token), + genSizeBaseButtonStyle(prefixCls, iconPrefixCls, token), + genSizeLargeButtonStyle(prefixCls, iconPrefixCls, token), + + // Group (type, ghost, danger, disabled, loading) + genTypeButtonStyle(prefixCls, token), + ]); +} diff --git a/components/config-provider/__tests__/cssinjs.test.tsx b/components/config-provider/__tests__/cssinjs.test.tsx new file mode 100644 index 0000000000..e8e6e15dd9 --- /dev/null +++ b/components/config-provider/__tests__/cssinjs.test.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { mount } from 'enzyme'; +import ConfigProvider from '..'; +import Button from '../../button'; + +describe('ConfigProvider.DynamicTheme', () => { + beforeEach(() => { + Array.from(document.querySelectorAll('style')).forEach(style => { + style.parentNode?.removeChild(style); + }); + }); + + it('customize primary color', () => { + mount( + + + } + destroyOnClose + > +
+ {keys.map((key: keyof typeof defaultToken) => { + const originValue = defaultToken[key]; + const originValueType = typeof originValue; + + let node: React.ReactElement; + + switch (originValueType) { + case 'number': + node = ; + break; + + default: + node = ; + } + + const rules: any[] = [{ required: true }]; + const originColor = new TinyColor(originValue); + if (originValueType === 'string' && originColor.isValid) { + rules.push({ + validator: async (_: any, value: string) => { + if (!new TinyColor(value).isValid) { + throw new Error('Invalidate color type'); + } + }, + }); + } + + return ( + + {node} + + ); + })} + + + + ); +}; diff --git a/site/theme/template/Layout/index.jsx b/site/theme/template/Layout/index.jsx index 01124e251f..f642990a87 100644 --- a/site/theme/template/Layout/index.jsx +++ b/site/theme/template/Layout/index.jsx @@ -15,6 +15,9 @@ import SiteContext from './SiteContext'; import enLocale from '../../en-US'; import cnLocale from '../../zh-CN'; import * as utils from '../utils'; +import defaultDesignToken from '../../../../components/_util/theme/default'; + +import DynamicTheme from './DynamicTheme'; if (typeof window !== 'undefined' && navigator.serviceWorker) { navigator.serviceWorker.getRegistrations().then(registrations => { @@ -78,6 +81,7 @@ export default class Layout extends React.Component { setTheme: this.setTheme, direction: 'ltr', setIframeTheme: this.setIframeTheme, + designToken: defaultDesignToken, }; } @@ -206,7 +210,8 @@ export default class Layout extends React.Component { render() { const { children, helmetContext = {}, ...restProps } = this.props; - const { appLocale, direction, isMobile, theme, setTheme, setIframeTheme } = this.state; + const { appLocale, direction, isMobile, theme, setTheme, setIframeTheme, designToken } = + this.state; const title = appLocale.locale === 'zh-CN' ? 'Ant Design - 一套企业级 UI 设计语言和 React 组件库' @@ -248,9 +253,22 @@ export default class Layout extends React.Component {
{children} + + { + console.log('Change Theme:', newToken); + this.setState({ + designToken: newToken, + }); + }} + /> diff --git a/site/theme/zh-CN.js b/site/theme/zh-CN.js index 60df10fbb5..0bb1beccd1 100644 --- a/site/theme/zh-CN.js +++ b/site/theme/zh-CN.js @@ -1,6 +1,7 @@ module.exports = { locale: 'zh-CN', messages: { + 'app.theme.switch.dynamic': '动态主题', 'app.theme.switch.default': '默认主题', 'app.theme.switch.dark': '暗黑主题', 'app.theme.switch.compact': '紧凑主题', diff --git a/tests/shared/demoTest.tsx b/tests/shared/demoTest.tsx index 2c51e72052..837de149b3 100644 --- a/tests/shared/demoTest.tsx +++ b/tests/shared/demoTest.tsx @@ -4,6 +4,7 @@ import glob from 'glob'; import { render } from 'enzyme'; import MockDate from 'mockdate'; import moment from 'moment'; +import { StyleProvider, createCache } from '@ant-design/cssinjs'; import { TriggerProps } from 'rc-trigger'; import { excludeWarning } from './excludeWarning'; @@ -85,6 +86,9 @@ function baseText(doInject: boolean, component: string, options: Options = {}) { ); } + // Inject cssinjs cache to avoid create