├── .eslintignore
├── .eslintrc.js
├── .github
└── ISSUE_TEMPLATE
│ ├── bug-report.md
│ └── feature-request-------.md
├── .gitignore
├── .husky
├── .gitignore
└── commit-msg
├── .prettierrc
├── CHANGELOG.md
├── LICENSE
├── README.md
├── README.zh-CN.md
├── babel.config.js
├── commitlint.config.js
├── demo
├── .gitignore
├── index.html
├── package.json
├── src
│ ├── components
│ │ ├── DemoIndex.tsx
│ │ ├── DemoPage.tsx
│ │ ├── DemoSection.tsx
│ │ ├── LangSwitcher.tsx
│ │ └── index.ts
│ ├── demo
│ │ ├── Avatar.tsx
│ │ ├── Bubble.tsx
│ │ ├── Button.tsx
│ │ ├── Card.tsx
│ │ ├── Carousel.tsx
│ │ ├── Chat.tsx
│ │ ├── Checkbox.tsx
│ │ ├── ComponentProvider.tsx
│ │ ├── Confirm.tsx
│ │ ├── Divider.tsx
│ │ ├── Empty.tsx
│ │ ├── ErrorBoundary.tsx
│ │ ├── FileCard.tsx
│ │ ├── Flex.tsx
│ │ ├── Form.tsx
│ │ ├── Goods.tsx
│ │ ├── Icon.tsx
│ │ ├── Image.tsx
│ │ ├── InfiniteScroll.tsx
│ │ ├── Input.tsx
│ │ ├── List.tsx
│ │ ├── Loading.tsx
│ │ ├── MediaObject.tsx
│ │ ├── MessageStatus.tsx
│ │ ├── Modal.tsx
│ │ ├── Navbar.tsx
│ │ ├── Notice.tsx
│ │ ├── OrdderSelector.tsx
│ │ ├── Popup.tsx
│ │ ├── Portal.tsx
│ │ ├── Price.tsx
│ │ ├── Progress.tsx
│ │ ├── PullToRefresh.tsx
│ │ ├── Quote.tsx
│ │ ├── Radio.tsx
│ │ ├── RateActions.tsx
│ │ ├── RichText.tsx
│ │ ├── ScrollGrid.tsx
│ │ ├── ScrollView.tsx
│ │ ├── Search.tsx
│ │ ├── Select.tsx
│ │ ├── Skeleton.tsx
│ │ ├── Stepper.tsx
│ │ ├── SystemMessage.tsx
│ │ ├── Tabs.tsx
│ │ ├── Tag.tsx
│ │ ├── Text.tsx
│ │ ├── Think.tsx
│ │ ├── Time.tsx
│ │ ├── Toast.tsx
│ │ ├── Typing.tsx
│ │ ├── TypingBubble.tsx
│ │ ├── Video.tsx
│ │ ├── VisuallyHidden.tsx
│ │ └── index.ts
│ ├── index.less
│ ├── main.tsx
│ ├── navConfig.ts
│ ├── routerConfig.tsx
│ ├── utils
│ │ ├── index.ts
│ │ └── toPascalCase.ts
│ └── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
├── jest.config.js
├── jest.setup.ts
├── package.json
├── postcss.config.js
├── rollup.config.js
├── src
├── components
│ ├── Avatar
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── BackBottom
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Backdrop
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Bubble
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Button
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Card
│ │ ├── Card.tsx
│ │ ├── CardActions.tsx
│ │ ├── CardContent.tsx
│ │ ├── CardHeader.tsx
│ │ ├── CardMedia.tsx
│ │ ├── CardText.tsx
│ │ ├── CardTitle.tsx
│ │ ├── __tests__
│ │ │ ├── actions.test.tsx
│ │ │ ├── content.test.tsx
│ │ │ ├── index.test.tsx
│ │ │ ├── media.test.tsx
│ │ │ ├── text.test.tsx
│ │ │ └── title.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Carousel
│ │ ├── Item.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Chat
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Checkbox
│ │ ├── Checkbox.tsx
│ │ ├── CheckboxGroup.tsx
│ │ ├── __tests__
│ │ │ ├── group.test.tsx
│ │ │ └── index.test.tsx
│ │ └── index.ts
│ ├── ClickOutside
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ └── index.tsx
│ ├── ComponentsProvider
│ │ ├── ComponentsContext.ts
│ │ ├── index.tsx
│ │ ├── interface.ts
│ │ └── useComponents.ts
│ ├── Composer
│ │ ├── AccessoryWrap.tsx
│ │ ├── Action.tsx
│ │ ├── ComposerInput.tsx
│ │ ├── SendButton.tsx
│ │ ├── ToolbarItem.tsx
│ │ ├── index.tsx
│ │ ├── riseInput.ts
│ │ ├── style.less
│ │ └── viewportTop.ts
│ ├── ConfigProvider
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── locales
│ │ │ ├── ar_EG.ts
│ │ │ ├── en_US.ts
│ │ │ ├── fr_FR.ts
│ │ │ ├── index.ts
│ │ │ └── zh_CN.ts
│ ├── Divider
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Empty
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── ErrorBoundary
│ │ └── index.tsx
│ ├── FileCard
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Flex
│ │ ├── Flex.tsx
│ │ ├── FlexItem.tsx
│ │ ├── __tests__
│ │ │ ├── index.test.tsx
│ │ │ └── item.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Form
│ │ ├── Form.tsx
│ │ ├── FormActions.tsx
│ │ ├── FormItem.tsx
│ │ ├── __tests__
│ │ │ ├── actions.test.tsx
│ │ │ ├── index.test.tsx
│ │ │ └── item.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Goods
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── HelpText
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Icon
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── IconButton
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Image
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── InfiniteScroll
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Input
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Label
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── LazyComponent
│ │ ├── SuspenseWrap.tsx
│ │ ├── index.tsx
│ │ └── interface.ts
│ ├── List
│ │ ├── List.tsx
│ │ ├── ListItem.tsx
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Loading
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── MediaObject
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Message
│ │ ├── Message.tsx
│ │ ├── SystemMessage.tsx
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── MessageContainer
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── MessageStatus
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Modal
│ │ ├── Base.tsx
│ │ ├── Confirm.tsx
│ │ ├── Modal.tsx
│ │ ├── Popup.tsx
│ │ ├── __tests__
│ │ │ ├── base.test.tsx
│ │ │ ├── confirm.test.tsx
│ │ │ ├── modal.test.tsx
│ │ │ └── popup.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Navbar
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Notice
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Popover
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Portal
│ │ └── index.ts
│ ├── Price
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Progress
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── PullToRefresh
│ │ ├── index.tsx
│ │ └── style.less
│ ├── QuickReplies
│ │ ├── QuickReplies.tsx
│ │ ├── QuickReply.tsx
│ │ ├── __tests__
│ │ │ ├── index.test.tsx
│ │ │ └── item.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Quote
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Radio
│ │ ├── Radio.tsx
│ │ ├── RadioGroup.tsx
│ │ ├── __tests__
│ │ │ ├── group.test.tsx
│ │ │ └── index.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── RateActions
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Recorder
│ │ ├── index.tsx
│ │ └── style.less
│ ├── RichText
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── configDOMPurify.ts
│ │ ├── index.tsx
│ │ └── style.less
│ ├── ScrollGrid
│ │ ├── index.tsx
│ │ └── style.less
│ ├── ScrollView
│ │ ├── Item.tsx
│ │ ├── ScrollView.tsx
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Search
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Select
│ │ ├── index.tsx
│ │ └── style.less
│ ├── SendConfirm
│ │ ├── SendConfirm.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Skeleton
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Stepper
│ │ ├── Step.tsx
│ │ ├── Stepper.tsx
│ │ ├── __tests__
│ │ │ ├── index.test.tsx
│ │ │ └── step.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Tabs
│ │ ├── Tab.tsx
│ │ ├── Tabs.tsx
│ │ ├── __tests__
│ │ │ ├── index.test.tsx
│ │ │ └── tab.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Tag
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Text
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Think
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Time
│ │ ├── Time.tsx
│ │ ├── __tests__
│ │ │ ├── index.test.tsx
│ │ │ └── parser.test.ts
│ │ ├── index.ts
│ │ ├── parser.ts
│ │ └── style.less
│ ├── Toast
│ │ ├── Toast.tsx
│ │ ├── __tests__
│ │ │ ├── index.test.ts
│ │ │ └── toast.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ ├── Toolbar
│ │ ├── Toolbar.tsx
│ │ ├── ToolbarButton.tsx
│ │ ├── __tests__
│ │ │ ├── button.test.tsx
│ │ │ └── index.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Tooltip
│ │ └── style.less
│ ├── Tree
│ │ ├── Tree.tsx
│ │ ├── TreeNode.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── Typing
│ │ ├── Typing.tsx
│ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── index.test.tsx.snap
│ │ │ └── index.test.tsx
│ │ ├── index.ts
│ │ └── style.less
│ ├── TypingBubble
│ │ └── index.tsx
│ ├── Video
│ │ ├── __tests__
│ │ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
│ └── VisuallyHidden
│ │ ├── __tests__
│ │ ├── __snapshots__
│ │ │ └── index.test.tsx.snap
│ │ └── index.test.tsx
│ │ ├── index.tsx
│ │ └── style.less
├── hooks
│ ├── __test__
│ │ ├── useMessages.test.tsx
│ │ ├── useNextId.test.tsx
│ │ └── useQuickReplies.test.tsx
│ ├── useClickOutside.ts
│ ├── useForwardRef.ts
│ ├── useLatest.ts
│ ├── useMessages.ts
│ ├── useMount.ts
│ ├── useNextId.ts
│ ├── useQuickReplies.ts
│ ├── useTitleTyping.ts
│ ├── useTypewriter.ts
│ └── useWindowResize.ts
├── index.ts
├── styles
│ ├── animation.less
│ ├── dark.less
│ ├── index.less
│ ├── root.less
│ ├── scale.less
│ ├── utils.less
│ └── var.less
└── utils
│ ├── __tests__
│ ├── getExtName.test.ts
│ ├── index.test.tsx
│ ├── prettyBytes.test.ts
│ ├── style.test.tsx
│ └── toggleClass.test.tsx
│ ├── canUse.ts
│ ├── countLines.ts
│ ├── formatTime.ts
│ ├── getExtName.ts
│ ├── getFps.ts
│ ├── getRandomInt.ts
│ ├── getToBottom.ts
│ ├── importScript.ts
│ ├── index.ts
│ ├── lazyComponent.tsx
│ ├── mountComponent.ts
│ ├── parseDataTransfer.ts
│ ├── prettyBytes.ts
│ ├── smoothScroll.ts
│ ├── style.ts
│ ├── throttle.ts
│ ├── toggleClass.ts
│ └── ua.ts
├── tsconfig.build.json
├── tsconfig.eslint.json
└── tsconfig.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.js
2 |
3 | # build
4 | dist
5 | es
6 | lib
7 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parserOptions: {
4 | ecmaVersion: 2020,
5 | project: './tsconfig.json',
6 | },
7 | extends: [
8 | 'airbnb/hooks',
9 | 'airbnb-typescript',
10 | 'prettier',
11 | ],
12 | env: {
13 | browser: true,
14 | jest: true,
15 | },
16 | plugins: ['compat', 'import', 'jsx-a11y', 'react', 'react-hooks'],
17 | rules: {
18 | 'compat/compat': 'error',
19 | 'import/prefer-default-export': 'off',
20 | // 'no-underscore-dangle': 'off',
21 | // 'react/jsx-filename-extension': 'off',
22 | 'react/jsx-props-no-spreading': 'off',
23 | // 'react/no-array-index-key': 'off',
24 | 'react/require-default-props': 'off',
25 | 'react/prop-types': 'off',
26 | // 'jsx-a11y/click-events-have-key-events': 'off',
27 | // 'jsx-a11y/label-has-associated-control': 'off',
28 | // 'jsx-a11y/label-has-for': 'off',
29 | },
30 | settings: {
31 | polyfills: ['IntersectionObserver', 'Promise'],
32 | },
33 | };
34 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Version information (版本信息)**
11 | - ChatUI or ChatUI Pro?
12 | - ChatUI Version:
13 | - React Version:
14 | - OS Version:
15 | - Browser Version:
16 |
17 | **Describe the bug (描述问题)**
18 |
19 | **Steps To Reproduce (重现步骤)**
20 | 1.
21 | 2.
22 |
23 | **Link to minimal reproduction (最小化重现链接)**
24 |
25 | **Expected behavior (期望的结果是什么)**
26 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request-------.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request (功能要求)
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **What problem does this feature solve? (这个功能解决了什么问题)**
11 |
12 | **Describe the solution you'd like (描述您想要的解决方案)**
13 |
14 | **What does the proposed API look like? (你期望的 API 是怎样的)**
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /storybook/node_modules
4 |
5 | # testing
6 | /coverage
7 |
8 | # production
9 | /build
10 | /dist
11 | /lib
12 | /es
13 |
14 | # misc
15 | .DS_Store
16 |
17 | npm-debug.log*
18 | yarn-debug.log*
19 | yarn-error.log*
20 |
--------------------------------------------------------------------------------
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
2 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx --no-install commitlint --edit "$1"
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "singleQuote": true,
4 | "trailingComma": "all",
5 | "proseWrap": "never",
6 | "endOfLine": "lf"
7 | }
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Alibaba
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = (api) => {
2 | const env = api.env();
3 | api.cache.using(() => env === 'development');
4 |
5 | return {
6 | presets: ['@babel/preset-env', '@babel/preset-typescript', '@babel/preset-react'],
7 | plugins: [
8 | '@babel/plugin-transform-runtime',
9 |
10 | // Stage 3
11 | '@babel/plugin-proposal-class-properties',
12 | ],
13 | env: {
14 | esm: {
15 | presets: [
16 | [
17 | '@babel/preset-env',
18 | {
19 | modules: false,
20 | },
21 | ],
22 | ],
23 | plugins: [
24 | [
25 | '@babel/plugin-transform-runtime',
26 | {
27 | useESModules: true,
28 | },
29 | ],
30 | ],
31 | },
32 | umd: {
33 | presets: [
34 | [
35 | '@babel/preset-env',
36 | {
37 | targets: {
38 | android: '4.4',
39 | ios: '9',
40 | },
41 | useBuiltIns: 'usage',
42 | corejs: 3,
43 | },
44 | ],
45 | ],
46 | plugins: [['@babel/plugin-transform-runtime', { corejs: 3 }]],
47 | },
48 | },
49 | ignore: [
50 | '**/*.test.ts',
51 | '**/*.test.tsx',
52 | ]
53 | };
54 | };
55 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional'],
3 | };
4 |
--------------------------------------------------------------------------------
/demo/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | ChatUI DEMO
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "demo",
3 | "private": true,
4 | "version": "0.1.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite --host",
8 | "build": "tsc -b && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "marked": "^15.0.7",
13 | "react": "^17.0.2",
14 | "react-dom": "^17.0.2",
15 | "react-router-dom": "^6.28.2"
16 | },
17 | "devDependencies": {
18 | "@types/react": "^17.0.83",
19 | "@types/react-dom": "^17.0.26",
20 | "@types/react-router-dom": "^5.3.3",
21 | "@vitejs/plugin-react": "^4.3.4",
22 | "typescript": "^5.7.3",
23 | "vite": "^6.0.11"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/demo/src/components/DemoIndex.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { Link } from 'react-router-dom';
3 | import { navConfig } from '../navConfig';
4 | import { toPascalCase } from '../utils';
5 |
6 | export default function DemoIndex() {
7 | useEffect(() => {
8 | const colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
9 | const handleColorSchemeChange = (e: MediaQueryListEvent | MediaQueryList) => {
10 | document.documentElement.dataset.colorScheme = e.matches ? 'dark' : 'light';
11 | };
12 |
13 | colorSchemeQuery.addEventListener('change', handleColorSchemeChange);
14 | handleColorSchemeChange(colorSchemeQuery);
15 | }, []);
16 |
17 | return (
18 |
19 | {navConfig.map((t) => (
20 |
21 |
{t.title}
22 |
23 | {t.list.map((tt) => (
24 | -
25 |
26 | {`${toPascalCase(tt.code)} ${tt.name}`}
27 |
28 |
29 | ))}
30 |
31 |
32 | ))}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/demo/src/components/DemoPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link, useLocation } from 'react-router-dom';
3 | import { toPascalCase } from '../utils';
4 |
5 | interface DemoPageProps {
6 | children: React.ReactNode;
7 | }
8 |
9 | export const DemoPage = ({ children }: DemoPageProps) => {
10 | const { pathname } = useLocation();
11 | const name = pathname.slice(1);
12 |
13 | return (
14 |
15 |
16 |
17 |
20 |
21 |
{toPascalCase(name)}
22 |
23 | {children}
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/demo/src/components/DemoSection.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface DemoSectionProps {
4 | title: string;
5 | bg?: 'white' | 'gray';
6 | children: React.ReactNode;
7 | }
8 |
9 | export const DemoSection = ({ title, bg = 'white', children }: DemoSectionProps) => (
10 |
11 |
{title}
12 | {children}
13 |
14 | );
15 |
--------------------------------------------------------------------------------
/demo/src/components/LangSwitcher.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import locales from '../../../src/components/ConfigProvider/locales';
3 |
4 | interface LangSwitcherProps {
5 | value: string;
6 | onChange: (lang: string) => void;
7 | }
8 |
9 | export const LangSwitcher = ({ value, onChange }: LangSwitcherProps) => (
10 |
22 | );
23 |
--------------------------------------------------------------------------------
/demo/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { DemoPage } from './DemoPage';
2 | export { DemoSection } from './DemoSection';
3 | export { LangSwitcher } from './LangSwitcher';
4 |
--------------------------------------------------------------------------------
/demo/src/demo/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Avatar } from '../../../src';
4 |
5 | const img = '//gw.alicdn.com/tfs/TB1U7FBiAT2gK0jSZPcXXcKkpXa-108-108.jpg';
6 |
7 | export default () => (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | );
21 |
--------------------------------------------------------------------------------
/demo/src/demo/Bubble.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Bubble } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 |
10 |
11 | 左边气泡内容
12 |
13 |
14 |
15 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 |
--------------------------------------------------------------------------------
/demo/src/demo/Carousel.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Carousel, Image } from '../../../src';
4 |
5 | const imgs = [
6 | '//gw.alicdn.com/tfs/TB1GRW3voY1gK0jSZFMXXaWcVXa-620-320.jpg',
7 | '//gw.alicdn.com/tfs/TB1I6i2vhD1gK0jSZFsXXbldVXa-620-320.jpg',
8 | '//gw.alicdn.com/tfs/TB1XCq4veH2gK0jSZFEXXcqMpXa-620-320.jpg',
9 | '//gw.alicdn.com/tfs/TB1dzG8vbj1gK0jSZFuXXcrHpXa-620-319.jpg',
10 | ];
11 |
12 | export default () => (
13 |
14 |
15 |
16 | {imgs.map((img, i) => (
17 |
21 | ))}
22 |
23 |
24 |
25 | );
26 |
--------------------------------------------------------------------------------
/demo/src/demo/Confirm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Confirm, Card, List, ListItem } from '../../../src';
4 |
5 | export default () => {
6 | const [open1, setOpen1] = useState(false);
7 |
8 | return (
9 |
10 |
11 |
12 |
13 | {
17 | setOpen1(true);
18 | }}
19 | rightIcon="chevron-right"
20 | />
21 |
22 |
23 |
24 | {
28 | setOpen1(false);
29 | }}
30 | actions={[
31 | {
32 | label: '确认',
33 | color: 'primary',
34 | },
35 | ]}
36 | >
37 | 内容详情内容详情内容详情内容详情内容详情内容详情
38 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/demo/src/demo/Divider.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Divider } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 |
10 |
11 | 文本
12 |
13 |
14 | 文本
15 | 左文本
16 | 右文本
17 |
18 |
19 | );
20 |
--------------------------------------------------------------------------------
/demo/src/demo/Empty.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Empty, Button } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 |
--------------------------------------------------------------------------------
/demo/src/demo/FileCard.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { FileCard } from '../../../src';
4 |
5 | const file = new File(['foo'], 'foo.txt', {
6 | type: 'text/plain',
7 | });
8 |
9 | export default () => (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | 下载
20 |
21 |
22 |
23 | );
24 |
--------------------------------------------------------------------------------
/demo/src/demo/Icon.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Icon } from '../../../src';
4 |
5 | const symbols = document.getElementById('__CHATUI_ICONS__')?.querySelectorAll('symbol') || [];
6 |
7 | export default () => (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {Array.from(symbols).map((item) => {
20 | const icon = item.id.replace('icon-', '');
21 | return (
22 |
23 |
24 | {icon}
25 |
26 | );
27 | })}
28 |
29 |
30 |
31 | );
32 |
--------------------------------------------------------------------------------
/demo/src/demo/Image.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Image } from '../../../src';
4 |
5 | const picUrl = '//gw.alicdn.com/tfs/TB1GRW3voY1gK0jSZFMXXaWcVXa-620-320.jpg';
6 |
7 | const imgs = [
8 | '//gw.alicdn.com/tfs/TB1yGi2vfb2gK0jSZK9XXaEgFXa-320-240.png',
9 | '//gw.alicdn.com/tfs/TB1I6i2vhD1gK0jSZFsXXbldVXa-620-320.jpg',
10 | '//gw.alicdn.com/tfs/TB1GRW3voY1gK0jSZFMXXaWcVXa-620-320.jpg',
11 | '//gw.alicdn.com/tfs/TB1XCq4veH2gK0jSZFEXXcqMpXa-620-320.jpg',
12 | '//gw.alicdn.com/tfs/TB1dzG8vbj1gK0jSZFuXXcrHpXa-620-319.jpg',
13 | ];
14 |
15 | export default () => (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {imgs.map((img) => (
25 |
26 |
placeholder
27 |
28 |
29 | ))}
30 |
31 |
32 | );
33 |
--------------------------------------------------------------------------------
/demo/src/demo/InfiniteScroll.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { InfiniteScroll } from '../../../src';
4 |
5 | export default () => {
6 | const [list, setList] = useState([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
7 | const [disabled, setDisabled] = useState(false);
8 |
9 | function handleLoadMore() {
10 | if (list.length > 50) {
11 | setDisabled(true);
12 | return;
13 | }
14 |
15 | for (let i = 0; i < 10; i += 1) {
16 | setList((s) => [...s, s.length + 1]);
17 | }
18 | }
19 |
20 | return (
21 |
22 |
23 |
26 | {` : ${disabled}`}
27 |
28 |
29 | {list.map((t) => (
30 | - {t}
31 | ))}
32 |
33 |
34 |
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/demo/src/demo/Loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Loading } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | );
15 |
--------------------------------------------------------------------------------
/demo/src/demo/MediaObject.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { MediaObject } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/demo/src/demo/MessageStatus.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { MessageStatus } from '../../../src';
4 |
5 | export default () => {
6 | return (
7 |
8 |
9 | {
12 | console.log('retry', isAutoRetry);
13 | }}
14 | onChange={(t) => {
15 | console.log('change:', t);
16 | }}
17 | />
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/demo/src/demo/Notice.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Notice } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 |
--------------------------------------------------------------------------------
/demo/src/demo/Portal.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Portal } from '../../../src';
4 |
5 | export default () => {
6 | const containerRef = useRef(null);
7 | return (
8 |
9 |
10 |
11 | 出现到 `document.body`
12 |
13 |
14 |
15 |
16 |
17 | 出现到指定 `ref`
18 |
19 |
20 |
21 |
22 | 出现到指定元素
23 |
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/demo/src/demo/Progress.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Progress, Button } from '../../../src';
4 |
5 | export default () => {
6 | const [percentage, setPercentage] = useState(20);
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/demo/src/demo/PullToRefresh.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { PullToRefresh } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 |
Promise.resolve({})}>
10 | list
11 |
12 |
13 |
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/demo/src/demo/Quote.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Quote, Text, Image, Video } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 | 亲,您好我是官方客服014964,马上为您服务~
10 |
11 |
12 |
13 |
14 |
15 | 我们会帮您联系物流催促配送,后续会在24小时内联系您的手机号158****0702。我们会帮您联系物流催促配送,后续会在24小时内联系您的手机号158****0702。
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
30 |
31 |
32 |
33 | );
34 |
--------------------------------------------------------------------------------
/demo/src/demo/Radio.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Radio, RadioGroup, RadioValue } from '../../../src';
4 |
5 | const options = [
6 | { label: 'Apple', value: 'Apple' },
7 | { label: 'Pear', value: 'Pear', disabled: true },
8 | { label: 'Orange', value: 'Orange' },
9 | { label: 'Banana', value: 'Banana' },
10 | ];
11 |
12 | export default () => {
13 | const [value, setValue] = useState('a');
14 |
15 | function handleChange(val: RadioValue) {
16 | setValue(val);
17 | }
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/demo/src/demo/RateActions.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { RateActions, ConfigProvider } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 | {
10 | console.log(val);
11 | }}
12 | />
13 |
14 |
15 |
16 | {
18 | console.log(val);
19 | }}
20 | />
21 |
22 |
23 |
24 |
25 | {
29 | console.log(val);
30 | }}
31 | />
32 |
33 |
34 |
35 | );
36 |
--------------------------------------------------------------------------------
/demo/src/demo/RichText.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { RichText } from '../../../src';
4 |
5 | const html = '';
6 |
7 | export default () => (
8 |
9 |
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/demo/src/demo/ScrollGrid.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { ScrollGrid } from '../../../src';
4 |
5 | export default () => {
6 | return (
7 |
8 |
9 |
10 | {Array.from({ length: 6 }).map((t, i) => (
11 |
20 | {i}
21 |
22 | ))}
23 |
24 |
25 |
26 |
27 | {Array.from({ length: 6 }).map((t, i) => (
28 |
37 | {i}
38 |
39 | ))}
40 |
41 |
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/demo/src/demo/ScrollView.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { ScrollView, Button } from '../../../src';
4 |
5 | // const list = [{ text: '内容1' }, { text: '内容2' }, { text: '内容3' }];
6 |
7 | export default () => {
8 | const [list, setList] = React.useState([{ text: '内容1' }, { text: '内容2' }, { text: '内容3' }]);
9 | return (
10 |
11 |
12 |
20 |
26 | } />
27 |
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/demo/src/demo/Search.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Search } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/demo/src/demo/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Skeleton, Flex, FlexItem } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 |
--------------------------------------------------------------------------------
/demo/src/demo/SystemMessage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { SystemMessage } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 |
10 |
11 | {
16 | console.log('取消');
17 | },
18 | }}
19 | />
20 |
21 |
22 | );
23 |
--------------------------------------------------------------------------------
/demo/src/demo/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Tabs, Tab } from '../../../src';
4 |
5 | export default () => {
6 | const [tabIndex, setTabIndex] = useState(0);
7 | const [tabIndex2, setTabIndex2] = useState(0);
8 |
9 | return (
10 |
11 |
12 |
13 |
14 | 内容1
15 |
16 |
17 | 内容2
18 |
19 |
20 | 内容3
21 |
22 |
23 |
24 |
25 |
26 |
27 | 内容1
28 |
29 |
30 | 内容2
31 |
32 |
33 | 内容3
34 |
35 |
36 | 内容4
37 |
38 |
39 | 内容5
40 |
41 |
42 | 内容6
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/demo/src/demo/Tag.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Tag } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 | 默认标签
9 | 商品标签
10 | 成功状态
11 | 失败状态
12 | 警戒状态
13 |
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/demo/src/demo/Text.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Text } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 | 文本内容
9 |
10 |
11 | 这是一段非常非常非常非常非常非常非常非常长的文本内容
12 |
13 |
14 |
15 | 这是一段非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常非常长的文本内容
16 |
17 |
18 |
19 | ThisIsVeryVeryVeryVeryVeryVeryVeryLongEnglishWord
20 |
21 |
22 | );
23 |
--------------------------------------------------------------------------------
/demo/src/demo/Think.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Think } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 | 好的,用户发来“你好”,我需要回应。首先,保持友好,用中文回复。
10 |
11 |
12 |
13 |
14 | 好的,用户发来“你好”,我需要回应。首先,保持友好,用中文回复。然后按照之前设定的角色,表现出有性格和脾气,可能带点俏皮或幽默。还要推动情节发展,比如引入新情景或事件。同时,注意口语化,避免太正式。
15 |
16 |
17 |
18 | );
19 |
--------------------------------------------------------------------------------
/demo/src/demo/Time.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { DemoPage, DemoSection, LangSwitcher } from '../components';
3 | import { Time, ConfigProvider } from '../../../src';
4 |
5 | const now = Date.now();
6 | const MS_A_DAY = 24 * 60 * 60 * 1000;
7 | const MS_A_WEEK = MS_A_DAY * 7;
8 |
9 | export default () => {
10 | const [lang, setLang] = useState('zh-CN');
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 | 现在:
19 |
20 |
21 |
22 | 刚才:
23 |
24 |
25 |
26 | 昨天:
27 |
28 |
29 |
30 | 前天:
31 |
32 |
33 |
34 | 上上周:
35 |
36 |
37 |
38 |
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/demo/src/demo/Typing.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DemoPage, DemoSection } from '../components';
3 | import { Typing } from '../../../src';
4 |
5 | export default () => (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 |
--------------------------------------------------------------------------------
/demo/src/demo/Video.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable compat/compat */
2 | import React from 'react';
3 | import { DemoPage, DemoSection } from '../components';
4 | import { Video } from '../../../src';
5 |
6 | export default () => (
7 |
8 |
9 |
14 |
15 |
16 | );
17 |
--------------------------------------------------------------------------------
/demo/src/demo/VisuallyHidden.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable compat/compat */
2 | import React from 'react';
3 | import { DemoPage, DemoSection } from '../components';
4 | import { VisuallyHidden } from '../../../src';
5 |
6 | export default () => (
7 |
8 |
9 |
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/demo/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { createHashRouter, RouterProvider } from 'react-router-dom';
4 | import { routerConfig } from './routerConfig';
5 | import './index.less';
6 | import '../../src/styles/index.less';
7 |
8 | const router = createHashRouter(routerConfig, {
9 | future: {
10 | v7_relativeSplatPath: true,
11 | },
12 | });
13 |
14 | ReactDOM.render(
15 |
16 |
22 | ,
23 | document.getElementById('root'),
24 | );
25 |
--------------------------------------------------------------------------------
/demo/src/routerConfig.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import DemoIndex from './components/DemoIndex';
3 | import * as demos from './demo';
4 | import { navConfig } from './navConfig';
5 | import { toPascalCase } from './utils';
6 |
7 | export const routerConfig = navConfig.reduce(
8 | (prev, current) => {
9 | const currentList = current.list.map((item: any) => {
10 | const Comp = (demos as any)[toPascalCase(item.code)];
11 | return { path: item.code, element: };
12 | });
13 | return [...prev, ...currentList];
14 | },
15 | [{ path: '/', element: }],
16 | );
17 |
--------------------------------------------------------------------------------
/demo/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export { default as toPascalCase } from './toPascalCase';
2 |
--------------------------------------------------------------------------------
/demo/src/utils/toPascalCase.ts:
--------------------------------------------------------------------------------
1 | export default function toPascalCase(str: string) {
2 | return str
3 | .split('-')
4 | .map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase())
5 | .join('');
6 | }
7 |
--------------------------------------------------------------------------------
/demo/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/demo/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "isolatedModules": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/demo/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "isolatedModules": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/demo/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import { defineConfig } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | base: './',
8 | plugins: [react()],
9 | build: {
10 | outDir: '../dist/demo',
11 | rollupOptions: {
12 | output: {
13 | entryFileNames: `[name].js`,
14 | chunkFileNames: `[name].js`,
15 | assetFileNames: `[name].[ext]`,
16 | },
17 | },
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'jsdom',
4 | testPathIgnorePatterns: ['/node_modules/', '/lib/', '/es/', '/dist/', 'examples'],
5 | setupFilesAfterEnv: ['./jest.setup.ts'],
6 | };
7 |
--------------------------------------------------------------------------------
/jest.setup.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | // https://github.com/testing-library/jest-dom
3 | import '@testing-library/jest-dom/extend-expect';
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | const cssnanoConfig = {
2 | preset: [
3 | 'default',
4 | {
5 | calc: false,
6 | discardComments: { removeAll: true },
7 | },
8 | ],
9 | };
10 |
11 | const pxtoremConfig = {
12 | propList: ['*', '!border*', '!box-shadow'],
13 | selectorBlackList: [':root'],
14 | };
15 |
16 | /** @type {import('postcss-load-config').Config} */
17 | const config = {
18 | plugins:
19 | process.env.NODE_ENV === 'production'
20 | ? [
21 | require('autoprefixer'),
22 | require('postcss-pxtorem')(pxtoremConfig),
23 | require('cssnano')(cssnanoConfig),
24 | ]
25 | : [],
26 | };
27 |
28 | module.exports = config;
29 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import commonjs from '@rollup/plugin-commonjs';
2 | import resolve from '@rollup/plugin-node-resolve';
3 | import babel from '@rollup/plugin-babel';
4 | import terser from '@rollup/plugin-terser';
5 | import pkg from './package.json';
6 |
7 | const name = 'ChatUI';
8 | const extensions = ['.js', '.jsx', '.ts', '.tsx'];
9 |
10 | export default {
11 | input: './src/index.ts',
12 | external: ['react', 'react-dom'],
13 | plugins: [
14 | resolve({ extensions }),
15 | commonjs(),
16 | babel({
17 | extensions,
18 | babelHelpers: 'runtime',
19 | include: ['src/**/*'],
20 | }),
21 | terser({
22 | output: { comments: false },
23 | compress: { drop_console: true },
24 | }),
25 | ],
26 | output: {
27 | file: pkg.browser,
28 | format: 'umd',
29 | name,
30 | globals: {
31 | react: 'React',
32 | 'react-dom': 'ReactDOM',
33 | },
34 | intro: `exports.version = '${pkg.version}';`,
35 | },
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/Avatar/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type AvatarSize = 'sm' | 'md' | 'lg';
5 |
6 | export type AvatarShape = 'circle' | 'square';
7 |
8 | export interface AvatarProps {
9 | className?: string;
10 | src?: string;
11 | alt?: string;
12 | url?: string;
13 | size?: AvatarSize;
14 | shape?: AvatarShape;
15 | }
16 |
17 | export const Avatar: React.FC = (props) => {
18 | const { className, src, alt, url, size = 'md', shape = 'circle', children } = props;
19 |
20 | const Element = url ? 'a' : 'span';
21 | return (
22 |
26 | {src ?
: children}
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/src/components/Avatar/style.less:
--------------------------------------------------------------------------------
1 | .Avatar {
2 | --avatar-size: 32px;
3 |
4 | display: inline-block;
5 | overflow: hidden;
6 | border-radius: 50%;
7 |
8 | img {
9 | display: block;
10 | width: var(--avatar-size);
11 | height: var(--avatar-size);
12 | object-fit: cover;
13 | }
14 | }
15 |
16 | .Avatar--sm {
17 | --avatar-size: 18px;
18 | }
19 |
20 | .Avatar--lg {
21 | --avatar-size: 40px;
22 | }
23 |
24 | .Avatar--square {
25 | border-radius: var(--radius-md);
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/BackBottom/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { Button } from '../Button';
3 | import { Icon } from '../Icon';
4 | import { useLocale } from '../ConfigProvider';
5 |
6 | interface BackBottomProps {
7 | count: number;
8 | onClick: () => void;
9 | onDidMount?: () => void;
10 | }
11 |
12 | export const BackBottom = ({ count, onClick, onDidMount }: BackBottomProps) => {
13 | const { trans } = useLocale('BackBottom');
14 | let text = trans('bottom');
15 | if (count) {
16 | text = trans(count === 1 ? 'newMsgOne' : 'newMsgOther').replace('{n}', count);
17 | }
18 |
19 | useEffect(() => {
20 | if (onDidMount) {
21 | onDidMount();
22 | }
23 | }, [onDidMount]);
24 |
25 | return (
26 |
27 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/BackBottom/style.less:
--------------------------------------------------------------------------------
1 | .BackBottom {
2 | position: absolute;
3 | right: 0;
4 | bottom: (20px + 48px);
5 | z-index: 10;
6 | overflow: hidden;
7 |
8 | .Btn {
9 | border-radius: 50px 0 0 50px;
10 | border-right: 0;
11 | background: rgba(255, 255, 255, 0.85);
12 | color: var(--brand-1);
13 | font-size: var(--font-size-sm);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/Backdrop/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Backdrop } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render Backdrop', () => {
9 | const { container } = render();
10 | const element = container.querySelector('.Backdrop');
11 |
12 | expect(element).toBeInTheDocument();
13 | expect(element).toHaveClass('active');
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/Backdrop/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export interface BackdropProps {
5 | className?: string;
6 | active?: boolean;
7 | onClick?: React.MouseEventHandler;
8 | }
9 |
10 | export const Backdrop = (props: BackdropProps) => {
11 | const { className, active, onClick, ...rest } = props;
12 | return (
13 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/components/Backdrop/style.less:
--------------------------------------------------------------------------------
1 | .Backdrop {
2 | position: fixed;
3 | bottom: 0;
4 | left: 0;
5 | right: 0;
6 | z-index: @zindex-backdrop;
7 | transition: 0.3s;
8 | width: 100vw;
9 | height: 100vh;
10 | background: var(--color-mask);
11 | opacity: 0;
12 | outline: 0;
13 |
14 | &.active {
15 | opacity: 1;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/Bubble/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Bubble } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render bubble', () => {
9 | const content = 'myTestContent';
10 | const type = 'text';
11 |
12 | const { getByText } = render({content});
13 | const element = getByText(content);
14 |
15 | expect(element).toHaveClass(type);
16 | expect(element).toHaveAttribute('data-type', type);
17 | });
18 |
19 | it('should render content', () => {
20 | const text = 'myText';
21 | const { container } = render();
22 | const element = container.querySelector('.Bubble');
23 |
24 | expect(element).toHaveClass('text');
25 | expect(element).toHaveAttribute('data-type', 'text');
26 | expect(element).toHaveTextContent(text);
27 | });
28 |
29 | it('should render custom type', () => {
30 | const text = 'myCustom';
31 | const content = {text};
32 | const type = 'myType';
33 |
34 | const { container } = render({content});
35 | const element = container.querySelector('.Bubble');
36 |
37 | expect(element).toHaveClass(type);
38 | expect(element).toHaveAttribute('data-type', type);
39 | expect(element).toHaveTextContent(text);
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/components/Bubble/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export interface BubbleProps extends React.HTMLAttributes {
4 | type?: string;
5 | content?: string;
6 | }
7 |
8 | export const Bubble = React.forwardRef((props, ref) => {
9 | const { type = 'text', content, children, ...other } = props;
10 | return (
11 |
12 | {content &&
{content}
}
13 | {children}
14 |
15 | );
16 | });
17 |
--------------------------------------------------------------------------------
/src/components/Bubble/style.less:
--------------------------------------------------------------------------------
1 | .Bubble {
2 | max-width: 512px;
3 | // min-width: 0;
4 | min-width: 1Px; // for IE bug
5 | background: var(--color-fill-1);
6 | border-radius: var(--radius-md);
7 |
8 | &.text,
9 | &.typing,
10 | &.richtext {
11 | padding: 12px;
12 | box-sizing: border-box;
13 | word-wrap: break-word;
14 | overflow-wrap: break-word;
15 | }
16 | &.text {
17 | min-width: 40px;
18 | white-space: pre-wrap;
19 | }
20 | &.image {
21 | img {
22 | display: block;
23 | max-width: 180px;
24 | min-width: 80px;
25 | max-height: 180px;
26 | min-height: 80px;
27 | object-fit: cover;
28 | height: auto;
29 | border-radius: inherit;
30 | }
31 | }
32 | p {
33 | margin: 0;
34 | }
35 | }
36 |
37 | [data-effect='typing'] {
38 | position: relative;
39 | overflow: hidden;
40 |
41 | h1,
42 | h2,
43 | h3,
44 | h4,
45 | h5,
46 | h6,
47 | p,
48 | ol:last-child li,
49 | ul:last-child li {
50 | &:last-child:after {
51 | content: '';
52 | width: 80px;
53 | height: 1.5em;
54 | background: linear-gradient(90deg, transparent, var(--color-fill-1));
55 | position: absolute;
56 | margin-left: -80px;
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/Card/Card.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type CardSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
5 |
6 | export interface CardProps {
7 | className?: string;
8 | size?: CardSize;
9 | fluid?: boolean | 'order';
10 | children?: React.ReactNode;
11 | }
12 |
13 | export const Card = React.forwardRef((props, ref) => {
14 | const { className, size, fluid, children, ...other } = props;
15 |
16 | return (
17 |
23 | {children}
24 |
25 | );
26 | });
27 |
--------------------------------------------------------------------------------
/src/components/Card/CardActions.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type CardActionsProps = {
5 | className?: string;
6 | direction?: 'column' | 'row';
7 | };
8 |
9 | export const CardActions: React.FC = (props) => {
10 | const { children, className, direction, ...other } = props;
11 | return (
12 |
16 | {children}
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/components/Card/CardContent.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type CardContentProps = {
5 | className?: string;
6 | };
7 |
8 | export const CardContent: React.FC = (props) => {
9 | const { className, children, ...other } = props;
10 | return (
11 |
12 | {children}
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/Card/CardMedia.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { Flex } from '../Flex';
4 |
5 | export type CardMediaProps = {
6 | className?: string;
7 | aspectRatio?: 'square' | 'wide';
8 | color?: string;
9 | image?: string;
10 | };
11 |
12 | export const CardMedia: React.FC = (props) => {
13 | const { className, aspectRatio = 'square', color, image, children, ...other } = props;
14 |
15 | const bgStyle = {
16 | backgroundColor: color || undefined,
17 | backgroundImage: typeof image === 'string' ? `url('${image}')` : undefined,
18 | };
19 |
20 | return (
21 |
33 | {children && (
34 |
35 | {children}
36 |
37 | )}
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/src/components/Card/CardText.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type CardTextProps = {
5 | className?: string;
6 | };
7 |
8 | export const CardText: React.FC = (props) => {
9 | const { className, children, ...other } = props;
10 | return (
11 |
12 | {typeof children === 'string' ?
{children}
: children}
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/Card/CardTitle.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type CardTitleProps = {
5 | className?: string;
6 | title?: React.ReactNode;
7 | subtitle?: React.ReactNode;
8 | center?: boolean;
9 | };
10 |
11 | export const CardTitle: React.FC = (props) => {
12 | const { className, title, subtitle, center, children, ...other } = props;
13 | return (
14 |
15 | {title &&
{title}
}
16 | {children && typeof children === 'string' &&
{children}
}
17 | {subtitle &&
{subtitle}
}
18 | {children && typeof children !== 'string' && children}
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/components/Card/__tests__/actions.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { CardActions } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render CardActions', () => {
9 | const text = 'myText';
10 |
11 | const { getByText } = render({text});
12 | const element = getByText(text);
13 |
14 | expect(element).toBeInTheDocument();
15 | expect(element).toHaveClass('CardActions');
16 | });
17 |
18 | it('should apply direction class', () => {
19 | const text = 'myText';
20 |
21 | const { getByText } = render({text});
22 | const element = getByText(text);
23 |
24 | expect(element).toHaveClass('CardActions--column');
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/components/Card/__tests__/content.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { CardContent } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render CardContent', () => {
9 | const text = 'myText';
10 |
11 | const { getByText } = render({text});
12 | const element = getByText(text);
13 |
14 | expect(element).toBeInTheDocument();
15 | expect(element).toHaveClass('CardContent');
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/src/components/Card/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Card } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render card', () => {
9 | const text = 'myCard';
10 |
11 | const { getByText } = render({text});
12 | const element = getByText(text);
13 |
14 | expect(element).toBeInTheDocument();
15 | expect(element).toHaveClass('Card');
16 | });
17 |
18 | it('should apply size class', () => {
19 | const text = 'myCard';
20 |
21 | const { getByText } = render({text});
22 | const element = getByText(text);
23 |
24 | expect(element).toHaveClass('Card--sm');
25 | });
26 |
27 | it('should be fluid', () => {
28 | const text = 'myCard';
29 |
30 | const { getByText } = render({text});
31 | const element = getByText(text);
32 |
33 | expect(element).toHaveClass('Card--fluid');
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/components/Card/__tests__/media.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { CardMedia } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render CardMedia', () => {
9 | const text = 'myText';
10 |
11 | const { getByText } = render({text});
12 | const element = getByText(text);
13 |
14 | expect(element).toBeInTheDocument();
15 | });
16 |
17 | it('should apply aspectRatio class', () => {
18 | const { container } = render(square);
19 | const element = container.querySelector('.CardMedia');
20 |
21 | expect(element).toHaveClass('CardMedia--square');
22 | });
23 |
24 | it('should render color', () => {
25 | const { container } = render(color);
26 | const element = container.querySelector('.CardMedia');
27 |
28 | expect(element).toHaveStyle({ backgroundColor: 'red' });
29 | });
30 |
31 | it('should render image', () => {
32 | const url = '//gw.alicdn.com/tfs/TB17TaySSzqK1RjSZFHXXb3CpXa-80-80.svg';
33 | const { container } = render(image);
34 | const element = container.querySelector('.CardMedia');
35 |
36 | expect(element).toHaveStyle({ backgroundImage: `url(${url})` });
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/src/components/Card/__tests__/text.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { CardText } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render CardText', () => {
9 | const { container } = render(myText);
10 | const element = container.querySelector('p');
11 |
12 | expect(element).toBeInTheDocument();
13 | });
14 |
15 | it('should render with children', () => {
16 | const { getByText } = render(
17 |
18 | myText
19 | ,
20 | );
21 | const element = getByText('myText');
22 |
23 | expect(element).toBeInTheDocument();
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/Card/index.ts:
--------------------------------------------------------------------------------
1 | export { Card } from './Card';
2 | export type { CardProps, CardSize } from './Card';
3 | export { CardMedia } from './CardMedia';
4 | export type { CardMediaProps } from './CardMedia';
5 | export { CardContent } from './CardContent';
6 | export type { CardContentProps } from './CardContent';
7 | export { CardHeader } from './CardHeader';
8 | export type { CardHeaderProps } from './CardHeader';
9 | export { CardTitle } from './CardTitle';
10 | export type { CardTitleProps } from './CardTitle';
11 | export { CardText } from './CardText';
12 | export type { CardTextProps } from './CardText';
13 | export { CardActions } from './CardActions';
14 | export type { CardActionsProps } from './CardActions';
15 |
--------------------------------------------------------------------------------
/src/components/Carousel/Item.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface CarouselItemProps {
4 | width: string;
5 | }
6 |
7 | export const CarouselItem: React.FC = (props) => {
8 | const { width, children } = props;
9 |
10 | return (
11 |
12 | {children}
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/Carousel/style.less:
--------------------------------------------------------------------------------
1 | .Carousel {
2 | position: relative;
3 | overflow: hidden;
4 |
5 | &--draggable {
6 | .Carousel-inner {
7 | touch-action: pan-y;
8 | cursor: grab;
9 |
10 | &:active {
11 | cursor: grabbing;
12 | }
13 | }
14 | }
15 | &--rtl {
16 | direction: rtl;
17 | }
18 | &--dragging {
19 | .Carousel-item {
20 | pointer-events: none;
21 | }
22 | }
23 | &-inner {
24 | display: flex;
25 | will-change: transform;
26 | }
27 | &-dots {
28 | position: absolute;
29 | z-index: 1;
30 | bottom: @carousel-dots-bottom;
31 | left: 50%;
32 | transform: translateX(-50%);
33 | display: flex;
34 | justify-content: center;
35 | list-style-type: none;
36 | margin: 0;
37 | padding: 0;
38 | }
39 | &-dot {
40 | display: block;
41 | width: @carousel-dot-width;
42 | height: @carousel-dot-height;
43 | margin: @carousel-dot-margin;
44 | padding: @carousel-dot-padding;
45 | border: @carousel-dot-border;
46 | border-radius: @carousel-dot-border-radius;
47 | background: var(--color-fill-2);
48 | transition: @carousel-dot-transition;
49 | cursor: pointer;
50 |
51 | &.active {
52 | background: var(--brand-1);
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/Chat/style.less:
--------------------------------------------------------------------------------
1 | & when (@global-style = true) {
2 | html {
3 | height: 100vh;
4 |
5 | &[data-safari] {
6 | height: calc(100vh - calc(100vh - 100%));
7 | }
8 | }
9 |
10 | body,
11 | #root {
12 | height: 100%;
13 | }
14 |
15 | body {
16 | overflow: hidden;
17 | margin: 0;
18 | }
19 |
20 | @media (hover: none) {
21 | body {
22 | user-select: none;
23 | -webkit-touch-callout: none;
24 | }
25 | }
26 | }
27 |
28 | .ChatApp {
29 | display: flex;
30 | flex-direction: column;
31 | height: 100%;
32 | background: var(--app-bg);
33 | color: @body-color;
34 | font-family: @font-family-base;
35 | line-height: @line-height-base;
36 | -webkit-tap-highlight-color: transparent;
37 | }
38 |
39 | .S--focusing {
40 | --safe-bottom: 0px;
41 | }
42 |
43 | // only for iOS
44 | @supports (-webkit-touch-callout: none) {
45 | .S--focusing {
46 | .MessageList {
47 | margin-top: 75vh;
48 | }
49 | }
50 | }
51 |
52 | .ChatFooter {
53 | position: relative;
54 | z-index: @zindex-footer;
55 | padding-bottom: var(--safe-bottom);
56 | background: var(--footer-bg);
57 | }
58 |
59 | @media (max-width: 374px) {
60 | :root {
61 | --msg-avatar-gap: 3Px;
62 | --rate-width: 24Px;
63 | }
64 | .MessageList {
65 | padding-left: 6px;
66 | padding-right: 6px;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/Checkbox/Checkbox.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type CheckboxValue = string | number | undefined;
5 |
6 | export type CheckboxProps = React.InputHTMLAttributes & {
7 | value?: CheckboxValue;
8 | label?: CheckboxValue;
9 | };
10 |
11 | export const Checkbox: React.FC = (props) => {
12 | const { className, label, checked, disabled, onChange, ...other } = props;
13 | return (
14 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/components/Checkbox/CheckboxGroup.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { Checkbox, CheckboxProps, CheckboxValue } from './Checkbox';
4 |
5 | export type CheckboxGroupProps = {
6 | className?: string;
7 | options: CheckboxProps[];
8 | value: CheckboxValue[];
9 | name?: string;
10 | disabled?: boolean;
11 | block?: boolean;
12 | onChange: (value: CheckboxValue[], event: React.ChangeEvent) => void;
13 | };
14 |
15 | export const CheckboxGroup: React.FC = (props) => {
16 | const { className, options, value, name, disabled, block, onChange } = props;
17 |
18 | function handleChange(val: CheckboxValue, e: React.ChangeEvent) {
19 | const newValue = e.target.checked ? value.concat(val) : value.filter((item) => item !== val);
20 | onChange(newValue, e);
21 | }
22 |
23 | return (
24 |
25 | {options.map((item) => (
26 | {
33 | handleChange(item.value, e);
34 | }}
35 | key={item.value}
36 | />
37 | ))}
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/src/components/Checkbox/index.ts:
--------------------------------------------------------------------------------
1 | export { Checkbox } from './Checkbox';
2 | export type { CheckboxProps, CheckboxValue } from './Checkbox';
3 | export { CheckboxGroup } from './CheckboxGroup';
4 | export type { CheckboxGroupProps } from './CheckboxGroup';
5 |
--------------------------------------------------------------------------------
/src/components/ClickOutside/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 |
3 | const doc = document;
4 | const html = doc.documentElement;
5 |
6 | export type ClickOutsideProps = {
7 | onClick: (event: React.MouseEvent) => void;
8 | // mouseEvent?: 'click' | 'mousedown' | 'mouseup' | false;
9 | mouseEvent?: 'click' | 'mousedown' | 'mouseup';
10 | };
11 |
12 | export const ClickOutside: React.FC = (props) => {
13 | const { children, onClick, mouseEvent = 'mouseup', ...others } = props;
14 | const wrapper = useRef(null!);
15 |
16 | function handleClick(e: any) {
17 | if (!wrapper.current) return;
18 |
19 | if (html.contains(e.target) && !wrapper.current.contains(e.target)) {
20 | onClick(e);
21 | }
22 | }
23 |
24 | useEffect(() => {
25 | if (mouseEvent) {
26 | doc.addEventListener(mouseEvent, handleClick);
27 | }
28 | return () => {
29 | doc.removeEventListener(mouseEvent, handleClick);
30 | };
31 | });
32 |
33 | return (
34 |
35 | {children}
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/src/components/ComponentsProvider/ComponentsContext.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ComponentsContextInterface } from './interface';
3 |
4 | export const ComponentsContext = React.createContext({
5 | addComponent: () => {},
6 | hasComponent: () => false,
7 | getComponent: () => null,
8 | });
9 |
--------------------------------------------------------------------------------
/src/components/ComponentsProvider/useComponents.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ComponentsContext } from './ComponentsContext';
3 |
4 | export function useComponents() {
5 | return React.useContext(ComponentsContext);
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/Composer/AccessoryWrap.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ClickOutside } from '../ClickOutside';
3 |
4 | interface AccessoryWrapProps {
5 | onClickOutside: () => void;
6 | children: React.ReactNode;
7 | }
8 |
9 | export const AccessoryWrap = ({ onClickOutside, children }: AccessoryWrapProps) => (
10 | {children}
11 | );
12 |
--------------------------------------------------------------------------------
/src/components/Composer/Action.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IconButton, IconButtonProps } from '../IconButton';
3 |
4 | export const Action = (props: IconButtonProps) => (
5 |
6 |
7 |
8 | );
9 |
--------------------------------------------------------------------------------
/src/components/Composer/SendButton.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import { Button } from '../Button';
3 | import { useLocale } from '../ConfigProvider';
4 |
5 | interface SendButtonProps {
6 | disabled?: boolean;
7 | onClick: (e: React.MouseEvent) => void;
8 | }
9 |
10 | export const SendButton = ({ disabled, onClick }: SendButtonProps) => {
11 | const { trans } = useLocale('Composer');
12 | const wrapRef = useRef(null);
13 | const btnRef = useRef(null);
14 |
15 | useEffect(() => {
16 | const wrap = wrapRef.current;
17 | const btn = btnRef.current;
18 | if (wrap && btn) {
19 | wrap.style.setProperty('--send-width', `${btn.offsetWidth}px`);
20 | }
21 | }, [])
22 |
23 | return (
24 |
25 |
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/Composer/ToolbarItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ToolbarItemProps } from '../Toolbar';
3 | import { Action } from './Action';
4 |
5 | type IToolbarItem = {
6 | item: ToolbarItemProps;
7 | onClick: (event: React.MouseEvent) => void;
8 | };
9 |
10 | export const ToolbarItem: React.FC = (props) => {
11 | const { item, onClick } = props;
12 |
13 | return (
14 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/Composer/viewportTop.ts:
--------------------------------------------------------------------------------
1 | const rootEl = document.documentElement;
2 | let chatApp: HTMLElement | null;
3 | let requestID: number;
4 | let viewportTop = 0;
5 |
6 | export function setViewportTop(top: number) {
7 | cancelAnimationFrame(requestID);
8 | rootEl.style.setProperty('--viewport-top', `${top}px`);
9 | }
10 |
11 | export function updateViewportTop() {
12 | if (!chatApp) {
13 | chatApp = document.querySelector('.ChatApp');
14 | }
15 |
16 | if (!chatApp) return;
17 |
18 | const { top } = chatApp.getBoundingClientRect();
19 |
20 | if (top === 0) {
21 | requestID = requestAnimationFrame(updateViewportTop);
22 | } else {
23 | viewportTop = Math.abs(top);
24 | setViewportTop(viewportTop);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/ConfigProvider/locales/ar_EG.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | BackBottom: {
3 | newMsgOne: '{n} رسالة جديدة',
4 | newMsgOther: '{n} رسالة جديدة',
5 | bottom: 'الأسفل',
6 | },
7 | Time: {
8 | weekdays: 'الأحد_الإثنين_الثلاثاء_الأربعاء_الخميس_الجمعة_السبت'.split('_'),
9 | formats: {
10 | LT: 'HH:mm',
11 | lll: 'YYYY/M/D HH:mm',
12 | WT: 'HH:mm dddd',
13 | YT: 'HH:mm أمس',
14 | },
15 | },
16 | Composer: {
17 | send: 'إرسال',
18 | },
19 | SendConfirm: {
20 | title: 'إرسال صورة',
21 | send: 'أرسل',
22 | cancel: 'إلغاء',
23 | },
24 | RateActions: {
25 | up: 'التصويت',
26 | down: 'تصويت سلبي',
27 | },
28 | Recorder: {
29 | hold2talk: 'أستمر بالضغط لتتحدث',
30 | release2send: 'حرر للإرسال',
31 | releaseOrSwipe: 'حرر للإرسال ، اسحب لأعلى للإلغاء',
32 | release2cancel: 'حرر للإلغاء',
33 | },
34 | Search: {
35 | search: 'يبحث',
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/src/components/ConfigProvider/locales/en_US.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | BackBottom: {
3 | newMsgOne: '{n} new message',
4 | newMsgOther: '{n} new messages',
5 | bottom: 'Bottom',
6 | },
7 | Time: {
8 | weekdays: 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'),
9 | formats: {
10 | LT: 'HH:mm',
11 | lll: 'M/D/YYYY HH:mm',
12 | WT: 'dddd HH:mm',
13 | YT: 'Yesterday HH:mm',
14 | },
15 | },
16 | Composer: {
17 | send: 'Send',
18 | },
19 | SendConfirm: {
20 | title: 'Send photo',
21 | send: 'Send',
22 | cancel: 'Cancel',
23 | },
24 | RateActions: {
25 | up: 'Up vote',
26 | down: 'Down vote',
27 | },
28 | Recorder: {
29 | hold2talk: 'Hold to Talk',
30 | release2send: 'Release to Send',
31 | releaseOrSwipe: 'Release to send, swipe up to cancel',
32 | release2cancel: 'Release to cancel',
33 | },
34 | Search: {
35 | search: 'Search',
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/src/components/ConfigProvider/locales/fr_FR.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | BackBottom: {
3 | newMsgOne: '{n} nouveau message',
4 | newMsgOther: '{n} nouveau messages',
5 | bottom: 'Fond',
6 | },
7 | Time: {
8 | weekdays: 'Dimanche_Lundi_Mardi_Mercredi_Jeudi_Vendredi_Samedi'.split('_'),
9 | formats: {
10 | LT: 'HH:mm',
11 | lll: 'D/M/YYYY HH:mm',
12 | WT: 'dddd HH:mm',
13 | YT: 'Hier HH:mm',
14 | },
15 | },
16 | Composer: {
17 | send: 'Envoyer',
18 | },
19 | SendConfirm: {
20 | title: 'Envoyer une photo',
21 | send: 'Envoyer',
22 | cancel: 'Annuler',
23 | },
24 | RateActions: {
25 | up: 'Voter pour',
26 | down: 'Vote négatif',
27 | },
28 | Recorder: {
29 | hold2talk: 'Tenir pour parler',
30 | release2send: 'Libérer pour envoyer',
31 | releaseOrSwipe: 'Relâchez pour envoyer, balayez vers le haut pour annuler',
32 | release2cancel: 'Relâcher pour annuler',
33 | },
34 | Search: {
35 | search: 'Chercher',
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/src/components/ConfigProvider/locales/index.ts:
--------------------------------------------------------------------------------
1 | import arEG from './ar_EG';
2 | import enUS from './en_US';
3 | import frFR from './fr_FR';
4 | import zhCN from './zh_CN';
5 |
6 | export default {
7 | 'ar-EG': arEG, // 阿拉伯
8 | 'fr-FR': frFR, // 法语
9 | 'en-US': enUS, // 英语(美式)
10 | 'zh-CN': zhCN, // 简体中文
11 | } as Record;
12 |
--------------------------------------------------------------------------------
/src/components/ConfigProvider/locales/zh_CN.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | BackBottom: {
3 | newMsgOne: '{n}条新消息',
4 | newMsgOther: '{n}条新消息',
5 | bottom: '回到底部',
6 | },
7 | Time: {
8 | weekdays: '星期日_星期一_星期二_星期三_星期四_星期五_星期六'.split('_'),
9 | formats: {
10 | LT: 'HH:mm', // 00:32
11 | lll: 'YYYY年M月D日 HH:mm', // 2019年2月22日 00:28
12 | WT: 'dddd HH:mm', // 星期一 00:32
13 | YT: '昨天 HH:mm', // 昨天 00:32
14 | },
15 | },
16 | Composer: {
17 | send: '发送',
18 | },
19 | SendConfirm: {
20 | title: '发送图片',
21 | send: '发送',
22 | cancel: '取消',
23 | },
24 | RateActions: {
25 | up: '赞同',
26 | down: '反对',
27 | },
28 | Recorder: {
29 | hold2talk: '按住 说话',
30 | release2send: '松开 发送',
31 | releaseOrSwipe: '松开发送,上滑取消',
32 | release2cancel: '松开手指,取消发送',
33 | },
34 | Search: {
35 | search: '搜索',
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/src/components/Divider/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Divider } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render the children', () => {
9 | const { getByTestId } = render(
10 |
11 |
12 | ,
13 | );
14 | const wrap = getByTestId('wrap');
15 | const inside = getByTestId('inside');
16 |
17 | expect(wrap).toContainElement(inside);
18 | });
19 |
20 | it('should apply position class', () => {
21 | const { getByTestId } = render(
22 |
23 | testText
24 | ,
25 | );
26 | const wrap = getByTestId('wrap');
27 |
28 | expect(wrap).toHaveClass('Divider--text-center');
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/components/Divider/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type DividerProps = {
5 | className?: string;
6 | position?: 'center' | 'left' | 'right';
7 | };
8 |
9 | export const Divider: React.FC = (props) => {
10 | const { className, position = 'center', children, ...other } = props;
11 | return (
12 |
17 | {children}
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/components/Divider/style.less:
--------------------------------------------------------------------------------
1 | .Divider {
2 | display: flex;
3 | align-items: center;
4 | margin: 12px 0;
5 | font-size: @font-size-xs;
6 | color: var(--color-text-3);
7 |
8 | &:before,
9 | &:after {
10 | content: '';
11 | display: block;
12 | flex: 1;
13 | border-top: 1px solid var(--color-line-1);
14 |
15 | @media (hover: none) {
16 | & {
17 | transform: scaleY(0.5);
18 | }
19 | }
20 | }
21 | }
22 |
23 | .Divider--text-center,
24 | .Divider--text-left,
25 | .Divider--text-right {
26 | &:before {
27 | margin-right: var(--gutter);
28 | }
29 | &:after {
30 | margin-left: var(--gutter);
31 | }
32 | }
33 |
34 | .Divider--text-left {
35 | &:before {
36 | max-width: 10%;
37 | }
38 | }
39 |
40 | .Divider--text-right {
41 | &:after {
42 | max-width: 10%;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/Empty/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { Flex } from '../Flex';
4 |
5 | export type EmptyProps = {
6 | className?: string;
7 | type?: 'error' | 'default';
8 | image?: string;
9 | tip?: string;
10 | };
11 |
12 | const IMAGE_EMPTY = 'https://gw.alicdn.com/imgextra/i3/O1CN01c0BqGH1Jx6L1ihheM_!!6000000001094-55-tps-280-280.svg';
13 | const IMAGE_OOPS = 'https://gw.alicdn.com/imgextra/i3/O1CN011bYju01hGYK2LMydz_!!6000000004250-55-tps-280-280.svg';
14 |
15 | export const Empty: React.FC = (props) => {
16 | const { className, type, image, tip, children } = props;
17 | const imgUrl = image || (type === 'error' ? IMAGE_OOPS : IMAGE_EMPTY);
18 |
19 | return (
20 |
21 |
22 | {tip && {tip}
}
23 | {children}
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/src/components/Empty/style.less:
--------------------------------------------------------------------------------
1 | .Empty {
2 | padding: 30px;
3 | text-align: center;
4 | }
5 |
6 | .Empty-img {
7 | height: 125px;
8 | }
9 |
10 | .Empty-tip {
11 | margin: 20px 0;
12 | color: var(--color-text-2);
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/FileCard/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { FileCard } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | const file = new File(['foo'], 'foo.txt', { type: 'text/plain' });
8 |
9 | describe('', () => {
10 | it('should render the file', () => {
11 | const { container } = render();
12 |
13 | expect(container.querySelector('.FileCard-ext')).toHaveTextContent('txt');
14 | expect(container.querySelector('.FileCard-icon')).toHaveAttribute('data-type', 'txt');
15 | expect(container.querySelector('.FileCard-name')).toHaveTextContent('foo.txt');
16 | expect(container.querySelector('.FileCard-size')).toHaveTextContent('3 B');
17 | });
18 |
19 | it('should apply the extension', () => {
20 | const { container } = render();
21 |
22 | expect(container.querySelector('.FileCard-ext')).toHaveTextContent('jpg');
23 | expect(container.querySelector('.FileCard-icon')).toHaveAttribute('data-type', 'jpg');
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/FileCard/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { Card } from '../Card';
4 | import { Flex, FlexItem } from '../Flex';
5 | import { Icon } from '../Icon';
6 | import { Text } from '../Text';
7 | import getExtName from '../../utils/getExtName';
8 | import prettyBytes from '../../utils/prettyBytes';
9 |
10 | export interface FileCardProps {
11 | className?: string;
12 | file: File;
13 | extension?: string;
14 | }
15 |
16 | export const FileCard: React.FC = (props) => {
17 | const { className, file, extension, children } = props;
18 | const { name, size } = file;
19 | const ext = extension || getExtName(name);
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 | {ext}
28 |
29 |
30 |
31 |
32 | {name}
33 |
34 |
35 | {size != null && {prettyBytes(size)}}
36 | {children}
37 |
38 |
39 |
40 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/src/components/FileCard/style.less:
--------------------------------------------------------------------------------
1 | .FileCard {
2 | padding: 8px;
3 | }
4 |
5 | .FileCard-icon {
6 | position: relative;
7 | height: 60px;
8 | margin-right: 8px;
9 | color: var(--color-text-2);
10 |
11 | &[data-type='pdf'] {
12 | color: var(--red);
13 | }
14 | &[data-type*='doc'] {
15 | color: var(--blue);
16 | }
17 | &[data-type*='ppt'],
18 | &[data-type='key'] {
19 | color: var(--orange);
20 | }
21 | &[data-type*='xls'] {
22 | color: var(--green);
23 | }
24 | &[data-type='rar'],
25 | &[data-type='zip'] {
26 | color: var(--brand-1);
27 | }
28 | .Icon {
29 | font-size: 60px;
30 | }
31 | }
32 |
33 | .FileCard-name {
34 | height: 38px;
35 | margin-bottom: 4px;
36 | line-height: 1.4;
37 | }
38 |
39 | .FileCard-ext {
40 | position: absolute;
41 | left: 20px;
42 | bottom: 15px;
43 | transform-origin: left bottom;
44 | transform: scale(0.5);
45 | max-width: 50px;
46 | font-size: @font-size-md;
47 | font-weight: 700;
48 | text-transform: uppercase;
49 | }
50 |
51 | .FileCard-meta {
52 | color: var(--color-text-3);
53 | font-size: @font-size-xs;
54 |
55 | & > a,
56 | & > span {
57 | margin-right: 10px;
58 | }
59 | a {
60 | color: var(--link-color);
61 | text-decoration: none;
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/Flex/FlexItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export interface FlexItemProps extends React.HTMLAttributes {
5 | className?: string;
6 | flex?: string;
7 | alignSelf?: 'auto' | 'flex-start' | 'flex-end' | 'center' | 'baseline' | 'stretch';
8 | order?: number;
9 | }
10 |
11 | export const FlexItem: React.FC = (props) => {
12 | const { className, flex, alignSelf, order, style, children, ...other } = props;
13 | return (
14 |
24 | {children}
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/Flex/index.ts:
--------------------------------------------------------------------------------
1 | export { Flex } from './Flex';
2 | export type { FlexProps } from './Flex';
3 | export { FlexItem } from './FlexItem';
4 | export type { FlexItemProps } from './FlexItem';
5 |
--------------------------------------------------------------------------------
/src/components/Flex/style.less:
--------------------------------------------------------------------------------
1 | .Flex {
2 | display: flex;
3 | }
4 |
5 | .Flex--inline {
6 | display: inline-flex;
7 | }
8 |
9 | .Flex--center {
10 | justify-content: center;
11 | align-items: center;
12 | }
13 |
14 | .Flex--d-r {
15 | flex-direction: row;
16 | }
17 |
18 | .Flex--d-rr {
19 | flex-direction: row-reverse;
20 | }
21 |
22 | .Flex--d-c {
23 | flex-direction: column;
24 | }
25 |
26 | .Flex--d-cr {
27 | flex-direction: column-reverse;
28 | }
29 |
30 | .Flex--w-n {
31 | flex-wrap: nowrap;
32 | }
33 |
34 | .Flex--w-w {
35 | flex-wrap: wrap;
36 | }
37 |
38 | .Flex--w-wr {
39 | flex-wrap: wrap-reverse;
40 | }
41 |
42 | .Flex--jc-fs {
43 | justify-content: flex-start;
44 | }
45 |
46 | .Flex--jc-fe {
47 | justify-content: flex-end;
48 | }
49 |
50 | .Flex--jc-c {
51 | justify-content: center;
52 | }
53 |
54 | .Flex--jc-sb {
55 | justify-content: space-between;
56 | }
57 |
58 | .Flex--jc-sa {
59 | justify-content: space-around;
60 | }
61 |
62 | .Flex--ai-fs {
63 | align-items: flex-start;
64 | }
65 |
66 | .Flex--ai-fe {
67 | align-items: flex-end;
68 | }
69 |
70 | .Flex--ai-c {
71 | align-items: center;
72 | }
73 |
74 | .FlexItem {
75 | flex: 1;
76 | min-width: 0;
77 | min-height: 0;
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/Form/Form.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type FormProps = {
5 | className?: string;
6 | /** @deprecated Use ``'s `variant` instead */
7 | theme?: string;
8 | };
9 |
10 | export const ThemeContext = React.createContext('');
11 |
12 | export const Form: React.FC = (props) => {
13 | const { className, theme = '', children, ...other } = props;
14 | return (
15 |
16 |
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/components/Form/FormActions.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export const FormActions: React.FC = (props) => {
5 | const { children, ...other } = props;
6 | return (
7 |
8 | {children}
9 |
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/src/components/Form/FormItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { Label } from '../Label';
4 | import { HelpText } from '../HelpText';
5 |
6 | export type FormItemProps = {
7 | label?: string | React.ReactNode;
8 | help?: string;
9 | required?: boolean;
10 | invalid?: boolean;
11 | hidden?: boolean;
12 | };
13 |
14 | export const FormItem: React.FC = (props) => {
15 | const { label, help, required, invalid, hidden, children } = props;
16 | return (
17 |
18 | {label && }
19 | {children}
20 | {help && {help}}
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/Form/__tests__/actions.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { FormActions } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render a form actions', () => {
9 | const { getByTestId } = render();
10 | const formActions = getByTestId('formActions');
11 |
12 | expect(formActions).toHaveClass('FormActions');
13 | });
14 |
15 | it('should render children', () => {
16 | const { getByTestId } = render(
17 |
18 | testChild
19 | ,
20 | );
21 | const formActions = getByTestId('formActions');
22 |
23 | expect(formActions).toContainHTML('testChild');
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/Form/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Form } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render a form', () => {
9 | const { getByTestId } = render();
10 | const form = getByTestId('form');
11 |
12 | expect(form).toBeInTheDocument();
13 | });
14 |
15 | it('should render a form (light)', () => {
16 | const { getByTestId } = render();
17 | const form = getByTestId('form');
18 |
19 | expect(form).toHaveClass('is-light');
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/src/components/Form/index.ts:
--------------------------------------------------------------------------------
1 | export { Form, ThemeContext } from './Form';
2 | export type { FormProps } from './Form';
3 | export { FormItem } from './FormItem';
4 | export type { FormItemProps } from './FormItem';
5 | export { FormActions } from './FormActions';
6 |
--------------------------------------------------------------------------------
/src/components/Form/style.less:
--------------------------------------------------------------------------------
1 | .Form {
2 | background: var(--color-fill-1);
3 |
4 | &.is-light {
5 | // background: var(--gray-7);
6 |
7 | .FormItem {
8 | padding: 0;
9 | }
10 | .Label,
11 | .HelpText {
12 | padding: 0 var(--gutter);
13 | }
14 | }
15 | }
16 |
17 | .FormItem {
18 | position: relative;
19 | padding: 0 var(--gutter);
20 |
21 | & + & {
22 | margin-top: 20px;
23 | }
24 | &.required {
25 | .Label:after {
26 | content: '*';
27 | display: inline-block;
28 | color: var(--red);
29 | font-size: @font-size-sm;
30 | font-family: SimSun,sans-serif;
31 | line-height: 1;
32 | vertical-align: middle;
33 | }
34 | }
35 | &.is-invalid {
36 | .Label,
37 | .HelpText {
38 | color: var(--red);
39 | }
40 | .Input {
41 | border-color: var(--red);
42 | }
43 | }
44 | .RadioGroup,
45 | .CheckboxGroup {
46 | margin-top: 10px;
47 | }
48 | .Label + .Input {
49 | margin-top: 5px;
50 | }
51 | }
52 |
53 | .FormActions {
54 | display: flex;
55 | padding: 10px var(--gutter);
56 | background: var(--color-fill-1);
57 |
58 | .Btn {
59 | flex: 1;
60 | }
61 | .Btn + .Btn {
62 | margin-left: 6px;
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/HelpText/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { HelpText } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render the children', () => {
9 | const { container, getByTestId } = render(
10 |
11 |
12 | ,
13 | );
14 | const wrap = container.querySelector('.HelpText');
15 | const inside = getByTestId('inside');
16 |
17 | expect(wrap).toContainElement(inside);
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/src/components/HelpText/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export const HelpText: React.FC = (props) => {
4 | const { children, ...others } = props;
5 | return (
6 |
7 | {children}
8 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/src/components/HelpText/style.less:
--------------------------------------------------------------------------------
1 | .HelpText {
2 | font-size: @font-size-xs;
3 | color: var(--color-text-2);
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/Icon/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type IconProps = React.SVGProps & {
5 | type: string;
6 | className?: string;
7 | name?: string;
8 | spin?: boolean;
9 | };
10 |
11 | export const Icon: React.FC = (props) => {
12 | const { type, className, spin, name, ...other } = props;
13 | const ariaProps = typeof name === 'string' ? { 'aria-label': name } : { 'aria-hidden': true };
14 |
15 | return (
16 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/components/Icon/style.less:
--------------------------------------------------------------------------------
1 | .Icon {
2 | display: inline-block;
3 | width: 1em;
4 | height: 1em;
5 | stroke-width: 0;
6 | fill: currentColor;
7 | transition: all 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28);
8 | }
9 |
10 | .is-spin {
11 | animation: spin 1s infinite linear;
12 | }
13 |
14 | @keyframes spin {
15 | 0% {
16 | transform: rotate(0deg);
17 | }
18 | to {
19 | transform: rotate(1turn);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/IconButton/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { IconButton } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render the icon', () => {
9 | const { getByTestId } = render();
10 | const btn = getByTestId('btn');
11 |
12 | expect(btn?.querySelector('use')).toHaveAttribute('xlink:href', '#icon-foo');
13 | });
14 |
15 | it('should have a custom className', () => {
16 | const { getByTestId } = render(
17 | ,
18 | );
19 | const btn = getByTestId('btn');
20 |
21 | expect(btn).toHaveClass('testName');
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/components/IconButton/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { Button, ButtonProps } from '../Button';
4 | import { Icon } from '../Icon';
5 |
6 | export interface IconButtonProps extends ButtonProps {
7 | img?: string;
8 | }
9 |
10 | export const IconButton: React.FC = (props) => {
11 | const { className, icon, img, ...other } = props;
12 | return (
13 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/components/IconButton/style.less:
--------------------------------------------------------------------------------
1 | .IconBtn {
2 | min-width: 0;
3 | padding: 0;
4 | border: 0;
5 | border-radius: @icon-button-border-radius;
6 | background: @icon-button-bg;
7 | color: @icon-button-color;
8 | font-size: @icon-button-size;
9 |
10 | &.Btn--primary {
11 | color: @icon-button-primary-color;
12 | }
13 | &.Btn--lg {
14 | border-radius: @icon-button-lg-border-radius;
15 | font-size: @icon-button-lg-size;
16 | }
17 | & > .Icon {
18 | display: block;
19 | }
20 | & > img {
21 | display: block;
22 | width: 1em;
23 | height: 1em;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/Image/style.less:
--------------------------------------------------------------------------------
1 | .Image {
2 | position: relative;
3 | display: inline-block;
4 | overflow: hidden;
5 | border-style: none;
6 | }
7 |
8 | .Image--fluid {
9 | max-width: 100%;
10 | height: auto;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/InfiniteScroll/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import useForwardRef from '../../hooks/useForwardRef';
4 | import getToBottom from '../../utils/getToBottom';
5 |
6 | export interface InfiniteScrollProps extends React.HTMLAttributes {
7 | className?: string;
8 | disabled?: boolean;
9 | distance?: number;
10 | onLoadMore: () => void;
11 | }
12 |
13 | export const InfiniteScroll = React.forwardRef(
14 | (props, ref) => {
15 | const { className, disabled, distance = 0, children, onLoadMore, onScroll, ...other } = props;
16 | const wrapperRef = useForwardRef(ref);
17 |
18 | function handleScroll(e: React.UIEvent) {
19 | if (onScroll) {
20 | onScroll(e);
21 | }
22 |
23 | const el = wrapperRef.current;
24 | if (!el) return;
25 |
26 | const nearBottom = getToBottom(el) <= distance;
27 |
28 | if (nearBottom) {
29 | onLoadMore();
30 | }
31 | }
32 |
33 | return (
34 |
41 | {children}
42 |
43 | );
44 | },
45 | );
46 |
--------------------------------------------------------------------------------
/src/components/InfiniteScroll/style.less:
--------------------------------------------------------------------------------
1 | .InfiniteScroll {
2 | overflow-y: scroll;
3 | -webkit-overflow-scrolling: touch;
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/Label/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Label } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render label', () => {
9 | const content = 'Hello ChatUI';
10 | const { getByText } = render();
11 | const label = getByText(content);
12 |
13 | expect(label).toBeInTheDocument();
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/Label/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/label-has-associated-control */
2 | import React from 'react';
3 |
4 | export const Label: React.FC = (props) => {
5 | const { children, ...other } = props;
6 |
7 | return (
8 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/src/components/Label/style.less:
--------------------------------------------------------------------------------
1 | .Label {
2 | display: block;
3 | font-size: @font-size-xs;
4 | color: var(--color-text-2);
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/LazyComponent/SuspenseWrap.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from 'react';
2 | import { ErrorBoundary } from '../ErrorBoundary';
3 | import { LazyComponentPropsWithComponent } from './interface';
4 |
5 | export const SuspenseWrap: React.FC = (props) => {
6 | const { component: Comp, onError, fallback, ...rest } = props;
7 |
8 | return Comp ? (
9 |
10 |
11 |
12 |
13 |
14 | ) : null;
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/LazyComponent/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { SuspenseWrap } from './SuspenseWrap';
3 | import {
4 | LazyComponentProps,
5 | LazyComponentPropsWithCode,
6 | LazyComponentOnLoadParams,
7 | } from './interface';
8 | import { useComponents } from '../ComponentsProvider/useComponents';
9 |
10 | export type { LazyComponentProps, LazyComponentOnLoadParams };
11 |
12 | export const LazyComponentWithCode: React.FC = (props) => {
13 | const { code, fallback, onLoad, onError, ...rest } = props;
14 | const { getComponent } = useComponents();
15 |
16 | const Comp = getComponent(code, (res) => {
17 | if ('async' in res && onLoad) {
18 | onLoad(res);
19 | } else if ('errCode' in res && onError) {
20 | onError(new Error(res.errCode));
21 | }
22 | });
23 |
24 | return ;
25 | };
26 |
27 | export const LazyComponent: React.FC = (props) => {
28 | const { component, code, onLoad, ...rest } = props;
29 |
30 | if (component) {
31 | if (onLoad) {
32 | onLoad({ async: false, component });
33 | }
34 | return ;
35 | }
36 |
37 | return ;
38 | };
39 |
40 | export default LazyComponent;
41 |
--------------------------------------------------------------------------------
/src/components/LazyComponent/interface.ts:
--------------------------------------------------------------------------------
1 | interface LazyComponentBaseProps {
2 | fallback?: NonNullable | null;
3 | onError?: (error: Error, info?: React.ErrorInfo) => void;
4 | [k: string]: any;
5 | }
6 |
7 | export interface LazyComponentPropsWithComponent extends LazyComponentBaseProps {
8 | component: React.ComponentType | null;
9 | }
10 |
11 | export interface LazyComponentOnLoadParams {
12 | async: boolean;
13 | component: React.ComponentType;
14 | }
15 |
16 | export interface LazyComponentPropsWithCode extends LazyComponentBaseProps {
17 | code: string;
18 | onLoad?: (e: LazyComponentOnLoadParams) => void;
19 | }
20 |
21 | export type LazyComponentProps = LazyComponentPropsWithComponent | LazyComponentPropsWithCode;
22 |
--------------------------------------------------------------------------------
/src/components/List/List.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type ListProps = {
5 | className?: string;
6 | bordered?: boolean;
7 | variant?: 'buttons';
8 | children?: React.ReactNode;
9 | };
10 |
11 | export const List = React.forwardRef((props, ref) => {
12 | const { bordered = false, className, variant, children } = props;
13 | return (
14 |
20 | {children}
21 |
22 | );
23 | });
24 |
--------------------------------------------------------------------------------
/src/components/List/ListItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { Icon } from '../Icon';
4 | import { Text } from '../Text';
5 |
6 | interface ListItemPropsBase {
7 | className?: string;
8 | as?: React.ElementType;
9 | content?: React.ReactNode;
10 | ellipsis?: boolean;
11 | rightIcon?: string;
12 | onClick?: (event: React.MouseEvent) => void;
13 | children?: React.ReactNode;
14 | }
15 |
16 | interface ListItemPropsWithLink extends ListItemPropsBase {
17 | as: 'a';
18 | href: string;
19 | }
20 |
21 | export type ListItemProps = ListItemPropsBase | ListItemPropsWithLink;
22 |
23 | export const ListItem = React.forwardRef((props, ref) => {
24 | const {
25 | className,
26 | as: Element = 'div',
27 | content,
28 | ellipsis,
29 | rightIcon,
30 | children,
31 | onClick,
32 | ...other
33 | } = props;
34 | return (
35 |
42 |
43 | {content || children}
44 |
45 | {rightIcon && }
46 |
47 | );
48 | });
49 |
--------------------------------------------------------------------------------
/src/components/List/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { List, ListItem } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('
', () => {
8 | it('should support bordered', () => {
9 | const { getByRole } = render(
10 |
11 |
12 |
,
13 | );
14 | const list = getByRole('list');
15 | expect(list).toHaveClass('List--bordered');
16 | });
17 |
18 | it('should support right icon', () => {
19 | const { getByRole } = render(
20 |
21 |
22 |
,
23 | );
24 | const listItem = getByRole('listitem');
25 | expect(listItem.querySelectorAll('.Icon').length).toBe(1);
26 | });
27 |
28 | it('should support as', () => {
29 | const { getByRole } = render(
30 |
31 |
32 |
,
33 | );
34 | const list = getByRole('list');
35 | expect(list.querySelectorAll('a').length).toBe(1);
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/List/index.ts:
--------------------------------------------------------------------------------
1 | export { List } from './List';
2 | export type { ListProps } from './List';
3 | export { ListItem } from './ListItem';
4 | export type { ListItemProps } from './ListItem';
5 |
--------------------------------------------------------------------------------
/src/components/Loading/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Loading } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should support tip text', () => {
9 | const content = 'Hello ChatUI';
10 | const { getByText } = render();
11 | const loading = getByText(content);
12 | expect(loading).toBeInTheDocument();
13 | });
14 |
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/Loading/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Flex } from '../Flex';
3 | import { Icon } from '../Icon';
4 |
5 | export type LoadingProps = {
6 | tip?: string;
7 | };
8 |
9 | export const Loading: React.FC = (props) => {
10 | const { tip, children } = props;
11 | return (
12 |
13 |
14 | {tip && {tip}
}
15 | {children}
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/components/Loading/style.less:
--------------------------------------------------------------------------------
1 | .Loading {
2 | padding: @gutter;
3 | color: var(--color-text-2);
4 |
5 | .Icon {
6 | font-size: @icon-size-lg;
7 | }
8 | }
9 |
10 | .Loading-tip {
11 | margin: 0 0 0 6px;
12 | font-size: @font-size-sm;
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/MediaObject/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type MediaObjectProps = {
5 | className?: string;
6 | picUrl?: string;
7 | picAlt?: string;
8 | picSize?: 'sm' | 'md' | 'lg';
9 | title?: string;
10 | meta?: React.ReactNode;
11 | };
12 |
13 | export const MediaObject: React.FC = (props) => {
14 | const { className, picUrl, picSize, title, picAlt, meta } = props;
15 | return (
16 |
17 | {picUrl && (
18 |
19 |

20 |
21 | )}
22 |
23 |
{title}
24 |
{meta}
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/src/components/MediaObject/style.less:
--------------------------------------------------------------------------------
1 | .MediaObject {
2 | display: flex;
3 | }
4 |
5 | .MediaObject-pic {
6 | width: 70px;
7 | margin-right: 10px;
8 | > img {
9 | display: block;
10 | width: 100%;
11 | height: 100%;
12 | }
13 | }
14 |
15 | .MediaObject-info {
16 | flex: 1;
17 | }
18 |
19 | .MediaObject-title {
20 | margin: 0 0 6px;
21 | font-size: @font-size-sm;
22 | font-weight: 400;
23 | }
24 |
25 | .MediaObject-meta {
26 | font-size: @font-size-xs;
27 | color: var(--color-text-2);
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/Message/SystemMessage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import clsx from 'clsx';
3 | import { Button } from '../Button';
4 |
5 | export type SystemMessageProps = {
6 | className?: string;
7 | content: string;
8 | action?: {
9 | text: string;
10 | onClick: (event: React.MouseEvent) => void;
11 | once?: boolean;
12 | disabled?: boolean;
13 | };
14 | };
15 |
16 | export const SystemMessage = (props: SystemMessageProps) => {
17 | const { className, content, action } = props;
18 | const { onClick, once } = action || {};
19 | const [disabled, setDisabled] = useState(action && action.disabled);
20 |
21 | const handleClick = (e: React.MouseEvent) => {
22 | if (onClick) {
23 | onClick(e);
24 | }
25 | if (once) {
26 | setDisabled(true);
27 | }
28 | };
29 |
30 | return (
31 |
32 |
33 | {content}
34 | {action && (
35 |
38 | )}
39 |
40 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/src/components/Message/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Message } from './Message';
2 | export type { MessageProps, MessageId } from './Message';
3 | export { SystemMessage } from './SystemMessage';
4 | export type { SystemMessageProps } from './SystemMessage';
5 |
--------------------------------------------------------------------------------
/src/components/MessageContainer/style.less:
--------------------------------------------------------------------------------
1 | .MessageContainer {
2 | position: relative;
3 | display: flex;
4 | flex-direction: column;
5 | flex: 1;
6 | min-height: 0;
7 |
8 | & > .PullToRefresh {
9 | flex: 1;
10 | }
11 | &:focus {
12 | outline: 0;
13 | }
14 | }
15 |
16 | .MessageList {
17 | padding: var(--gutter);
18 | font-size: 15px;
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/MessageStatus/style.less:
--------------------------------------------------------------------------------
1 | .MessageStatus {
2 | align-self: center;
3 | margin-right: @gutter;
4 | font-size: 15Px;
5 |
6 | &[data-status='loading'] {
7 | .Icon {
8 | color: var(--color-text-2);
9 | }
10 | }
11 | &[data-status='fail'] {
12 | .IconBtn {
13 | color: #ff5959;
14 | }
15 | }
16 | .IconBtn,
17 | .Icon {
18 | display: block;
19 | }
20 | .Message[data-type="text"] & {
21 | // = calc(12px - var(--rate-width) - 8Px)
22 | margin-right: calc(4Px - var(--rate-width));
23 | }
24 | .Message[data-type="order"] & {
25 | // = -(气泡外边距40 - 间距12)
26 | // calc(var(--rate-width) + 8Px) - 间距12
27 | margin-right: calc(-4Px - var(--rate-width));
28 | // = margin-right - 自宽15
29 | // = calc(-4Px - var(--rate-width)) - 15Px
30 | margin-left: calc(var(--rate-width) - 11Px);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/Modal/Confirm.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { Base, ModalProps } from './Base';
4 | import { useLocale } from '../ConfigProvider';
5 | import { ButtonProps } from '../Button';
6 |
7 | const isPrimary = (btn: ButtonProps) => btn.color === 'primary';
8 |
9 | export const Confirm: React.FC = ({
10 | className,
11 | vertical: oVertical,
12 | actions,
13 | ...other
14 | }) => {
15 | const { locale = '' } = useLocale();
16 | const isZh = locale.includes('zh');
17 | // 中文默认横排
18 | const vertical = oVertical != null ? oVertical : !isZh;
19 |
20 | if (Array.isArray(actions)) {
21 | // 主按钮排序:横排主按钮在后,竖排主按钮在前
22 | actions.sort((a, b) => {
23 | if (isPrimary(a)) {
24 | return vertical ? -1 : 1;
25 | }
26 | if (isPrimary(b)) {
27 | return vertical ? 1 : -1;
28 | }
29 | return 0;
30 | });
31 | }
32 |
33 | return (
34 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/src/components/Modal/Modal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Base, ModalProps } from './Base';
3 |
4 | export const Modal: React.FC = (props) => (
5 |
10 | );
11 |
--------------------------------------------------------------------------------
/src/components/Modal/Popup.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Base, ModalProps } from './Base';
3 |
4 | export const Popup: React.FC = (props) => (
5 |
6 | );
7 |
--------------------------------------------------------------------------------
/src/components/Modal/__tests__/confirm.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Confirm } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render a confirm', () => {
9 | const { baseElement } = render();
10 |
11 | expect(baseElement.querySelector('.Confirm')).not.toBeNull();
12 | expect(baseElement.querySelector('.Confirm')).toHaveClass('Modal');
13 | });
14 |
15 | it('should not have close button', () => {
16 | const { baseElement } = render( {}} />);
17 |
18 | expect(baseElement.querySelector('.Modal-close')).toBeNull();
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/components/Modal/__tests__/modal.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Modal } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render a modal', () => {
9 | const { baseElement } = render();
10 |
11 | expect(baseElement.querySelector('.Modal')).not.toBeNull();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/src/components/Modal/__tests__/popup.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Popup } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render a popup', () => {
9 | const { baseElement } = render();
10 |
11 | expect(baseElement.querySelector('.Popup')).not.toBeNull();
12 | expect(baseElement.querySelector('.Popup-body')).toHaveClass('overflow');
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/src/components/Modal/index.ts:
--------------------------------------------------------------------------------
1 | export { Modal } from './Modal';
2 | export { Confirm } from './Confirm';
3 | export { Popup } from './Popup';
4 | export type { ModalProps } from './Base';
5 |
--------------------------------------------------------------------------------
/src/components/Navbar/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Navbar } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should support leftContent', () => {
9 | const title = 'Hello ChatUI';
10 | const leftContent = {
11 | icon: 'chevron-left',
12 | onClick: jest.fn(),
13 | };
14 | const { container } = render();
15 | expect(container.querySelectorAll('.Navbar-left .IconBtn').length).toBe(1);
16 | });
17 |
18 | it('should support rightContent', () => {
19 | const title = 'Hello ChatUI';
20 | const rightContent = [
21 | {
22 | icon: 'apps',
23 | onClick: jest.fn(),
24 | },
25 | {
26 | icon: 'cart',
27 | onClick: jest.fn(),
28 | },
29 | ];
30 | const { container } = render();
31 | expect(container.querySelectorAll('.Navbar-right .IconBtn').length).toBe(2);
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/components/Notice/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Icon } from '../Icon';
3 | import { IconButton } from '../IconButton';
4 | import { Text } from '../Text';
5 |
6 | export interface NoticeProps {
7 | content: string;
8 | closable?: boolean;
9 | leftIcon?: string;
10 | onClick?: (e: React.MouseEvent) => void;
11 | onClose?: (e: React.MouseEvent) => void;
12 | }
13 |
14 | export const Notice = (props: NoticeProps) => {
15 | const { content, closable = true, leftIcon = 'bullhorn', onClick, onClose } = props;
16 |
17 | return (
18 |
19 | {leftIcon &&
}
20 |
21 |
22 | {content}
23 |
24 |
25 | {closable && (
26 |
27 | )}
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/src/components/Notice/style.less:
--------------------------------------------------------------------------------
1 | .Notice {
2 | display: flex;
3 | align-items: center;
4 | padding: @notice-padding;
5 | border-radius: var(--radius-md);
6 | background: @notice-bg;
7 |
8 | &-icon {
9 | margin-right: 6px;
10 | }
11 | .Icon {
12 | color: @notice-icon-color;
13 | font-size: 16px;
14 | }
15 | &-close {
16 | margin-left: 6px;
17 |
18 | .Icon {
19 | color: var(--color-text-3);
20 | }
21 | }
22 | }
23 |
24 | .Notice-content {
25 | flex: 1;
26 | min-width: 0;
27 | color: @notice-content-color;
28 | font-size: @notice-content-font-size;
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/Popover/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup, fireEvent } from '@testing-library/react';
3 | import { Popover } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | function Test() {
8 | const [active, setActive] = React.useState(false);
9 | const popoverTarget = React.useRef(null!);
10 |
11 | return (
12 |
13 |
22 |
{
26 | setActive(!active);
27 | }}
28 | >
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | describe('', () => {
36 | it('should not render popover default', () => {
37 | render();
38 |
39 | expect(document.querySelector('.Popover')).not.toBeInTheDocument();
40 | });
41 |
42 | it('should render popover when active', () => {
43 | const { getByTestId } = render();
44 |
45 | fireEvent.click(getByTestId('btn'));
46 |
47 | expect(document.querySelector('.Popover')).toBeInTheDocument();
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/src/components/Popover/style.less:
--------------------------------------------------------------------------------
1 | .Popover {
2 | position: absolute;
3 | top: 0;
4 | left: 0;
5 | z-index: @zindex-popover;
6 | font-size: @font-size-sm;
7 | transform: translate(0, -10px);
8 | }
9 |
10 | .Popover-body {
11 | border-radius: @popover-border-radius;
12 | background: @popover-bg;
13 | box-shadow: @popover-box-shadow;
14 | }
15 |
16 | .Popover-arrow {
17 | display: block;
18 | width: 9px;
19 | height: 5px;
20 | margin-left: 10px;
21 | fill: var(--color-fill-1);
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/Portal/index.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useLayoutEffect } from 'react';
2 | import { createPortal } from 'react-dom';
3 |
4 | type Container = React.RefObject | Element | (() => Element) | null;
5 |
6 | export interface PortalProps {
7 | container?: Container;
8 | onRendered?: () => void;
9 | }
10 |
11 | function getEl(el: Container) {
12 | if (!el) return null;
13 |
14 | if (el instanceof Element) {
15 | return el;
16 | }
17 | return typeof el === 'function' ? el() : el.current || el;
18 | }
19 |
20 | export const Portal: React.FC = (props) => {
21 | const { children, container = document.body, onRendered } = props;
22 | const [mountNode, setMountNode] = useState(null);
23 |
24 | useEffect(() => {
25 | setMountNode(getEl(container));
26 | }, [container]);
27 |
28 | useLayoutEffect(() => {
29 | if (onRendered && mountNode) {
30 | onRendered();
31 | }
32 | }, [mountNode, onRendered]);
33 |
34 | return mountNode ? createPortal(children, mountNode) : mountNode;
35 | };
36 |
--------------------------------------------------------------------------------
/src/components/Price/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Price } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render the price', () => {
9 | const { getByTestId } = render();
10 | const price = getByTestId('price');
11 |
12 | expect(price.querySelector('.Price-integer')).toHaveTextContent('12');
13 | });
14 |
15 | it('should render the price', () => {
16 | const { getByTestId } = render();
17 | const price = getByTestId('price');
18 |
19 | expect(price.querySelector('.Price-integer')).toHaveTextContent('12');
20 | expect(price.querySelector('.Price-fraction')).toHaveTextContent('34');
21 | });
22 |
23 | it('should render the currency', () => {
24 | const { getByTestId } = render();
25 | const price = getByTestId('price');
26 |
27 | expect(price.querySelector('.Price-currency')).toHaveTextContent('¥');
28 | });
29 |
30 | it('should have a original class', () => {
31 | const { getByTestId } = render();
32 | const price = getByTestId('price');
33 |
34 | expect(price).toHaveClass('Price--original');
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/src/components/Progress/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Progress } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render the progress (0%)', () => {
9 | const { getByRole } = render();
10 | const progressbar = getByRole('progressbar');
11 |
12 | expect(progressbar).toHaveStyle({ width: '0%' });
13 | });
14 |
15 | it('should render the progress (25%)', () => {
16 | const { getByRole } = render();
17 | const progressbar = getByRole('progressbar');
18 |
19 | expect(progressbar).toHaveStyle({ width: '25%' });
20 | });
21 |
22 | it('should apply the status class', () => {
23 | const { getByTestId } = render();
24 | const progress = getByTestId('progress');
25 |
26 | expect(progress).toHaveClass('Progress--success');
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/Progress/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type ProgressProps = {
5 | className?: string;
6 | value: number;
7 | status?: 'active' | 'success' | 'error';
8 | };
9 |
10 | export const Progress = React.forwardRef((props, ref) => {
11 | const { className, value, status, children, ...other } = props;
12 |
13 | return (
14 |
19 |
27 | {children}
28 |
29 |
30 | );
31 | });
32 |
--------------------------------------------------------------------------------
/src/components/Progress/style.less:
--------------------------------------------------------------------------------
1 | .Progress {
2 | display: flex;
3 | height: @progress-height;
4 | overflow: hidden;
5 | background-color: @progress-bg;
6 | border-radius: @progress-border-radius;
7 |
8 | &-bar {
9 | overflow: hidden;
10 | background-color: @progress-bar-bg;
11 | transition: @progress-bar-transition;
12 | }
13 | &--success {
14 | .Progress-bar {
15 | background-color: @progress-bar-bg-success;
16 | }
17 | }
18 | &--error {
19 | .Progress-bar {
20 | background-color: @progress-bar-bg-error;
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/PullToRefresh/style.less:
--------------------------------------------------------------------------------
1 | .PullToRefresh {
2 | height: 100%;
3 | overflow-y: scroll;
4 |
5 | .no-scrolling & {
6 | -webkit-overflow-scrolling: touch;
7 | }
8 | &-fallback {
9 | padding-top: var(--gutter);
10 | text-align: center;
11 | }
12 | &-loadMore {
13 | font-size: 14Px;
14 | }
15 | }
16 |
17 | .PullToRefresh-inner {
18 | overflow: hidden;
19 | min-height: 100%;
20 | }
21 |
22 | .PullToRefresh-indicator {
23 | height: 30px;
24 | margin-top: -30px;
25 | color: grey;
26 | text-align: center;
27 | line-height: 30px;
28 | }
29 |
30 | .PullToRefresh-spinner {
31 | color: var(--color-text-3);
32 | font-size: 27px;
33 | }
34 |
35 | .PullToRefresh-transition {
36 | transition: transform 0.3s;
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/QuickReplies/QuickReply.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { Icon } from '../Icon';
4 |
5 | export interface QuickReplyItemProps {
6 | name: string;
7 | code?: string;
8 | icon?: string;
9 | img?: string;
10 | isNew?: boolean;
11 | isHighlight?: boolean;
12 | }
13 |
14 | export interface QuickReplyProps {
15 | item: QuickReplyItemProps;
16 | index: number;
17 | onClick: (item: QuickReplyItemProps, index: number) => void;
18 | }
19 |
20 | export const QuickReply = (props: QuickReplyProps) => {
21 | const { item, index, onClick } = props;
22 |
23 | function handleClick() {
24 | onClick(item, index);
25 | }
26 |
27 | return (
28 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/src/components/QuickReplies/index.ts:
--------------------------------------------------------------------------------
1 | export { default as QuickReplies } from './QuickReplies';
2 | export type { QuickRepliesProps } from './QuickReplies';
3 | export { QuickReply } from './QuickReply';
4 | export type { QuickReplyProps, QuickReplyItemProps } from './QuickReply';
5 |
--------------------------------------------------------------------------------
/src/components/Quote/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export interface QuoteProps {
5 | className?: string;
6 | author?: string;
7 | children?: React.ReactNode;
8 | onClick?: (e: React.MouseEvent) => void;
9 | }
10 |
11 | export const Quote = (props: QuoteProps) => {
12 | const { className, author, children, onClick } = props;
13 |
14 | return (
15 |
16 | {author &&
{author}
}
17 |
{children}
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/components/Quote/style.less:
--------------------------------------------------------------------------------
1 | .Quote {
2 | padding-left: 9px;
3 | border-left: 3px solid var(--color-line-1);
4 | color: var(--color-text-2);
5 | font-size: 12px;
6 |
7 | & + .Divider {
8 | margin-top: 9px;
9 | }
10 | .Image,
11 | .Video-cover,
12 | .Video-video:not([hidden]) {
13 | max-width: 72px;
14 | max-height: 72px;
15 | border-radius: var(--radius-md);
16 | }
17 | .Image,
18 | .Video {
19 | display: inline-block;
20 | vertical-align: top;
21 | }
22 | .Video-video {
23 | width: auto;
24 | }
25 | .Video-playBtn {
26 | pointer-events: none;
27 | }
28 | .Video-playIcon {
29 | font-size: 24px;
30 | }
31 | }
32 |
33 | .Bubble {
34 | .Quote:hover {
35 | cursor: pointer;
36 | }
37 | .Quote-content {
38 | a {
39 | pointer-events: none;
40 | }
41 | }
42 | }
43 |
44 | .Quote-author {
45 | color: var(--color-text-3);
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/Radio/Radio.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type RadioValue = string | number | undefined;
5 |
6 | export type RadioProps = React.InputHTMLAttributes & {
7 | value?: RadioValue;
8 | label?: RadioValue;
9 | };
10 |
11 | export const Radio: React.FC = (props) => {
12 | const { className, label, checked, disabled, onChange, ...other } = props;
13 |
14 | return (
15 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/Radio/RadioGroup.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { Radio, RadioProps, RadioValue } from './Radio';
4 |
5 | export type RadioGroupProps = {
6 | className?: string;
7 | options: RadioProps[];
8 | value: RadioValue;
9 | name?: string;
10 | disabled?: boolean;
11 | block?: boolean;
12 | onChange: (value: RadioValue, event: React.ChangeEvent) => void;
13 | };
14 |
15 | export const RadioGroup: React.FC = (props) => {
16 | const { className, options, value, name, disabled, block, onChange } = props;
17 | return (
18 |
19 | {options.map((item) => (
20 | {
27 | onChange(item.value, e);
28 | }}
29 | key={item.value}
30 | />
31 | ))}
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/src/components/Radio/index.ts:
--------------------------------------------------------------------------------
1 | export { Radio } from './Radio';
2 | export type { RadioProps, RadioValue } from './Radio';
3 | export { RadioGroup } from './RadioGroup';
4 | export type { RadioGroupProps } from './RadioGroup';
5 |
--------------------------------------------------------------------------------
/src/components/Radio/style.less:
--------------------------------------------------------------------------------
1 | .Checkbox,
2 | .Radio {
3 | position: relative;
4 | display: inline-block;
5 | margin: 9px 12px 0 0;
6 | padding: 4px 12px;
7 | border: 1px solid var(--color-line-1);
8 | border-radius: var(--radius-md);
9 | background: var(--color-fill-1);
10 | color: var(--color-text-2);
11 | font-size: @font-size-sm;
12 | line-height: 20px;
13 | text-align: center;
14 | cursor: pointer;
15 | transition: 0.15s ease-in-out;
16 | -webkit-tap-highlight-color: transparent;
17 | }
18 |
19 | .RadioGroup {
20 | margin-top: -9px;
21 | }
22 |
23 | .RadioGroup--block {
24 | .Radio {
25 | display: block;
26 | margin-right: 9px;
27 | }
28 | }
29 |
30 | .CheckboxGroup--block {
31 | .Checkbox {
32 | display: block;
33 | margin-right: 0;
34 | }
35 | }
36 |
37 | .Checkbox--disabled,
38 | .Radio--disabled {
39 | opacity: 0.5;
40 | cursor: initial;
41 | }
42 |
43 | .Checkbox--checked,
44 | .Radio--checked {
45 | border-color: var(--brand-1);
46 | color: var(--brand-1);
47 | }
48 |
49 | .Checkbox-input,
50 | .Radio-input {
51 | position: absolute;
52 | top: 0;
53 | left: 0;
54 | width: 100%;
55 | height: 100%;
56 | margin: 0;
57 | padding: 0;
58 | opacity: 0;
59 | cursor: inherit;
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/RateActions/style.less:
--------------------------------------------------------------------------------
1 | .ChatApp[data-elder-mode='true'] {
2 | --rate-width: 38Px;
3 | }
4 |
5 | .RateActions {
6 | position: relative;
7 | z-index: @zindex-rate-actions;
8 | align-self: flex-end;
9 | width: var(--rate-width);
10 | margin-left: var(--msg-avatar-gap);
11 | }
12 |
13 | .RateBtn {
14 | padding: 6Px;
15 | border-radius: var(--radius-md);
16 | background: @rate-btn-bg;
17 | font-size: calc(var(--rate-width) - 12Px);
18 |
19 | & + .RateBtn {
20 | margin-top: 9Px;
21 | }
22 | &[data-type='up'] {
23 | &:hover,
24 | &.active {
25 | color: var(--brand-1);
26 | }
27 | }
28 | &[data-type='down'] {
29 | &:hover,
30 | &.active {
31 | color: var(--blue);
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/RichText/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { RichText } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render the plain text', () => {
9 | const { getByTestId } = render();
10 | const richText = getByTestId('richText');
11 |
12 | expect(richText).toHaveTextContent('testContent');
13 | });
14 |
15 | it('should render the rich text', () => {
16 | const { getByTestId } = render(
17 | ,
18 | );
19 | const richText = getByTestId('richText');
20 | const content = getByTestId('content');
21 |
22 | expect(richText).toContainHTML('foo');
23 | expect(richText).toContainElement(content);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/RichText/configDOMPurify.ts:
--------------------------------------------------------------------------------
1 | import DOMPurify from 'dompurify';
2 |
3 | if (DOMPurify.isSupported) {
4 | DOMPurify.addHook('beforeSanitizeAttributes', (node: Element) => {
5 | if (node instanceof HTMLElement && node.hasAttribute('href')) {
6 | const href = node.getAttribute('href');
7 |
8 | if (href) {
9 | node.dataset.cuiHref = href;
10 | }
11 | if (node.getAttribute('target') === '_blank') {
12 | node.dataset.cuiTarget = '1';
13 | }
14 | }
15 | });
16 |
17 | DOMPurify.addHook('afterSanitizeAttributes', (node: Element) => {
18 | if (node instanceof HTMLElement) {
19 | if (node.dataset.cuiHref && node.hasAttribute('href')) {
20 | node.removeAttribute('data-cui-href');
21 | }
22 | if (node.dataset.cuiTarget) {
23 | node.setAttribute('target', '_blank');
24 | node.setAttribute('rel', 'noopener noreferrer');
25 | node.removeAttribute('data-cui-target');
26 | }
27 | }
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/RichText/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import DOMPurify from 'dompurify';
4 | import './configDOMPurify';
5 |
6 | export interface RichTextProps extends React.HTMLAttributes {
7 | content: string;
8 | className?: string;
9 | options?: DOMPurify.Config;
10 | }
11 |
12 | export const RichText = React.forwardRef((props, ref) => {
13 | const { className, content, options = {}, ...other } = props;
14 | const html = {
15 | __html: DOMPurify.sanitize(content, options) as string,
16 | };
17 |
18 | return (
19 |
27 | );
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/RichText/style.less:
--------------------------------------------------------------------------------
1 | .RichText {
2 | word-wrap: break-word;
3 | overflow-wrap: break-word;
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/ScrollGrid/style.less:
--------------------------------------------------------------------------------
1 | .ScrollGrid {
2 | overflow: hidden;
3 |
4 | &[data-wrap='true'] {
5 | .ScrollGrid-inner {
6 | flex-wrap: wrap;
7 | }
8 | }
9 | &[data-wrap='false'] {
10 | .ScrollGrid-scroller {
11 | display: flex;
12 | overflow-x: scroll;
13 | overflow-y: hidden;
14 | margin-bottom: -18px;
15 | padding-bottom: 18px;
16 | }
17 | .ScrollGrid-inner {
18 | & > div {
19 | flex: 0 0 auto;
20 | }
21 | }
22 | }
23 | }
24 |
25 | .ScrollGrid-scroller {
26 | .no-scrolling & {
27 | -webkit-overflow-scrolling: touch;
28 | }
29 | &::-webkit-scrollbar {
30 | display: none;
31 | }
32 | }
33 |
34 | .ScrollGrid-inner {
35 | display: flex;
36 | min-width: 100%;
37 | }
38 |
39 | .ScrollGrid-indicator,
40 | .ScrollGrid-indicatorBar {
41 | height: 3px;
42 | border-radius: 100px;
43 | }
44 |
45 | .ScrollGrid-indicator {
46 | width: 20px;
47 | margin: 3px auto 0;
48 | background: var(--color-line-1);
49 | }
50 |
51 | .ScrollGrid-indicatorBar {
52 | width: 10px;
53 | background: var(--brand-1);
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/ScrollView/Item.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type ScrollViewEffect = 'slide' | 'fade' | '';
5 |
6 | export type ScrollViewItemProps = {
7 | item: any;
8 | effect?: ScrollViewEffect;
9 | onIntersect?: (item?: any, entry?: IntersectionObserverEntry) => boolean | void;
10 | };
11 |
12 | const observerOptions = {
13 | threshold: [0, 0.1],
14 | };
15 |
16 | export const Item: React.FC = (props) => {
17 | const { item, effect, children, onIntersect } = props;
18 | const itemRef = useRef(null);
19 |
20 | useEffect(() => {
21 | if (!onIntersect) return undefined;
22 |
23 | const observer = new IntersectionObserver(([entry]) => {
24 | if (entry.intersectionRatio > 0) {
25 | // 根据回调返回值判断是否继续监听
26 | if (!onIntersect(item, entry)) {
27 | observer.unobserve(entry.target);
28 | }
29 | }
30 | }, observerOptions);
31 |
32 | if (itemRef.current) {
33 | observer.observe(itemRef.current);
34 | }
35 | return () => {
36 | observer.disconnect();
37 | };
38 | }, [item, onIntersect]);
39 |
40 | return (
41 |
48 | {children}
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/src/components/ScrollView/index.ts:
--------------------------------------------------------------------------------
1 | export { ScrollView } from './ScrollView';
2 | export type { ScrollViewProps } from './ScrollView';
3 |
--------------------------------------------------------------------------------
/src/components/ScrollView/style.less:
--------------------------------------------------------------------------------
1 | .ScrollView {
2 | overflow: hidden;
3 |
4 | &-scroller {
5 | scroll-behavior: smooth;
6 | -webkit-overflow-scrolling: touch;
7 | -ms-overflow-style: none; // IE/Edge
8 | scrollbar-width: none; // FF
9 |
10 | &::-webkit-scrollbar {
11 | display: none;
12 | }
13 | }
14 | &--fullWidth {
15 | margin: 0 calc(var(--gutter) * -1);
16 | }
17 | &--fullWidth:not(&--hasControls) &-inner {
18 | padding: 0 var(--gutter);
19 | }
20 | }
21 |
22 | .ScrollView--x {
23 | .ScrollView-scroller {
24 | display: flex;
25 | overflow-x: scroll;
26 | overflow-y: hidden;
27 | margin-bottom: -18Px;
28 | padding-bottom: 18Px;
29 | }
30 | .ScrollView-inner {
31 | display: flex;
32 | }
33 | .ScrollView-item {
34 | flex: 0 0 auto;
35 | margin-left: @scroll-view-spacing-x;
36 |
37 | &:first-child {
38 | margin-left: 0;
39 | }
40 | }
41 | }
42 |
43 | .ScrollView--hasControls {
44 | display: flex;
45 | align-items: center;
46 |
47 | .ScrollView-scroller {
48 | flex: 1;
49 | }
50 | }
51 |
52 | .ScrollView-control {
53 | padding: 6px;
54 | color: var(--color-text-3);
55 | font-size: @font-size-md;
56 |
57 | &:not(:disabled):hover {
58 | color: var(--brand-1);
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/Search/style.less:
--------------------------------------------------------------------------------
1 | .Search {
2 | display: flex;
3 | align-items: center;
4 | padding: 3px 5px 3px 15px;
5 | background: var(--color-fill-1);
6 | border-radius: var(--radius-md);
7 |
8 | &-icon,
9 | &-clear {
10 | font-size: var(--font-size-lg);
11 | }
12 | &-icon {
13 | color: var(--color-text-3);
14 | }
15 | &-input {
16 | flex: 1;
17 | border: 0;
18 | padding: 0 9px;
19 |
20 | &::-webkit-search-cancel-button {
21 | display: none;
22 | }
23 | }
24 | &-input:focus + &-clear,
25 | &-input:focus ~ .Btn--primary {
26 | opacity: 1;
27 | }
28 | &-clear {
29 | color: var(--color-text-3);
30 | opacity: 0;
31 |
32 | &:hover {
33 | background: initial;
34 | color: var(--color-text-3);
35 | }
36 | }
37 | &[data-disabled="true"] {
38 | opacity: 0.5;
39 | }
40 | .Btn--primary {
41 | min-width: 56px;
42 | margin-left: 6px;
43 | padding: 2px 12px;
44 | font-size: var(--font-size-xs);
45 | opacity: 0;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/Select/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { InputVariant } from '../Input';
4 |
5 | export interface SelectProps extends React.SelectHTMLAttributes {
6 | placeholder?: string;
7 | variant?: InputVariant;
8 | }
9 |
10 | export const Select = React.forwardRef(
11 | ({ className, placeholder, variant = 'outline', children, ...rest }, ref) => (
12 |
16 | ),
17 | );
18 |
--------------------------------------------------------------------------------
/src/components/Select/style.less:
--------------------------------------------------------------------------------
1 | .Select {
2 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
3 | background-repeat: no-repeat;
4 | background-position: right 0.75rem center;
5 | background-size: 16px 12px;
6 | appearance: none;
7 |
8 | &:disabled {
9 | opacity: 0.5;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/SendConfirm/SendConfirm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { Modal } from '../Modal';
3 | import { Flex } from '../Flex';
4 | import { useLocale } from '../ConfigProvider';
5 |
6 | export type SendConfirmProps = {
7 | file: Blob;
8 | onCancel: () => void;
9 | onSend: () => void;
10 | };
11 |
12 | export const SendConfirm: React.FC = (props) => {
13 | const { file, onCancel, onSend } = props;
14 | const [img, setImg] = useState('');
15 | const { trans } = useLocale('SendConfirm');
16 |
17 | useEffect(() => {
18 | const reader = new FileReader();
19 | reader.onload = (e: ProgressEvent) => {
20 | if (e.target) {
21 | setImg(e.target.result as string);
22 | }
23 | };
24 | reader.readAsDataURL(file);
25 | }, [file]);
26 |
27 | return (
28 |
45 |
46 |
47 |
48 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/src/components/SendConfirm/index.ts:
--------------------------------------------------------------------------------
1 | export { SendConfirm } from './SendConfirm';
2 |
--------------------------------------------------------------------------------
/src/components/SendConfirm/style.less:
--------------------------------------------------------------------------------
1 | .SendConfirm {
2 | .Modal-dialog {
3 | width: @send-confirm-dialog-width;
4 | margin: @send-confirm-dialog-margin;
5 | }
6 | }
7 |
8 | .SendConfirm-inner {
9 | height: @send-confirm-inner-height;
10 | text-align: center;
11 |
12 | img {
13 | max-width: 100%;
14 | max-height: 100%;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/Skeleton/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export interface SkeletonProps {
5 | className?: string;
6 | w?: React.CSSProperties['width'];
7 | h?: React.CSSProperties['height'];
8 | mb?: React.CSSProperties['marginBottom'];
9 | style?: React.CSSProperties;
10 | r?: 'sm' | 'md' | 'xl' | 'none';
11 | }
12 |
13 | export const Skeleton = ({ className, w, h, mb, r, style }: SkeletonProps) => {
14 | return (
15 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/components/Skeleton/style.less:
--------------------------------------------------------------------------------
1 | @keyframes shimmer {
2 | 0% {
3 | background-position: -468px 0;
4 | }
5 | to {
6 | background-position: 468px 0;
7 | }
8 | }
9 |
10 | .Skeleton {
11 | background: linear-gradient(90deg, var(--skeleton-bg-1) 8%, var(--skeleton-bg-2) 18%, var(--skeleton-bg-1) 33%);
12 | background-size: 800px 104px;
13 | animation: 1.25s linear infinite forwards shimmer;
14 | }
15 |
16 | .Skeleton--r-sm {
17 | border-radius: 2px;
18 | }
19 |
20 | .Skeleton--r-md {
21 | border-radius: 6px;
22 | }
23 |
24 | .Skeleton--r-xl {
25 | border-radius: 32px;
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/Stepper/Stepper.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import type { StepProps, StepStatus } from './Step';
4 |
5 | export type StepperProps = {
6 | className?: string;
7 | current?: number;
8 | status?: StepStatus;
9 | inverted?: boolean;
10 | children?: React.ReactNode;
11 | };
12 |
13 | export const Stepper = React.forwardRef((props, ref) => {
14 | const { className, current = 0, status, inverted, children, ...other } = props;
15 |
16 | const childrenArray = React.Children.toArray(children);
17 | const steps = childrenArray.map((child, index) => {
18 | const state: StepProps = {
19 | index,
20 | active: false,
21 | completed: false,
22 | disabled: false,
23 | };
24 |
25 | if (current === index) {
26 | state.active = true;
27 | state.status = status;
28 | } else if (current > index) {
29 | state.completed = true;
30 | } else {
31 | state.disabled = !inverted;
32 | state.completed = inverted;
33 | }
34 |
35 | return React.isValidElement(child)
36 | ? React.cloneElement(child, { ...state, ...child.props })
37 | : null;
38 | });
39 |
40 | return (
41 |
44 | );
45 | });
46 |
--------------------------------------------------------------------------------
/src/components/Stepper/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Stepper, Step } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render a stepper', () => {
9 | const { container } = render(
10 |
11 |
12 |
13 |
14 | ,
15 | );
16 |
17 | const stepList = container.querySelectorAll('.Step');
18 |
19 | expect(stepList.length).toBe(3);
20 | expect(stepList[0]).toHaveClass('Step--active');
21 | });
22 |
23 | it('should activate the current step', () => {
24 | const { container } = render(
25 |
26 |
27 |
28 |
29 | ,
30 | );
31 |
32 | const stepList = container.querySelectorAll('.Step');
33 |
34 | expect(stepList[0]).toHaveClass('Step--completed');
35 | expect(stepList[1]).toHaveClass('Step--active');
36 | expect(stepList[2]).toHaveClass('Step--disabled');
37 | });
38 |
39 | it('should ignore invalid element', () => {
40 | const { container } = render({123});
41 | const stepList = container.querySelectorAll('.Step');
42 |
43 | expect(stepList.length).toBe(0);
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/src/components/Stepper/index.ts:
--------------------------------------------------------------------------------
1 | export { Stepper } from './Stepper';
2 | export type { StepperProps } from './Stepper';
3 | export { Step } from './Step';
4 | export type { StepProps } from './Step';
5 |
--------------------------------------------------------------------------------
/src/components/Tabs/Tab.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export interface TabProps {
4 | label: string;
5 | }
6 |
7 | export const Tab: React.FC = ({ children }) => {children}
;
8 |
--------------------------------------------------------------------------------
/src/components/Tabs/__tests__/tab.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { Tab } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render children', () => {
9 | const { getByTestId } = render(
10 |
11 |
12 | ,
13 | );
14 |
15 | expect(getByTestId('child')).toBeInTheDocument();
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/src/components/Tabs/index.ts:
--------------------------------------------------------------------------------
1 | export { Tabs } from './Tabs';
2 | export type { TabsProps } from './Tabs';
3 | export { Tab } from './Tab';
4 | export type { TabProps } from './Tab';
5 |
--------------------------------------------------------------------------------
/src/components/Tag/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 |
4 | import { Tag } from '..';
5 |
6 | afterEach(cleanup);
7 |
8 | describe('', () => {
9 |
10 | it('should support className', () => {
11 | const { container } = render(ChatUI);
12 | const tag = container.querySelectorAll('.Tag')[0];
13 | if (tag) {
14 | expect(tag).toHaveClass('test');
15 | }
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/src/components/Tag/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export interface TagProps {
5 | as?: React.ElementType;
6 | className?: string;
7 | color?: 'primary' | 'success' | 'danger' | 'warning';
8 | children?: React.ReactNode;
9 | }
10 |
11 | type TagRef = React.ElementType;
12 |
13 | export const Tag = React.forwardRef((props, ref) => {
14 | const { as: Element = 'span', className, color, children, ...other } = props;
15 |
16 | return (
17 |
18 | {children}
19 |
20 | );
21 | });
22 |
--------------------------------------------------------------------------------
/src/components/Tag/style.less:
--------------------------------------------------------------------------------
1 | .Tag {
2 | display: inline-block;
3 | position: relative;
4 | margin: @tag-margin;
5 | padding: @tag-padding;
6 | border: 1px solid var(--brand-1);
7 | border-radius: var(--radius-sm);
8 | color: @tag-color;
9 | font-size: @tag-font-size;
10 | line-height: 1.25;
11 | white-space: nowrap;
12 |
13 | &--primary {
14 | border-color: transparent;
15 | color: var(--orange);
16 |
17 | &:before {
18 | content: '';
19 | position: absolute;
20 | top: 0;
21 | left: 0;
22 | right: 0;
23 | bottom: 0;
24 | margin: -1px;
25 | border-radius: inherit;
26 | background: currentColor;
27 | opacity: 0.14;
28 | }
29 | }
30 | &--success {
31 | border-color: var(--green);
32 | background: var(--green);
33 | color: #fff;
34 | }
35 | &--danger {
36 | border-color: var(--red);
37 | background: var(--red);
38 | color: #fff;
39 | }
40 | &--warning {
41 | border-color: var(--orange);
42 | background: var(--orange);
43 | color: #fff;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/Text/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 |
4 | import { Text } from '..';
5 |
6 | afterEach(cleanup);
7 |
8 | describe('', () => {
9 |
10 | it('should support align', () => {
11 | const { getByText } = render(ChatUI);
12 | expect(getByText('ChatUI')).toHaveClass('Text--center');
13 | });
14 |
15 | it('should support truncate', () => {
16 | const { getByText } = render(ChatUI is easy to use);
17 | expect(getByText('ChatUI is easy to use')).toHaveClass('Text--truncate');
18 | });
19 |
20 | it('should support ellipsis', () => {
21 | const { getByText } = render(ChatUI is easy to use);
22 | expect(getByText('ChatUI is easy to use')).toHaveClass('Text--ellipsis');
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/components/Text/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export interface TextProps {
5 | className?: string;
6 | as?: React.ElementType;
7 | align?: 'left' | 'center' | 'right' | 'justify';
8 | breakWord?: boolean;
9 | truncate?: boolean | number;
10 | }
11 |
12 | export const Text: React.FC = (props) => {
13 | const { as: Element = 'div', className, align, breakWord, truncate, children, ...other } = props;
14 | const ellipsis = Number.isInteger(truncate);
15 |
16 | const cls = clsx(
17 | align && `Text--${align}`,
18 | {
19 | 'Text--break': breakWord,
20 | 'Text--truncate': truncate === true,
21 | 'Text--ellipsis': ellipsis,
22 | },
23 | className,
24 | );
25 |
26 | const style = ellipsis ? { WebkitLineClamp: truncate } : null;
27 |
28 | return (
29 |
30 | {children}
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/Text/style.less:
--------------------------------------------------------------------------------
1 | .Text--truncate {
2 | overflow: hidden;
3 | text-overflow: ellipsis;
4 | white-space: nowrap;
5 | }
6 |
7 | .Text--break {
8 | word-break: break-word !important;
9 | overflow-wrap: break-word !important;
10 | }
11 |
12 | .Text--ellipsis {
13 | overflow: hidden;
14 | display: -webkit-box;
15 | /* autoprefixer: ignore next */
16 | -webkit-box-orient: vertical;
17 | /* -webkit-line-clamp: 2; */
18 | text-overflow: ellipsis;
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/Think/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import clsx from 'clsx';
3 | import { Icon } from '../Icon';
4 |
5 | export interface ThinkProps {
6 | className?: string;
7 | isDone?: boolean;
8 | thinkTime?: number;
9 | children?: React.ReactNode;
10 | }
11 |
12 | export const Think = ({ className, isDone, thinkTime, children }: ThinkProps) => {
13 | const [show, setShow] = useState(true);
14 |
15 | const handleClick = () => {
16 | setShow((s) => !s);
17 | };
18 |
19 | const getText = () => {
20 | if (isDone) {
21 | const time = thinkTime ? `(用时${thinkTime}秒)` : '';
22 | return `已深度思考${time}`;
23 | }
24 | return '思考中...';
25 | };
26 |
27 | return (
28 |
29 |
30 | {getText()}
31 |
32 |
33 | {show &&
{children}
}
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/Time/Time.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import formatDate, { IDate } from './parser';
3 | import { useLocale } from '../ConfigProvider';
4 |
5 | export interface TimeProps {
6 | date: IDate;
7 | }
8 |
9 | export const Time: React.FC = ({ date }) => {
10 | const { trans } = useLocale('Time');
11 | const dateTime = new Date(date).toLocaleString('zh').replace(/\//g, '-');
12 |
13 | return (
14 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/components/Time/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { ConfigProvider } from '../../ConfigProvider';
4 | import { Time } from '..';
5 |
6 | afterEach(cleanup);
7 |
8 | describe('', () => {
9 | it(`should display English by default`, () => {
10 | const { container } = render(
11 |
12 |
13 | ,
14 | );
15 | const time = container.querySelector('.Time');
16 | expect(time).toHaveTextContent('1/1/2020 12:00');
17 | });
18 |
19 | it(`should display Chinese by set locale`, () => {
20 | const { container } = render(
21 |
22 |
23 | ,
24 | );
25 | const time = container.querySelector('.Time');
26 | expect(time).toHaveTextContent('2020年1月1日 12:00');
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/Time/index.ts:
--------------------------------------------------------------------------------
1 | export { Time } from './Time';
2 | export type { TimeProps } from './Time';
3 |
--------------------------------------------------------------------------------
/src/components/Time/style.less:
--------------------------------------------------------------------------------
1 | .Time {
2 | font-size: 12px;
3 | color: var(--color-text-3);
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/Toast/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { mountComponent } from '../../utils/mountComponent';
3 | import { Toast, ToastProps } from './Toast';
4 |
5 | function show(content: string, type?: ToastProps['type'], duration?: number) {
6 | mountComponent();
7 | }
8 |
9 | export const toast = {
10 | show,
11 | success(content: string, duration?: number) {
12 | show(content, 'success', duration);
13 | },
14 | fail(content: string, duration?: number) {
15 | show(content, 'error', duration);
16 | },
17 | loading(content: string, duration?: number) {
18 | show(content, 'loading', duration);
19 | },
20 | };
21 |
22 | export { Toast };
23 | export type { ToastProps };
24 |
--------------------------------------------------------------------------------
/src/components/Toast/style.less:
--------------------------------------------------------------------------------
1 | .Toast {
2 | position: fixed;
3 | top: 30%;
4 | left: 0;
5 | right: 0;
6 | z-index: @zindex-toast;
7 | display: flex;
8 | justify-content: center;
9 | transition: all 300ms ease 0s;
10 | transform: translateY(-50%);
11 | opacity: 0;
12 | visibility: hidden;
13 |
14 | &[data-type='success'] .Icon {
15 | color: var(--green);
16 | }
17 | &[data-type='error'] .Icon {
18 | color: var(--red);
19 | }
20 | &[data-type='loading'] .Icon {
21 | color: var(--brand-1);
22 | }
23 | &.show {
24 | opacity: 1;
25 | visibility: visible;
26 | }
27 | .Icon {
28 | margin-right: 6px;
29 | font-size: 24px;
30 | }
31 | }
32 |
33 | .Toast-content {
34 | display: flex;
35 | max-width: 90vw;
36 | padding: @toast-content-padding;
37 | border-radius: var(--radius-md);
38 | background: var(--color-toast);
39 | box-sizing: border-box;
40 | }
41 |
42 | .Toast-message {
43 | flex: 1;
44 | margin: @toast-message-margin;
45 | color: @toast-message-color;
46 | font-size: @toast-message-font-size;
47 | word-break: break-word;
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/Toolbar/Toolbar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ToolbarButton, ToolbarItemProps } from './ToolbarButton';
3 |
4 | export interface ToolbarProps {
5 | items: ToolbarItemProps[];
6 | onClick: (item: ToolbarItemProps, event: React.MouseEvent) => void;
7 | }
8 |
9 | export const Toolbar: React.FC = (props) => {
10 | const { items, onClick } = props;
11 | return (
12 |
13 | {items.map((item) => (
14 |
15 | ))}
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/components/Toolbar/ToolbarButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Button } from '../Button';
3 | import { Icon } from '../Icon';
4 |
5 | export interface ToolbarItemProps {
6 | type: string;
7 | title: string;
8 | icon?: string;
9 | img?: string;
10 | render?: any; // FIXME
11 | }
12 |
13 | export interface ToolbarButtonProps {
14 | item: ToolbarItemProps;
15 | onClick: (item: ToolbarItemProps, event: React.MouseEvent) => void;
16 | }
17 |
18 | export const ToolbarButton: React.FC = (props) => {
19 | const { item, onClick } = props;
20 | const { type, icon, img, title } = item;
21 |
22 | return (
23 |
24 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/Toolbar/__tests__/button.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { ToolbarButton } from '../ToolbarButton';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render the toolbar button', () => {
9 | const { container } = render(
10 | {}} />,
11 | );
12 | const item = container.querySelector('.Toolbar-item');
13 |
14 | expect(item).toHaveAttribute('data-type', 'test');
15 | expect(item?.querySelector('.Toolbar-btnText')).toHaveTextContent('test');
16 | });
17 |
18 | it('should have the icon', () => {
19 | const { container } = render(
20 | {}} />,
21 | );
22 | const btnIcon = container.querySelector('.Toolbar-btnIcon');
23 |
24 | expect(btnIcon).toContainHTML('');
25 | });
26 |
27 | it('should have the image', () => {
28 | const imgUrl = '/test.png';
29 | const { container } = render(
30 | {}} />,
31 | );
32 | const img = container.querySelector('.Toolbar-img');
33 |
34 | expect(img).toHaveAttribute('src', imgUrl);
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/src/components/Toolbar/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup, fireEvent } from '@testing-library/react';
3 | import { Toolbar } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('', () => {
8 | it('should render toolbar', () => {
9 | const items = [
10 | {
11 | type: 'item1',
12 | title: 'item1',
13 | },
14 | {
15 | type: 'item2',
16 | title: 'item2',
17 | },
18 | ];
19 |
20 | const { container } = render( {}} />);
21 | const toolbar = container.querySelector('.Toolbar');
22 |
23 | expect(toolbar).not.toBeNull();
24 | expect(toolbar?.querySelectorAll('.Toolbar-btn').length).toBe(2);
25 | });
26 |
27 | it('should render toolbar', (done) => {
28 | const items = [
29 | {
30 | type: 'item1',
31 | title: 'item1',
32 | },
33 | {
34 | type: 'item2',
35 | title: 'item2',
36 | },
37 | ];
38 |
39 | const { getByText } = render(
40 | {
43 | if (item.type === 'item2') {
44 | done();
45 | }
46 | }}
47 | />,
48 | );
49 |
50 | fireEvent.click(getByText('item2'));
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/components/Toolbar/index.ts:
--------------------------------------------------------------------------------
1 | export { Toolbar } from './Toolbar';
2 | export type { ToolbarProps } from './Toolbar';
3 | export type { ToolbarItemProps } from './ToolbarButton';
4 |
--------------------------------------------------------------------------------
/src/components/Tooltip/style.less:
--------------------------------------------------------------------------------
1 | [data-tooltip] {
2 | position: relative;
3 | cursor: pointer;
4 |
5 | &:after,
6 | &:before {
7 | position: absolute;
8 | bottom: 100%;
9 | left: 50%;
10 | z-index: @zindex-tooltip;
11 | opacity: 0;
12 | pointer-events: none;
13 | transition: all 0.18s ease-out 0.18s;
14 | transform: translate(-50%, 4px);
15 | transform-origin: top;
16 | }
17 | &:after {
18 | content: attr(aria-label);
19 | margin-bottom: 10px;
20 | padding: 0.5em 1em;
21 | border-radius: var(--radius-md);
22 | background: var(--color-text-1);
23 | color: var(--color-fill-1);
24 | font-size: @font-size-xs;
25 | white-space: nowrap;
26 | }
27 | &:before {
28 | content: "";
29 | width: 0;
30 | height: 0;
31 | transform-origin: top;
32 | border: (5 / 16rem) solid transparent;
33 | border-top-color: var(--color-text-1);
34 | }
35 | &:hover:before,
36 | &:hover:after {
37 | opacity: 1;
38 | transform: translate(-50%, 0);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/Tree/Tree.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type TreeProps = {
5 | className?: string;
6 | };
7 |
8 | export const Tree: React.FC = (props) => {
9 | const { className, children } = props;
10 | return (
11 |
12 | {children}
13 |
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/Tree/index.ts:
--------------------------------------------------------------------------------
1 | export { Tree } from './Tree';
2 | export type { TreeProps } from './Tree';
3 | export { TreeNode } from './TreeNode';
4 | export type { TreeNodeProps } from './TreeNode';
5 |
--------------------------------------------------------------------------------
/src/components/Tree/style.less:
--------------------------------------------------------------------------------
1 | .Tree {
2 | background: var(--color-fill-1);
3 | }
4 |
5 | .TreeNode-title {
6 | padding: 10px 15px;
7 | display: flex;
8 | justify-content: space-between;
9 | align-items: center;
10 | border-bottom: 1px solid var(--color-line-1);
11 | &:hover {
12 | background: var(--color-fill-2);
13 | // color: var(--brand-1);
14 | cursor: pointer;
15 | }
16 | }
17 | .TreeNode {
18 | &:last-child {
19 | .TreeNode-title {
20 | border: 0;
21 | }
22 | }
23 | }
24 | .TreeNode-children-title {
25 | // background: var(--color-fill-2);
26 | border-bottom: 1px solid var(--color-line-1);
27 | }
28 | .TreeNode-title-text {
29 | overflow: hidden;
30 | text-overflow: ellipsis;
31 | display: -webkit-box;
32 | -webkit-line-clamp: 1;
33 | -webkit-box-orient: vertical;
34 | flex: 1;
35 | }
36 | .TreeNode-children {
37 | display: none;
38 | }
39 | .TreeNode-children-active {
40 | display: block;
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/Typing/Typing.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Bubble } from '../Bubble';
3 |
4 | interface TypingProps {
5 | text?: string;
6 | }
7 |
8 | export function Typing({ text }: TypingProps) {
9 | return (
10 |
11 |
12 | {text &&
{text}}
13 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/Typing/__tests__/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` should render correctly 1`] = `
4 |
25 | `;
26 |
--------------------------------------------------------------------------------
/src/components/Typing/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 |
4 | import { Typing } from '..';
5 |
6 | afterEach(cleanup);
7 |
8 | describe('', () => {
9 |
10 | it('should render correctly', () => {
11 | const { container } = render();
12 | expect(container).toMatchSnapshot();
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/src/components/Typing/index.ts:
--------------------------------------------------------------------------------
1 | export { Typing } from './Typing';
--------------------------------------------------------------------------------
/src/components/Typing/style.less:
--------------------------------------------------------------------------------
1 | .Typing {
2 | display: flex;
3 | align-items: center;
4 | height: 22.5px;
5 | }
6 |
7 | .Typing-text {
8 | margin-right: 9px;
9 | font-size: 15px;
10 | }
11 |
12 | @frame-time: 0.4s;
13 |
14 | .Typing-dot {
15 | display: inline-block;
16 | width: 4px;
17 | height: 4px;
18 | border-radius: var(--radius-sm);
19 | animation: typing-dot (@frame-time * 3) ease-in-out infinite;
20 | background: var(--gray-5);
21 | transform: rotate(-15deg);
22 |
23 | & + & {
24 | margin-left: 4px;
25 | }
26 | &:nth-child(3) {
27 | animation-delay: 0.1s;
28 | }
29 | &:nth-child(4) {
30 | animation-delay: 0.2s;
31 | }
32 | }
33 |
34 | @keyframes typing-dot {
35 | 0%, 40%, 100% {
36 | height: 4px;
37 | background: var(--color-line-2);
38 | }
39 | 20% {
40 | height: 12px;
41 | background-image: linear-gradient(162deg, var(--brand-2) 0%, var(--brand-1) 100%);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/Video/style.less:
--------------------------------------------------------------------------------
1 | .Video {
2 | position: relative;
3 | border-radius: inherit;
4 | }
5 |
6 | .Video-cover,
7 | .Video-video:not([hidden]) {
8 | display: block;
9 | width: 100%;
10 | max-height: 100%;
11 | border-radius: inherit;
12 | }
13 |
14 | .Video-duration {
15 | position: absolute;
16 | right: 6px;
17 | bottom: 6px;
18 | z-index: 1;
19 | color: var(--color-fill-1);
20 | line-height: 1;
21 | }
22 |
23 | .Video-playBtn {
24 | position: absolute;
25 | top: 0;
26 | left: 0;
27 | width: 100%;
28 | height: 100%;
29 | margin: 0;
30 | padding: 0;
31 | border: 0;
32 | background: transparent;
33 |
34 | &:hover {
35 | cursor: pointer;
36 | }
37 | }
38 |
39 | .Video-playIcon {
40 | font-size: 42px;
41 | }
42 |
43 | .Video--playing {
44 | .Video-playBtn {
45 | display: none;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/VisuallyHidden/__tests__/__snapshots__/index.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` should render correctly 1`] = `
4 |
5 |
8 |
9 | `;
10 |
--------------------------------------------------------------------------------
/src/components/VisuallyHidden/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 |
4 | import { VisuallyHidden } from '..';
5 |
6 | afterEach(cleanup);
7 |
8 | describe('', () => {
9 |
10 | it('should render correctly', () => {
11 | const { container } = render();
12 | expect(container).toMatchSnapshot();
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/src/components/VisuallyHidden/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const style = {
4 | position: 'absolute',
5 | height: '1px',
6 | width: '1px',
7 | overflow: 'hidden',
8 | clip: 'rect(0 0 0 0)',
9 | margin: '-1px',
10 | // padding: 0,
11 | // border: 0,
12 | whiteSpace: 'nowrap',
13 | };
14 |
15 | export const VisuallyHidden = (props: any) => ;
16 |
--------------------------------------------------------------------------------
/src/components/VisuallyHidden/style.less:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alibaba/ChatUI/7d7fde2433dd5147380bd11680a0315c3de054fd/src/components/VisuallyHidden/style.less
--------------------------------------------------------------------------------
/src/hooks/__test__/useNextId.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import useNextId from '../useNextId';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('useNextId', () => {
8 | it('should get id', () => {
9 | function Test() {
10 | const id = useNextId();
11 | return {id};
12 | }
13 |
14 | const { getByTestId } = render();
15 |
16 | expect(getByTestId('span')).toHaveTextContent('id-0');
17 | });
18 |
19 | it('should get unique id', () => {
20 | function Test() {
21 | const id1 = useNextId();
22 | const id2 = useNextId();
23 | return {`${id1 === id2}`};
24 | }
25 |
26 | const { getByTestId } = render();
27 |
28 | expect(getByTestId('span')).toHaveTextContent('false');
29 | });
30 |
31 | it('should have a custom prefix', () => {
32 | function Test() {
33 | const id = useNextId('test-');
34 | return {id};
35 | }
36 |
37 | const { getByTestId } = render();
38 |
39 | expect(getByTestId('span')).toHaveTextContent('test-');
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/hooks/useClickOutside.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | export default function useClickOutside(
4 | handler: (event: any) => void,
5 | eventName: string = 'click',
6 | ) {
7 | const ref = useRef();
8 |
9 | useEffect(() => {
10 | const listener = (e: any) => {
11 | const targetElement = ref.current;
12 |
13 | if (!targetElement || targetElement.contains(e.target)) {
14 | return;
15 | }
16 | if (handler) {
17 | handler(e);
18 | }
19 | };
20 |
21 | document.addEventListener(eventName, listener);
22 |
23 | return () => {
24 | document.removeEventListener(eventName, listener);
25 | };
26 | }, [eventName, handler]);
27 |
28 | return ref;
29 | }
30 |
--------------------------------------------------------------------------------
/src/hooks/useForwardRef.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, ForwardedRef } from 'react';
2 |
3 | export default function useForwardRef(ref: ForwardedRef) {
4 | const targetRef = useRef(null);
5 |
6 | useEffect(() => {
7 | if (!ref) return;
8 |
9 | if (typeof ref === 'function') {
10 | ref(targetRef.current);
11 | } else {
12 | // eslint-disable-next-line no-param-reassign
13 | ref.current = targetRef.current;
14 | }
15 | }, [ref]);
16 |
17 | return targetRef;
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/useLatest.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 |
3 | export function useLatest(value: T) {
4 | const ref = useRef(value);
5 | ref.current = value;
6 |
7 | return ref;
8 | }
9 |
--------------------------------------------------------------------------------
/src/hooks/useMount.ts:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from 'react';
2 | import { reflow } from '../utils';
3 |
4 | interface UseMountOptions {
5 | active?: boolean;
6 | ref: React.RefObject;
7 | delay?: number;
8 | }
9 |
10 | function useMount({ active = false, ref, delay = 300 }: UseMountOptions) {
11 | const [isShow, setIsShow] = useState(false);
12 | const [didMount, setDidMount] = useState(false);
13 | const timeout = useRef>();
14 |
15 | const clear = () => {
16 | if (timeout.current) {
17 | clearTimeout(timeout.current);
18 | }
19 | };
20 |
21 | useEffect(() => {
22 | if (active) {
23 | clear();
24 | setDidMount(active);
25 | } else {
26 | setIsShow(active);
27 | timeout.current = setTimeout(() => {
28 | setDidMount(active);
29 | }, delay);
30 | }
31 |
32 | return clear;
33 | }, [active, delay]);
34 |
35 | useEffect(() => {
36 | if (ref.current) {
37 | reflow(ref.current);
38 | }
39 | setIsShow(didMount);
40 | }, [didMount, ref]);
41 |
42 | return {
43 | didMount,
44 | isShow,
45 | };
46 | }
47 |
48 | export default useMount;
49 |
--------------------------------------------------------------------------------
/src/hooks/useNextId.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 |
3 | let nextId = 0;
4 | // eslint-disable-next-line no-plusplus
5 | const getNextId = () => nextId++;
6 |
7 | export default function useNextId(prefix = 'id-') {
8 | const idRef = useRef(`${prefix}${getNextId()}`);
9 | return idRef.current;
10 | }
11 |
--------------------------------------------------------------------------------
/src/hooks/useQuickReplies.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 | import { QuickReplyItemProps } from '../components/QuickReplies';
3 |
4 | type QuickReplies = QuickReplyItemProps[];
5 |
6 | export default function useQuickReplies(initialState: QuickReplies = []) {
7 | const [quickReplies, setQuickReplies] = useState(initialState);
8 | const [visible, setVisible] = useState(true);
9 | const savedRef = useRef();
10 | const stashRef = useRef();
11 |
12 | useEffect(() => {
13 | savedRef.current = quickReplies;
14 | }, [quickReplies]);
15 |
16 | const prepend = (list: QuickReplies) => {
17 | setQuickReplies((prev) => [...list, ...prev]);
18 | };
19 |
20 | // prepend、replace 后立即 save 只会保存上一个状态
21 | // 因为 savedRef.current 的更新优先级最后,用 setTimeout 可解
22 | const save = () => {
23 | stashRef.current = savedRef.current;
24 | };
25 |
26 | const pop = () => {
27 | if (stashRef.current) {
28 | setQuickReplies(stashRef.current);
29 | }
30 | };
31 |
32 | return {
33 | quickReplies,
34 | prepend,
35 | replace: setQuickReplies,
36 | visible,
37 | setVisible,
38 | save,
39 | pop,
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/src/hooks/useTypewriter.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import getRandomInt from '../utils/getRandomInt';
3 |
4 | export interface Options {
5 | interval?: number;
6 | step?: number | number[];
7 | initialIndex?: number;
8 | }
9 |
10 | export function useTypewriter(content: string, options: Options = {}) {
11 | const { interval = 80, step = 1, initialIndex = 5 } = options;
12 | const length = content.length;
13 |
14 | const [index, setIndex] = useState(initialIndex);
15 |
16 | useEffect(() => {
17 | if (index < length) {
18 | const timer = setTimeout(() => {
19 | const currentStep = Array.isArray(step) ? getRandomInt(step[0], step[1]) : step;
20 | setIndex((prev) => prev + currentStep);
21 | }, interval);
22 |
23 | return () => {
24 | clearTimeout(timer);
25 | };
26 | }
27 | return;
28 | }, [index, interval, length, step]);
29 |
30 | return {
31 | typedContent: content.slice(0, index),
32 | isTyping: index < length,
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/src/hooks/useWindowResize.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | export default function useWindowResize(handler: () => void) {
4 | const running = useRef(false);
5 |
6 | useEffect(() => {
7 | function runCallback() {
8 | handler();
9 | running.current = false;
10 | }
11 |
12 | function resizeThrottler() {
13 | if (!running.current) {
14 | running.current = true;
15 |
16 | if (window.requestAnimationFrame) {
17 | window.requestAnimationFrame(runCallback);
18 | } else {
19 | setTimeout(runCallback, 66);
20 | }
21 | }
22 | }
23 |
24 | window.addEventListener('resize', resizeThrottler);
25 | return () => {
26 | window.removeEventListener('resize', resizeThrottler);
27 | };
28 | }, [handler]);
29 | }
30 |
--------------------------------------------------------------------------------
/src/styles/animation.less:
--------------------------------------------------------------------------------
1 | @keyframes slideInRight {
2 | 0% {
3 | transform: translateX(100px);
4 | opacity: 0;
5 | }
6 | 100% {
7 | transform: translateX(0);
8 | opacity: 1;
9 | }
10 | }
11 |
12 | .slide-in-right-item {
13 | animation: slideInRight 0.5s ease-in-out both;
14 | }
15 |
16 | each(range(2, 11), {
17 | .slide-in-right-item:nth-child(@{value}) {
18 | animation-delay: ((@value - 1) * 0.1s);
19 | }
20 | });
21 |
22 | @keyframes fadeIn {
23 | from {
24 | opacity: 0;
25 | }
26 | to {
27 | opacity: 1;
28 | }
29 | }
30 |
31 | @keyframes up {
32 | from {
33 | transform: translate3d(0, 20px, 0);
34 | }
35 | to {
36 | transform: translate3d(0, 0, 0);
37 | }
38 | }
39 |
40 | .A-fadeIn,
41 | [data-animation='fadeIn'] {
42 | animation: 0.6s fadeIn;
43 | }
44 |
45 | [data-animation='fadeInUp'] {
46 | animation: fadeIn 0.6s cubic-bezier(0.17, 0.17, 0.67, 1),
47 | up 0.6s cubic-bezier(0.02, 0.25, 0.04, 0.98);
48 | }
49 |
--------------------------------------------------------------------------------
/src/styles/dark.less:
--------------------------------------------------------------------------------
1 | :root[data-color-scheme="dark"] {
2 | // 不再建议直接用,用下面的文字色、填充色、线条色、功能色
3 | --black: var(--gray-1); // 不再使用
4 | --white: #1c222e;
5 | --gray-1: #f3f6f8;
6 | --gray-2: #cacfd7;
7 | --gray-4: #444c5a;
8 | --gray-5: rgba(204, 223, 255, 0.15);
9 | --gray-8: rgba(124, 136, 156, 0.15);
10 | --blue: #409fff;
11 | --red: #ff6666;
12 |
13 | // Brand 品牌色
14 | --brand-3: #343B4D;
15 | --brand-4: #332B26;
16 |
17 | --color-mask: rgba(14, 17, 25, 0.7);
18 | --color-toast: rgba(243, 246, 248, 0.9);
19 |
20 | --app-bg: #0e1119;
21 | --navbar-bg: #0e1119;
22 | --footer-bg: #0e1119;
23 |
24 | --btn-primary-color: #fff;
25 |
26 | --skeleton-bg-1: var(--color-fill-2);
27 | --skeleton-bg-2: var(--color-line-1);
28 |
29 | .Toolbar-btnIcon {
30 | background: #1c222e;
31 | }
32 |
33 | .Modal-dialog[data-has-avatar='true'] {
34 | background: @modal-bg;
35 | }
36 |
37 | .Popup-dialog[data-bg-color="gray"] {
38 | background: var(--brand-3);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/styles/scale.less:
--------------------------------------------------------------------------------
1 | & when (@global-style = true) {
2 | :root {
3 | --scale-ratio: 1;
4 | // vw = 16 * 100 / 375
5 | font-size: calc(4.2666666667vw * var(--scale-ratio));
6 | }
7 |
8 | @media (max-width: 374px) {
9 | :root {
10 | font-size: calc(16px * var(--scale-ratio));
11 | }
12 | }
13 |
14 | @media (min-width: 768px) {
15 | :root {
16 | font-size: 16px;
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/styles/utils.less:
--------------------------------------------------------------------------------
1 | // state
2 | .S--invisible {
3 | position: absolute;
4 | clip: rect(0, 0, 0, 0);
5 | }
6 |
7 | .pb-safe {
8 | padding-bottom: var(--safe-bottom);
9 | }
10 |
--------------------------------------------------------------------------------
/src/utils/__tests__/getExtName.test.ts:
--------------------------------------------------------------------------------
1 | import { cleanup } from '@testing-library/react';
2 | import getExtName from '../getExtName';
3 |
4 | afterEach(cleanup);
5 |
6 | describe('utils/getExtName', () => {
7 | it('should get the extension', () => {
8 | expect(getExtName('foo.jpg')).toBe('jpg');
9 | });
10 |
11 | it('should get the extension', () => {
12 | expect(getExtName('foo.jpg.png')).toBe('png');
13 | });
14 |
15 | it('should get empty string when no extension', () => {
16 | expect(getExtName('foo.')).toBe('');
17 | });
18 |
19 | it('should get empty string when no extension', () => {
20 | expect(getExtName('foo')).toBe('');
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/utils/__tests__/index.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { getRandomString, reflow } from '..';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('utils/index', () => {
8 | it('should get the random string', () => {
9 | const str1 = getRandomString();
10 | const str2 = getRandomString();
11 |
12 | expect(str1).not.toBe(str2);
13 | });
14 |
15 | it('should get the random string', () => {
16 | const str1 = getRandomString();
17 | const str2 = getRandomString();
18 |
19 | expect(str1).not.toBe(str2);
20 | });
21 |
22 | it('should return element offset height to force the reflow', () => {
23 | const { getByTestId } = render();
24 | const foo = getByTestId('foo');
25 |
26 | expect(reflow(foo)).toBe(0);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/utils/__tests__/prettyBytes.test.ts:
--------------------------------------------------------------------------------
1 | import { cleanup } from '@testing-library/react';
2 | import prettyBytes from '../prettyBytes';
3 |
4 | afterEach(cleanup);
5 |
6 | describe('utils/prettyBytes', () => {
7 | it('should convert bytes to human readable strings', () => {
8 | expect(prettyBytes(0)).toBe('0 B');
9 | expect(prettyBytes(0.7)).toBe('0.7 B');
10 | expect(prettyBytes(10)).toBe('10 B');
11 | expect(prettyBytes(10.1)).toBe('10.1 B');
12 | expect(prettyBytes(1000)).toBe('1000 B');
13 | expect(prettyBytes(1024)).toBe('1 KB');
14 | expect(prettyBytes(1024 ** 5)).toBe('1 PB');
15 | expect(prettyBytes(1024 ** 8 * 100)).toBe('100 YB');
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/src/utils/__tests__/style.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import { setTransform, setTransition } from '../style';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('utils/style', () => {
8 | it('should have the transform style', () => {
9 | const { getByTestId } = render();
10 | const foo = getByTestId('foo');
11 |
12 | setTransform(foo, 'translate(1px,2px)');
13 |
14 | expect(foo).toHaveStyle({ transform: 'translate(1px,2px)' });
15 | });
16 |
17 | it('should have the transform style', () => {
18 | const { getByTestId } = render();
19 | const foo = getByTestId('foo');
20 |
21 | setTransition(foo, '1s');
22 |
23 | expect(foo).toHaveStyle({ transition: '1s' });
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/utils/__tests__/toggleClass.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, cleanup } from '@testing-library/react';
3 | import toggleClass from '../toggleClass';
4 |
5 | afterEach(cleanup);
6 |
7 | describe('utils/toggleClass', () => {
8 | it('should add a class to `body`', () => {
9 | render();
10 | toggleClass('test1', true);
11 |
12 | expect(document.body).toHaveClass('test1');
13 | });
14 |
15 | it('should add a class', () => {
16 | const { getByTestId } = render();
17 | const foo = getByTestId('foo');
18 |
19 | toggleClass('test1', true, foo);
20 |
21 | expect(foo).toHaveClass('test1');
22 | });
23 |
24 | it('should have the transform style', () => {
25 | const { getByTestId } = render();
26 | const foo = getByTestId('foo');
27 |
28 | toggleClass('test1', false, foo);
29 |
30 | expect(foo).not.toHaveClass('test1');
31 | expect(foo).toHaveClass('test2');
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/utils/canUse.ts:
--------------------------------------------------------------------------------
1 | const testCache = {
2 | passiveListener: () => {
3 | let supportsPassive = false;
4 | try {
5 | const opts = Object.defineProperty({}, 'passive', {
6 | // eslint-disable-next-line
7 | get() {
8 | supportsPassive = true;
9 | },
10 | });
11 | // @ts-ignore
12 | window.addEventListener('test', null, opts);
13 | } catch (e) {
14 | // No support
15 | }
16 | return supportsPassive;
17 | },
18 | smoothScroll: () => 'scrollBehavior' in document.documentElement.style,
19 | touch: () => 'ontouchstart' in window,
20 | };
21 |
22 | export function addTest(name: string, test: Function) {
23 | // @ts-ignore
24 | testCache[name] = test();
25 | }
26 |
27 | export default function canUse(name: string) {
28 | // @ts-ignore
29 | return testCache[name]();
30 | }
31 |
--------------------------------------------------------------------------------
/src/utils/countLines.ts:
--------------------------------------------------------------------------------
1 | export default function countLines(el: Element) {
2 | const styles = window.getComputedStyle(el, null);
3 | const lh = parseInt(styles.lineHeight, 10);
4 | const h = parseInt(styles.height, 10);
5 | return Math.round(h / lh);
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/formatTime.ts:
--------------------------------------------------------------------------------
1 | export function formatTime(duration: number) {
2 | if (!duration) return '';
3 |
4 | const hours = Math.floor(duration / 3600);
5 | const minutes = Math.floor((duration - hours * 3600) / 60);
6 | const seconds = Math.floor(duration - hours * 3600 - minutes * 60);
7 |
8 | let ret = '';
9 |
10 | if (hours > 0) {
11 | ret += `${hours}:`;
12 | }
13 |
14 | ret += `${minutes}:`;
15 |
16 | if (seconds < 10) {
17 | ret += '0';
18 | }
19 |
20 | ret += seconds;
21 |
22 | return ret;
23 | }
24 |
--------------------------------------------------------------------------------
/src/utils/getExtName.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line no-bitwise
2 | export default (str: string) => str.slice(((str.lastIndexOf('.') - 1) >>> 0) + 2);
3 |
--------------------------------------------------------------------------------
/src/utils/getFps.ts:
--------------------------------------------------------------------------------
1 | export default (callback: (fps: number) => void, maxCount?: number) => {
2 | let fps = 0;
3 | let last = Date.now();
4 | let count = 0; // 回调触发次数
5 |
6 | // 兼容性处理
7 | if (!requestAnimationFrame) {
8 | callback(0);
9 | return;
10 | }
11 |
12 | const loop = function() {
13 | const offset = Date.now() - last;
14 | fps += 1;
15 |
16 | if (offset >= 1000) {
17 | last += offset;
18 | callback(fps);
19 | if (maxCount) count += 1;
20 | fps = 0;
21 | }
22 |
23 | if (!maxCount || count <= maxCount) {
24 | requestAnimationFrame(loop);
25 | }
26 | };
27 |
28 | loop();
29 | }
30 |
--------------------------------------------------------------------------------
/src/utils/getRandomInt.ts:
--------------------------------------------------------------------------------
1 | export default function getRandomInt(min: number, max: number) {
2 | return Math.floor(Math.random() * (max - min + 1)) + min;
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/getToBottom.ts:
--------------------------------------------------------------------------------
1 | export default function getToBottom(el: HTMLElement) {
2 | return el.scrollHeight - el.scrollTop - el.offsetHeight;
3 | }
4 |
--------------------------------------------------------------------------------
/src/utils/importScript.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | [index: string]: any;
4 | }
5 | }
6 |
7 | export function importScript(url: string, name: string) {
8 | return new Promise((resolve, reject) => {
9 | const script = document.createElement('script');
10 | script.async = true;
11 | script.crossOrigin = 'anonymous';
12 |
13 | const destroy = () => {
14 | if (script.parentNode) {
15 | script.parentNode.removeChild(script);
16 | }
17 | if (name && window[name]) {
18 | delete window[name];
19 | }
20 | };
21 |
22 | script.onload = () => {
23 | resolve(window[name]);
24 | destroy();
25 | };
26 |
27 | script.onerror = () => {
28 | reject(new Error(`Failed to import: ${url}`));
29 | destroy();
30 | };
31 |
32 | script.src = url;
33 | document.head.appendChild(script);
34 | });
35 | }
36 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns a string with at least 64-bits of randomness.
3 | *
4 | * Doesn't trust Javascript's random function entirely. Uses a combination of
5 | * random and current timestamp, and then encodes the string in base-36 to
6 | * make it shorter.
7 | *
8 | * From: https://medium.com/este-js-framework/its-ok-to-use-javascript-generated-guid-in-a-browser-373ca6431cf7
9 | * Code: https://github.com/google/closure-library/blob/555e0138c83ed54d25a3e1cd82a7e789e88335a7/closure/goog/string/string.js#L1177
10 | *
11 | * @return {string} A random string, e.g. sn1s7vb4gcic.
12 | */
13 | export function getRandomString() {
14 | const x = 2147483648;
15 | return (
16 | Math.floor(Math.random() * x).toString(36) +
17 | // eslint-disable-next-line no-bitwise
18 | Math.abs(Math.floor(Math.random() * x) ^ Date.now()).toString(36)
19 | );
20 | }
21 |
22 | // return element offset height to force the reflow
23 | export function reflow(el: HTMLElement) {
24 | return el.offsetHeight;
25 | }
26 |
--------------------------------------------------------------------------------
/src/utils/lazyComponent.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { importScript } from './importScript';
3 |
4 | export type LazyComponentResult = React.LazyExoticComponent> & {
5 | WrappedComponent?: React.ComponentType;
6 | };
7 |
8 | export function lazyComponent(
9 | url: string,
10 | name: string,
11 | success?: () => void,
12 | fail?: (err: Error) => void,
13 | ) {
14 | const ret: LazyComponentResult = React.lazy(() =>
15 | importScript(url, name)
16 | .then((res: any) => {
17 | if (!res.default) {
18 | throw new Error(`Failed to import ${name} component: no default export`);
19 | }
20 |
21 | ret.WrappedComponent = res.default || res;
22 |
23 | if (success) {
24 | success();
25 | }
26 | return res;
27 | })
28 | .catch((err: any) => {
29 | if (fail) {
30 | fail(err);
31 | }
32 | return { default: () => <>> };
33 | }),
34 | );
35 |
36 | return ret;
37 | }
38 |
--------------------------------------------------------------------------------
/src/utils/mountComponent.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | export function mountComponent(Comp: React.ReactElement, root = document.body) {
5 | const div = document.createElement('div');
6 | root.appendChild(div);
7 |
8 | const Clone = React.cloneElement(Comp, {
9 | onUnmount() {
10 | if (div) {
11 | ReactDOM.unmountComponentAtNode(div);
12 | if (div.parentNode) {
13 | div.parentNode.removeChild(div);
14 | }
15 | }
16 | },
17 | });
18 |
19 | ReactDOM.render(Clone, div);
20 |
21 | return div;
22 | }
23 |
--------------------------------------------------------------------------------
/src/utils/parseDataTransfer.ts:
--------------------------------------------------------------------------------
1 | export default function parseDataTransfer(
2 | e: React.ClipboardEvent,
3 | callback: (file: File) => void,
4 | ) {
5 | // const dataTransfer = e.dataTransfer || e.clipboardData;
6 | const { items } = e.clipboardData;
7 | if (items && items.length) {
8 | // eslint-disable-next-line no-plusplus
9 | for (let i = 0; i < items.length; i++) {
10 | const item = items[i];
11 |
12 | if (item.type.indexOf('image') !== -1) {
13 | const file = item.getAsFile();
14 | if (file) {
15 | callback(file);
16 | }
17 | e.preventDefault();
18 | break;
19 | }
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/utils/prettyBytes.ts:
--------------------------------------------------------------------------------
1 | const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
2 | const k = 1024;
3 |
4 | export default (bytes: number, decimals?: number) => {
5 | if (bytes < 1) {
6 | return `${bytes} ${UNITS[0]}`;
7 | }
8 |
9 | const dm = decimals || 2;
10 | const i = Math.floor(Math.log(bytes) / Math.log(k));
11 |
12 | return `${parseFloat((bytes / k ** i).toFixed(dm))} ${UNITS[i]}`;
13 | };
14 |
--------------------------------------------------------------------------------
/src/utils/smoothScroll.ts:
--------------------------------------------------------------------------------
1 | import getFps from './getFps';
2 |
3 | interface Props {
4 | el: HTMLElement;
5 | to: number;
6 | duration?: number;
7 | x?: boolean;
8 | }
9 |
10 | let rAF = requestAnimationFrame;
11 | const mockRAF = (cb: Function) => window.setTimeout(cb, 16);
12 |
13 | getFps((fps) => {
14 | rAF = fps < 55 ? mockRAF : requestAnimationFrame;
15 | }, 3);
16 |
17 | export default function smoothScroll({ el, to, duration = 300, x }: Props) {
18 | const attr = x ? 'scrollLeft' : 'scrollTop';
19 |
20 | if (!rAF) {
21 | el[attr] = to;
22 | return;
23 | }
24 |
25 | const from = el[attr];
26 | const frames = Math.round(duration / 16);
27 | const step = (to - from) / frames;
28 | let count = 0;
29 |
30 | function animate() {
31 | // eslint-disable-next-line no-param-reassign
32 | el[attr] += step;
33 |
34 | // eslint-disable-next-line no-plusplus
35 | if (++count < frames) {
36 | rAF(animate);
37 | }
38 | }
39 |
40 | animate();
41 | }
42 |
--------------------------------------------------------------------------------
/src/utils/style.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | export const setTransform = (el: HTMLElement, value: string) => {
3 | if (el) {
4 | el.style.transform = value;
5 | el.style.webkitTransform = value;
6 | }
7 | };
8 |
9 | export const setTransition = (el: HTMLElement, value: string) => {
10 | if (el) {
11 | el.style.transition = value;
12 | el.style.webkitTransition = value;
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/src/utils/throttle.ts:
--------------------------------------------------------------------------------
1 | export default function throttle(fn: Function, delay = 300) {
2 | let ready = true;
3 |
4 | return (...args: any) => {
5 | if (!ready) {
6 | return;
7 | }
8 |
9 | ready = false;
10 | fn(...args);
11 |
12 | setTimeout(() => {
13 | ready = true;
14 | }, delay);
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/src/utils/toggleClass.ts:
--------------------------------------------------------------------------------
1 | // IE 不支持 toggle 第二个参数
2 | export default (className: string, flag: boolean, el: HTMLElement = document.body) => {
3 | el.classList[flag ? 'add' : 'remove'](className);
4 | }
5 |
--------------------------------------------------------------------------------
/src/utils/ua.ts:
--------------------------------------------------------------------------------
1 | const ua = navigator.userAgent;
2 |
3 | export const isIOS = /iPad|iPhone|iPod/.test(ua);
4 |
5 | export const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(ua);
6 |
7 | export const isSafariOrIOS11 = ua.includes('Safari/') || /OS 11_[0-3]\D/.test(ua);
8 |
9 | export function getIOSMajorVersion() {
10 | const v = ua.match(/OS (\d+)_/);
11 | return v ? +v[1] : 0;
12 | }
13 |
14 | export const isArkWeb = ua.includes('ArkWeb');
15 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "src",
5 | "declaration": true,
6 | "declarationDir": "lib",
7 | "emitDeclarationOnly": true
8 | },
9 | "exclude": [
10 | "node_modules",
11 | "dist",
12 | "es",
13 | "lib",
14 | "demo",
15 | "storybook",
16 | "./*.js",
17 | "jest.setup.ts",
18 | "**/*.test.ts",
19 | "**/*.test.tsx"
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": ["**/*.js"]
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Project Options */
4 | "allowJs": true,
5 | "isolatedModules": true,
6 | "jsx": "react",
7 | "lib": ["dom", "esnext"],
8 | "module": "esnext",
9 | "target": "esnext",
10 |
11 | /* Strict Checks */
12 | "noImplicitAny": true,
13 | "noImplicitThis": true,
14 | "strict": true,
15 | "strictFunctionTypes": true,
16 | "strictNullChecks": true,
17 | "strictPropertyInitialization": true,
18 |
19 | /* Module Resolution */
20 | "allowSyntheticDefaultImports": true,
21 | "esModuleInterop": true,
22 | "moduleResolution": "node",
23 |
24 | /* Linter Checks */
25 | "noFallthroughCasesInSwitch": true,
26 | "noImplicitReturns": true,
27 | "noUnusedLocals": true,
28 | "noUnusedParameters": true,
29 |
30 | /* Advanced */
31 | "forceConsistentCasingInFileNames": true,
32 | "skipLibCheck": true,
33 | "stripInternal": true,
34 | "resolveJsonModule": true
35 | },
36 |
37 | /* File Inclusion */
38 | "exclude": ["node_modules", "dist", "es", "lib", "./*.js"]
39 | }
40 |
--------------------------------------------------------------------------------