feat: add segmented (#6286)

pull/6295/head
selicens 2023-02-18 16:16:44 +08:00 committed by GitHub
parent 47385347ee
commit 62e7f94aba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 854 additions and 0 deletions

View File

@ -244,3 +244,7 @@ export type { UploadProps, UploadListProps, UploadChangeParam, UploadFile } from
export { default as Upload, UploadDragger } from './upload';
export { default as LocaleProvider } from './locale-provider';
export type { SegmentedProps } from './segmented';
export { default as Segmented } from './segmented';

View File

@ -0,0 +1,3 @@
import demoTest from '../../../tests/shared/demoTest';
demoTest('segmented');

View File

@ -0,0 +1,11 @@
import { mount } from '@vue/test-utils';
import Segmented from '../index';
describe('Segmented', () => {
const wrapper = mount({
render() {
return <Segmented></Segmented>;
},
});
const todo = wrapper.get('[options="[1,2,3,4,5]"]');
expect(todo.text()).toBe('segmented');
});

View File

@ -0,0 +1,32 @@
<docs>
---
order: 0
title:
zh-CN: 基本用法
en-US: Basic Usage
---
## zh-CN
最简单的用法
## en-US
The most basic usage.
</docs>
<template>
<a-segmented :options="data" />
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue';
export default defineComponent({
setup() {
const data = reactive(['Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly']);
return {
data,
};
},
});
</script>

View File

@ -0,0 +1,31 @@
<docs>
---
order: 1
title:
zh-CN: Block分段控制器
en-US: Block Segmented
---
## zh-CN
`block` 属性使其适合父元素宽度
## en-US
`block` property will make the `Segmented` fit to its parent width.
</docs>
<template>
<a-segmented block :options="data" />
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue';
export default defineComponent({
setup() {
const data = reactive([123, 456, 'longtext-longtext-longtext-longtext']);
return {
data,
};
},
});
</script>

View File

@ -0,0 +1,35 @@
<docs>
---
order: 4
title:
zh-CN: 受控模式
en-US: Controlled mode
---
## zh-CN
受控的 Segmented
## en-US
Controlled Segmented.
</docs>
<template>
<div>
<a-segmented :options="data" default-value="1" @change="handle" />
</div>
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue';
export default defineComponent({
setup() {
const data = reactive(['Map', 'Transit', 'Satellite']);
const handle = v => console.log(v);
return {
data,
handle,
};
},
});
</script>

View File

@ -0,0 +1,94 @@
<docs>
---
order: 5
title:
zh-CN: 自定义渲染
en-US: Custom
---
## zh-CN
自定义渲染每一个 Segmented Item
## en-US
Custom each Segmented Item.
</docs>
<template>
<a-segmented :options="data">
<template #title="index">
<template v-if="index === 0">
<div style="padding: 4px 4px">
<a-avatar src="https://joeschmoe.io/api/v1/random" />
<div>User 1</div>
</div>
</template>
<template v-if="index === 1">
<div style="padding: 4px 4px">
<a-avatar style="background-color: #f56a00">K</a-avatar>
<div>User 2</div>
</div>
</template>
<template v-if="index === 2">
<div style="padding: 4px 4px">
<a-avatar style="background-color: #1890ff">
<template #icon><UserOutlined /></template>
</a-avatar>
<div>User 3</div>
</div>
</template>
</template>
</a-segmented>
<br />
<br />
<a-segmented :options="options2">
<template #title="index">
<template v-if="index === 0">
<div style="padding: 4px 4px">
<div>Spring</div>
<div>Jan-Mar</div>
</div>
</template>
<template v-if="index === 1">
<div style="padding: 4px 4px">
<div>Summer</div>
<div>Apr-Jun</div>
</div>
</template>
<template v-if="index === 2">
<div style="padding: 4px 4px">
<div>Autumn</div>
<div>Jul-Sept</div>
</div>
</template>
<template v-if="index === 3">
<div style="padding: 4px 4px">
<div>Winter</div>
<div>Oct-Dec</div>
</div>
</template>
</template>
</a-segmented>
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue';
import { UserOutlined } from '@ant-design/icons-vue';
import ASegmented from 'ant-design-vue/es/segmented/src/segmented';
export default defineComponent({
components: { ASegmented, UserOutlined },
setup() {
const data = reactive([{ value: 'user1' }, { value: 'user2' }, { value: 'user3' }]);
const options2 = reactive([
{ value: 'spring' },
{ value: 'summer' },
{ value: 'autumn' },
{ value: 'winter' },
]);
return {
data,
options2,
};
},
});
</script>

View File

@ -0,0 +1,44 @@
<docs>
---
order: 3
title:
zh-CN: 不可用
en-US: Disabled
---
## zh-CN
Segmented 不可用
## en-US
Disabled Segmented.
</docs>
<template>
<div>
<a-segmented disabled :options="data" />
<br />
<br />
<a-segmented :options="data2" />
</div>
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue';
export default defineComponent({
setup() {
const data = reactive(['Map', 'Transit', 'Satellite']);
const data2 = reactive([
'Daily',
{ value: 'Weekly', disabled: true },
'Monthly',
{ value: 'Quarterly', disabled: true },
'Yearly',
]);
return {
data,
data2,
};
},
});
</script>

View File

@ -0,0 +1,40 @@
<docs>
---
order: 6
title:
zh-CN: 动态数据
en-US: Dynamic
---
## zh-CN
动态加载数据
## en-US
Load dynamically.
</docs>
<template>
<a-segmented :options="data"></a-segmented>
<br />
<br />
<a-button type="primary" @click="loadMore" :disabled="isDisabled">Load More</a-button>
</template>
<script lang="ts">
import { defineComponent, reactive, ref } from 'vue';
export default defineComponent({
setup() {
const data = reactive(['Daily', 'Weekly', 'Monthly']);
const isDisabled = ref<boolean>(false);
const loadMore = () => {
data.push(...['Quarterly', 'Yearly']);
isDisabled.value = true;
};
return {
data,
loadMore,
isDisabled,
};
},
});
</script>

View File

@ -0,0 +1,49 @@
<docs>
---
order: 7
title:
zh-CN: 设置图标
en-US: With Icon
---
## zh-CN
Segmented Item 设置 Icon
## en-US
Set `icon` for Segmented Item.
</docs>
<template>
<a-segmented :options="data">
<template #icon="index">
<template v-if="index == 0">
<unordered-list-outlined />
</template>
<template v-if="index == 1">
<appstore-outlined />
</template>
</template>
</a-segmented>
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue';
import { UnorderedListOutlined, AppstoreOutlined } from '@ant-design/icons-vue';
export default defineComponent({
components: { UnorderedListOutlined, AppstoreOutlined },
setup() {
const data = reactive([
{
value: 'List',
},
{
value: 'Kanban',
},
]);
return {
data,
};
},
});
</script>

View File

@ -0,0 +1,36 @@
<template>
<demo-sort>
<basic />
<block />
<disabled />
<controlled />
<custom />
<dynamic />
<size />
<icon />
</demo-sort>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import CN from '../index.zh-CN.md';
import US from '../index.en-US.md';
import Basic from './basic.vue';
import Block from './block.vue';
import Disabled from './disabled.vue';
import Controlled from './controlled.vue';
import Custom from './custom.vue';
import Dynamic from './dynamic.vue';
import Size from './size.vue';
import Icon from './icon.vue';
export default defineComponent({
components: { Icon, Size, Dynamic, Custom, Controlled, Disabled, Block, Basic },
category: 'Components',
subtitle: '分段控制器',
type: 'Data Display',
title: 'Segmented',
CN,
US,
});
</script>

View File

@ -0,0 +1,37 @@
<docs>
---
order: 6
title:
zh-CN: 三种大小
en-US: Three sizes of Segmented
---
## zh-CN
我们为 `<a-segmented />` 组件定义了三种尺寸默认高度分别为 `40px``32px` `24px`
## en-US
There are three sizes of an a-segmented: `large` (40px), `default` (32px) and `small` (24px).
</docs>
<template>
<a-segmented :options="data" size="large" />
<br />
<br />
<a-segmented :options="data" />
<br />
<br />
<a-segmented :options="data" size="small" />
</template>
<script lang="ts">
import { defineComponent, reactive } from 'vue';
export default defineComponent({
setup() {
const data = reactive(['Daily', 'Weekly', 'Monthly', 'Quarterly', 'Yearly']);
return {
data,
};
},
});
</script>

View File

@ -0,0 +1,26 @@
---
category: Components
type: Data Display
title: Segmented
---
Segmented Controls.
### 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
### Segmented
| 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 | |
| change | The callback function that is triggered when the state changes | function(value: string \| number) | | |
| options | Set children optional | string[] \| number[] \| Array<{ value?: string disabled?: boolean }> | [] | |
| size | The size of the Segmented. | `large` \| `middle` \| `small` | - | |
| value | Currently selected value | string \| number | | |

View File

@ -0,0 +1,10 @@
import type { App } from 'vue';
import Segmented from './src';
import type { SegmentedProps } from './src';
Segmented.install = function (app: App) {
app.component(Segmented.name, Segmented);
return app;
};
export default Segmented;
export type { SegmentedProps };

View File

@ -0,0 +1,27 @@
---
category: Components
subtitle: 分段控制器
type: 数据展示
title: Segmented
---
分段控制器。
## 何时使用
- 用于展示多个选项并允许用户选择其中单个选项;
- 当切换选中选项时,关联区域的内容会发生变化。
## API
### Segmented
| 参数 | 说明 | 类型 | 默认值 | 版本 |
| --- | --- | --- | --- | --- |
| block | 将宽度调整为父元素宽度的选项 | boolean | 无 | |
| defaultValue | 默认选中的值 | string \| number | | |
| disabled | 是否禁用 | boolean | false | |
| change | 选项变化时的回调函数 | function(value: string \| number) | | |
| options | 数据化配置选项内容 | string[] \| number[] \| Array<{ value?: string disabled?: boolean }> | [] | |
| size | 控件尺寸 | `large` \| `middle` \| `small` | - | |
| value | 当前选中的值 | string \| number | | |

View File

@ -0,0 +1,5 @@
import Segmented from './segmented';
import type { SegmentedProps } from './segmented';
export type { SegmentedProps };
export default Segmented;

View File

@ -0,0 +1,154 @@
import { defineComponent, ref, toRefs, reactive, watch } from 'vue';
import type { ExtractPropTypes, PropType } from 'vue';
import classNames from '../../_util/classNames';
import useConfigInject from '../../config-provider/hooks/useConfigInject';
import { getPropsSlot, initDefaultProps } from '../../_util/props-util';
import useStyle from '../style';
export type segmentedSize = 'large' | 'small';
export interface SegmentedOptions {
value?: string;
disabled?: boolean;
}
export const segmentedProps = () => {
return {
options: { type: Array as PropType<Array<SegmentedOptions | string | number>> },
defaultValue: { type: [Number, String] },
block: Boolean,
disabled: Boolean,
size: { type: String as PropType<segmentedSize> },
};
};
export type SegmentedProps = Partial<ExtractPropTypes<ReturnType<typeof segmentedProps>>>;
export default defineComponent({
name: 'ASegmented',
inheritAttrs: false,
props: { ...initDefaultProps(segmentedProps(), {}) },
emits: ['change', 'value'],
slots: ['icon', 'title'],
setup(props, { emit, slots }) {
const { prefixCls } = useConfigInject('segmented', props);
const [wrapSSR, hashId] = useStyle(prefixCls);
const pre = prefixCls.value;
const { size } = toRefs(props);
const itemRef = ref([]);
const { options, disabled, defaultValue } = toRefs(props);
const segmentedItemInput = () => {
return <input type="radio" class={`${pre}-item-input`} disabled checked />;
};
const isDisabled = item => {
if (disabled.value || (typeof item == 'object' && item.disabled)) {
return segmentedItemInput();
}
};
const currentItemKey = ref();
currentItemKey.value = defaultValue.value ? defaultValue.value : 0;
const toPX = (value: number) => (value !== undefined ? `${value}px` : undefined);
// or
const thumbShow = ref(true);
const mergedStyle = reactive({
startLeft: '',
startWidth: '',
activeLeft: '',
activeWidth: '',
});
const handleSelectedChange = (item, index) => {
if (disabled.value || item.disabled) return;
currentItemKey.value = index;
emit('change', { value: item, key: index });
};
const icon = getPropsSlot(slots, props, 'icon');
const title = getPropsSlot(slots, props, 'title');
const iconNode = index => {
return icon ? (
<span class={classNames({ [`${pre}-item-icon`]: icon })}>{slots.icon?.(index)}</span>
) : (
''
);
};
const itemNode = (item, index) => {
if (title) {
return <div>{slots.title?.(index)}</div>;
}
return <span>{item.value}</span>;
};
const calcThumbStyle = index => {
return {
left: itemRef.value[index].children[0].offsetParent.offsetLeft,
width: itemRef.value[index].children[0].clientWidth,
};
};
const thumbStyle = reactive({
transform: '',
width: '',
});
const isValueType = item => {
return item instanceof Object ? (item.disabled ? true : false) : false;
};
watch(
() => currentItemKey.value,
(newValue, oldValue) => {
const prev = oldValue ? oldValue : defaultValue.value ? defaultValue.value : 0;
const next = newValue;
const calcPrevStyle = calcThumbStyle(prev);
const calcNextStyle = calcThumbStyle(next);
mergedStyle.startLeft = toPX(calcPrevStyle.left);
mergedStyle.startWidth = toPX(calcPrevStyle.width);
mergedStyle.activeLeft = toPX(calcNextStyle.left);
mergedStyle.activeWidth = toPX(calcNextStyle.width);
if (prev !== next) {
thumbStyle.transform = `translateX(${mergedStyle.activeLeft})`;
thumbStyle.width = `${mergedStyle.activeWidth}`;
}
},
);
const thumbNode = () => {
return thumbShow.value ? (
<div
class={classNames({
[`${pre}-thumb`]: thumbShow.value,
[`${pre}-thumb-motion-appear-active`]: thumbShow.value,
})}
style={thumbStyle}
/>
) : (
''
);
};
return () => {
return wrapSSR(
<div
class={classNames(pre, {
[hashId.value]: true,
[`${pre}-block`]: props.block,
[`${pre}-item-disabled`]: props.disabled,
[`${pre}-lg`]: size.value == 'large',
[`${pre}-sm`]: size.value == 'small',
})}
>
<div class={classNames(`${pre}-group`)}>
{thumbNode()}
{options.value.map((item, index) => {
return (
<label
ref={ref => (itemRef.value[index] = ref)}
class={classNames(`${pre}-item`, {
[`${pre}-item-selected`]: currentItemKey.value == index,
[`${pre}-item-disabled`]: disabled.value || isValueType(item),
})}
onClick={() => handleSelectedChange(item, index)}
>
{isDisabled(item)}
<div class={classNames(`${pre}-item-label`)} key={index}>
{iconNode(index)}
{typeof item == 'object' ? itemNode(item, index) : item}
</div>
</label>
);
})}
</div>
</div>,
);
};
},
});

View File

@ -0,0 +1,214 @@
import type { CSSObject } from '../../_util/cssinjs';
import type { FullToken, GenerateStyle } from '../../theme/internal';
import { genComponentStyleHook, mergeToken } from '../../theme/internal';
import { resetComponent, textEllipsis } from '../../_style';
export interface ComponentToken {}
interface SegmentedToken extends FullToken<'Segmented'> {
segmentedPaddingHorizontal: number;
segmentedPaddingHorizontalSM: number;
segmentedContainerPadding: number;
labelColor: string;
labelColorHover: string;
bgColor: string;
bgColorHover: string;
bgColorSelected: string;
}
// ============================== Mixins ==============================
function segmentedDisabledItem(cls: string, token: SegmentedToken): CSSObject {
return {
[`${cls}, ${cls}:hover, ${cls}:focus`]: {
color: token.colorTextDisabled,
cursor: 'not-allowed',
},
};
}
function getSegmentedItemSelectedStyle(token: SegmentedToken): CSSObject {
return {
backgroundColor: token.bgColorSelected,
boxShadow: token.boxShadow,
};
}
const segmentedTextEllipsisCss: CSSObject = {
overflow: 'hidden',
// handle text ellipsis
...textEllipsis,
};
// ============================== Shared ==============================
const genSharedSegmentedStyle: GenerateStyle<SegmentedToken> = (token): CSSObject => {
const { componentCls } = token;
return {
[componentCls]: {
...resetComponent(token),
display: 'inline-block',
padding: token.segmentedContainerPadding,
color: token.labelColor,
backgroundColor: token.bgColor,
borderRadius: token.borderRadius,
transition: `all ${token.motionDurationMid} ${token.motionEaseInOut}`,
[`${componentCls}-group`]: {
position: 'relative',
display: 'flex',
alignItems: 'stretch',
justifyItems: 'flex-start',
width: '100%',
},
// RTL styles
'&&-rtl': {
direction: 'rtl',
},
// block styles
'&&-block': {
display: 'flex',
},
[`&&-block ${componentCls}-item`]: {
flex: 1,
minWidth: 0,
},
// item styles
[`${componentCls}-item`]: {
position: 'relative',
textAlign: 'center',
cursor: 'pointer',
transition: `color ${token.motionDurationMid} ${token.motionEaseInOut}`,
borderRadius: token.borderRadiusSM,
'&-selected': {
...getSegmentedItemSelectedStyle(token),
color: token.labelColorHover,
},
'&::after': {
content: '""',
position: 'absolute',
width: '100%',
height: '100%',
top: 0,
insetInlineStart: 0,
borderRadius: token.borderRadiusSM,
transition: `background-color ${token.motionDurationMid}`,
},
[`&:hover:not(${componentCls}-item-selected):not(${componentCls}-item-disabled)`]: {
color: token.labelColorHover,
'&::after': {
backgroundColor: token.bgColorHover,
},
},
'&-label': {
minHeight: token.controlHeight - token.segmentedContainerPadding * 2,
lineHeight: `${token.controlHeight - token.segmentedContainerPadding * 2}px`,
padding: `0 ${token.segmentedPaddingHorizontal}px`,
...segmentedTextEllipsisCss,
},
// syntactic sugar to add `icon` for Segmented Item
'&-icon + *': {
marginInlineEnd: token.marginSM / 2,
},
'&-input': {
position: 'absolute',
insetBlockStart: 0,
insetInlineStart: 0,
width: 0,
height: 0,
opacity: 0,
pointerEvents: 'none',
},
},
// size styles
'&&-lg': {
borderRadius: token.borderRadiusLG,
[`${componentCls}-item-label`]: {
minHeight: token.controlHeightLG - token.segmentedContainerPadding * 2,
lineHeight: `${token.controlHeightLG - token.segmentedContainerPadding * 2}px`,
padding: `0 ${token.segmentedPaddingHorizontal}px`,
fontSize: token.fontSizeLG,
},
[`${componentCls}-item-selected`]: {
borderRadius: token.borderRadius,
},
},
'&&-sm': {
borderRadius: token.borderRadiusSM,
[`${componentCls}-item-label`]: {
minHeight: token.controlHeightSM - token.segmentedContainerPadding * 2,
lineHeight: `${token.controlHeightSM - token.segmentedContainerPadding * 2}px`,
padding: `0 ${token.segmentedPaddingHorizontalSM}px`,
},
[`${componentCls}-item-selected`]: {
borderRadius: token.borderRadiusXS,
},
},
// disabled styles
...segmentedDisabledItem(`&-disabled ${componentCls}-item`, token),
...segmentedDisabledItem(`${componentCls}-item-disabled`, token),
// thumb styles
[`${componentCls}-thumb`]: {
...getSegmentedItemSelectedStyle(token),
position: 'absolute',
insetBlockStart: 0,
insetInlineStart: 0,
width: 0,
height: '100%',
padding: `${token.paddingXXS}px 0`,
borderRadius: token.borderRadiusSM,
[`& ~ ${componentCls}-item:not(${componentCls}-item-selected):not(${componentCls}-item-disabled)::after`]:
{
backgroundColor: 'transparent',
},
},
// transition effect when `appear-active`
[`${componentCls}-thumb-motion-appear-active`]: {
transition: `transform ${token.motionDurationSlow} ${token.motionEaseInOut}, width ${token.motionDurationSlow} ${token.motionEaseInOut}`,
willChange: 'transform, width',
},
},
};
};
// ============================== Export ==============================
export default genComponentStyleHook('Segmented', token => {
const {
lineWidthBold,
lineWidth,
colorTextLabel,
colorText,
colorFillSecondary,
colorBgLayout,
colorBgElevated,
} = token;
const segmentedToken = mergeToken<SegmentedToken>(token, {
segmentedPaddingHorizontal: token.controlPaddingHorizontal - lineWidth,
segmentedPaddingHorizontalSM: token.controlPaddingHorizontalSM - lineWidth,
segmentedContainerPadding: lineWidthBold,
labelColor: colorTextLabel,
labelColorHover: colorText,
bgColor: colorBgLayout,
bgColorHover: colorFillSecondary,
bgColorSelected: colorBgElevated,
});
return [genSharedSegmentedStyle(segmentedToken)];
});

View File

@ -49,6 +49,7 @@ import type { ComponentToken as UploadComponentToken } from '../../upload/style'
// import type { ComponentToken as QRCodeComponentToken } from '../../qrcode/style';
// import type { ComponentToken as AppComponentToken } from '../../app/style';
// import type { ComponentToken as WaveToken } from '../../_util/wave/style';
import type { ComponentToken as SegmentedComponentToken } from '../../segmented/style';
export interface ComponentTokenMap {
Affix?: {};
@ -118,4 +119,5 @@ export interface ComponentTokenMap {
// /** @private Internal TS definition. Do not use. */
// Wave?: WaveToken;
Segmented?: SegmentedComponentToken;
}