├── .dumirc.ts ├── .editorconfig ├── .eslintrc.js ├── .fatherrc.ts ├── .github ├── dependabot.yml └── workflows │ ├── main.yml │ └── preview.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── assets ├── index.less └── style.less ├── docs ├── demo │ ├── basic.tsx │ ├── controlled.tsx │ ├── custom-render.tsx │ ├── dynamic.tsx │ ├── html-title.tsx │ ├── name.tsx │ ├── refs.tsx │ └── rtl.tsx ├── example.md └── index.md ├── index.js ├── jest.config.ts ├── package.json ├── src ├── MotionThumb.tsx └── index.tsx ├── tests ├── __snapshots__ │ └── index.test.tsx.snap ├── index.test.tsx ├── setup.ts └── setupFilesAfterEnv.ts ├── tsconfig.json └── type.d.ts /.dumirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | 3 | const basePath = process.env.GH_PAGES ? '/segmented/' : '/'; 4 | const publicPath = process.env.GH_PAGES ? '/segmented/' : '/'; 5 | 6 | export default defineConfig({ 7 | favicons: [ 8 | 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', 9 | ], 10 | themeConfig: { 11 | name: 'Segmented', 12 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4' 13 | }, 14 | outputPath: '.doc', 15 | exportStatic: {}, 16 | base: basePath, 17 | publicPath, 18 | }); 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const base = require('@umijs/fabric/dist/eslint'); 2 | 3 | module.exports = { 4 | ...base, 5 | rules: { 6 | ...base.rules, 7 | 'no-template-curly-in-string': 0, 8 | 'prefer-promise-reject-errors': 0, 9 | 'react/no-array-index-key': 0, 10 | 'react/sort-comp': 0, 11 | '@typescript-eslint/no-explicit-any': 0, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /.fatherrc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | time: '21:00' 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ✅ test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | uses: react-component/rc-test/.github/workflows/test.yml@main 6 | secrets: inherit 7 | -------------------------------------------------------------------------------- /.github/workflows/preview.yml: -------------------------------------------------------------------------------- 1 | name: 🔂 Surge PR Preview 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | preview: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: afc163/surge-preview@v1 11 | id: preview_step 12 | with: 13 | surge_token: ${{ secrets.SURGE_TOKEN }} 14 | github_token: ${{ secrets.GITHUB_TOKEN }} 15 | dist: .doc 16 | build: | 17 | npm install 18 | npm run docs:build 19 | - name: Get the preview_url 20 | run: echo "url => ${{ steps.preview_step.outputs.preview_url }}" 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | *.iml 3 | *.log 4 | .idea/ 5 | .ipr 6 | .iws 7 | *~ 8 | ~* 9 | *.diff 10 | *.patch 11 | *.bak 12 | .DS_Store 13 | Thumbs.db 14 | .project 15 | .*proj 16 | .svn/ 17 | *.swp 18 | *.swo 19 | *.pyc 20 | *.pyo 21 | .build 22 | node_modules 23 | .cache 24 | assets/**/*.css 25 | build 26 | lib 27 | es 28 | yarn.lock 29 | package-lock.json 30 | pnpm-lock.yaml 31 | coverage/ 32 | .doc 33 | 34 | # umi 35 | .umi 36 | .umi-production 37 | .umi-test 38 | .env.local 39 | 40 | # vscode 41 | .vscode 42 | 43 | # dumi 44 | .dumi/tmp 45 | .dumi/tmp-production 46 | 47 | bun.lockb 48 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | build/ 2 | *.cfg 3 | nohup.out 4 | *.iml 5 | .idea/ 6 | .ipr 7 | .iws 8 | *~ 9 | ~* 10 | *.diff 11 | *.log 12 | *.patch 13 | *.bak 14 | .DS_Store 15 | Thumbs.db 16 | .project 17 | .*proj 18 | .svn/ 19 | *.swp 20 | out/ 21 | .build 22 | node_modules 23 | .cache 24 | examples 25 | tests 26 | src 27 | /index.js 28 | .* 29 | assets/**/*.less -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | node_modules 3 | lib 4 | es 5 | .cache 6 | package.json 7 | package-lock.json 8 | public 9 | .site 10 | _site 11 | .umi 12 | .doc 13 | README.md 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-present afc163 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rc-segmented 2 | 3 | [![NPM version][npm-image]][npm-url] [![npm download][download-image]][download-url] [![dumi](https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square)](https://github.com/umijs/dumi) [![build status][github-actions-image]][github-actions-url] [![Codecov][codecov-image]][codecov-url] [![bundle size][bundlephobia-image]][bundlephobia-url] 4 | 5 | [npm-image]: http://img.shields.io/npm/v/rc-segmented.svg?style=flat-square 6 | [npm-url]: http://npmjs.org/package/rc-segmented 7 | [github-actions-image]: https://github.com/react-component/segmented/workflows/CI/badge.svg 8 | [github-actions-url]: https://github.com/react-component/segmented/actions 9 | [codecov-image]: https://codecov.io/gh/react-component/segmented/branch/master/graph/badge.svg 10 | [codecov-url]: https://codecov.io/gh/react-component/segmented/branch/master 11 | [download-image]: https://img.shields.io/npm/dm/rc-segmented.svg?style=flat-square 12 | [download-url]: https://npmjs.org/package/rc-segmented 13 | [bundlephobia-url]: https://bundlephobia.com/package/rc-segmented 14 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-segmented 15 | 16 | React Segmented Control. 17 | 18 | ![](https://gw.alipayobjects.com/mdn/rms_50855f/afts/img/A*bmGGQpnWs0oAAAAAAAAAAAAAARQnAQ) 19 | 20 | ## Live Demo 21 | 22 | https://react-component.github.io/segmented/ 23 | 24 | ## Install 25 | 26 | [![rc-segmented](https://nodei.co/npm/rc-segmented.png)](https://npmjs.org/package/rc-segmented) 27 | 28 | ## Usage 29 | 30 | ```js 31 | import Segmented from 'rc-segmented'; 32 | import 'rc-segmented/assets/index.css'; // import 'rc-segmented/asssets/index.less'; 33 | import { render } from 'react-dom'; 34 | 35 | render( 36 | handleValueChange(value)} 39 | />, 40 | mountNode, 41 | ); 42 | ``` 43 | 44 | ## API 45 | 46 | Please note that **onChange** API 47 | changed on v2.0.0+ 48 | 49 | | Property | Type | Default | Description | 50 | | --------- | --------- | --------- | --------- | 51 | | prefixCls | string | rc-segmented | prefixCls of this component | 52 | | className | string | '' | additional class name of segmented | 53 | | style | React.CSSProperties | | style properties of segmented | 54 | | options | Array | [] | options for choices | 55 | | value | string \| number | | value of segmented | 56 | | defaultValue | string \| number | | defaultValue of segmented | 57 | | value | string \| number | | currently selected value of segmented | 58 | | onChange | (value: string \| number) => void | | defaultValue of segmented | 59 | | disabled | boolean | false | disabled status of segmented | 60 | 61 | ### SegmentedOption 62 | 63 | | Property | Type | Default | Description | 64 | | --------- | --------- | --------- | --------- | 65 | | label | ReactNode | | label of segmented option | 66 | | value | string \| number | | value of segmented option | 67 | | className | string | '' | additional class name of segmented option | 68 | | disabled | boolean | false | disabled status of segmented option | 69 | 70 | ## Development 71 | 72 | ``` 73 | npm install 74 | npm start 75 | ``` 76 | 77 | ## License 78 | 79 | rc-segmented is released under the MIT license. 80 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @segmented-prefix-cls: rc-segmented; 2 | 3 | @disabled-color: fade(#000, 25%); 4 | @selected-bg-color: white; 5 | @text-color: #262626; 6 | @transition-duration: 0.3s; 7 | @transition-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1); 8 | 9 | .segmented-disabled-item() { 10 | &, 11 | &:hover, 12 | &:focus { 13 | color: @disabled-color; 14 | cursor: not-allowed; 15 | } 16 | } 17 | 18 | .segmented-item-selected() { 19 | background-color: @selected-bg-color; 20 | } 21 | 22 | .@{segmented-prefix-cls} { 23 | display: inline-block; 24 | padding: 2px; 25 | background-color: rgba(0, 0, 0, 0.04); 26 | 27 | &-group { 28 | position: relative; 29 | display: flex; 30 | flex-direction: row; 31 | align-items: stretch; 32 | justify-content: flex-start; 33 | width: 100%; 34 | border-radius: 2px; 35 | } 36 | 37 | &-item { 38 | position: relative; 39 | min-height: 28px; 40 | padding: 4px 10px; 41 | color: fade(#000, 85%); 42 | text-align: center; 43 | cursor: pointer; 44 | 45 | &-selected { 46 | .segmented-item-selected(); 47 | color: @text-color; 48 | } 49 | 50 | &:hover, 51 | &:focus { 52 | color: @text-color; 53 | } 54 | 55 | &-disabled { 56 | .segmented-disabled-item(); 57 | } 58 | 59 | &-label { 60 | z-index: 2; 61 | line-height: 24px; 62 | } 63 | 64 | &-input { 65 | position: absolute; 66 | top: 0; 67 | left: 0; 68 | width: 0; 69 | height: 0; 70 | opacity: 0; 71 | pointer-events: none; 72 | } 73 | } 74 | 75 | &-thumb { 76 | .segmented-item-selected(); 77 | position: absolute; 78 | width: 0; 79 | height: 100%; 80 | padding: 4px 0; 81 | transition: transform @transition-duration @transition-timing-function, 82 | width @transition-duration @transition-timing-function; 83 | } 84 | 85 | &-vertical &-group { 86 | flex-direction: column; 87 | } 88 | 89 | &-vertical &-item { 90 | width: 100%; 91 | text-align: left; 92 | } 93 | 94 | &-vertical &-thumb { 95 | width: 100%; 96 | height: 0; 97 | padding: 0 4px; 98 | transition: transform @transition-duration @transition-timing-function, 99 | height @transition-duration @transition-timing-function; 100 | } 101 | 102 | // disabled styles 103 | &-disabled &-item, 104 | &-disabled &-item:hover, 105 | &-disabled &-item:focus { 106 | .segmented-disabled-item(); 107 | } 108 | 109 | &-thumb-motion-appear-active, 110 | &-thumb-motion-enter-active { 111 | transition: transform @transition-duration @transition-timing-function, 112 | width @transition-duration @transition-timing-function; 113 | will-change: transform, width; 114 | } 115 | 116 | &-rtl { 117 | direction: rtl; 118 | } 119 | } 120 | 121 | .rc-segmented-item { 122 | &:focus { 123 | outline: none; 124 | } 125 | 126 | &-focused { 127 | border-radius: 2px; 128 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /assets/style.less: -------------------------------------------------------------------------------- 1 | @import './index.less'; 2 | 3 | // reset 4 | * { 5 | box-sizing: border-box; 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | .wrapper { 11 | margin-bottom: 10px; 12 | } 13 | -------------------------------------------------------------------------------- /docs/demo/basic.tsx: -------------------------------------------------------------------------------- 1 | import Segmented from 'rc-segmented'; 2 | import React from 'react'; 3 | import '../../assets/style.less'; 4 | 5 | export default function App() { 6 | return ( 7 |
8 |
9 | console.log(value, typeof value)} 14 | /> 15 |
16 |
17 | console.log(value, typeof value)} 22 | /> 23 |
24 |
25 | console.log(value, typeof value)} 28 | /> 29 |
30 |
31 | 32 |
33 |
34 | 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /docs/demo/controlled.tsx: -------------------------------------------------------------------------------- 1 | import '../../assets/style.less'; 2 | import React from 'react'; 3 | import Segmented from 'rc-segmented'; 4 | import type { SegmentedValue } from 'rc-segmented'; 5 | 6 | export default class Demo extends React.Component< 7 | unknown, 8 | { value: SegmentedValue } 9 | > { 10 | state = { 11 | value: 'Web3', 12 | }; 13 | 14 | render() { 15 | return ( 16 | 17 | 21 | this.setState({ 22 | value, 23 | }) 24 | } 25 | /> 26 |    27 | 31 | this.setState({ 32 | value, 33 | }) 34 | } 35 | /> 36 | 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /docs/demo/custom-render.tsx: -------------------------------------------------------------------------------- 1 | import '../../assets/style.less'; 2 | import * as React from 'react'; 3 | import Segmented from 'rc-segmented'; 4 | 5 | const options = [ 6 | { 7 | label: ( 8 |
9 | iOS 10 |
11 | 10 12 |
13 | 11 14 |
15 | ), 16 | value: 'iOS', 17 | }, 18 | { label:

Android

, value: 'Android' }, 19 | { 20 | label: ( 21 |
22 | Web 23 |
24 | 345 25 |
26 | ), 27 | value: 'Web', 28 | }, 29 | { label:

Electron

, value: 'Electron', disabled: true }, 30 | // debug usage 31 | // { label: '', value: 'Empty' }, 32 | ]; 33 | 34 | export default () => { 35 | return ( 36 | <> 37 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /docs/demo/dynamic.tsx: -------------------------------------------------------------------------------- 1 | import '../../assets/style.less'; 2 | import * as React from 'react'; 3 | import Segmented from 'rc-segmented'; 4 | 5 | const defaultOptions1 = ['iOS', 'Android', 'Web']; 6 | const defaultOptions2 = [ 7 | { label: 'iOS', value: 'iOS' }, 8 | { label: 'Android', value: 'Android' }, 9 | 'Web', 10 | ]; 11 | 12 | export default () => { 13 | const [options1, setOptions1] = React.useState(defaultOptions1); 14 | const [options2, setOptions2] = React.useState(defaultOptions2); 15 | 16 | const handleLoadOptions1 = () => { 17 | setOptions1((r) => r.concat('Electron', 'Mini App')); 18 | }; 19 | 20 | const handleLoadOptions2 = () => { 21 | setOptions2([ 22 | { label: 'Electron', value: 'Electron' }, 23 | 'Mini App', 24 | ...defaultOptions2.reverse(), 25 | ]); 26 | }; 27 | 28 | return ( 29 | <> 30 |
31 |
32 | 33 |
34 | 35 |
36 |
37 |
38 | 39 |
40 | 41 |
42 |
43 |
44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /docs/demo/html-title.tsx: -------------------------------------------------------------------------------- 1 | import '../../assets/style.less'; 2 | import React from 'react'; 3 | import Segmented from 'rc-segmented'; 4 | 5 | export default function App() { 6 | return ( 7 |
8 |
9 | 10 |
11 |
12 | Web, 25 | value: 'Web', 26 | }, 27 | ]} 28 | /> 29 |
30 |
31 | Web, value: 'Web', title: 'WEB' }, 36 | ]} 37 | /> 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /docs/demo/name.tsx: -------------------------------------------------------------------------------- 1 | import Segmented from 'rc-segmented'; 2 | import React from 'react'; 3 | import '../../assets/style.less'; 4 | 5 | export default function App() { 6 | return ( 7 |
8 |
9 | console.log(value, typeof value)} 13 | /> 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /docs/demo/refs.tsx: -------------------------------------------------------------------------------- 1 | import '../../assets/style.less'; 2 | import React from 'react'; 3 | import Segmented from 'rc-segmented'; 4 | 5 | class ClassComponentWithStringRef extends React.Component { 6 | componentDidMount() { 7 | // eslint-disable-next-line react/no-string-refs 8 | console.log(this.refs.segmentedRef, 'ref'); 9 | } 10 | 11 | render() { 12 | return ( 13 | 17 | ); 18 | } 19 | } 20 | 21 | class ClassComponent2 extends React.Component { 22 | segmentedRef: HTMLDivElement | null = null; 23 | 24 | componentDidMount() { 25 | console.log(this.segmentedRef, 'ref'); 26 | } 27 | 28 | render() { 29 | return ( 30 | (this.segmentedRef = ref)} 33 | /> 34 | ); 35 | } 36 | } 37 | 38 | class ClassComponentWithCreateRef extends React.Component< 39 | Record, 40 | Record 41 | > { 42 | segmentedRef = React.createRef(); 43 | 44 | componentDidMount() { 45 | console.log(this.segmentedRef.current, 'ref'); 46 | } 47 | 48 | render() { 49 | return ( 50 | 51 | ); 52 | } 53 | } 54 | 55 | function FunctionalComponent() { 56 | const segmentedRef = React.useRef(null); 57 | React.useEffect(() => { 58 | console.log(segmentedRef.current, 'ref'); 59 | }, []); 60 | return ; 61 | } 62 | 63 | export default function App() { 64 | return ( 65 |
66 |
67 | 68 |
69 |
70 | 71 |
72 |
73 | 74 |
75 |
76 | 77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /docs/demo/rtl.tsx: -------------------------------------------------------------------------------- 1 | import '../../assets/style.less'; 2 | import React, { useState } from 'react'; 3 | import Segmented from 'rc-segmented'; 4 | 5 | export default function App() { 6 | const [direction, setDirection] = useState<'rtl' | 'ltr'>('rtl'); 7 | return ( 8 |
9 | 20 | 30 |

35 | console.log(value, typeof value)} 38 | direction={direction} 39 | /> 40 |

41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /docs/example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Example 3 | nav: 4 | title: Example 5 | path: /example 6 | --- 7 | 8 | ## basic 9 | 10 | 11 | 12 | ## controlled 13 | 14 | 15 | 16 | ## custom-render 17 | 18 | 19 | 20 | ## dynamic 21 | 22 | 23 | 24 | ## html-title 25 | 26 | 27 | 28 | ## refs 29 | 30 | 31 | 32 | ## rtl 33 | 34 | 35 | 36 | ## name 37 | 38 | 39 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: rc-segmented 4 | description: React segmented controls used in ant.design 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/'); 2 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { createConfig, type Config } from '@umijs/test'; 2 | 3 | const defaultConfig = createConfig({ 4 | target: 'browser', 5 | }); 6 | 7 | const config: Config.InitialOptions = { 8 | ...defaultConfig, 9 | setupFiles: [ 10 | ...(defaultConfig.setupFiles || []), 11 | './tests/setup.ts' 12 | ], 13 | setupFilesAfterEnv: [ 14 | ...(defaultConfig.setupFilesAfterEnv || []), 15 | './tests/setupFilesAfterEnv.ts' 16 | ], 17 | } 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rc-component/segmented", 3 | "version": "1.2.1", 4 | "description": "React segmented controls used in ant.design", 5 | "keywords": [ 6 | "react", 7 | "react-component", 8 | "react-segmented", 9 | "react-segmented-controls", 10 | "segmented controls", 11 | "antd", 12 | "ant-design" 13 | ], 14 | "main": "./lib/index", 15 | "module": "./es/index", 16 | "types": "./es/index.d.ts", 17 | "files": [ 18 | "assets/*.css", 19 | "assets/*.less", 20 | "es", 21 | "lib", 22 | "dist" 23 | ], 24 | "homepage": "https://react-component.github.io/segmented", 25 | "repository": { 26 | "type": "git", 27 | "url": "git@github.com:react-component/segmented.git" 28 | }, 29 | "bugs": { 30 | "url": "http://github.com/react-component/segmented/issues" 31 | }, 32 | "license": "MIT", 33 | "scripts": { 34 | "start": "dumi dev", 35 | "type:check": "tsc --noEmit", 36 | "docs:build": "dumi build", 37 | "docs:deploy": "gh-pages -d .doc", 38 | "compile": "father build && lessc assets/index.less assets/index.css", 39 | "gh-pages": "GH_PAGES=1 npm run docs:build && npm run docs:deploy", 40 | "prepublishOnly": "npm run compile && rc-np", 41 | "lint": "eslint src/ --ext .ts,.tsx,.jsx,.js,.md", 42 | "prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", 43 | "pretty-quick": "pretty-quick", 44 | "test": "jest", 45 | "coverage": "jest --coverage", 46 | "prepare": "husky install" 47 | }, 48 | "dependencies": { 49 | "@babel/runtime": "^7.11.1", 50 | "classnames": "^2.2.1", 51 | "rc-motion": "^2.4.4", 52 | "@rc-component/util": "^1.1.0" 53 | }, 54 | "devDependencies": { 55 | "@rc-component/father-plugin": "^2.0.1", 56 | "@rc-component/np": "^1.0.0", 57 | "@testing-library/jest-dom": "^5.16.5", 58 | "@testing-library/react": "^14.2.1", 59 | "@testing-library/user-event": "^14.5.2", 60 | "@types/classnames": "^2.2.9", 61 | "@types/jest": "^29.2.4", 62 | "@types/react": "^18.3.11", 63 | "@types/react-dom": "^18.3.1", 64 | "@umijs/fabric": "^3.0.0", 65 | "@umijs/test": "^4.0.36", 66 | "coveralls": "^3.0.6", 67 | "cross-env": "^7.0.3", 68 | "cssstyle": "^2.3.0", 69 | "dumi": "^2.1.2", 70 | "eslint": "^7.0.0", 71 | "father": "^4.1.1", 72 | "gh-pages": "^3.1.0", 73 | "husky": "^8.0.0", 74 | "jest": "^29.3.1", 75 | "jest-environment-jsdom": "^29.3.1", 76 | "less": "^3.10.3", 77 | "prettier": "^2.0.5", 78 | "pretty-quick": "^3.0.0", 79 | "react": "^18.0.0", 80 | "react-dom": "^18.0.0", 81 | "ts-node": "^10.9.1", 82 | "typescript": "^5.3.0" 83 | }, 84 | "peerDependencies": { 85 | "react": ">=16.0.0", 86 | "react-dom": ">=16.0.0" 87 | }, 88 | "cnpm": { 89 | "mode": "npm" 90 | }, 91 | "tnpm": { 92 | "mode": "npm" 93 | } 94 | } -------------------------------------------------------------------------------- /src/MotionThumb.tsx: -------------------------------------------------------------------------------- 1 | import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect'; 2 | import { composeRef } from '@rc-component/util/lib/ref'; 3 | import classNames from 'classnames'; 4 | import CSSMotion from 'rc-motion'; 5 | import * as React from 'react'; 6 | import type { SegmentedValue } from '.'; 7 | 8 | type ThumbReact = { 9 | left: number; 10 | right: number; 11 | width: number; 12 | top: number; 13 | bottom: number; 14 | height: number; 15 | } | null; 16 | 17 | export interface MotionThumbInterface { 18 | containerRef: React.RefObject; 19 | value: SegmentedValue; 20 | getValueIndex: (value: SegmentedValue) => number; 21 | prefixCls: string; 22 | motionName: string; 23 | onMotionStart: VoidFunction; 24 | onMotionEnd: VoidFunction; 25 | direction?: 'ltr' | 'rtl'; 26 | vertical?: boolean; 27 | } 28 | 29 | const calcThumbStyle = ( 30 | targetElement: HTMLElement | null | undefined, 31 | vertical?: boolean, 32 | ): ThumbReact => { 33 | if (!targetElement) return null; 34 | 35 | const style: ThumbReact = { 36 | left: targetElement.offsetLeft, 37 | right: 38 | (targetElement.parentElement!.clientWidth as number) - 39 | targetElement.clientWidth - 40 | targetElement.offsetLeft, 41 | width: targetElement.clientWidth, 42 | top: targetElement.offsetTop, 43 | bottom: 44 | (targetElement.parentElement!.clientHeight as number) - 45 | targetElement.clientHeight - 46 | targetElement.offsetTop, 47 | height: targetElement.clientHeight, 48 | }; 49 | 50 | if (vertical) { 51 | // Adjusts positioning and size for vertical layout by setting horizontal properties to 0 and using vertical properties from the style object. 52 | return { 53 | left: 0, 54 | right: 0, 55 | width: 0, 56 | top: style.top, 57 | bottom: style.bottom, 58 | height: style.height, 59 | }; 60 | } 61 | 62 | return { 63 | left: style.left, 64 | right: style.right, 65 | width: style.width, 66 | top: 0, 67 | bottom: 0, 68 | height: 0, 69 | }; 70 | }; 71 | 72 | const toPX = (value: number | undefined): string | undefined => 73 | value !== undefined ? `${value}px` : undefined; 74 | 75 | export default function MotionThumb(props: MotionThumbInterface) { 76 | const { 77 | prefixCls, 78 | containerRef, 79 | value, 80 | getValueIndex, 81 | motionName, 82 | onMotionStart, 83 | onMotionEnd, 84 | direction, 85 | vertical = false, 86 | } = props; 87 | 88 | const thumbRef = React.useRef(null); 89 | const [prevValue, setPrevValue] = React.useState(value); 90 | 91 | // =========================== Effect =========================== 92 | const findValueElement = (val: SegmentedValue) => { 93 | const index = getValueIndex(val); 94 | const ele = containerRef.current?.querySelectorAll( 95 | `.${prefixCls}-item`, 96 | )[index]; 97 | return ele?.offsetParent && ele; 98 | }; 99 | 100 | const [prevStyle, setPrevStyle] = React.useState(null); 101 | const [nextStyle, setNextStyle] = React.useState(null); 102 | 103 | useLayoutEffect(() => { 104 | if (prevValue !== value) { 105 | const prev = findValueElement(prevValue); 106 | const next = findValueElement(value); 107 | 108 | const calcPrevStyle = calcThumbStyle(prev, vertical); 109 | const calcNextStyle = calcThumbStyle(next, vertical); 110 | 111 | setPrevValue(value); 112 | setPrevStyle(calcPrevStyle); 113 | setNextStyle(calcNextStyle); 114 | 115 | if (prev && next) { 116 | onMotionStart(); 117 | } else { 118 | onMotionEnd(); 119 | } 120 | } 121 | }, [value]); 122 | 123 | const thumbStart = React.useMemo(() => { 124 | if (vertical) { 125 | return toPX(prevStyle?.top ?? 0); 126 | } 127 | 128 | if (direction === 'rtl') { 129 | return toPX(-(prevStyle?.right as number)); 130 | } 131 | 132 | return toPX(prevStyle?.left as number); 133 | }, [vertical, direction, prevStyle]); 134 | 135 | const thumbActive = React.useMemo(() => { 136 | if (vertical) { 137 | return toPX(nextStyle?.top ?? 0); 138 | } 139 | 140 | if (direction === 'rtl') { 141 | return toPX(-(nextStyle?.right as number)); 142 | } 143 | 144 | return toPX(nextStyle?.left as number); 145 | }, [vertical, direction, nextStyle]); 146 | 147 | // =========================== Motion =========================== 148 | const onAppearStart = () => { 149 | if (vertical) { 150 | return { 151 | transform: 'translateY(var(--thumb-start-top))', 152 | height: 'var(--thumb-start-height)', 153 | }; 154 | } 155 | 156 | return { 157 | transform: 'translateX(var(--thumb-start-left))', 158 | width: 'var(--thumb-start-width)', 159 | }; 160 | }; 161 | 162 | const onAppearActive = () => { 163 | if (vertical) { 164 | return { 165 | transform: 'translateY(var(--thumb-active-top))', 166 | height: 'var(--thumb-active-height)', 167 | }; 168 | } 169 | 170 | return { 171 | transform: 'translateX(var(--thumb-active-left))', 172 | width: 'var(--thumb-active-width)', 173 | }; 174 | }; 175 | 176 | const onVisibleChanged = () => { 177 | setPrevStyle(null); 178 | setNextStyle(null); 179 | onMotionEnd(); 180 | }; 181 | 182 | // =========================== Render =========================== 183 | // No need motion when nothing exist in queue 184 | if (!prevStyle || !nextStyle) { 185 | return null; 186 | } 187 | 188 | return ( 189 | 197 | {({ className: motionClassName, style: motionStyle }, ref) => { 198 | const mergedStyle = { 199 | ...motionStyle, 200 | '--thumb-start-left': thumbStart, 201 | '--thumb-start-width': toPX(prevStyle?.width), 202 | '--thumb-active-left': thumbActive, 203 | '--thumb-active-width': toPX(nextStyle?.width), 204 | '--thumb-start-top': thumbStart, 205 | '--thumb-start-height': toPX(prevStyle?.height), 206 | '--thumb-active-top': thumbActive, 207 | '--thumb-active-height': toPX(nextStyle?.height), 208 | } as React.CSSProperties; 209 | 210 | // It's little ugly which should be refactor when @umi/test update to latest jsdom 211 | const motionProps = { 212 | ref: composeRef(thumbRef, ref), 213 | style: mergedStyle, 214 | className: classNames(`${prefixCls}-thumb`, motionClassName), 215 | }; 216 | 217 | if (process.env.NODE_ENV === 'test') { 218 | (motionProps as any)['data-test-style'] = JSON.stringify(mergedStyle); 219 | } 220 | 221 | return
; 222 | }} 223 | 224 | ); 225 | } 226 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import useMergedState from '@rc-component/util/lib/hooks/useMergedState'; 2 | import omit from '@rc-component/util/lib/omit'; 3 | import { composeRef } from '@rc-component/util/lib/ref'; 4 | import classNames from 'classnames'; 5 | import * as React from 'react'; 6 | 7 | import MotionThumb from './MotionThumb'; 8 | 9 | export type SemanticName = 'item' | 'label'; 10 | export type SegmentedValue = string | number; 11 | 12 | export type SegmentedRawOption = SegmentedValue; 13 | 14 | export interface SegmentedLabeledOption { 15 | className?: string; 16 | disabled?: boolean; 17 | label: React.ReactNode; 18 | value: ValueType; 19 | /** 20 | * html `title` property for label 21 | */ 22 | title?: string; 23 | } 24 | 25 | type ItemRender = ( 26 | node: React.ReactNode, 27 | info: { item: SegmentedLabeledOption }, 28 | ) => React.ReactNode; 29 | 30 | type SegmentedOptions = ( 31 | | T 32 | | SegmentedLabeledOption 33 | )[]; 34 | 35 | export interface SegmentedProps 36 | extends Omit< 37 | React.HTMLProps, 38 | 'defaultValue' | 'value' | 'onChange' 39 | > { 40 | options: SegmentedOptions; 41 | defaultValue?: ValueType; 42 | value?: ValueType; 43 | onChange?: (value: ValueType) => void; 44 | disabled?: boolean; 45 | prefixCls?: string; 46 | direction?: 'ltr' | 'rtl'; 47 | motionName?: string; 48 | vertical?: boolean; 49 | name?: string; 50 | classNames?: Partial>; 51 | styles?: Partial>; 52 | itemRender?: ItemRender; 53 | } 54 | 55 | function getValidTitle(option: SegmentedLabeledOption) { 56 | if (typeof option.title !== 'undefined') { 57 | return option.title; 58 | } 59 | 60 | // read `label` when title is `undefined` 61 | if (typeof option.label !== 'object') { 62 | return option.label?.toString(); 63 | } 64 | } 65 | 66 | function normalizeOptions(options: SegmentedOptions): SegmentedLabeledOption[] { 67 | return options.map((option) => { 68 | if (typeof option === 'object' && option !== null) { 69 | const validTitle = getValidTitle(option); 70 | return { 71 | ...option, 72 | title: validTitle, 73 | }; 74 | } 75 | return { 76 | label: option?.toString(), 77 | title: option?.toString(), 78 | value: option, 79 | }; 80 | }); 81 | } 82 | 83 | const InternalSegmentedOption: React.FC<{ 84 | prefixCls: string; 85 | className?: string; 86 | style?: React.CSSProperties; 87 | classNames?: Partial>; 88 | styles?: Partial>; 89 | data: SegmentedLabeledOption; 90 | disabled?: boolean; 91 | checked: boolean; 92 | label: React.ReactNode; 93 | title?: string; 94 | value: SegmentedRawOption; 95 | name?: string; 96 | onChange: ( 97 | e: React.ChangeEvent, 98 | value: SegmentedRawOption, 99 | ) => void; 100 | onFocus: (e: React.FocusEvent) => void; 101 | onBlur: (e?: React.FocusEvent) => void; 102 | onKeyDown: (e: React.KeyboardEvent) => void; 103 | onKeyUp: (e: React.KeyboardEvent) => void; 104 | onMouseDown: () => void; 105 | itemRender?: ItemRender; 106 | }> = ({ 107 | prefixCls, 108 | className, 109 | style, 110 | styles, 111 | classNames: segmentedClassNames, 112 | data, 113 | disabled, 114 | checked, 115 | label, 116 | title, 117 | value, 118 | name, 119 | onChange, 120 | onFocus, 121 | onBlur, 122 | onKeyDown, 123 | onKeyUp, 124 | onMouseDown, 125 | itemRender = (node: React.ReactNode) => node, 126 | }) => { 127 | const handleChange = (event: React.ChangeEvent) => { 128 | if (disabled) { 129 | return; 130 | } 131 | onChange(event, value); 132 | }; 133 | const itemContent: React.ReactNode = ( 134 | 166 | ); 167 | return itemRender(itemContent, { item: data }); 168 | }; 169 | 170 | const Segmented = React.forwardRef( 171 | (props, ref) => { 172 | const { 173 | prefixCls = 'rc-segmented', 174 | direction, 175 | vertical, 176 | options = [], 177 | disabled, 178 | defaultValue, 179 | value, 180 | name, 181 | onChange, 182 | className = '', 183 | style, 184 | styles, 185 | classNames: segmentedClassNames, 186 | motionName = 'thumb-motion', 187 | itemRender, 188 | ...restProps 189 | } = props; 190 | 191 | const containerRef = React.useRef(null); 192 | const mergedRef = React.useMemo( 193 | () => composeRef(containerRef, ref), 194 | [containerRef, ref], 195 | ); 196 | 197 | const segmentedOptions = React.useMemo(() => { 198 | return normalizeOptions(options); 199 | }, [options]); 200 | 201 | // Note: We should not auto switch value when value not exist in options 202 | // which may break single source of truth. 203 | const [rawValue, setRawValue] = useMergedState(segmentedOptions[0]?.value, { 204 | value, 205 | defaultValue, 206 | }); 207 | 208 | // ======================= Change ======================== 209 | const [thumbShow, setThumbShow] = React.useState(false); 210 | 211 | const handleChange = ( 212 | event: React.ChangeEvent, 213 | val: SegmentedRawOption, 214 | ) => { 215 | setRawValue(val); 216 | onChange?.(val); 217 | }; 218 | 219 | const divProps = omit(restProps, ['children']); 220 | 221 | // ======================= Focus ======================== 222 | const [isKeyboard, setIsKeyboard] = React.useState(false); 223 | const [isFocused, setIsFocused] = React.useState(false); 224 | 225 | const handleFocus = () => { 226 | setIsFocused(true); 227 | }; 228 | 229 | const handleBlur = () => { 230 | setIsFocused(false); 231 | }; 232 | 233 | const handleMouseDown = () => { 234 | setIsKeyboard(false); 235 | }; 236 | 237 | // capture keyboard tab interaction for correct focus style 238 | const handleKeyUp = (event: React.KeyboardEvent) => { 239 | if (event.key === 'Tab') { 240 | setIsKeyboard(true); 241 | } 242 | }; 243 | 244 | // ======================= Keyboard ======================== 245 | const onOffset = (offset: number) => { 246 | const currentIndex = segmentedOptions.findIndex( 247 | (option) => option.value === rawValue, 248 | ); 249 | 250 | const total = segmentedOptions.length; 251 | const nextIndex = (currentIndex + offset + total) % total; 252 | 253 | const nextOption = segmentedOptions[nextIndex]; 254 | if (nextOption) { 255 | setRawValue(nextOption.value); 256 | onChange?.(nextOption.value); 257 | } 258 | }; 259 | 260 | const handleKeyDown = (event: React.KeyboardEvent) => { 261 | switch (event.key) { 262 | case 'ArrowLeft': 263 | case 'ArrowUp': 264 | onOffset(-1); 265 | break; 266 | case 'ArrowRight': 267 | case 'ArrowDown': 268 | onOffset(1); 269 | break; 270 | } 271 | }; 272 | 273 | const renderOption = (segmentedOption: SegmentedLabeledOption) => { 274 | const { value: optionValue, disabled: optionDisabled } = segmentedOption; 275 | 276 | return ( 277 | 307 | ); 308 | }; 309 | 310 | return ( 311 |
328 |
329 | 337 | segmentedOptions.findIndex((n) => n.value === val) 338 | } 339 | onMotionStart={() => { 340 | setThumbShow(true); 341 | }} 342 | onMotionEnd={() => { 343 | setThumbShow(false); 344 | }} 345 | /> 346 | {segmentedOptions.map(renderOption)} 347 |
348 |
349 | ); 350 | }, 351 | ); 352 | 353 | if (process.env.NODE_ENV !== 'production') { 354 | Segmented.displayName = 'Segmented'; 355 | } 356 | 357 | const TypedSegmented = Segmented as ( 358 | props: SegmentedProps & { 359 | ref?: React.ForwardedRef; 360 | }, 361 | ) => ReturnType; 362 | 363 | export default TypedSegmented; 364 | -------------------------------------------------------------------------------- /tests/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`rc-segmented render empty segmented 1`] = ` 4 |
10 |
13 |
14 | `; 15 | 16 | exports[`rc-segmented render label with ReactNode 1`] = ` 17 |
23 |
26 | 43 | 62 | 79 |
80 |
81 | `; 82 | 83 | exports[`rc-segmented render segmented ok 1`] = ` 84 |
90 |
93 | 110 | 126 | 142 |
143 |
144 | `; 145 | 146 | exports[`rc-segmented render segmented with CSSMotion basic 1`] = ` 147 |
153 |
156 | 173 | 189 | 205 |
206 |
207 | `; 208 | 209 | exports[`rc-segmented render segmented with options 1`] = ` 210 |
216 |
219 | 236 | 252 | 268 |
269 |
270 | `; 271 | 272 | exports[`rc-segmented render segmented with options null/undefined 1`] = ` 273 |
278 |
281 |
, 487 | value: 'test2', 488 | }, 489 | { 490 | label: 'hello1', 491 | value: 'hello1', 492 | title: 'hello1.5', 493 | }, 494 | { 495 | label: 'foo1', 496 | value: 'foo2', 497 | title: '', 498 | }, 499 | ]} 500 | />, 501 | ); 502 | expect(asFragment().firstChild).toMatchSnapshot(); 503 | 504 | expect( 505 | Array.from( 506 | container.querySelectorAll( 507 | '.rc-segmented-item-label', 508 | ), 509 | ).map((n) => n.title), 510 | ).toEqual(['Web', 'hello1', '', 'hello1.5', '']); 511 | }); 512 | 513 | it('render with rtl', () => { 514 | const { container } = render( 515 | , 519 | ); 520 | 521 | expect(container.querySelector('.rc-segmented')).toHaveClass( 522 | 'rc-segmented-rtl', 523 | ); 524 | 525 | expectMatchChecked(container, [true, false, false]); 526 | }); 527 | 528 | it('click can work as expected with rtl', () => { 529 | const offsetParentSpy = jest 530 | .spyOn(HTMLElement.prototype, 'offsetParent', 'get') 531 | .mockImplementation(() => { 532 | return container; 533 | }); 534 | const handleValueChange = jest.fn(); 535 | const { container } = render( 536 | handleValueChange(value)} 540 | />, 541 | ); 542 | 543 | fireEvent.click(container.querySelectorAll('.rc-segmented-item-input')[1]); 544 | expectMatchChecked(container, [false, true, false]); 545 | expect(handleValueChange).toBeCalledWith('Android'); 546 | 547 | // Motion to active 548 | act(() => { 549 | jest.runAllTimers(); 550 | }); 551 | 552 | exceptThumbHaveStyle(container, { 553 | '--thumb-active-left': '-22px', 554 | '--thumb-active-width': '118px', 555 | }); 556 | 557 | offsetParentSpy.mockRestore(); 558 | }); 559 | 560 | it('should render vertical segmented', () => { 561 | const { container, asFragment } = render( 562 | , 563 | ); 564 | 565 | expect(asFragment().firstChild).toMatchSnapshot(); 566 | expect(container.querySelector('.rc-segmented')).toHaveClass( 567 | 'rc-segmented-vertical', 568 | ); 569 | expectMatchChecked(container, [true, false, false]); 570 | }); 571 | 572 | it('should render vertical segmented and handle thumb animations correctly', () => { 573 | const offsetParentSpy = jest 574 | .spyOn(HTMLElement.prototype, 'offsetParent', 'get') 575 | .mockImplementation(() => { 576 | return container; 577 | }); 578 | const handleValueChange = jest.fn(); 579 | const { container, asFragment } = render( 580 | handleValueChange(value)} 584 | />, 585 | ); 586 | 587 | // Snapshot test 588 | expect(asFragment().firstChild).toMatchSnapshot(); 589 | expect(container.querySelector('.rc-segmented')).toHaveClass( 590 | 'rc-segmented-vertical', 591 | ); 592 | expectMatchChecked(container, [true, false, false]); 593 | 594 | // Click: Web 595 | fireEvent.click(container.querySelectorAll('.rc-segmented-item-input')[2]); 596 | expect(handleValueChange).toBeCalledWith('Web'); 597 | expectMatchChecked(container, [false, false, true]); 598 | 599 | // Thumb should appear at `iOS` 600 | exceptThumbHaveStyle(container, { 601 | '--thumb-start-top': '0px', 602 | '--thumb-start-height': '0px', 603 | }); 604 | 605 | // Motion => active 606 | act(() => { 607 | jest.runAllTimers(); 608 | }); 609 | 610 | // Motion enter end 611 | fireEvent.animationEnd(container.querySelector('.rc-segmented-thumb')!); 612 | act(() => { 613 | jest.runAllTimers(); 614 | }); 615 | 616 | // Thumb should disappear 617 | expect(container.querySelector('.rc-segmented-thumb')).toBeFalsy(); 618 | 619 | // Click: Android 620 | fireEvent.click(container.querySelectorAll('.rc-segmented-item-input')[1]); 621 | expect(handleValueChange).toBeCalledWith('Android'); 622 | expectMatchChecked(container, [false, true, false]); 623 | 624 | // Thumb should move 625 | expect(container.querySelector('.rc-segmented-thumb')).toHaveClass( 626 | 'rc-segmented-thumb-motion', 627 | ); 628 | 629 | // Thumb appeared at `Web` 630 | exceptThumbHaveStyle(container, { 631 | '--thumb-start-top': '0px', 632 | '--thumb-start-height': '0px', 633 | }); 634 | 635 | // Motion appear end 636 | act(() => { 637 | jest.runAllTimers(); 638 | }); 639 | exceptThumbHaveStyle(container, { 640 | '--thumb-active-top': '0px', 641 | '--thumb-active-height': '0px', 642 | }); 643 | 644 | fireEvent.animationEnd(container.querySelector('.rc-segmented-thumb')!); 645 | act(() => { 646 | jest.runAllTimers(); 647 | }); 648 | 649 | // Thumb should disappear 650 | expect(container.querySelector('.rc-segmented-thumb')).toBeFalsy(); 651 | 652 | offsetParentSpy.mockRestore(); 653 | }); 654 | 655 | it('all children should have a name property', () => { 656 | const GROUP_NAME = 'GROUP_NAME'; 657 | const { container } = render( 658 | , 659 | ); 660 | 661 | container 662 | .querySelectorAll('input[type="radio"]') 663 | .forEach((el) => { 664 | expect(el.name).toEqual(GROUP_NAME); 665 | }); 666 | }); 667 | }); 668 | 669 | describe('Segmented keyboard navigation', () => { 670 | it('should be focusable through Tab key', async () => { 671 | const user = userEvent.setup(); 672 | const { getByRole, container } = render( 673 | , 674 | ); 675 | 676 | const segmentedContainer = getByRole('radiogroup'); 677 | const inputs = container.querySelectorAll('.rc-segmented-item-input'); 678 | const firstInput = inputs[0]; 679 | 680 | await user.tab(); 681 | // segmented container should be focused 682 | expect(segmentedContainer).toHaveFocus(); 683 | await user.tab(); 684 | // first segmented item should be focused 685 | expect(firstInput).toHaveFocus(); 686 | }); 687 | 688 | it('should handle circular navigation with arrow keys', async () => { 689 | const user = userEvent.setup(); 690 | const onChange = jest.fn(); 691 | render( 692 | , 693 | ); 694 | 695 | // focus on segmented 696 | await user.tab(); 697 | // focus on first item 698 | await user.tab(); 699 | 700 | // Test right navigation from first item and back to first item 701 | await user.keyboard('{ArrowRight}'); 702 | expect(onChange).toHaveBeenCalledWith('Android'); 703 | await user.keyboard('{ArrowRight}'); 704 | expect(onChange).toHaveBeenCalledWith('Web'); 705 | await user.keyboard('{ArrowRight}'); 706 | expect(onChange).toHaveBeenCalledWith('iOS'); 707 | 708 | // Test left navigation from first item to last item 709 | await user.keyboard('{ArrowLeft}'); 710 | expect(onChange).toHaveBeenCalledWith('Web'); 711 | }); 712 | 713 | it('should skip Tab navigation when disabled', async () => { 714 | const user = userEvent.setup(); 715 | const { container } = render( 716 | , 717 | ); 718 | 719 | const segmentedContainer = container.querySelector('.rc-segmented'); 720 | 721 | await user.tab(); 722 | 723 | // Disabled state should not get focus 724 | expect(segmentedContainer).not.toHaveFocus(); 725 | 726 | // Verify container has no tabIndex attribute 727 | expect(segmentedContainer?.getAttribute('tabIndex')).toBeNull(); 728 | }); 729 | 730 | it('should handle keyboard navigation with disabled options', async () => { 731 | const user = userEvent.setup(); 732 | const onChange = jest.fn(); 733 | render( 734 | , 743 | ); 744 | 745 | await user.tab(); 746 | await user.tab(); 747 | 748 | await user.keyboard('{ArrowRight}'); 749 | expect(onChange).toHaveBeenCalledWith('Web'); 750 | 751 | onChange.mockClear(); 752 | 753 | await user.keyboard('{ArrowLeft}'); 754 | expect(onChange).toHaveBeenCalledWith('iOS'); 755 | }); 756 | 757 | it('should not have focus style when clicking', async () => { 758 | const user = userEvent.setup(); 759 | const { container } = render( 760 | , 761 | ); 762 | 763 | await user.click(container.querySelector('.rc-segmented-item-input')!); 764 | expect(container.querySelector('.rc-segmented-item-input')).not.toHaveClass( 765 | 'rc-segmented-item-input-focused', 766 | ); 767 | }); 768 | it('should apply custom styles to Segmented', () => { 769 | const customClassNames = { 770 | item: 'custom-item', 771 | label: 'custom-label', 772 | }; 773 | 774 | const customStyles = { 775 | item: { color: 'yellow' }, 776 | label: { backgroundColor: 'black' }, 777 | }; 778 | 779 | const { container } = render( 780 | , 785 | ); 786 | 787 | const itemElement = container.querySelector( 788 | '.rc-segmented-item', 789 | ) as HTMLElement; 790 | const labelElement = container.querySelector( 791 | '.rc-segmented-item-label', 792 | ) as HTMLElement; 793 | 794 | // check classNames 795 | expect(itemElement.classList).toContain('custom-item'); 796 | expect(labelElement.classList).toContain('custom-label'); 797 | 798 | // check styles 799 | expect(itemElement.style.color).toBe('yellow'); 800 | expect(labelElement.style.backgroundColor).toBe('black'); 801 | }); 802 | describe('itemRender', () => { 803 | it('When "itemRender" is not configured, render the original "label"', () => { 804 | const { container } = render( 805 | , 806 | ); 807 | const label = container.querySelector('.rc-segmented-item-label'); 808 | expect(label).toHaveTextContent('iOS'); 809 | }); 810 | it('Configure "itemRender" to render the return value', () => { 811 | const { container } = render( 812 |
{node}
} 815 | />, 816 | ); 817 | const labels = container.querySelectorAll('.test-title'); 818 | expect(labels).toHaveLength(3); 819 | }); 820 | it('should pass complete params to itemRender', () => { 821 | const mockItemRender = jest.fn((node, params) => node); 822 | const testData = { 823 | label: 'iOS', 824 | value: 'iOS', 825 | disabled: false, 826 | title: 'iOS', 827 | tooltip: 'hello iOS', 828 | }; 829 | render( 830 | , 834 | ); 835 | expect(mockItemRender).toHaveBeenCalledTimes(3); 836 | const callArgs = mockItemRender.mock.calls[0]; 837 | const receivedParams = callArgs[1]; 838 | expect(receivedParams).toEqual({ 839 | item: { 840 | ...testData, 841 | }, 842 | }); 843 | expect(React.isValidElement(callArgs[0])).toBeTruthy(); 844 | }); 845 | }); 846 | }); 847 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | require('regenerator-runtime/runtime'); 2 | 3 | window.requestAnimationFrame = (func) => { 4 | return window.setTimeout(func, 16); 5 | }; 6 | 7 | window.cancelAnimationFrame = (id) => { 8 | return window.clearTimeout(id); 9 | }; 10 | 11 | // https://github.com/jsdom/jsdom/issues/135#issuecomment-68191941 12 | Object.defineProperties(window.HTMLElement.prototype, { 13 | offsetLeft: { 14 | get() { 15 | let offsetLeft = 0; 16 | const childList: HTMLElement[] = Array.from( 17 | (this.parentNode as HTMLElement).querySelectorAll('.rc-segmented-item'), 18 | ); 19 | for (let i = 0; i < childList.length; i++) { 20 | const child = childList[i]; 21 | const lastChild = childList[i - 1]; 22 | offsetLeft += lastChild?.clientWidth || 0; 23 | if (child === this) { 24 | break; 25 | } 26 | } 27 | return offsetLeft; 28 | }, 29 | }, 30 | clientWidth: { 31 | get() { 32 | // text length + vertical padding 33 | return this.textContent.length * 14 + 20; 34 | }, 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /tests/setupFilesAfterEnv.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "baseUrl": "./", 7 | "lib": ["dom", "es2017"], 8 | "jsx": "react", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "skipLibCheck": true, 14 | "declaration": true, 15 | "types": ["jest", "node"], 16 | "paths": { 17 | "@/*": ["src/*"], 18 | "@@/*": [".dumi/tmp/*"], 19 | "rc-segmented": ["src/index.tsx"] 20 | } 21 | }, 22 | "include": [".dumi/**/*", ".dumirc.ts", "src", "tests", "docs/examples"], 23 | } 24 | -------------------------------------------------------------------------------- /type.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | 3 | declare module '*.less'; 4 | --------------------------------------------------------------------------------