diff --git a/.gitignore b/.gitignore
index 54b212c74d..5be4d3cb0b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,3 +67,5 @@ __image_snapshots__/
/jest-stare
/imageSnapshots
/imageDiffSnapshots
+
+.devcontainer*
diff --git a/components/__tests__/__snapshots__/index.test.js.snap b/components/__tests__/__snapshots__/index.test.js.snap
index 194d145727..3e17f31437 100644
--- a/components/__tests__/__snapshots__/index.test.js.snap
+++ b/components/__tests__/__snapshots__/index.test.js.snap
@@ -45,6 +45,7 @@ Array [
"Rate",
"Result",
"Row",
+ "Segmented",
"Select",
"Skeleton",
"Slider",
diff --git a/components/index.tsx b/components/index.tsx
index 88b07ad7ae..3915e62f22 100644
--- a/components/index.tsx
+++ b/components/index.tsx
@@ -154,6 +154,9 @@ export { default as Row } from './row';
export type { SelectProps } from './select';
export { default as Select } from './select';
+export type { SegmentedProps } from './segmented';
+export { default as Segmented } from './segmented';
+
export type { SkeletonProps } from './skeleton';
export { default as Skeleton } from './skeleton';
diff --git a/components/segmented/__tests__/__snapshots__/demo-extend.test.ts.snap b/components/segmented/__tests__/__snapshots__/demo-extend.test.ts.snap
new file mode 100644
index 0000000000..d2102e19fa
--- /dev/null
+++ b/components/segmented/__tests__/__snapshots__/demo-extend.test.ts.snap
@@ -0,0 +1,861 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders ./components/segmented/demo/basic.md extend context correctly 1`] = `
+
+`;
+
+exports[`renders ./components/segmented/demo/block.md extend context correctly 1`] = `
+
+`;
+
+exports[`renders ./components/segmented/demo/controlled.md extend context correctly 1`] = `
+
+`;
+
+exports[`renders ./components/segmented/demo/custom.md extend context correctly 1`] = `
+Array [
+
+
+
+
+
,
+
,
+
+
+
+
+
+
,
+]
+`;
+
+exports[`renders ./components/segmented/demo/disabled.md extend context correctly 1`] = `
+Array [
+ ,
+
,
+ ,
+]
+`;
+
+exports[`renders ./components/segmented/demo/dynamic.md extend context correctly 1`] = `
+Array [
+ ,
+
,
+ ,
+]
+`;
+
+exports[`renders ./components/segmented/demo/size.md extend context correctly 1`] = `
+Array [
+ ,
+
,
+ ,
+
,
+ ,
+]
+`;
+
+exports[`renders ./components/segmented/demo/with-icon.md extend context correctly 1`] = `
+
+
+
+
+`;
diff --git a/components/segmented/__tests__/__snapshots__/demo.test.js.snap b/components/segmented/__tests__/__snapshots__/demo.test.js.snap
new file mode 100644
index 0000000000..4d7e4a8add
--- /dev/null
+++ b/components/segmented/__tests__/__snapshots__/demo.test.js.snap
@@ -0,0 +1,861 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders ./components/segmented/demo/basic.md correctly 1`] = `
+
+`;
+
+exports[`renders ./components/segmented/demo/block.md correctly 1`] = `
+
+`;
+
+exports[`renders ./components/segmented/demo/controlled.md correctly 1`] = `
+
+`;
+
+exports[`renders ./components/segmented/demo/custom.md correctly 1`] = `
+Array [
+
+
+
+
+
,
+
,
+
+
+
+
+
+
,
+]
+`;
+
+exports[`renders ./components/segmented/demo/disabled.md correctly 1`] = `
+Array [
+ ,
+
,
+ ,
+]
+`;
+
+exports[`renders ./components/segmented/demo/dynamic.md correctly 1`] = `
+Array [
+ ,
+
,
+ ,
+]
+`;
+
+exports[`renders ./components/segmented/demo/size.md correctly 1`] = `
+Array [
+ ,
+
,
+ ,
+
,
+ ,
+]
+`;
+
+exports[`renders ./components/segmented/demo/with-icon.md correctly 1`] = `
+
+
+
+
+`;
diff --git a/components/segmented/__tests__/__snapshots__/index.test.tsx.snap b/components/segmented/__tests__/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000000..aac71f442c
--- /dev/null
+++ b/components/segmented/__tests__/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,638 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Segmented render empty segmented 1`] = `
+
+`;
+
+exports[`Segmented render label with ReactNode 1`] = `
+
+`;
+
+exports[`Segmented render segmented ok 1`] = `
+
+`;
+
+exports[`Segmented render segmented with \`block\` 1`] = `
+
+`;
+
+exports[`Segmented render segmented with \`size#large\` 1`] = `
+
+`;
+
+exports[`Segmented render segmented with \`size#small\` 1`] = `
+
+`;
+
+exports[`Segmented render segmented with mixed options 1`] = `
+
+`;
+
+exports[`Segmented render segmented with numeric options 1`] = `
+
+`;
+
+exports[`Segmented render segmented with options null/undefined 1`] = `
+
+`;
+
+exports[`Segmented render segmented with options: disabled 1`] = `
+
+`;
+
+exports[`Segmented render segmented with string options 1`] = `
+
+`;
+
+exports[`Segmented render segmented with thumb 1`] = `
+
+`;
+
+exports[`Segmented render segmented: disabled 1`] = `
+
+`;
+
+exports[`Segmented rtl render component should be rendered correctly in RTL direction 1`] = `
+
+`;
diff --git a/components/segmented/__tests__/demo-extend.test.ts b/components/segmented/__tests__/demo-extend.test.ts
new file mode 100644
index 0000000000..c7e9ef89f0
--- /dev/null
+++ b/components/segmented/__tests__/demo-extend.test.ts
@@ -0,0 +1,3 @@
+import { extendTest } from '../../../tests/shared/demoTest';
+
+extendTest('segmented');
diff --git a/components/segmented/__tests__/demo.test.js b/components/segmented/__tests__/demo.test.js
new file mode 100644
index 0000000000..6a3124c88b
--- /dev/null
+++ b/components/segmented/__tests__/demo.test.js
@@ -0,0 +1,3 @@
+import demoTest from '../../../tests/shared/demoTest';
+
+demoTest('segmented');
diff --git a/components/segmented/__tests__/image.test.ts b/components/segmented/__tests__/image.test.ts
new file mode 100644
index 0000000000..03f761f8da
--- /dev/null
+++ b/components/segmented/__tests__/image.test.ts
@@ -0,0 +1,5 @@
+import { imageDemoTest } from '../../../tests/shared/imageTest';
+
+describe('Segmented image', () => {
+ imageDemoTest('segmented');
+});
diff --git a/components/segmented/__tests__/index.test.tsx b/components/segmented/__tests__/index.test.tsx
new file mode 100644
index 0000000000..e14165c1c3
--- /dev/null
+++ b/components/segmented/__tests__/index.test.tsx
@@ -0,0 +1,360 @@
+import React from 'react';
+import { mount } from 'enzyme';
+
+import mountTest from '../../../tests/shared/mountTest';
+import rtlTest from '../../../tests/shared/rtlTest';
+
+import Segmented from '../index';
+import type { SegmentedValue } from '../index';
+
+// Make CSSMotion working without transition
+jest.mock('rc-motion/lib/util/motion', () => ({
+ ...jest.requireActual('rc-motion/lib/util/motion'),
+ supportTransition: false,
+}));
+
+const prefixCls = 'ant-segmented';
+
+describe('Segmented', () => {
+ mountTest(Segmented);
+ rtlTest(Segmented);
+
+ beforeAll(() => {
+ jest.useFakeTimers();
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ });
+
+ it('render empty segmented', () => {
+ const wrapper = mount();
+ expect(wrapper.render()).toMatchSnapshot();
+ });
+
+ it('render segmented ok', () => {
+ const wrapper = mount(
+ ,
+ );
+
+ expect(wrapper.render()).toMatchSnapshot();
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([true, false, false]);
+ });
+
+ it('render label with ReactNode', () => {
+ const wrapper = mount(
+ Weekly, value: 'Weekly' },
+ { label: Monthly
, value: 'Monthly' },
+ ]}
+ />,
+ );
+
+ expect(wrapper.render()).toMatchSnapshot();
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([true, false, false]);
+
+ expect(wrapper.find('#weekly').at(0).text()).toContain('Weekly');
+ expect(wrapper.find('h2').at(0).text()).toContain('Monthly');
+ });
+
+ it('render segmented with defaultValue', () => {
+ const wrapper = mount(
+ ,
+ );
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([false, false, false, true, false]);
+ });
+
+ it('render segmented with string options', () => {
+ const handleValueChange = jest.fn();
+ const wrapper = mount(
+ handleValueChange(e.target.value)}
+ />,
+ );
+ expect(wrapper.render()).toMatchSnapshot();
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([true, false, false]);
+ expect(
+ wrapper.find(`.${prefixCls}-item`).at(0).hasClass(`${prefixCls}-item-selected`),
+ ).toBeTruthy();
+
+ wrapper.find(`.${prefixCls}-item-input`).at(2).simulate('change');
+ expect(handleValueChange).toBeCalledWith('Monthly');
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([false, false, true]);
+ });
+
+ it('render segmented with numeric options', () => {
+ const handleValueChange = jest.fn();
+ const wrapper = mount(
+ handleValueChange(e.target.value)} />,
+ );
+ expect(wrapper.render()).toMatchSnapshot();
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([true, false, false, false, false]);
+
+ wrapper.find(`.${prefixCls}-item-input`).last().simulate('change');
+ expect(handleValueChange).toBeCalledWith(5);
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([false, false, false, false, true]);
+ });
+
+ it('render segmented with mixed options', () => {
+ const handleValueChange = jest.fn();
+ const wrapper = mount(
+ handleValueChange(e.target.value)}
+ />,
+ );
+ expect(wrapper.render()).toMatchSnapshot();
+
+ wrapper.find(`.${prefixCls}-item-input`).at(1).simulate('change');
+ expect(handleValueChange).toBeCalledWith('Weekly');
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([false, true, false]);
+ });
+
+ it('render segmented with options: disabled', () => {
+ const handleValueChange = jest.fn();
+ const wrapper = mount(
+ handleValueChange(e.target.value)}
+ />,
+ );
+ expect(wrapper.render()).toMatchSnapshot();
+ expect(
+ wrapper.find(`.${prefixCls}-item`).at(1).hasClass(`${prefixCls}-item-disabled`),
+ ).toBeTruthy();
+ expect(wrapper.find(`.${prefixCls}-item-input`).at(1).prop('disabled')).toBeTruthy();
+
+ wrapper.find(`.${prefixCls}-item-input`).at(1).simulate('change');
+ expect(handleValueChange).not.toBeCalled();
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([true, false, false]);
+
+ wrapper.find(`.${prefixCls}-item-input`).at(2).simulate('change');
+ expect(handleValueChange).toBeCalledWith('Monthly');
+ expect(handleValueChange).toBeCalledTimes(1);
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([false, false, true]);
+ });
+
+ it('render segmented: disabled', () => {
+ const handleValueChange = jest.fn();
+ const wrapper = mount(
+ handleValueChange(e.target.value)}
+ />,
+ );
+ expect(wrapper.render()).toMatchSnapshot();
+ expect(wrapper.find(`.${prefixCls}`).hasClass(`${prefixCls}-disabled`)).toBeTruthy();
+
+ wrapper.find(`.${prefixCls}-item-input`).at(1).simulate('change');
+ expect(handleValueChange).not.toBeCalled();
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([true, false, false]);
+
+ wrapper.find(`.${prefixCls}-item-input`).at(2).simulate('change');
+ expect(handleValueChange).not.toBeCalled();
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([true, false, false]);
+ });
+
+ it('render segmented with className and other html attributes', () => {
+ const wrapper = mount(
+ ,
+ );
+
+ expect(wrapper.hasClass('mock-cls')).toBeTruthy();
+ expect(wrapper.prop('data-test-id')).toBe('hello');
+ });
+
+ it('render segmented with ref', () => {
+ const ref = React.createRef();
+ const wrapper = mount(
+ ,
+ );
+
+ expect((wrapper.find(Segmented).getElement() as any).ref).toBe(ref);
+ });
+
+ it('render segmented with controlled mode', () => {
+ class Demo extends React.Component<{}, { value: SegmentedValue }> {
+ state = {
+ value: 'Map',
+ };
+
+ render() {
+ return (
+
+ this.setState({
+ value: e.target.value,
+ })
+ }
+ />
+ );
+ }
+ }
+
+ const wrapper = mount();
+ wrapper.find('Segmented').find(`.${prefixCls}-item-input`).at(0).simulate('change');
+ expect(wrapper.state().value).toBe('Map');
+
+ wrapper.find('Segmented').find(`.${prefixCls}-item-input`).at(1).simulate('change');
+ expect(wrapper.state().value).toBe('Transit');
+ });
+
+ it('render segmented with options null/undefined', () => {
+ const handleValueChange = jest.fn();
+ const wrapper = mount(
+ handleValueChange(e.target.value)}
+ />,
+ );
+ expect(wrapper.render()).toMatchSnapshot();
+ expect(wrapper.find(`.${prefixCls}-item-label`).map(n => n.getDOMNode().textContent)).toEqual([
+ '',
+ '',
+ '',
+ ]);
+ });
+
+ it('render segmented with thumb', () => {
+ const handleValueChange = jest.fn();
+ const wrapper = mount(
+ handleValueChange(e.target.value)}
+ />,
+ );
+ expect(wrapper.render()).toMatchSnapshot();
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([true, false, false]);
+ expect(
+ wrapper.find(`.${prefixCls}-item`).at(0).hasClass(`${prefixCls}-item-selected`),
+ ).toBeTruthy();
+
+ wrapper.find(`.${prefixCls}-item-input`).at(2).simulate('change');
+ expect(handleValueChange).toBeCalledWith('Satellite');
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([false, false, true]);
+
+ // thumb appeared
+ expect(wrapper.find(`.${prefixCls}-thumb`).length).toBe(1);
+
+ // change selection again
+ wrapper.find(`.${prefixCls}-item-input`).at(1).simulate('change');
+ expect(handleValueChange).toBeCalledWith('Transit');
+
+ expect(
+ wrapper
+ .find(`.${prefixCls}-item-input`)
+ .map(el => (el.getDOMNode() as HTMLInputElement).checked),
+ ).toEqual([false, true, false]);
+
+ // thumb appeared
+ expect(wrapper.find(`.${prefixCls}-thumb`).length).toBe(1);
+ });
+
+ it('render segmented with `block`', () => {
+ const wrapper = mount();
+
+ expect(wrapper.render()).toMatchSnapshot();
+
+ expect(wrapper.find(`.${prefixCls}`).at(0).hasClass(`${prefixCls}-block`)).toBeTruthy();
+ });
+
+ it('render segmented with `size#small`', () => {
+ const wrapper = mount();
+
+ expect(wrapper.render()).toMatchSnapshot();
+
+ expect(wrapper.find(`.${prefixCls}`).at(0).hasClass(`${prefixCls}-sm`)).toBeTruthy();
+ });
+
+ it('render segmented with `size#large`', () => {
+ const wrapper = mount();
+
+ expect(wrapper.render()).toMatchSnapshot();
+
+ expect(wrapper.find(`.${prefixCls}`).at(0).hasClass(`${prefixCls}-lg`)).toBeTruthy();
+ });
+});
diff --git a/components/segmented/demo/basic.md b/components/segmented/demo/basic.md
new file mode 100644
index 0000000000..382531e8a0
--- /dev/null
+++ b/components/segmented/demo/basic.md
@@ -0,0 +1,33 @@
+---
+order: 0
+title:
+ zh-CN: 基本
+ en-US: Basic
+---
+
+## zh-CN
+
+最简单的用法。
+
+## en-US
+
+The most basic usage.
+
+```jsx
+import { Segmented } from 'antd';
+
+ReactDOM.render(
+ ,
+ mountNode,
+);
+```
+
+```css
+.code-box-demo {
+ overflow-x: auto;
+}
+
+.code-box-demo .ant-segmented {
+ margin-bottom: 10px;
+}
+```
diff --git a/components/segmented/demo/block.md b/components/segmented/demo/block.md
new file mode 100644
index 0000000000..ef311f8274
--- /dev/null
+++ b/components/segmented/demo/block.md
@@ -0,0 +1,23 @@
+---
+order: 10
+title:
+ zh-CN: Block 分段选择器
+ en-US: Block Segmented
+---
+
+## zh-CN
+
+`block` 属性使其适合父元素宽度。
+
+## en-US
+
+`block` property will make the `Segmented` fit to its parent width.
+
+```jsx
+import { Segmented } from 'antd';
+
+ReactDOM.render(
+ ,
+ mountNode,
+);
+```
diff --git a/components/segmented/demo/controlled.md b/components/segmented/demo/controlled.md
new file mode 100644
index 0000000000..523849d9b0
--- /dev/null
+++ b/components/segmented/demo/controlled.md
@@ -0,0 +1,33 @@
+---
+order: 0
+title:
+ zh-CN: 受控模式
+ en-US: Controlled mode
+---
+
+## zh-CN
+
+受控的 Segmented。
+
+## en-US
+
+Controlled Segmented.
+
+```jsx
+import React, { useState } from 'react';
+import { Segmented } from 'antd';
+
+const Demo: React.FC = () => {
+ const [value, setValue] = useState('Map');
+
+ return (
+ setValue(e.target.value)}
+ />
+ );
+};
+
+ReactDOM.render(, mountNode);
+```
diff --git a/components/segmented/demo/custom.md b/components/segmented/demo/custom.md
new file mode 100644
index 0000000000..eef9d749c7
--- /dev/null
+++ b/components/segmented/demo/custom.md
@@ -0,0 +1,97 @@
+---
+order: 1
+title:
+ zh-CN: 自定义渲染
+ en-US: Custom Render
+---
+
+## zh-CN
+
+使用 ReactNode 自定义渲染每一个 Segmented Item。
+
+## en-US
+
+Custom each Segmented Item by ReactNode.
+
+```jsx
+import { Avatar, Segmented } from 'antd';
+import { UserOutlined } from '@ant-design/icons';
+
+ReactDOM.render(
+ <>
+
+
+ User 1
+ >
+ ),
+ value: 'user1',
+ },
+ {
+ label: (
+ <>
+ K
+ User 2
+ >
+ ),
+ value: 'user2',
+ },
+ {
+ label: (
+ <>
+ } />
+ User 3
+ >
+ ),
+ value: 'user3',
+ },
+ ]}
+ />
+
+
+ Spring
+ Jan-Mar
+ >
+ ),
+ value: 'spring',
+ },
+ {
+ label: (
+ <>
+ Summer
+ Apr-Jun
+ >
+ ),
+ value: 'summer',
+ },
+ {
+ label: (
+ <>
+ Autumn
+ Jul-Sept
+ >
+ ),
+ value: 'autumn',
+ },
+ {
+ label: (
+ <>
+ Winter
+ Oct-Dec
+ >
+ ),
+ value: 'winter',
+ },
+ ]}
+ />
+ >,
+ mountNode,
+);
+```
diff --git a/components/segmented/demo/disabled.md b/components/segmented/demo/disabled.md
new file mode 100644
index 0000000000..a560c89c27
--- /dev/null
+++ b/components/segmented/demo/disabled.md
@@ -0,0 +1,35 @@
+---
+order: 0
+title:
+ zh-CN: 不可用
+ en-US: Basic
+---
+
+## zh-CN
+
+Segmented 不可用。
+
+## en-US
+
+Disabled Segmented.
+
+```jsx
+import { Segmented } from 'antd';
+
+ReactDOM.render(
+ <>
+
+
+
+ >,
+ mountNode,
+);
+```
diff --git a/components/segmented/demo/dynamic.md b/components/segmented/demo/dynamic.md
new file mode 100644
index 0000000000..5739c7091b
--- /dev/null
+++ b/components/segmented/demo/dynamic.md
@@ -0,0 +1,43 @@
+---
+order: 0
+title:
+ zh-CN: 动态数据
+ en-US: Dynamic
+---
+
+## zh-CN
+
+动态加载数据。
+
+## en-US
+
+Load `options` dynamically.
+
+```tsx
+import React, { useState } from 'react';
+import { Segmented, Button } from 'antd';
+
+const defaultOptions = ['Daily', 'Weekly', 'Monthly'];
+
+const Demo: React.FC = () => {
+ const [options, setOptions] = useState(defaultOptions);
+ const [moreLoaded, setMoreLoaded] = useState(false);
+
+ const handleLoadOptions = () => {
+ setOptions([...defaultOptions, 'Quarterly', 'Yearly']);
+ setMoreLoaded(true);
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+ReactDOM.render(, mountNode);
+```
diff --git a/components/segmented/demo/size.md b/components/segmented/demo/size.md
new file mode 100644
index 0000000000..009a8b3145
--- /dev/null
+++ b/components/segmented/demo/size.md
@@ -0,0 +1,29 @@
+---
+order: 1
+title:
+ zh-CN: 三种大小
+ en-US: Three sizes of Segmented
+---
+
+## zh-CN
+
+我们为 `` 组件定义了三种尺寸(大、默认、小),高度分别为 `40px`、`32px` 和 `24px`。
+
+## en-US
+
+There are three sizes of an Segmented: `large` (40px), `default` (32px) and `small` (24px).
+
+```jsx
+import { Segmented } from 'antd';
+
+ReactDOM.render(
+ <>
+
+
+
+
+
+ >,
+ mountNode,
+);
+```
diff --git a/components/segmented/demo/with-icon.md b/components/segmented/demo/with-icon.md
new file mode 100644
index 0000000000..8519860916
--- /dev/null
+++ b/components/segmented/demo/with-icon.md
@@ -0,0 +1,37 @@
+---
+order: 0
+title:
+ zh-CN: 设置图标
+ en-US: With Icon
+---
+
+## zh-CN
+
+给 Segmented Item 设置 Icon。
+
+## en-US
+
+Set `icon` for Segmented Item.
+
+```jsx
+import { Segmented } from 'antd';
+import { AppstoreOutlined, BarsOutlined } from '@ant-design/icons';
+
+ReactDOM.render(
+ ,
+ },
+ {
+ label: 'Kanban',
+ value: 'Kanban',
+ icon: ,
+ },
+ ]}
+ />,
+ mountNode,
+);
+```
diff --git a/components/segmented/index.en-US.md b/components/segmented/index.en-US.md
new file mode 100644
index 0000000000..0cb0b509e8
--- /dev/null
+++ b/components/segmented/index.en-US.md
@@ -0,0 +1,27 @@
+---
+category: Components
+type: Data Display
+title: Segmented
+cover: https://gw.alipayobjects.com/zos/bmw-prod/a3ff040f-24ba-43e0-92e9-c845df1612ad.svg
+---
+
+Segmented Controls. This component is available since `antd@4.20.0`.
+
+## When To Use
+
+- When displaying multiple options and user can select a single option;
+- When switching the selected option, the content of the associated area changes.
+
+## API
+
+> This component is available since `antd@4.20.0`
+
+| Property | Description | Type | Default | Version |
+| --- | --- | --- | --- | --- |
+| block | Option to fit width to its parent\'s width | boolean | false | |
+| defaultValue | Default selected value | string \| number | | |
+| disabled | Disable all segments | boolean | false | |
+| onChange | The callback function that is triggered when the state changes | function(e:Event) | | |
+| options | Set children optional | string\[] \| number\[] \| Array<{ label: string value: string icon? ReactNode disabled?: boolean className?: string }> | [] | |
+| size | The size of the Segmented. | `large` \| `middle` \| `small` | - | |
+| value | Currently selected value | string \| number | | |
diff --git a/components/segmented/index.tsx b/components/segmented/index.tsx
new file mode 100644
index 0000000000..da17860bcb
--- /dev/null
+++ b/components/segmented/index.tsx
@@ -0,0 +1,87 @@
+import * as React from 'react';
+import classNames from 'classnames';
+import RcSegmented from 'rc-segmented';
+import type {
+ SegmentedProps as RCSegmentedProps,
+ SegmentedRawOption,
+ SegmentedLabeledOption as RcSegmentedLabeledOption,
+} from 'rc-segmented';
+
+import { ConfigContext } from '../config-provider';
+import SizeContext, { SizeType } from '../config-provider/SizeContext';
+
+export type { SegmentedValue } from 'rc-segmented';
+
+export interface SegmentedLabeledOption extends RcSegmentedLabeledOption {
+ /** Set icon for Segmented item */
+ icon?: React.ReactNode;
+}
+
+export interface SegmentedProps extends Omit {
+ options: (SegmentedRawOption | SegmentedLabeledOption)[];
+ /** Option to fit width to its parent's width */
+ block?: boolean;
+ /** Option to control the display size */
+ size?: SizeType;
+}
+
+const Segmented = React.forwardRef((props, ref) => {
+ const {
+ prefixCls: customizePrefixCls,
+ className,
+ block,
+ options,
+ size: customSize = 'middle',
+ ...restProps
+ } = props;
+
+ const { getPrefixCls, direction } = React.useContext(ConfigContext);
+ const prefixCls = getPrefixCls('segmented', customizePrefixCls);
+
+ // ===================== Size =====================
+ const size = React.useContext(SizeContext);
+ const mergedSize = customSize || size;
+
+ // syntactic sugar to support `icon` for Segmented Item
+ const extendedOptions = React.useMemo(
+ () =>
+ options.map(option => {
+ if (typeof option === 'object' && option?.icon) {
+ const { icon, label, ...restOption } = option;
+ return {
+ ...restOption,
+ label: (
+ <>
+ {icon}
+ {label}
+ >
+ ),
+ };
+ }
+ return option;
+ }),
+ [options, prefixCls],
+ );
+
+ return (
+
+ );
+});
+
+Segmented.displayName = 'Segmented';
+Segmented.defaultProps = {
+ options: [],
+};
+
+export default Segmented;
diff --git a/components/segmented/index.zh-CN.md b/components/segmented/index.zh-CN.md
new file mode 100644
index 0000000000..0c1b2d04b4
--- /dev/null
+++ b/components/segmented/index.zh-CN.md
@@ -0,0 +1,30 @@
+---
+category: Components
+subtitle: 分段控制器
+type: 数据展示
+title: Segmented
+cover: https://gw.alipayobjects.com/zos/bmw-prod/a3ff040f-24ba-43e0-92e9-c845df1612ad.svg
+---
+
+分段控制器。自 `antd@4.20.0` 版本开始提供该组件。
+
+## 何时使用
+
+- 用于展示多个选项并允许用户选择其中单个选项;
+- 当切换选中选项时,关联区域的内容会发生变化。
+
+## API
+
+> 自 `antd@4.20.0` 版本开始提供该组件。
+
+### Segmented
+
+| 参数 | 说明 | 类型 | 默认值 | 版本 |
+| --- | --- | --- | --- | --- |
+| block | 将宽度调整为父元素宽度的选项 | boolean | false | |
+| defaultValue | 默认选中的值 | string \| number | | |
+| disabled | 是否禁用 | boolean | false | |
+| onChange | 选项变化时的回调函数 | function(e:Event) | | |
+| options | 数据化配置选项内容 | string\[] \| number\[] \| Array<{ label: string value: string icon? ReactNode disabled?: boolean className?: string }> | [] | |
+| size | 控件尺寸 | `large` \| `middle` \| `small` | - | |
+| value | 当前选中的值 | string \| number | | |
diff --git a/components/segmented/style/index.less b/components/segmented/style/index.less
new file mode 100644
index 0000000000..6f53806014
--- /dev/null
+++ b/components/segmented/style/index.less
@@ -0,0 +1,116 @@
+@import '../../style/themes/index';
+@import '../../style/mixins/index';
+@import './mixins.less';
+
+@segmented-prefix-cls: ~'@{ant-prefix}-segmented';
+
+.@{segmented-prefix-cls} {
+ .reset-component();
+
+ position: relative;
+ display: inline-flex;
+ align-items: stretch;
+ justify-items: flex-start;
+ color: @segmented-label-color;
+ background-color: @segmented-bg;
+ border-radius: 2px;
+ box-shadow: 0 0 0 2px @segmented-bg;
+ transition: all 0.3s @ease-in-out;
+
+ // hover/focus styles
+ &:not(&-disabled) {
+ &:hover,
+ &:focus {
+ background-color: @segmented-hover-bg;
+ box-shadow: 0 0 0 2px @segmented-hover-bg;
+ }
+ }
+
+ // block styles
+ &&-block {
+ display: flex;
+ }
+
+ &&-block &-item {
+ flex: 1;
+ min-width: 0;
+ }
+
+ // item styles
+ &-item {
+ position: relative;
+ text-align: center;
+ cursor: pointer;
+ transition: color 0.3s @ease-in-out;
+
+ &-selected {
+ .segmented-item-selected();
+ color: @segmented-label-hover-color;
+ }
+
+ &:hover,
+ &:focus {
+ color: @segmented-label-hover-color;
+ }
+
+ &-label {
+ min-height: @input-height-base;
+ padding: @input-padding-vertical-base @input-padding-horizontal-base;
+ line-height: @input-height-base - @input-padding-vertical-base * 2;
+ .segmented-text-ellipsis();
+ }
+
+ // syntactic sugar to add `icon` for Segmented Item
+ &-icon {
+ margin-right: 6px;
+ }
+
+ &-input {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ pointer-events: none;
+ }
+ }
+
+ // size styles
+ &&-lg &-item-label {
+ min-height: @input-height-lg;
+ padding: @input-padding-vertical-lg @input-padding-horizontal-lg;
+ font-size: @font-size-lg;
+ line-height: @input-height-lg - @input-padding-vertical-lg * 2;
+ }
+
+ &&-sm &-item-label {
+ min-height: @input-height-sm;
+ padding: @input-padding-vertical-sm @input-padding-horizontal-sm;
+ line-height: @input-height-sm - @input-padding-vertical-sm * 2;
+ }
+
+ // disabled styles
+ &-disabled &-item,
+ &-item-disabled {
+ .segmented-disabled-item();
+ }
+
+ // thumb styles
+ &-thumb {
+ .segmented-item-selected();
+
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 0;
+ height: 100%;
+ padding: 4px 0;
+ }
+
+ // transition effect when `enter-active`
+ &-thumb-motion-enter-active {
+ transition: transform 0.3s @ease-in-out, width 0.3s @ease-in-out;
+ will-change: transform, width;
+ }
+}
diff --git a/components/segmented/style/index.tsx b/components/segmented/style/index.tsx
new file mode 100644
index 0000000000..3a3ab0de59
--- /dev/null
+++ b/components/segmented/style/index.tsx
@@ -0,0 +1,2 @@
+import '../../style/index.less';
+import './index.less';
diff --git a/components/segmented/style/mixins.less b/components/segmented/style/mixins.less
new file mode 100644
index 0000000000..0c6839af74
--- /dev/null
+++ b/components/segmented/style/mixins.less
@@ -0,0 +1,24 @@
+// mixins
+.segmented-disabled-item {
+ &,
+ &:hover,
+ &:focus {
+ color: @disabled-color;
+ cursor: not-allowed;
+ }
+}
+
+.segmented-item-selected {
+ background-color: @segmented-selected-bg;
+ border-radius: @border-radius-base;
+ box-shadow: 0 2px 8px -2px fade(@black, 5%), 0 1px 4px -1px fade(@black, 7%),
+ 0 0 1px 0 fade(@black, 8%);
+}
+
+.segmented-text-ellipsis {
+ overflow: hidden;
+ // handle text ellipsis
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ word-break: keep-all;
+}
diff --git a/components/style/themes/dark.less b/components/style/themes/dark.less
index 59a9b69f20..4cfda904be 100644
--- a/components/style/themes/dark.less
+++ b/components/style/themes/dark.less
@@ -447,3 +447,11 @@
// Mentions
// ---
@mentions-dropdown-bg: @popover-background;
+
+// Segmented
+// ---
+@segmented-bg: fade(@black, 25%);
+@segmented-hover-bg: fade(@black, 45%);
+@segmented-selected-bg: #333333;
+@segmented-label-color: fade(@white, 65%);
+@segmented-label-hover-color: fade(@white, 85%);
diff --git a/components/style/themes/default.less b/components/style/themes/default.less
index a7dbc8fcc3..0c641b975e 100644
--- a/components/style/themes/default.less
+++ b/components/style/themes/default.less
@@ -1070,3 +1070,11 @@
@image-preview-operation-size: 18px;
@image-preview-operation-color: @text-color-dark;
@image-preview-operation-disabled-color: fade(@image-preview-operation-color, 25%);
+
+// Segmented
+// ---
+@segmented-bg: fade(@black, 4%);
+@segmented-hover-bg: fade(@black, 6%);
+@segmented-selected-bg: @white;
+@segmented-label-color: fade(@black, 65%);
+@segmented-label-hover-color: #262626;
diff --git a/components/style/themes/variable.less b/components/style/themes/variable.less
index 6797fa34d8..dd2c52dbb9 100644
--- a/components/style/themes/variable.less
+++ b/components/style/themes/variable.less
@@ -1125,3 +1125,11 @@ html {
@image-preview-operation-size: 18px;
@image-preview-operation-color: @text-color-dark;
@image-preview-operation-disabled-color: fade(@image-preview-operation-color, 25%);
+
+// Segmented
+// ---
+@segmented-bg: fade(@black, 4%);
+@segmented-hover-bg: fade(@black, 6%);
+@segmented-selected-bg: @white;
+@segmented-label-color: fade(@black, 65%);
+@segmented-label-hover-color: #262626;
diff --git a/package.json b/package.json
index f8a1ca96cf..77ac9a0d4e 100644
--- a/package.json
+++ b/package.json
@@ -142,6 +142,7 @@
"rc-progress": "~3.2.1",
"rc-rate": "~2.9.0",
"rc-resize-observer": "^1.2.0",
+ "rc-segmented": "^1.0.0",
"rc-select": "~14.0.2",
"rc-slider": "~10.0.0-alpha.4",
"rc-steps": "~4.1.0",
@@ -316,7 +317,7 @@
},
{
"path": "./dist/antd.variable.min.css",
- "maxSize": "66 kB"
+ "maxSize": "67 kB"
}
],
"tnpm": {
diff --git a/tests/__snapshots__/index.test.js.snap b/tests/__snapshots__/index.test.js.snap
index adc35cb2c1..a1e9ba738e 100644
--- a/tests/__snapshots__/index.test.js.snap
+++ b/tests/__snapshots__/index.test.js.snap
@@ -45,6 +45,7 @@ Array [
"Rate",
"Result",
"Row",
+ "Segmented",
"Select",
"Skeleton",
"Slider",