diff --git a/config/components.ts b/config/components.ts index 534e13e71a..aa725f712f 100644 --- a/config/components.ts +++ b/config/components.ts @@ -27,6 +27,7 @@ export const components = { '/components/infinite-scroll', '/components/list', '/components/page-indicator', + '/components/segmented', '/components/steps', '/components/swiper', '/components/tag', diff --git a/package.json b/package.json index 3cb741bc95..fea125a993 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "deepmerge": "^4.3.1", "nano-memoize": "^3.0.16", "rc-field-form": "^1.34.2", + "rc-segmented": "~2.4.1", "rc-util": "^5.38.1", "react-fast-compare": "^3.2.2", "react-is": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7606a7e099..cb9d513ec1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ dependencies: rc-field-form: specifier: ^1.34.2 version: 1.34.2(react-dom@18.2.0)(react@18.2.0) + rc-segmented: + specifier: ~2.4.1 + version: 2.4.1(react-dom@18.2.0)(react@18.2.0) rc-util: specifier: ^5.38.1 version: 5.38.1(react-dom@18.2.0)(react@18.2.0) @@ -15338,7 +15341,6 @@ packages: rc-util: 5.38.1(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - dev: true /rc-overflow@1.3.2(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-nsUm78jkYAoPygDAcGZeC2VwIg/IBGSodtOY3pMof4W3M9qRJgqaDYm03ZayHlde3I6ipliAxbN0RUcGf5KOzw==} @@ -15368,6 +15370,20 @@ packages: resize-observer-polyfill: 1.5.1 dev: true + /rc-segmented@2.4.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-KUi+JJFdKnumV9iXlm+BJ00O4NdVBp2TEexLCk6bK1x/RH83TvYKQMzIz/7m3UTRPD08RM/8VG/JNjWgWbd4cw==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + dependencies: + '@babel/runtime': 7.22.15 + classnames: 2.3.2 + rc-motion: 2.9.0(react-dom@18.2.0)(react@18.2.0) + rc-util: 5.38.1(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /rc-tabs@11.16.1(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-bR7Dap23YyfzZQwtKomhiFEFzZuE7WaKWo+ypNRSGB9PDKSc6tM12VP8LWYkvmmQHthgwP0WRN8nFbSJWuqLYw==} engines: {node: '>=8.x'} diff --git a/src/components/segmented/demos/demo1.tsx b/src/components/segmented/demos/demo1.tsx new file mode 100644 index 0000000000..790f8d6bde --- /dev/null +++ b/src/components/segmented/demos/demo1.tsx @@ -0,0 +1,174 @@ +import React, { useState } from 'react' +import { Avatar, Button, Segmented } from 'antd-mobile' +import { AppOutline, UnorderedListOutline } from 'antd-mobile-icons' +import { DemoBlock } from 'demos' + +const defaultOptions = ['Daily', 'Weekly', 'Monthly'] + +export default () => { + const [value, setValue] = useState('Map') + + const [options, setOptions] = useState(defaultOptions) + const [moreLoaded, setMoreLoaded] = useState(false) + + const handleLoadOptions = () => { + setOptions([...defaultOptions, 'Quarterly', 'Yearly']) + setMoreLoaded(true) + } + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + , + }, + { + label: 'Kanban', + value: 'Kanban', + icon: , + }, + ]} + /> + + + + , + }, + { + value: 'Kanban', + icon: , + }, + ]} + /> + + + + + +
User 1
+ + ), + value: 'user1', + }, + { + label: ( +
+ +
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', + }, + ]} + /> +
+ + ) +} diff --git a/src/components/segmented/index.en.md b/src/components/segmented/index.en.md new file mode 100644 index 0000000000..807f53796b --- /dev/null +++ b/src/components/segmented/index.en.md @@ -0,0 +1,47 @@ +# Segmented + +This component is available since `antd-mobile@5.38.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. + +## Demos + + + +## Segmented + +### Props + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| 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(value: string \| number) | | +| options | Set children optional | string\[] \| number\[] \| SegmentedItemType\[] | [] | +| value | Currently selected value | string \| number | | + +### SegmentedItemType + +| Property | Description | Type | Default | +| --------- | -------------------------------- | ---------------- | ------- | +| label | Display text for Segmented item | ReactNode | - | +| value | Value for Segmented item | string \| number | - | +| icon | Display icon for Segmented item | ReactNode | - | +| disabled | Disabled state of segmented item | boolean | false | +| className | The additional css class | string | - | + +### CSS Variables + +| Name | Description | Default | +| --- | --- | --- | +| Name | Description | Default | +| ------------------- | ---------------------- | ----------------------- | +| --segmented-background | Background color | `var(--adm-color-fill-content)` | +| --segmented-item-color | Text color of segment item | `var(--adm-color-text-secondary)` | +| --segmented-item-selected-background | Background color of selected segment | `var(--adm-color-background)` | +| --segmented-item-selected-color | Text color of selected segment item | `var(--adm-color-text)` | +| --segmented-item-disabled-color | Text color of disabled segment item | `var(--adm-color-weak)` | diff --git a/src/components/segmented/index.ts b/src/components/segmented/index.ts new file mode 100644 index 0000000000..35d6b0bf72 --- /dev/null +++ b/src/components/segmented/index.ts @@ -0,0 +1,6 @@ +import './segmented.less' +import { Segmented } from './segmented' + +export type { SegmentedProps, SegmentedValue } from './segmented' + +export default Segmented diff --git a/src/components/segmented/index.zh.md b/src/components/segmented/index.zh.md new file mode 100644 index 0000000000..3a0474391b --- /dev/null +++ b/src/components/segmented/index.zh.md @@ -0,0 +1,45 @@ +# Segmented 分段控制器 + +分段控制器。自 `antd-mobile@5.38.0` 版本开始提供该组件。 + +## 何时使用 + +- 用于展示多个选项并允许用户选择其中单个选项; +- 当切换选中选项时,关联区域的内容会发生变化。 + +## 示例 + + + +## Segmented + +### 属性 + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| block | 将宽度调整为父元素宽度的选项 | boolean | false | +| defaultValue | 默认选中的值 | string \| number | | +| disabled | 是否禁用 | boolean | false | +| onChange | 选项变化时的回调函数 | function(value: string \| number) | | +| options | 数据化配置选项内容 | string\[] \| number\[] \| SegmentedItemType\[] | [] | +| value | 当前选中的值 | string \| number | | + +### SegmentedItemType + +| 属性 | 描述 | 类型 | 默认值 | +| --------- | ---------------- | ---------------- | ------ | +| label | 分段项的显示文本 | ReactNode | - | +| value | 分段项的值 | string \| number | - | +| icon | 分段项的显示图标 | ReactNode | - | +| disabled | 分段项的禁用状态 | boolean | false | +| className | 自定义类名 | string | - | + +### CSS 变量 + +| 属性 | 说明 | 默认值 | +| --- | --- | --- | +| --segmented-background | 背景色 | `var(--adm-color-fill-content)` | +| --segmented-item-color | 分段项的文本颜色 | `var(--adm-color-text-secondary)` | +| --segmented-item-selected-background | 被选中分段的背景色 | `var(--adm-color-background)` | +| --segmented-item-selected-color | 被选中分段项的文本颜色 | `var(--adm-color-text)` | +| --segmented-item-disabled-color | 禁用分段项的文本颜色 | `var(--adm-color-weak)` | diff --git a/src/components/segmented/segmented.less b/src/components/segmented/segmented.less new file mode 100644 index 0000000000..64ea95e0ce --- /dev/null +++ b/src/components/segmented/segmented.less @@ -0,0 +1,119 @@ +@class-prefix-segmented: ~'adm-segmented'; + +.@{class-prefix-segmented} { + --segmented-background: var(--adm-color-fill-content); + --segmented-item-color: var(--adm-color-text-secondary); + --segmented-item-selected-background: var(--adm-color-background); + --segmented-item-selected-color: var(--adm-color-text); + --segmented-item-disabled-color: var(--adm-color-weak); + --transition-time-function: cubic-bezier(0.645, 0.045, 0.355, 1); + + display: inline-block; + padding: 2px; + color: var(--segmented-item-color); + background-color: var(--segmented-background); + border-radius: 2px; + transition: all 0.3s var(--transition-time-function); + + &-group { + position: relative; + display: flex; + align-items: stretch; + justify-items: flex-start; + width: 100%; + } + + // 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 var(--transition-time-function); + + &-selected { + .segmented-item-selected(); + color: var(--segmented-item-selected-color); + } + + &-label { + min-height: 10px; + padding: 0 11px; + line-height: 28px; + .segmented-text-ellipsis(); + } + + // syntactic sugar to add `icon` for Segmented Item + &-icon + * { + margin-left: 6px; + } + + &-input { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; + } + } + + // disabled styles + &-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 `appear-active` + &-thumb-motion-appear-active { + transition: + transform 0.3s var(--transition-time-function), + width 0.3s var(--transition-time-function); + will-change: transform, width; + } +} + +/* ---- mixins part starts ---- */ +.segmented-disabled-item { + color: var(--segmented-item-disabled-color); + cursor: not-allowed; +} + +.segmented-item-selected { + background-color: var(--segmented-item-selected-background); + border-radius: 2px; + box-shadow: + 0 2px 8px -2px fade(#000, 5%), + 0 1px 4px -1px fade(#000, 7%), + 0 0 1px 0 fade(#000, 8%); +} + +.segmented-text-ellipsis { + overflow: hidden; + // handle text ellipsis + white-space: nowrap; + text-overflow: ellipsis; + word-break: keep-all; +} +/* ---- mixins part ends ---- */ diff --git a/src/components/segmented/segmented.tsx b/src/components/segmented/segmented.tsx new file mode 100644 index 0000000000..9e929d4276 --- /dev/null +++ b/src/components/segmented/segmented.tsx @@ -0,0 +1,109 @@ +import classNames from 'classnames' +import type { + SegmentedLabeledOption as RcSegmentedLabeledOption, + SegmentedProps as RCSegmentedProps, + SegmentedRawOption, +} from 'rc-segmented' +import RcSegmented from 'rc-segmented' +import * as React from 'react' + +import { NativeProps, withNativeProps } from '../../utils/native-props' + +export type { SegmentedValue } from 'rc-segmented' + +interface SegmentedLabeledOptionWithoutIcon extends RcSegmentedLabeledOption { + label: RcSegmentedLabeledOption['label'] +} + +interface SegmentedLabeledOptionWithIcon + extends Omit { + label?: RcSegmentedLabeledOption['label'] + /** Set icon for Segmented item */ + icon: React.ReactNode +} + +function isSegmentedLabeledOptionWithIcon( + option: + | SegmentedRawOption + | SegmentedLabeledOptionWithIcon + | SegmentedLabeledOptionWithoutIcon +): option is SegmentedLabeledOptionWithIcon { + return ( + typeof option === 'object' && + !!(option as SegmentedLabeledOptionWithIcon)?.icon + ) +} + +export type SegmentedLabeledOption = + | SegmentedLabeledOptionWithIcon + | SegmentedLabeledOptionWithoutIcon + +interface InternalSegmentedProps + extends Omit { + options: (SegmentedRawOption | SegmentedLabeledOption)[] + /** Option to fit width to its parent's width */ + block?: boolean +} + +export type SegmentedProps = InternalSegmentedProps & + NativeProps< + | '--segmented-background' + | '--segmented-item-color' + | '--segmented-item-selected-background' + | '--segmented-item-selected-color' + | '--segmented-item-disabled-color' + > + +const classPrefix = `adm-segmented` + +const Segmented = React.forwardRef( + (props, ref) => { + const { + prefixCls: customizePrefixCls, + className, + block, + options = [], + ...restProps + } = props + + // syntactic sugar to support `icon` for Segmented Item + const extendedOptions = React.useMemo( + () => + options.map(option => { + if (isSegmentedLabeledOptionWithIcon(option)) { + const { icon, label, ...restOption } = option + return { + ...restOption, + label: ( + <> + {icon} + {label && {label}} + + ), + } + } + return option + }), + [options, classPrefix] + ) + + return withNativeProps( + props, + + ) + } +) + +if (process.env.NODE_ENV !== 'production') { + Segmented.displayName = 'Segmented' +} + +export { Segmented } diff --git a/src/components/segmented/tests/__snapshots__/segmented.test.tsx.snap b/src/components/segmented/tests/__snapshots__/segmented.test.tsx.snap new file mode 100644 index 0000000000..a7fb56ffa1 --- /dev/null +++ b/src/components/segmented/tests/__snapshots__/segmented.test.tsx.snap @@ -0,0 +1,797 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`adm-segmented render empty segmented 1`] = ` +
+
+
+`; + +exports[`adm-segmented render label with ReactNode 1`] = ` +
+
+ + + +
+
+`; + +exports[`adm-segmented render segmented ok 1`] = ` +
+
+ + + +
+
+`; + +exports[`adm-segmented render segmented with \`block\` 1`] = ` +
+
+ + + +
+
+`; + +exports[`adm-segmented render segmented with mixed options 1`] = ` +
+
+ + + +
+
+`; + +exports[`adm-segmented render segmented with numeric options 1`] = ` +
+
+ + + + + +
+
+`; + +exports[`adm-segmented render segmented with options null/undefined 1`] = ` +
+
+