Merge pull request #48189 from ant-design/feature

chore: merge feature into master
This commit is contained in:
MadCcc 2024-03-31 13:55:04 +08:00 committed by GitHub
commit 5384a97f37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
112 changed files with 9311 additions and 485 deletions

View File

@ -6,9 +6,9 @@ import type { UseClosableParams } from '../hooks/useClosable';
import useClosable from '../hooks/useClosable';
type ParamsOfUseClosable = [
UseClosableParams['closable'],
UseClosableParams['closeIcon'],
UseClosableParams['defaultClosable'],
closable: UseClosableParams['closable'],
closeIcon: UseClosableParams['closeIcon'],
defaultClosable: UseClosableParams['defaultClosable'],
];
describe('hooks test', () => {
@ -81,7 +81,7 @@ describe('hooks test', () => {
res: [false, ''],
},
// test case like: <Component closeIcon={null | false | element} />
// test case like: <Component closeIcon={null | false | element | true} />
{
params: [undefined, null, undefined],
res: [false, ''],
@ -90,6 +90,10 @@ describe('hooks test', () => {
params: [undefined, false, undefined],
res: [false, ''],
},
{
params: [undefined, true, undefined],
res: [true, '.anticon-close'],
},
{
params: [
undefined,
@ -138,11 +142,16 @@ describe('hooks test', () => {
React.isValidElement(params[1]) ? 'element' : params[1]
},defaultClosable=${params[2]}. the result should be ${res}`, () => {
const App = () => {
const [closable, closeIcon] = useClosable({
closable: params[0],
closeIcon: params[1],
defaultClosable: params[2],
});
const [closable, closeIcon] = useClosable(
{
closable: params[0],
closeIcon: params[1],
},
null,
{
closable: params[2],
},
);
useEffect(() => {
expect(closable).toBe(res[0]);
}, [closable]);
@ -159,10 +168,15 @@ describe('hooks test', () => {
it('useClosable with defaultCloseIcon', () => {
const App = () => {
const [closable, closeIcon] = useClosable({
closable: true,
defaultCloseIcon: <CloseOutlined className="custom-close-icon" />,
});
const [closable, closeIcon] = useClosable(
{
closable: true,
},
null,
{
closeIcon: <CloseOutlined className="custom-close-icon" />,
},
);
useEffect(() => {
expect(closable).toBe(true);
}, [closable]);
@ -171,16 +185,37 @@ describe('hooks test', () => {
const { container } = render(<App />);
expect(container.querySelector('.custom-close-icon')).toBeTruthy();
});
it('useClosable without defaultCloseIcon', () => {
const App = () => {
const [closable, closeIcon] = useClosable(
{
closable: true,
},
null,
);
useEffect(() => {
expect(closable).toBe(true);
}, [closable]);
return <div>hooks test {closeIcon}</div>;
};
const { container } = render(<App />);
expect(container.querySelector('.anticon-close')).toBeTruthy();
});
it('useClosable with customCloseIconRender', () => {
const App = () => {
const customCloseIconRender = (icon: React.ReactNode) => (
<span className="custom-close-wrapper">{icon}</span>
);
const [closable, closeIcon] = useClosable({
closable: true,
customCloseIconRender,
});
const [closable, closeIcon] = useClosable(
{
closable: true,
},
null,
{
closeIconRender: customCloseIconRender,
},
);
useEffect(() => {
expect(closable).toBe(true);
}, [closable]);

View File

@ -3,69 +3,174 @@ import React from 'react';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import pickAttrs from 'rc-util/lib/pickAttrs';
export type BaseClosableType = { closeIcon?: React.ReactNode } & React.AriaAttributes;
export type ClosableType = boolean | BaseClosableType;
export type ContextClosable<T extends { closable?: ClosableType; closeIcon?: ReactNode } = any> =
Partial<Pick<T, 'closable' | 'closeIcon'>>;
export function pickClosable<T extends { closable?: ClosableType; closeIcon?: ReactNode }>(
context?: ContextClosable<T>,
): ContextClosable<T> | undefined {
if (!context) {
return undefined;
}
return {
closable: context.closable,
closeIcon: context.closeIcon,
};
}
export type UseClosableParams = {
closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes);
closable?: ClosableType;
closeIcon?: ReactNode;
defaultClosable?: boolean;
defaultCloseIcon?: ReactNode;
customCloseIconRender?: (closeIcon: ReactNode) => ReactNode;
context?: ContextClosable;
};
function useInnerClosable(
closable?: UseClosableParams['closable'],
closeIcon?: ReactNode,
defaultClosable?: boolean,
) {
if (typeof closable === 'boolean') {
return closable;
}
if (typeof closable === 'object') {
return true;
}
if (closeIcon === undefined) {
return !!defaultClosable;
}
return closeIcon !== false && closeIcon !== null;
/** Convert `closable` and `closeIcon` to config object */
function useClosableConfig(closableCollection?: ClosableCollection | null) {
const { closable, closeIcon } = closableCollection || {};
return React.useMemo(() => {
if (
// If `closable`, whatever rest be should be true
!closable &&
(closable === false || closeIcon === false || closeIcon === null)
) {
return false;
}
if (closable === undefined && closeIcon === undefined) {
return null;
}
let closableConfig: BaseClosableType = {
closeIcon: typeof closeIcon !== 'boolean' && closeIcon !== null ? closeIcon : undefined,
};
if (closable && typeof closable === 'object') {
closableConfig = {
...closableConfig,
...closable,
};
}
return closableConfig;
}, [closable, closeIcon]);
}
function useClosable({
closable,
closeIcon,
customCloseIconRender,
defaultCloseIcon = <CloseOutlined />,
defaultClosable = false,
}: UseClosableParams): [closable: boolean, closeIcon: React.ReactNode | null] {
const mergedClosable = useInnerClosable(closable, closeIcon, defaultClosable);
/**
* Assign object without `undefined` field. Will skip if is `false`.
* This helps to handle both closableConfig or false
*/
function assignWithoutUndefined<T extends object>(
...objList: (Partial<T> | false | null | undefined)[]
): Partial<T> {
const target: Partial<T> = {};
if (!mergedClosable) {
return [false, null];
}
const { closeIcon: closableIcon, ...restProps } =
typeof closable === 'object'
? closable
: ({} as { closeIcon: React.ReactNode } & React.AriaAttributes);
// Priority: closable.closeIcon > closeIcon > defaultCloseIcon
const mergedCloseIcon: ReactNode = (() => {
if (typeof closable === 'object' && closableIcon !== undefined) {
return closableIcon;
objList.forEach((obj) => {
if (obj) {
(Object.keys(obj) as (keyof T)[]).forEach((key) => {
if (obj[key] !== undefined) {
target[key] = obj[key];
}
});
}
return typeof closeIcon === 'boolean' || closeIcon === undefined || closeIcon === null
? defaultCloseIcon
: closeIcon;
})();
const ariaProps = pickAttrs(restProps, true);
});
const plainCloseIcon = customCloseIconRender
? customCloseIconRender(mergedCloseIcon)
: mergedCloseIcon;
return target;
}
const closeIconWithAria = React.isValidElement(plainCloseIcon) ? (
React.cloneElement(plainCloseIcon, ariaProps)
) : (
<span {...ariaProps}>{plainCloseIcon}</span>
/** Collection contains the all the props related with closable. e.g. `closable`, `closeIcon` */
interface ClosableCollection {
closable?: ClosableType;
closeIcon?: ReactNode;
}
/** Use same object to support `useMemo` optimization */
const EmptyFallbackCloseCollection: ClosableCollection = {};
export default function useClosable(
propCloseCollection?: ClosableCollection,
contextCloseCollection?: ClosableCollection | null,
fallbackCloseCollection: ClosableCollection & {
/**
* Some components need to wrap CloseIcon twice,
* this method will be executed once after the final CloseIcon is calculated
*/
closeIconRender?: (closeIcon: ReactNode) => ReactNode;
} = EmptyFallbackCloseCollection,
): [closable: boolean, closeIcon: React.ReactNode | null] {
// Align the `props`, `context` `fallback` to config object first
const propCloseConfig = useClosableConfig(propCloseCollection);
const contextCloseConfig = useClosableConfig(contextCloseCollection);
const mergedFallbackCloseCollection = React.useMemo(
() => ({
closeIcon: <CloseOutlined />,
...fallbackCloseCollection,
}),
[fallbackCloseCollection],
);
return [true, closeIconWithAria];
}
// Use fallback logic to fill the config
const mergedClosableConfig = React.useMemo(() => {
// ================ Props First ================
// Skip if prop is disabled
if (propCloseConfig === false) {
return false;
}
export default useClosable;
if (propCloseConfig) {
return assignWithoutUndefined(
mergedFallbackCloseCollection,
contextCloseConfig,
propCloseConfig,
);
}
// =============== Context Second ==============
// Skip if context is disabled
if (contextCloseConfig === false) {
return false;
}
if (contextCloseConfig) {
return assignWithoutUndefined(mergedFallbackCloseCollection, contextCloseConfig);
}
// ============= Fallback Default ==============
return !mergedFallbackCloseCollection.closable ? false : mergedFallbackCloseCollection;
}, [propCloseConfig, contextCloseConfig, mergedFallbackCloseCollection]);
// Calculate the final closeIcon
return React.useMemo(() => {
if (mergedClosableConfig === false) {
return [false, null];
}
const { closeIconRender } = mergedFallbackCloseCollection;
const { closeIcon } = mergedClosableConfig;
let mergedCloseIcon: ReactNode = closeIcon;
if (mergedCloseIcon !== null && mergedCloseIcon !== undefined) {
// Wrap the closeIcon if needed
if (closeIconRender) {
mergedCloseIcon = closeIconRender(closeIcon);
}
// Wrap the closeIcon with aria props
const ariaProps = pickAttrs(mergedClosableConfig, true);
if (Object.keys(ariaProps).length) {
mergedCloseIcon = React.isValidElement(mergedCloseIcon) ? (
React.cloneElement(mergedCloseIcon, ariaProps)
) : (
<span {...ariaProps}>{mergedCloseIcon}</span>
);
}
}
return [true, mergedCloseIcon];
}, [mergedClosableConfig, mergedFallbackCloseCollection]);
}

View File

@ -1,5 +1,7 @@
export const groupKeysMap = (keys: string[]) => {
const map = new Map<string, number>();
import type { TransferKey } from '../transfer/interface';
export const groupKeysMap = (keys: TransferKey[]) => {
const map = new Map<TransferKey, number>();
keys.forEach((key, index) => {
map.set(key, index);
});
@ -7,7 +9,7 @@ export const groupKeysMap = (keys: string[]) => {
};
export const groupDisabledKeysMap = <RecordType extends any[]>(dataSource: RecordType) => {
const map = new Map<string, number>();
const map = new Map<TransferKey, number>();
dataSource.forEach(({ disabled, key }, index) => {
if (disabled) {
map.set(key, index);

View File

@ -13,12 +13,13 @@ import { replaceElement } from '../_util/reactNode';
import { devUseWarning } from '../_util/warning';
import { ConfigContext } from '../config-provider';
import useStyle from './style';
import type { ClosableType } from '../_util/hooks/useClosable';
export interface AlertProps {
/** Type of Alert styles, options:`success`, `info`, `warning`, `error` */
type?: 'success' | 'info' | 'warning' | 'error';
/** Whether Alert can be closed */
closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes);
closable?: ClosableType;
/**
* @deprecated please use `closable.closeIcon` instead.
* Close text to show

View File

@ -218,8 +218,8 @@ const genSharedBadgeStyle: GenerateStyle<BadgeToken> = (token) => {
},
[`${componentCls}-status-processing`]: {
overflow: 'visible',
color: token.colorPrimary,
backgroundColor: token.colorPrimary,
color: token.colorInfo,
backgroundColor: token.colorInfo,
'&::after': {
position: 'absolute',

View File

@ -0,0 +1,3 @@
import uzUZ from '../../date-picker/locale/uz_UZ';
export default uzUZ;

View File

@ -727,7 +727,7 @@ exports[`renders components/carousel/demo/fade.tsx extend context correctly 1`]
aria-hidden="false"
class="slick-slide slick-active slick-current"
data-index="0"
style="outline: none; width: 0px; position: relative; left: 0px; opacity: 1; transition: opacity 500ms ease, visibility 500ms ease;"
style="outline: none; width: 0px; position: relative; left: 0px; opacity: 1; z-index: 999; transition: opacity 500ms ease, visibility 500ms ease;"
tabindex="-1"
>
<div>
@ -747,7 +747,7 @@ exports[`renders components/carousel/demo/fade.tsx extend context correctly 1`]
aria-hidden="true"
class="slick-slide"
data-index="1"
style="outline: none; width: 0px; position: relative; left: 0px; opacity: 0; transition: opacity 500ms ease, visibility 500ms ease;"
style="outline: none; width: 0px; position: relative; left: 0px; opacity: 0; z-index: 998; transition: opacity 500ms ease, visibility 500ms ease;"
tabindex="-1"
>
<div>
@ -767,7 +767,7 @@ exports[`renders components/carousel/demo/fade.tsx extend context correctly 1`]
aria-hidden="true"
class="slick-slide"
data-index="2"
style="outline: none; width: 0px; position: relative; left: 0px; opacity: 0; transition: opacity 500ms ease, visibility 500ms ease;"
style="outline: none; width: 0px; position: relative; left: 0px; opacity: 0; z-index: 998; transition: opacity 500ms ease, visibility 500ms ease;"
tabindex="-1"
>
<div>
@ -787,7 +787,7 @@ exports[`renders components/carousel/demo/fade.tsx extend context correctly 1`]
aria-hidden="true"
class="slick-slide"
data-index="3"
style="outline: none; width: 0px; position: relative; left: 0px; opacity: 0; transition: opacity 500ms ease, visibility 500ms ease;"
style="outline: none; width: 0px; position: relative; left: 0px; opacity: 0; z-index: 998; transition: opacity 500ms ease, visibility 500ms ease;"
tabindex="-1"
>
<div>

View File

@ -721,7 +721,7 @@ exports[`renders components/carousel/demo/fade.tsx correctly 1`] = `
aria-hidden="false"
class="slick-slide slick-active slick-current"
data-index="0"
style="outline:none;width:11.11111111111111%;position:relative;left:0;opacity:1;transition:opacity 500ms ease, visibility 500ms ease"
style="outline:none;width:11.11111111111111%;position:relative;left:0;opacity:1;z-index:999;transition:opacity 500ms ease, visibility 500ms ease"
tabindex="-1"
>
<div>
@ -741,7 +741,7 @@ exports[`renders components/carousel/demo/fade.tsx correctly 1`] = `
aria-hidden="true"
class="slick-slide"
data-index="1"
style="outline:none;width:11.11111111111111%;position:relative;left:-11px;opacity:0;transition:opacity 500ms ease, visibility 500ms ease"
style="outline:none;width:11.11111111111111%;position:relative;left:-11px;opacity:0;z-index:998;transition:opacity 500ms ease, visibility 500ms ease"
tabindex="-1"
>
<div>
@ -761,7 +761,7 @@ exports[`renders components/carousel/demo/fade.tsx correctly 1`] = `
aria-hidden="true"
class="slick-slide"
data-index="2"
style="outline:none;width:11.11111111111111%;position:relative;left:-22px;opacity:0;transition:opacity 500ms ease, visibility 500ms ease"
style="outline:none;width:11.11111111111111%;position:relative;left:-22px;opacity:0;z-index:998;transition:opacity 500ms ease, visibility 500ms ease"
tabindex="-1"
>
<div>
@ -781,7 +781,7 @@ exports[`renders components/carousel/demo/fade.tsx correctly 1`] = `
aria-hidden="true"
class="slick-slide"
data-index="3"
style="outline:none;width:11.11111111111111%;position:relative;left:-33px;opacity:0;transition:opacity 500ms ease, visibility 500ms ease"
style="outline:none;width:11.11111111111111%;position:relative;left:-33px;opacity:0;z-index:998;transition:opacity 500ms ease, visibility 500ms ease"
tabindex="-1"
>
<div>

View File

@ -6,6 +6,7 @@ exports[`Carousel rtl render component should be rendered correctly in RTL direc
>
<div
class="slick-slider slick-initialized"
dir="ltr"
>
<div
class="slick-list"
@ -25,6 +26,7 @@ exports[`Carousel should works for dotPosition bottom 1`] = `
>
<div
class="slick-slider slick-initialized"
dir="ltr"
>
<div
class="slick-list"
@ -33,6 +35,20 @@ exports[`Carousel should works for dotPosition bottom 1`] = `
class="slick-track"
style="opacity: 1; transform: translate3d(0px, 0px, 0px);"
>
<div
aria-hidden="true"
class="slick-slide slick-cloned"
data-index="-1"
style="width: 0px;"
tabindex="-1"
>
<div>
<div
style="width: 100%; display: inline-block;"
tabindex="-1"
/>
</div>
</div>
<div
aria-hidden="false"
class="slick-slide slick-active slick-current"
@ -47,8 +63,34 @@ exports[`Carousel should works for dotPosition bottom 1`] = `
/>
</div>
</div>
<div
aria-hidden="true"
class="slick-slide slick-cloned"
data-index="1"
style="width: 0px;"
tabindex="-1"
>
<div>
<div
style="width: 100%; display: inline-block;"
tabindex="-1"
/>
</div>
</div>
</div>
</div>
<ul
class="slick-dots slick-dots-bottom"
style="display: block;"
>
<li
class="slick-active"
>
<button>
1
</button>
</li>
</ul>
</div>
</div>
`;
@ -59,14 +101,30 @@ exports[`Carousel should works for dotPosition left 1`] = `
>
<div
class="slick-slider slick-vertical slick-initialized"
dir="ltr"
>
<div
class="slick-list"
style="height: 0px;"
>
<div
class="slick-track"
style="opacity: 1; transform: translate3d(0px, 0px, 0px);"
>
<div
aria-hidden="true"
class="slick-slide slick-cloned"
data-index="-1"
style="width: 0px;"
tabindex="-1"
>
<div>
<div
style="width: 100%; display: inline-block;"
tabindex="-1"
/>
</div>
</div>
<div
aria-hidden="false"
class="slick-slide slick-active slick-current"
@ -81,8 +139,34 @@ exports[`Carousel should works for dotPosition left 1`] = `
/>
</div>
</div>
<div
aria-hidden="true"
class="slick-slide slick-cloned"
data-index="1"
style="width: 0px;"
tabindex="-1"
>
<div>
<div
style="width: 100%; display: inline-block;"
tabindex="-1"
/>
</div>
</div>
</div>
</div>
<ul
class="slick-dots slick-dots-left"
style="display: block;"
>
<li
class="slick-active"
>
<button>
1
</button>
</li>
</ul>
</div>
</div>
`;
@ -93,14 +177,30 @@ exports[`Carousel should works for dotPosition right 1`] = `
>
<div
class="slick-slider slick-vertical slick-initialized"
dir="ltr"
>
<div
class="slick-list"
style="height: 0px;"
>
<div
class="slick-track"
style="opacity: 1; transform: translate3d(0px, 0px, 0px);"
>
<div
aria-hidden="true"
class="slick-slide slick-cloned"
data-index="-1"
style="width: 0px;"
tabindex="-1"
>
<div>
<div
style="width: 100%; display: inline-block;"
tabindex="-1"
/>
</div>
</div>
<div
aria-hidden="false"
class="slick-slide slick-active slick-current"
@ -115,8 +215,34 @@ exports[`Carousel should works for dotPosition right 1`] = `
/>
</div>
</div>
<div
aria-hidden="true"
class="slick-slide slick-cloned"
data-index="1"
style="width: 0px;"
tabindex="-1"
>
<div>
<div
style="width: 100%; display: inline-block;"
tabindex="-1"
/>
</div>
</div>
</div>
</div>
<ul
class="slick-dots slick-dots-right"
style="display: block;"
>
<li
class="slick-active"
>
<button>
1
</button>
</li>
</ul>
</div>
</div>
`;
@ -127,6 +253,7 @@ exports[`Carousel should works for dotPosition top 1`] = `
>
<div
class="slick-slider slick-initialized"
dir="ltr"
>
<div
class="slick-list"
@ -135,6 +262,20 @@ exports[`Carousel should works for dotPosition top 1`] = `
class="slick-track"
style="opacity: 1; transform: translate3d(0px, 0px, 0px);"
>
<div
aria-hidden="true"
class="slick-slide slick-cloned"
data-index="-1"
style="width: 0px;"
tabindex="-1"
>
<div>
<div
style="width: 100%; display: inline-block;"
tabindex="-1"
/>
</div>
</div>
<div
aria-hidden="false"
class="slick-slide slick-active slick-current"
@ -149,8 +290,34 @@ exports[`Carousel should works for dotPosition top 1`] = `
/>
</div>
</div>
<div
aria-hidden="true"
class="slick-slide slick-cloned"
data-index="1"
style="width: 0px;"
tabindex="-1"
>
<div>
<div
style="width: 100%; display: inline-block;"
tabindex="-1"
/>
</div>
</div>
</div>
</div>
<ul
class="slick-dots slick-dots-top"
style="display: block;"
>
<li
class="slick-active"
>
<button>
1
</button>
</li>
</ul>
</div>
</div>
`;

View File

@ -88,6 +88,7 @@ Common props ref[Common props](/docs/react/common-props)
| onSearch | The callback function triggered when input changed | (search: string) => void | - | 4.17.0 |
| dropdownMenuColumnStyle | The style of the drop-down menu column | CSSProperties | - | |
| loadingIcon | The appearance of lazy loading (now is useless) | ReactNode | - | |
| optionRender | Customize the rendering dropdown options | (option: Option) => React.ReactNode | - | 5.16.0 |
### showSearch

View File

@ -88,6 +88,7 @@ demo:
| searchValue | 设置搜索的值,需要与 `showSearch` 配合使用 | string | - | 4.17.0 |
| onSearch | 监听搜索,返回输入的值 | (search: string) => void | - | 4.17.0 |
| dropdownMenuColumnStyle | 下拉菜单列的样式 | CSSProperties | - | |
| optionRender | 自定义渲染下拉选项 | (option: Option) => React.ReactNode | - | 5.16.0 |
### showSearch

View File

@ -21,6 +21,7 @@ import Drawer from '../../drawer';
import Dropdown from '../../dropdown';
import Empty from '../../empty';
import Flex from '../../flex';
import FloatButton from '../../float-button';
import Form from '../../form';
import Image from '../../image';
import Input from '../../input';
@ -1085,6 +1086,59 @@ describe('ConfigProvider support style and className props', () => {
expect(element?.querySelector<HTMLSpanElement>('.cp-test-closeIcon')).toBeTruthy();
});
it('Should Tag support aria-* in closable', () => {
const { container } = render(
<ConfigProvider
tag={{
closable: {
closeIcon: <span className="cp-test-closeIcon">cp-test-closeIcon</span>,
'aria-label': 'Close Tag',
},
}}
>
<Tag>Test</Tag>
<Tag.CheckableTag checked>CheckableTag</Tag.CheckableTag>
</ConfigProvider>,
);
const element = container.querySelector<HTMLSpanElement>('.ant-tag');
expect(element?.querySelector('.ant-tag-close-icon')).toBeTruthy();
expect(element?.querySelector('.ant-tag-close-icon')?.getAttribute('aria-label')).toBe(
'Close Tag',
);
expect(element?.querySelector('.cp-test-closeIcon')).toBeTruthy();
});
it('Should Tag hide closeIcon when closeIcon=false', () => {
const { container } = render(
<ConfigProvider
tag={{
closeIcon: false,
}}
>
<Tag>Test</Tag>
<Tag.CheckableTag checked>CheckableTag</Tag.CheckableTag>
</ConfigProvider>,
);
const element = container.querySelector<HTMLSpanElement>('.ant-tag');
expect(element?.querySelector('.ant-tag-close-icon')).toBeFalsy();
});
it('Should Tag show default closeIcon when closeIcon=true', () => {
const { container } = render(
<ConfigProvider
tag={{
closeIcon: true,
}}
>
<Tag>Test</Tag>
<Tag.CheckableTag checked>CheckableTag</Tag.CheckableTag>
</ConfigProvider>,
);
const element = container.querySelector<HTMLSpanElement>('.ant-tag');
expect(element?.querySelector('.ant-tag-close-icon')).toBeTruthy();
expect(element?.querySelector('.anticon-close')).toBeTruthy();
});
it('Should Table className & style works', () => {
const { container } = render(
<ConfigProvider
@ -1507,4 +1561,18 @@ describe('ConfigProvider support style and className props', () => {
const element = container.querySelector<HTMLSpanElement>(selectors);
expect(element).toBeTruthy();
});
it('Should FloatButton.Group closeIcon works', () => {
const { container } = render(
<ConfigProvider
floatButtonGroup={{ closeIcon: <span className="test-cp-icon">test-cp-icon</span> }}
>
<FloatButton.Group trigger="click" open>
<FloatButton />
</FloatButton.Group>
</ConfigProvider>,
);
const element = container.querySelector<HTMLSpanElement>('.test-cp-icon');
expect(element).toBeTruthy();
});
});

View File

@ -9,6 +9,7 @@ import type { CardProps } from '../card';
import type { CollapseProps } from '../collapse';
import type { DrawerProps } from '../drawer';
import type { FlexProps } from '../flex/interface';
import type { FloatButtonGroupProps } from '../float-button/interface';
import type { FormProps } from '../form/Form';
import type { InputProps, TextAreaProps } from '../input';
import type { Locale } from '../locale';
@ -124,7 +125,7 @@ export type MenuConfig = ComponentStyleConfig & Pick<MenuProps, 'expandIcon'>;
export type TourConfig = Pick<TourProps, 'closeIcon'>;
export type ModalConfig = ComponentStyleConfig &
Pick<ModalProps, 'classNames' | 'styles' | 'closeIcon'>;
Pick<ModalProps, 'classNames' | 'styles' | 'closeIcon' | 'closable'>;
export type TabsConfig = ComponentStyleConfig &
Pick<TabsProps, 'indicator' | 'indicatorSize' | 'moreIcon' | 'addIcon' | 'removeIcon'>;
@ -143,7 +144,7 @@ export type ButtonConfig = ComponentStyleConfig & Pick<ButtonProps, 'classNames'
export type NotificationConfig = ComponentStyleConfig & Pick<ArgsProps, 'closeIcon'>;
export type TagConfig = ComponentStyleConfig & Pick<TagProps, 'closeIcon'>;
export type TagConfig = ComponentStyleConfig & Pick<TagProps, 'closeIcon' | 'closable'>;
export type CardConfig = ComponentStyleConfig & Pick<CardProps, 'classNames' | 'styles'>;
@ -157,6 +158,8 @@ export type TransferConfig = ComponentStyleConfig & Pick<TransferProps, 'selecti
export type FormConfig = ComponentStyleConfig &
Pick<FormProps, 'requiredMark' | 'colon' | 'scrollToFirstError' | 'validateMessages'>;
export type FloatButtonGroupConfig = Pick<FloatButtonGroupProps, 'closeIcon'>;
export type PaginationConfig = ComponentStyleConfig & Pick<PaginationProps, 'showSizeChanger'>;
export type SelectConfig = ComponentStyleConfig & Pick<SelectProps, 'showSearch'>;
@ -213,6 +216,7 @@ export interface ConfigConsumerProps {
carousel?: ComponentStyleConfig;
cascader?: ComponentStyleConfig;
collapse?: CollapseConfig;
floatButtonGroup?: FloatButtonGroupConfig;
typography?: ComponentStyleConfig;
skeleton?: ComponentStyleConfig;
spin?: ComponentStyleConfig;

View File

@ -121,6 +121,7 @@ const {
| dropdown | Set Dropdown common props | { className?: string, style?: React.CSSProperties } | - | 5.11.0 |
| empty | Set Empty common props | { className?: string, style?: React.CSSProperties } | - | 5.7.0 |
| flex | Set Flex common props | { className?: string, style?: React.CSSProperties, vertical?: boolean } | - | 5.10.0 |
| floatButtonGroup | Set FloatButton.Group common props | { closeIcon?: React.ReactNode } | - | 5.16.0 |
| form | Set Form common props | { className?: string, style?: React.CSSProperties, validateMessages?: [ValidateMessages](/components/form/#validatemessages), requiredMark?: boolean \| `optional`, scrollToFirstError?: boolean \| [Options](https://github.com/stipsan/scroll-into-view-if-needed/tree/ece40bd9143f48caf4b99503425ecb16b0ad8249#options) } | - | requiredMark: 4.8.0; colon: 4.18.0; scrollToFirstError: 5.2.0; className: 5.7.0; style: 5.7.0 |
| image | Set Image common props | { className?: string, style?: React.CSSProperties, preview?: { closeIcon?: React.ReactNode } } | - | 5.7.0, closeIcon: 5.14.0 |
| input | Set Input common props | { autoComplete?: string, className?: string, style?: React.CSSProperties, allowClear?: boolean \| { clearIcon?: ReactNode } } | - | 4.2.0, allowClear: 5.15.0 |

View File

@ -26,6 +26,7 @@ import type {
DirectionType,
DrawerConfig,
FlexConfig,
FloatButtonGroupConfig,
FormConfig,
ImageConfig,
InputConfig,
@ -178,6 +179,7 @@ export interface ConfigProviderProps {
slider?: ComponentStyleConfig;
breadcrumb?: ComponentStyleConfig;
menu?: MenuConfig;
floatButtonGroup?: FloatButtonGroupConfig;
checkbox?: ComponentStyleConfig;
descriptions?: ComponentStyleConfig;
empty?: ComponentStyleConfig;
@ -362,6 +364,7 @@ const ProviderChildren: React.FC<ProviderChildrenProps> = (props) => {
dropdown,
warning: warningConfig,
tour,
floatButtonGroup,
} = props;
// =================================== Context ===================================
@ -457,6 +460,7 @@ const ProviderChildren: React.FC<ProviderChildrenProps> = (props) => {
dropdown,
warning: warningConfig,
tour,
floatButtonGroup,
};
const config: ConfigConsumerProps = {

View File

@ -123,6 +123,7 @@ const {
| dropdown | 设置 Dropdown 组件的通用属性 | { className?: string, style?: React.CSSProperties } | - | 5.11.0 |
| empty | 设置 Empty 组件的通用属性 | { className?: string, style?: React.CSSProperties } | - | 5.7.0 |
| flex | 设置 Flex 组件的通用属性 | { className?: string, style?: React.CSSProperties, vertical?: boolean } | - | 5.10.0 |
| floatButtonGroup | 设置 FloatButton.Group 组件的通用属性 | { closeIcon?: React.ReactNode } | - | 5.16.0 |
| form | 设置 Form 组件的通用属性 | { className?: string, style?: React.CSSProperties, validateMessages?: [ValidateMessages](/components/form-cn#validatemessages), requiredMark?: boolean \| `optional`, colon?: boolean, scrollToFirstError?: boolean \| [Options](https://github.com/stipsan/scroll-into-view-if-needed/tree/ece40bd9143f48caf4b99503425ecb16b0ad8249#options)} | - | requiredMark: 4.8.0; colon: 4.18.0; scrollToFirstError: 5.2.0; className: 5.7.0; style: 5.7.0 |
| image | 设置 Image 组件的通用属性 | { className?: string, style?: React.CSSProperties, preview?: { closeIcon?: React.ReactNode } } | - | 5.7.0, closeIcon: 5.14.0 |
| input | 设置 Input 组件的通用属性 | { autoComplete?: string, className?: string, style?: React.CSSProperties, allowClear?: boolean \| { clearIcon?: ReactNode } } | - | 5.7.0, allowClear: 5.15.0 |

View File

@ -0,0 +1,28 @@
import CalendarLocale from 'rc-picker/lib/locale/uz_UZ';
import TimePickerLocale from '../../time-picker/locale/uz_UZ';
import type { PickerLocale } from '../generatePicker';
// Merge into a locale object
const locale: PickerLocale = {
lang: {
placeholder: 'Sanani tanlang',
yearPlaceholder: 'Yilni tanlang',
quarterPlaceholder: 'Chorakni tanlang',
monthPlaceholder: 'Oyni tanlang',
weekPlaceholder: 'Haftani tanlang',
rangePlaceholder: ['Boshlanish sanasi', 'Tugallanish sanasi'],
rangeYearPlaceholder: ['Boshlanish yili', 'Tugallanish yili'],
rangeMonthPlaceholder: ['Boshlanish oyi', 'Tugallanish oyi'],
rangeWeekPlaceholder: ['Boshlanish haftasi', 'Tugallanish haftasi'],
...CalendarLocale,
},
timePickerLocale: {
...TimePickerLocale,
},
};
// All settings at:
// https://github.com/ant-design/ant-design/blob/master/components/date-picker/locale/example.json
export default locale;

View File

@ -1,7 +1,8 @@
import * as React from 'react';
import classNames from 'classnames';
import type { DrawerProps as RCDrawerProps } from 'rc-drawer';
import useClosable from '../_util/hooks/useClosable';
import useClosable, { pickClosable, type ClosableType } from '../_util/hooks/useClosable';
import { ConfigContext } from '../config-provider';
export interface DrawerClassNames extends NonNullable<RCDrawerProps['classNames']> {
@ -29,7 +30,7 @@ export interface DrawerPanelProps {
*
* `<Drawer closeIcon={false} />`
*/
closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes);
closable?: ClosableType;
closeIcon?: React.ReactNode;
onClose?: RCDrawerProps['onClose'];
@ -57,8 +58,6 @@ const DrawerPanel: React.FC<DrawerPanelProps> = (props) => {
title,
footer,
extra,
closeIcon,
closable,
onClose,
headerStyle,
bodyStyle,
@ -78,19 +77,14 @@ const DrawerPanel: React.FC<DrawerPanelProps> = (props) => {
[onClose],
);
const mergedContextCloseIcon = React.useMemo(() => {
if (typeof drawerContext?.closable === 'object' && drawerContext.closable.closeIcon) {
return drawerContext.closable.closeIcon;
}
return drawerContext?.closeIcon;
}, [drawerContext?.closable, drawerContext?.closeIcon]);
const [mergedClosable, mergedCloseIcon] = useClosable({
closable: closable ?? drawerContext?.closable,
closeIcon: typeof closeIcon !== 'undefined' ? closeIcon : mergedContextCloseIcon,
customCloseIconRender,
defaultClosable: true,
});
const [mergedClosable, mergedCloseIcon] = useClosable(
pickClosable(props),
pickClosable(drawerContext),
{
closable: true,
closeIconRender: customCloseIconRender,
},
);
const headerNode = React.useMemo<React.ReactNode>(() => {
if (!title && !mergedClosable) {

View File

@ -8,11 +8,11 @@ import useMergedState from 'rc-util/lib/hooks/useMergedState';
import { devUseWarning } from '../_util/warning';
import type { ConfigConsumerProps } from '../config-provider';
import { ConfigContext } from '../config-provider';
import useCSSVarCls from '../config-provider/hooks/useCSSVarCls';
import { FloatButtonGroupProvider } from './context';
import FloatButton, { floatButtonPrefixCls } from './FloatButton';
import type { FloatButtonGroupProps, FloatButtonRef } from './interface';
import useStyle from './style';
import useCSSVarCls from '../config-provider/hooks/useCSSVarCls';
const FloatButtonGroup: React.FC<FloatButtonGroupProps> = (props) => {
const {
@ -22,7 +22,7 @@ const FloatButtonGroup: React.FC<FloatButtonGroupProps> = (props) => {
shape = 'circle',
type = 'default',
icon = <FileTextOutlined />,
closeIcon = <CloseOutlined />,
closeIcon,
description,
trigger,
children,
@ -31,7 +31,11 @@ const FloatButtonGroup: React.FC<FloatButtonGroupProps> = (props) => {
...floatButtonProps
} = props;
const { direction, getPrefixCls } = useContext<ConfigConsumerProps>(ConfigContext);
const { direction, getPrefixCls, floatButtonGroup } =
useContext<ConfigConsumerProps>(ConfigContext);
const mergedCloseIcon = closeIcon ?? floatButtonGroup?.closeIcon ?? <CloseOutlined />;
const prefixCls = getPrefixCls(floatButtonPrefixCls, customizePrefixCls);
const rootCls = useCSSVarCls(prefixCls);
const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls, rootCls);
@ -120,7 +124,7 @@ const FloatButtonGroup: React.FC<FloatButtonGroupProps> = (props) => {
ref={floatButtonRef}
type={type}
shape={shape}
icon={open ? closeIcon : icon}
icon={open ? mergedCloseIcon : icon}
description={description}
aria-label={props['aria-label']}
{...floatButtonProps}

View File

@ -28500,6 +28500,216 @@ exports[`renders components/form/demo/validate-static.tsx extend context correct
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-success"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Success"
>
Success
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-warning"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Warning"
>
Warning
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-error"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Error"
>
Error
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-error"
>

View File

@ -11508,6 +11508,216 @@ exports[`renders components/form/demo/validate-static.tsx correctly 1`] = `
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-success"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Success"
>
Success
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-success ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-warning"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Warning"
>
Warning
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-warning ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-error"
>
<div
class="ant-row ant-form-item-row"
>
<div
class="ant-col ant-form-item-label ant-col-xs-24 ant-col-sm-6"
>
<label
class=""
title="Error"
>
Error
</label>
</div>
<div
class="ant-col ant-form-item-control ant-col-xs-24 ant-col-sm-14"
>
<div
class="ant-form-item-control-input"
>
<div
class="ant-form-item-control-input-content"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-input-status-error ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="ant-form-item ant-form-item-has-feedback ant-form-item-has-error"
>

View File

@ -136,6 +136,17 @@ const App: React.FC = () => (
<Input.Password allowClear placeholder="with input password and allowClear" />
</Form.Item>
<Form.Item label="Success" hasFeedback validateStatus="success">
<Input.OTP />
</Form.Item>
<Form.Item label="Warning" hasFeedback validateStatus="warning">
<Input.OTP />
</Form.Item>
<Form.Item label="Error" hasFeedback validateStatus="error">
<Input.OTP />
</Form.Item>
<Form.Item label="Fail" validateStatus="error" hasFeedback>
<Mentions />
</Form.Item>

View File

@ -264,4 +264,8 @@ const Input = forwardRef<InputRef, InputProps>((props, ref) => {
);
});
if (process.env.NODE_ENV !== 'production') {
Input.displayName = 'Input';
}
export default Input;

View File

@ -0,0 +1,69 @@
import * as React from 'react';
import raf from 'rc-util/lib/raf';
import Input, { type InputProps, type InputRef } from '../Input';
export interface OTPInputProps extends Omit<InputProps, 'onChange'> {
index: number;
onChange: (index: number, value: string) => void;
/** Tell parent to do active offset */
onActiveChange: (nextIndex: number) => void;
}
const OTPInput = React.forwardRef<InputRef, OTPInputProps>((props, ref) => {
const { value, onChange, onActiveChange, index, ...restProps } = props;
const onInternalChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
onChange(index, e.target.value);
};
// ========================== Ref ===========================
const inputRef = React.useRef<InputRef>(null);
React.useImperativeHandle(ref, () => inputRef.current!);
// ========================= Focus ==========================
const syncSelection = () => {
raf(() => {
const inputEle = inputRef.current?.input;
if (document.activeElement === inputEle && inputEle) {
inputEle.select();
}
});
};
// ======================== Keyboard ========================
const onInternalKeyDown: React.KeyboardEventHandler<HTMLInputElement> = ({ key }) => {
if (key === 'ArrowLeft') {
onActiveChange(index - 1);
} else if (key === 'ArrowRight') {
onActiveChange(index + 1);
}
syncSelection();
};
const onInternalKeyUp: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key === 'Backspace' && !value) {
onActiveChange(index - 1);
}
syncSelection();
};
// ========================= Render =========================
return (
<Input
{...restProps}
ref={inputRef}
value={value}
onInput={onInternalChange}
onFocus={syncSelection}
onKeyDown={onInternalKeyDown}
onKeyUp={onInternalKeyUp}
onMouseDown={syncSelection}
onMouseUp={syncSelection}
/>
);
});
export default OTPInput;

View File

@ -0,0 +1,244 @@
import * as React from 'react';
import classNames from 'classnames';
import { useEvent } from 'rc-util';
import pickAttrs from 'rc-util/lib/pickAttrs';
import { getMergedStatus, type InputStatus } from '../../_util/statusUtils';
import { ConfigContext } from '../../config-provider';
import useCSSVarCls from '../../config-provider/hooks/useCSSVarCls';
import useSize from '../../config-provider/hooks/useSize';
import { type SizeType } from '../../config-provider/SizeContext';
import { FormItemInputContext } from '../../form/context';
import type { Variant } from '../../form/hooks/useVariants';
import { type InputRef } from '../Input';
import useStyle from '../style/otp';
import OTPInput, { type OTPInputProps } from './OTPInput';
export interface OTPRef {
focus: VoidFunction;
blur: VoidFunction;
nativeElement: HTMLDivElement;
}
export interface OTPProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
prefixCls?: string;
length?: number;
// Style
variant?: Variant;
rootClassName?: string;
className?: string;
style?: React.CSSProperties;
size?: SizeType;
// Values
defaultValue?: string;
value?: string;
onChange?: (value: string) => void;
formatter?: (value: string) => string;
// Status
disabled?: boolean;
status?: InputStatus;
}
function strToArr(str: string) {
return str.split('');
}
const OTP = React.forwardRef<OTPRef, OTPProps>((props, ref) => {
const {
prefixCls: customizePrefixCls,
length = 6,
size: customSize,
defaultValue,
value,
onChange,
formatter,
variant,
disabled,
status: customStatus,
autoFocus,
...restProps
} = props;
const { getPrefixCls, direction } = React.useContext(ConfigContext);
const prefixCls = getPrefixCls('otp', customizePrefixCls);
const domAttrs = pickAttrs(restProps, {
aria: true,
data: true,
attr: true,
});
// ========================= Root =========================
// Style
const rootCls = useCSSVarCls(prefixCls);
const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls, rootCls);
// ========================= Size =========================
const mergedSize = useSize((ctx) => customSize ?? ctx);
// ======================== Status ========================
const formContext = React.useContext(FormItemInputContext);
const mergedStatus = getMergedStatus(formContext.status, customStatus);
const proxyFormContext = React.useMemo(
() => ({
...formContext,
status: mergedStatus,
hasFeedback: false,
feedbackIcon: null,
}),
[formContext, mergedStatus],
);
// ========================= Refs =========================
const containerRef = React.useRef<HTMLDivElement>(null);
const refs = React.useRef<Record<number, InputRef | null>>({});
React.useImperativeHandle(ref, () => ({
focus: () => {
refs.current[0]?.focus();
},
blur: () => {
for (let i = 0; i < length; i += 1) {
refs.current[i]?.blur();
}
},
nativeElement: containerRef.current!,
}));
// ======================= Formatter ======================
const internalFormatter = (txt: string) => (formatter ? formatter(txt) : txt);
// ======================== Values ========================
const [valueCells, setValueCells] = React.useState<string[]>(
strToArr(internalFormatter(defaultValue || '')),
);
React.useEffect(() => {
if (value) {
setValueCells(strToArr(value));
}
}, [value]);
const triggerValueCellsChange = useEvent((nextValueCells: string[]) => {
setValueCells(nextValueCells);
// Trigger if all cells are filled
if (
onChange &&
nextValueCells.length === length &&
nextValueCells.every((c) => c) &&
nextValueCells.some((c, index) => valueCells[index] !== c)
) {
onChange(nextValueCells.join(''));
}
});
const patchValue = useEvent((index: number, txt: string) => {
let nextCells = [...valueCells];
// Fill cells till index
for (let i = 0; i < index; i += 1) {
if (!nextCells[i]) {
nextCells[i] = '';
}
}
if (txt.length <= 1) {
nextCells[index] = txt;
} else {
nextCells = nextCells.slice(0, index).concat(strToArr(txt));
}
nextCells = nextCells.slice(0, length);
// Clean the last empty cell
for (let i = nextCells.length - 1; i >= 0; i -= 1) {
if (nextCells[i]) {
break;
}
nextCells.pop();
}
// Format if needed
const formattedValue = internalFormatter(nextCells.map((c) => c || ' ').join(''));
nextCells = strToArr(formattedValue).map((c, i) => {
if (c === ' ' && !nextCells[i]) {
return nextCells[i];
}
return c;
});
return nextCells;
});
// ======================== Change ========================
const onInputChange: OTPInputProps['onChange'] = (index, txt) => {
const nextCells = patchValue(index, txt);
const nextIndex = Math.min(index + txt.length, length - 1);
if (nextIndex !== index) {
refs.current[nextIndex]?.focus();
}
triggerValueCellsChange(nextCells);
};
const onInputActiveChange: OTPInputProps['onActiveChange'] = (nextIndex) => {
refs.current[nextIndex]?.focus();
};
// ======================== Render ========================
const inputSharedProps = {
variant,
disabled,
status: mergedStatus as InputStatus,
};
return wrapCSSVar(
<div
{...domAttrs}
ref={containerRef}
className={classNames(
prefixCls,
{
[`${prefixCls}-sm`]: mergedSize === 'small',
[`${prefixCls}-lg`]: mergedSize === 'large',
[`${prefixCls}-rtl`]: direction === 'rtl',
},
cssVarCls,
hashId,
)}
>
<FormItemInputContext.Provider value={proxyFormContext}>
{new Array(length).fill(0).map((_, index) => {
const key = `otp-${index}`;
const singleValue = valueCells[index] || '';
return (
<OTPInput
ref={(inputEle) => {
refs.current[index] = inputEle;
}}
key={key}
index={index}
size={mergedSize}
htmlSize={1}
className={`${prefixCls}-input`}
onChange={onInputChange}
value={singleValue}
onActiveChange={onInputActiveChange}
autoFocus={index === 0 && autoFocus}
{...inputSharedProps}
/>
);
})}
</FormItemInputContext.Provider>
</div>,
);
});
export default OTP;

View File

@ -10114,6 +10114,245 @@ exports[`renders components/input/demo/group.tsx extend context correctly 2`] =
]
`;
exports[`renders components/input/demo/otp.tsx extend context correctly 1`] = `
<div
class="ant-space ant-space-vertical ant-space-gap-row-small ant-space-gap-col-small"
>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With formatter (Upcase)
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With Disabled
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With Length (8)
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With variant
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
`;
exports[`renders components/input/demo/otp.tsx extend context correctly 2`] = `[]`;
exports[`renders components/input/demo/password-input.tsx extend context correctly 1`] = `
<div
class="ant-space ant-space-vertical ant-space-gap-row-small ant-space-gap-col-small"

View File

@ -3511,6 +3511,243 @@ exports[`renders components/input/demo/group.tsx correctly 1`] = `
</div>
`;
exports[`renders components/input/demo/otp.tsx correctly 1`] = `
<div
class="ant-space ant-space-vertical ant-space-gap-row-small ant-space-gap-col-small"
>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With formatter (Upcase)
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With Disabled
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-disabled ant-input-outlined ant-otp-input"
disabled=""
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With Length (8)
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
<div
class="ant-space-item"
>
<h5
class="ant-typography"
>
With variant
</h5>
</div>
<div
class="ant-space-item"
>
<div
class="ant-otp"
>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-filled ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
</div>
</div>
`;
exports[`renders components/input/demo/password-input.tsx correctly 1`] = `
<div
class="ant-space ant-space-vertical ant-space-gap-row-small ant-space-gap-col-small"

View File

@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Input.OTP rtl render component should be rendered correctly in RTL direction 1`] = `
<div
class="ant-otp ant-otp-rtl"
>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
<input
class="ant-input ant-input-rtl ant-input-outlined ant-otp-input"
size="1"
type="text"
value=""
/>
</div>
`;

View File

@ -0,0 +1,131 @@
import React from 'react';
import Input from '..';
import focusTest from '../../../tests/shared/focusTest';
import mountTest from '../../../tests/shared/mountTest';
import rtlTest from '../../../tests/shared/rtlTest';
import { fireEvent, render, waitFakeTimer } from '../../../tests/utils';
const { OTP } = Input;
describe('Input.OTP', () => {
focusTest(Input.OTP, { refFocus: true });
mountTest(Input.OTP);
rtlTest(Input.OTP);
function getText(container: HTMLElement) {
const inputList = container.querySelectorAll('input');
return Array.from(inputList)
.map((input) => input.value || ' ')
.join('')
.replace(/\s*$/, '');
}
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it('paste to fill all', async () => {
const onChange = jest.fn();
const { container } = render(<OTP onChange={onChange} />);
fireEvent.input(container.querySelector('input')!, { target: { value: '123456' } });
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('123456');
});
it('fill step by step', () => {
const CODE = 'BAMBOO';
const onChange = jest.fn();
render(<OTP onChange={onChange} autoFocus />);
for (let i = 0; i < CODE.length; i += 1) {
expect(onChange).not.toHaveBeenCalled();
fireEvent.input(document.activeElement!, { target: { value: CODE[i] } });
}
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(CODE);
});
it('backspace to delete', async () => {
const CODE = 'LITTLE';
const onChange = jest.fn();
const { container } = render(<OTP defaultValue={CODE} onChange={onChange} />);
expect(getText(container)).toBe(CODE);
// Focus on the last cell
const inputList = container.querySelectorAll('input');
inputList[inputList.length - 1].focus();
for (let i = 0; i < CODE.length; i += 1) {
fireEvent.keyDown(document.activeElement!, { key: 'Backspace' });
fireEvent.input(document.activeElement!, { target: { value: '' } });
fireEvent.keyUp(document.activeElement!, { key: 'Backspace' });
}
expect(getText(container)).toBe('');
// We do not trigger change if empty. It's safe to modify this logic if needed.
expect(onChange).not.toHaveBeenCalled();
});
it('controlled', () => {
const { container, rerender } = render(<OTP value="BAMBOO" />);
expect(getText(container)).toBe('BAMBOO');
rerender(<OTP value="LITTLE" />);
expect(getText(container)).toBe('LITTLE');
});
it('focus to selection', async () => {
const { container } = render(<OTP defaultValue="BAMBOO" />);
const firstInput = container.querySelector('input')!;
const selectSpy = jest.spyOn(firstInput, 'select');
expect(selectSpy).not.toHaveBeenCalled();
// Trigger focus
firstInput.focus();
await waitFakeTimer();
expect(selectSpy).toHaveBeenCalled();
});
it('arrow key to switch', () => {
const { container } = render(<OTP autoFocus />);
const inputList = Array.from(container.querySelectorAll('input'));
expect(document.activeElement).toEqual(inputList[0]);
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
expect(document.activeElement).toEqual(inputList[1]);
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
expect(document.activeElement).toEqual(inputList[0]);
});
it('fill last cell', () => {
const { container } = render(<OTP />);
fireEvent.input(container.querySelectorAll('input')[5], { target: { value: '1' } });
expect(getText(container)).toBe(' 1');
});
it('formatter', () => {
const { container } = render(
<OTP defaultValue="bamboo" formatter={(val) => val.toUpperCase()} />,
);
expect(getText(container)).toBe('BAMBOO');
// Type to trigger formatter
fireEvent.input(container.querySelector('input')!, { target: { value: 'little' } });
expect(getText(container)).toBe('LITTLE');
});
});

View File

@ -0,0 +1,7 @@
## zh-CN
一次性密码输入框。
## en-US
One time password input.

View File

@ -0,0 +1,29 @@
import React from 'react';
import { Input, Space, Typography, type GetProp } from 'antd';
const { Title } = Typography;
const App: React.FC = () => {
const onChange: GetProp<typeof Input.OTP, 'onChange'> = (text) => {
console.log('onChange:', text);
};
const sharedProps = {
onChange,
};
return (
<Space direction="vertical">
<Title level={5}>With formatter (Upcase)</Title>
<Input.OTP formatter={(str) => str.toUpperCase()} {...sharedProps} />
<Title level={5}>With Disabled</Title>
<Input.OTP disabled {...sharedProps} />
<Title level={5}>With Length (8)</Title>
<Input.OTP length={8} {...sharedProps} />
<Title level={5}>With variant</Title>
<Input.OTP variant="filled" {...sharedProps} />
</Space>
);
};
export default App;

View File

@ -28,6 +28,7 @@ demo:
<code src="./demo/search-input-loading.tsx">Search box with loading</code>
<code src="./demo/textarea.tsx">TextArea</code>
<code src="./demo/autosize-textarea.tsx">Autosizing the height to fit the content</code>
<code src="./demo/otp.tsx" version="5.16.0">OTP</code>
<code src="./demo/tooltip.tsx">Format Tooltip Input</code>
<code src="./demo/presuffix.tsx">prefix and suffix</code>
<code src="./demo/password-input.tsx">Password box</code>
@ -102,7 +103,7 @@ Same as Input, and more:
The rest of the props of `Input.TextArea` are the same as the original [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea).
#### Input.Search
### Input.Search
| Property | Description | Type | Default |
| --- | --- | --- | --- |
@ -112,13 +113,29 @@ The rest of the props of `Input.TextArea` are the same as the original [textarea
Supports all props of `Input`.
#### Input.Password
### Input.Password
| Property | Description | Type | Default | Version |
| --- | --- | --- | --- | --- |
| iconRender | Custom toggle button | (visible) => ReactNode | (visible) => (visible ? &lt;EyeOutlined /> : &lt;EyeInvisibleOutlined />) | 4.3.0 |
| visibilityToggle | Whether show toggle button or control password visible | boolean \| [VisibilityToggle](#visibilitytoggle) | true | |
### Input.OTP
Added in `5.16.0`.
| Property | Description | Type | Default | Version |
| --- | --- | --- | --- | --- |
| defaultValue | Default value | string | - | |
| disabled | Whether the input is disabled | boolean | false | |
| formatter | Format display, blank fields will be filled with ` ` | (value: string) => string | - | |
| length | The number of input elements | number | 6 | |
| status | Set validation status | 'error' \| 'warning' | - | |
| size | The size of the input box | `small` \| `middle` \| `large` | `middle` | |
| variant | Variants of Input | `outlined` \| `borderless` \| `filled` | `outlined` | |
| value | The input content value | string | - | |
| onChange | Trigger when all the fields are filled | function(value: string) | - | |
#### VisibilityToggle
| Property | Description | Type | Default | Version |

View File

@ -1,7 +1,9 @@
import type * as React from 'react';
import Group from './Group';
import type { InputProps, InputRef } from './Input';
import InternalInput from './Input';
import OTP from './OTP';
import Password from './Password';
import Search from './Search';
import TextArea from './TextArea';
@ -19,16 +21,14 @@ type CompoundedComponent = React.ForwardRefExoticComponent<
Search: typeof Search;
TextArea: typeof TextArea;
Password: typeof Password;
OTP: typeof OTP;
};
const Input = InternalInput as CompoundedComponent;
if (process.env.NODE_ENV !== 'production') {
Input.displayName = 'Input';
}
Input.Group = Group;
Input.Search = Search;
Input.TextArea = TextArea;
Input.Password = Password;
Input.OTP = OTP;
export default Input;

View File

@ -29,6 +29,7 @@ demo:
<code src="./demo/search-input-loading.tsx">搜索框 loading</code>
<code src="./demo/textarea.tsx">文本域</code>
<code src="./demo/autosize-textarea.tsx">适应文本高度的文本域</code>
<code src="./demo/otp.tsx" version="5.16.0">一次性密码框</code>
<code src="./demo/tooltip.tsx">输入时格式化展示</code>
<code src="./demo/presuffix.tsx">前缀和后缀</code>
<code src="./demo/password-input.tsx">密码框</code>
@ -103,7 +104,7 @@ interface CountConfig {
`Input.TextArea` 的其他属性和浏览器自带的 [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) 一致。
#### Input.Search
### Input.Search
| 参数 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
@ -113,13 +114,29 @@ interface CountConfig {
其余属性和 Input 一致。
#### Input.Password
### Input.Password
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| iconRender | 自定义切换按钮 | (visible) => ReactNode | (visible) => (visible ? &lt;EyeOutlined /> : &lt;EyeInvisibleOutlined />) | 4.3.0 |
| visibilityToggle | 是否显示切换按钮或者控制密码显隐 | boolean \| [VisibilityToggle](#visibilitytoggle) | true | |
### Input.OTP
`5.16.0` 新增。
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| defaultValue | 默认值 | string | - | |
| disabled | 是否禁用 | boolean | false | |
| formatter | 格式化展示,留空字段会被 ` ` 填充 | (value: string) => string | - | |
| length | 输入元素数量 | number | 6 | |
| status | 设置校验状态 | 'error' \| 'warning' | - | |
| size | 输入框大小 | `small` \| `middle` \| `large` | `middle` | |
| variant | 形态变体 | `outlined` \| `borderless` \| `filled` | `outlined` | |
| value | 输入框内容 | string | - | |
| onChange | 当输入框内容全部填充时触发回调 | function(value: string) | - | |
#### VisibilityToggle
| Property | Description | Type | Default | Version |

View File

@ -0,0 +1,47 @@
import type { GenerateStyle } from '../../theme/internal';
import { genStyleHooks, mergeToken } from '../../theme/internal';
import type { InputToken } from './token';
import { initComponentToken, initInputToken } from './token';
// =============================== OTP ================================
const genOTPStyle: GenerateStyle<InputToken> = (token) => {
const { componentCls, paddingXS } = token;
return {
[`${componentCls}`]: {
display: 'inline-flex',
alignItems: 'center',
flexWrap: 'nowrap',
columnGap: paddingXS,
'&-rtl': {
direction: 'rtl',
},
[`${componentCls}-input`]: {
textAlign: 'center',
paddingInline: token.paddingXXS,
},
// ================= Size =================
[`&${componentCls}-sm ${componentCls}-input`]: {
paddingInline: token.calc(token.paddingXXS).div(2).equal(),
},
[`&${componentCls}-lg ${componentCls}-input`]: {
paddingInline: token.paddingXS,
},
},
};
};
// ============================== Export ==============================
export default genStyleHooks(
['Input', 'OTP'],
(token) => {
const inputToken = mergeToken<InputToken>(token, initInputToken(token));
return [genOTPStyle(inputToken)];
},
initComponentToken,
);

File diff suppressed because it is too large Load Diff

View File

@ -61,6 +61,7 @@ import 'dayjs/locale/tr';
import 'dayjs/locale/uk';
import 'dayjs/locale/ur';
import 'dayjs/locale/vi';
import 'dayjs/locale/uz-latn';
import 'dayjs/locale/zh-cn';
import 'dayjs/locale/zh-hk';
import 'dayjs/locale/zh-tw';
@ -151,6 +152,7 @@ import zhCN from '../../locale/zh_CN';
import zhHK from '../../locale/zh_HK';
import zhTW from '../../locale/zh_TW';
import myMM from '../../locale/my_MM';
import uzUZ from '../../locale/uz_UZ';
dayjs.extend(preParsePostFormat);
@ -224,6 +226,7 @@ const locales = [
zhTW,
urPK,
myMM,
uzUZ,
];
const { Option } = Select;

View File

@ -80,6 +80,7 @@ const localeValues: Locale = {
copy: 'Copy',
copied: 'Copied',
expand: 'Expand',
collapse: 'Collapse',
},
Form: {
optional: '(optional)',

View File

@ -40,6 +40,7 @@ export interface Locale {
copy?: any;
copied?: any;
expand?: any;
collapse?: any;
};
Form?: {
optional?: string;

149
components/locale/uz_UZ.ts Normal file
View File

@ -0,0 +1,149 @@
/* eslint-disable no-template-curly-in-string */
import Pagination from 'rc-pagination/lib/locale/uz_UZ';
import type { Locale } from '.';
import Calendar from '../calendar/locale/uz_UZ';
import DatePicker from '../date-picker/locale/uz_UZ';
import TimePicker from '../time-picker/locale/uz_UZ';
const typeTemplate: string = '${label} ${type} turi emas';
const localeValues: Locale = {
// NOTE: In
// https://github.com/react-component/picker/blob/master/src/locale/uz_UZ.ts
// and
// https://github.com/react-component/pagination/blob/master/src/locale/uz_UZ.ts
// both implemented as uz-latn Uzbek
locale: 'uz-latn',
Pagination,
DatePicker,
TimePicker,
Calendar,
global: {
placeholder: 'Iltimos tanlang',
},
Table: {
filterTitle: 'Filtr',
filterConfirm: 'OK',
filterReset: 'Tshlash',
filterEmptyText: 'Filtrlarsiz',
filterCheckall: 'Barcha elementlarni tanlash',
filterSearchPlaceholder: 'Filtrlarda qidiruv',
emptyText: "Ma'lumotlar topilmadi",
selectAll: 'Barchasini tanlash',
selectInvert: 'Tanlovni aylantirish',
selectNone: "Barcha ma'lumotlarni tozalang",
selectionAll: "Barcha ma'lumotlarni tanlash",
sortTitle: 'Tartiblash',
expand: 'Satirni yozish',
collapse: "Satirni yig'ish",
triggerDesc: 'Kamayish tartibida tartiblash uchun bosing',
triggerAsc: "O'sish tartibida tartiblash uchun bosing",
cancelSort: 'Tartiblshni rad etish uchun bosing',
},
Tour: {
Next: "So'ngra",
Previous: 'Ortga',
Finish: 'Tugatish',
},
Modal: {
okText: 'OK',
cancelText: "O'chirish",
justOkText: 'OK',
},
Popconfirm: {
okText: 'OK',
cancelText: 'Bekor qilish',
},
Transfer: {
titles: ['', ''],
searchPlaceholder: 'Qidiruv',
itemUnit: 'элем.',
itemsUnit: 'элем.',
remove: 'Oʻchirish',
selectAll: "Barch ma'lumotlarni tanlash",
selectCurrent: 'Joriy sahifani tanlash',
selectInvert: 'Tanlovni aylantirish',
removeAll: "Barcha ma'lumotlarni o'chirish",
removeCurrent: "Joriy sahifani o'chirish",
},
Upload: {
uploading: 'Yuklanish...',
removeFile: "Faylni o'chirish",
uploadError: 'Yuklashda xatolik yuz berdi',
previewFile: "Faylni oldindan ko'rish",
downloadFile: 'Faylni yuklash',
},
Empty: {
description: 'Maʼlumot topilmadi',
},
Icon: {
icon: 'ikonka',
},
Text: {
edit: 'Tahrirlash',
copy: 'Nusxalash',
copied: 'Nusxalandi',
expand: 'Ochib qoyish',
},
Form: {
optional: '(shart emas)',
defaultValidateMessages: {
default: '${label} maydonini tekshirishda xatolik yuz berdi',
required: 'Iltimos, ${label} kiriting',
enum: '${label}, [${enum}] dan biri boʻlishi kerak',
whitespace: '${label} boʻsh boʻlishi mumkin emas',
date: {
format: '${label} toʻgʻri sana formatida emas',
parse: '${label} sanaga aylantirilmaydi',
invalid: "${label} tog'ri sana emas",
},
types: {
string: typeTemplate,
method: typeTemplate,
array: typeTemplate,
object: typeTemplate,
number: typeTemplate,
date: typeTemplate,
boolean: typeTemplate,
integer: typeTemplate,
float: typeTemplate,
regexp: typeTemplate,
email: typeTemplate,
url: typeTemplate,
hex: typeTemplate,
},
string: {
len: '${label}, ${len} ta belgidan iborat boʻlishi kerak',
min: '${label} должна быть больше или равна ${min} символов',
max: '${label}, ${max} belgidan katta yoki teng boʻlishi kerak',
range: '${label} uzunligi ${min}-${max} belgilar orasida boʻlishi kerak',
},
number: {
len: '${label}, ${len} ga teng boʻlishi kerak',
min: '${label}, ${min} dan katta yoki teng boʻlishi kerak',
max: '${label}, ${max} dan kichik yoki teng boʻlishi kerak',
range: '${label}, ${min}-${max} orasida boʻlishi kerak',
},
array: {
len: '${label} elementlari soni ${len} ga teng boʻlishi kerak',
min: '${label} elementlari soni ${min} dan katta yoki teng boʻlishi kerak',
max: '${label} elementlari soni ${max} dan kam yoki teng boʻlishi kerak',
range: '${label} elementlari soni ${min} va ${max} orasida boʻlishi kerak',
},
pattern: {
mismatch: '${label}, ${pattern} andazasiga mos emas',
},
},
},
Image: {
preview: 'Korib chiqish',
},
QRCode: {
expired: 'QR-kod eskirgan',
refresh: 'Yangilash',
},
};
export default localeValues;

View File

@ -80,6 +80,7 @@ const localeValues: Locale = {
copy: '复制',
copied: '复制成功',
expand: '展开',
collapse: '收起',
},
Form: {
optional: '(可选)',

View File

@ -3,7 +3,7 @@ import CloseOutlined from '@ant-design/icons/CloseOutlined';
import classNames from 'classnames';
import Dialog from 'rc-dialog';
import useClosable from '../_util/hooks/useClosable';
import useClosable, { pickClosable } from '../_util/hooks/useClosable';
import { useZIndex } from '../_util/hooks/useZIndex';
import { getTransitionName } from '../_util/motion';
import { canUseDocElement } from '../_util/styleChecker';
@ -44,7 +44,7 @@ const Modal: React.FC<ModalProps> = (props) => {
getPopupContainer: getContextPopupContainer,
getPrefixCls,
direction,
modal,
modal: modalContext,
} = React.useContext(ConfigContext);
const handleCancel = (e: React.MouseEvent<HTMLButtonElement>) => {
@ -77,8 +77,6 @@ const Modal: React.FC<ModalProps> = (props) => {
wrapClassName,
centered,
getContainer,
closeIcon,
closable,
focusTriggerAfterClose = true,
style,
// Deprecated
@ -106,13 +104,15 @@ const Modal: React.FC<ModalProps> = (props) => {
<Footer {...props} onOk={handleOk} onCancel={handleCancel} />
);
const [mergedClosable, mergedCloseIcon] = useClosable({
closable,
closeIcon: typeof closeIcon !== 'undefined' ? closeIcon : modal?.closeIcon,
customCloseIconRender: (icon) => renderCloseIcon(prefixCls, icon),
defaultCloseIcon: <CloseOutlined className={`${prefixCls}-close-icon`} />,
defaultClosable: true,
});
const [mergedClosable, mergedCloseIcon] = useClosable(
pickClosable(props),
pickClosable(modalContext),
{
closable: true,
closeIcon: <CloseOutlined className={`${prefixCls}-close-icon`} />,
closeIconRender: (icon) => renderCloseIcon(prefixCls, icon),
},
);
// ============================ Refs ============================
// Select `ant-modal-content` by `panelRef`
@ -142,15 +142,15 @@ const Modal: React.FC<ModalProps> = (props) => {
focusTriggerAfterClose={focusTriggerAfterClose}
transitionName={getTransitionName(rootPrefixCls, 'zoom', props.transitionName)}
maskTransitionName={getTransitionName(rootPrefixCls, 'fade', props.maskTransitionName)}
className={classNames(hashId, className, modal?.className)}
style={{ ...modal?.style, ...style }}
className={classNames(hashId, className, modalContext?.className)}
style={{ ...modalContext?.style, ...style }}
classNames={{
...modal?.classNames,
...modalContext?.classNames,
...modalClassNames,
wrapper: classNames(wrapClassNameExtended, modalClassNames?.wrapper),
}}
styles={{
...modal?.styles,
...modalContext?.styles,
...modalStyles,
}}
panelRef={panelRef}

View File

@ -3,6 +3,7 @@ import type { DialogProps } from 'rc-dialog';
import type { ButtonProps, LegacyButtonType } from '../button/button';
import type { DirectionType } from '../config-provider';
import type { ClosableType } from '../_util/hooks/useClosable';
export type ModalFooterRender = (
originNode: React.ReactNode,
@ -19,7 +20,7 @@ export interface ModalProps extends ModalCommonProps {
/** The modal dialog's title */
title?: React.ReactNode;
/** Whether a close (x) button is visible on top right of the modal dialog or not. Recommend to use closeIcon instead. */
closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes);
closable?: ClosableType;
/** Specify a function that will be called when a user clicks the OK button */
onOk?: (e: React.MouseEvent<HTMLButtonElement>) => void;
/** Specify a function that will be called when a user clicks mask, close button on top right or Cancel button */
@ -84,7 +85,7 @@ export interface ModalFuncProps extends ModalCommonProps {
/** @deprecated Please use `open` instead. */
visible?: boolean;
title?: React.ReactNode;
closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes);
closable?: ClosableType;
content?: React.ReactNode;
// TODO: find out exact types
onOk?: (...args: any[]) => any;

View File

@ -506,6 +506,7 @@ exports[`renders components/notification/demo/render-panel.tsx extend context co
</div>
</div>
<a
aria-label="Close"
class="ant-notification-notice-close"
tabindex="0"
>

View File

@ -494,6 +494,7 @@ exports[`renders components/notification/demo/render-panel.tsx correctly 1`] = `
</div>
</div>
<a
aria-label="Close"
class="ant-notification-notice-close"
tabindex="0"
>

View File

@ -249,6 +249,42 @@ describe('notification', () => {
});
});
it('support config closable', async () => {
notification.config({
closable: {
closeIcon: <span className="test-customize-icon" />,
'aria-label': 'CloseBtn',
},
});
// Global Icon
notification.open({
message: 'Notification Title',
duration: 0,
});
await awaitPromise();
expect(document.querySelector('.test-customize-icon')).toBeTruthy();
expect(document.querySelector('*[aria-label="CloseBtn"]')).toBeTruthy();
// Notice Icon
notification.open({
message: 'Notification Title',
duration: 0,
closable: {
closeIcon: <span className="replace-icon" />,
'aria-label': 'CloseBtn2',
},
});
expect(document.querySelector('.replace-icon')).toBeTruthy();
expect(document.querySelector('*[aria-label="CloseBtn2"]')).toBeTruthy();
notification.config({
closable: undefined,
});
});
it('closeIcon should be update', async () => {
const list = ['1', '2'];
list.forEach((type) => {

View File

@ -1,4 +1,5 @@
import type * as React from 'react';
import type { ClosableType } from '../_util/hooks/useClosable';
interface DivProps extends React.HTMLProps<HTMLDivElement> {
'data-testid'?: string;
@ -30,6 +31,7 @@ export interface ArgsProps {
readonly type?: IconType;
onClick?: () => void;
closeIcon?: React.ReactNode;
closable?: ClosableType;
props?: DivProps;
role?: 'alert' | 'status';
}
@ -53,6 +55,7 @@ export interface GlobalConfigProps {
getContainer?: () => HTMLElement | ShadowRoot;
placement?: NotificationPlacement;
closeIcon?: React.ReactNode;
closable?: ClosableType;
rtl?: boolean;
maxCount?: number;
props?: DivProps;

View File

@ -148,6 +148,7 @@ export function useInternalNotification(
style,
role = 'alert',
closeIcon,
closable,
...restConfig
} = config;
@ -178,7 +179,7 @@ export function useInternalNotification(
),
style: { ...notification?.style, ...style },
closeIcon: realCloseIcon,
closable: !!realCloseIcon,
closable: closable ?? !!realCloseIcon,
});
};

View File

@ -2,11 +2,9 @@ import * as React from 'react';
import ExclamationCircleFilled from '@ant-design/icons/ExclamationCircleFilled';
import classNames from 'classnames';
import useMergedState from 'rc-util/lib/hooks/useMergedState';
import KeyCode from 'rc-util/lib/KeyCode';
import omit from 'rc-util/lib/omit';
import type { RenderFunction } from '../_util/getRenderPropValue';
import { cloneElement } from '../_util/reactNode';
import type { ButtonProps, LegacyButtonType } from '../button/button';
import { ConfigContext } from '../config-provider';
import Popover from '../popover';
@ -78,18 +76,15 @@ const Popconfirm = React.forwardRef<TooltipRef, PopconfirmProps>((props, ref) =>
props.onCancel?.call(this, e);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.keyCode === KeyCode.ESC && open) {
settingOpen(false, e);
}
};
const onInternalOpenChange = (value: boolean) => {
const onInternalOpenChange = (
value: boolean,
e?: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLDivElement>,
) => {
const { disabled = false } = props;
if (disabled) {
return;
}
settingOpen(value);
settingOpen(value, e);
};
const prefixCls = getPrefixCls('popconfirm', customizePrefixCls);
@ -119,14 +114,7 @@ const Popconfirm = React.forwardRef<TooltipRef, PopconfirmProps>((props, ref) =>
}
data-popover-inject
>
{cloneElement(children, {
onKeyDown: (e: React.KeyboardEvent<any>) => {
if (React.isValidElement(children)) {
children?.props.onKeyDown?.(e);
}
onKeyDown(e);
},
})}
{children}
</Popover>,
);
}) as React.ForwardRefExoticComponent<

View File

@ -11,6 +11,11 @@ const { _InternalPanelDoNotUseOrYouWillBeFired: InternalPanelDoNotUseOrYouWillBe
describe('Popover', () => {
mountTest(Popover);
const eventObject = expect.objectContaining({
target: expect.anything(),
preventDefault: expect.any(Function),
});
it('should show overlay when trigger is clicked', () => {
const ref = React.createRef<TooltipRef>();
const { container } = render(
@ -94,4 +99,20 @@ describe('Popover', () => {
render(<InternalPanelDoNotUseOrYouWillBeFired content={null} title={null} trigger="click" />);
}).not.toThrow();
});
it('should be closed by pressing ESC', () => {
const onOpenChange = jest.fn((_, e) => {
e?.persist?.();
});
const wrapper = render(
<Popover title="Title" trigger="click" onOpenChange={onOpenChange}>
<span>Delete</span>
</Popover>,
);
const triggerNode = wrapper.container.querySelectorAll('span')[0];
fireEvent.click(triggerNode);
expect(onOpenChange).toHaveBeenLastCalledWith(true, undefined);
fireEvent.keyDown(triggerNode, { key: 'Escape', keyCode: 27 });
expect(onOpenChange).toHaveBeenLastCalledWith(false, eventObject);
});
});

View File

@ -11,9 +11,17 @@ import PurePanel from './PurePanel';
// CSSINJS
import useStyle from './style';
import KeyCode from 'rc-util/lib/KeyCode';
import { cloneElement } from '../_util/reactNode';
import useMergedState from 'rc-util/lib/hooks/useMergedState';
export interface PopoverProps extends AbstractTooltipProps {
title?: React.ReactNode | RenderFunction;
content?: React.ReactNode | RenderFunction;
onOpenChange?: (
open: boolean,
e?: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLDivElement>,
) => void;
}
interface OverlayProps {
@ -37,8 +45,10 @@ const Popover = React.forwardRef<TooltipRef, PopoverProps>((props, ref) => {
overlayClassName,
placement = 'top',
trigger = 'hover',
children,
mouseEnterDelay = 0.1,
mouseLeaveDelay = 0.1,
onOpenChange,
overlayStyle = {},
...otherProps
} = props;
@ -49,6 +59,27 @@ const Popover = React.forwardRef<TooltipRef, PopoverProps>((props, ref) => {
const rootPrefixCls = getPrefixCls();
const overlayCls = classNames(overlayClassName, hashId, cssVarCls);
const [open, setOpen] = useMergedState(false, {
value: props.open ?? props.visible,
});
const settingOpen = (
value: boolean,
e?: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLDivElement>,
) => {
setOpen(value, true);
onOpenChange?.(value, e);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.keyCode === KeyCode.ESC) {
settingOpen(false, e);
}
};
const onInternalOpenChange = (value: boolean) => {
settingOpen(value);
};
return wrapCSSVar(
<Tooltip
@ -61,12 +92,23 @@ const Popover = React.forwardRef<TooltipRef, PopoverProps>((props, ref) => {
prefixCls={prefixCls}
overlayClassName={overlayCls}
ref={ref}
open={open}
onOpenChange={onInternalOpenChange}
overlay={
title || content ? <Overlay prefixCls={prefixCls} title={title} content={content} /> : null
}
transitionName={getTransitionName(rootPrefixCls, 'zoom-big', otherProps.transitionName)}
data-popover-inject
/>,
>
{cloneElement(children, {
onKeyDown: (e: React.KeyboardEvent<any>) => {
if (React.isValidElement(children)) {
children?.props.onKeyDown?.(e);
}
onKeyDown(e);
},
})}
</Tooltip>,
);
}) as React.ForwardRefExoticComponent<
React.PropsWithoutRef<PopoverProps> & React.RefAttributes<unknown>

View File

@ -29,6 +29,7 @@ const Circle: React.FC<CircleProps> = (props) => {
children,
success,
size = originWidth,
steps,
} = props;
const [width, height] = getSize(size, 'circle');
@ -51,6 +52,7 @@ const Circle: React.FC<CircleProps> = (props) => {
return undefined;
}, [gapDegree, type]);
const percentArray = getPercentage(props);
const gapPos = gapPosition || (type === 'dashboard' && 'bottom') || undefined;
// using className to style stroke color
@ -63,10 +65,11 @@ const Circle: React.FC<CircleProps> = (props) => {
const circleContent = (
<RCCircle
percent={getPercentage(props)}
steps={steps}
percent={steps ? percentArray[1] : percentArray}
strokeWidth={strokeWidth}
trailWidth={strokeWidth}
strokeColor={strokeColor}
strokeColor={steps ? strokeColor[1] : strokeColor}
strokeLinecap={strokeLinecap}
trailColor={trailColor}
prefixCls={prefixCls}

View File

@ -485,6 +485,296 @@ exports[`renders components/progress/demo/circle-mini.tsx extend context correct
exports[`renders components/progress/demo/circle-mini.tsx extend context correctly 2`] = `[]`;
exports[`renders components/progress/demo/circle-steps.tsx extend context correctly 1`] = `
Array [
<h5
class="ant-typography"
>
Custom count:
</h5>,
<div
class="ant-slider ant-slider-horizontal"
>
<div
class="ant-slider-rail"
/>
<div
class="ant-slider-track"
style="left: 0%; width: 37.5%;"
/>
<div
class="ant-slider-step"
/>
<div
aria-disabled="false"
aria-orientation="horizontal"
aria-valuemax="10"
aria-valuemin="2"
aria-valuenow="5"
class="ant-slider-handle"
role="slider"
style="left: 37.5%; transform: translateX(-50%);"
tabindex="0"
/>
<div
class="ant-tooltip ant-zoom-big-fast-appear ant-zoom-big-fast-appear-prepare ant-zoom-big-fast ant-slider-tooltip ant-tooltip-placement-top"
style="--arrow-x: 0px; --arrow-y: 0px; left: -1000vw; top: -1000vh; box-sizing: border-box;"
>
<div
class="ant-tooltip-arrow"
style="position: absolute; bottom: 0px; left: 0px;"
/>
<div
class="ant-tooltip-content"
>
<div
class="ant-tooltip-inner"
role="tooltip"
>
5
</div>
</div>
</div>
</div>,
<h5
class="ant-typography"
>
Custom gap:
</h5>,
<div
class="ant-slider ant-slider-horizontal"
>
<div
class="ant-slider-rail"
/>
<div
class="ant-slider-track"
style="left: 0%; width: 20%;"
/>
<div
class="ant-slider-step"
/>
<div
aria-disabled="false"
aria-orientation="horizontal"
aria-valuemax="40"
aria-valuemin="0"
aria-valuenow="8"
class="ant-slider-handle"
role="slider"
style="left: 20%; transform: translateX(-50%);"
tabindex="0"
/>
<div
class="ant-tooltip ant-zoom-big-fast-appear ant-zoom-big-fast-appear-prepare ant-zoom-big-fast ant-slider-tooltip ant-tooltip-placement-top"
style="--arrow-x: 0px; --arrow-y: 0px; left: -1000vw; top: -1000vh; box-sizing: border-box;"
>
<div
class="ant-tooltip-arrow"
style="position: absolute; bottom: 0px; left: 0px;"
/>
<div
class="ant-tooltip-content"
>
<div
class="ant-tooltip-inner"
role="tooltip"
>
8
</div>
</div>
</div>
</div>,
<div
class="ant-flex ant-flex-wrap-wrap ant-flex-gap-middle"
style="margin-top: 16px;"
>
<div
aria-valuenow="50"
class="ant-progress ant-progress-status-normal ant-progress-circle ant-progress-steps ant-progress-show-info ant-progress-default"
role="progressbar"
>
<div
class="ant-progress-inner"
style="width: 120px; height: 120px; font-size: 24px;"
>
<svg
class="ant-progress-circle"
role="presentation"
viewBox="0 0 100 100"
>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray: 198.96753472735355px 251.32741228718345; stroke-dashoffset: 176.09659288643437; transform: rotate(127.5deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray: 198.96753472735355px 251.32741228718345; stroke-dashoffset: 176.09659288643437; transform: rotate(163.125deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray: 198.96753472735355px 251.32741228718345; stroke-dashoffset: 176.09659288643437; transform: rotate(198.74999999999997deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray: 198.96753472735355px 251.32741228718345; stroke-dashoffset: 176.09659288643437; transform: rotate(234.37499999999994deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke: rgba(0, 0, 0, 0.06); stroke-dasharray: 198.96753472735355px 251.32741228718345; stroke-dashoffset: 176.09659288643437; transform: rotate(269.99999999999994deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke: rgba(0, 0, 0, 0.06); stroke-dasharray: 198.96753472735355px 251.32741228718345; stroke-dashoffset: 176.09659288643437; transform: rotate(305.625deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke: rgba(0, 0, 0, 0.06); stroke-dasharray: 198.96753472735355px 251.32741228718345; stroke-dashoffset: 176.09659288643437; transform: rotate(341.24999999999994deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke: rgba(0, 0, 0, 0.06); stroke-dasharray: 198.96753472735355px 251.32741228718345; stroke-dashoffset: 176.09659288643437; transform: rotate(376.87499999999994deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
</svg>
<span
class="ant-progress-text"
title="50%"
>
50%
</span>
</div>
</div>
<div
aria-valuenow="100"
class="ant-progress ant-progress-status-success ant-progress-circle ant-progress-steps ant-progress-show-info ant-progress-default"
role="progressbar"
>
<div
class="ant-progress-inner"
style="width: 120px; height: 120px; font-size: 24px;"
>
<svg
class="ant-progress-circle"
role="presentation"
viewBox="0 0 100 100"
>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray: 251.32741228718345px 251.32741228718345; stroke-dashoffset: 208.06192982974676; transform: rotate(-90deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray: 251.32741228718345px 251.32741228718345; stroke-dashoffset: 208.06192982974676; transform: rotate(-18deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray: 251.32741228718345px 251.32741228718345; stroke-dashoffset: 208.06192982974676; transform: rotate(54deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray: 251.32741228718345px 251.32741228718345; stroke-dashoffset: 208.06192982974676; transform: rotate(126deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray: 251.32741228718345px 251.32741228718345; stroke-dashoffset: 208.06192982974676; transform: rotate(198deg); transform-origin: 50px 50px; transition: stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s; fill-opacity: 0; transition-duration: 0s, 0s;"
/>
</svg>
<span
class="ant-progress-text"
>
<span
aria-label="check"
class="anticon anticon-check"
role="img"
>
<svg
aria-hidden="true"
data-icon="check"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 00-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"
/>
</svg>
</span>
</span>
</div>
</div>
</div>,
]
`;
exports[`renders components/progress/demo/circle-steps.tsx extend context correctly 2`] = `[]`;
exports[`renders components/progress/demo/component-token.tsx extend context correctly 1`] = `
Array [
<div

View File

@ -455,6 +455,256 @@ exports[`renders components/progress/demo/circle-mini.tsx correctly 1`] = `
</div>
`;
exports[`renders components/progress/demo/circle-steps.tsx correctly 1`] = `
Array [
<h5
class="ant-typography"
>
Custom count:
</h5>,
<div
class="ant-slider ant-slider-horizontal"
>
<div
class="ant-slider-rail"
/>
<div
class="ant-slider-track"
style="left:0%;width:37.5%"
/>
<div
class="ant-slider-step"
/>
<div
aria-disabled="false"
aria-orientation="horizontal"
aria-valuemax="10"
aria-valuemin="2"
aria-valuenow="5"
class="ant-slider-handle"
role="slider"
style="left:37.5%;transform:translateX(-50%)"
tabindex="0"
/>
</div>,
<h5
class="ant-typography"
>
Custom gap:
</h5>,
<div
class="ant-slider ant-slider-horizontal"
>
<div
class="ant-slider-rail"
/>
<div
class="ant-slider-track"
style="left:0%;width:20%"
/>
<div
class="ant-slider-step"
/>
<div
aria-disabled="false"
aria-orientation="horizontal"
aria-valuemax="40"
aria-valuemin="0"
aria-valuenow="8"
class="ant-slider-handle"
role="slider"
style="left:20%;transform:translateX(-50%)"
tabindex="0"
/>
</div>,
<div
class="ant-flex ant-flex-wrap-wrap ant-flex-gap-middle"
style="margin-top:16px"
>
<div
aria-valuenow="50"
class="ant-progress ant-progress-status-normal ant-progress-circle ant-progress-steps ant-progress-show-info ant-progress-default"
role="progressbar"
>
<div
class="ant-progress-inner"
style="width:120px;height:120px;font-size:24px"
>
<svg
class="ant-progress-circle"
role="presentation"
viewBox="0 0 100 100"
>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray:198.96753472735355px 251.32741228718345;stroke-dashoffset:176.09659288643437;transform:rotate(127.5deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray:198.96753472735355px 251.32741228718345;stroke-dashoffset:176.09659288643437;transform:rotate(163.125deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray:198.96753472735355px 251.32741228718345;stroke-dashoffset:176.09659288643437;transform:rotate(198.74999999999997deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray:198.96753472735355px 251.32741228718345;stroke-dashoffset:176.09659288643437;transform:rotate(234.37499999999994deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke:rgba(0, 0, 0, 0.06);stroke-dasharray:198.96753472735355px 251.32741228718345;stroke-dashoffset:176.09659288643437;transform:rotate(269.99999999999994deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke:rgba(0, 0, 0, 0.06);stroke-dasharray:198.96753472735355px 251.32741228718345;stroke-dashoffset:176.09659288643437;transform:rotate(305.625deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke:rgba(0, 0, 0, 0.06);stroke-dasharray:198.96753472735355px 251.32741228718345;stroke-dashoffset:176.09659288643437;transform:rotate(341.24999999999994deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke:rgba(0, 0, 0, 0.06);stroke-dasharray:198.96753472735355px 251.32741228718345;stroke-dashoffset:176.09659288643437;transform:rotate(376.87499999999994deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
</svg>
<span
class="ant-progress-text"
title="50%"
>
50%
</span>
</div>
</div>
<div
aria-valuenow="100"
class="ant-progress ant-progress-status-success ant-progress-circle ant-progress-steps ant-progress-show-info ant-progress-default"
role="progressbar"
>
<div
class="ant-progress-inner"
style="width:120px;height:120px;font-size:24px"
>
<svg
class="ant-progress-circle"
role="presentation"
viewBox="0 0 100 100"
>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray:251.32741228718345px 251.32741228718345;stroke-dashoffset:208.06192982974676;transform:rotate(-90deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray:251.32741228718345px 251.32741228718345;stroke-dashoffset:208.06192982974676;transform:rotate(-18deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray:251.32741228718345px 251.32741228718345;stroke-dashoffset:208.06192982974676;transform:rotate(54deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray:251.32741228718345px 251.32741228718345;stroke-dashoffset:208.06192982974676;transform:rotate(126deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
<circle
class="ant-progress-circle-path"
cx="50"
cy="50"
opacity="1"
r="40"
stroke-width="20"
style="stroke-dasharray:251.32741228718345px 251.32741228718345;stroke-dashoffset:208.06192982974676;transform:rotate(198deg);transform-origin:50px 50px;transition:stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s;fill-opacity:0"
/>
</svg>
<span
class="ant-progress-text"
>
<span
aria-label="check"
class="anticon anticon-check"
role="img"
>
<svg
aria-hidden="true"
data-icon="check"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 00-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"
/>
</svg>
</span>
</span>
</div>
</div>
</div>,
]
`;
exports[`renders components/progress/demo/component-token.tsx correctly 1`] = `
Array [
<div

View File

@ -1,5 +1,83 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Progress circle progress should accept steps 1`] = `
<div
aria-valuenow="70"
class="ant-progress ant-progress-status-normal ant-progress-steps ant-progress-show-info ant-progress-default"
role="progressbar"
>
<div
class="ant-progress-steps-outer"
>
<div
class="ant-progress-steps-item ant-progress-steps-item-active"
style="width: 14px; height: 8px;"
/>
<div
class="ant-progress-steps-item ant-progress-steps-item-active"
style="width: 14px; height: 8px;"
/>
<div
class="ant-progress-steps-item ant-progress-steps-item-active"
style="width: 14px; height: 8px;"
/>
<div
class="ant-progress-steps-item ant-progress-steps-item-active"
style="width: 14px; height: 8px;"
/>
<div
class="ant-progress-steps-item"
style="width: 14px; height: 8px;"
/>
<span
class="ant-progress-text"
title="70%"
>
70%
</span>
</div>
</div>
`;
exports[`Progress circle progress steps can be number 1`] = `
<div
aria-valuenow="70"
class="ant-progress ant-progress-status-normal ant-progress-steps ant-progress-show-info ant-progress-default"
role="progressbar"
>
<div
class="ant-progress-steps-outer"
>
<div
class="ant-progress-steps-item ant-progress-steps-item-active"
style="width: 14px; height: 8px;"
/>
<div
class="ant-progress-steps-item ant-progress-steps-item-active"
style="width: 14px; height: 8px;"
/>
<div
class="ant-progress-steps-item ant-progress-steps-item-active"
style="width: 14px; height: 8px;"
/>
<div
class="ant-progress-steps-item ant-progress-steps-item-active"
style="width: 14px; height: 8px;"
/>
<div
class="ant-progress-steps-item"
style="width: 14px; height: 8px;"
/>
<span
class="ant-progress-text"
title="70%"
>
70%
</span>
</div>
</div>
`;
exports[`Progress render dashboard 295 gapDegree 1`] = `
<div
aria-valuenow="0"

View File

@ -404,4 +404,14 @@ describe('Progress', () => {
expect(progress).toHaveAttribute('aria-labelledby', 'progressLabel');
expect(progress).toHaveAttribute('aria-valuenow', '90');
});
it('circle progress should accept steps', () => {
const { container } = render(<Progress percent={70} steps={{ count: 5, gap: 5 }} />);
expect(container.firstChild).toMatchSnapshot();
});
it('circle progress steps can be number', () => {
const { container } = render(<Progress percent={70} steps={5} />);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@ -0,0 +1,7 @@
## zh-CN
步骤进度圈,支持颜色分段展示,默认间隔为 2px。
## en-US
A circular progress bar that support steps and color segments, default gap is 2px.

View File

@ -0,0 +1,33 @@
import React from 'react';
import { Flex, Progress, Slider, Typography } from 'antd';
const App: React.FC = () => {
const [stepsCount, setStepsCount] = React.useState<number>(5);
const [stepsGap, setStepsGap] = React.useState<number>(7);
return (
<>
<Typography.Title level={5}>Custom count:</Typography.Title>
<Slider min={2} max={10} value={stepsCount} onChange={setStepsCount} />
<Typography.Title level={5}>Custom gap:</Typography.Title>
<Slider step={4} min={0} max={40} value={stepsGap} onChange={setStepsGap} />
<Flex wrap="wrap" gap="middle" style={{ marginTop: 16 }}>
<Progress
type="dashboard"
steps={8}
percent={50}
trailColor="rgba(0, 0, 0, 0.06)"
strokeWidth={20}
/>
<Progress
type="circle"
percent={100}
steps={{ count: stepsCount, gap: stepsGap }}
trailColor="rgba(0, 0, 0, 0.06)"
strokeWidth={20}
/>
</Flex>
</>
);
};
export default App;

View File

@ -31,6 +31,7 @@ If it will take a long time to complete an operation, you can use `Progress` to
<code src="./demo/linecap.tsx">Stroke Linecap</code>
<code src="./demo/gradient-line.tsx">Custom line gradient</code>
<code src="./demo/steps.tsx">Progress bar with steps</code>
<code src="./demo/circle-steps.tsx" version="5.16.0">Circular progress bar whit steps</code>
<code src="./demo/size.tsx">Progress size</code>
## API
@ -63,13 +64,15 @@ Properties that shared by all types.
| Property | Description | Type | Default | Version |
| --- | --- | --- | --- | --- |
| steps | The total step count.When passing an object, `count` refers to the number of steps, and `gap` refers to the distance between them.When passing number, the default value for `gap` is 2. | number \| { count: number, gap: number } | - | 5.16.0 |
| strokeColor | The color of circular progress, render gradient when passing an object | string \| { number%: string } | - | - |
| strokeWidth | To set the width of the circular progress, unit: percentage of the canvas width | number | 6 | - |
### `type="dashboard"`
| Property | Description | Type | Default |
| --- | --- | --- | --- |
| Property | Description | Type | Default | Version |
| --- | --- | --- | --- | --- |
| steps | The total step count.When passing an object, `count` refers to the number of steps, and `gap` refers to the distance between them.When passing number, the default value for `gap` is 2. | number \| { count: number, gap: number } | - | 5.16.0 |
| gapDegree | The gap degree of half circle, 0 ~ 295 | number | 75 |
| gapPosition | The gap position, options: `top` `bottom` `left` `right` | string | `bottom` |
| strokeWidth | To set the width of the dashboard progress, unit: percentage of the canvas width | number | 6 |

View File

@ -32,6 +32,7 @@ demo:
<code src="./demo/linecap.tsx">边缘形状</code>
<code src="./demo/gradient-line.tsx">自定义进度条渐变色</code>
<code src="./demo/steps.tsx">步骤进度条</code>
<code src="./demo/circle-steps.tsx" version="5.16.0">步骤进度圈</code>
<code src="./demo/size.tsx">尺寸</code>
## API
@ -64,6 +65,7 @@ demo:
| 属性 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| steps | 进度条总共步数,传入 object 时count 指步数gap 指间隔大小。传 number 类型时gap 默认为 2。 | number \| { count: number, gap: number } | - | 5.16.0 |
| strokeColor | 圆形进度条线的色彩,传入 object 时为渐变 | string \| { number%: string } | - | - |
| strokeWidth | 圆形进度条线的宽度,单位是进度条画布宽度的百分比 | number | 6 | - |
@ -71,6 +73,7 @@ demo:
| 属性 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| steps | 进度条总共步数,传入 object 时count 指步数gap 指间隔大小。传 number 类型时gap 默认为 2。 | number \| { count: number, gap: number } | - | 5.16.0 |
| gapDegree | 仪表盘进度条缺口角度,可取值 0 ~ 295 | number | 75 | - |
| gapPosition | 仪表盘进度条缺口位置 | `top` \| `bottom` \| `left` \| `right` | `bottom` | - |
| strokeWidth | 仪表盘进度条线的宽度,单位是进度条画布宽度的百分比 | number | 6 | - |

View File

@ -53,7 +53,7 @@ export interface ProgressProps extends ProgressAriaProps {
gapDegree?: number;
gapPosition?: 'top' | 'bottom' | 'left' | 'right';
size?: number | [number | string, number] | ProgressSize;
steps?: number;
steps?: number | { count: number; gap: number };
/** @deprecated Use `success` instead */
successPercent?: number;
children?: React.ReactNode;
@ -148,7 +148,12 @@ const Progress = React.forwardRef<HTMLDivElement, ProgressProps>((props, ref) =>
// Render progress shape
if (type === 'line') {
progress = steps ? (
<Steps {...props} strokeColor={strokeColorNotGradient} prefixCls={prefixCls} steps={steps}>
<Steps
{...props}
strokeColor={strokeColorNotGradient}
prefixCls={prefixCls}
steps={typeof steps === 'object' ? steps.count : steps}
>
{progressInfo}
</Steps>
) : (
@ -177,9 +182,11 @@ const Progress = React.forwardRef<HTMLDivElement, ProgressProps>((props, ref) =>
const classString = classNames(
prefixCls,
`${prefixCls}-status-${progressStatus}`,
`${prefixCls}-${(type === 'dashboard' && 'circle') || (steps && 'steps') || type}`,
{
[`${prefixCls}-${(type === 'dashboard' && 'circle') || type}`]: type !== 'line',
[`${prefixCls}-inline-circle`]: type === 'circle' && getSize(size, 'circle')[0] <= 20,
[`${prefixCls}-line`]: !steps && type === 'line',
[`${prefixCls}-steps`]: steps,
[`${prefixCls}-show-info`]: showInfo,
[`${prefixCls}-${size}`]: typeof size === 'string',
[`${prefixCls}-rtl`]: direction === 'rtl',

View File

@ -25,7 +25,6 @@ import Pagination from '../pagination';
import type { SpinProps } from '../spin';
import Spin from '../spin';
import { useToken } from '../theme/internal';
import type { TooltipProps } from '../tooltip';
import renderExpandIcon from './ExpandIcon';
import useContainerWidth from './hooks/useContainerWidth';
import type { FilterState } from './hooks/useFilter';
@ -47,6 +46,7 @@ import type {
GetRowKey,
RefInternalTable,
SorterResult,
SorterTooltipProps,
SortOrder,
TableAction,
TableCurrentDataSource,
@ -116,7 +116,7 @@ export interface TableProps<RecordType = any>
scrollToFirstRowOnChange?: boolean;
};
sortDirections?: SortOrder[];
showSorterTooltip?: boolean | TooltipProps;
showSorterTooltip?: boolean | SorterTooltipProps;
virtual?: boolean;
}
@ -151,7 +151,7 @@ const InternalTable = <RecordType extends AnyObject = AnyObject>(
scroll,
sortDirections,
locale,
showSorterTooltip = true,
showSorterTooltip = { target: 'full-header' },
virtual,
} = props;

View File

@ -1,5 +1,6 @@
/* eslint-disable react/no-multi-comp */
import React from 'react';
import type { ColumnType, TableProps } from '..';
import Table from '..';
import { act, fireEvent, render } from '../../../tests/utils';
@ -283,7 +284,7 @@ describe('Table.sorter', () => {
// set table props showSorterTooltip is false, column showSorterTooltip is true
rerender(
createTable({ showSorterTooltip: true, columns: [{ ...column, showSorterTooltip: true }] }),
createTable({ showSorterTooltip: false, columns: [{ ...column, showSorterTooltip: true }] }),
);
fireEvent.mouseEnter(container.querySelector('.ant-table-column-sorters')!);
act(() => {
@ -305,6 +306,96 @@ describe('Table.sorter', () => {
});
expect(container.querySelector('.ant-tooltip-open')).toBeFalsy();
fireEvent.mouseOut(container.querySelector('.ant-table-column-sorters')!);
// table props showSorterTooltip is 'full-header' by default
rerender(
createTable({
showSorterTooltip: true,
columns: [{ ...column }],
}),
);
expect(container.querySelector('.ant-table-column-sorters')).not.toHaveClass(
'ant-table-column-sorters-tooltip-target-sorter',
);
fireEvent.mouseEnter(container.querySelector('.ant-table-column-sorters')!);
act(() => {
jest.runAllTimers();
});
expect(container.querySelector('.ant-tooltip-open')).toBeTruthy();
fireEvent.mouseOut(container.querySelector('.ant-table-column-sorters')!);
// set table props showSorterTooltip target is 'sorter-icon'
rerender(
createTable({
showSorterTooltip: { target: 'sorter-icon' },
columns: [{ ...column }],
}),
);
expect(container.querySelector('.ant-table-column-sorters')).toHaveClass(
'ant-table-column-sorters-tooltip-target-sorter',
);
// hovering over the sorters element does NOT open tooltip
fireEvent.mouseEnter(container.querySelector('.ant-table-column-sorters')!);
act(() => {
jest.runAllTimers();
});
expect(container.querySelector('.ant-tooltip-open')).toBeFalsy();
fireEvent.mouseOut(container.querySelector('.ant-table-column-sorters')!);
// hovering over the sorter element DOES open tooltip
fireEvent.mouseEnter(container.querySelector('.ant-table-column-sorter')!);
act(() => {
jest.runAllTimers();
});
expect(container.querySelector('.ant-tooltip-open')).toBeTruthy();
fireEvent.mouseOut(container.querySelector('.ant-table-column-sorter')!);
// set table props showSorterTooltip target is 'sorter-icon', column showSorterTooltip target is 'full-header'
rerender(
createTable({
showSorterTooltip: { target: 'sorter-icon' },
columns: [{ ...column, showSorterTooltip: { target: 'full-header' } }],
}),
);
expect(container.querySelector('.ant-table-column-sorters')).not.toHaveClass(
'ant-table-column-sorters-tooltip-target-sorter',
);
fireEvent.mouseEnter(container.querySelector('.ant-table-column-sorters')!);
act(() => {
jest.runAllTimers();
});
expect(container.querySelector('.ant-tooltip-open')).toBeTruthy();
fireEvent.mouseOut(container.querySelector('.ant-table-column-sorters')!);
// set table props showSorterTooltip target is 'full-header', column showSorterTooltip target is 'sorter-icon'
rerender(
createTable({
showSorterTooltip: { target: 'full-header' },
columns: [{ ...column, showSorterTooltip: { target: 'sorter-icon' } }],
}),
);
expect(container.querySelector('.ant-table-column-sorters')).toHaveClass(
'ant-table-column-sorters-tooltip-target-sorter',
);
// hovering over the sorters element does NOT open tooltip
fireEvent.mouseEnter(container.querySelector('.ant-table-column-sorters')!);
act(() => {
jest.runAllTimers();
});
expect(container.querySelector('.ant-tooltip-open')).toBeFalsy();
fireEvent.mouseOut(container.querySelector('.ant-table-column-sorters')!);
// hovering over the title element does NOT open tooltip
fireEvent.mouseEnter(container.querySelector('.ant-table-column-title')!);
act(() => {
jest.runAllTimers();
});
expect(container.querySelector('.ant-tooltip-open')).toBeFalsy();
// hovering over the sorter element DOES open tooltip
fireEvent.mouseEnter(container.querySelector('.ant-table-column-sorter')!);
act(() => {
jest.runAllTimers();
});
expect(container.querySelector('.ant-tooltip-open')).toBeTruthy();
fireEvent.mouseOut(container.querySelector('.ant-table-column-sorter')!);
});
it('should show correct tooltip when showSorterTooltip is an object', () => {

View File

@ -473,4 +473,28 @@ describe('Table', () => {
container.querySelectorAll('.ant-table-thead tr')[1].querySelectorAll('th'),
).toHaveLength(1);
});
it('support disable row hover', () => {
const { container } = render(
<Table
columns={[
{
title: 'Name',
key: 'name',
dataIndex: 'name',
},
]}
dataSource={[
{
name: 'name1',
},
]}
rowHoverable={false}
/>,
);
const cell = container.querySelector('.ant-table-row .ant-table-cell')!;
fireEvent.mouseEnter(cell);
expect(container.querySelectorAll('.ant-table-cell-row-hover')).toHaveLength(0);
});
});

View File

@ -16446,7 +16446,7 @@ exports[`renders components/table/demo/head.tsx extend context correctly 1`] = `
tabindex="0"
>
<div
class="ant-table-column-sorters"
class="ant-table-column-sorters ant-table-column-sorters-tooltip-target-sorter"
>
<span
class="ant-table-column-title"
@ -16500,23 +16500,23 @@ exports[`renders components/table/demo/head.tsx extend context correctly 1`] = `
</span>
</span>
</span>
</div>
<div
class="ant-tooltip ant-zoom-big-fast-appear ant-zoom-big-fast-appear-prepare ant-zoom-big-fast ant-tooltip-placement-top"
style="--arrow-x: 0px; --arrow-y: 0px; left: -1000vw; top: -1000vh; box-sizing: border-box;"
>
<div
class="ant-tooltip-arrow"
style="position: absolute; bottom: 0px; left: 0px;"
/>
<div
class="ant-tooltip-content"
class="ant-tooltip ant-zoom-big-fast-appear ant-zoom-big-fast-appear-prepare ant-zoom-big-fast ant-tooltip-placement-top"
style="--arrow-x: 0px; --arrow-y: 0px; left: -1000vw; top: -1000vh; box-sizing: border-box;"
>
<div
class="ant-tooltip-inner"
role="tooltip"
class="ant-tooltip-arrow"
style="position: absolute; bottom: 0px; left: 0px;"
/>
<div
class="ant-tooltip-content"
>
Click to cancel sorting
<div
class="ant-tooltip-inner"
role="tooltip"
>
Click to cancel sorting
</div>
</div>
</div>
</div>

View File

@ -13204,7 +13204,7 @@ exports[`renders components/table/demo/head.tsx correctly 1`] = `
tabindex="0"
>
<div
class="ant-table-column-sorters"
class="ant-table-column-sorters ant-table-column-sorters-tooltip-target-sorter"
>
<span
class="ant-table-column-title"

View File

@ -13,6 +13,7 @@ const columns: TableColumnsType<DataType> = [
{
title: 'Name',
dataIndex: 'name',
showSorterTooltip: { target: 'full-header' },
filters: [
{
text: 'Joe',
@ -97,6 +98,13 @@ const onChange: TableProps<DataType>['onChange'] = (pagination, filters, sorter,
console.log('params', pagination, filters, sorter, extra);
};
const App: React.FC = () => <Table columns={columns} dataSource={data} onChange={onChange} />;
const App: React.FC = () => (
<Table
columns={columns}
dataSource={data}
onChange={onChange}
showSorterTooltip={{ target: 'sorter-icon' }}
/>
);
export default App;

View File

@ -14,6 +14,7 @@ import type {
CompareFn,
Key,
SorterResult,
SorterTooltipProps,
SortOrder,
TableLocale,
TransformColumns,
@ -111,7 +112,7 @@ function injectSorter<RecordType>(
triggerSorter: (sorterSates: SortState<RecordType>) => void,
defaultSortDirections: SortOrder[],
tableLocale?: TableLocale,
tableShowSorterTooltip?: boolean | TooltipProps,
tableShowSorterTooltip?: boolean | SorterTooltipProps,
pos?: string,
): ColumnsType<RecordType> {
return (columns || []).map((column, index) => {
@ -124,6 +125,7 @@ function injectSorter<RecordType>(
newColumn.showSorterTooltip === undefined
? tableShowSorterTooltip
: newColumn.showSorterTooltip;
const columnKey = getColumnKey(newColumn, columnPos);
const sorterState = sorterStates.find(({ key }) => key === columnKey);
const sortOrder = sorterState ? sorterState.sortOrder : null;
@ -179,19 +181,35 @@ function injectSorter<RecordType>(
...newColumn,
className: classNames(newColumn.className, { [`${prefixCls}-column-sort`]: sortOrder }),
title: (renderProps: ColumnTitleProps<RecordType>) => {
const columnSortersClass = `${prefixCls}-column-sorters`;
const renderColumnTitleWrapper = (
<span className={`${prefixCls}-column-title`}>
{renderColumnTitle(column.title, renderProps)}
</span>
);
const renderSortTitle = (
<div className={`${prefixCls}-column-sorters`}>
<span className={`${prefixCls}-column-title`}>
{renderColumnTitle(column.title, renderProps)}
</span>
<div className={columnSortersClass}>
{renderColumnTitleWrapper}
{sorter}
</div>
);
return showSorterTooltip ? (
<Tooltip {...tooltipProps}>{renderSortTitle}</Tooltip>
) : (
renderSortTitle
);
if (showSorterTooltip) {
if (
typeof showSorterTooltip !== 'boolean' &&
showSorterTooltip?.target === 'sorter-icon'
) {
return (
<div
className={`${columnSortersClass} ${prefixCls}-column-sorters-tooltip-target-sorter`}
>
{renderColumnTitleWrapper}
<Tooltip {...tooltipProps}>{sorter}</Tooltip>
</div>
);
}
return <Tooltip {...tooltipProps}>{renderSortTitle}</Tooltip>;
}
return renderSortTitle;
},
onHeaderCell: (col) => {
const cell: React.HTMLAttributes<HTMLElement> =
@ -357,7 +375,7 @@ interface SorterConfig<RecordType> {
) => void;
sortDirections: SortOrder[];
tableLocale?: TableLocale;
showSorterTooltip?: boolean | TooltipProps;
showSorterTooltip?: boolean | SorterTooltipProps;
}
export default function useFilterSorter<RecordType>({

View File

@ -129,19 +129,21 @@ Common props ref[Common props](/docs/react/common-props)
| rowClassName | Row's className | function(record, index): string | - | |
| rowKey | Row's unique key, could be a string or function that returns a string | string \| function(record): string | `key` | |
| rowSelection | Row selection [config](#rowselection) | object | - | |
| rowHoverable | Row hover | boolean | true | 5.16.0 |
| scroll | Whether the table can be scrollable, [config](#scroll) | object | - | |
| showHeader | Whether to show table header | boolean | true | |
| showSorterTooltip | The header show next sorter direction tooltip. It will be set as the property of Tooltip if its type is object | boolean \| [Tooltip props](/components/tooltip/#api) | true | |
| showSorterTooltip | The header show next sorter direction tooltip. It will be set as the property of Tooltip if its type is object | boolean \| [Tooltip props](/components/tooltip/#api) & `{target?: 'full-header' \| 'sorter-icon' }` | { target: 'full-header' } | 5.16.0 |
| size | Size of table | `large` \| `middle` \| `small` | `large` | |
| sortDirections | Supported sort way, could be `ascend`, `descend` | Array | \[`ascend`, `descend`] | |
| sticky | Set sticky header and scroll bar | boolean \| `{offsetHeader?: number, offsetScroll?: number, getContainer?: () => HTMLElement}` | - | 4.6.0 (getContainer: 4.7.0) |
| summary | Summary content | (currentData) => ReactNode | - | |
| tableLayout | The [table-layout](https://developer.mozilla.org/en-US/docs/Web/CSS/table-layout) attribute of table element | - \| `auto` \| `fixed` | -<hr />`fixed` when header/columns are fixed, or using `column.ellipsis` | |
| title | Table title renderer | function(currentPageData) | - | |
| virtual | Support virtual list | boolean | - | 5.9.0 |
| onChange | Callback executed when pagination, filters or sorter is changed | function(pagination, filters, sorter, extra: { currentDataSource: \[], action: `paginate` \| `sort` \| `filter` }) | - | |
| onHeaderRow | Set props on per header row | function(columns, index) | - | |
| onRow | Set props on per row | function(record, index) | - | |
| virtual | Support virtual list | boolean | - | 5.9.0 |
| onScroll | Triggered when the table body is scrolled. Note that only vertical scrolling will trigger the event when `virtual` | function(event) | - | 5.16.0 |
### Table ref
@ -203,7 +205,7 @@ One of the Table `columns` prop for describing the table's columns, Column has t
| responsive | The list of breakpoints at which to display this column. Always visible if not set | [Breakpoint](https://github.com/ant-design/ant-design/blob/015109b42b85c63146371b4e32b883cf97b088e8/components/_util/responsiveObserve.ts#L1)\[] | - | 4.2.0 |
| rowScope | Set scope attribute for all cells in this column | `row` \| `rowgroup` | - | 5.1.0 |
| shouldCellUpdate | Control cell render logic | (record, prevRecord) => boolean | - | 4.3.0 |
| showSorterTooltip | If header show next sorter direction tooltip, override `showSorterTooltip` in table | boolean \| [Tooltip props](/components/tooltip/) | true | |
| showSorterTooltip | If header show next sorter direction tooltip, override `showSorterTooltip` in table | boolean \| [Tooltip props](/components/tooltip/) & `{target?: 'full-header' \| 'sorter-icon' }` | { target: 'full-header' } | 5.16.0 |
| sortDirections | Supported sort way, override `sortDirections` in `Table`, could be `ascend`, `descend` | Array | \[`ascend`, `descend`] | |
| sorter | Sort function for local sort, see [Array.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)'s compareFunction. If it is server-side sorting, set to `true`, but if you want to support multi-column sorting, you can set it to `{ multiple: number }` | function \| boolean \| { compare: function, multiple: number } | - | |
| sortOrder | Order of sorted values: `ascend` `descend` `null` | `ascend` \| `descend` \| null | - | |

View File

@ -130,19 +130,21 @@ const columns = [
| rowClassName | 表格行的类名 | function(record, index): string | - | |
| rowKey | 表格行 key 的取值,可以是字符串或一个函数 | string \| function(record): string | `key` | |
| rowSelection | 表格行是否可选择,[配置项](#rowselection) | object | - | |
| rowHoverable | 表格行是否开启 hover 交互 | boolean | true | 5.16.0 |
| scroll | 表格是否可滚动,也可以指定滚动区域的宽、高,[配置项](#scroll) | object | - | |
| showHeader | 是否显示表头 | boolean | true | |
| showSorterTooltip | 表头是否显示下一次排序的 tooltip 提示。当参数类型为对象时,将被设置为 Tooltip 的属性 | boolean \| [Tooltip props](/components/tooltip-cn) | true | |
| showSorterTooltip | 表头是否显示下一次排序的 tooltip 提示。当参数类型为对象时,将被设置为 Tooltip 的属性 | boolean \| [Tooltip props](/components/tooltip-cn) & `{target?: 'full-header' \| 'sorter-icon' }` | { target: 'full-header' } | 5.16.0 |
| size | 表格大小 | `large` \| `middle` \| `small` | `large` | |
| sortDirections | 支持的排序方式,取值为 `ascend` `descend` | Array | \[`ascend`, `descend`] | |
| sticky | 设置粘性头部和滚动条 | boolean \| `{offsetHeader?: number, offsetScroll?: number, getContainer?: () => HTMLElement}` | - | 4.6.0 (getContainer: 4.7.0) |
| summary | 总结栏 | (currentData) => ReactNode | - | |
| tableLayout | 表格元素的 [table-layout](https://developer.mozilla.org/zh-CN/docs/Web/CSS/table-layout) 属性,设为 `fixed` 表示内容不会影响列的布局 | - \| `auto` \| `fixed` | 无<hr />固定表头/列或使用了 `column.ellipsis` 时,默认值为 `fixed` | |
| title | 表格标题 | function(currentPageData) | - | |
| virtual | 支持虚拟列表 | boolean | - | 5.9.0 |
| onChange | 分页、排序、筛选变化时触发 | function(pagination, filters, sorter, extra: { currentDataSource: \[], action: `paginate` \| `sort` \| `filter` }) | - | |
| onHeaderRow | 设置头部行属性 | function(columns, index) | - | |
| onRow | 设置行属性 | function(record, index) | - | |
| virtual | 支持虚拟列表 | boolean | - | 5.9.0 |
| onScroll | 表单内容滚动时触发(虚拟滚动下只有垂直滚动会触发事件) | function(event) | - | 5.16.0 |
### Table ref
@ -204,7 +206,7 @@ const columns = [
| responsive | 响应式 breakpoint 配置列表。未设置则始终可见。 | [Breakpoint](https://github.com/ant-design/ant-design/blob/015109b42b85c63146371b4e32b883cf97b088e8/components/_util/responsiveObserve.ts#L1)\[] | - | 4.2.0 |
| rowScope | 设置列范围 | `row` \| `rowgroup` | - | 5.1.0 |
| shouldCellUpdate | 自定义单元格渲染时机 | (record, prevRecord) => boolean | - | 4.3.0 |
| showSorterTooltip | 表头显示下一次排序的 tooltip 提示, 覆盖 table 中 `showSorterTooltip` | boolean \| [Tooltip props](/components/tooltip-cn/#api) | true | |
| showSorterTooltip | 表头显示下一次排序的 tooltip 提示, 覆盖 table 中 `showSorterTooltip` | boolean \| [Tooltip props](/components/tooltip-cn/#api) & `{target?: 'full-header' \| 'sorter-icon' }` | { target: 'full-header' } | 5.16.0 |
| sortDirections | 支持的排序方式,覆盖 `Table``sortDirections` 取值为 `ascend` `descend` | Array | \[`ascend`, `descend`] | |
| sorter | 排序函数,本地排序使用一个函数(参考 [Array.sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) 的 compareFunction)。需要服务端排序可设为 `true`(单列排序) 或 `{ multiple: number }`(多列排序) | function \| boolean \| { compare: function, multiple: number } | - | |
| sortOrder | 排序的受控属性,外界可用此控制列的排序,可设置为 `ascend` `descend` `null` | `ascend` \| `descend` \| null | - | |

View File

@ -58,6 +58,12 @@ export interface TableLocale {
export type SortOrder = 'descend' | 'ascend' | null;
export type SorterTooltipTarget = 'full-header' | 'sorter-icon';
export type SorterTooltipProps = TooltipProps & {
target?: SorterTooltipTarget;
};
const TableActions = ['paginate', 'sort', 'filter'] as const;
export type TableAction = (typeof TableActions)[number];
@ -123,7 +129,7 @@ export interface ColumnType<RecordType> extends Omit<RcColumnType<RecordType>, '
defaultSortOrder?: SortOrder;
sortDirections?: SortOrder[];
sortIcon?: (props: { sortOrder: SortOrder }) => React.ReactNode;
showSorterTooltip?: boolean | TooltipProps;
showSorterTooltip?: boolean | SorterTooltipProps;
// Filter
filtered?: boolean;

View File

@ -1,4 +1,5 @@
import type { CSSObject } from '@ant-design/cssinjs';
import type { GenerateStyle } from '../../theme/internal';
import type { TableToken } from './index';
@ -65,6 +66,12 @@ const genSorterStyle: GenerateStyle<TableToken, CSSObject> = (token) => {
},
},
[`${componentCls}-column-sorters-tooltip-target-sorter`]: {
'&::after': {
content: 'none',
},
},
[`${componentCls}-column-sorter`]: {
marginInlineStart: marginXXS,
color: headerIconColor,

View File

@ -178,28 +178,25 @@ Array [
>
Tag 2
<span
class="ant-tag-close-icon"
aria-label="close-circle"
class="anticon anticon-close-circle ant-tag-close-icon"
role="img"
tabindex="-1"
>
<span
aria-label="close-circle"
class="anticon anticon-close-circle"
role="img"
<svg
aria-hidden="true"
data-icon="close-circle"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<svg
aria-hidden="true"
data-icon="close-circle"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm0 76c-205.4 0-372 166.6-372 372s166.6 372 372 372 372-166.6 372-372-166.6-372-372-372zm128.01 198.83c.03 0 .05.01.09.06l45.02 45.01a.2.2 0 01.05.09.12.12 0 010 .07c0 .02-.01.04-.05.08L557.25 512l127.87 127.86a.27.27 0 01.05.06v.02a.12.12 0 010 .07c0 .03-.01.05-.05.09l-45.02 45.02a.2.2 0 01-.09.05.12.12 0 01-.07 0c-.02 0-.04-.01-.08-.05L512 557.25 384.14 685.12c-.04.04-.06.05-.08.05a.12.12 0 01-.07 0c-.03 0-.05-.01-.09-.05l-45.02-45.02a.2.2 0 01-.05-.09.12.12 0 010-.07c0-.02.01-.04.06-.08L466.75 512 338.88 384.14a.27.27 0 01-.05-.06l-.01-.02a.12.12 0 010-.07c0-.03.01-.05.05-.09l45.02-45.02a.2.2 0 01.09-.05.12.12 0 01.07 0c.02 0 .04.01.08.06L512 466.75l127.86-127.86c.04-.05.06-.06.08-.06a.12.12 0 01.07 0z"
/>
</svg>
</span>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm0 76c-205.4 0-372 166.6-372 372s166.6 372 372 372 372-166.6 372-372-166.6-372-372-372zm128.01 198.83c.03 0 .05.01.09.06l45.02 45.01a.2.2 0 01.05.09.12.12 0 010 .07c0 .02-.01.04-.05.08L557.25 512l127.87 127.86a.27.27 0 01.05.06v.02a.12.12 0 010 .07c0 .03-.01.05-.05.09l-45.02 45.02a.2.2 0 01-.09.05.12.12 0 01-.07 0c-.02 0-.04-.01-.08-.05L512 557.25 384.14 685.12c-.04.04-.06.05-.08.05a.12.12 0 01-.07 0c-.03 0-.05-.01-.09-.05l-45.02-45.02a.2.2 0 01-.05-.09.12.12 0 010-.07c0-.02.01-.04.06-.08L466.75 512 338.88 384.14a.27.27 0 01-.05-.06l-.01-.02a.12.12 0 010-.07c0-.03.01-.05.05-.09l45.02-45.02a.2.2 0 01.09-.05.12.12 0 01.07 0c.02 0 .04.01.08.06L512 466.75l127.86-127.86c.04-.05.06-.06.08-.06a.12.12 0 01.07 0z"
/>
</svg>
</span>
</span>,
]
@ -845,28 +842,25 @@ exports[`renders components/tag/demo/customize.tsx extend context correctly 1`]
>
Tag2
<span
class="ant-tag-close-icon"
aria-label="close-circle"
class="anticon anticon-close-circle ant-tag-close-icon"
role="img"
tabindex="-1"
>
<span
aria-label="close-circle"
class="anticon anticon-close-circle"
role="img"
<svg
aria-hidden="true"
data-icon="close-circle"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<svg
aria-hidden="true"
data-icon="close-circle"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm0 76c-205.4 0-372 166.6-372 372s166.6 372 372 372 372-166.6 372-372-166.6-372-372-372zm128.01 198.83c.03 0 .05.01.09.06l45.02 45.01a.2.2 0 01.05.09.12.12 0 010 .07c0 .02-.01.04-.05.08L557.25 512l127.87 127.86a.27.27 0 01.05.06v.02a.12.12 0 010 .07c0 .03-.01.05-.05.09l-45.02 45.02a.2.2 0 01-.09.05.12.12 0 01-.07 0c-.02 0-.04-.01-.08-.05L512 557.25 384.14 685.12c-.04.04-.06.05-.08.05a.12.12 0 01-.07 0c-.03 0-.05-.01-.09-.05l-45.02-45.02a.2.2 0 01-.05-.09.12.12 0 010-.07c0-.02.01-.04.06-.08L466.75 512 338.88 384.14a.27.27 0 01-.05-.06l-.01-.02a.12.12 0 010-.07c0-.03.01-.05.05-.09l45.02-45.02a.2.2 0 01.09-.05.12.12 0 01.07 0c.02 0 .04.01.08.06L512 466.75l127.86-127.86c.04-.05.06-.06.08-.06a.12.12 0 01.07 0z"
/>
</svg>
</span>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm0 76c-205.4 0-372 166.6-372 372s166.6 372 372 372 372-166.6 372-372-166.6-372-372-372zm128.01 198.83c.03 0 .05.01.09.06l45.02 45.01a.2.2 0 01.05.09.12.12 0 010 .07c0 .02-.01.04-.05.08L557.25 512l127.87 127.86a.27.27 0 01.05.06v.02a.12.12 0 010 .07c0 .03-.01.05-.05.09l-45.02 45.02a.2.2 0 01-.09.05.12.12 0 01-.07 0c-.02 0-.04-.01-.08-.05L512 557.25 384.14 685.12c-.04.04-.06.05-.08.05a.12.12 0 01-.07 0c-.03 0-.05-.01-.09-.05l-45.02-45.02a.2.2 0 01-.05-.09.12.12 0 010-.07c0-.02.01-.04.06-.08L466.75 512 338.88 384.14a.27.27 0 01-.05-.06l-.01-.02a.12.12 0 010-.07c0-.03.01-.05.05-.09l45.02-45.02a.2.2 0 01.09-.05.12.12 0 01.07 0c.02 0 .04.01.08.06L512 466.75l127.86-127.86c.04-.05.06-.06.08-.06a.12.12 0 01.07 0z"
/>
</svg>
</span>
</span>
</div>

View File

@ -176,28 +176,25 @@ Array [
>
Tag 2
<span
class="ant-tag-close-icon"
aria-label="close-circle"
class="anticon anticon-close-circle ant-tag-close-icon"
role="img"
tabindex="-1"
>
<span
aria-label="close-circle"
class="anticon anticon-close-circle"
role="img"
<svg
aria-hidden="true"
data-icon="close-circle"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<svg
aria-hidden="true"
data-icon="close-circle"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm0 76c-205.4 0-372 166.6-372 372s166.6 372 372 372 372-166.6 372-372-166.6-372-372-372zm128.01 198.83c.03 0 .05.01.09.06l45.02 45.01a.2.2 0 01.05.09.12.12 0 010 .07c0 .02-.01.04-.05.08L557.25 512l127.87 127.86a.27.27 0 01.05.06v.02a.12.12 0 010 .07c0 .03-.01.05-.05.09l-45.02 45.02a.2.2 0 01-.09.05.12.12 0 01-.07 0c-.02 0-.04-.01-.08-.05L512 557.25 384.14 685.12c-.04.04-.06.05-.08.05a.12.12 0 01-.07 0c-.03 0-.05-.01-.09-.05l-45.02-45.02a.2.2 0 01-.05-.09.12.12 0 010-.07c0-.02.01-.04.06-.08L466.75 512 338.88 384.14a.27.27 0 01-.05-.06l-.01-.02a.12.12 0 010-.07c0-.03.01-.05.05-.09l45.02-45.02a.2.2 0 01.09-.05.12.12 0 01.07 0c.02 0 .04.01.08.06L512 466.75l127.86-127.86c.04-.05.06-.06.08-.06a.12.12 0 01.07 0z"
/>
</svg>
</span>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm0 76c-205.4 0-372 166.6-372 372s166.6 372 372 372 372-166.6 372-372-166.6-372-372-372zm128.01 198.83c.03 0 .05.01.09.06l45.02 45.01a.2.2 0 01.05.09.12.12 0 010 .07c0 .02-.01.04-.05.08L557.25 512l127.87 127.86a.27.27 0 01.05.06v.02a.12.12 0 010 .07c0 .03-.01.05-.05.09l-45.02 45.02a.2.2 0 01-.09.05.12.12 0 01-.07 0c-.02 0-.04-.01-.08-.05L512 557.25 384.14 685.12c-.04.04-.06.05-.08.05a.12.12 0 01-.07 0c-.03 0-.05-.01-.09-.05l-45.02-45.02a.2.2 0 01-.05-.09.12.12 0 010-.07c0-.02.01-.04.06-.08L466.75 512 338.88 384.14a.27.27 0 01-.05-.06l-.01-.02a.12.12 0 010-.07c0-.03.01-.05.05-.09l45.02-45.02a.2.2 0 01.09-.05.12.12 0 01.07 0c.02 0 .04.01.08.06L512 466.75l127.86-127.86c.04-.05.06-.06.08-.06a.12.12 0 01.07 0z"
/>
</svg>
</span>
</span>,
]
@ -829,28 +826,25 @@ exports[`renders components/tag/demo/customize.tsx correctly 1`] = `
>
Tag2
<span
class="ant-tag-close-icon"
aria-label="close-circle"
class="anticon anticon-close-circle ant-tag-close-icon"
role="img"
tabindex="-1"
>
<span
aria-label="close-circle"
class="anticon anticon-close-circle"
role="img"
<svg
aria-hidden="true"
data-icon="close-circle"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<svg
aria-hidden="true"
data-icon="close-circle"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm0 76c-205.4 0-372 166.6-372 372s166.6 372 372 372 372-166.6 372-372-166.6-372-372-372zm128.01 198.83c.03 0 .05.01.09.06l45.02 45.01a.2.2 0 01.05.09.12.12 0 010 .07c0 .02-.01.04-.05.08L557.25 512l127.87 127.86a.27.27 0 01.05.06v.02a.12.12 0 010 .07c0 .03-.01.05-.05.09l-45.02 45.02a.2.2 0 01-.09.05.12.12 0 01-.07 0c-.02 0-.04-.01-.08-.05L512 557.25 384.14 685.12c-.04.04-.06.05-.08.05a.12.12 0 01-.07 0c-.03 0-.05-.01-.09-.05l-45.02-45.02a.2.2 0 01-.05-.09.12.12 0 010-.07c0-.02.01-.04.06-.08L466.75 512 338.88 384.14a.27.27 0 01-.05-.06l-.01-.02a.12.12 0 010-.07c0-.03.01-.05.05-.09l45.02-45.02a.2.2 0 01.09-.05.12.12 0 01.07 0c.02 0 .04.01.08.06L512 466.75l127.86-127.86c.04-.05.06-.06.08-.06a.12.12 0 01.07 0z"
/>
</svg>
</span>
<path
d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm0 76c-205.4 0-372 166.6-372 372s166.6 372 372 372 372-166.6 372-372-166.6-372-372-372zm128.01 198.83c.03 0 .05.01.09.06l45.02 45.01a.2.2 0 01.05.09.12.12 0 010 .07c0 .02-.01.04-.05.08L557.25 512l127.87 127.86a.27.27 0 01.05.06v.02a.12.12 0 010 .07c0 .03-.01.05-.05.09l-45.02 45.02a.2.2 0 01-.09.05.12.12 0 01-.07 0c-.02 0-.04-.01-.08-.05L512 557.25 384.14 685.12c-.04.04-.06.05-.08.05a.12.12 0 01-.07 0c-.03 0-.05-.01-.09-.05l-45.02-45.02a.2.2 0 01-.05-.09.12.12 0 010-.07c0-.02.01-.04.06-.08L466.75 512 338.88 384.14a.27.27 0 01-.05-.06l-.01-.02a.12.12 0 010-.07c0-.03.01-.05.05-.09l45.02-45.02a.2.2 0 01.09-.05.12.12 0 01.07 0c.02 0 .04.01.08.06L512 466.75l127.86-127.86c.04-.05.06-.06.08-.06a.12.12 0 01.07 0z"
/>
</svg>
</span>
</span>
</div>

View File

@ -1,10 +1,9 @@
import React from 'react';
import { CheckCircleOutlined } from '@ant-design/icons';
import { Simulate } from 'react-dom/test-utils';
import { CheckCircleOutlined } from '@ant-design/icons';
import Tag from '..';
import { resetWarned } from '../../_util/warning';
import mountTest from '../../../tests/shared/mountTest';
import rtlTest from '../../../tests/shared/rtlTest';
import { act, fireEvent, render } from '../../../tests/utils';
@ -219,4 +218,11 @@ describe('Tag', () => {
waitRaf();
expect(document.querySelector('.ant-wave')).toBeFalsy();
});
it('should support aria-* in closable', () => {
const { container } = render(<Tag closable={{ closeIcon: 'X', 'aria-label': 'CloseBtn' }} />);
expect(container.querySelector('.ant-tag-close-icon')?.getAttribute('aria-label')).toEqual(
'CloseBtn',
);
expect(container.querySelector('.ant-tag-close-icon')?.textContent).toEqual('X');
});
});

View File

@ -1,10 +1,11 @@
import * as React from 'react';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import classNames from 'classnames';
import omit from 'rc-util/lib/omit';
import type { PresetColorType, PresetStatusColorType } from '../_util/colors';
import { isPresetColor, isPresetStatusColor } from '../_util/colors';
import useClosable from '../_util/hooks/useClosable';
import useClosable, { pickClosable } from '../_util/hooks/useClosable';
import { replaceElement } from '../_util/reactNode';
import type { LiteralUnion } from '../_util/type';
import { devUseWarning } from '../_util/warning';
import Wave from '../_util/wave';
@ -21,7 +22,7 @@ export interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
className?: string;
rootClassName?: string;
color?: LiteralUnion<PresetColorType | PresetStatusColorType>;
closable?: boolean;
closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes);
/** Advised to use closeIcon instead. */
closeIcon?: React.ReactNode;
/** @deprecated `visible` will be removed in next major version. */
@ -47,26 +48,27 @@ const InternalTag: React.ForwardRefRenderFunction<HTMLSpanElement, TagProps> = (
icon,
color,
onClose,
closeIcon,
closable,
bordered = true,
visible: deprecatedVisible,
...props
} = tagProps;
const { getPrefixCls, direction, tag } = React.useContext(ConfigContext);
const { getPrefixCls, direction, tag: tagContext } = React.useContext(ConfigContext);
const [visible, setVisible] = React.useState(true);
const domProps = omit(props, ['closeIcon', 'closable']);
// Warning for deprecated usage
if (process.env.NODE_ENV !== 'production') {
const warning = devUseWarning('Tag');
warning.deprecated(!('visible' in props), 'visible', 'visible && <Tag />');
warning.deprecated(!('visible' in tagProps), 'visible', 'visible && <Tag />');
}
React.useEffect(() => {
if ('visible' in props) {
setVisible(props.visible!);
if (deprecatedVisible !== undefined) {
setVisible(deprecatedVisible!);
}
}, [props.visible]);
}, [deprecatedVisible]);
const isPreset = isPresetColor(color);
const isStatus = isPresetStatusColor(color);
@ -74,7 +76,7 @@ const InternalTag: React.ForwardRefRenderFunction<HTMLSpanElement, TagProps> = (
const tagStyle: React.CSSProperties = {
backgroundColor: color && !isInternalColor ? color : undefined,
...tag?.style,
...tagContext?.style,
...style,
};
@ -84,7 +86,7 @@ const InternalTag: React.ForwardRefRenderFunction<HTMLSpanElement, TagProps> = (
const tagClassName = classNames(
prefixCls,
tag?.className,
tagContext?.className,
{
[`${prefixCls}-${color}`]: isInternalColor,
[`${prefixCls}-has-color`]: color && !isInternalColor,
@ -108,19 +110,22 @@ const InternalTag: React.ForwardRefRenderFunction<HTMLSpanElement, TagProps> = (
setVisible(false);
};
const [, mergedCloseIcon] = useClosable({
closable,
closeIcon: closeIcon ?? tag?.closeIcon,
customCloseIconRender: (iconNode: React.ReactNode) =>
iconNode === null ? (
<CloseOutlined className={`${prefixCls}-close-icon`} onClick={handleCloseClick} />
) : (
const [, mergedCloseIcon] = useClosable(pickClosable(tagProps), pickClosable(tagContext), {
closable: false,
closeIconRender: (iconNode: React.ReactNode) => {
const replacement = (
<span className={`${prefixCls}-close-icon`} onClick={handleCloseClick}>
{iconNode}
</span>
),
defaultCloseIcon: null,
defaultClosable: false,
);
return replaceElement(iconNode, replacement, (originProps) => ({
onClick: (e: React.MouseEvent<HTMLElement>) => {
originProps?.onClick?.(e);
handleCloseClick(e);
},
className: classNames(originProps?.className, `${prefixCls}-close-icon`),
}));
},
});
const isNeedWave =
@ -139,7 +144,7 @@ const InternalTag: React.ForwardRefRenderFunction<HTMLSpanElement, TagProps> = (
);
const tagNode: React.ReactNode = (
<span {...props} ref={ref} className={tagClassName} style={tagStyle}>
<span {...domProps} ref={ref} className={tagClassName} style={tagStyle}>
{kids}
{mergedCloseIcon}
{isPreset && <PresetCmp key="preset" prefixCls={prefixCls} />}

View File

@ -0,0 +1,8 @@
import type { TimePickerLocale } from '../index';
const locale: TimePickerLocale = {
placeholder: 'Vaqtni tanlang',
rangePlaceholder: ['Boshlanish vaqti', 'Tugallanish vaqti'],
};
export default locale;

View File

@ -1,5 +1,4 @@
import * as React from 'react';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import classNames from 'classnames';
import useClosable from '../_util/hooks/useClosable';
@ -31,17 +30,14 @@ const PurePanel: React.FC<PurePanelProps> = (props) => {
const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls);
const [mergedClosable, mergedCloseIcon] = useClosable({
closable,
closeIcon,
customCloseIconRender: (icon) =>
const [mergedClosable, mergedCloseIcon] = useClosable({ closable, closeIcon }, null, {
closable: true,
closeIconRender: (icon) =>
React.isValidElement(icon)
? cloneElement(icon, {
className: classNames(icon.props.className, `${prefixCls}-close-icon`),
})
: icon,
defaultCloseIcon: <CloseOutlined />,
defaultClosable: true,
});
return wrapCSSVar(

View File

@ -4,19 +4,19 @@ import useMergedState from 'rc-util/lib/hooks/useMergedState';
import type { KeyWiseTransferItem } from '.';
import Pagination from '../pagination';
import type { PaginationType } from './interface';
import type { PaginationType, TransferKey } from './interface';
import type { RenderedItem, TransferListProps } from './list';
import ListItem from './ListItem';
export const OmitProps = ['handleFilter', 'handleClear', 'checkedKeys'] as const;
export type OmitProp = typeof OmitProps[number];
export type OmitProp = (typeof OmitProps)[number];
type PartialTransferListProps<RecordType> = Omit<TransferListProps<RecordType>, OmitProp>;
type ExistPagination = Exclude<PaginationType, boolean>;
export interface TransferListBodyProps<RecordType> extends PartialTransferListProps<RecordType> {
filteredItems: RecordType[];
filteredRenderItems: RenderedItem<RecordType>[];
selectedKeys: string[];
selectedKeys: TransferKey[];
}
const parsePagination = (pagination?: ExistPagination) => {

View File

@ -437,7 +437,9 @@ describe('Transfer', () => {
const renderFunc: TransferProps<any>['render'] = (item) => item.title;
const handleChange = jest.fn();
const TransferDemo = () => {
const [selectedKeys, setSelectedKeys] = useState<string[]>(searchTransferProps.selectedKeys);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>(
searchTransferProps.selectedKeys,
);
const handleSelectChange: TransferProps<any>['onSelectChange'] = (
sourceSelectedKeys,
targetSelectedKeys,
@ -600,7 +602,7 @@ describe('Transfer', () => {
const onSelectChange = jest.fn();
const Demo = () => {
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
return (
<Transfer

View File

@ -17,7 +17,7 @@ const mockData: RecordType[] = Array.from({ length: 20 }).map((_, i) => ({
const initialTargetKeys = mockData.filter((item) => Number(item.key) > 10).map((item) => item.key);
const App: React.FC = () => {
const [targetKeys, setTargetKeys] = useState(initialTargetKeys);
const [targetKeys, setTargetKeys] = useState<React.Key[]>(initialTargetKeys);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const onChange: TransferProps['onChange'] = (nextTargetKeys, direction, moveKeys) => {

View File

@ -114,8 +114,8 @@ const rightTableColumns: TableColumnsType<Pick<DataType, 'title'>> = [
const initialTargetKeys = mockData.filter((item) => Number(item.key) > 10).map((item) => item.key);
const App: React.FC = () => {
const [targetKeys, setTargetKeys] = useState(initialTargetKeys);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [targetKeys, setTargetKeys] = useState<React.Key[]>(initialTargetKeys);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const onChange: TransferProps['onChange'] = (nextTargetKeys, direction, moveKeys) => {
console.log('targetKeys:', nextTargetKeys);

View File

@ -11,7 +11,7 @@ interface RecordType {
const App: React.FC = () => {
const [mockData, setMockData] = useState<RecordType[]>([]);
const [targetKeys, setTargetKeys] = useState<string[]>([]);
const [targetKeys, setTargetKeys] = useState<React.Key[]>([]);
const getMock = () => {
const tempTargetKeys = [];

View File

@ -22,7 +22,7 @@ const selectAllLabels: TransferProps['selectAllLabels'] = [
];
const App: React.FC = () => {
const [targetKeys, setTargetKeys] = useState(oriTargetKeys);
const [targetKeys, setTargetKeys] = useState<React.Key[]>(oriTargetKeys);
return (
<Transfer
dataSource={mockData}

View File

@ -12,7 +12,7 @@ interface RecordType {
const App: React.FC = () => {
const [oneWay, setOneWay] = useState(false);
const [mockData, setMockData] = useState<RecordType[]>([]);
const [targetKeys, setTargetKeys] = useState<string[]>([]);
const [targetKeys, setTargetKeys] = useState<React.Key[]>([]);
useEffect(() => {
const newTargetKeys = [];

View File

@ -19,8 +19,8 @@ const mockData: RecordType[] = Array.from({ length: 20 }).map((_, i) => ({
const oriTargetKeys = mockData.filter((item) => Number(item.key) % 3 > 1).map((item) => item.key);
const App: React.FC = () => {
const [targetKeys, setTargetKeys] = useState<string[]>(oriTargetKeys);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [targetKeys, setTargetKeys] = useState<React.Key[]>(oriTargetKeys);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [disabled, setDisabled] = useState(false);
const handleChange: TransferProps['onChange'] = (newTargetKeys, direction, moveKeys) => {

View File

@ -3,11 +3,12 @@ import * as React from 'react';
import type { KeyWise, TransferProps } from '..';
import { groupKeysMap } from '../../_util/transKeys';
import type { AnyObject } from '../../_util/type';
import type { TransferKey } from '../interface';
const useData = <RecordType extends AnyObject>(
dataSource?: RecordType[],
rowKey?: TransferProps<RecordType>['rowKey'],
targetKeys?: string[],
targetKeys?: TransferKey[],
) => {
const mergedDataSource = React.useMemo(
() =>

View File

@ -1,25 +1,26 @@
import * as React from 'react';
import type { TransferKey } from '../interface';
const EMPTY_KEYS: string[] = [];
const EMPTY_KEYS: TransferKey[] = [];
function filterKeys(keys: string[], dataKeys: Set<string>) {
function filterKeys(keys: TransferKey[], dataKeys: Set<TransferKey>) {
const filteredKeys = keys.filter((key) => dataKeys.has(key));
return keys.length === filteredKeys.length ? keys : filteredKeys;
}
function flattenKeys(keys: Set<string>) {
function flattenKeys(keys: Set<TransferKey>) {
return Array.from(keys).join(';');
}
export default function useSelection<T extends { key: string }>(
export default function useSelection<T extends { key: TransferKey }>(
leftDataSource: T[],
rightDataSource: T[],
selectedKeys: string[] = EMPTY_KEYS,
selectedKeys: TransferKey[] = EMPTY_KEYS,
): [
sourceSelectedKeys: string[],
targetSelectedKeys: string[],
setSourceSelectedKeys: React.Dispatch<React.SetStateAction<string[]>>,
setTargetSelectedKeys: React.Dispatch<React.SetStateAction<string[]>>,
sourceSelectedKeys: TransferKey[],
targetSelectedKeys: TransferKey[],
setSourceSelectedKeys: React.Dispatch<React.SetStateAction<TransferKey[]>>,
setTargetSelectedKeys: React.Dispatch<React.SetStateAction<TransferKey[]>>,
] {
// Prepare `dataSource` keys
const [leftKeys, rightKeys] = React.useMemo(

View File

@ -52,11 +52,11 @@ Common props ref[Common props](/docs/react/common-props)
| pagination | Use pagination. Not work in render props | boolean \| { pageSize: number, simple: boolean, showSizeChanger?: boolean, showLessItems?: boolean } | false | 4.3.0 |
| render | The function to generate the item shown on a column. Based on an record (element of the dataSource array), this function should return a React element which is generated from that record. Also, it can return a plain object with `value` and `label`, `label` is a React element and `value` is for title | (record) => ReactNode | - | |
| selectAllLabels | A set of customized labels for select all checkboxes on the header | (ReactNode \| (info: { selectedCount: number, totalCount: number }) => ReactNode)\[] | - | |
| selectedKeys | A set of keys of selected items | string\[] | \[] | |
| selectedKeys | A set of keys of selected items | string\[] \| number\[] | \[] | |
| showSearch | If included, a search box is shown on each column | boolean | false | |
| showSelectAll | Show select all checkbox on the header | boolean | true | |
| status | Set validation status | 'error' \| 'warning' | - | 4.19.0 |
| targetKeys | A set of keys of elements that are listed on the right column | string\[] | \[] | |
| targetKeys | A set of keys of elements that are listed on the right column | string\[] \| number\[] | \[] | |
| titles | A set of titles that are sorted from left to right | ReactNode\[] | - | |
| onChange | A callback function that is executed when the transfer between columns is complete | (targetKeys, direction, moveKeys): void | - | |
| onScroll | A callback function which is executed when scroll options list | (direction, event): void | - | |
@ -67,14 +67,14 @@ Common props ref[Common props](/docs/react/common-props)
Transfer accept `children` to customize render list, using follow props:
| Property | Description | Type | Version |
| --------------- | ----------------------- | ------------------------------------ | ------- |
| direction | List render direction | `left` \| `right` | |
| disabled | Disable list or not | boolean | |
| filteredItems | Filtered items | RecordType\[] | |
| selectedKeys | Selected items | string\[] | |
| onItemSelect | Select item | (key: string, selected: boolean) | |
| onItemSelectAll | Select a group of items | (keys: string\[], selected: boolean) | |
| Property | Description | Type | Version |
| --- | --- | --- | --- |
| direction | List render direction | `left` \| `right` | |
| disabled | Disable list or not | boolean | |
| filteredItems | Filtered items | RecordType\[] | |
| selectedKeys | Selected items | string\[] \| number\[] | |
| onItemSelect | Select item | (key: string \| number, selected: boolean) | |
| onItemSelectAll | Select a group of items | (keys: string\[] \| number\[], selected: boolean) | |
#### example

View File

@ -17,7 +17,7 @@ import { useLocale } from '../locale';
import defaultLocale from '../locale/en_US';
import useData from './hooks/useData';
import useSelection from './hooks/useSelection';
import type { PaginationType } from './interface';
import type { PaginationType, TransferKey } from './interface';
import type { TransferCustomListBodyProps, TransferListProps } from './list';
import List from './list';
import Operation from './operation';
@ -38,14 +38,14 @@ export interface RenderResultObject {
export type RenderResult = React.ReactElement | RenderResultObject | string | null;
export interface TransferItem {
key?: string;
key?: TransferKey;
title?: string;
description?: string;
disabled?: boolean;
[name: string]: any;
}
export type KeyWise<T> = T & { key: string };
export type KeyWise<T> = T & { key: TransferKey };
export type KeyWiseTransferItem = KeyWise<TransferItem>;
@ -79,11 +79,15 @@ export interface TransferProps<RecordType = any> {
rootClassName?: string;
disabled?: boolean;
dataSource?: RecordType[];
targetKeys?: string[];
selectedKeys?: string[];
targetKeys?: TransferKey[];
selectedKeys?: TransferKey[];
render?: TransferRender<RecordType>;
onChange?: (targetKeys: string[], direction: TransferDirection, moveKeys: string[]) => void;
onSelectChange?: (sourceSelectedKeys: string[], targetSelectedKeys: string[]) => void;
onChange?: (
targetKeys: TransferKey[],
direction: TransferDirection,
moveKeys: TransferKey[],
) => void;
onSelectChange?: (sourceSelectedKeys: TransferKey[], targetSelectedKeys: TransferKey[]) => void;
style?: React.CSSProperties;
listStyle?: ((style: ListStyle) => CSSProperties) | CSSProperties;
operationStyle?: CSSProperties;
@ -96,7 +100,7 @@ export interface TransferProps<RecordType = any> {
props: TransferListProps<RecordType>,
info?: { direction: TransferDirection },
) => React.ReactNode;
rowKey?: (record: RecordType) => string;
rowKey?: (record: RecordType) => TransferKey;
onSearch?: (direction: TransferDirection, value: string) => void;
onScroll?: (direction: TransferDirection, e: React.SyntheticEvent<HTMLUListElement>) => void;
children?: (props: TransferCustomListBodyProps<RecordType>) => React.ReactNode;
@ -172,11 +176,11 @@ const Transfer = <RecordType extends TransferItem = TransferItem>(
const [leftMultipleSelect, updateLeftPrevSelectedIndex] = useMultipleSelect<
KeyWise<RecordType>,
string
TransferKey
>((item) => item.key);
const [rightMultipleSelect, updateRightPrevSelectedIndex] = useMultipleSelect<
KeyWise<RecordType>,
string
TransferKey
>((item) => item.key);
if (process.env.NODE_ENV !== 'production') {
@ -186,7 +190,10 @@ const Transfer = <RecordType extends TransferItem = TransferItem>(
}
const setStateKeys = useCallback(
(direction: TransferDirection, keys: string[] | ((prevKeys: string[]) => string[])) => {
(
direction: TransferDirection,
keys: TransferKey[] | ((prevKeys: TransferKey[]) => TransferKey[]),
) => {
if (direction === 'left') {
const nextKeys = typeof keys === 'function' ? keys(sourceSelectedKeys || []) : keys;
setSourceSelectedKeys(nextKeys);
@ -207,7 +214,7 @@ const Transfer = <RecordType extends TransferItem = TransferItem>(
};
const handleSelectChange = useCallback(
(direction: TransferDirection, holder: string[]) => {
(direction: TransferDirection, holder: TransferKey[]) => {
if (direction === 'left') {
onSelectChange?.(holder, targetSelectedKeys);
} else {
@ -263,12 +270,12 @@ const Transfer = <RecordType extends TransferItem = TransferItem>(
checkAll: boolean | 'replace',
) => {
setStateKeys(direction, (prevKeys) => {
let mergedCheckedKeys: string[] = [];
let mergedCheckedKeys: TransferKey[] = [];
if (checkAll === 'replace') {
mergedCheckedKeys = keys;
} else if (checkAll) {
// Merge current keys with origin key
mergedCheckedKeys = Array.from(new Set<string>([...prevKeys, ...keys]));
mergedCheckedKeys = Array.from(new Set<TransferKey>([...prevKeys, ...keys]));
} else {
const selectedKeysMap = groupKeysMap(keys);
// Remove current keys from origin keys
@ -298,8 +305,8 @@ const Transfer = <RecordType extends TransferItem = TransferItem>(
const handleSingleSelect = (
direction: TransferDirection,
holder: Set<string>,
selectedKey: string,
holder: Set<TransferKey>,
selectedKey: TransferKey,
checked: boolean,
currentSelectedIndex: number,
) => {
@ -317,7 +324,7 @@ const Transfer = <RecordType extends TransferItem = TransferItem>(
const handleMultipleSelect = (
direction: TransferDirection,
data: KeyWise<RecordType>[],
holder: Set<string>,
holder: Set<TransferKey>,
currentSelectedIndex: number,
) => {
const isLeftDirection = direction === 'left';
@ -327,7 +334,7 @@ const Transfer = <RecordType extends TransferItem = TransferItem>(
const onItemSelect = (
direction: TransferDirection,
selectedKey: string,
selectedKey: TransferKey,
checked: boolean,
multiple?: boolean,
) => {
@ -360,14 +367,14 @@ const Transfer = <RecordType extends TransferItem = TransferItem>(
};
const onRightItemSelect = (
selectedKey: string,
selectedKey: TransferKey,
checked: boolean,
e?: React.MouseEvent<Element, MouseEvent>,
) => {
onItemSelect('right', selectedKey, checked, e?.shiftKey);
};
const onRightItemRemove = (keys: string[]) => {
const onRightItemRemove = (keys: TransferKey[]) => {
setStateKeys('right', []);
onChange?.(
targetKeys.filter((key) => !keys.includes(key)),

View File

@ -55,11 +55,11 @@ coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*g9vUQq2nkpEAAA
| pagination | 使用分页样式,自定义渲染列表下无效 | boolean \| { pageSize: number, simple: boolean, showSizeChanger?: boolean, showLessItems?: boolean } | false | 4.3.0 |
| render | 每行数据渲染函数,该函数的入参为 `dataSource` 中的项,返回值为 ReactElement。或者返回一个普通对象其中 `label` 字段为 ReactElement`value` 字段为 title | (record) => ReactNode | - | |
| selectAllLabels | 自定义顶部多选框标题的集合 | (ReactNode \| (info: { selectedCount: number, totalCount: number }) => ReactNode)\[] | - | |
| selectedKeys | 设置哪些项应该被选中 | string\[] | \[] | |
| selectedKeys | 设置哪些项应该被选中 | string\[] \| number\[] | \[] | |
| showSearch | 是否显示搜索框 | boolean | false | |
| showSelectAll | 是否展示全选勾选框 | boolean | true | |
| status | 设置校验状态 | 'error' \| 'warning' | - | 4.19.0 |
| targetKeys | 显示在右侧框数据的 key 集合 | string\[] | \[] | |
| targetKeys | 显示在右侧框数据的 key 集合 | string\[] \| number\[] | \[] | |
| titles | 标题集合,顺序从左至右 | ReactNode\[] | - | |
| onChange | 选项在两栏之间转移时的回调函数 | (targetKeys, direction, moveKeys): void | - | |
| onScroll | 选项列表滚动时的回调函数 | (direction, event): void | - | |
@ -70,14 +70,14 @@ coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*g9vUQq2nkpEAAA
Transfer 支持接收 `children` 自定义渲染列表,并返回以下参数:
| 参数 | 说明 | 类型 | 版本 |
| --------------- | -------------- | ------------------------------------ | ---- |
| direction | 渲染列表的方向 | `left` \| `right` | |
| disabled | 是否禁用列表 | boolean | |
| filteredItems | 过滤后的数据 | RecordType\[] | |
| selectedKeys | 选中的条目 | string\[] | |
| onItemSelect | 勾选条目 | (key: string, selected: boolean) | |
| onItemSelectAll | 勾选一组条目 | (keys: string\[], selected: boolean) | |
| 参数 | 说明 | 类型 | 版本 |
| --------------- | -------------- | ------------------------------------------------- | ---- |
| direction | 渲染列表的方向 | `left` \| `right` | |
| disabled | 是否禁用列表 | boolean | |
| filteredItems | 过滤后的数据 | RecordType\[] | |
| selectedKeys | 选中的条目 | string\[] \| number\[] | |
| onItemSelect | 勾选条目 | (key: string \| number, selected: boolean) | |
| onItemSelectAll | 勾选一组条目 | (keys: string\[] \| number\[], selected: boolean) | |
#### 参考示例

View File

@ -1,3 +1,5 @@
export type TransferKey = React.Key;
export type PaginationType =
| boolean
| {

View File

@ -15,7 +15,7 @@ import type {
TransferDirection,
TransferLocale,
} from './index';
import type { PaginationType } from './interface';
import type { PaginationType, TransferKey } from './interface';
import type { ListBodyRef, TransferListBodyProps } from './ListBody';
import DefaultListBody, { OmitProps } from './ListBody';
import Search from './search';
@ -50,11 +50,15 @@ export interface TransferListProps<RecordType> extends TransferLocale {
dataSource: RecordType[];
filterOption?: (filterText: string, item: RecordType, direction: TransferDirection) => boolean;
style?: React.CSSProperties;
checkedKeys: string[];
checkedKeys: TransferKey[];
handleFilter: (e: React.ChangeEvent<HTMLInputElement>) => void;
onItemSelect: (key: string, check: boolean, e?: React.MouseEvent<Element, MouseEvent>) => void;
onItemSelectAll: (dataSource: string[], checkAll: boolean | 'replace') => void;
onItemRemove?: (keys: string[]) => void;
onItemSelect: (
key: TransferKey,
check: boolean,
e?: React.MouseEvent<Element, MouseEvent>,
) => void;
onItemSelectAll: (dataSource: TransferKey[], checkAll: boolean | 'replace') => void;
onItemRemove?: (keys: TransferKey[]) => void;
handleClear: () => void;
/** Render item */
render?: (item: RecordType) => RenderResult;

View File

@ -1,4 +1,5 @@
import * as React from 'react';
import LoadingOutlined from '@ant-design/icons/LoadingOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CopyOutlined from '@ant-design/icons/CopyOutlined';
import classNames from 'classnames';
@ -15,10 +16,11 @@ export interface CopyBtnProps extends CopyConfig {
locale: Locale['Text'];
onCopy: React.MouseEventHandler<HTMLDivElement>;
iconOnly: boolean;
loading: boolean;
}
export default function CopyBtn(props: CopyBtnProps) {
const { prefixCls, copied, locale = {}, onCopy, iconOnly, tooltips, icon } = props;
const { prefixCls, copied, locale = {}, onCopy, iconOnly, tooltips, icon, loading } = props;
const tooltipNodes = toList(tooltips);
const iconNodes = toList(icon);
@ -43,7 +45,7 @@ export default function CopyBtn(props: CopyBtnProps) {
>
{copied
? getNode(iconNodes[1], <CheckOutlined />, true)
: getNode(iconNodes[0], <CopyOutlined />, true)}
: getNode(iconNodes[0], loading ? <LoadingOutlined /> : <CopyOutlined />, true)}
</TransButton>
</Tooltip>
);

View File

@ -105,12 +105,11 @@ export interface EllipsisProps {
rows: number;
children: (
cutChildren: React.ReactNode[],
/** Tell current `cutChildren` is in ellipsis */
inEllipsis: boolean,
/** Tell current `text` is exceed the `rows` which can be ellipsis */
canEllipsis: boolean,
) => React.ReactNode;
onEllipsis: (isEllipsis: boolean) => void;
expanded: boolean;
/**
* Mark for misc update. Which will not affect ellipsis content length.
* e.g. tooltip content update.
@ -131,13 +130,14 @@ const lineClipStyle: React.CSSProperties = {
};
export default function EllipsisMeasure(props: EllipsisProps) {
const { enableMeasure, width, text, children, rows, miscDeps, onEllipsis } = props;
const { enableMeasure, width, text, children, rows, expanded, miscDeps, onEllipsis } = props;
const nodeList = React.useMemo(() => toArray(text), [text]);
const nodeLen = React.useMemo(() => getNodesLen(nodeList), [text]);
// ========================= Full Content =========================
const fullContent = React.useMemo(() => children(nodeList, false, false), [text]);
// Used for measure only, which means it's always render as no need ellipsis
const fullContent = React.useMemo(() => children(nodeList, false), [text]);
// ========================= Cut Content ==========================
const [ellipsisCutIndex, setEllipsisCutIndex] = React.useState<[number, number] | null>(null);
@ -150,6 +150,7 @@ export default function EllipsisMeasure(props: EllipsisProps) {
const descRowsEllipsisRef = React.useRef<MeasureTextRef>(null);
const symbolRowEllipsisRef = React.useRef<MeasureTextRef>(null);
const [canEllipsis, setCanEllipsis] = React.useState(false);
const [needEllipsis, setNeedEllipsis] = React.useState(STATUS_MEASURE_NONE);
const [ellipsisHeight, setEllipsisHeight] = React.useState(0);
@ -169,6 +170,7 @@ export default function EllipsisMeasure(props: EllipsisProps) {
setNeedEllipsis(isOverflow ? STATUS_MEASURE_NEED_ELLIPSIS : STATUS_MEASURE_NO_NEED_ELLIPSIS);
setEllipsisCutIndex(isOverflow ? [0, nodeLen] : null);
setCanEllipsis(isOverflow);
// Get the basic height of ellipsis rows
const baseRowsEllipsisHeight = needEllipsisRef.current?.getHeight() || 0;
@ -218,7 +220,7 @@ export default function EllipsisMeasure(props: EllipsisProps) {
!ellipsisCutIndex ||
ellipsisCutIndex[0] !== ellipsisCutIndex[1]
) {
const content = children(nodeList, false, false);
const content = children(nodeList, false);
// Limit the max line count to avoid scrollbar blink
// https://github.com/ant-design/ant-design/issues/42958
@ -241,8 +243,8 @@ export default function EllipsisMeasure(props: EllipsisProps) {
return content;
}
return children(sliceNodes(nodeList, ellipsisCutIndex[0]), true, true);
}, [needEllipsis, ellipsisCutIndex, nodeList, ...miscDeps]);
return children(expanded ? nodeList : sliceNodes(nodeList, ellipsisCutIndex[0]), canEllipsis);
}, [expanded, needEllipsis, ellipsisCutIndex, nodeList, ...miscDeps]);
// ============================ Render ============================
const measureStyle: React.CSSProperties = {
@ -293,7 +295,7 @@ export default function EllipsisMeasure(props: EllipsisProps) {
}}
ref={symbolRowEllipsisRef}
>
{children([], true, true)}
{children([], true)}
</MeasureText>
</>
)}
@ -309,7 +311,7 @@ export default function EllipsisMeasure(props: EllipsisProps) {
}}
ref={cutMidRef}
>
{children(sliceNodes(nodeList, cutMidIndex), true, true)}
{children(sliceNodes(nodeList, cutMidIndex), true)}
</MeasureText>
)}
</>

View File

@ -1,7 +1,6 @@
import * as React from 'react';
import EditOutlined from '@ant-design/icons/EditOutlined';
import classNames from 'classnames';
import copy from 'copy-to-clipboard';
import ResizeObserver from 'rc-resize-observer';
import type { AutoSizeType } from 'rc-textarea';
import toArray from 'rc-util/lib/Children/toArray';
@ -17,6 +16,7 @@ import useLocale from '../../locale/useLocale';
import type { TooltipProps } from '../../tooltip';
import Tooltip from '../../tooltip';
import Editable from '../Editable';
import useCopyClick from '../hooks/useCopyClick';
import useMergedConfig from '../hooks/useMergedConfig';
import useUpdatedEffect from '../hooks/useUpdatedEffect';
import type { TypographyProps } from '../Typography';
@ -28,7 +28,7 @@ import EllipsisTooltip from './EllipsisTooltip';
export type BaseType = 'secondary' | 'success' | 'warning' | 'danger';
export interface CopyConfig {
text?: string;
text?: string | (() => string | Promise<string>);
onCopy?: (event?: React.MouseEvent<HTMLDivElement>) => void;
icon?: React.ReactNode;
tooltips?: React.ReactNode;
@ -52,10 +52,12 @@ interface EditConfig {
export interface EllipsisConfig {
rows?: number;
expandable?: boolean;
expandable?: boolean | 'collapsible';
suffix?: string;
symbol?: React.ReactNode;
onExpand?: React.MouseEventHandler<HTMLElement>;
symbol?: React.ReactNode | ((expanded: boolean) => React.ReactNode);
defaultExpanded?: boolean;
expanded?: boolean;
onExpand?: (e: React.MouseEvent<HTMLElement, MouseEvent>, info: { expanded: boolean }) => void;
onEllipsis?: (ellipsis: boolean) => void;
tooltip?: React.ReactNode | TooltipProps;
}
@ -178,52 +180,26 @@ const Base = React.forwardRef<HTMLElement, BlockProps>((props, ref) => {
// ========================== Copyable ==========================
const [enableCopy, copyConfig] = useMergedConfig<CopyConfig>(copyable);
const [copied, setCopied] = React.useState(false);
const copyIdRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const copyOptions: Pick<CopyConfig, 'format'> = {};
if (copyConfig.format) {
copyOptions.format = copyConfig.format;
}
const cleanCopyId = () => {
if (copyIdRef.current) {
clearTimeout(copyIdRef.current);
}
};
const onCopyClick = (e?: React.MouseEvent<HTMLDivElement>) => {
e?.preventDefault();
e?.stopPropagation();
copy(copyConfig.text || String(children) || '', copyOptions);
setCopied(true);
// Trigger tips update
cleanCopyId();
copyIdRef.current = setTimeout(() => {
setCopied(false);
}, 3000);
copyConfig.onCopy?.(e);
};
React.useEffect(() => cleanCopyId, []);
const { copied, copyLoading, onClick: onCopyClick } = useCopyClick({ copyConfig, children });
// ========================== Ellipsis ==========================
const [isLineClampSupport, setIsLineClampSupport] = React.useState(false);
const [isTextOverflowSupport, setIsTextOverflowSupport] = React.useState(false);
const [expanded, setExpanded] = React.useState(false);
const [isJsEllipsis, setIsJsEllipsis] = React.useState(false);
const [isNativeEllipsis, setIsNativeEllipsis] = React.useState(false);
const [isNativeVisible, setIsNativeVisible] = React.useState(true);
const [enableEllipsis, ellipsisConfig] = useMergedConfig<EllipsisConfig>(ellipsis, {
expandable: false,
symbol: (isExpanded) => (isExpanded ? textLocale?.collapse : textLocale?.expand),
});
const [expanded, setExpanded] = useMergedState(ellipsisConfig.defaultExpanded || false, {
value: ellipsisConfig.expanded,
});
const mergedEnableEllipsis = enableEllipsis && !expanded;
const mergedEnableEllipsis =
enableEllipsis && (!expanded || ellipsisConfig.expandable === 'collapsible');
// Shared prop to reduce bundle size
const { rows = 1 } = ellipsisConfig;
@ -267,9 +243,9 @@ const Base = React.forwardRef<HTMLElement, BlockProps>((props, ref) => {
const cssLineClamp = mergedEnableEllipsis && rows > 1 && cssEllipsis;
// >>>>> Expand
const onExpandClick: React.MouseEventHandler<HTMLElement> = (e) => {
setExpanded(true);
ellipsisConfig.onExpand?.(e);
const onExpandClick: EllipsisConfig['onExpand'] = (e, info) => {
setExpanded(info.expanded);
ellipsisConfig.onExpand?.(e, info);
};
const [ellipsisWidth, setEllipsisWidth] = React.useState(0);
@ -389,22 +365,16 @@ const Base = React.forwardRef<HTMLElement, BlockProps>((props, ref) => {
const { expandable, symbol } = ellipsisConfig;
if (!expandable) return null;
let expandContent: React.ReactNode;
if (symbol) {
expandContent = symbol;
} else {
expandContent = textLocale?.expand;
}
if (expanded && expandable !== 'collapsible') return null;
return (
<a
key="expand"
className={`${prefixCls}-expand`}
onClick={onExpandClick}
aria-label={textLocale?.expand}
className={`${prefixCls}-${expanded ? 'collapse' : 'expand'}`}
onClick={(e) => onExpandClick(e, { expanded: !expanded })}
aria-label={expanded ? textLocale.collapse : textLocale?.expand}
>
{expandContent}
{typeof symbol === 'function' ? symbol(expanded) : symbol}
</a>
);
};
@ -446,25 +416,27 @@ const Base = React.forwardRef<HTMLElement, BlockProps>((props, ref) => {
copied={copied}
locale={textLocale}
onCopy={onCopyClick}
loading={copyLoading}
iconOnly={children === null || children === undefined}
/>
);
};
const renderOperations = (renderExpanded: boolean) => [
renderExpanded && renderExpand(),
const renderOperations = (canEllipsis: boolean) => [
// (renderExpanded || ellipsisConfig.collapsible) && renderExpand(),
canEllipsis && renderExpand(),
renderEdit(),
renderCopy(),
];
const renderEllipsis = (needEllipsis: boolean) => [
needEllipsis && (
const renderEllipsis = (canEllipsis: boolean) => [
canEllipsis && !expanded && (
<span aria-hidden key="ellipsis">
{ELLIPSIS_STR}
</span>
),
ellipsisConfig.suffix,
renderOperations(needEllipsis),
renderOperations(canEllipsis),
];
return (
@ -506,11 +478,12 @@ const Base = React.forwardRef<HTMLElement, BlockProps>((props, ref) => {
rows={rows}
width={ellipsisWidth}
onEllipsis={onJsEllipsis}
miscDeps={[copied, expanded]}
expanded={expanded}
miscDeps={[copied, expanded, copyLoading]}
>
{(node, needEllipsis) => {
{(node, canEllipsis) => {
let renderNode: React.ReactNode = node;
if (node.length && needEllipsis && topAriaLabel) {
if (node.length && canEllipsis && !expanded && topAriaLabel) {
renderNode = (
<span key="show-content" aria-hidden>
{renderNode}
@ -522,7 +495,7 @@ const Base = React.forwardRef<HTMLElement, BlockProps>((props, ref) => {
props,
<>
{renderNode}
{renderEllipsis(needEllipsis)}
{renderEllipsis(canEllipsis)}
</>,
);

View File

@ -688,6 +688,57 @@ Array [
</div>
</div>
</span>,
<div
class="ant-typography"
>
Request copy text.
<div
aria-label="Copy"
class="ant-typography-copy"
role="button"
style="border: 0px; background: transparent; padding: 0px; line-height: inherit; display: inline-block;"
tabindex="0"
>
<span
aria-label="copy"
class="anticon anticon-copy"
role="img"
>
<svg
aria-hidden="true"
data-icon="copy"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530.7c0 8.5 3.4 16.6 9.4 22.6l173.3 173.3c2.2 2.2 4.7 4 7.4 5.5v1.9h4.2c3.5 1.3 7.2 2 11 2H704c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32zM350 856.2L263.9 770H350v86.2zM664 888H414V746c0-22.1-17.9-40-40-40H232V264h432v624z"
/>
</svg>
</span>
</div>
<div
class="ant-tooltip ant-zoom-big-fast-appear ant-zoom-big-fast-appear-prepare ant-zoom-big-fast ant-tooltip-placement-top"
style="--arrow-x: 0px; --arrow-y: 0px; left: -1000vw; top: -1000vh; box-sizing: border-box;"
>
<div
class="ant-tooltip-arrow"
style="position: absolute; bottom: 0px; left: 0px;"
/>
<div
class="ant-tooltip-content"
>
<div
class="ant-tooltip-inner"
role="tooltip"
>
Copy
</div>
</div>
</div>
</div>,
]
`;
@ -1520,6 +1571,139 @@ Array [
exports[`renders components/typography/demo/ellipsis.tsx extend context correctly 2`] = `[]`;
exports[`renders components/typography/demo/ellipsis-controlled.tsx extend context correctly 1`] = `
<div
class="ant-flex ant-flex-align-stretch ant-flex-vertical"
style="gap: 16px;"
>
<div
class="ant-flex ant-flex-align-center"
style="gap: 16px;"
>
<button
aria-checked="false"
class="ant-switch"
role="switch"
style="flex: 0 0 auto;"
type="button"
>
<div
class="ant-switch-handle"
/>
<span
class="ant-switch-inner"
>
<span
class="ant-switch-inner-checked"
/>
<span
class="ant-switch-inner-unchecked"
/>
</span>
</button>
<div
class="ant-slider ant-slider-horizontal"
style="flex-basis: auto;"
>
<div
class="ant-slider-rail"
/>
<div
class="ant-slider-track"
style="left: 0%; width: 5.263157894736842%;"
/>
<div
class="ant-slider-step"
/>
<div
aria-disabled="false"
aria-orientation="horizontal"
aria-valuemax="20"
aria-valuemin="1"
aria-valuenow="2"
class="ant-slider-handle"
role="slider"
style="left: 5.263157894736842%; transform: translateX(-50%);"
tabindex="0"
/>
<div
class="ant-tooltip ant-zoom-big-fast-appear ant-zoom-big-fast-appear-prepare ant-zoom-big-fast ant-slider-tooltip ant-tooltip-placement-top"
style="--arrow-x: 0px; --arrow-y: 0px; left: -1000vw; top: -1000vh; box-sizing: border-box;"
>
<div
class="ant-tooltip-arrow"
style="position: absolute; bottom: 0px; left: 0px;"
/>
<div
class="ant-tooltip-content"
>
<div
class="ant-tooltip-inner"
role="tooltip"
>
2
</div>
</div>
</div>
</div>
</div>
<div
aria-label="Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team."
class="ant-typography ant-typography-ellipsis"
>
Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.
<div
aria-label="Copy"
class="ant-typography-copy"
role="button"
style="border: 0px; background: transparent; padding: 0px; line-height: inherit; display: inline-block;"
tabindex="0"
>
<span
aria-label="copy"
class="anticon anticon-copy"
role="img"
>
<svg
aria-hidden="true"
data-icon="copy"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530.7c0 8.5 3.4 16.6 9.4 22.6l173.3 173.3c2.2 2.2 4.7 4 7.4 5.5v1.9h4.2c3.5 1.3 7.2 2 11 2H704c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32zM350 856.2L263.9 770H350v86.2zM664 888H414V746c0-22.1-17.9-40-40-40H232V264h432v624z"
/>
</svg>
</span>
</div>
<div
class="ant-tooltip ant-zoom-big-fast-appear ant-zoom-big-fast-appear-prepare ant-zoom-big-fast ant-tooltip-placement-top"
style="--arrow-x: 0px; --arrow-y: 0px; left: -1000vw; top: -1000vh; box-sizing: border-box;"
>
<div
class="ant-tooltip-arrow"
style="position: absolute; bottom: 0px; left: 0px;"
/>
<div
class="ant-tooltip-content"
>
<div
class="ant-tooltip-inner"
role="tooltip"
>
Copy
</div>
</div>
</div>
</div>
</div>
`;
exports[`renders components/typography/demo/ellipsis-controlled.tsx extend context correctly 2`] = `[]`;
exports[`renders components/typography/demo/ellipsis-debug.tsx extend context correctly 1`] = `
Array [
<button

View File

@ -593,6 +593,38 @@ Array [
</span>
</div>
</span>,
<div
class="ant-typography"
>
Request copy text.
<div
aria-label="Copy"
class="ant-typography-copy"
role="button"
style="border:0;background:transparent;padding:0;line-height:inherit;display:inline-block"
tabindex="0"
>
<span
aria-label="copy"
class="anticon anticon-copy"
role="img"
>
<svg
aria-hidden="true"
data-icon="copy"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530.7c0 8.5 3.4 16.6 9.4 22.6l173.3 173.3c2.2 2.2 4.7 4 7.4 5.5v1.9h4.2c3.5 1.3 7.2 2 11 2H704c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32zM350 856.2L263.9 770H350v86.2zM664 888H414V746c0-22.1-17.9-40-40-40H232V264h432v624z"
/>
</svg>
</span>
</div>
</div>,
]
`;
@ -1143,6 +1175,99 @@ Array [
]
`;
exports[`renders components/typography/demo/ellipsis-controlled.tsx correctly 1`] = `
<div
class="ant-flex ant-flex-align-stretch ant-flex-vertical"
style="gap:16px"
>
<div
class="ant-flex ant-flex-align-center"
style="gap:16px"
>
<button
aria-checked="false"
class="ant-switch"
role="switch"
style="flex:none"
type="button"
>
<div
class="ant-switch-handle"
/>
<span
class="ant-switch-inner"
>
<span
class="ant-switch-inner-checked"
/>
<span
class="ant-switch-inner-unchecked"
/>
</span>
</button>
<div
class="ant-slider ant-slider-horizontal"
style="flex:auto"
>
<div
class="ant-slider-rail"
/>
<div
class="ant-slider-track"
style="left:0%;width:5.263157894736842%"
/>
<div
class="ant-slider-step"
/>
<div
aria-disabled="false"
aria-orientation="horizontal"
aria-valuemax="20"
aria-valuemin="1"
aria-valuenow="2"
class="ant-slider-handle"
role="slider"
style="left:5.263157894736842%;transform:translateX(-50%)"
tabindex="0"
/>
</div>
</div>
<div
aria-label="Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team."
class="ant-typography ant-typography-ellipsis"
>
Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.Ant Design, a design language for background applications, is refined by Ant UED Team.
<div
aria-label="Copy"
class="ant-typography-copy"
role="button"
style="border:0;background:transparent;padding:0;line-height:inherit;display:inline-block"
tabindex="0"
>
<span
aria-label="copy"
class="anticon anticon-copy"
role="img"
>
<svg
aria-hidden="true"
data-icon="copy"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530.7c0 8.5 3.4 16.6 9.4 22.6l173.3 173.3c2.2 2.2 4.7 4 7.4 5.5v1.9h4.2c3.5 1.3 7.2 2 11 2H704c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32zM350 856.2L263.9 770H350v86.2zM664 888H414V746c0-22.1-17.9-40-40-40H232V264h432v624z"
/>
</svg>
</span>
</div>
</div>
</div>
`;
exports[`renders components/typography/demo/ellipsis-debug.tsx correctly 1`] = `
Array [
<button

View File

@ -2,8 +2,9 @@ import React from 'react';
import { LikeOutlined, SmileOutlined } from '@ant-design/icons';
import * as copyObj from 'copy-to-clipboard';
import { fireEvent, render, waitFakeTimer, waitFor } from '../../../tests/utils';
import { fireEvent, render, renderHook, waitFakeTimer, waitFor } from '../../../tests/utils';
import Base from '../Base';
import useCopyClick from '../hooks/useCopyClick';
describe('Typography copy', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
@ -262,6 +263,39 @@ describe('Typography copy', () => {
fireEvent.click(copyBtn);
expect(spy.mock.calls[0][0]).toEqual(nextText);
jest.useRealTimers();
spy.mockReset();
});
it('copy by async', async () => {
const spy = jest.spyOn(copyObj, 'default');
const { container: wrapper } = render(
<Base
component="p"
copyable={{
text: jest.fn().mockResolvedValueOnce('Request text'),
}}
>
test copy
</Base>,
);
fireEvent.click(wrapper.querySelectorAll('.ant-typography-copy')[0]);
expect(wrapper.querySelectorAll('.anticon-loading')[0]).toBeTruthy();
await waitFakeTimer();
expect(spy.mock.calls[0][0]).toEqual('Request text');
spy.mockReset();
expect(wrapper.querySelectorAll('.anticon-loading')[0]).toBeFalsy();
});
it('useCopyClick error', async () => {
const { result } = renderHook(() =>
useCopyClick({
copyConfig: {
text: jest.fn().mockRejectedValueOnce('Oops'),
},
}),
);
await expect(() => result.current?.onClick?.()).rejects.toMatch('Oops');
expect(result.current?.copyLoading).toBe(false);
});
});
});

View File

@ -249,6 +249,34 @@ describe('Typography.Ellipsis', () => {
expect(container.querySelector('p')?.textContent).toEqual(fullStr);
});
it('should collapsible work', async () => {
const ref = React.createRef<HTMLElement>();
const { container: wrapper } = render(
<Base
ellipsis={{
expandable: 'collapsible',
symbol: (expanded) => (expanded ? 'CloseIt' : 'OpenIt'),
}}
component="p"
ref={ref}
>
{fullStr}
</Base>,
);
triggerResize(ref.current!);
await waitFakeTimer();
expect(wrapper.querySelector('p')?.textContent).toEqual(`Bamboo is L...OpenIt`);
fireEvent.click(wrapper.querySelector('.ant-typography-expand')!);
expect(wrapper.querySelector('p')?.textContent).toEqual(`${fullStr}CloseIt`);
fireEvent.click(wrapper.querySelector('.ant-typography-collapse')!);
expect(wrapper.querySelector('p')?.textContent).toEqual(`Bamboo is L...OpenIt`);
});
it('should have custom expand style', async () => {
const ref = React.createRef<HTMLElement>();
const symbol = 'more';

Some files were not shown because too many files have changed in this diff Show More