From 62e7f94abac828643badb8d1d74bfe1d719ee857 Mon Sep 17 00:00:00 2001 From: selicens <1244620067@qq.com> Date: Sat, 18 Feb 2023 16:16:44 +0800 Subject: [PATCH] feat: add segmented (#6286) --- components/components.ts | 4 + components/segmented/__tests__/demo.test.js | 3 + components/segmented/__tests__/index.test.js | 11 + components/segmented/demo/basic.vue | 32 +++ components/segmented/demo/block.vue | 31 +++ components/segmented/demo/controlled.vue | 35 +++ components/segmented/demo/custom.vue | 94 ++++++++ components/segmented/demo/disabled.vue | 44 ++++ components/segmented/demo/dynamic.vue | 40 ++++ components/segmented/demo/icon.vue | 49 +++++ components/segmented/demo/index.vue | 36 ++++ components/segmented/demo/size.vue | 37 ++++ components/segmented/index.en-US.md | 26 +++ components/segmented/index.ts | 10 + components/segmented/index.zh-CN.md | 27 +++ components/segmented/src/index.ts | 5 + components/segmented/src/segmented.tsx | 154 +++++++++++++ components/segmented/style/index.ts | 214 +++++++++++++++++++ components/theme/interface/components.ts | 2 + 19 files changed, 854 insertions(+) create mode 100644 components/segmented/__tests__/demo.test.js create mode 100644 components/segmented/__tests__/index.test.js create mode 100644 components/segmented/demo/basic.vue create mode 100644 components/segmented/demo/block.vue create mode 100644 components/segmented/demo/controlled.vue create mode 100644 components/segmented/demo/custom.vue create mode 100644 components/segmented/demo/disabled.vue create mode 100644 components/segmented/demo/dynamic.vue create mode 100644 components/segmented/demo/icon.vue create mode 100644 components/segmented/demo/index.vue create mode 100644 components/segmented/demo/size.vue create mode 100644 components/segmented/index.en-US.md create mode 100644 components/segmented/index.ts create mode 100644 components/segmented/index.zh-CN.md create mode 100644 components/segmented/src/index.ts create mode 100644 components/segmented/src/segmented.tsx create mode 100644 components/segmented/style/index.ts diff --git a/components/components.ts b/components/components.ts index 020c5add2..dd4c8c553 100644 --- a/components/components.ts +++ b/components/components.ts @@ -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'; diff --git a/components/segmented/__tests__/demo.test.js b/components/segmented/__tests__/demo.test.js new file mode 100644 index 000000000..6a3124c88 --- /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__/index.test.js b/components/segmented/__tests__/index.test.js new file mode 100644 index 000000000..161bbefb2 --- /dev/null +++ b/components/segmented/__tests__/index.test.js @@ -0,0 +1,11 @@ +import { mount } from '@vue/test-utils'; +import Segmented from '../index'; +describe('Segmented', () => { + const wrapper = mount({ + render() { + return ; + }, + }); + const todo = wrapper.get('[options="[1,2,3,4,5]"]'); + expect(todo.text()).toBe('segmented'); +}); diff --git a/components/segmented/demo/basic.vue b/components/segmented/demo/basic.vue new file mode 100644 index 000000000..229460011 --- /dev/null +++ b/components/segmented/demo/basic.vue @@ -0,0 +1,32 @@ + +--- +order: 0 +title: + zh-CN: 基本用法 + en-US: Basic Usage +--- + +## zh-CN + +最简单的用法。 + +## en-US +The most basic usage. + + + + + diff --git a/components/segmented/demo/block.vue b/components/segmented/demo/block.vue new file mode 100644 index 000000000..4a94c9e9e --- /dev/null +++ b/components/segmented/demo/block.vue @@ -0,0 +1,31 @@ + +--- +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. + + + + diff --git a/components/segmented/demo/controlled.vue b/components/segmented/demo/controlled.vue new file mode 100644 index 000000000..602315048 --- /dev/null +++ b/components/segmented/demo/controlled.vue @@ -0,0 +1,35 @@ + +--- +order: 4 +title: + zh-CN: 受控模式 + en-US: Controlled mode +--- + +## zh-CN + +受控的 Segmented + +## en-US +Controlled Segmented. + + + + diff --git a/components/segmented/demo/custom.vue b/components/segmented/demo/custom.vue new file mode 100644 index 000000000..09165d99d --- /dev/null +++ b/components/segmented/demo/custom.vue @@ -0,0 +1,94 @@ + +--- +order: 5 +title: + zh-CN: 自定义渲染 + en-US: Custom +--- + +## zh-CN + +自定义渲染每一个 Segmented Item。 + +## en-US +Custom each Segmented Item. + + + + diff --git a/components/segmented/demo/disabled.vue b/components/segmented/demo/disabled.vue new file mode 100644 index 000000000..ac7ed75cc --- /dev/null +++ b/components/segmented/demo/disabled.vue @@ -0,0 +1,44 @@ + +--- +order: 3 +title: + zh-CN: 不可用 + en-US: Disabled +--- + +## zh-CN + +Segmented 不可用。 + +## en-US +Disabled Segmented. + + + + diff --git a/components/segmented/demo/dynamic.vue b/components/segmented/demo/dynamic.vue new file mode 100644 index 000000000..b888fa3d2 --- /dev/null +++ b/components/segmented/demo/dynamic.vue @@ -0,0 +1,40 @@ + +--- +order: 6 +title: + zh-CN: 动态数据 + en-US: Dynamic +--- + +## zh-CN + +动态加载数据。 + +## en-US +Load dynamically. + + + + diff --git a/components/segmented/demo/icon.vue b/components/segmented/demo/icon.vue new file mode 100644 index 000000000..b48685158 --- /dev/null +++ b/components/segmented/demo/icon.vue @@ -0,0 +1,49 @@ + +--- +order: 7 +title: + zh-CN: 设置图标 + en-US: With Icon +--- + +## zh-CN + +给 Segmented Item 设置 Icon。 + +## en-US +Set `icon` for Segmented Item. + + + + diff --git a/components/segmented/demo/index.vue b/components/segmented/demo/index.vue new file mode 100644 index 000000000..a43e7e60c --- /dev/null +++ b/components/segmented/demo/index.vue @@ -0,0 +1,36 @@ + + + diff --git a/components/segmented/demo/size.vue b/components/segmented/demo/size.vue new file mode 100644 index 000000000..b22197f1a --- /dev/null +++ b/components/segmented/demo/size.vue @@ -0,0 +1,37 @@ + +--- +order: 6 +title: + zh-CN: 三种大小 + en-US: Three sizes of Segmented +--- + +## zh-CN + +我们为 `` 组件定义了三种尺寸(大、默认、小),高度分别为 `40px`、`32px` 和 `24px`。 + +## en-US +There are three sizes of an a-segmented: `large` (40px), `default` (32px) and `small` (24px). + + + + diff --git a/components/segmented/index.en-US.md b/components/segmented/index.en-US.md new file mode 100644 index 000000000..9a22d29f8 --- /dev/null +++ b/components/segmented/index.en-US.md @@ -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 | | | diff --git a/components/segmented/index.ts b/components/segmented/index.ts new file mode 100644 index 000000000..02d21bc86 --- /dev/null +++ b/components/segmented/index.ts @@ -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 }; diff --git a/components/segmented/index.zh-CN.md b/components/segmented/index.zh-CN.md new file mode 100644 index 000000000..70faa863a --- /dev/null +++ b/components/segmented/index.zh-CN.md @@ -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 | | | diff --git a/components/segmented/src/index.ts b/components/segmented/src/index.ts new file mode 100644 index 000000000..4c8b46ef8 --- /dev/null +++ b/components/segmented/src/index.ts @@ -0,0 +1,5 @@ +import Segmented from './segmented'; +import type { SegmentedProps } from './segmented'; + +export type { SegmentedProps }; +export default Segmented; diff --git a/components/segmented/src/segmented.tsx b/components/segmented/src/segmented.tsx new file mode 100644 index 000000000..3ce995877 --- /dev/null +++ b/components/segmented/src/segmented.tsx @@ -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> }, + defaultValue: { type: [Number, String] }, + block: Boolean, + disabled: Boolean, + size: { type: String as PropType }, + }; +}; +export type SegmentedProps = Partial>>; +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 ; + }; + 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 ? ( + {slots.icon?.(index)} + ) : ( + '' + ); + }; + const itemNode = (item, index) => { + if (title) { + return
{slots.title?.(index)}
; + } + return {item.value}; + }; + 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 ? ( +
+ ) : ( + '' + ); + }; + return () => { + return wrapSSR( +
+
+ {thumbNode()} + {options.value.map((item, index) => { + return ( + + ); + })} +
+
, + ); + }; + }, +}); diff --git a/components/segmented/style/index.ts b/components/segmented/style/index.ts new file mode 100644 index 000000000..cd0fba445 --- /dev/null +++ b/components/segmented/style/index.ts @@ -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 = (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(token, { + segmentedPaddingHorizontal: token.controlPaddingHorizontal - lineWidth, + segmentedPaddingHorizontalSM: token.controlPaddingHorizontalSM - lineWidth, + segmentedContainerPadding: lineWidthBold, + labelColor: colorTextLabel, + labelColorHover: colorText, + bgColor: colorBgLayout, + bgColorHover: colorFillSecondary, + bgColorSelected: colorBgElevated, + }); + return [genSharedSegmentedStyle(segmentedToken)]; +}); diff --git a/components/theme/interface/components.ts b/components/theme/interface/components.ts index b388ec4a1..ce9cd3fdb 100644 --- a/components/theme/interface/components.ts +++ b/components/theme/interface/components.ts @@ -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; }