From aac8835a566783f805121d95f646aa5510acdafc Mon Sep 17 00:00:00 2001 From: senDev001 Date: Wed, 16 Sep 2020 23:06:01 +0800 Subject: [PATCH] feat: initial release --- .eslintignore | 6 + .eslintrc.js | 35 ++ .gitignore | 19 + .prettierrc | 7 + README.md | 73 ++++ babel.config.js | 50 +++ commitlint.config.js | 3 + config/webpack.demo.js | 43 +++ gulpfile.js | 48 +++ jest.config.js | 6 + jest.setup.ts | 2 + package.json | 101 +++++ postcss.config.js | 13 + rollup.config.js | 35 ++ .../Avatar/__tests__/index.test.tsx | 38 ++ src/components/Avatar/index.tsx | 29 ++ src/components/Avatar/style.less | 26 ++ .../Backdrop/__tests__/index.test.tsx | 15 + src/components/Backdrop/index.tsx | 21 ++ src/components/Backdrop/style.less | 16 + .../Bubble/__tests__/index.test.tsx | 41 ++ src/components/Bubble/index.tsx | 16 + src/components/Bubble/style.less | 27 ++ .../Button/__tests__/index.test.tsx | 99 +++++ src/components/Button/index.tsx | 66 ++++ src/components/Button/style.less | 85 +++++ src/components/Card/Card.tsx | 25 ++ src/components/Card/CardActions.tsx | 19 + src/components/Card/CardContent.tsx | 15 + src/components/Card/CardMedia.tsx | 40 ++ src/components/Card/CardText.tsx | 15 + src/components/Card/CardTitle.tsx | 21 ++ .../Card/__tests__/actions.test.tsx | 26 ++ .../Card/__tests__/content.test.tsx | 17 + src/components/Card/__tests__/index.test.tsx | 35 ++ src/components/Card/__tests__/media.test.tsx | 38 ++ src/components/Card/__tests__/text.test.tsx | 25 ++ src/components/Card/__tests__/title.test.tsx | 46 +++ src/components/Card/index.ts | 12 + src/components/Card/style.less | 136 +++++++ src/components/Carousel/index.tsx | 343 +++++++++++++++++ src/components/Carousel/style.less | 25 ++ src/components/Chat/index.tsx | 226 +++++++++++ src/components/Chat/style.less | 124 ++++++ src/components/Checkbox/Checkbox.tsx | 32 ++ src/components/Checkbox/CheckboxGroup.tsx | 40 ++ .../Checkbox/__tests__/group.test.tsx | 100 +++++ .../Checkbox/__tests__/index.test.tsx | 80 ++++ src/components/Checkbox/index.ts | 4 + .../ClickOutside/__tests__/index.test.tsx | 50 +++ src/components/ClickOutside/index.tsx | 38 ++ src/components/Composer/Action.tsx | 8 + src/components/Composer/ToolbarItem.tsx | 31 ++ src/components/Composer/index.tsx | 327 ++++++++++++++++ src/components/Composer/riseInput.ts | 60 +++ src/components/Composer/style.less | 110 ++++++ .../Divider/__tests__/index.test.tsx | 30 ++ src/components/Divider/index.tsx | 20 + src/components/Divider/style.less | 38 ++ src/components/Empty/__tests__/index.test.tsx | 51 +++ src/components/Empty/index.tsx | 26 ++ src/components/Empty/style.less | 13 + src/components/ErrorBoundary/index.tsx | 23 ++ .../FileCard/__tests__/index.test.tsx | 25 ++ src/components/FileCard/index.tsx | 42 +++ src/components/FileCard/style.less | 59 +++ src/components/Flex/Flex.tsx | 93 +++++ src/components/Flex/FlexItem.tsx | 26 ++ src/components/Flex/__tests__/index.test.tsx | 61 +++ src/components/Flex/__tests__/item.test.tsx | 45 +++ src/components/Flex/index.ts | 4 + src/components/Flex/style.less | 78 ++++ src/components/Form/Form.tsx | 20 + src/components/Form/FormActions.tsx | 11 + src/components/Form/FormItem.tsx | 23 ++ src/components/Form/index.ts | 5 + src/components/Form/style.less | 63 ++++ src/components/Goods/__tests__/index.test.tsx | 67 ++++ src/components/Goods/index.tsx | 115 ++++++ src/components/Goods/style.less | 57 +++ .../HelpText/__tests__/index.test.tsx | 19 + src/components/HelpText/index.tsx | 10 + src/components/HelpText/style.less | 4 + src/components/Icon/__tests__/index.test.tsx | 44 +++ src/components/Icon/index.tsx | 20 + src/components/Icon/style.less | 21 ++ .../IconButton/__tests__/index.test.tsx | 23 ++ src/components/IconButton/index.tsx | 18 + src/components/IconButton/style.less | 17 + src/components/Image/__tests__/index.test.tsx | 51 +++ src/components/Image/index.tsx | 58 +++ src/components/Image/style.less | 10 + src/components/InfiniteScroll/index.tsx | 37 ++ src/components/InfiniteScroll/style.less | 4 + src/components/Input/__tests__/index.test.tsx | 43 +++ src/components/Input/index.tsx | 152 ++++++++ src/components/Input/style.less | 86 +++++ src/components/Label/__tests__/index.test.tsx | 15 + src/components/Label/index.tsx | 11 + src/components/Label/style.less | 5 + src/components/List/List.tsx | 16 + src/components/List/ListItem.tsx | 21 ++ src/components/List/__tests__/index.test.tsx | 32 ++ src/components/List/index.ts | 4 + src/components/List/style.less | 26 ++ .../Loading/__tests__/index.test.tsx | 15 + src/components/Loading/index.tsx | 18 + src/components/Loading/style.less | 13 + .../LocaleProvider/__tests__/index.test.tsx | 24 ++ src/components/LocaleProvider/index.tsx | 40 ++ .../LocaleProvider/locales/ar_EG.ts | 11 + .../LocaleProvider/locales/en_US.ts | 24 ++ .../LocaleProvider/locales/fr_FR.ts | 11 + .../LocaleProvider/locales/index.ts | 11 + .../LocaleProvider/locales/zh_CN.ts | 22 ++ .../MediaObject/__tests__/index.test.tsx | 40 ++ src/components/MediaObject/index.tsx | 28 ++ src/components/MediaObject/style.less | 27 ++ src/components/Message/Message.tsx | 78 ++++ src/components/Message/SystemMessage.tsx | 25 ++ .../Message/__tests__/index.test.tsx | 65 ++++ src/components/Message/index.ts | 4 + src/components/Message/style.less | 91 +++++ .../MessageContainer/__tests__/index.test.tsx | 58 +++ src/components/MessageContainer/index.tsx | 115 ++++++ src/components/MessageContainer/style.less | 19 + src/components/Modal/Base.tsx | 91 +++++ src/components/Modal/Confirm.tsx | 6 + src/components/Modal/Modal.tsx | 6 + src/components/Modal/Popup.tsx | 6 + src/components/Modal/__tests__/index.test.tsx | 75 ++++ src/components/Modal/index.ts | 4 + src/components/Modal/style.less | 189 ++++++++++ .../Navbar/__tests__/index.test.tsx | 38 ++ src/components/Navbar/index.tsx | 41 ++ src/components/Navbar/style.less | 43 +++ .../Notice/__tests__/index.test.tsx | 48 +++ src/components/Notice/index.tsx | 65 ++++ src/components/Notice/style.less | 60 +++ .../Popover/__tests__/index.test.tsx | 49 +++ src/components/Popover/index.tsx | 51 +++ src/components/Popover/style.less | 22 ++ src/components/Portal/index.ts | 23 ++ src/components/Price/__tests__/index.test.tsx | 36 ++ src/components/Price/index.tsx | 23 ++ src/components/Price/style.less | 12 + .../Progress/__tests__/index.test.tsx | 28 ++ src/components/Progress/index.tsx | 31 ++ src/components/Progress/style.less | 23 ++ src/components/PullToRefresh/index.tsx | 231 ++++++++++++ src/components/PullToRefresh/style.less | 34 ++ src/components/QuickReplies/QuickReplies.tsx | 49 +++ src/components/QuickReplies/QuickReply.tsx | 38 ++ .../QuickReplies/__tests__/index.test.tsx | 51 +++ .../QuickReplies/__tests__/item.test.tsx | 60 +++ src/components/QuickReplies/index.ts | 4 + src/components/QuickReplies/style.less | 62 +++ src/components/Radio/Radio.tsx | 33 ++ src/components/Radio/RadioGroup.tsx | 34 ++ src/components/Radio/__tests__/group.test.tsx | 88 +++++ src/components/Radio/__tests__/index.test.tsx | 67 ++++ src/components/Radio/index.ts | 4 + src/components/Radio/style.less | 66 ++++ .../RateActions/__tests__/index.test.tsx | 85 +++++ src/components/RateActions/index.tsx | 55 +++ src/components/RateActions/style.less | 36 ++ src/components/Recorder/index.tsx | 130 +++++++ src/components/Recorder/style.less | 91 +++++ .../RichText/__tests__/index.test.tsx | 25 ++ src/components/RichText/index.tsx | 26 ++ src/components/RichText/style.less | 4 + src/components/ScrollView/Item.tsx | 49 +++ src/components/ScrollView/ScrollView.tsx | 96 +++++ .../ScrollView/__tests__/index.test.tsx | 51 +++ src/components/ScrollView/index.ts | 2 + src/components/ScrollView/style.less | 60 +++ src/components/SendConfirm/SendConfirm.tsx | 55 +++ .../SendConfirm/__tests__/index.test.tsx | 23 ++ src/components/SendConfirm/index.ts | 1 + src/components/SendConfirm/style.less | 16 + src/components/Stepper/Step.tsx | 50 +++ src/components/Stepper/Stepper.tsx | 40 ++ .../Stepper/__tests__/index.test.tsx | 19 + src/components/Stepper/index.ts | 4 + src/components/Stepper/style.less | 67 ++++ src/components/Tabs/Tab.tsx | 7 + src/components/Tabs/Tabs.tsx | 165 ++++++++ src/components/Tabs/__tests__/index.test.tsx | 50 +++ src/components/Tabs/index.ts | 4 + src/components/Tabs/style.less | 62 +++ src/components/Tag/__tests__/index.test.tsx | 17 + src/components/Tag/index.tsx | 20 + src/components/Tag/style.less | 23 ++ src/components/Text/__tests__/index.test.tsx | 24 ++ src/components/Text/index.tsx | 33 ++ src/components/Text/style.less | 19 + src/components/Time/Time.tsx | 17 + src/components/Time/__tests__/index.test.tsx | 26 ++ src/components/Time/index.ts | 1 + src/components/Time/parser.ts | 62 +++ src/components/Time/style.less | 6 + src/components/Toast/Toast.tsx | 59 +++ src/components/Toast/__tests__/index.test.tsx | 27 ++ src/components/Toast/index.tsx | 20 + src/components/Toast/style.less | 50 +++ src/components/Toolbar/Toolbar.tsx | 18 + src/components/Toolbar/ToolbarButton.tsx | 32 ++ .../Toolbar/__tests__/index.test.tsx | 22 ++ src/components/Toolbar/index.ts | 3 + src/components/Toolbar/style.less | 48 +++ src/components/Tooltip/style.less | 40 ++ src/components/Tree/Tree.tsx | 15 + src/components/Tree/TreeNode.tsx | 74 ++++ src/components/Tree/__tests__/index.test.tsx | 90 +++++ src/components/Tree/index.ts | 4 + src/components/Tree/style.less | 41 ++ src/components/Typing/Typing.tsx | 14 + .../Typing/__tests__/index.test.tsx | 14 + src/components/Typing/index.ts | 1 + src/components/Typing/style.less | 47 +++ src/components/Video/__tests__/index.test.tsx | 44 +++ src/components/Video/index.tsx | 85 +++++ src/components/Video/style.less | 50 +++ .../VisuallyHidden/__tests__/index.test.tsx | 14 + src/components/VisuallyHidden/index.tsx | 17 + src/components/VisuallyHidden/style.less | 0 src/hooks/useClickOutside.ts | 32 ++ src/hooks/useMessages.ts | 78 ++++ src/hooks/useMount.ts | 40 ++ src/hooks/useNextId.ts | 10 + src/hooks/useQuickReplies.ts | 41 ++ src/hooks/useWindowResize.ts | 29 ++ src/index.ts | 111 ++++++ src/styles/index.less | 61 +++ src/styles/reboot.less | 357 ++++++++++++++++++ src/styles/root.less | 49 +++ src/styles/utils.less | 5 + src/styles/var.less | 287 ++++++++++++++ src/utils/__tests__/getExtName.test.ts | 22 ++ src/utils/__tests__/index.test.tsx | 28 ++ src/utils/__tests__/prettyBytes.test.ts | 17 + src/utils/__tests__/style.test.tsx | 25 ++ src/utils/__tests__/toggleClass.test.tsx | 33 ++ src/utils/canUse.ts | 34 ++ src/utils/getExtName.ts | 2 + src/utils/index.ts | 25 ++ src/utils/mountComponent.ts | 20 + src/utils/parseDataTransfer.ts | 22 ++ src/utils/prettyBytes.ts | 13 + src/utils/smoothScroll.ts | 33 ++ src/utils/style.ts | 9 + src/utils/toggleClass.ts | 4 + storybook/.storybook/main.js | 26 ++ storybook/.storybook/preview-head.html | 1 + storybook/.storybook/preview.js | 3 + storybook/package.json | 16 + storybook/stories/Avatar.stories.tsx | 18 + storybook/stories/Chat.stories.tsx | 130 +++++++ storybook/stories/Stepper.stories.tsx | 27 ++ tsconfig.build.json | 8 + tsconfig.eslint.json | 4 + tsconfig.json | 40 ++ webpack.config.js | 74 ++++ 263 files changed, 11344 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 babel.config.js create mode 100644 commitlint.config.js create mode 100644 config/webpack.demo.js create mode 100644 gulpfile.js create mode 100644 jest.config.js create mode 100644 jest.setup.ts create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 rollup.config.js create mode 100644 src/components/Avatar/__tests__/index.test.tsx create mode 100644 src/components/Avatar/index.tsx create mode 100644 src/components/Avatar/style.less create mode 100644 src/components/Backdrop/__tests__/index.test.tsx create mode 100644 src/components/Backdrop/index.tsx create mode 100644 src/components/Backdrop/style.less create mode 100644 src/components/Bubble/__tests__/index.test.tsx create mode 100644 src/components/Bubble/index.tsx create mode 100644 src/components/Bubble/style.less create mode 100644 src/components/Button/__tests__/index.test.tsx create mode 100644 src/components/Button/index.tsx create mode 100644 src/components/Button/style.less create mode 100644 src/components/Card/Card.tsx create mode 100644 src/components/Card/CardActions.tsx create mode 100644 src/components/Card/CardContent.tsx create mode 100644 src/components/Card/CardMedia.tsx create mode 100644 src/components/Card/CardText.tsx create mode 100644 src/components/Card/CardTitle.tsx create mode 100644 src/components/Card/__tests__/actions.test.tsx create mode 100644 src/components/Card/__tests__/content.test.tsx create mode 100644 src/components/Card/__tests__/index.test.tsx create mode 100644 src/components/Card/__tests__/media.test.tsx create mode 100644 src/components/Card/__tests__/text.test.tsx create mode 100644 src/components/Card/__tests__/title.test.tsx create mode 100644 src/components/Card/index.ts create mode 100644 src/components/Card/style.less create mode 100644 src/components/Carousel/index.tsx create mode 100644 src/components/Carousel/style.less create mode 100644 src/components/Chat/index.tsx create mode 100644 src/components/Chat/style.less create mode 100644 src/components/Checkbox/Checkbox.tsx create mode 100644 src/components/Checkbox/CheckboxGroup.tsx create mode 100644 src/components/Checkbox/__tests__/group.test.tsx create mode 100644 src/components/Checkbox/__tests__/index.test.tsx create mode 100644 src/components/Checkbox/index.ts create mode 100644 src/components/ClickOutside/__tests__/index.test.tsx create mode 100644 src/components/ClickOutside/index.tsx create mode 100644 src/components/Composer/Action.tsx create mode 100644 src/components/Composer/ToolbarItem.tsx create mode 100644 src/components/Composer/index.tsx create mode 100644 src/components/Composer/riseInput.ts create mode 100644 src/components/Composer/style.less create mode 100644 src/components/Divider/__tests__/index.test.tsx create mode 100644 src/components/Divider/index.tsx create mode 100644 src/components/Divider/style.less create mode 100644 src/components/Empty/__tests__/index.test.tsx create mode 100644 src/components/Empty/index.tsx create mode 100644 src/components/Empty/style.less create mode 100644 src/components/ErrorBoundary/index.tsx create mode 100644 src/components/FileCard/__tests__/index.test.tsx create mode 100644 src/components/FileCard/index.tsx create mode 100644 src/components/FileCard/style.less create mode 100644 src/components/Flex/Flex.tsx create mode 100644 src/components/Flex/FlexItem.tsx create mode 100644 src/components/Flex/__tests__/index.test.tsx create mode 100644 src/components/Flex/__tests__/item.test.tsx create mode 100644 src/components/Flex/index.ts create mode 100644 src/components/Flex/style.less create mode 100644 src/components/Form/Form.tsx create mode 100644 src/components/Form/FormActions.tsx create mode 100644 src/components/Form/FormItem.tsx create mode 100644 src/components/Form/index.ts create mode 100644 src/components/Form/style.less create mode 100644 src/components/Goods/__tests__/index.test.tsx create mode 100644 src/components/Goods/index.tsx create mode 100644 src/components/Goods/style.less create mode 100644 src/components/HelpText/__tests__/index.test.tsx create mode 100644 src/components/HelpText/index.tsx create mode 100644 src/components/HelpText/style.less create mode 100644 src/components/Icon/__tests__/index.test.tsx create mode 100644 src/components/Icon/index.tsx create mode 100644 src/components/Icon/style.less create mode 100644 src/components/IconButton/__tests__/index.test.tsx create mode 100644 src/components/IconButton/index.tsx create mode 100644 src/components/IconButton/style.less create mode 100644 src/components/Image/__tests__/index.test.tsx create mode 100644 src/components/Image/index.tsx create mode 100644 src/components/Image/style.less create mode 100644 src/components/InfiniteScroll/index.tsx create mode 100644 src/components/InfiniteScroll/style.less create mode 100644 src/components/Input/__tests__/index.test.tsx create mode 100644 src/components/Input/index.tsx create mode 100644 src/components/Input/style.less create mode 100644 src/components/Label/__tests__/index.test.tsx create mode 100644 src/components/Label/index.tsx create mode 100644 src/components/Label/style.less create mode 100644 src/components/List/List.tsx create mode 100644 src/components/List/ListItem.tsx create mode 100644 src/components/List/__tests__/index.test.tsx create mode 100644 src/components/List/index.ts create mode 100644 src/components/List/style.less create mode 100644 src/components/Loading/__tests__/index.test.tsx create mode 100644 src/components/Loading/index.tsx create mode 100644 src/components/Loading/style.less create mode 100644 src/components/LocaleProvider/__tests__/index.test.tsx create mode 100644 src/components/LocaleProvider/index.tsx create mode 100644 src/components/LocaleProvider/locales/ar_EG.ts create mode 100644 src/components/LocaleProvider/locales/en_US.ts create mode 100644 src/components/LocaleProvider/locales/fr_FR.ts create mode 100644 src/components/LocaleProvider/locales/index.ts create mode 100644 src/components/LocaleProvider/locales/zh_CN.ts create mode 100644 src/components/MediaObject/__tests__/index.test.tsx create mode 100644 src/components/MediaObject/index.tsx create mode 100644 src/components/MediaObject/style.less create mode 100644 src/components/Message/Message.tsx create mode 100644 src/components/Message/SystemMessage.tsx create mode 100644 src/components/Message/__tests__/index.test.tsx create mode 100644 src/components/Message/index.ts create mode 100644 src/components/Message/style.less create mode 100644 src/components/MessageContainer/__tests__/index.test.tsx create mode 100644 src/components/MessageContainer/index.tsx create mode 100644 src/components/MessageContainer/style.less create mode 100644 src/components/Modal/Base.tsx create mode 100644 src/components/Modal/Confirm.tsx create mode 100644 src/components/Modal/Modal.tsx create mode 100644 src/components/Modal/Popup.tsx create mode 100644 src/components/Modal/__tests__/index.test.tsx create mode 100644 src/components/Modal/index.ts create mode 100644 src/components/Modal/style.less create mode 100644 src/components/Navbar/__tests__/index.test.tsx create mode 100644 src/components/Navbar/index.tsx create mode 100644 src/components/Navbar/style.less create mode 100644 src/components/Notice/__tests__/index.test.tsx create mode 100644 src/components/Notice/index.tsx create mode 100644 src/components/Notice/style.less create mode 100644 src/components/Popover/__tests__/index.test.tsx create mode 100644 src/components/Popover/index.tsx create mode 100644 src/components/Popover/style.less create mode 100644 src/components/Portal/index.ts create mode 100644 src/components/Price/__tests__/index.test.tsx create mode 100644 src/components/Price/index.tsx create mode 100644 src/components/Price/style.less create mode 100644 src/components/Progress/__tests__/index.test.tsx create mode 100644 src/components/Progress/index.tsx create mode 100644 src/components/Progress/style.less create mode 100644 src/components/PullToRefresh/index.tsx create mode 100644 src/components/PullToRefresh/style.less create mode 100644 src/components/QuickReplies/QuickReplies.tsx create mode 100644 src/components/QuickReplies/QuickReply.tsx create mode 100644 src/components/QuickReplies/__tests__/index.test.tsx create mode 100644 src/components/QuickReplies/__tests__/item.test.tsx create mode 100644 src/components/QuickReplies/index.ts create mode 100644 src/components/QuickReplies/style.less create mode 100644 src/components/Radio/Radio.tsx create mode 100644 src/components/Radio/RadioGroup.tsx create mode 100644 src/components/Radio/__tests__/group.test.tsx create mode 100644 src/components/Radio/__tests__/index.test.tsx create mode 100644 src/components/Radio/index.ts create mode 100644 src/components/Radio/style.less create mode 100644 src/components/RateActions/__tests__/index.test.tsx create mode 100644 src/components/RateActions/index.tsx create mode 100644 src/components/RateActions/style.less create mode 100644 src/components/Recorder/index.tsx create mode 100644 src/components/Recorder/style.less create mode 100644 src/components/RichText/__tests__/index.test.tsx create mode 100644 src/components/RichText/index.tsx create mode 100644 src/components/RichText/style.less create mode 100644 src/components/ScrollView/Item.tsx create mode 100644 src/components/ScrollView/ScrollView.tsx create mode 100644 src/components/ScrollView/__tests__/index.test.tsx create mode 100644 src/components/ScrollView/index.ts create mode 100644 src/components/ScrollView/style.less create mode 100644 src/components/SendConfirm/SendConfirm.tsx create mode 100644 src/components/SendConfirm/__tests__/index.test.tsx create mode 100644 src/components/SendConfirm/index.ts create mode 100644 src/components/SendConfirm/style.less create mode 100644 src/components/Stepper/Step.tsx create mode 100644 src/components/Stepper/Stepper.tsx create mode 100644 src/components/Stepper/__tests__/index.test.tsx create mode 100644 src/components/Stepper/index.ts create mode 100644 src/components/Stepper/style.less create mode 100644 src/components/Tabs/Tab.tsx create mode 100644 src/components/Tabs/Tabs.tsx create mode 100644 src/components/Tabs/__tests__/index.test.tsx create mode 100644 src/components/Tabs/index.ts create mode 100644 src/components/Tabs/style.less create mode 100644 src/components/Tag/__tests__/index.test.tsx create mode 100644 src/components/Tag/index.tsx create mode 100644 src/components/Tag/style.less create mode 100644 src/components/Text/__tests__/index.test.tsx create mode 100644 src/components/Text/index.tsx create mode 100644 src/components/Text/style.less create mode 100644 src/components/Time/Time.tsx create mode 100644 src/components/Time/__tests__/index.test.tsx create mode 100644 src/components/Time/index.ts create mode 100644 src/components/Time/parser.ts create mode 100644 src/components/Time/style.less create mode 100644 src/components/Toast/Toast.tsx create mode 100644 src/components/Toast/__tests__/index.test.tsx create mode 100644 src/components/Toast/index.tsx create mode 100644 src/components/Toast/style.less create mode 100644 src/components/Toolbar/Toolbar.tsx create mode 100644 src/components/Toolbar/ToolbarButton.tsx create mode 100644 src/components/Toolbar/__tests__/index.test.tsx create mode 100644 src/components/Toolbar/index.ts create mode 100644 src/components/Toolbar/style.less create mode 100644 src/components/Tooltip/style.less create mode 100644 src/components/Tree/Tree.tsx create mode 100644 src/components/Tree/TreeNode.tsx create mode 100644 src/components/Tree/__tests__/index.test.tsx create mode 100644 src/components/Tree/index.ts create mode 100644 src/components/Tree/style.less create mode 100644 src/components/Typing/Typing.tsx create mode 100644 src/components/Typing/__tests__/index.test.tsx create mode 100644 src/components/Typing/index.ts create mode 100644 src/components/Typing/style.less create mode 100644 src/components/Video/__tests__/index.test.tsx create mode 100644 src/components/Video/index.tsx create mode 100644 src/components/Video/style.less create mode 100644 src/components/VisuallyHidden/__tests__/index.test.tsx create mode 100644 src/components/VisuallyHidden/index.tsx create mode 100644 src/components/VisuallyHidden/style.less create mode 100644 src/hooks/useClickOutside.ts create mode 100644 src/hooks/useMessages.ts create mode 100644 src/hooks/useMount.ts create mode 100644 src/hooks/useNextId.ts create mode 100644 src/hooks/useQuickReplies.ts create mode 100644 src/hooks/useWindowResize.ts create mode 100644 src/index.ts create mode 100644 src/styles/index.less create mode 100644 src/styles/reboot.less create mode 100644 src/styles/root.less create mode 100644 src/styles/utils.less create mode 100644 src/styles/var.less create mode 100644 src/utils/__tests__/getExtName.test.ts create mode 100644 src/utils/__tests__/index.test.tsx create mode 100644 src/utils/__tests__/prettyBytes.test.ts create mode 100644 src/utils/__tests__/style.test.tsx create mode 100644 src/utils/__tests__/toggleClass.test.tsx create mode 100644 src/utils/canUse.ts create mode 100644 src/utils/getExtName.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/mountComponent.ts create mode 100644 src/utils/parseDataTransfer.ts create mode 100644 src/utils/prettyBytes.ts create mode 100644 src/utils/smoothScroll.ts create mode 100644 src/utils/style.ts create mode 100644 src/utils/toggleClass.ts create mode 100644 storybook/.storybook/main.js create mode 100644 storybook/.storybook/preview-head.html create mode 100644 storybook/.storybook/preview.js create mode 100644 storybook/package.json create mode 100644 storybook/stories/Avatar.stories.tsx create mode 100644 storybook/stories/Chat.stories.tsx create mode 100644 storybook/stories/Stepper.stories.tsx create mode 100644 tsconfig.build.json create mode 100644 tsconfig.eslint.json create mode 100644 tsconfig.json create mode 100644 webpack.config.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..3265097 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +*.js + +# build +dist +es +lib diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..d36ff91 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,35 @@ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: 2020, + // project: './tsconfig.eslint.json', + }, + extends: [ + 'airbnb', + 'airbnb-typescript', + 'prettier', + 'prettier/react', + 'prettier/@typescript-eslint', + ], + env: { + browser: true, + // jest: true, + }, + plugins: ['compat', 'react-hooks'], + rules: { + 'compat/compat': 'error', + 'no-underscore-dangle': 'off', + 'react/jsx-filename-extension': 'off', + 'react/jsx-props-no-spreading': 'off', + 'react/no-array-index-key': 'off', + 'react/require-default-props': 'off', + 'react/prop-types': 'off', + 'react-hooks/rules-of-hooks': 'error', + 'jsx-a11y/click-events-have-key-events': 'off', + 'jsx-a11y/label-has-associated-control': 'off', + 'jsx-a11y/label-has-for': 'off', + }, + settings: { + polyfills: ['IntersectionObserver', 'Promise'], + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31b1edb --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# dependencies +/node_modules +/storybook/node_modules + +# testing +/coverage + +# production +/build +/dist +/lib +/es + +# misc +.DS_Store + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b08d8ce --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all", + "proseWrap": "never", + "endOfLine": "lf" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d118283 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +

