From c279a4b11252898fe087ce0b769fd2ec00908e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=81=87=E8=A7=81=E5=90=8C=E5=AD=A6?= <1875694521@qq.com> Date: Wed, 1 Oct 2025 00:27:52 +0800 Subject: [PATCH] feat(badge): Support better customization with semantic classNames/styles as function (#54977) * feat(badge): Support better customization with semantic classNames/styles as function * update demo and snap * update docs * feat(badge): Support better customization with semantic classNames/styles as function * update demo and snap * update docs * chore: update demo and snap * chore: update demo * chore: update demo --------- Co-authored-by: thinkasany <480968828@qq.com> --- components/badge/Ribbon.tsx | 48 ++--- .../__snapshots__/demo-extend.test.ts.snap | 167 ++++++++++++++++++ .../__snapshots__/demo.test.tsx.snap | 165 +++++++++++++++++ components/badge/__tests__/index.test.tsx | 29 +++ components/badge/__tests__/ribbon.test.tsx | 36 ++++ components/badge/demo/style-class.md | 7 + components/badge/demo/style-class.tsx | 94 ++++++++++ components/badge/index.en-US.md | 7 +- components/badge/index.tsx | 49 +++-- components/badge/index.zh-CN.md | 7 +- 10 files changed, 565 insertions(+), 44 deletions(-) create mode 100644 components/badge/demo/style-class.md create mode 100644 components/badge/demo/style-class.tsx diff --git a/components/badge/Ribbon.tsx b/components/badge/Ribbon.tsx index b1a4a8895a..06902f1300 100644 --- a/components/badge/Ribbon.tsx +++ b/components/badge/Ribbon.tsx @@ -4,6 +4,8 @@ import classNames from 'classnames'; import type { PresetColorType } from '../_util/colors'; import { isPresetColor } from '../_util/colors'; import type { LiteralUnion } from '../_util/type'; +import useMergeSemantic from '../_util/hooks/useMergeSemantic'; +import type { SemanticClassNamesType, SemanticStylesType } from '../_util/hooks/useMergeSemantic'; import useStyle from './style/ribbon'; import { useComponentConfig } from '../config-provider/context'; @@ -11,6 +13,9 @@ type RibbonPlacement = 'start' | 'end'; type SemanticName = 'root' | 'content' | 'indicator'; +export type RibbonClassNamesType = SemanticClassNamesType; +export type RibbonStylesType = SemanticStylesType; + export interface RibbonProps { className?: string; prefixCls?: string; @@ -20,8 +25,8 @@ export interface RibbonProps { children?: React.ReactNode; placement?: RibbonPlacement; rootClassName?: string; - classNames?: Partial>; - styles?: Partial>; + classNames?: RibbonClassNamesType; + styles?: RibbonStylesType; } const Ribbon: React.FC = (props) => { @@ -50,6 +55,20 @@ const Ribbon: React.FC = (props) => { const wrapperCls = `${prefixCls}-wrapper`; const [hashId, cssVarCls] = useStyle(prefixCls, wrapperCls); + // =========== Merged Props for Semantic =========== + const mergedProps: RibbonProps = { + ...props, + placement, + }; + + const [mergedClassNames, mergedStyles] = useMergeSemantic< + RibbonClassNamesType, + RibbonStylesType, + RibbonProps + >([contextClassNames, ribbonClassNames], [contextStyles, styles], undefined, { + props: mergedProps, + }); + const colorInPreset = isPresetColor(color, false); const ribbonCls = classNames( prefixCls, @@ -60,8 +79,7 @@ const Ribbon: React.FC = (props) => { }, className, contextClassName, - contextClassNames.indicator, - ribbonClassNames?.indicator, + mergedClassNames.indicator, ); const colorStyle: React.CSSProperties = {}; @@ -72,34 +90,22 @@ const Ribbon: React.FC = (props) => { } return (
{children}
{text} diff --git a/components/badge/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/badge/__tests__/__snapshots__/demo-extend.test.ts.snap index ea60ddd15e..05808ebe28 100644 --- a/components/badge/__tests__/__snapshots__/demo-extend.test.ts.snap +++ b/components/badge/__tests__/__snapshots__/demo-extend.test.ts.snap @@ -3060,6 +3060,173 @@ Array [ exports[`renders components/badge/demo/status.tsx extend context correctly 2`] = `[]`; +exports[`renders components/badge/demo/style-class.tsx extend context correctly 1`] = ` +
+
+
+ + + + + + + + + 5 + + + + + + + + + + + + + + 5 + + + + + +
+
+
+
+
+
+
+
+
+ Card with custom ribbon +
+
+
+
+ This card has a customized ribbon with semantic classNames and styles. +
+
+
+ + Custom Ribbon + +
+
+
+
+
+
+
+
+ Card with custom ribbon +
+
+
+
+ This card has a customized ribbon with semantic classNames and styles. +
+
+
+ + Custom Ribbon + +
+
+
+
+
+
+`; + +exports[`renders components/badge/demo/style-class.tsx extend context correctly 2`] = `[]`; + exports[`renders components/badge/demo/title.tsx extend context correctly 1`] = `
+
+
+ + + + + + + + + 5 + + + + + + + + + + + + + + 5 + + + + + +
+
+
+
+
+
+
+
+
+ Card with custom ribbon +
+
+
+
+ This card has a customized ribbon with semantic classNames and styles. +
+
+
+ + Custom Ribbon + +
+
+
+
+
+
+
+
+ Card with custom ribbon +
+
+
+
+ This card has a customized ribbon with semantic classNames and styles. +
+
+
+ + Custom Ribbon + +
+
+
+
+
+
+`; + exports[`renders components/badge/demo/title.tsx correctly 1`] = `
{ backgroundColor: 'rgb(0, 0, 255)', }); }); + + it('should support function-based semantic classNames and styles', () => { + const { container } = render( + ({ + root: `badge-${props.size}`, + indicator: 'indicator-small', + })} + styles={({ props }) => ({ + root: { padding: props.size === 'small' ? '2px' : '4px' }, + indicator: { fontSize: '10px' }, + })} + > + test + , + ); + + const element = container.querySelector('.ant-badge'); + + // function-based classNames + expect(element).toHaveClass('badge-small'); + expect(element?.querySelector('sup')).toHaveClass('indicator-small'); + + // function-based styles + expect(element).toHaveStyle({ padding: '2px' }); + expect(element?.querySelector('sup')).toHaveStyle({ fontSize: '10px' }); + }); }); diff --git a/components/badge/__tests__/ribbon.test.tsx b/components/badge/__tests__/ribbon.test.tsx index c85b5fae5f..003da55902 100644 --- a/components/badge/__tests__/ribbon.test.tsx +++ b/components/badge/__tests__/ribbon.test.tsx @@ -113,4 +113,40 @@ describe('Ribbon', () => { expect(indicatorElement.style.color).toBe('green'); expect(contentElement.style.color).toBe('yellow'); }); + + it('should support function-based classNames and styles', () => { + const { container } = render( + ({ + root: `ribbon-${props.placement}`, + indicator: 'ribbon-indicator', + content: 'ribbon-content', + })} + styles={({ props }) => ({ + root: { border: props.placement === 'start' ? '1px solid red' : '1px solid blue' }, + indicator: { opacity: '0.8' }, + content: { fontWeight: 'bold' }, + })} + > +
Test content
+
, + ); + + const rootElement = container.querySelector('.ant-ribbon-wrapper') as HTMLElement; + const indicatorElement = container.querySelector('.ant-ribbon') as HTMLElement; + const contentElement = container.querySelector('.ant-ribbon-content') as HTMLElement; + + // check function-based classNames + expect(rootElement.classList).toContain('ribbon-start'); + expect(indicatorElement.classList).toContain('ribbon-indicator'); + expect(contentElement.classList).toContain('ribbon-content'); + + // check function-based styles + expect(rootElement.style.border).toBe('1px solid red'); + expect(indicatorElement.style.opacity).toBe('0.8'); + expect(contentElement.style.fontWeight).toBe('bold'); + }); }); diff --git a/components/badge/demo/style-class.md b/components/badge/demo/style-class.md new file mode 100644 index 0000000000..79b1666a97 --- /dev/null +++ b/components/badge/demo/style-class.md @@ -0,0 +1,7 @@ +## zh-CN + +通过 `classNames` 和 `styles` 传入对象/函数可以自定义 Badge 的[语义化结构](#semantic-dom)样式。 + +## en-US + +You can customize the [semantic dom](#semantic-dom) style of Badge by passing objects/functions through `classNames` and `styles`. diff --git a/components/badge/demo/style-class.tsx b/components/badge/demo/style-class.tsx new file mode 100644 index 0000000000..3418f24a7b --- /dev/null +++ b/components/badge/demo/style-class.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { Avatar, Badge, Card, Flex, Space } from 'antd'; +import type { BadgeProps } from 'antd'; +import { createStyles } from 'antd-style'; +import type { RibbonProps } from 'antd/es/badge/Ribbon'; + +const useStylesBadge = createStyles(() => ({ + indicator: { + fontSize: 10, + }, +})); + +const useStylesRibbon = createStyles(() => ({ + root: { + width: 400, + border: '1px solid #d9d9d9', + borderRadius: 10, + }, +})); + +const App: React.FC = () => { + const { styles: badgeClassNames } = useStylesBadge(); + const { styles: ribbonClassNames } = useStylesRibbon(); + const badgeStyles: BadgeProps['styles'] = { + root: { + backgroundColor: '#f0f0f0', + }, + }; + + const badgeStylesFn: BadgeProps['styles'] = (info) => { + if (info.props.size === 'default') { + return { + indicator: { + fontSize: 14, + backgroundColor: '#696FC7', + }, + }; + } + return {}; + }; + + const ribbonStyles: RibbonProps['styles'] = { + indicator: { + boxShadow: '0 2px 4px rgba(0,0,0,0.1)', + }, + }; + + const ribbonStylesFn: RibbonProps['styles'] = (info) => { + if (info.props.color === '#696FC7') { + return { + content: { + fontWeight: 'bold', + }, + indicator: { + boxShadow: '0 2px 4px rgba(0,0,0,0.1)', + }, + }; + } + return {}; + }; + + return ( + + + + + + + + + + + + + This card has a customized ribbon with semantic classNames and styles. + + + + + + This card has a customized ribbon with semantic classNames and styles. + + + + + ); +}; + +export default App; diff --git a/components/badge/index.en-US.md b/components/badge/index.en-US.md index deb58685b1..6667083bff 100644 --- a/components/badge/index.en-US.md +++ b/components/badge/index.en-US.md @@ -27,6 +27,7 @@ Badge normally appears in proximity to notifications or user avatars with eye-ca Status Colorful Badge Ribbon +Custom semantic dom styling Ribbon Debug Mixed usage Title @@ -43,14 +44,14 @@ Common props ref:[Common props](/docs/react/common-props) | --- | --- | --- | --- | --- | | color | Customize Badge dot color | string | - | | | count | Number to show in badge | ReactNode | - | | -| classNames | Semantic DOM class | [Record](#semantic-dom) | - | 5.7.0 | +| classNames | Customize class for each semantic structure inside the component. Supports object or function. | Record<[SemanticDOM](#semantic-dom), string> \| (info: { props })=> Record<[SemanticDOM](#semantic-dom), string> | - | | | dot | Whether to display a red dot instead of `count` | boolean | false | | | offset | Set offset of the badge dot | \[number, number] | - | | | overflowCount | Max count to show | number | 99 | | | showZero | Whether to show badge when `count` is zero | boolean | false | | | size | If `count` is set, `size` sets the size of badge | `default` \| `small` | - | - | | status | Set Badge as a status dot | `success` \| `processing` \| `default` \| `error` \| `warning` | - | | -| styles | Semantic DOM style | [Record](#semantic-dom) | - | 5.7.0 | +| styles | Customize inline style for each semantic structure inside the component. Supports object or function. | Record<[SemanticDOM](#semantic-dom), CSSProperties> \| (info: { props })=> Record<[SemanticDOM](#semantic-dom), CSSProperties> | - | | | text | If `status` is set, `text` sets the display text of the status `dot` | ReactNode | - | | | title | Text to show when hovering over the badge | string | - | | @@ -58,8 +59,10 @@ Common props ref:[Common props](/docs/react/common-props) | Property | Description | Type | Default | Version | | --- | --- | --- | --- | --- | +| classNames | Customize class for each semantic structure inside the component. Supports object or function. | Record<[SemanticDOM](#semantic-dom), string> \| (info: { props })=> Record<[SemanticDOM](#semantic-dom), string> | - | | | color | Customize Ribbon color | string | - | | | placement | The placement of the Ribbon, `start` and `end` follow text direction (RTL or LTR) | `start` \| `end` | `end` | | +| styles | Customize inline style for each semantic structure inside the component. Supports object or function. | Record<[SemanticDOM](#semantic-dom), CSSProperties> \| (info: { props })=> Record<[SemanticDOM](#semantic-dom), CSSProperties> | - | | | text | Content inside the Ribbon | ReactNode | - | | ## Semantic DOM diff --git a/components/badge/index.tsx b/components/badge/index.tsx index 1523bbd8e3..d960a47267 100644 --- a/components/badge/index.tsx +++ b/components/badge/index.tsx @@ -8,6 +8,8 @@ import { isPresetColor } from '../_util/colors'; import { cloneElement } from '../_util/reactNode'; import type { LiteralUnion } from '../_util/type'; import type { PresetColorKey } from '../theme/internal'; +import useMergeSemantic from '../_util/hooks/useMergeSemantic'; +import type { SemanticClassNamesType, SemanticStylesType } from '../_util/hooks/useMergeSemantic'; import Ribbon from './Ribbon'; import ScrollNumber from './ScrollNumber'; import useStyle from './style'; @@ -16,6 +18,10 @@ import { useComponentConfig } from '../config-provider/context'; export type { ScrollNumberProps } from './ScrollNumber'; type SemanticName = 'root' | 'indicator'; + +export type BadgeClassNamesType = SemanticClassNamesType; +export type BadgeStylesType = SemanticStylesType; + export interface BadgeProps extends React.HTMLAttributes { /** Number to show in badge */ count?: React.ReactNode; @@ -36,8 +42,8 @@ export interface BadgeProps extends React.HTMLAttributes { offset?: [number | string, number | string]; title?: string; children?: React.ReactNode; - classNames?: Partial>; - styles?: Partial>; + classNames?: BadgeClassNamesType; + styles?: BadgeStylesType; } const InternalBadge = React.forwardRef((props, ref) => { @@ -74,6 +80,21 @@ const InternalBadge = React.forwardRef((props, ref) const [hashId, cssVarCls] = useStyle(prefixCls); + // =========== Merged Props for Semantic =========== + const mergedProps: BadgeProps = { + ...props, + overflowCount, + size, + dot, + showZero, + }; + + const [mergedClassNames, mergedStyles] = useMergeSemantic< + BadgeClassNamesType, + BadgeStylesType, + BadgeProps + >([contextClassNames, classNames], [contextStyles, styles], undefined, { props: mergedProps }); + // ================================ Misc ================================ const numberedDisplayCount = ( (count as number) > (overflowCount as number) ? `${overflowCount}+` : count @@ -162,7 +183,7 @@ const InternalBadge = React.forwardRef((props, ref) const isInternalColor = isPresetColor(color, false); // Shared styles - const statusCls = classnames(classNames?.indicator, contextClassNames.indicator, { + const statusCls = classnames(mergedClassNames.indicator, { [`${prefixCls}-status-dot`]: hasStatus, [`${prefixCls}-status-${status}`]: !!status, [`${prefixCls}-color-${color}`]: isInternalColor, @@ -184,8 +205,7 @@ const InternalBadge = React.forwardRef((props, ref) className, rootClassName, contextClassName, - contextClassNames.root, - classNames?.root, + mergedClassNames.root, hashId, cssVarCls, ); @@ -197,12 +217,9 @@ const InternalBadge = React.forwardRef((props, ref) - + {showStatusTextNode && ( {text} @@ -213,12 +230,7 @@ const InternalBadge = React.forwardRef((props, ref) } return ( - + {children} ((props, ref) const isDot = isDotRef.current; - const scrollNumberCls = classnames(classNames?.indicator, contextClassNames.indicator, { + const scrollNumberCls = classnames(mergedClassNames.indicator, { [`${prefixCls}-dot`]: isDot, [`${prefixCls}-count`]: !isDot, [`${prefixCls}-count-sm`]: size === 'small', @@ -245,8 +257,7 @@ const InternalBadge = React.forwardRef((props, ref) }); let scrollNumberStyle: React.CSSProperties = { - ...styles?.indicator, - ...contextStyles.indicator, + ...mergedStyles.indicator, ...mergedStyle, }; diff --git a/components/badge/index.zh-CN.md b/components/badge/index.zh-CN.md index 8f296a1a8e..d628458f06 100644 --- a/components/badge/index.zh-CN.md +++ b/components/badge/index.zh-CN.md @@ -28,6 +28,7 @@ group: 数据展示 状态点 多彩徽标 缎带 +自定义各种语义结构的样式和类 Ribbon Debug 各种混用的情况 自定义标题 @@ -44,14 +45,14 @@ group: 数据展示 | --- | --- | --- | --- | --- | | color | 自定义小圆点的颜色 | string | - | | | count | 展示的数字,大于 overflowCount 时显示为 `${overflowCount}+`,为 0 时隐藏 | ReactNode | - | | -| classNames | 语义化结构 class | [Record](#semantic-dom) | - | 5.7.0 | +| classNames | 用于自定义组件内部各语义化结构的 class,支持对象或函数 | Record<[SemanticDOM](#semantic-dom), string> \| (info: { props })=> Record<[SemanticDOM](#semantic-dom), string> | - | | | dot | 不展示数字,只有一个小红点 | boolean | false | | | offset | 设置状态点的位置偏移 | \[number, number] | - | | | overflowCount | 展示封顶的数字值 | number | 99 | | | showZero | 当数值为 0 时,是否展示 Badge | boolean | false | | | size | 在设置了 `count` 的前提下有效,设置小圆点的大小 | `default` \| `small` | - | - | | status | 设置 Badge 为状态点 | `success` \| `processing` \| `default` \| `error` \| `warning` | - | | -| styles | 语义化结构 style | [Record](#semantic-dom) | - | 5.7.0 | +| styles | 用于自定义组件内部各语义化结构的行内 style,支持对象或函数 | Record<[SemanticDOM](#semantic-dom), CSSProperties> \| (info: { props })=> Record<[SemanticDOM](#semantic-dom), CSSProperties> | - | | | text | 在设置了 `status` 的前提下有效,设置状态点的文本 | ReactNode | - | | | title | 设置鼠标放在状态点上时显示的文字 | string | - | | @@ -59,8 +60,10 @@ group: 数据展示 | 参数 | 说明 | 类型 | 默认值 | 版本 | | --- | --- | --- | --- | --- | +| classNames | 用于自定义组件内部各语义化结构的 class,支持对象或函数 | Record<[SemanticDOM](#semantic-dom), string> \| (info: { props })=> Record<[SemanticDOM](#semantic-dom), string> | - | | | color | 自定义缎带的颜色 | string | - | | | placement | 缎带的位置,`start` 和 `end` 随文字方向(RTL 或 LTR)变动 | `start` \| `end` | `end` | | +| styles | 用于自定义组件内部各语义化结构的行内 style,支持对象或函数 | Record<[SemanticDOM](#semantic-dom), CSSProperties> \| (info: { props })=> Record<[SemanticDOM](#semantic-dom), CSSProperties> | - | | | text | 缎带中填入的内容 | ReactNode | - | | ## Semantic DOM