+ + ChatUI + +

+ +

服务于智能对话领域的设计和开发体系,助力智能对话机器人的搭建

+ +## 安装 + +```bash +npm install chatui --save +``` + +```bash +yarn add chatui +``` + +## 示例 + +```jsx +import Chat, { Bubble, useMessages } from 'chatui'; +import 'chatui/dist/index.css'; + +const App = () => { + const { messages, appendMsg, setTyping } = useMessages([]); + + function handleSend(type, val) { + if (type === 'text' && val.trim()) { + appendMsg({ + type: 'text', + content: { text: val }, + position: 'right', + }); + + setTyping(true); + + setTimeout(() => { + appendMsg({ + type: 'text', + content: { text: 'Bala bala' }, + }); + }, 1000); + } + } + + function renderMessageContent(msg) { + const { content } = msg; + return ; + } + + return ( + + ); +}; +``` + +### 定制主题 + +参考 [定制主题](https://chatui.io/docs/customize-theme) 文档。 + +## 国际化 + +参考 [国际化](https://chatui.io/docs/i18n) 文档。 + +## License + +MIT diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..7efe801 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,50 @@ +module.exports = (api) => { + const env = api.env(); + api.cache.using(() => env === 'development'); + + return { + presets: ['@babel/preset-env', '@babel/preset-typescript', '@babel/preset-react'], + plugins: [ + '@babel/plugin-transform-runtime', + + // Stage 3 + '@babel/plugin-proposal-class-properties', + ], + env: { + esm: { + presets: [ + [ + '@babel/preset-env', + { + modules: false, + }, + ], + ], + plugins: [ + [ + '@babel/plugin-transform-runtime', + { + useESModules: true, + }, + ], + ], + }, + umd: { + presets: [ + [ + '@babel/preset-env', + { + targets: { + android: '4.4', + ios: '9', + }, + useBuiltIns: 'usage', + corejs: 3, + }, + ], + ], + plugins: [['@babel/plugin-transform-runtime', { corejs: 3 }]], + }, + }, + }; +}; diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..84dcb12 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], +}; diff --git a/config/webpack.demo.js b/config/webpack.demo.js new file mode 100644 index 0000000..4e5ae46 --- /dev/null +++ b/config/webpack.demo.js @@ -0,0 +1,43 @@ +module.exports = () => { + process.env.BROWSERSLIST_ENV = 'production'; + + return { + mode: 'development', + devtool: 'cheap-module-eval-source-map', + entry: './demo/index.js', + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: 'babel-loader', + }, + { + test: /\.less$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + importLoaders: 2, + sourceMap: true, + }, + }, + 'postcss-loader', + 'less-loader', + ], + }, + ], + }, + externals: { + react: 'React', + 'react-dom': 'ReactDOM', + 'chat-ui': 'ChatUI', + }, + devServer: { + contentBase: './demo', + port: 9000, + stats: 'minimal', + }, + }; +}; diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..0bb4c95 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,48 @@ +const gulp = require('gulp'); +const babel = require('gulp-babel'); +const less = require('gulp-less'); +const postcss = require('gulp-postcss'); + +process.env.NODE_ENV = 'production'; + +const paths = { + js: ['./src/**/*.js'], + dest: { + lib: 'lib', + esm: 'es', + dist: 'dist', + }, +}; + +function fullStyle(cb) { + gulp.src('./src/styles/index.less').pipe(less()).pipe(postcss()).pipe(gulp.dest(paths.dest.dist)); + + cb(); +} + +function copyLess() { + return gulp + .src('src/**/**/*.less') + .pipe(gulp.dest(paths.dest.lib)) + .pipe(gulp.dest(paths.dest.esm)); +} + +function compileScripts(babelEnv, destDir) { + process.env.BABEL_ENV = babelEnv; + + return gulp + .src(['src/**/*.{ts,tsx}', '!src/**/__tests__/*.{ts,tsx}']) + .pipe(babel()) + .pipe(gulp.dest(destDir)); +} + +function compileCJS() { + return compileScripts('cjs', paths.dest.lib); +} + +function compileESM() { + return compileScripts('esm', paths.dest.esm); +} + +exports.default = gulp.series([compileESM, compileCJS, copyLess]); +exports.umd = gulp.series([fullStyle]); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..043a531 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + testPathIgnorePatterns: ['/node_modules/', '/lib/', '/es/', '/dist/', 'examples'], + setupFilesAfterEnv: ['./jest.setup.ts'], +}; diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..aa5a853 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,2 @@ +// https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom/extend-expect'; diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b246c5 --- /dev/null +++ b/package.json @@ -0,0 +1,101 @@ +{ + "name": "chatui", + "version": "0.1.0", + "description": "The chat UI for React.", + "main": "lib/index.js", + "module": "es/index.js", + "browser": "dist/index.js", + "style": "dist/index.css", + "typings": "lib/index.d.ts", + "files": [ + "dist", + "es", + "lib" + ], + "scripts": { + "build": "gulp && npm run build:types", + "build:types": "tsc -p tsconfig.build.json", + "build:umd": "BABEL_ENV=umd rollup -c && gulp umd", + "prepublishOnly": "npm run build", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" + }, + "dependencies": { + "@babel/runtime": "^7.11.2", + "@babel/runtime-corejs3": "^7.11.2", + "clsx": "^1.1.1", + "core-js": "^3.6.5", + "dompurify": "^1.0.11", + "intersection-observer": "^0.11.0" + }, + "devDependencies": { + "@babel/core": "^7.11.6", + "@babel/plugin-proposal-class-properties": "^7.10.4", + "@babel/plugin-transform-runtime": "^7.11.5", + "@babel/preset-env": "^7.11.5", + "@babel/preset-react": "^7.10.4", + "@babel/preset-typescript": "^7.10.4", + "@commitlint/cli": "^11.0.0", + "@commitlint/config-conventional": "^11.0.0", + "@rollup/plugin-babel": "^5.2.1", + "@rollup/plugin-commonjs": "^15.0.0", + "@rollup/plugin-node-resolve": "^9.0.0", + "@testing-library/jest-dom": "^5.11.4", + "@testing-library/react": "^11.0.4", + "@types/dompurify": "^2.0.4", + "@types/jest": "^26.0.13", + "@types/react": "^16.9.49", + "@types/react-dom": "^16.9.8", + "@types/resize-observer-browser": "^0.1.3", + "@typescript-eslint/eslint-plugin": "^4.1.1", + "autoprefixer": "^9.8.6", + "babel-eslint": "^10.1.0", + "cssnano": "^4.1.10", + "eslint": "^7.9.0", + "eslint-config-airbnb": "^18.2.0", + "eslint-config-airbnb-typescript": "^10.0.0", + "eslint-config-prettier": "^6.11.0", + "eslint-plugin-compat": "^3.8.0", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-jsx-a11y": "^6.3.1", + "eslint-plugin-react": "^7.20.6", + "eslint-plugin-react-hooks": "^4.1.2", + "gulp": "^4.0.2", + "gulp-babel": "^8.0.0", + "gulp-less": "^4.0.1", + "gulp-postcss": "^8.0.0", + "husky": "^4.3.0", + "jest": "^26.4.2", + "less": "^3.12.2", + "react": "16.8.0", + "react-dom": "16.8.0", + "rollup": "^2.27.0", + "rollup-plugin-terser": "^7.0.2", + "ts-jest": "^26.3.0", + "typescript": "^4.0.2" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + }, + "browserslist": [ + ">0.2%", + "Android >= 4.4", + "not dead", + "not op_mini all" + ], + "husky": { + "hooks": { + "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" + } + }, + "keywords": [ + "react", + "react-component", + "chat", + "chat-ui" + ], + "author": "akai ", + "license": "MIT" +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..ada3381 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,13 @@ +module.exports = (ctx) => ({ + map: ctx.env === 'development' ? ctx.options.map : false, + plugins: { + autoprefixer: {}, + cssnano: ctx.env === 'production' ? { + preset: ['default', { + discardComments: { + removeAll: true, + }, + }], + } : false, + }, +}); diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..8fa5938 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,35 @@ +import commonjs from '@rollup/plugin-commonjs'; +import resolve from '@rollup/plugin-node-resolve'; +import babel from '@rollup/plugin-babel'; +import { terser } from 'rollup-plugin-terser'; +import pkg from './package.json'; + +const name = 'ChatUI'; +const extensions = ['.js', '.jsx', '.ts', '.tsx']; + +export default { + input: './src/index.ts', + external: ['react', 'react-dom'], + plugins: [ + resolve({ extensions }), + commonjs(), + babel({ + extensions, + babelHelpers: 'runtime', + include: ['src/**/*'], + }), + terser({ + output: { comments: false }, + compress: { drop_console: true }, + }), + ], + output: { + file: pkg.browser, + format: 'umd', + name, + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + }, +}; diff --git a/src/components/Avatar/__tests__/index.test.tsx b/src/components/Avatar/__tests__/index.test.tsx new file mode 100644 index 0000000..dfd021f --- /dev/null +++ b/src/components/Avatar/__tests__/index.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { Avatar } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render avatar', () => { + const text = 'chatui'; + const { getByText } = render({text}); + const element = getByText(text); + + expect(element).toBeInTheDocument(); + }); + + it('should render image avatar', () => { + const src = '//gw.alicdn.com/tfs/TB1U7FBiAT2gK0jSZPcXXcKkpXa-108-108.jpg'; + const alt = 'ChatUI'; + const { container } = render(); + const avatarImg = container.querySelector('img'); + + expect(avatarImg && avatarImg.getAttribute('src')).toBe(src); + expect(avatarImg && avatarImg.getAttribute('alt')).toBe(alt); + }); + + it('should render link', () => { + const src = '//gw.alicdn.com/tfs/TB1U7FBiAT2gK0jSZPcXXcKkpXa-108-108.jpg'; + const alt = 'ChatUI'; + + const { container } = render(); + const link = container.querySelector('a'); + const avatarImg = container.querySelector('img'); + + expect(container).toContainElement(link); + expect(avatarImg && avatarImg.getAttribute('src')).toBe(src); + expect(avatarImg && avatarImg.getAttribute('alt')).toBe(alt); + }); +}); diff --git a/src/components/Avatar/index.tsx b/src/components/Avatar/index.tsx new file mode 100644 index 0000000..baea812 --- /dev/null +++ b/src/components/Avatar/index.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type AvatarSize = 'sm' | 'md' | 'lg'; + +export type AvatarShape = 'circle' | 'square'; + +export interface AvatarProps { + className?: string; + src?: string; + alt?: string; + url?: string; + size?: AvatarSize; + shape?: AvatarShape; +} + +export const Avatar: React.FC = (props) => { + const { className, src, alt, url, size = 'md', shape = 'circle', children } = props; + + const Element = url ? 'a' : 'span'; + return ( + + {src ? {alt} : children} + + ); +}; diff --git a/src/components/Avatar/style.less b/src/components/Avatar/style.less new file mode 100644 index 0000000..1209b8b --- /dev/null +++ b/src/components/Avatar/style.less @@ -0,0 +1,26 @@ +.Avatar { + display: inline-block; + overflow: hidden; + border-radius: 50%; + + img { + display: block; + width: 36px; + height: 36px; + object-fit: cover; + } +} + +.Avatar--sm img { + width: 24px; + height: 24px; +} + +.Avatar--lg img { + width: 40px; + height: 40px; +} + +.Avatar--square { + border-radius: 4px; +} diff --git a/src/components/Backdrop/__tests__/index.test.tsx b/src/components/Backdrop/__tests__/index.test.tsx new file mode 100644 index 0000000..194c09f --- /dev/null +++ b/src/components/Backdrop/__tests__/index.test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { Backdrop } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render Backdrop', () => { + const { container } = render(); + const element = container.querySelector('.Backdrop'); + + expect(element).toBeInTheDocument(); + expect(element).toHaveClass('active'); + }); +}); diff --git a/src/components/Backdrop/index.tsx b/src/components/Backdrop/index.tsx new file mode 100644 index 0000000..67b5282 --- /dev/null +++ b/src/components/Backdrop/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import clsx from 'clsx'; + +export interface BackdropProps { + className?: string; + active?: boolean; + onClick?: () => void; +} + +export const Backdrop: React.FC = (props) => { + const { className, active, onClick } = props; + return ( +
+ ); +}; diff --git a/src/components/Backdrop/style.less b/src/components/Backdrop/style.less new file mode 100644 index 0000000..7a674e5 --- /dev/null +++ b/src/components/Backdrop/style.less @@ -0,0 +1,16 @@ +.Backdrop { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + transition: 0.3s; + width: 100vw; + height: 100vh; + background: var(--gray-5); + opacity: 0; + + &.active { + opacity: 1; + } +} diff --git a/src/components/Bubble/__tests__/index.test.tsx b/src/components/Bubble/__tests__/index.test.tsx new file mode 100644 index 0000000..7612f63 --- /dev/null +++ b/src/components/Bubble/__tests__/index.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { Bubble } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render bubble', () => { + const content = 'myTestContent'; + const type = 'text'; + + const { getByText } = render({content}); + const element = getByText(content); + + expect(element).toHaveClass(type); + expect(element).toHaveAttribute('data-type', type); + }); + + it('should render content', () => { + const text = 'myText'; + const { container } = render(); + const element = container.querySelector('.Bubble'); + + expect(element).toHaveClass('text'); + expect(element).toHaveAttribute('data-type', 'text'); + expect(element).toHaveTextContent(text); + }); + + it('should render custom type', () => { + const text = 'myCustom'; + const content = {text}; + const type = 'myType'; + + const { container } = render({content}); + const element = container.querySelector('.Bubble'); + + expect(element).toHaveClass(type); + expect(element).toHaveAttribute('data-type', type); + expect(element).toHaveTextContent(text); + }); +}); diff --git a/src/components/Bubble/index.tsx b/src/components/Bubble/index.tsx new file mode 100644 index 0000000..3ac603d --- /dev/null +++ b/src/components/Bubble/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export interface BubbleProps { + type?: string; + content?: React.ReactNode; +} + +export const Bubble: React.FC = (props) => { + const { type = 'text', content, children } = props; + return ( +
+ {content &&

{content}

} + {children} +
+ ); +}; diff --git a/src/components/Bubble/style.less b/src/components/Bubble/style.less new file mode 100644 index 0000000..43f68f2 --- /dev/null +++ b/src/components/Bubble/style.less @@ -0,0 +1,27 @@ +.Bubble { + max-width: 680px; + // min-width: 0; + // for IE bug + min-width: 1px; + background: @bubble-left-bg; + border-radius: @bubble-left-border-radius; + + &.text { + padding: @bubble-text-padding; + line-height: @bubble-text-line-height; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + } + &.image { + img { + display: block; + max-width: 100%; + height: auto; + border-radius: inherit; + } + } + &.typing { + padding: 8px 16px; + } +} diff --git a/src/components/Button/__tests__/index.test.tsx b/src/components/Button/__tests__/index.test.tsx new file mode 100644 index 0000000..22a9a79 --- /dev/null +++ b/src/components/Button/__tests__/index.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { render, cleanup, fireEvent } from '@testing-library/react'; +import { Button } from '..'; + +afterEach(cleanup); + +describe('); + const element = getByText(text); + + expect(element).toHaveClass('Btn'); + }); + + it('should render primary button', () => { + const text = 'myButton'; + + const { getByText } = render(); + const element = getByText(text); + + expect(element).toHaveClass('Btn--primary'); + }); + + it('should render text button', () => { + const text = 'myButton'; + + const { getByText } = render(); + const element = getByText(text); + + expect(element).toHaveClass('Btn--text'); + }); + + it('should apply size class', () => { + const text = 'myButton'; + + const { getByText } = render(); + const element = getByText(text); + + expect(element).toHaveClass('Btn--sm'); + }); + + it('should be block', () => { + const text = 'myButton'; + + const { getByText } = render(); + const element = getByText(text); + + expect(element).toHaveClass('Btn--block'); + }); + + it('should call onClick callback', () => { + const text = 'myButton'; + let flag = false; + const handleClick = () => (flag = true); + + const { getByText } = render(); + const element = getByText(text); + + fireEvent.click(element); + + expect(flag).toBeTruthy(); + }); + + it('should be disabled', () => { + const text = 'myButton'; + let flag = false; + const handleClick = () => (flag = true); + + const { getByText } = render( + , + ); + const element = getByText(text); + + fireEvent.click(element); + + expect(flag).toBeFalsy(); + }); + + it('should loading', () => { + const text = 'myButton'; + let flag = false; + const handleClick = () => (flag = true); + + const { getByText } = render( + , + ); + const element = getByText(text); + + fireEvent.click(element); + + expect(flag).toBeFalsy(); + }); +}); diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx new file mode 100644 index 0000000..fd48941 --- /dev/null +++ b/src/components/Button/index.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type ButtonVariant = 'text'; + +export type ButtonSize = 'sm' | 'md' | 'lg'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + className?: string; + label?: string; + color?: 'primary'; + // title?: string; + variant?: ButtonVariant; + size?: ButtonSize; + block?: boolean; + loading?: boolean; + disabled?: boolean; + onClick?: (event: React.MouseEvent) => void; +} + +function composeClass(type?: string) { + return type && `Btn--${type}`; +} + +export const Button: React.FC = (props) => { + const { + className, + label, + color, + variant, + size, + loading = false, + block, + disabled = false, + children, + onClick, + ...other + } = props; + + function handleClick(e: React.MouseEvent) { + if (!disabled && !loading && onClick) { + onClick(e); + } + } + + return ( + + ); +}; diff --git a/src/components/Button/style.less b/src/components/Button/style.less new file mode 100644 index 0000000..c60a227 --- /dev/null +++ b/src/components/Button/style.less @@ -0,0 +1,85 @@ +.Btn { + display: inline-block; + margin: 0; + padding: @btn-padding; + border: @btn-border-width solid @btn-border-color; + border-radius: @btn-border-radius; + background: @btn-bg; + color: @body-color; + font-weight: @btn-font-weight; + font-size: @btn-font-size; + line-height: @btn-line-height; + font-family: @btn-font-family; + text-align: center; + vertical-align: middle; + white-space: nowrap; + transition: @btn-transition; + user-select: none; + + &:not(:disabled) { + cursor: pointer; + } + &:focus { + outline: 0; + } + // &:hover, + // &:active { + // background-color: rgba(0, 0, 0, 0.04); + // } + &:disabled { + pointer-events: none; + border-color: @btn-disabled-border-color; + background-color: @btn-disabled-bg; + color: @btn-disabled-color; + } + &--primary { + border-color: @btn-primary-border-color; + background: @btn-primary-bg; + color: @btn-primary-color; + + // &:hover, + // &:active { + // background-image: linear-gradient(90deg, #f2b830 0%, #f2a900 100%); + // } + &:disabled { + background: @btn-primary-disabled-bg; + color: @btn-primary-disabled-color; + } + } + &--text { + padding: 0; + border: 0; + background: transparent; + color: var(--blue); + vertical-align: initial; + } + &--float { + border: 0; + background: @btn-float-bg; + box-shadow: @btn-float-box-shadow; + color: @btn-float-color; + + &:hover, + &:active { + background: @btn-float-hover-bg; + } + } + &--sm { + padding: @btn-padding-sm; + border-radius: @btn-border-radius-sm; + font-size: @btn-font-size-sm; + } + &--lg { + padding: @btn-padding-lg; + border-radius: @btn-border-radius-lg; + font-size: @btn-font-size-lg; + } + &--block { + display: block; + width: 100%; + + & + & { + margin-top: @btn-block-spacing-y; + } + } +} diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx new file mode 100644 index 0000000..db44307 --- /dev/null +++ b/src/components/Card/Card.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type CardSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +export interface CardProps { + className?: string; + size?: CardSize; + fluid?: boolean; + children?: React.ReactNode; +} + +export const Card = React.forwardRef((props, ref) => { + const { className, size, fluid, children, ...other } = props; + + return ( +
+ {children} +
+ ); +}); diff --git a/src/components/Card/CardActions.tsx b/src/components/Card/CardActions.tsx new file mode 100644 index 0000000..32c2a48 --- /dev/null +++ b/src/components/Card/CardActions.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type CardActionsProps = { + className?: string; + direction?: 'column' | 'row'; +}; + +export const CardActions: React.FC = (props) => { + const { children, className, direction, ...other } = props; + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/Card/CardContent.tsx b/src/components/Card/CardContent.tsx new file mode 100644 index 0000000..9c05937 --- /dev/null +++ b/src/components/Card/CardContent.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type CardContentProps = { + className?: string; +}; + +export const CardContent: React.FC = (props) => { + const { className, children, ...other } = props; + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/Card/CardMedia.tsx b/src/components/Card/CardMedia.tsx new file mode 100644 index 0000000..9b8a82c --- /dev/null +++ b/src/components/Card/CardMedia.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Flex } from '../Flex'; + +export type CardMediaProps = { + className?: string; + aspectRatio?: 'square' | 'wide'; + color?: string; + image?: string; +}; + +export const CardMedia: React.FC = (props) => { + const { className, aspectRatio = 'square', color, image, children, ...other } = props; + + const bgStyle = { + backgroundColor: color || undefined, + backgroundImage: typeof image === 'string' ? `url('${image}')` : undefined, + }; + + return ( +
+ {children && ( + + {children} + + )} +
+ ); +}; diff --git a/src/components/Card/CardText.tsx b/src/components/Card/CardText.tsx new file mode 100644 index 0000000..82de89c --- /dev/null +++ b/src/components/Card/CardText.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type CardTextProps = { + className?: string; +}; + +export const CardText: React.FC = (props) => { + const { className, children, ...other } = props; + return ( +
+ {typeof children === 'string' ?

{children}

: children} +
+ ); +}; diff --git a/src/components/Card/CardTitle.tsx b/src/components/Card/CardTitle.tsx new file mode 100644 index 0000000..815c31d --- /dev/null +++ b/src/components/Card/CardTitle.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type CardTitleProps = { + className?: string; + title?: React.ReactNode; + subtitle?: React.ReactNode; + center?: boolean; +}; + +export const CardTitle: React.FC = (props) => { + const { className, title, subtitle, center, children, ...other } = props; + return ( +
+ {title &&
{title}
} + {children && typeof children === 'string' &&
{children}
} + {subtitle &&

{subtitle}

} + {children && typeof children !== 'string' && children} +
+ ); +}; diff --git a/src/components/Card/__tests__/actions.test.tsx b/src/components/Card/__tests__/actions.test.tsx new file mode 100644 index 0000000..699641e --- /dev/null +++ b/src/components/Card/__tests__/actions.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { CardActions } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render CardActions', () => { + const text = 'myText'; + + const { getByText } = render({text}); + const element = getByText(text); + + expect(element).toBeInTheDocument(); + expect(element).toHaveClass('CardActions'); + }); + + it('should apply direction class', () => { + const text = 'myText'; + + const { getByText } = render({text}); + const element = getByText(text); + + expect(element).toHaveClass('CardActions--column'); + }); +}); diff --git a/src/components/Card/__tests__/content.test.tsx b/src/components/Card/__tests__/content.test.tsx new file mode 100644 index 0000000..03c8da7 --- /dev/null +++ b/src/components/Card/__tests__/content.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { CardContent } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render CardContent', () => { + const text = 'myText'; + + const { getByText } = render({text}); + const element = getByText(text); + + expect(element).toBeInTheDocument(); + expect(element).toHaveClass('CardContent'); + }); +}); diff --git a/src/components/Card/__tests__/index.test.tsx b/src/components/Card/__tests__/index.test.tsx new file mode 100644 index 0000000..83aecda --- /dev/null +++ b/src/components/Card/__tests__/index.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { Card } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render card', () => { + const text = 'myCard'; + + const { getByText } = render({text}); + const element = getByText(text); + + expect(element).toBeInTheDocument(); + expect(element).toHaveClass('Card'); + }); + + it('should apply size class', () => { + const text = 'myCard'; + + const { getByText } = render({text}); + const element = getByText(text); + + expect(element).toHaveClass('Card--sm'); + }); + + it('should be fluid', () => { + const text = 'myCard'; + + const { getByText } = render({text}); + const element = getByText(text); + + expect(element).toHaveClass('Card--fluid'); + }); +}); diff --git a/src/components/Card/__tests__/media.test.tsx b/src/components/Card/__tests__/media.test.tsx new file mode 100644 index 0000000..ae27918 --- /dev/null +++ b/src/components/Card/__tests__/media.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { CardMedia } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render CardMedia', () => { + const text = 'myText'; + + const { getByText } = render({text}); + const element = getByText(text); + + expect(element).toBeInTheDocument(); + }); + + it('should apply aspectRatio class', () => { + const { container } = render(square); + const element = container.querySelector('.CardMedia'); + + expect(element).toHaveClass('CardMedia--square'); + }); + + it('should render color', () => { + const { container } = render(color); + const element = container.querySelector('.CardMedia'); + + expect(element).toHaveStyle({ backgroundColor: 'red' }); + }); + + it('should render image', () => { + const url = '//gw.alicdn.com/tfs/TB17TaySSzqK1RjSZFHXXb3CpXa-80-80.svg'; + const { container } = render(image); + const element = container.querySelector('.CardMedia'); + + expect(element).toHaveStyle({ backgroundImage: `url(${url})` }); + }); +}); diff --git a/src/components/Card/__tests__/text.test.tsx b/src/components/Card/__tests__/text.test.tsx new file mode 100644 index 0000000..994bd6d --- /dev/null +++ b/src/components/Card/__tests__/text.test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { CardText } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render CardText', () => { + const { container } = render(myText); + const element = container.querySelector('p'); + + expect(element).toBeInTheDocument(); + }); + + it('should render with children', () => { + const { getByText } = render( + + myText + , + ); + const element = getByText('myText'); + + expect(element).toBeInTheDocument(); + }); +}); diff --git a/src/components/Card/__tests__/title.test.tsx b/src/components/Card/__tests__/title.test.tsx new file mode 100644 index 0000000..75a6377 --- /dev/null +++ b/src/components/Card/__tests__/title.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { CardTitle } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render CardTitle', () => { + const { container } = render(myTitle); + const element = container.querySelector('.CardTitle-title'); + + expect(element).toBeInTheDocument(); + }); + + it('should render with title', () => { + const { container } = render(); + const element = container.querySelector('.CardTitle-title'); + + expect(element).toBeInTheDocument(); + }); + + it('should render with subtitle', () => { + const { container } = render(); + const element = container.querySelector('.CardTitle-subtitle'); + + expect(element).toBeInTheDocument(); + }); + + it('should render with children', () => { + const { getByText } = render( + + myContent + , + ); + const element = getByText('myContent'); + + expect(element).toBeInTheDocument(); + }); + + it('should be center', () => { + const { container } = render(myTitle); + const element = container.querySelector('.CardTitle'); + + expect(element).toHaveClass('CardTitle--center'); + }); +}); diff --git a/src/components/Card/index.ts b/src/components/Card/index.ts new file mode 100644 index 0000000..19b89b8 --- /dev/null +++ b/src/components/Card/index.ts @@ -0,0 +1,12 @@ +export { Card } from './Card'; +export type { CardProps, CardSize } from './Card'; +export { CardMedia } from './CardMedia'; +export type { CardMediaProps } from './CardMedia'; +export { CardContent } from './CardContent'; +export type { CardContentProps } from './CardContent'; +export { CardTitle } from './CardTitle'; +export type { CardTitleProps } from './CardTitle'; +export { CardText } from './CardText'; +export type { CardTextProps } from './CardText'; +export { CardActions } from './CardActions'; +export type { CardActionsProps } from './CardActions'; diff --git a/src/components/Card/style.less b/src/components/Card/style.less new file mode 100644 index 0000000..d1e2e97 --- /dev/null +++ b/src/components/Card/style.less @@ -0,0 +1,136 @@ +.Card { + overflow: hidden; + border-radius: @card-border-radius; + background: @card-bg; + box-shadow: @card-box-shadow; + + &--xl { + width: @card-size-xl; + } + &--lg { + width: @card-size-lg; + } + &--md { + width: @card-size-md; + } + &--sm { + width: @card-size-sm; + } + &--xs { + width: @card-size-xs; + } + &--fluid { + width: @card-fluid-width; + max-width: @card-max-width; + min-width: @card-min-width; + } +} + +/* CardMedia */ +.CardMedia { + position: relative; + background-repeat: no-repeat; + background-position: 50%; + background-size: cover; + + &:after { + display: block; + height: 0; + content: ''; + } + &--wide { + &:after { + padding-top: 56.25%; + } + } + &--square { + &:after { + padding-top: 100%; + } + } + &-content { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + } +} + +/* CardTitle */ +.CardTitle { + padding: @card-title-padding; + + &--center { + padding: 4px 2px; // FIXME + text-align: center; + } + &-title { + font-size: @card-title-font-size; + } + &-subtitle { + color: @card-subtitle-color; + font-size: @card-subtitle-font-size; + } +} + +/* CardContent */ +.CardContent { + padding: @card-padding; + + .CardTitle + & { + padding-top: 0; + } +} + +/* CardText */ +.CardText { + padding: @card-padding; + color: @card-text-color; + + .CardTitle + & { + padding-top: 0; + } +} + +/* CardActions */ +.CardActions { + display: flex; + padding: @card-padding; + + .CardTitle + &, + .CardText + & { + padding-top: 0; + } + .Btn { + flex: 1 1 auto; + line-height: @card-btn-line-height; + } + .Btn + .Btn { + margin-left: @card-btn-spacing-x; + } +} + +.CardActions--column { + flex-direction: column; + padding: 0; + + .Btn { + padding: @card-btn-padding; + border: 0; + border-top: 1px solid @card-btn-border-color; + border-radius: 0; + background: @card-column-btn-bg; + + &:last-child { + border-radius: 0 0 @card-border-radius @card-border-radius; + } + } + .Btn + .Btn { + margin: @card-btn-spacing-y; + } + .Btn--primary { + color: @card-column-btn-primary-color; + } +} diff --git a/src/components/Carousel/index.tsx b/src/components/Carousel/index.tsx new file mode 100644 index 0000000..8ee9112 --- /dev/null +++ b/src/components/Carousel/index.tsx @@ -0,0 +1,343 @@ +import React, { createRef } from 'react'; +import clsx from 'clsx'; +import canUse from '../../utils/canUse'; +import { setTransform, setTransition } from '../../utils/style'; + +const formElements = ['TEXTAREA', 'OPTION', 'INPUT', 'SELECT']; + +export type CarouselProps = typeof Carousel.defaultProps & { + className?: string; + // duration?: number; + // easing?: string; + // startIndex?: number; + // draggable?: boolean; + // multipleDrag?: boolean; + // threshold?: number; + // loop?: boolean; + // rtl?: boolean; + // indicators?: boolean; + // autoplay?: boolean; + // autoplaySpeed?: number; + onChange?: () => void; +}; + +export interface CarouselState { + currentSlide: number; +} + +interface DragProps { + startX: number; + endX: number; + startY: number; + letItGo: boolean | null; + preventClick: boolean; +} + +class Carousel extends React.Component { + private wrapperRef = createRef(); + + private innerRef = createRef(); + + len = 0; + + selectorWidth = 0; + + pointerDown = false; + + autoPlayTimer: any = 0; + + drag: DragProps = { + startX: 0, + endX: 0, + startY: 0, + letItGo: null, + preventClick: false, + }; + + static defaultProps = { + duration: 300, + easing: 'ease', + startIndex: 0, + draggable: true, + multipleDrag: true, + threshold: 20, + loop: true, + rtl: false, + indicators: true, + autoplay: false, + autoplaySpeed: 3000, + onChange: () => {}, + }; + + constructor(props: CarouselProps) { + super(props); + + const { startIndex, children } = this.props; + this.len = children ? React.Children.count(children) : 0; + + this.state = { + currentSlide: Math.max(0, Math.min(startIndex, this.len - 1)), + }; + } + + componentDidMount() { + const wrap = this.wrapperRef.current; + if (wrap) { + this.selectorWidth = wrap.offsetWidth; + this.attachEvents(); + } + + if (this.props.autoplay) { + this.autoPlay(); + } + + if (this.state.currentSlide !== 0) { + this.slideToCurrent(); + } + } + + attachEvents() { + const { draggable } = this.props; + + if (!draggable) return; + + this.pointerDown = false; + this.drag = { + startX: 0, + endX: 0, + startY: 0, + letItGo: null, + preventClick: false, + }; + + const wrapper = this.wrapperRef.current; + const passive = canUse('passiveListener') ? { passive: false } : false; + + if (wrapper) { + wrapper.addEventListener('touchstart', this.touchStart); + wrapper.addEventListener('touchmove', this.touchMove, passive); + wrapper.addEventListener('touchend', this.touchEnd); + } + } + + detachEvents() { + const wrapper = this.wrapperRef.current; + + if (wrapper) { + wrapper.removeEventListener('touchstart', this.touchStart); + wrapper.removeEventListener('touchmove', this.touchMove); + wrapper.removeEventListener('touchend', this.touchEnd); + } + } + + setTranslateX(offset: number) { + const el = this.innerRef.current; + if (el) { + setTransform(el, `translate3d(${offset}px, 0, 0)`); + } + } + + enableTransition() { + const { easing, duration } = this.props; + const el = this.innerRef.current; + if (el) { + setTransition(el, `all ${duration}ms ${easing}`); + } + } + + disableTransition() { + const { easing } = this.props; + const el = this.innerRef.current; + if (el) { + setTransition(el, `all 0ms ${easing}`); + } + } + + touchStart = (e: TouchEvent) => { + const ignore = formElements.indexOf((e.target as Element).nodeName) !== -1; + if (ignore) return; + + e.stopPropagation(); + this.pointerDown = true; + this.drag.startX = e.touches[0].pageX; + this.drag.startY = e.touches[0].pageY; + + this.autoPlayClear(); + }; + + touchMove = (e: TouchEvent) => { + e.stopPropagation(); + + const touch = e.touches[0]; + + if (this.drag.letItGo === null) { + // eslint-disable-next-line max-len + this.drag.letItGo = + Math.abs(this.drag.startY - touch.pageY) < Math.abs(this.drag.startX - touch.pageX); + } + + if (this.pointerDown && this.drag.letItGo) { + e.preventDefault(); + this.drag.endX = touch.pageX; + + const { rtl } = this.props; + const { currentSlide } = this.state; + const currentOffset = currentSlide * this.selectorWidth; + let dragOffset = this.drag.endX - this.drag.startX; + + if ( + (currentSlide === 0 && dragOffset > 0) || + (currentSlide === this.len - 1 && dragOffset < 0) + ) { + // 阻尼 + dragOffset *= 0.35; + } + + const offset = rtl ? currentOffset + dragOffset : (currentOffset - dragOffset) * -1; + + this.disableTransition(); + this.setTranslateX(offset); + } + }; + + touchEnd = (e: TouchEvent) => { + const { autoplay } = this.props; + e.stopPropagation(); + this.pointerDown = false; + this.enableTransition(); + if (this.drag.endX) { + this.updateAfterDrag(); + } + this.clearDrag(); + if (autoplay) { + this.autoPlay(); + } + }; + + clearDrag() { + this.drag = { + startX: 0, + endX: 0, + startY: 0, + letItGo: null, + preventClick: this.drag.preventClick, + }; + } + + slideToCurrent() { + const { rtl } = this.props; + const { currentSlide } = this.state; + const offset = (rtl ? 1 : -1) * currentSlide * this.selectorWidth; + + this.setTranslateX(offset); + } + + goTo(index: number, callback?: () => void) { + if (this.len <= 1) return; + + const { onChange } = this.props; + const { currentSlide } = this.state; + + this.setState({ + currentSlide: Math.min(Math.max(index, 0), this.len - 1), + }); + + // eslint-disable-next-line react/destructuring-assignment + if (currentSlide !== this.state.currentSlide) { + this.slideToCurrent(); + onChange.call(this); + if (callback) { + callback.call(this); + } + } + } + + prev(howManySlides = 1, callback?: () => void) { + const { currentSlide } = this.state; + const index = Math.max(currentSlide - howManySlides, 0); + + this.goTo(index, callback); + } + + next(howManySlides = 1, callback?: () => void) { + const { currentSlide } = this.state; + const index = Math.min(currentSlide + howManySlides, this.len - 1); + + this.goTo(index, callback); + } + + updateAfterDrag() { + const { rtl, multipleDrag, threshold } = this.props; + const { len } = this; + + const movement = (rtl ? -1 : 1) * (this.drag.endX - this.drag.startX); + const movementDistance = Math.abs(movement); + const howManySliderToSlide = multipleDrag + ? Math.ceil(movementDistance / this.selectorWidth) + : 1; + + if (movement > 0 && movementDistance > threshold && len > 1) { + this.prev(howManySliderToSlide); + } else if (movement < 0 && movementDistance > threshold && len > 1) { + this.next(howManySliderToSlide); + } + + this.slideToCurrent(); + } + + autoPlayClear() { + if (this.autoPlayTimer) { + clearInterval(this.autoPlayTimer); + } + } + + autoPlay() { + const { autoplaySpeed } = this.props; + this.enableTransition(); + this.autoPlayClear(); + this.autoPlayTimer = setInterval(this.autoPlayIterator, autoplaySpeed); + } + + autoPlayIterator = () => { + const { loop } = this.props; + const { currentSlide } = this.state; + const isLastSlide = currentSlide === this.len - 1; + + if (isLastSlide) { + if (loop) { + this.goTo(0); + } else { + this.autoPlayClear(); + } + } else { + this.next(); + } + }; + + render() { + const { className, indicators, children } = this.props; + const { currentSlide } = this.state; + const len = React.Children.count(children); + + return ( +
+
+ {React.Children.map(children, (item, i) => ( +
+ {item} +
+ ))} +
+ {indicators && ( +
    + {React.Children.map(children, (_, i) => ( +
  1. + ))} +
+ )} +
+ ); + } +} + +export default Carousel; diff --git a/src/components/Carousel/style.less b/src/components/Carousel/style.less new file mode 100644 index 0000000..4aa7991 --- /dev/null +++ b/src/components/Carousel/style.less @@ -0,0 +1,25 @@ +.Carousel { + overflow: hidden; +} + +.Carousel-inner { + display: flex; +} + +.Carousel-indicators { + display: flex; + justify-content: center; + list-style-type: none; + + li { + width: 6px; + height: 6px; + margin: 0 2px; + border-radius: 50%; + background: var(--gray-4); + transition: 0.3s; + } + .active { + background: var(--brand-1); + } +} diff --git a/src/components/Chat/index.tsx b/src/components/Chat/index.tsx new file mode 100644 index 0000000..8d95936 --- /dev/null +++ b/src/components/Chat/index.tsx @@ -0,0 +1,226 @@ +/* eslint-disable react/forbid-prop-types */ +import React from 'react'; +import { LocaleProvider } from '../LocaleProvider'; +import { Navbar } from '../Navbar'; +import { MessageContainer } from '../MessageContainer'; +import { QuickReplies } from '../QuickReplies'; +import { Composer as DComposer, ComposerProps } from '../Composer'; +import { NavbarProps } from '../Navbar'; +import { MessageProps } from '../Message/Message'; +import { QuickReplyItemProps } from '../QuickReplies'; + +export type ChatProps = ComposerProps & { + /** + * 宽版模式断点 + */ + // wideBreakpoint?: string; + /** + * 当前语言 + */ + locale?: string; + /** + * 多语言 + */ + locales?: any; // FIXME + /** + * 导航栏配置 + */ + navbar?: NavbarProps; + /** + * 导航栏渲染函数 + */ + renderNavbar?: () => React.ReactNode; + /** + * 加载更多文案 + */ + loadMoreText?: string; + /** + * 在消息列表上面的渲染函数 + */ + renderBeforeMessageList?: () => React.ReactNode; + /** + * 消息列表 ref + */ + messagesRef?: any; // FIXME + /** + * 下拉加载回调 + */ + onRefresh?: () => Promise; + /** + * 滚动消息列表回调 + */ + onScroll?: () => void; + /** + * 消息列表 + */ + messages: MessageProps[]; + /** + * 消息内容渲染函数 + */ + renderMessageContent: (message: MessageProps) => React.ReactNode; + /** + * 快捷短语 + */ + quickReplies?: QuickReplyItemProps[]; + /** + * 快捷短语是否可见 + */ + quickRepliesVisible?: boolean; + /** + * 快捷短语的点击回调 + */ + onQuickReplyClick?: (item: QuickReplyItemProps, index: number) => void; + /** + * 快捷短语的滚动回调 + */ + onQuickReplyScroll?: () => void; + /** + * 快捷短语渲染函数 + */ + renderQuickReplies?: () => void; + /** + * 输入区 ref + */ + composerRef?: any; + /** + * 输入框初始内容 + */ + // text?: string; + /** + * 输入框占位符 + */ + // placeholder?: string; + /** + * 输入框聚焦回调 + */ + onInputFocus?: () => void; + /** + * 输入框更新回调 + */ + onInputChange?: () => void; + /** + * 输入框失去焦点回调 + */ + onInputBlur?: () => void; + /** + * 发送消息回调 + */ + // onSend: (type: string, content: string) => void; + /** + * 发送图片回调 + */ + // onImageSend?: (file: File) => Promise; + /** + * 输入方式 + */ + // inputType?: InputType; + /** + * 输入方式切换回调 + */ + // onInputTypeChange?: () => void; + /** + * 语音输入 + */ + // recorder?: RecorderProps; + /** + * 工具栏 + */ + // toolbar?: ToolbarItemProps[]; + /** + * 点击工具栏回调 + */ + // onToolbarClick?: () => void; + /** + * 点击附加内容回调 + */ + // onAccessoryToggle?: () => void; + /** + * 输入组件 + */ + Composer?: React.ElementType; // FIXME +}; + +export const Chat = React.forwardRef((props, ref) => { + const { + wideBreakpoint, + locale = 'zh-CN', + locales, + navbar, + renderNavbar, + loadMoreText, + renderBeforeMessageList, + messagesRef, + onRefresh, + onScroll, + messages = [], + renderMessageContent, + quickReplies = [], + quickRepliesVisible, + onQuickReplyClick = () => {}, + onQuickReplyScroll, + renderQuickReplies, + text, + placeholder, + onInputFocus, + onInputChange, + onInputBlur, + onSend, + onImageSend, + composerRef, + inputType, + onInputTypeChange, + recorder, + toolbar, + onToolbarClick, + onAccessoryToggle, + rightAction, + Composer = DComposer, + } = props; + + return ( + +
+ {renderNavbar ? renderNavbar() : navbar && } + +
+ {renderQuickReplies ? ( + renderQuickReplies() + ) : ( + + )} + +
+
+
+ ); +}); diff --git a/src/components/Chat/style.less b/src/components/Chat/style.less new file mode 100644 index 0000000..38b13fd --- /dev/null +++ b/src/components/Chat/style.less @@ -0,0 +1,124 @@ +& when (@global-style = true) { + html, + body, + #root { + height: 100%; + } + + @supports (top: constant(safe-area-inset-top)) or + (top: env(safe-area-inset-top)) { + body:not(.S--noHomeBar) { + height: calc(100% - var(--safe-bottom)); + } + } +} + +.ChatApp { + display: flex; + flex-direction: column; + height: 100%; + background: var(--light-1); +} + +.ChatFooter { + position: relative; + z-index: 10; + background: rgba(242, 244, 245, 0.95); +} + +/* common */ +.bordered { + border-radius: 12px; + box-shadow: var(--shadow-1); +} + +/* utils */ +.animated { + animation-duration: 1s; + animation-fill-mode: both; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.fadeIn { + animation-name: fadeIn; +} + +/* footer */ +.scrollable { + overflow: hidden; + -webkit-overflow-scrolling: touch; + + &::-webkit-scrollbar { + display: none; + } +} + +.scroller { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + margin-bottom: -18px; + padding-bottom: 18px; + white-space: nowrap; + + &::-webkit-scrollbar { + display: none; + } +} + +/* animation */ +.slide-in-right-item { + animation: slideInRight 0.5s ease-in-out both; +} + +.slide-in-right-item { + &:nth-child(2) { + animation-delay: 0.2s; + } + &:nth-child(3) { + animation-delay: 0.4s; + } + &:nth-child(4) { + animation-delay: 0.6s; + } + &:nth-child(5) { + animation-delay: 0.8s; + } + &:nth-child(6) { + animation-delay: 1s; + } + &:nth-child(7) { + animation-delay: 1.2s; + } + &:nth-child(8) { + animation-delay: 1.4s; + } + &:nth-child(9) { + animation-delay: 1.6s; + } + &:nth-child(10) { + animation-delay: 1.8s; + } + &:nth-child(11) { + animation-delay: 2s; + } +} + +@keyframes slideInRight { + 0% { + transform: translateX(100px); + opacity: 0; + } + 100% { + transform: translateX(0); + opacity: 1; + } +} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 0000000..9447b11 --- /dev/null +++ b/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type CheckboxValue = string | number | undefined; + +export type CheckboxProps = React.InputHTMLAttributes & { + value?: CheckboxValue; + label?: CheckboxValue; +}; + +export const Checkbox: React.FC = (props) => { + const { className, label, checked, disabled, onChange, ...other } = props; + return ( + + ); +}; diff --git a/src/components/Checkbox/CheckboxGroup.tsx b/src/components/Checkbox/CheckboxGroup.tsx new file mode 100644 index 0000000..01f801a --- /dev/null +++ b/src/components/Checkbox/CheckboxGroup.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Checkbox, CheckboxProps, CheckboxValue } from './Checkbox'; + +export type CheckboxGroupProps = { + className?: string; + options: CheckboxProps[]; + value: CheckboxValue[]; + name?: string; + disabled?: boolean; + block?: boolean; + onChange: (value: CheckboxValue[], event: React.ChangeEvent) => void; +}; + +export const CheckboxGroup: React.FC = (props) => { + const { className, options, value, name, disabled, block, onChange } = props; + + function handleChange(val: CheckboxValue, e: React.ChangeEvent) { + const newValue = e.target.checked ? value.concat(val) : value.filter((item) => item !== val); + onChange(newValue, e); + } + + return ( +
+ {options.map((item) => ( + { + handleChange(item.value, e); + }} + key={item.value} + /> + ))} +
+ ); +}; diff --git a/src/components/Checkbox/__tests__/group.test.tsx b/src/components/Checkbox/__tests__/group.test.tsx new file mode 100644 index 0000000..bfee64f --- /dev/null +++ b/src/components/Checkbox/__tests__/group.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { render, cleanup, fireEvent } from '@testing-library/react'; +import { CheckboxGroup, CheckboxValue } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render CheckboxGroup', () => { + const { container } = render( {}} />); + const element = container.querySelector('.CheckboxGroup'); + + expect(element).toBeInTheDocument(); + }); + + it('should have a name in input', () => { + const { container } = render( + {}} + />, + ); + + expect(container.querySelectorAll('input[name="testName"]').length).toBe(2); + }); + + it('should be checked when set value', () => { + const { getByDisplayValue } = render( + {}} + />, + ); + + expect(getByDisplayValue('test1')).toBeChecked(); + expect(getByDisplayValue('test2')).not.toBeChecked(); + }); + + it('should be disabled', () => { + const { getByDisplayValue } = render( + {}} + />, + ); + + expect(getByDisplayValue('test1')).toBeDisabled(); + expect(getByDisplayValue('test2')).toBeDisabled(); + }); + + it('should disable item', () => { + const { getByDisplayValue } = render( + {}} + />, + ); + + expect(getByDisplayValue('test1')).toBeDisabled(); + expect(getByDisplayValue('test2')).not.toBeDisabled(); + }); + + it('should call onChange callback', () => { + function Test() { + const [value, setValue] = React.useState(['test2']); + + return ( + setValue(val)} + /> + ); + } + + const { getByDisplayValue } = render(); + + fireEvent.click(getByDisplayValue('test1')); + expect(getByDisplayValue('test1')).toBeChecked(); + + fireEvent.click(getByDisplayValue('test2')); + expect(getByDisplayValue('test2')).not.toBeChecked(); + }); + + it('should have a custom className', () => { + const { container } = render( + {}} />, + ); + const element = container.querySelector('.CheckboxGroup'); + + expect(element).toHaveClass('testName'); + }); +}); diff --git a/src/components/Checkbox/__tests__/index.test.tsx b/src/components/Checkbox/__tests__/index.test.tsx new file mode 100644 index 0000000..f55ae43 --- /dev/null +++ b/src/components/Checkbox/__tests__/index.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { render, cleanup, fireEvent } from '@testing-library/react'; +import { Checkbox } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render Checkbox', () => { + const { container } = render(); + const element = container.querySelector('.Checkbox'); + + expect(element).toBeInTheDocument(); + }); + + it('should render label', () => { + const text = 'myLabel'; + const { getByText } = render(); + const element = getByText(text); + + expect(element).toBeInTheDocument(); + }); + + it('should be defaultChecked', () => { + const { container } = render(); + + expect(container.querySelector('input')).toBeChecked(); + }); + + it('should be checked', () => { + const { container } = render( {}} />); + + expect(container.querySelector('.Checkbox')).toHaveClass('Checkbox--checked'); + expect(container.querySelector('input')).toBeChecked(); + }); + + it('should be disabled', () => { + const { container } = render(); + + expect(container.querySelector('.Checkbox')).toHaveClass('Checkbox--disabled'); + expect(container.querySelector('input')).toBeDisabled(); + }); + + it('should call onChange callback', (done) => { + const { container } = render( done()} />); + const element = container.querySelector('input'); + + fireEvent.click(element!); + }); + + it('should be checked with change', (done) => { + function handleChange(e: React.ChangeEvent) { + if (e.target.checked) { + done(); + } + } + + const { container } = render(); + const element = container.querySelector('input'); + + fireEvent.click(element!); + }); + + it('should be unchecked with change', (done) => { + function handleChange(e: React.ChangeEvent) { + if (!e.target.checked) { + done(); + } + } + + const { container } = render(); + const element = container.querySelector('input'); + + fireEvent.click(element!); + }); + + it('should have a custom className', () => { + const { container } = render(); + expect(container.querySelector('.Checkbox')).toHaveClass('myCheckbox'); + }); +}); diff --git a/src/components/Checkbox/index.ts b/src/components/Checkbox/index.ts new file mode 100644 index 0000000..4ca797d --- /dev/null +++ b/src/components/Checkbox/index.ts @@ -0,0 +1,4 @@ +export { Checkbox } from './Checkbox'; +export type { CheckboxProps, CheckboxValue } from './Checkbox'; +export { CheckboxGroup } from './CheckboxGroup'; +export type { CheckboxGroupProps } from './CheckboxGroup'; diff --git a/src/components/ClickOutside/__tests__/index.test.tsx b/src/components/ClickOutside/__tests__/index.test.tsx new file mode 100644 index 0000000..2fbc3ab --- /dev/null +++ b/src/components/ClickOutside/__tests__/index.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render, cleanup, fireEvent } from '@testing-library/react'; +import { ClickOutside } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render the children', () => { + const { getByTestId } = render( + {}}> + + , + ); + const wrap = getByTestId('wrap'); + const inside = getByTestId('inside'); + + expect(wrap).toContainElement(inside); + }); + + it('should be called when clicking away', (done) => { + const cb = jest.fn(); + const handleClick = () => { + cb(); + done(); + }; + + const { container } = render( + + + , + ); + + fireEvent.mouseUp(container); + expect(cb).toHaveBeenCalled(); + }); + + it('should not be called when clicking inside', () => { + const handleClick = jest.fn(); + + const { getByTestId } = render( + {}}> + + , + ); + const inside = getByTestId('inside'); + + fireEvent.click(inside); + expect(handleClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ClickOutside/index.tsx b/src/components/ClickOutside/index.tsx new file mode 100644 index 0000000..e82ca3a --- /dev/null +++ b/src/components/ClickOutside/index.tsx @@ -0,0 +1,38 @@ +import React, { useEffect, useRef } from 'react'; + +const doc = document; +const html = doc.documentElement; + +export type ClickOutsideProps = { + onClick: (event: React.MouseEvent) => void; + // mouseEvent?: 'click' | 'mousedown' | 'mouseup' | false; + mouseEvent?: 'click' | 'mousedown' | 'mouseup'; +}; + +export const ClickOutside: React.FC = (props) => { + const { children, onClick, mouseEvent = 'mouseup', ...others } = props; + const wrapper = useRef(null!); + + function handleClick(e: any) { + if (!wrapper.current) return; + + if (html.contains(e.target) && !wrapper.current.contains(e.target)) { + onClick(e); + } + } + + useEffect(() => { + if (mouseEvent) { + doc.addEventListener(mouseEvent, handleClick); + } + return () => { + doc.removeEventListener(mouseEvent, handleClick); + }; + }); + + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/Composer/Action.tsx b/src/components/Composer/Action.tsx new file mode 100644 index 0000000..50a728f --- /dev/null +++ b/src/components/Composer/Action.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { IconButton, IconButtonProps } from '../IconButton'; + +export const Action: React.FC = (props) => ( +
+ +
+); diff --git a/src/components/Composer/ToolbarItem.tsx b/src/components/Composer/ToolbarItem.tsx new file mode 100644 index 0000000..4e982bc --- /dev/null +++ b/src/components/Composer/ToolbarItem.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Button } from '../Button'; +import { IconButton } from '../IconButton'; +import { ToolbarItemProps } from '../Toolbar'; + +type IToolbarItem = { + item: ToolbarItemProps; + onClick: (event: React.MouseEvent) => void; +}; + +export const ToolbarItem: React.FC = (props) => { + const { item, onClick } = props; + + if (item.img) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx new file mode 100644 index 0000000..0caaa31 --- /dev/null +++ b/src/components/Composer/index.tsx @@ -0,0 +1,327 @@ +/* eslint-disable react/forbid-prop-types */ +import React, { useState, useRef, useEffect, useImperativeHandle } from 'react'; +import clsx from 'clsx'; +import { IconButton, IconButtonProps } from '../IconButton'; +import { Input } from '../Input'; +import { Recorder, RecorderProps } from '../Recorder'; +import { Toolbar } from '../Toolbar'; +import { ClickOutside } from '../ClickOutside'; +import { Popover } from '../Popover'; +import { SendConfirm } from '../SendConfirm'; +import { ToolbarItem } from './ToolbarItem'; +import { Action } from './Action'; +import riseInput from './riseInput'; +import parseDataTransfer from '../../utils/parseDataTransfer'; +import toggleClass from '../../utils/toggleClass'; +import { ToolbarItemProps } from '../Toolbar'; + +const NO_HOME_BAR = 'S--noHomeBar'; + +export type InputType = 'voice' | 'text'; + +export type ComposerProps = { + wideBreakpoint?: string; + text?: string; + placeholder?: string; + inputType?: InputType; + onInputTypeChange?: (inputType: InputType) => void; + recorder?: RecorderProps; + onSend: (type: string, content: string) => void; + onImageSend?: (file: File) => Promise; + onFocus?: (event: React.FocusEvent) => void; + onChange?: (value: string, event: React.ChangeEvent) => void; + onBlur?: (event: React.FocusEvent) => void; + toolbar?: ToolbarItemProps[]; + onToolbarClick?: (item: ToolbarItemProps, event: React.MouseEvent) => void; + onAccessoryToggle?: (isAccessoryOpen: boolean) => void; + rightAction?: IconButtonProps; +}; + +export interface ComposerHandle { + setText: (text: string) => void; +} + +export const Composer = React.forwardRef((props, ref) => { + const { + text: initialText = '', + inputType: initialInputType = 'text', + wideBreakpoint, + placeholder = '请输入...', + recorder = {}, + onInputTypeChange, + onFocus, + onBlur, + onChange, + onSend, + onImageSend, + onAccessoryToggle, + toolbar = [], + onToolbarClick, + rightAction, + } = props; + + const [text, setText] = useState(initialText); + const [inputType, setInputType] = useState(initialInputType || 'text'); + const [isAccessoryOpen, setAccessoryOpen] = useState(false); + const [accessoryContent, setAccessoryContent] = useState(''); + const [pastedImage, setPastedImage] = useState(null); + const composerRef = useRef(null!); + const inputRef = useRef(null!); + const focused = useRef(false); + const blurTimer = useRef(); + const popoverTarget = useRef(); + const isMountRef = useRef(false); + const [isWide, setWide] = useState(false); + + useEffect(() => { + const mq = + wideBreakpoint && window.matchMedia + ? window.matchMedia(`(min-width: ${wideBreakpoint})`) + : false; + + function handleMq(e: MediaQueryListEvent) { + setWide(e.matches); + } + + setWide(mq && mq.matches); + + if (mq) { + mq.addListener(handleMq); + } + return () => { + if (mq) { + mq.removeListener(handleMq); + } + }; + }, [wideBreakpoint]); + + useEffect(() => { + toggleClass('S--wide', isWide); + if (!isWide) { + setAccessoryContent(''); + } + }, [isWide]); + + useEffect(() => { + if (isMountRef.current && onAccessoryToggle) { + onAccessoryToggle(isAccessoryOpen); + } + }, [isAccessoryOpen]); + + useEffect(() => { + isMountRef.current = true; + + riseInput(inputRef.current, composerRef.current); + }, []); + + useImperativeHandle(ref, () => ({ + setText(val) { + setText(val); + }, + })); + + function handleInputTypeChange() { + const isVoice = inputType === 'voice'; + const nextType = isVoice ? 'text' : 'voice'; + setInputType(nextType); + + if (isVoice) { + const input = inputRef.current; + input.focus(); + // eslint-disable-next-line no-multi-assign + input.selectionStart = input.selectionEnd = input.value.length; + } + if (onInputTypeChange) { + onInputTypeChange(nextType); + } + } + + function handleInputFocus(e: React.FocusEvent) { + clearTimeout(blurTimer.current); + toggleClass(NO_HOME_BAR, true); + focused.current = true; + + if (onFocus) { + onFocus(e); + } + } + + function handleInputBlur(e: React.FocusEvent) { + blurTimer.current = setTimeout(() => { + toggleClass(NO_HOME_BAR, false); + focused.current = false; + }, 0); + + if (onBlur) { + onBlur(e); + } + } + + function send() { + onSend('text', text); + setText(''); + + if (focused.current) { + inputRef.current.focus(); + } + } + + function handleInputKeyDown(e: React.KeyboardEvent) { + if (!e.shiftKey && e.keyCode === 13) { + send(); + e.preventDefault(); + } + } + + function handleTextChange(value: string, e: React.ChangeEvent) { + setText(value); + + if (onChange) { + onChange(value, e); + } + } + + function handlePaste(e: React.ClipboardEvent) { + parseDataTransfer(e, (file) => { + setPastedImage(file); + }); + } + + function handleImageCancel() { + setPastedImage(null); + } + + function handleImageSend() { + if (onImageSend && pastedImage) { + onImageSend(pastedImage).then(() => { + setPastedImage(null); + }); + } + } + + function handleSendBtnClick(e: React.MouseEvent) { + send(); + e.preventDefault(); + } + + function handleAccessoryToggle() { + setAccessoryOpen(!isAccessoryOpen); + } + + function handleAccessoryBlur() { + setTimeout(() => { + setAccessoryOpen(false); + setAccessoryContent(''); + }); + } + + function handleToolbarClick(item: ToolbarItemProps, e: React.MouseEvent) { + if (onToolbarClick) { + onToolbarClick(item, e); + } + if (item.render) { + popoverTarget.current = e.currentTarget; + setAccessoryContent(item.render); + } + } + + function handlePopoverClose() { + setAccessoryContent(''); + } + + function renderExtra() { + const accessory = accessoryContent || ; + return {accessory}; + } + + const inputTypeIcon = inputType === 'text' ? 'mic' : 'keyboard'; + const hasToolbar = toolbar.length > 0; + + const renderInput = () => ( +
+ + + {handlePaste && ( + + )} +
+ ); + + if (isWide) { + return ( +
+ {hasToolbar && ( +
+ {toolbar.map((item) => ( + handleToolbarClick(item, e)} + key={item.type} + /> + ))} +
+ )} + {accessoryContent && ( + + {accessoryContent} + + )} +
{renderInput()}
+
+ ); + } + + return ( + <> +
+ {recorder.canRecord && ( + + )} +
+ {renderInput()} + {inputType === 'voice' && } +
+ {rightAction && } + {hasToolbar && ( + + )} +
+ {isAccessoryOpen && renderExtra()} + + ); +}); diff --git a/src/components/Composer/riseInput.ts b/src/components/Composer/riseInput.ts new file mode 100644 index 0000000..38b9fa7 --- /dev/null +++ b/src/components/Composer/riseInput.ts @@ -0,0 +1,60 @@ +const ua = navigator.userAgent; +const iOS = /iPad|iPhone|iPod/.test(ua); + +function uaIncludes(str: string) { + return ua.indexOf(str) !== -1; +} + +function testScrollType() { + if (iOS) { + if (uaIncludes('Safari/') || /OS 11_[0-3]\D/.test(ua)) { + /** + * 不处理 + * - Safari + * - iOS 11.0-11.3(`scrollTop`/`scrolIntoView` 有 bug) + */ + return 0; + } + // 用 `scrollTop` 的方式 + return 1; + } + // 其它的用 `scrollIntoView` 的方式 + return 2; +} + +export default function riseInput(input: HTMLElement, target: HTMLElement) { + const scrollType = testScrollType(); + let scrollTimer: ReturnType; + + if (!target) { + // eslint-disable-next-line no-param-reassign + target = input; + } + + const scrollIntoView = () => { + if (scrollType === 0) return; + if (scrollType === 1) { + document.body.scrollTop = document.body.scrollHeight; + } else { + target.scrollIntoView(false); + } + }; + + input.addEventListener('focus', () => { + setTimeout(scrollIntoView, 300); + scrollTimer = setTimeout(scrollIntoView, 1000); + }); + + input.addEventListener('blur', () => { + clearTimeout(scrollTimer); + + // 某些情况下收起键盘后输入框不收回,页面下面空白 + // 比如:闲鱼、大麦、乐动力、微信 + if (scrollType && iOS) { + // 以免点击快捷短语无效 + setTimeout(() => { + document.body.scrollIntoView(); + }); + } + }); +} diff --git a/src/components/Composer/style.less b/src/components/Composer/style.less new file mode 100644 index 0000000..905fb1f --- /dev/null +++ b/src/components/Composer/style.less @@ -0,0 +1,110 @@ +.Composer { + display: flex; + align-items: flex-end; + padding: 8px; + + > div + div { + margin-left: 8px; + } +} + +.Composer-actions { + .IconBtn { + padding: 6px; + border-radius: 50%; + font-size: 26px; + } + .IconBtn, + .IconBtn:hover, + .IconBtn:active { + background: var(--white); + color: var(--brand-1); + } +} + +.Composer-toggleBtn { + transition: transform 0.2s; + + &.active { + transform: rotate(45deg); + } +} + +.Composer-inputWrap { + flex: 1; + position: relative; +} + +.Composer-input { + overflow: hidden; + max-height: 126px; + line-height: 22px; + padding: 8px 32px 8px 16px; + border: 0; + background-color: var(--white); + border-radius: 20px; + word-break: break-all; + caret-color: var(--brand-2); + transition: border-color .15s ease-in-out; + + &:placeholder-shown + .Composer-sendBtn { + visibility: hidden; + opacity: 0; + } +} + +.Composer-sendBtn { + position: absolute; + right: 7px; + bottom: 7px; + padding: 2px; + background: var(--brand-1); + color: var(--white); + font-size: 20px; + transition: 0.3s; +} + +.Composer-toolbar { + margin-right: 20px; + padding: 8px 0; + + .Btn { + margin-left: 20px; + font-size: 28px; + + &:first-child { + margin: 0; + } + &:hover { + color: var(--brand-1); + } + &:focus:before, + &:focus:after { + opacity: 0; + } + } + img { + display: block; + width: 28px; + height: 28px; + } +} + +.Composer--lg { + padding: 12px 20px 16px; + + .Composer-input { + padding: 10px 38px 10px 16px; + border: 1px solid var(--white); + border-radius: 22px; + + &:focus { + border-color: var(--brand-1); + } + } + .Composer-sendBtn { + right: 8px; + bottom: 8px; + font-size: 24px; + } +} diff --git a/src/components/Divider/__tests__/index.test.tsx b/src/components/Divider/__tests__/index.test.tsx new file mode 100644 index 0000000..eae201e --- /dev/null +++ b/src/components/Divider/__tests__/index.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { Divider } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render the children', () => { + const { getByTestId } = render( + + + , + ); + const wrap = getByTestId('wrap'); + const inside = getByTestId('inside'); + + expect(wrap).toContainElement(inside); + }); + + it('should apply position class', () => { + const { getByTestId } = render( + + testText + , + ); + const wrap = getByTestId('wrap'); + + expect(wrap).toHaveClass('Divider--text-center'); + }); +}); diff --git a/src/components/Divider/index.tsx b/src/components/Divider/index.tsx new file mode 100644 index 0000000..88b9f8e --- /dev/null +++ b/src/components/Divider/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type DividerProps = { + className?: string; + position?: 'center' | 'left' | 'right'; +}; + +export const Divider: React.FC = (props) => { + const { className, position = 'center', children, ...other } = props; + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/Divider/style.less b/src/components/Divider/style.less new file mode 100644 index 0000000..e8ddf19 --- /dev/null +++ b/src/components/Divider/style.less @@ -0,0 +1,38 @@ +.Divider { + display: flex; + align-items: center; + margin: 12px 0; + font-size: 12px; + color: var(--gray-3); + + &:before, + &:after { + content: ''; + display: block; + flex: 1; + border-top: 1px solid var(--gray-6); + } +} + +.Divider--text-center, +.Divider--text-left, +.Divider--text-right { + &:before { + margin-right: 12px; + } + &:after { + margin-left: 12px; + } +} + +.Divider--text-left { + &:before { + max-width: 10%; + } +} + +.Divider--text-right { + &:after { + max-width: 10%; + } +} diff --git a/src/components/Empty/__tests__/index.test.tsx b/src/components/Empty/__tests__/index.test.tsx new file mode 100644 index 0000000..d1dbb0d --- /dev/null +++ b/src/components/Empty/__tests__/index.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { Empty } from '..'; + +afterEach(cleanup); + +const IMAGE_EMPTY = '//gw.alicdn.com/tfs/TB1fnnLRkvoK1RjSZFDXXXY3pXa-300-250.svg'; +const IMAGE_OOPS = '//gw.alicdn.com/tfs/TB1lRjJRbvpK1RjSZPiXXbmwXXa-300-250.svg'; +const IMAGE_CHATUI = '//gw.alicdn.com/tfs/TB1uYH4QoY1gK0jSZFMXXaWcVXa-218-56.svg'; + +describe('', () => { + it('should render the children', () => { + const { container, getByTestId } = render( + + + , + ); + const wrap = container.querySelector('.Empty'); + const inside = getByTestId('inside'); + + expect(wrap).toContainElement(inside); + }); + + it('should render the tip', () => { + const { container } = render(); + const element = container.querySelector('.Empty-tip'); + + expect(element).toHaveTextContent('testTip'); + }); + + it('should render image (default)', () => { + const { container } = render(); + const element = container.querySelector('img'); + + expect(element?.getAttribute('src')).toBe(IMAGE_EMPTY); + }); + + it('should render image (error)', () => { + const { container } = render(); + const element = container.querySelector('img'); + + expect(element?.getAttribute('src')).toBe(IMAGE_OOPS); + }); + + it('should render image (custom)', () => { + const { container } = render(); + const element = container.querySelector('img'); + + expect(element?.getAttribute('src')).toBe(IMAGE_CHATUI); + }); +}); diff --git a/src/components/Empty/index.tsx b/src/components/Empty/index.tsx new file mode 100644 index 0000000..088acba --- /dev/null +++ b/src/components/Empty/index.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Flex } from '../Flex'; + +export type EmptyProps = { + className?: string; + type?: 'error' | 'default'; + image?: string; + tip?: string; +}; + +const IMAGE_EMPTY = '//gw.alicdn.com/tfs/TB1fnnLRkvoK1RjSZFDXXXY3pXa-300-250.svg'; +const IMAGE_OOPS = '//gw.alicdn.com/tfs/TB1lRjJRbvpK1RjSZPiXXbmwXXa-300-250.svg'; + +export const Empty: React.FC = (props) => { + const { className, type, image, tip, children } = props; + const imgUrl = image || (type === 'error' ? IMAGE_OOPS : IMAGE_EMPTY); + + return ( + + {tip} + {tip &&

{tip}

} + {children} +
+ ); +}; diff --git a/src/components/Empty/style.less b/src/components/Empty/style.less new file mode 100644 index 0000000..4d933dc --- /dev/null +++ b/src/components/Empty/style.less @@ -0,0 +1,13 @@ +.Empty { + padding: 30px; + text-align: center; +} + +.Empty-img { + height: 125px; +} + +.Empty-tip { + margin: 20px 0; + color: var(--gray-4); +} diff --git a/src/components/ErrorBoundary/index.tsx b/src/components/ErrorBoundary/index.tsx new file mode 100644 index 0000000..f134b12 --- /dev/null +++ b/src/components/ErrorBoundary/index.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +export interface ErrorBoundaryProps { + fallback?: React.ReactNode; +} + +export class ErrorBoundary extends React.Component { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + const { fallback, children } = this.props; + const { hasError } = this.state; + + if (hasError) { + return fallback || null; + } + return children; + } +} diff --git a/src/components/FileCard/__tests__/index.test.tsx b/src/components/FileCard/__tests__/index.test.tsx new file mode 100644 index 0000000..b83f08a --- /dev/null +++ b/src/components/FileCard/__tests__/index.test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { FileCard } from '..'; + +afterEach(cleanup); + +const file = new File(['foo'], 'foo.txt', { type: 'text/plain' }); + +describe('', () => { + it('should render the file', () => { + const { container } = render(); + + expect(container.querySelector('.FileCard-ext')).toHaveTextContent('txt'); + expect(container.querySelector('.FileCard-icon')).toHaveAttribute('data-type', 'txt'); + expect(container.querySelector('.FileCard-name')).toHaveTextContent('foo.txt'); + expect(container.querySelector('.FileCard-size')).toHaveTextContent('3 B'); + }); + + it('should apply the extension', () => { + const { container } = render(); + + expect(container.querySelector('.FileCard-ext')).toHaveTextContent('jpg'); + expect(container.querySelector('.FileCard-icon')).toHaveAttribute('data-type', 'jpg'); + }); +}); diff --git a/src/components/FileCard/index.tsx b/src/components/FileCard/index.tsx new file mode 100644 index 0000000..f341477 --- /dev/null +++ b/src/components/FileCard/index.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Card } from '../Card'; +import { Flex, FlexItem } from '../Flex'; +import { Icon } from '../Icon'; +import { Text } from '../Text'; +import getExtName from '../../utils/getExtName'; +import prettyBytes from '../../utils/prettyBytes'; + +export interface FileCardProps { + className?: string; + file: File; + extension?: string; +} + +export const FileCard: React.FC = (props) => { + const { className, file, extension, children } = props; + const { name, size } = file; + const ext = extension || getExtName(name); + + return ( + + +
+ + + {ext} + +
+ + + {name} + +
+ {size != null && {prettyBytes(size)}} + {children} +
+
+
+
+ ); +}; diff --git a/src/components/FileCard/style.less b/src/components/FileCard/style.less new file mode 100644 index 0000000..ff284cd --- /dev/null +++ b/src/components/FileCard/style.less @@ -0,0 +1,59 @@ +.FileCard { + padding: 8px; +} + +.FileCard-icon { + position: relative; + height: 60px; + margin-right: 8px; + color: var(--gray-2); + + &[data-type='pdf'] { + color: var(--red); + } + &[data-type*='doc'] { + color: var(--blue); + } + &[data-type*='ppt'], + &[data-type='key'] { + color: var(--orange); + } + &[data-type*='xls'] { + color: var(--green); + } + &[data-type='rar'], + &[data-type='zip'] { + color: var(--brand-1); + } + .Icon { + font-size: 60px; + } +} + +.FileCard-name { + height: 38px; + margin-bottom: 4px; + line-height: 1.4; +} + +.FileCard-ext { + position: absolute; + left: 20px; + bottom: 15px; + transform-origin: left bottom; + transform: scale(0.5); + max-width: 50px; + font-size: 16px; + font-weight: 700; + text-transform: uppercase; +} + +.FileCard-meta { + color: var(--gray-3); + font-size: 12px; + + & > a, + & > span { + margin-right: 10px; + } +} diff --git a/src/components/Flex/Flex.tsx b/src/components/Flex/Flex.tsx new file mode 100644 index 0000000..55eaf7c --- /dev/null +++ b/src/components/Flex/Flex.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import clsx from 'clsx'; + +const mapDirection = { + row: 'Flex--d-r', + 'row-reverse': 'Flex--d-rr', + column: 'Flex--d-c', + 'column-reverse': 'Flex--d-cr', +}; + +const mapWrap = { + nowrap: 'Flex--w-n', + wrap: 'Flex--w-w', + 'wrap-reverse': 'Flex--w-wr', +}; + +const mapJustify = { + 'flex-start': 'Flex--jc-fs', + 'flex-end': 'Flex--jc-fe', + center: 'Flex--jc-c', + 'space-between': 'Flex--jc-sb', + 'space-around': 'Flex--jc-sa', +}; + +const mapAlign = { + 'flex-start': 'Flex--ai-fs', + 'flex-end': 'Flex--ai-fe', + center: 'Flex--ai-c', +}; + +export type FlexProps = { + as?: React.ElementType; + className?: string; + center?: boolean; + inline?: boolean; + direction?: 'row' | 'row-reverse' | 'column' | 'column-reverse'; + wrap?: 'nowrap' | 'wrap' | 'wrap-reverse'; + justify?: + | 'flex-start' + | 'flex-end' + | 'center' + | 'space-between' + | 'space-around'; + justifyContent?: + | 'flex-start' + | 'flex-end' + | 'center' + | 'space-between' + | 'space-around'; + align?: 'flex-start' | 'flex-end' | 'center'; + alignItems?: 'flex-start' | 'flex-end' | 'center'; + children?: React.ReactNode; +}; + +export const Flex = React.forwardRef( + (props, ref) => { + const { + as: Element = 'div', + className, + inline, + center, + direction, + wrap, + justifyContent, + justify = justifyContent, + alignItems, + align = alignItems, + children, + ...other + } = props; + + return ( + + {children} + + ); + }, +); diff --git a/src/components/Flex/FlexItem.tsx b/src/components/Flex/FlexItem.tsx new file mode 100644 index 0000000..db8a557 --- /dev/null +++ b/src/components/Flex/FlexItem.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type FlexItemProps = { + className?: string; + flex?: string; + alignSelf?: 'auto' | 'flex-start' | 'flex-end' | 'center' | 'baseline' | 'stretch'; + order?: number; +}; + +export const FlexItem: React.FC = (props) => { + const { className, flex, alignSelf, order, children, ...other } = props; + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/Flex/__tests__/index.test.tsx b/src/components/Flex/__tests__/index.test.tsx new file mode 100644 index 0000000..f1db1d1 --- /dev/null +++ b/src/components/Flex/__tests__/index.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { Flex } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render the children', () => { + const { getByTestId } = render( + + + , + ); + const wrap = getByTestId('wrap'); + const inside = getByTestId('inside'); + + expect(wrap).toContainElement(inside); + }); + + it('should apply direction class', () => { + const { getByTestId } = render(); + const wrap = getByTestId('wrap'); + + expect(wrap).toHaveClass('Flex--d-r'); + }); + + it('should apply justify class', () => { + const { getByTestId } = render(); + const wrap = getByTestId('wrap'); + + expect(wrap).toHaveClass('Flex--jc-c'); + }); + + it('should apply wrap class', () => { + const { getByTestId } = render(); + const wrap = getByTestId('wrap'); + + expect(wrap).toHaveClass('Flex--ai-fe'); + }); + + it('should apply wrap class', () => { + const { getByTestId } = render(); + const wrap = getByTestId('wrap'); + + expect(wrap).toHaveClass('Flex--w-w'); + }); + + it('should apply inline class', () => { + const { getByTestId } = render(); + const wrap = getByTestId('wrap'); + + expect(wrap).toHaveClass('Flex--inline'); + }); + + it('should apply center class', () => { + const { getByTestId } = render(); + const wrap = getByTestId('wrap'); + + expect(wrap).toHaveClass('Flex--center'); + }); +}); diff --git a/src/components/Flex/__tests__/item.test.tsx b/src/components/Flex/__tests__/item.test.tsx new file mode 100644 index 0000000..ac7ccc7 --- /dev/null +++ b/src/components/Flex/__tests__/item.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { FlexItem } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render the children', () => { + const { getByTestId } = render( + + + , + ); + const wrap = getByTestId('wrap'); + const inside = getByTestId('inside'); + + expect(wrap).toContainElement(inside); + }); + + it('should render the flex style', () => { + const { getByTestId } = render(); + const wrap = getByTestId('wrap'); + + expect(wrap).toHaveStyle({ flex: '1' }); + }); + + it('should render the alignSelf style', () => { + const { getByTestId } = render(); + const wrap = getByTestId('wrap'); + + expect(wrap).toHaveStyle({ alignSelf: 'flex-start' }); + }); + + it('should render the order style', () => { + const { getByTestId } = render(); + const wrap = getByTestId('wrap'); + + expect(wrap).toHaveStyle({ order: '1' }); + }); + + it('should have a custom className', () => { + const { getByTestId } = render(); + expect(getByTestId('wrap')).toHaveClass('testClass'); + }); +}); diff --git a/src/components/Flex/index.ts b/src/components/Flex/index.ts new file mode 100644 index 0000000..7fe50d6 --- /dev/null +++ b/src/components/Flex/index.ts @@ -0,0 +1,4 @@ +export { Flex } from './Flex'; +export type { FlexProps } from './Flex'; +export { FlexItem } from './FlexItem'; +export type { FlexItemProps } from './FlexItem'; diff --git a/src/components/Flex/style.less b/src/components/Flex/style.less new file mode 100644 index 0000000..a6afa87 --- /dev/null +++ b/src/components/Flex/style.less @@ -0,0 +1,78 @@ +.Flex { + display: flex; +} + +.Flex--inline { + display: inline-flex; +} + +.Flex--center { + justify-content: center; + align-items: center; +} + +.Flex--d-r { + flex-direction: row; +} + +.Flex--d-rr { + flex-direction: row-reverse; +} + +.Flex--d-c { + flex-direction: column; +} + +.Flex--d-cr { + flex-direction: column-reverse; +} + +.Flex--w-n { + flex-wrap: nowrap; +} + +.Flex--w-w { + flex-wrap: wrap; +} + +.Flex--w-wr { + flex-wrap: wrap-reverse; +} + +.Flex--jc-fs { + justify-content: flex-start; +} + +.Flex--jc-fe { + justify-content: flex-end; +} + +.Flex--jc-c { + justify-content: center; +} + +.Flex--jc-sb { + justify-content: space-between; +} + +.Flex--jc-sa { + justify-content: space-around; +} + +.Flex--ai-fs { + align-items: flex-start; +} + +.Flex--ai-fe { + align-items: flex-end; +} + +.Flex--ai-c { + align-items: center; +} + +.FlexItem { + flex: 1; + min-width: 0; + min-height: 0; +} diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx new file mode 100644 index 0000000..05304c4 --- /dev/null +++ b/src/components/Form/Form.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type FormProps = { + className?: string; + theme?: 'default' | 'light'; +}; + +export const ThemeContext = React.createContext(''); + +export const Form: React.FC = (props) => { + const { className, theme = 'default', children, ...other } = props; + return ( + +
+ {children} +
+
+ ); +}; diff --git a/src/components/Form/FormActions.tsx b/src/components/Form/FormActions.tsx new file mode 100644 index 0000000..bc46c6d --- /dev/null +++ b/src/components/Form/FormActions.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import clsx from 'clsx'; + +export const FormActions: React.FC = (props) => { + const { children, ...other } = props; + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/Form/FormItem.tsx b/src/components/Form/FormItem.tsx new file mode 100644 index 0000000..54f906c --- /dev/null +++ b/src/components/Form/FormItem.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Label } from '../Label'; +import { HelpText } from '../HelpText'; + +export type FormItemProps = { + label?: string | React.ReactNode; + help?: string; + required?: boolean; + invalid?: boolean; + hidden?: boolean; +}; + +export const FormItem: React.FC = (props) => { + const { label, help, required, invalid, hidden, children } = props; + return ( + + ); +}; diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts new file mode 100644 index 0000000..9008712 --- /dev/null +++ b/src/components/Form/index.ts @@ -0,0 +1,5 @@ +export { Form, ThemeContext } from './Form'; +export type { FormProps } from './Form'; +export { FormItem } from './FormItem'; +export type { FormItemProps } from './FormItem'; +export { FormActions } from './FormActions'; diff --git a/src/components/Form/style.less b/src/components/Form/style.less new file mode 100644 index 0000000..df3acd4 --- /dev/null +++ b/src/components/Form/style.less @@ -0,0 +1,63 @@ +.Form { + background: var(--white); + + &.is-light { + background: var(--light-2); + + .InputWrapper { + margin: 0 -12px; + } + } +} + +/* FormItem */ +.FormItem { + position: relative; + padding: 0 12px; + + & + & { + margin-top: 20px; + } + &.required { + .Label:after { + content: '*'; + display: inline-block; + color: var(--red); + font-size: 14px; + font-family: SimSun,sans-serif; + line-height: 1; + vertical-align: middle; + } + } + &.is-invalid { + .Label, + .HelpText { + color: var(--red); + } + .InputWrapper.is-light .Input { + border-bottom-color: var(--red); + } + } + .RadioGroup, + .CheckboxGroup { + margin-top: 10px; + } + .Label + .Input { + margin-top: 5px; + } +} + +/* FormActions */ +.FormActions { + display: flex; + padding: 10px 12px; + background: var(--white); + + .Btn { + flex: 1; + line-height: 20px; + } + .Btn + .Btn { + margin-left: 6px; + } +} diff --git a/src/components/Goods/__tests__/index.test.tsx b/src/components/Goods/__tests__/index.test.tsx new file mode 100644 index 0000000..ddf2e46 --- /dev/null +++ b/src/components/Goods/__tests__/index.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, cleanup, fireEvent } from '@testing-library/react'; +import { Goods } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render the name', () => { + const { getByTestId } = render(); + const goods = getByTestId('goods'); + + expect(goods.querySelector('.Goods-name')).toHaveTextContent('testName'); + }); + + it('should render the image', () => { + const img = '//gw.alicdn.com/tfs/TB1uYH4QoY1gK0jSZFMXXaWcVXa-218-56.svg'; + const { getByTestId } = render(); + const goods = getByTestId('goods'); + + expect(goods.querySelector('.Goods-img')).toHaveAttribute('src', img); + }); + + it('should render the description', () => { + const { getByTestId } = render(); + const goods = getByTestId('goods'); + + expect(goods.querySelector('.Goods-desc')).toHaveTextContent('testDesc'); + }); + + it('should render the tags', () => { + const { getByTestId } = render( + , + ); + const goods = getByTestId('goods'); + + expect(goods.querySelectorAll('.Tag').length).toBe(2); + }); + + it('should render the price', () => { + const { getByTestId } = render(); + const goods = getByTestId('goods'); + + expect(goods.querySelector('.Price')).toHaveTextContent('123'); + }); + + it('should render the count', () => { + const { getByTestId } = render( + , + ); + const goods = getByTestId('goods'); + + expect(goods.querySelector('.Goods-count')).toHaveTextContent('×123'); + expect(goods.querySelector('.Goods-unit')).toHaveTextContent('g'); + }); + + it('should render the action', (done) => { + const { getByTestId } = render( + done() }} data-testid="goods" />, + ); + const goods = getByTestId('goods'); + const btn = goods.querySelector('.Goods-buyBtn'); + + if (btn) { + fireEvent.click(btn); + } + }); +}); diff --git a/src/components/Goods/index.tsx b/src/components/Goods/index.tsx new file mode 100644 index 0000000..5872350 --- /dev/null +++ b/src/components/Goods/index.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Flex, FlexItem } from '../Flex'; +import { Text } from '../Text'; +import { Price } from '../Price'; +import { Tag } from '../Tag'; +import { IconButton, IconButtonProps } from '../IconButton'; +import { Button, ButtonProps } from '../Button'; + +type TagProps = { + name: string; +}; + +export interface GoodsProps { + className?: string; + type?: 'goods' | 'order'; + img?: string; + name: string; + desc?: string; + tags?: TagProps[]; + currency?: string; + price?: string | number; + originalPrice?: string | number; + meta?: string; + count?: number; + unit?: string; + status?: string; + action?: ButtonProps | IconButtonProps; +} + +export const Goods = React.forwardRef((props, ref) => { + const { + // 通用 + className, + type, + img, + name, + desc, + tags = [], + currency, + price, + count, + unit, + action, + // 商品 + originalPrice, + meta, + + // 订单 + status, + ...other + } = props; + + const isOrder = type === 'order'; + + const infos = ( + <> + + {name} + + {desc} +
+ {tags.map((t) => ( + {t.name} + ))} +
+ + ); + + const priceCont = price && ; + + const countUnit = ( +
+ {count && ( + + × + {count} + + )} + {unit && {unit}} +
+ ); + + const mainCont = isOrder ? ( + infos + ) : ( + <> + {action && } + {infos} + + + {priceCont} + {originalPrice && } + {meta && {meta}} + + {countUnit} + + + ); + + return ( + + {img && {name}} + {mainCont} + {isOrder && ( +
+ {priceCont} + {countUnit} + {status} + {action &&
+ )} +
+ ); +}); diff --git a/src/components/Goods/style.less b/src/components/Goods/style.less new file mode 100644 index 0000000..b8a1ff9 --- /dev/null +++ b/src/components/Goods/style.less @@ -0,0 +1,57 @@ +.Goods { + padding: @goods-gap; + + & + & { + border-top: @goods-border-width solid @goods-border-color; + } + &-img { + width: @goods-img-width; + height: @goods-img-height; + margin-right: @goods-gap; + object-fit: contain; + } + &-main { + .Price { + margin-right: @goods-gap; + } + } + &-desc { + color: @goods-desc-color; + font-size: @goods-desc-font-size; + } + &-meta { + color: @goods-meta-color; + font-size: @goods-meta-font-size; + } + &-countUnit { + color: @goods-count-color; + font-size: @goods-count-font-size; + } + &-unit { + margin-left: 3px; + font-size: @goods-unit-font-size; + } + &-buyBtn { + float: right; + background: @goods-buy-btn-bg; + color: @goods-buy-btn-color; + padding: @goods-buy-btn-padding; + } + &-detailBtn { + min-width: @goods-detail-btn-min-width; + padding: @goods-detail-btn-padding; + border-radius: @goods-detail-btn-border-radius; + font-size: @goods-detail-btn-font-size; + line-height: @goods-detail-btn-line-height; + } + &-aside { + display: flex; + flex-direction: column; + align-items: flex-end; + margin-left: @goods-gap; + } + &-status { + color: @goods-status-color; + font-size: @goods-status-font-size; + } +} diff --git a/src/components/HelpText/__tests__/index.test.tsx b/src/components/HelpText/__tests__/index.test.tsx new file mode 100644 index 0000000..8f67e68 --- /dev/null +++ b/src/components/HelpText/__tests__/index.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { HelpText } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render the children', () => { + const { container, getByTestId } = render( + + + , + ); + const wrap = container.querySelector('.HelpText'); + const inside = getByTestId('inside'); + + expect(wrap).toContainElement(inside); + }); +}); diff --git a/src/components/HelpText/index.tsx b/src/components/HelpText/index.tsx new file mode 100644 index 0000000..e1c6119 --- /dev/null +++ b/src/components/HelpText/index.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export const HelpText: React.FC = (props) => { + const { children, ...others } = props; + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/HelpText/style.less b/src/components/HelpText/style.less new file mode 100644 index 0000000..e784b65 --- /dev/null +++ b/src/components/HelpText/style.less @@ -0,0 +1,4 @@ +.HelpText { + font-size: 12px; + color: var(--gray-4); +} diff --git a/src/components/Icon/__tests__/index.test.tsx b/src/components/Icon/__tests__/index.test.tsx new file mode 100644 index 0000000..eb777cf --- /dev/null +++ b/src/components/Icon/__tests__/index.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { Icon } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render the icon', () => { + const { getByTestId } = render(); + const icon = getByTestId('icon'); + + expect(icon?.querySelector('use')).toHaveAttribute('xlink:href', '#icon-foo'); + }); + + it('should have spin class', () => { + const { getByTestId } = render(); + const icon = getByTestId('icon'); + + expect(icon).toHaveClass('is-spin'); + }); + + it('should have a custom className', () => { + const { getByTestId } = render(); + const icon = getByTestId('icon'); + + expect(icon).toHaveClass('testName'); + }); + + it('should have a custom style', () => { + const { getByTestId } = render( + , + ); + const icon = getByTestId('icon'); + + expect(icon).toHaveStyle({ fontSize: '12px' }); + }); + + it('should render the name', () => { + const { getByTestId } = render(); + const icon = getByTestId('icon'); + + expect(icon).toHaveAttribute('aria-label', 'testName'); + }); +}); diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx new file mode 100644 index 0000000..247f855 --- /dev/null +++ b/src/components/Icon/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type IconProps = React.SVGProps & { + type: string; + className?: string; + name?: string; + spin?: boolean; +}; + +export const Icon: React.FC = (props) => { + const { type, className, spin, name, ...other } = props; + const ariaProps = typeof name === 'string' ? { 'aria-label': name } : { 'aria-hidden': true }; + + return ( + + + + ); +}; diff --git a/src/components/Icon/style.less b/src/components/Icon/style.less new file mode 100644 index 0000000..5a6a24d --- /dev/null +++ b/src/components/Icon/style.less @@ -0,0 +1,21 @@ +.Icon { + display: inline-block; + width: 1em; + height: 1em; + stroke-width: 0; + fill: currentColor; + transition: all 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28); +} + +.is-spin { + animation: spin 1s infinite linear; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + to { + transform: rotate(1turn); + } +} diff --git a/src/components/IconButton/__tests__/index.test.tsx b/src/components/IconButton/__tests__/index.test.tsx new file mode 100644 index 0000000..7f99412 --- /dev/null +++ b/src/components/IconButton/__tests__/index.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { IconButton } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render the icon', () => { + const { getByTestId } = render(); + const btn = getByTestId('btn'); + + expect(btn?.querySelector('use')).toHaveAttribute('xlink:href', '#icon-foo'); + }); + + it('should have a custom className', () => { + const { getByTestId } = render( + , + ); + const btn = getByTestId('btn'); + + expect(btn).toHaveClass('testName'); + }); +}); diff --git a/src/components/IconButton/index.tsx b/src/components/IconButton/index.tsx new file mode 100644 index 0000000..bd47209 --- /dev/null +++ b/src/components/IconButton/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Button, ButtonProps } from '../Button'; +import { Icon } from '../Icon'; + +export type IconButtonProps = ButtonProps & { + className?: string; + icon: string; +}; + +export const IconButton: React.FC = (props) => { + const { className, icon, ...other } = props; + return ( + + ); +}; diff --git a/src/components/IconButton/style.less b/src/components/IconButton/style.less new file mode 100644 index 0000000..e1f94ab --- /dev/null +++ b/src/components/IconButton/style.less @@ -0,0 +1,17 @@ +.IconBtn { + padding: 0; + border: 0; + background: transparent; + color: var(--gray-2); + + &.Btn--primary { + color: var(--brand-2); + } + &:disabled { + border-color: var(--gray-6); + color: var(--gray-6); + } + & > .Icon { + display: block; + } +} diff --git a/src/components/Image/__tests__/index.test.tsx b/src/components/Image/__tests__/index.test.tsx new file mode 100644 index 0000000..24ec5dd --- /dev/null +++ b/src/components/Image/__tests__/index.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { Image } from '..'; + +beforeEach(() => { + class IntersectionObserver { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + } + + Object.defineProperty(window, 'IntersectionObserver', { + writable: true, + configurable: true, + value: IntersectionObserver, + }); +}); + +afterEach(cleanup); + +const testImg = '//gw.alicdn.com/tfs/TB1uYH4QoY1gK0jSZFMXXaWcVXa-218-56.svg'; + +describe('', () => { + it('should render the image', () => { + const { getByTestId } = render(); + const img = getByTestId('img'); + + expect(img).toHaveAttribute('src', testImg); + }); + + it('should render the alt', () => { + const { getByTestId } = render(testAlt); + const img = getByTestId('img'); + + expect(img).toHaveAttribute('alt', 'testAlt'); + }); + + it('should apply fluid class', () => { + const { getByTestId } = render(); + const img = getByTestId('img'); + + expect(img).toHaveClass('Image--fluid'); + }); + + it('should lazy render', () => { + const { getByTestId } = render(); + const img = getByTestId('img'); + + expect(img).toHaveAttribute('src', ''); + }); +}); diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx new file mode 100644 index 0000000..edfe52b --- /dev/null +++ b/src/components/Image/index.tsx @@ -0,0 +1,58 @@ +import React, { useState, useEffect, useRef } from 'react'; +import clsx from 'clsx'; + +export interface ImageProps { + className?: string, + src: string, + alt?: string, + lazy?: boolean, + fluid?: boolean, +}; + +export const Image = React.forwardRef((props, ref) => { + const { + className, + src: oSrc, + alt = '', + lazy, + fluid, + children, + ...other + } = props; + const [src, setSrc] = useState(''); + const imgRef = ref || useRef(null); + const savedSrc = useRef(''); + const lazyLoaded = useRef(false); + + useEffect(() => { + if (!lazy) return undefined; + + const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + setSrc(savedSrc.current); + lazyLoaded.current = true; + observer.unobserve(entry.target); + } + }); + + observer.observe((imgRef as React.MutableRefObject).current); + return () => { + observer.disconnect(); + }; + }, []); + + useEffect(() => { + savedSrc.current = oSrc; + setSrc(lazy && !lazyLoaded.current ? '' : oSrc); + }, [oSrc]); + + return ( + {alt} + ); +}); diff --git a/src/components/Image/style.less b/src/components/Image/style.less new file mode 100644 index 0000000..70c8b96 --- /dev/null +++ b/src/components/Image/style.less @@ -0,0 +1,10 @@ +.Image { + position: relative; + display: inline-block; + overflow: hidden; +} + +.Image--fluid { + max-width: 100%; + height: auto; +} diff --git a/src/components/InfiniteScroll/index.tsx b/src/components/InfiniteScroll/index.tsx new file mode 100644 index 0000000..799e28a --- /dev/null +++ b/src/components/InfiniteScroll/index.tsx @@ -0,0 +1,37 @@ +import React, { useRef } from 'react'; +import clsx from 'clsx'; + +export type InfiniteScrollProps = { + className?: string; + disabled?: boolean; + distance?: number; + onLoadMore: () => void; +}; + +export const InfiniteScroll: React.FC = (props) => { + const { className, disabled, distance = 0, children, onLoadMore, ...other } = props; + const wrapperRef = useRef(null!); + + function handleScroll() { + if (disabled) return; + + const el = wrapperRef.current; + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= distance; + + if (nearBottom) { + onLoadMore(); + } + } + + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/InfiniteScroll/style.less b/src/components/InfiniteScroll/style.less new file mode 100644 index 0000000..bc35feb --- /dev/null +++ b/src/components/InfiniteScroll/style.less @@ -0,0 +1,4 @@ +.InfiniteScroll { + overflow-y: scroll; + -webkit-overflow-scrolling: touch; +} diff --git a/src/components/Input/__tests__/index.test.tsx b/src/components/Input/__tests__/index.test.tsx new file mode 100644 index 0000000..09720b1 --- /dev/null +++ b/src/components/Input/__tests__/index.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { render, cleanup, fireEvent } from '@testing-library/react'; +import { Input } from '..'; + +afterEach(cleanup); + +describe('', () => { + it('should render a input', () => { + const { getByTestId } = render(); + const inputElement = getByTestId('input'); + + expect(inputElement).toHaveClass('Input'); + }); + + it('should be disabled', () => { + const { getByTestId } = render(); + const inputElement = getByTestId('input'); + + expect(inputElement).toBeDisabled(); + }); + + it('should call onChange callback', (done) => { + const { getByTestId } = render( done()} data-testid="input" />); + const inputElement = getByTestId('input'); + + fireEvent.change(inputElement, { + target: { + value: 'foo', + }, + }); + }); + + it('should apply maxLength', () => { + const { getByTestId } = render(); + const inputElement = getByTestId('input'); + + fireEvent.change(inputElement, { + target: { + value: 'foo', + }, + }); + }); +}); diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx new file mode 100644 index 0000000..701521d --- /dev/null +++ b/src/components/Input/index.tsx @@ -0,0 +1,152 @@ +import React, { useState, useEffect, useRef, useContext } from 'react'; +import clsx from 'clsx'; +import { ThemeContext } from '../Form'; + +function renderCounter(value = '', maxLength?: number) { + return maxLength ?
{`${value.length}/${maxLength}`}
: null; +} + +export type InputRef = HTMLInputElement | HTMLTextAreaElement; + +export type InputProps = { + className?: string; + type?: string; + value: string; + placeholder?: string; + rows?: number; + minRows?: number; + maxRows?: number; + maxLength?: number; + multiline?: boolean; + autoSize?: boolean; + disabled?: boolean; + enterkeyhint?: string; + onChange?: ( + value: string, + event: React.ChangeEvent, + ) => void; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; + onKeyDown?: React.KeyboardEventHandler; + onKeyUp?: React.KeyboardEventHandler; + onPaste?: (event: React.ClipboardEvent) => void; +}; + +export const Input = React.forwardRef((props, ref) => { + const { + className, + type = 'text', + value, + placeholder, + rows: oRows = 1, + minRows = oRows, + maxRows = 5, + maxLength, + multiline, + autoSize, + onChange, + ...other + } = props; + + let initialRows = oRows; + if (initialRows < minRows) { + initialRows = minRows; + } else if (initialRows > maxRows) { + initialRows = maxRows; + } + + const [rows, setRows] = useState(initialRows); + const [lineHeight, setLineHeight] = useState(21); + const inputRef = (ref as React.MutableRefObject) || useRef(null); + const theme = useContext(ThemeContext); + const isMultiline = multiline || autoSize || oRows > 1; + const Element = isMultiline ? 'textarea' : 'input'; + const hasCounter = !!maxLength; + const isLight = theme === 'light'; + + useEffect(() => { + const lhStr = getComputedStyle(inputRef.current, null).lineHeight; + const lh = Number(lhStr.replace('px', '')); + + if (lh !== lineHeight) { + setLineHeight(lh); + } + }, []); + + function updateRow() { + if (!autoSize) return; + + const target = (inputRef as React.MutableRefObject).current; + const prevRows = target.rows; + target.rows = minRows; + + if (placeholder) { + target.placeholder = ''; + } + + // eslint-disable-next-line no-bitwise + const currentRows = ~~(target.scrollHeight / lineHeight); + + if (currentRows === prevRows) { + target.rows = currentRows; + } + + if (currentRows >= maxRows) { + target.rows = maxRows; + target.scrollTop = target.scrollHeight; + } + + setRows(currentRows < maxRows ? currentRows : maxRows); + + if (placeholder) { + target.placeholder = placeholder; + } + } + + useEffect(() => { + if (value === '') { + setRows(initialRows); + } else { + updateRow(); + } + }, [value]); + + function handleChange(e: React.ChangeEvent) { + updateRow(); + + if (onChange) { + const valueFromEvent = e.target.value; + const shouldTrim = isMultiline && maxLength && valueFromEvent.length > maxLength; + const val = shouldTrim ? valueFromEvent.substr(0, maxLength) : valueFromEvent; + onChange(val, e); + } + } + + const inputProps = { + ...other, + className: clsx('Input', className), + type, + ref: inputRef as any, + rows, + value, + placeholder, + maxLength, + onChange: handleChange, + }; + + if (isLight || hasCounter) { + return ( +
+ + {isLight &&
} + {renderCounter(value, maxLength)} +
+ ); + } + return ; +}); diff --git a/src/components/Input/style.less b/src/components/Input/style.less new file mode 100644 index 0000000..af8a895 --- /dev/null +++ b/src/components/Input/style.less @@ -0,0 +1,86 @@ +.InputWrapper { + position: relative; + + &.is-light { + &.has-counter { + padding-bottom: 20px; + + & + .HelpText { + margin-top: -18px; + } + } + .Input { + padding: 2px 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 0; + background-color: transparent; + } + .Input-counter { + bottom: 0; + margin-top: 0; + } + } +} + +.Input { + display: block; + box-sizing: border-box; + width: 100%; + min-height: 24px; + padding: 8px 12px; + border: 0; + border-radius: 10px; + font-size: 14px; + line-height: 1.5; + color: var(--gray-1); + background-color: var(--light-1); + background-clip: padding-box; + -webkit-tap-highlight-color: transparent; + + &:focus { + outline: none; + } + &:focus:not([disabled]):not([readonly]) { + & ~ .Input-line { + &:before, + &:after { + width: 50%; + } + } + } + &::placeholder { + color: #ccc; + } +} + +.Input-line { + position: relative; + width: 100%; + + &:before, + &:after { + content: ''; + position: absolute; + bottom: 0; + width: 0; + height: 1px; + background: var(--brand-1); + transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + } + &:before { + left: 50%; + } + &:after { + right: 50%; + } +} + +.Input-counter { + /* absolute 在iOS下有坑,在消息列表里的输入框,输入文字后,页面卡片下移 */ + position: relative; + z-index: 1; + float: right; + margin: -26px 12px 0 0; + color: var(--gray-3); + font-size: 12px; +} diff --git a/src/components/Label/__tests__/index.test.tsx b/src/components/Label/__tests__/index.test.tsx new file mode 100644 index 0000000..91847e3 --- /dev/null +++ b/src/components/Label/__tests__/index.test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { Label } from '..'; + +afterEach(cleanup); + +describe('