diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f0240aa --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +end_of_line = lf + +[*.{js,json,ts,html,jsx,mdx}] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..39db66d --- /dev/null +++ b/.eslintrc @@ -0,0 +1,46 @@ +{ + "env": { + "browser": true, + "commonjs": true, + "es6": true, + "node": true + }, + "plugins": [ + "react", + "jsx-a11y" + ], + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:jsx-a11y/recommended" + ], + "parser": "babel-eslint", + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "rules": { + "linebreak-style": [ + "error", + "unix" + ], + "semi": [ + "error", + "always" + ], + "no-console" : [ + "warn" + ], + "prefer-template" : [ + "error" + ] + }, + "settings": { + "react": { + "version": "detect" + } + } +} diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..0789e6c --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,33 @@ +name: build + +on: + push: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Node.js + uses: actions/setup-node@v1 + with: + node-version: 12 + registry-url: https://registry.npmjs.org/ + scope: '@chatscope' + - name: Install dependencies + run: yarn + - name: Build + run: yarn build + - name: Publish package + env: + GITHUB_TOKEN: ${{secrets.RELEASE_TOKEN}} + NPM_TOKEN: ${{secrets.NPM_TOKEN}} + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + GIT_AUTHOR_NAME: supersnager + GIT_AUTHOR_EMAIL: ${{secrets.GIT_AUTHOR_EMAIL}} + GIT_COMMITTER_NAME: supersnager + GIT_COMMITTER_EMAIL: ${{secrets.GIT_AUTHOR_EMAIL}} + run: npx semantic-release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0bb692 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +.idea +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build +/dist +/storybook-static + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.eslintcache \ No newline at end of file diff --git a/.lintstagedrc.js b/.lintstagedrc.js new file mode 100644 index 0000000..f3d4e27 --- /dev/null +++ b/.lintstagedrc.js @@ -0,0 +1,9 @@ +const { CLIEngine } = require("eslint"); + +const cli = new CLIEngine({}); + +module.exports = { + "*.js": (files) => + "eslint --max-warnings=0 " + + files.filter((file) => !cli.isPathIgnored(file)).join(" "), +}; diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000..5cb9a09 --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +version-git-tag false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e185c2b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 chatscope.io + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ebd7d1 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# Chat UI Kit React + +[![Actions Status](https://github.com/chatscope/chat-ui-kit-react/workflows/build/badge.svg)](https://github.com/chatscope/chat-ui-kit-react/actions) [![npm version](https://img.shields.io/npm/v/@chatscope/chat-ui-kit-react.svg?style=flat)](https://npmjs.com/@chatscope/chat-ui-kit-react) [![](https://img.shields.io/npm/l/@chatscope/chat-ui-kit-react)](https://github.com/chatscope/chat-ui-kit-react/blob/master/LICENSE) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) [![Storybook](https://cdn.jsdelivr.net/gh/storybookjs/brand@master/badge/badge-storybook.svg)](https://chatscope.io/storybook) + +Build your own chat UI in few minutes. +Chat UI Kit from chatscope is an open source UI toolkit for developing web chat applications. + +Tired of struggling with sticky scrollbars, contenteditable, responsiveness, css hacks...? +Our kit is for you! [See all features](https://chatscope.io/features). + +**Chat UI Kit makes chat UI development at warp speed** + +## Install + +**Component library**. + +Using yarn. + +```sh +yarn add @chatscope/chat-ui-kit-react +``` + +Using npm. + +```sh +npm install @chatscope/chat-ui-kit-react +``` + +**Styles**. + +Using yarn. + +```sh +yarn add @chatscope/chat-ui-kit-styles +``` + +Using npm. + +```sh +npm install @chatscope/chat-ui-kit-styles +``` + +## Usage + +### ESM + +```jsx +import styles from "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css"; +import { + MainContainer, + ChatContainer, + MessageList, + Message, + MessageInput, +} from "@chatscope/chat-ui-kit-react"; + +
+ + + + + + + + +
; +``` + +Yeah! Your first chat GUI is ready! + +### UMD + +UMD usage is documented in our [Storybook](https://chatscope.io/storybook/react/). + +## Show your support + +Now if you made your awesome chat UI and you love this library, please ⭐ this repository! + +## Community and support + +- Twitting via [@chatscope](https://twitter.com/chatscope) +- Chatting at [Discord](https://discord.gg/ZSuESK) +- Facebooking at [Facebook](https://www.facebook.com/chatscope) + +## Website + +[https://chatscope.io](https://chatscope.io) + +## License + +[MIT](https://github.com/chatscope/chat-ui-kit-react/blob/master/LICENSE) diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..1429390 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,52 @@ +module.exports = { + env: { + // ESM Build + esm: { + exclude: ["node_modules/**"], + presets: [ + [ + "@babel/preset-env", + { + modules: false, // Code is written as ES2015 - no need to transform modules + }, + ], + "@babel/preset-react", + ], + plugins: [ + "@babel/plugin-proposal-class-properties", + + [ + "transform-react-remove-prop-types", + { + mode: "unsafe-wrap", + ignoreFilenames: ["node_modules"], + }, + ], + ], + }, + + // CommonJs build + cjs: { + exclude: ["node_modules/**"], + presets: [ + [ + "@babel/preset-env", + { + modules: "commonjs", // Transform modules to CommonJs + }, + ], + "@babel/preset-react", + ], + plugins: [ + "@babel/plugin-proposal-class-properties", + [ + "transform-react-remove-prop-types", + { + mode: "unsafe-wrap", // Wrap propTypes in condition to remove by webpack in production build + ignoreFilenames: ["node_modules"], + }, + ], + ], + }, + }, +}; diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..5073c20 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +module.exports = { extends: ["@commitlint/config-conventional"] }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..7d1a422 --- /dev/null +++ b/package.json @@ -0,0 +1,142 @@ +{ + "name": "@chatscope/chat-ui-kit-react", + "version": "0.0.9", + "description": "React component library for creating chat interfaces", + "license": "MIT", + "homepage": "https://chatscope.io/", + "keywords": [ + "chat", + "react", + "reactjs", + "ui", + "user interface", + "components", + "ui kit", + "communication", + "conversation", + "toolkit", + "library", + "frontend", + "reusable", + "feed", + "comments", + "social", + "talk" + ], + "repository": { + "type": "git", + "url": "https://github.com/chatscope/chat-ui-kit-react.git" + }, + "main": "dist/cjs/index.js", + "module": "dist/es/index.js", + "peerDependencies": { + "prop-types": "^15.7.2", + "react": "^16.12.0", + "react-dom": "^16.12.0" + }, + "devDependencies": { + "@babel/cli": "7.10.5", + "@babel/core": "7.11.4", + "@babel/plugin-proposal-class-properties": "7.10.4", + "@babel/preset-env": "7.11.5", + "@babel/preset-react": "7.10.4", + "@commitlint/cli": "11.0.0", + "@commitlint/config-conventional": "11.0.0", + "@rollup/plugin-babel": "5.2.0", + "@rollup/plugin-commonjs": "11.1.0", + "@rollup/plugin-node-resolve": "7.1.3", + "@semantic-release/changelog": "5.0.1", + "@semantic-release/git": "9.0.0", + "@semantic-release/github": "7.1.1", + "babel-eslint": "10.1.0", + "babel-plugin-transform-react-remove-prop-types": "0.4.24", + "chokidar-cli": "2.1.0", + "eslint": "6.8.0", + "eslint-plugin-jsx-a11y": "6.3.1", + "eslint-plugin-react": "7.20.6", + "husky": "4.3.0", + "lint-staged": "10.4.0", + "prettier": "2.1.2", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "rollup": "2.26.5", + "rollup-plugin-peer-deps-external": "2.2.3", + "rollup-plugin-terser": "5.3.0", + "semantic-release": "17.1.1" + }, + "scripts": { + "build:clean": "rm -Rf dist", + "build:umd": "rollup -c", + "build:cjs": "BABEL_ENV=cjs babel src/components -d dist/cjs", + "build:esm": "BABEL_ENV=esm babel src/components -d dist/es", + "build": "yarn run build:clean && yarn run build:esm && yarn run build:cjs && yarn run build:umd", + "pack": "yarn pack", + "watch": "chokidar 'src/**/*.*' -c 'yarn run build:esm'" + }, + "dependencies": { + "@chatscope/chat-ui-kit-styles": "^1.0.2", + "@fortawesome/fontawesome-free": "^5.12.1", + "@fortawesome/fontawesome-svg-core": "^1.2.26", + "@fortawesome/free-solid-svg-icons": "^5.12.0", + "@fortawesome/react-fontawesome": "^0.1.8", + "classnames": "^2.2.6", + "prop-types": "^15.7.2", + "react-perfect-scrollbar": "^1.5.8" + }, + "husky": { + "hooks": { + "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "*.{js,css,md,jsx}": "prettier --write" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "release": { + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "angular", + "releaseRules": [ + { + "type": "docs", + "scope": "readme", + "release": "patch" + }, + { + "scope": "no-release", + "release": false + } + ] + } + ], + "@semantic-release/release-notes-generator", + [ + "@semantic-release/changelog", + { + "changelogFile": "CHANGELOG.md", + "changelogTitle": "# @chatscope/chat-ui-kit-react changelog" + } + ], + "@semantic-release/github", + "@semantic-release/npm", + [ + "@semantic-release/git", + { + "assets": [ + "package.json", + "CHANGELOG.md" + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] + ] + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..500031e --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,49 @@ +import resolve from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import babel from "@rollup/plugin-babel"; +import peerDepsExternal from "rollup-plugin-peer-deps-external"; +import { terser } from "rollup-plugin-terser"; + +export default [ + // browser-friendly UMD build + { + input: "src/components/index.js", + output: { + name: "ChatUiKitReact", + file: "dist/chat-ui-kit-react.min.js", + format: "umd", + noConflict: true, + globals: { + react: "React", + "react-dom": "ReactDOM", + "prop-types": "PropTypes", + }, + }, + + plugins: [ + peerDepsExternal(), + resolve({ + extensions: [".mjs", ".js", ".json", ".node", ".jsx"], + }), + commonjs(), + babel({ + exclude: "node_modules/**", + presets: ["@babel/preset-env", "@babel/preset-react"], + babelHelpers: "bundled", + compact: true, + plugins: [ + "@babel/plugin-proposal-class-properties", + [ + "transform-react-remove-prop-types", + { + mode: "remove", + removeImport: true, + ignoreFilenames: ["node_modules"], + }, + ], + ], + }), + terser(), + ], + }, +]; diff --git a/scripts/terser.js b/scripts/terser.js new file mode 100644 index 0000000..fa9ba6c --- /dev/null +++ b/scripts/terser.js @@ -0,0 +1,32 @@ +// Warning this script is not used for now +const Terser = require("terser"); +const fs = require("fs"); +const path = require("path"); + +function getAllFiles(dirPath, arrayOfFiles) { + let files = fs.readdirSync(dirPath); + + arrayOfFiles = arrayOfFiles || []; + + files.forEach(function (file) { + if (fs.statSync(dirPath + "/" + file).isDirectory()) { + arrayOfFiles = getAllFiles(dirPath + "/" + file, arrayOfFiles); + } else { + arrayOfFiles.push(path.join(dirPath, "/", file)); + } + }); + + return arrayOfFiles.filter((path) => path.match(/\.js$/)); +} + +function minifyFiles(filePaths) { + filePaths.forEach((filePath) => { + fs.writeFileSync( + filePath, + Terser.minify(fs.readFileSync(filePath, "utf8")).code + ); + }); +} + +const files = getAllFiles("./es/"); +minifyFiles(files); diff --git a/src/components/Avatar/Avatar.jsx b/src/components/Avatar/Avatar.jsx new file mode 100644 index 0000000..bb363e2 --- /dev/null +++ b/src/components/Avatar/Avatar.jsx @@ -0,0 +1,85 @@ +import React, { useRef, forwardRef, useImperativeHandle } from "react"; +import PropTypes from "prop-types"; +import { prefix } from "../settings"; +import classNames from "classnames"; +import { Status } from "../Status/Status"; +import { SizeEnum, StatusEnum } from "../enums"; + +function AvatarInner( + { name, src, size, status, className, active, children, ...rest }, + ref +) { + const cName = `${prefix}-avatar`; + const sizeClass = typeof size !== "undefined" ? ` ${cName}--${size}` : ""; + + const avatarRef = useRef(); + + useImperativeHandle(ref, () => ({ + focus: () => avatarRef.current.focus(), + })); + + return ( +
+ {children ? ( + children + ) : ( + <> + {name} + {typeof status === "string" && ( + + )}{" "} + + )} +
+ ); +} + +const Avatar = forwardRef(AvatarInner); +Avatar.displayName = "Avatar"; + +Avatar.propTypes = { + /** Primary content */ + children: PropTypes.node, + + /** + * User name/nickname/full name for displaying initials and image alt description + */ + name: PropTypes.string, + + /** Avatar image source */ + src: PropTypes.string, + + /** Size */ + size: PropTypes.oneOf(SizeEnum), + + /** Status. */ + status: PropTypes.oneOf(StatusEnum), + + /** Active */ + active: PropTypes.bool, + + /** Additional classes. */ + className: PropTypes.string, +}; + +AvatarInner.propTypes = Avatar.propTypes; + +AvatarInner.defaultProps = { + name: "", + src: "", + size: "md", + active: false, +}; + +Avatar.defaultProps = AvatarInner.defaultProps; + +export { Avatar }; +export default Avatar; diff --git a/src/components/Avatar/index.js b/src/components/Avatar/index.js new file mode 100644 index 0000000..9c73b19 --- /dev/null +++ b/src/components/Avatar/index.js @@ -0,0 +1,3 @@ +import Avatar from "./Avatar"; +export * from "./Avatar"; +export default Avatar; diff --git a/src/components/AvatarGroup/AvatarGroup.jsx b/src/components/AvatarGroup/AvatarGroup.jsx new file mode 100644 index 0000000..7a8c3cd --- /dev/null +++ b/src/components/AvatarGroup/AvatarGroup.jsx @@ -0,0 +1,84 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { allowedChildren } from "../utils"; +import { prefix } from "../settings"; +import classNames from "classnames"; +import Avatar from "../Avatar"; + +export const AvatarGroup = ({ + children, + size, + className, + max, + activeIndex, + hoverToFront, + ...rest +}) => { + const cName = `${prefix}-avatar-group`; + + // Reverse because of css + const avatars = + typeof max === "number" && React.Children.count(children) > max + ? React.Children.toArray(children).reverse().slice(0, max) + : React.Children.toArray(children).reverse(); + const reversedActiveIndex = + typeof activeIndex === "number" + ? avatars.length - activeIndex - 1 + : undefined; + + return ( +
+ {avatars.map((a, i) => { + const newProps = + typeof reversedActiveIndex === "number" + ? { active: reversedActiveIndex === i } + : {}; + + if (hoverToFront === true) { + newProps.className = classNames( + `${prefix}-avatar--active-on-hover`, + a.props.className + ); + } + + return React.cloneElement(a, newProps); + })} +
+ ); +}; + +AvatarGroup.propTypes = { + /** + * Primary content. + * Allowed node: + * + * * <Avatar /> + */ + children: allowedChildren([Avatar]), + + /** Additional classes. */ + className: PropTypes.string, + + /** Maximum stacked children */ + max: PropTypes.number, + + /** Size */ + size: PropTypes.oneOf(["xs", "sm", "md", "lg", "fluid"]), + + /** Active index. + * Active element has higher z-index independent of its order. + */ + activeIndex: PropTypes.number, + + /** Bring to front on hover */ + hoverToFront: PropTypes.bool, +}; + +AvatarGroup.defaultProps = { + size: "md", +}; + +export default AvatarGroup; diff --git a/src/components/AvatarGroup/index.js b/src/components/AvatarGroup/index.js new file mode 100644 index 0000000..c864ea3 --- /dev/null +++ b/src/components/AvatarGroup/index.js @@ -0,0 +1,3 @@ +import AvatarGroup from "./AvatarGroup"; +export * from "./AvatarGroup"; +export default AvatarGroup; diff --git a/src/components/Buttons/AddUserButton.jsx b/src/components/Buttons/AddUserButton.jsx new file mode 100644 index 0000000..d6510fe --- /dev/null +++ b/src/components/Buttons/AddUserButton.jsx @@ -0,0 +1,37 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import Button from "./Button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faUserPlus } from "@fortawesome/free-solid-svg-icons"; + +export const AddUserButton = ({ className, children, ...rest }) => { + const cName = `${prefix}-button--adduser`; + + return ( + + ); +}; + +AddUserButton.propTypes = { + /** + * Primary content. + */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +AddUserButton.defaultProps = { + className: "", +}; + +export default AddUserButton; diff --git a/src/components/Buttons/ArrowButton.jsx b/src/components/Buttons/ArrowButton.jsx new file mode 100644 index 0000000..c2fd4f1 --- /dev/null +++ b/src/components/Buttons/ArrowButton.jsx @@ -0,0 +1,57 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import Button from "./Button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faArrowUp, + faArrowRight, + faArrowDown, + faArrowLeft, +} from "@fortawesome/free-solid-svg-icons"; + +export const ArrowButton = ({ className, direction, children, ...rest }) => { + const cName = `${prefix}-button--arrow`; + + const icon = (() => { + if (direction === "up") { + return faArrowUp; + } else if (direction === "right") { + return faArrowRight; + } else if (direction === "down") { + return faArrowDown; + } else if (direction === "left") { + return faArrowLeft; + } + })(); + + return ( + + ); +}; + +ArrowButton.propTypes = { + /** + * Primary content. + */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, + + direction: PropTypes.oneOf(["up", "right", "down", "left"]), +}; + +ArrowButton.defaultProps = { + className: "", + direction: "right", +}; + +export default ArrowButton; diff --git a/src/components/Buttons/AttachmentButton.jsx b/src/components/Buttons/AttachmentButton.jsx new file mode 100644 index 0000000..1891fbc --- /dev/null +++ b/src/components/Buttons/AttachmentButton.jsx @@ -0,0 +1,35 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import Button from "./Button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPaperclip } from "@fortawesome/free-solid-svg-icons"; + +export const AttachmentButton = ({ className, children, ...rest }) => { + const cName = `${prefix}-button--attachment`; + + return ( + + ); +}; + +AttachmentButton.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +AttachmentButton.defaultProps = { + className: "", +}; + +export default AttachmentButton; diff --git a/src/components/Buttons/Button.jsx b/src/components/Buttons/Button.jsx new file mode 100644 index 0000000..71bfa13 --- /dev/null +++ b/src/components/Buttons/Button.jsx @@ -0,0 +1,55 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; + +export const Button = ({ + children, + className, + icon, + border, + labelPosition, + ...rest +}) => { + const cName = `${prefix}-button`; + + const lPos = typeof labelPosition !== "undefined" ? labelPosition : "right"; + const labelPositionClassName = + React.Children.count(children) > 0 ? `${cName}--${lPos}` : ""; + const borderClassName = border === true ? `${cName}--border` : ""; + return ( + + ); +}; + +Button.propTypes = { + /** Primary content */ + children: PropTypes.node, + /** Additional classes. */ + className: PropTypes.string, + icon: PropTypes.node, + labelPosition: PropTypes.oneOf(["left", "right"]), + border: PropTypes.bool, +}; + +Button.defaultProps = { + children: undefined, + className: "", + icon: undefined, + labelPosition: undefined, + border: false, +}; + +export default Button; diff --git a/src/components/Buttons/EllipsisButton.jsx b/src/components/Buttons/EllipsisButton.jsx new file mode 100644 index 0000000..08577f2 --- /dev/null +++ b/src/components/Buttons/EllipsisButton.jsx @@ -0,0 +1,45 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import Button from "./Button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEllipsisV, faEllipsisH } from "@fortawesome/free-solid-svg-icons"; + +export const EllipsisButton = ({ + className, + orientation, + children, + ...rest +}) => { + const cName = `${prefix}-button--ellipsis`; + + const icon = orientation === "vertical" ? faEllipsisV : faEllipsisH; + + return ( + + ); +}; + +EllipsisButton.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, + + orientation: PropTypes.oneOf(["horizontal", "vertical"]), +}; + +EllipsisButton.defaultProps = { + className: "", + orientation: "horizontal", +}; + +export default EllipsisButton; diff --git a/src/components/Buttons/InfoButton.jsx b/src/components/Buttons/InfoButton.jsx new file mode 100644 index 0000000..eadaa7b --- /dev/null +++ b/src/components/Buttons/InfoButton.jsx @@ -0,0 +1,35 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import Button from "./Button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; + +export const InfoButton = ({ className, children, ...rest }) => { + const cName = `${prefix}-button--info`; + + return ( + + ); +}; + +InfoButton.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +InfoButton.defaultProps = { + className: "", +}; + +export default InfoButton; diff --git a/src/components/Buttons/SendButton.jsx b/src/components/Buttons/SendButton.jsx new file mode 100644 index 0000000..ae8051f --- /dev/null +++ b/src/components/Buttons/SendButton.jsx @@ -0,0 +1,35 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import Button from "./Button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPaperPlane } from "@fortawesome/free-solid-svg-icons"; + +export const SendButton = ({ className, children, ...rest }) => { + const cName = `${prefix}-button--send`; + + return ( + + ); +}; + +SendButton.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +SendButton.defaultProps = { + className: "", +}; + +export default SendButton; diff --git a/src/components/Buttons/StarButton.jsx b/src/components/Buttons/StarButton.jsx new file mode 100644 index 0000000..33fc440 --- /dev/null +++ b/src/components/Buttons/StarButton.jsx @@ -0,0 +1,35 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import Button from "./Button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faStar } from "@fortawesome/free-solid-svg-icons"; + +export const StarButton = ({ className, children, ...rest }) => { + const cName = `${prefix}-button--star`; + + return ( + + ); +}; + +StarButton.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +StarButton.defaultProps = { + className: "", +}; + +export default StarButton; diff --git a/src/components/Buttons/VideoCallButton.jsx b/src/components/Buttons/VideoCallButton.jsx new file mode 100644 index 0000000..ca826c5 --- /dev/null +++ b/src/components/Buttons/VideoCallButton.jsx @@ -0,0 +1,35 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import Button from "./Button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faVideo } from "@fortawesome/free-solid-svg-icons"; + +export const VideoCallButton = ({ className, children, ...rest }) => { + const cName = `${prefix}-button--videocall`; + + return ( + + ); +}; + +VideoCallButton.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +VideoCallButton.defaultProps = { + className: "", +}; + +export default VideoCallButton; diff --git a/src/components/Buttons/VoiceCallButton.jsx b/src/components/Buttons/VoiceCallButton.jsx new file mode 100644 index 0000000..27f4546 --- /dev/null +++ b/src/components/Buttons/VoiceCallButton.jsx @@ -0,0 +1,35 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import Button from "./Button"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPhoneAlt } from "@fortawesome/free-solid-svg-icons"; + +export const VoiceCallButton = ({ className, children, ...rest }) => { + const cName = `${prefix}-button--voicecall`; + + return ( + + ); +}; + +VoiceCallButton.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +VoiceCallButton.defaultProps = { + className: "", +}; + +export default VoiceCallButton; diff --git a/src/components/Buttons/index.js b/src/components/Buttons/index.js new file mode 100644 index 0000000..f3c63ac --- /dev/null +++ b/src/components/Buttons/index.js @@ -0,0 +1,33 @@ +import Button from "./Button"; +import ArrowButton from "./ArrowButton"; +import InfoButton from "./InfoButton"; +import VoiceCallButton from "./VoiceCallButton"; +import VideoCallButton from "./VideoCallButton"; +import StarButton from "./StarButton"; +import AddUserButton from "./AddUserButton"; +import EllipsisButton from "./EllipsisButton"; +import SendButton from "./SendButton"; +import AttachmentButton from "./AttachmentButton"; + +export * from "./Button"; +export * from "./ArrowButton"; +export * from "./InfoButton"; +export * from "./VoiceCallButton"; +export * from "./VideoCallButton"; +export * from "./StarButton"; +export * from "./EllipsisButton"; +export * from "./SendButton"; +export * from "./AttachmentButton"; + +export default { + Button, + ArrowButton, + InfoButton, + VoiceCallButton, + VideoCallButton, + StarButton, + AddUserButton, + EllipsisButton, + SendButton, + AttachmentButton, +}; diff --git a/src/components/ChatContainer/ChatContainer.jsx b/src/components/ChatContainer/ChatContainer.jsx new file mode 100644 index 0000000..ad527a3 --- /dev/null +++ b/src/components/ChatContainer/ChatContainer.jsx @@ -0,0 +1,61 @@ +import React from "react"; +import { allowedChildren, getChildren } from "../utils"; +import ConversationHeader from "../ConversationHeader"; +import MessageList from "../MessageList"; +import MessageInput from "../MessageInput"; +import InputToolbox from "../InputToolbox"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import PropTypes from "prop-types"; + +export const ChatContainer = ({ children, className, ...rest }) => { + const cName = `${prefix}-chat-container`; + + const [ + header, + messageList, + messageInput, + inputToolbox, + ] = getChildren(children, [ + ConversationHeader, + MessageList, + MessageInput, + InputToolbox, + ]); + + return ( +
+ {header} + {messageList} + {messageInput} + {inputToolbox} +
+ ); +}; + +ChatContainer.propTypes = { + /** + * Primary content. + * Allowed elements: + * + * * <ConversationHeader /> + * * <MessageList /> + * * <MessageInput /> + * * <InputToolbox /> + */ + children: allowedChildren([ + ConversationHeader, + MessageList, + MessageInput, + InputToolbox, + ]), + + /** Additional classes. */ + className: PropTypes.string, +}; + +ChatContainer.defaultProps = { + children: undefined, +}; + +export default ChatContainer; diff --git a/src/components/ChatContainer/index.js b/src/components/ChatContainer/index.js new file mode 100644 index 0000000..0f7b6c1 --- /dev/null +++ b/src/components/ChatContainer/index.js @@ -0,0 +1,3 @@ +import ChatContainer from "./ChatContainer"; +export * from "./ChatContainer"; +export default ChatContainer; diff --git a/src/components/ContentEditable/ContentEditable.jsx b/src/components/ContentEditable/ContentEditable.jsx new file mode 100644 index 0000000..6269a42 --- /dev/null +++ b/src/components/ContentEditable/ContentEditable.jsx @@ -0,0 +1,180 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +const replaceCaret = (el, activateAfterChange) => { + const isTargetFocused = document.activeElement === el; + + // Place the caret at the end of the element + const target = document.createTextNode(""); + + // Put empty text node at the end of input + el.appendChild(target); + + // do not move caret if element was not focused + if ( + target !== null && + target.nodeValue !== null && + (isTargetFocused || activateAfterChange) + ) { + const sel = window.getSelection(); + if (sel !== null) { + const range = document.createRange(); + range.setStart(target, target.nodeValue.length); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + } +}; + +export class ContentEditable extends Component { + constructor(props) { + super(props); + this.msgRef = React.createRef(); + } + + innerHTML = () => { + const { + props: { value }, + } = this; + + return { + __html: typeof value !== "undefined" ? value : "", + }; + }; + + handleKeyDown = (evt) => { + const { + props: { onKeyDown }, + } = this; + onKeyDown(evt); + }; + + handleInput = (evt) => { + const { + props: { onChange }, + } = this; + + const target = evt.target; + onChange(target.innerHTML, target.textContent, target.innerText); + }; + + // Public API + focus() { + if (typeof this.msgRef.current !== "undefined") { + this.msgRef.current.focus(); + } + } + + componentDidMount() { + if (this.props.autoFocus === true) { + this.msgRef.current.focus(); + } + } + + shouldComponentUpdate(nextProps) { + const { + msgRef, + props: { placeholder, disabled, activateAfterChange }, + } = this; + + if (typeof msgRef.current === "undefined") { + return true; + } + + if (nextProps.value !== msgRef.current.innerHTML) { + return true; + } + + // DO NOT place callbacks here in comparison! + return ( + placeholder !== nextProps.placeholder || + disabled !== nextProps.disabled || + activateAfterChange !== nextProps.activateAfterChange + ); + } + + componentDidUpdate() { + const { + msgRef, + props: { value, activateAfterChange }, + } = this; + + if (value !== msgRef.current.innerHTML) { + msgRef.current.innerHTML = typeof value === "string" ? value : ""; + } + + replaceCaret(msgRef.current, activateAfterChange); + } + + render() { + const { + msgRef, + handleInput, + handleKeyDown, + innerHTML, + props: { placeholder, disabled, className }, + } = this, + ph = typeof placeholder === "string" ? placeholder : ""; + + return ( +
+ ); + } +} + +ContentEditable.propTypes = { + /** Value. */ + value: PropTypes.string, + + /** Placeholder. */ + placeholder: PropTypes.string, + + /** A input can show it is currently unable to be interacted with. */ + disabled: PropTypes.bool, + + /** + * Sets focus element and caret at the end of input + * when value is changed programmatically (e.g) from button click and element is not active + */ + activateAfterChange: PropTypes.bool, + + /** Set focus after mount. */ + autoFocus: PropTypes.bool, + + /** + * onChange handler
+ * @param {String} value + */ + onChange: PropTypes.func, + + /** + * onKeyDown handler
+ * @param {String} value + */ + onKeyDown: PropTypes.func, + + /** Additional classes. */ + className: PropTypes.string, +}; + +ContentEditable.defaultProps = { + value: undefined, + placeholder: "", + disabled: false, + activateAfterChange: false, + autoFocus: false, + onChange: () => {}, + onKeyDown: () => {}, +}; + +export default ContentEditable; diff --git a/src/components/ContentEditable/index.js b/src/components/ContentEditable/index.js new file mode 100644 index 0000000..87f2742 --- /dev/null +++ b/src/components/ContentEditable/index.js @@ -0,0 +1,3 @@ +import ContentEditable from "./ContentEditable"; +export * from "./ContentEditable"; +export default ContentEditable; diff --git a/src/components/Conversation/Conversation.jsx b/src/components/Conversation/Conversation.jsx new file mode 100644 index 0000000..efeab98 --- /dev/null +++ b/src/components/Conversation/Conversation.jsx @@ -0,0 +1,134 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { allowedChildren, getChildren } from "../utils"; +import classNames from "classnames"; +import cName from "./cName"; +import ConversationOperations from "./ConversationOperations"; +import ConversationContent from "./ConversationConent"; +import Avatar from "../Avatar"; +import AvatarGroup from "../AvatarGroup"; + +const LastActivityTime = ({ time }) => ( +
+ {time} +
+); + +const UnreadDot = () =>
; + +export const Conversation = ({ + name, + unreadCnt, + lastSenderName, + info, + lastActivityTime, + unreadDot, + children, + className, + active, + ...rest +}) => { + const [avatar, avatarGroup, operations, content] = getChildren(children, [ + Avatar, + AvatarGroup, + ConversationOperations, + ConversationContent, + ]); + + return ( +
+ {avatar} + {avatarGroup} + + {(typeof name !== "undefined" || + typeof lastSenderName !== "undefined" || + typeof info !== "undefined") && ( + + )} + + {(typeof name === "undefined" || name === null) && + (typeof lastSenderName === "undefined" || lastSenderName === null) && + (typeof info === "undefined" || info === null) && + content} + + {lastActivityTime !== null && typeof lastActivityTime !== "undefined" && ( + + )} + + {unreadDot && } + + {operations} + {unreadCnt !== null && + typeof unreadCnt !== "undefined" && + parseInt(unreadCnt) > 0 && ( +
+ {unreadCnt} +
+ )} +
+ ); +}; + +Conversation.propTypes = { + /** + * Primary content. + * Allowed node: + * + * * <Avatar /> + * * <AvatarGroup /> + * * <Conversation.Content /> + * * <Conversation.Operations /> + */ + children: allowedChildren([ + Avatar, + AvatarGroup, + ConversationOperations, + ConversationContent, + ]), + + /** First text line in <Conversation.Content /> contact name etc. */ + name: PropTypes.node, + + /** Unread messages quantity. */ + unreadCnt: PropTypes.number, + + /** Unread dot visible. */ + unreadDot: PropTypes.bool, + + /** Last sender in <Conversation.Content /> name. */ + lastSenderName: PropTypes.node, + + /** Informational message / last message in <Conversation.Content />. */ + info: PropTypes.node, + + /** Last activity time. */ + lastActivityTime: PropTypes.string, + + /** Active (currently viewed) */ + active: PropTypes.bool, + + /** Additional classes. */ + className: PropTypes.string, +}; + +Conversation.defaultProps = { + name: undefined, + unreadCnt: undefined, + unreadDot: false, + lastSenderName: undefined, + info: undefined, + lastActivityTime: undefined, + active: false, +}; + +Conversation.Operations = ConversationOperations; +Conversation.Content = ConversationContent; + +export default Conversation; diff --git a/src/components/Conversation/ConversationConent.jsx b/src/components/Conversation/ConversationConent.jsx new file mode 100644 index 0000000..1ed0ced --- /dev/null +++ b/src/components/Conversation/ConversationConent.jsx @@ -0,0 +1,71 @@ +import React from "react"; +import cName from "./cName"; +import classNames from "classnames"; +import PropTypes from "prop-types"; + +const LastSenderName = ({ name }) => ( + <> +
{name}
+ {":"} + +); + +LastSenderName.propTypes = { + name: PropTypes.node, +}; + +const InfoContent = ({ info }) => ( +
{info}
+); + +InfoContent.propTypes = { + info: PropTypes.node, +}; + +export const ConversationContent = ({ + lastSenderName, + info, + name, + children, + className, + ...rest +}) => ( +
+ {React.Children.count(children) > 0 ? ( + children + ) : ( + <> +
{name}
+
+ {typeof lastSenderName === "string" && ( + + )}{" "} + {typeof info !== "undefined" && } +
+ + )} +
+); + +ConversationContent.displayName = "Conversation.Content"; + +ConversationContent.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, + + /** First text line - contact name etc. */ + name: PropTypes.node, + + /** Last sender name. */ + lastSenderName: PropTypes.node, + + /** Informational message / last message. */ + info: PropTypes.node, +}; + +ConversationContent.defaultProps = {}; + +export default ConversationContent; diff --git a/src/components/Conversation/ConversationOperations.jsx b/src/components/Conversation/ConversationOperations.jsx new file mode 100644 index 0000000..84348b0 --- /dev/null +++ b/src/components/Conversation/ConversationOperations.jsx @@ -0,0 +1,45 @@ +import React from "react"; +import cName from "./cName"; +import classNames from "classnames"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEllipsisV } from "@fortawesome/free-solid-svg-icons"; +import PropTypes from "prop-types"; + +export const ConversationOperations = ({ + children, + className, + visible, + ...rest +}) => ( +
+ {React.Children.count(children) > 0 ? ( + children + ) : ( + + )} +
+); + +ConversationOperations.displayName = "Conversation.Operations"; + +ConversationOperations.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, + + /** Always visible not only on hover */ + visible: PropTypes.bool, +}; + +ConversationOperations.defaultProps = {}; + +export default ConversationOperations; diff --git a/src/components/Conversation/cName.js b/src/components/Conversation/cName.js new file mode 100644 index 0000000..576ac25 --- /dev/null +++ b/src/components/Conversation/cName.js @@ -0,0 +1,3 @@ +import { prefix } from "../settings"; +export const cName = `${prefix}-conversation`; +export default cName; diff --git a/src/components/Conversation/index.js b/src/components/Conversation/index.js new file mode 100644 index 0000000..0b72b71 --- /dev/null +++ b/src/components/Conversation/index.js @@ -0,0 +1,3 @@ +import Conversation from "./Conversation"; +export * from "./Conversation"; +export default Conversation; diff --git a/src/components/ConversationHeader/ConversationHeader.jsx b/src/components/ConversationHeader/ConversationHeader.jsx new file mode 100644 index 0000000..3a52db7 --- /dev/null +++ b/src/components/ConversationHeader/ConversationHeader.jsx @@ -0,0 +1,66 @@ +import React from "react"; +import { prefix } from "../settings"; +import { allowedChildren, getChildren } from "../utils"; +import classNames from "classnames"; +import Avatar from "../Avatar"; +import AvatarGroup from "../AvatarGroup"; +import ConversationHeaderBack from "./ConversationHeaderBack"; +import ConversationHeaderActions from "./ConversationHeaderActions"; +import ConversationHeaderContent from "./ConversationHeaderContent"; +import PropTypes from "prop-types"; + +export const ConversationHeader = ({ children, className, ...rest }) => { + const cName = `${prefix}-conversation-header`; + + const [back, avatar, avatarGroup, content, actions] = getChildren(children, [ + ConversationHeaderBack, + Avatar, + AvatarGroup, + ConversationHeaderContent, + ConversationHeaderActions, + ]); + + return ( +
+ {back} + {avatar &&
{avatar}
} + {!avatar && avatarGroup && ( +
{avatarGroup}
+ )} + {content} + {actions} +
+ ); +}; + +ConversationHeader.propTypes = { + /** + * Primary content. + * Available elements: + * + * * <Avatar /> + * * <AvatarGroup /> + * * <ConversationHeader.Back /> + * * <ConversationHeader.Content /> + * * <ConversationHeader.Actions /> + */ + children: allowedChildren([ + ConversationHeaderBack, + Avatar, + AvatarGroup, + ConversationHeaderContent, + ConversationHeaderActions, + ]), + + /** Additional classes. */ + className: PropTypes.string, +}; + +ConversationHeader.defaultProps = { + children: undefined, +}; + +ConversationHeader.Back = ConversationHeaderBack; +ConversationHeader.Actions = ConversationHeaderActions; +ConversationHeader.Content = ConversationHeaderContent; +export default ConversationHeader; diff --git a/src/components/ConversationHeader/ConversationHeaderActions.jsx b/src/components/ConversationHeader/ConversationHeaderActions.jsx new file mode 100644 index 0000000..392dfb8 --- /dev/null +++ b/src/components/ConversationHeader/ConversationHeaderActions.jsx @@ -0,0 +1,30 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { prefix } from "../settings"; +import classNames from "classnames"; + +export const ConversationHeaderActions = ({ children, className, ...rest }) => { + const cName = `${prefix}-conversation-header__actions`; + + return ( +
+ {children} +
+ ); +}; + +ConversationHeaderActions.displayName = "ConversationHeader.Actions"; + +ConversationHeaderActions.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +ConversationHeaderActions.defaultProps = { + children: undefined, +}; + +export default ConversationHeaderActions; diff --git a/src/components/ConversationHeader/ConversationHeaderBack.jsx b/src/components/ConversationHeader/ConversationHeaderBack.jsx new file mode 100644 index 0000000..7724ec1 --- /dev/null +++ b/src/components/ConversationHeader/ConversationHeaderBack.jsx @@ -0,0 +1,44 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { prefix } from "../settings"; +import classNames from "classnames"; +import { ArrowButton } from "../Buttons"; + +export const ConversationHeaderBack = ({ + onClick, + children, + className, + ...rest +}) => { + const cName = `${prefix}-conversation-header__back`; + + return ( +
+ {typeof children !== "undefined" ? ( + children + ) : ( + + )} +
+ ); +}; + +ConversationHeaderBack.displayName = "ConversationHeader.Back"; + +ConversationHeaderBack.propTypes = { + /** OnClick handler attached to button. */ + onClick: PropTypes.func, + + /** Primary content - override default button. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +ConversationHeaderBack.defaultProps = { + children: undefined, + onClick: () => {}, +}; + +export default ConversationHeaderBack; diff --git a/src/components/ConversationHeader/ConversationHeaderContent.jsx b/src/components/ConversationHeader/ConversationHeaderContent.jsx new file mode 100644 index 0000000..8403388 --- /dev/null +++ b/src/components/ConversationHeader/ConversationHeaderContent.jsx @@ -0,0 +1,49 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; + +export const ConversationHeaderContent = ({ + userName, + info, + children, + className, + ...rest +}) => { + const cName = `${prefix}-conversation-header__content`; + + return ( +
+ {typeof children !== "undefined" ? ( + children + ) : ( + <> +
+ {userName} +
+
{info}
+ + )} +
+ ); +}; + +ConversationHeaderContent.displayName = "ConversationHeader.Content"; + +ConversationHeaderContent.propTypes = { + /** Primary content. Has precedence over userName and info properties. */ + children: PropTypes.node, + userName: PropTypes.node, + info: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +ConversationHeaderContent.defaultProps = { + children: undefined, + userName: "", + info: "", +}; + +export default ConversationHeaderContent; diff --git a/src/components/ConversationHeader/index.js b/src/components/ConversationHeader/index.js new file mode 100644 index 0000000..f8302a1 --- /dev/null +++ b/src/components/ConversationHeader/index.js @@ -0,0 +1,3 @@ +import ConversationHeader from "./ConversationHeader"; +export * from "./ConversationHeader"; +export default ConversationHeader; diff --git a/src/components/ConversationList/ConversationList.jsx b/src/components/ConversationList/ConversationList.jsx new file mode 100644 index 0000000..f584e8b --- /dev/null +++ b/src/components/ConversationList/ConversationList.jsx @@ -0,0 +1,91 @@ +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; +import { allowedChildren } from "../utils"; +import { prefix } from "../settings"; +import PerfectScrollbar from "react-perfect-scrollbar"; +import classNames from "classnames"; +import Overlay from "../Overlay"; +import Loader from "../Loader"; +import Conversation from "../Conversation"; + +export const ConversationList = ({ + children, + scrollable, + loading, + className, + ...props +}) => { + const cName = `${prefix}-conversation-list`; + + // Memoize, to avoid re-render each time when props (children) changed + const Tag = useMemo( + () => ({ children, className, ...rest }) => { + // PerfectScrollbar for now cant be disabled, so render div instead of disabling it + // https://github.com/goldenyz/react-perfect-scrollbar/issues/107 + if (scrollable === false || (scrollable === true && loading === true)) { + return ( +
+ {loading && ( + + + + )} + {children} +
+ ); + } else { + return ( + + {children} + + ); + } + }, + [scrollable, loading] + ); + + return ( + + {React.Children.count(children) > 0 && ( + + )} + + ); +}; + +ConversationList.propTypes = { + /** + * Primary content. + * Allowed components: + * + * * <Conversation /> + * + */ + children: allowedChildren([Conversation]), + + /** Init scrollbar flag. */ + scrollable: PropTypes.bool, + + /** Loading flag. */ + loading: PropTypes.bool, + + /** Additional classes. */ + className: PropTypes.string, +}; + +ConversationList.defaultProps = { + children: [], + scrollable: true, + loading: false, + className: "", +}; + +export default ConversationList; diff --git a/src/components/ConversationList/index.js b/src/components/ConversationList/index.js new file mode 100644 index 0000000..71c469a --- /dev/null +++ b/src/components/ConversationList/index.js @@ -0,0 +1,3 @@ +import ConversationList from "./ConversationList"; +export * from "./ConversationList"; +export default ConversationList; diff --git a/src/components/ExpansionPanel/ExpansionPanel.jsx b/src/components/ExpansionPanel/ExpansionPanel.jsx new file mode 100644 index 0000000..c8e4c35 --- /dev/null +++ b/src/components/ExpansionPanel/ExpansionPanel.jsx @@ -0,0 +1,58 @@ +import React, { useState } from "react"; +import PropTypes from "prop-types"; +import { prefix } from "../settings"; +import classNames from "classnames"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; + +export const ExpansionPanel = ({ + children, + title, + open: defaultOpen, + className, + ...rest +}) => { + const cName = `${prefix}-expansion-panel`; + + const defaultOpenFlag = defaultOpen === true ? defaultOpen : false; + + const [open, setOpen] = useState(defaultOpenFlag); + + const openModifier = open === true ? `${cName}--open` : ""; + const icon = open === true ? faChevronDown : faChevronLeft; + + return ( +
+
setOpen(!open)}> +
{title}
+
+ +
+
+
{children}
+
+ ); +}; + +ExpansionPanel.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Title. */ + title: PropTypes.string, + + /** Default open state. */ + open: PropTypes.bool, + + /** Additional classes. */ + className: PropTypes.string, +}; + +ExpansionPanel.defaultProps = { + children: undefined, + title: "", + open: false, +}; + +export default ExpansionPanel; diff --git a/src/components/ExpansionPanel/index.js b/src/components/ExpansionPanel/index.js new file mode 100644 index 0000000..6c63dfb --- /dev/null +++ b/src/components/ExpansionPanel/index.js @@ -0,0 +1,3 @@ +import ExpansionPanel from "./ExpansionPanel"; +export * from "./ExpansionPanel"; +export default ExpansionPanel; diff --git a/src/components/InputToolbox/InputToolbox.jsx b/src/components/InputToolbox/InputToolbox.jsx new file mode 100644 index 0000000..1dcf3f4 --- /dev/null +++ b/src/components/InputToolbox/InputToolbox.jsx @@ -0,0 +1,26 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; + +export const InputToolbox = ({ className, children, ...rest }) => { + const cName = `${prefix}-input-toolbox`; + + return ( +
+ {children} +
+ ); +}; + +InputToolbox.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +InputToolbox.defaultProps = {}; + +export default InputToolbox; diff --git a/src/components/InputToolbox/index.js b/src/components/InputToolbox/index.js new file mode 100644 index 0000000..132dada --- /dev/null +++ b/src/components/InputToolbox/index.js @@ -0,0 +1,3 @@ +import InputToolbox from "./InputToolbox"; +export * from "./InputToolbox"; +export default InputToolbox; diff --git a/src/components/Loader/Loader.jsx b/src/components/Loader/Loader.jsx new file mode 100644 index 0000000..6481d41 --- /dev/null +++ b/src/components/Loader/Loader.jsx @@ -0,0 +1,43 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; + +export const Loader = ({ className, variant, children, ...rest }) => { + const cName = `${prefix}-loader`; + const textClass = + React.Children.count(children) > 0 ? `${cName}--content` : ""; + return ( +
+ {children} +
+ ); +}; + +Loader.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, + + /** Loader variant */ + variant: PropTypes.oneOf(["default"]), +}; + +Loader.defaultProps = { + className: undefined, + title: undefined, + variant: "default", +}; + +export default Loader; diff --git a/src/components/Loader/index.js b/src/components/Loader/index.js new file mode 100644 index 0000000..8ce2fb1 --- /dev/null +++ b/src/components/Loader/index.js @@ -0,0 +1,3 @@ +import Loader from "./Loader"; +export * from "./Loader"; +export default Loader; diff --git a/src/components/MainContainer/MainContainer.jsx b/src/components/MainContainer/MainContainer.jsx new file mode 100644 index 0000000..38acbc8 --- /dev/null +++ b/src/components/MainContainer/MainContainer.jsx @@ -0,0 +1,39 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; + +export const MainContainer = ({ responsive, children, className, ...rest }) => { + const cName = `${prefix}-main-container`; + + return ( +
+ {children} +
+ ); +}; + +MainContainer.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Is container responsive. */ + responsive: PropTypes.bool, + + /** Additional classes. */ + className: PropTypes.string, +}; + +MainContainer.defaultProps = { + children: undefined, + responsive: false, +}; + +export default MainContainer; diff --git a/src/components/MainContainer/index.js b/src/components/MainContainer/index.js new file mode 100644 index 0000000..d948abc --- /dev/null +++ b/src/components/MainContainer/index.js @@ -0,0 +1,3 @@ +import MainContainer from "./MainContainer"; +export * from "./MainContainer"; +export default MainContainer; diff --git a/src/components/Message/Message.jsx b/src/components/Message/Message.jsx new file mode 100644 index 0000000..415654f --- /dev/null +++ b/src/components/Message/Message.jsx @@ -0,0 +1,205 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { allowedChildren, getChildren } from "../utils"; +import { prefix } from "../settings"; +import Avatar from "../Avatar"; +import MessageHeader from "./MessageHeader"; +import MessageFooter from "./MessageFooter"; + +/** + * Chat message + */ +export const Message = ({ + model: { message, sentTime, sender, direction, position }, + avatarSpacer, + avatarPosition, + children, + className, + ...rest +}) => { + const cName = `${prefix}-message`; + const createMarkup = () => ({ __html: message }); + + const [avatar, header, footer] = getChildren(children, [ + Avatar, + MessageHeader, + MessageFooter, + ]); + + const directionClass = (() => { + if (direction === 0 || direction === "incoming") { + return `${cName}--incoming`; + } else if (direction === 1 || direction === "outgoing") { + return `${cName}--outgoing`; + } + })(); + + const avatarPositionClass = ((position) => { + const classPrefix = `${cName}--avatar-`; + if (position === 0 || position === "top-left" || position === "tl") { + return `${classPrefix}tl`; + } else if ( + position === 1 || + position === "top-right" || + position === "tr" + ) { + return `${classPrefix}tr`; + } else if ( + position === 2 || + position === "bottom-right" || + position === "br" + ) { + return `${classPrefix}br`; + } else if ( + position === 3 || + position === "bottom-left" || + position === "bl" + ) { + return `${classPrefix}bl`; + } else if ( + position === 4 || + position === "center-left" || + position === "cl" + ) { + return `${classPrefix}cl`; + } else if ( + position === 5 || + position === "center-right" || + position === "cr" + ) { + return `${classPrefix}cr`; + } + })(avatarPosition); + + const positionClass = ((position) => { + const classPrefix = `${prefix}-message--`; + if (position === "single" || position === 0) { + return `${classPrefix}single`; + } else if (position === "first" || position === 1) { + return `${classPrefix}first`; + } else if (position === "normal" || position === 2) { + return ""; + } else if (position === "last" || position === 3) { + return `${classPrefix}last`; + } + })(position); + + const ariaLabel = (() => { + if (sender?.length > 0 && sentTime?.length > 0) { + return `${sender}: ${sentTime}`; + } else if ( + sender?.length > 0 && + (typeof sentTime === "undefined" || sentTime?.length === 0) + ) { + return sender; + } else { + return null; + } + })(); + + return ( +
+ {typeof avatar !== "undefined" && ( +
{avatar}
+ )} +
+ {header} +
+ {footer} +
+
+ ); +}; + +Message.propTypes = { + /** + * Model object + * **message:** string - Message to send + * **sentTime**: string - Message sent time + * **sender**: string - Sender name + * **direction**: "incoming" | "outgoing" | 0 | 1 - Message direction + * **position**: "single" | "first" | "normal" | "last" | 0 | 1 | 2 | 3 - Message position in feed + */ + model: PropTypes.shape({ + /** Chat message to display - content. */ + message: PropTypes.string, + sentTime: PropTypes.string, + sender: PropTypes.string, + direction: PropTypes.oneOf(["incoming", "outgoing", 0, 1]), + + /** Position. */ + position: PropTypes.oneOf([ + "single", + "first", + "normal", + "last", + 0, + 1, + 2, + 3, + ]), + }), + avatarSpacer: PropTypes.bool, + avatarPosition: PropTypes.oneOf([ + "tl", + "tr", + "cl", + "cr", + "bl", + "br", + "top-left", + "top-right", + "center-left", + "center-right", + "bottom-left", + "bottom-right", + ]), + + /** + * Primary content. + * Allowed components: + * + * * <Avatar /> + * * <Message.Header /> + * * <Message.Footer /> + */ + children: allowedChildren( + [Avatar, MessageHeader, MessageFooter], + "Allowed types: Message.Header, Message.Footer" + ), + + /** Additional classes. */ + className: PropTypes.string, +}; + +Message.defaultProps = { + model: { + message: "", + sentTime: "", + sender: "", + direction: 1, + }, + avatarSpacer: false, + avatarPosition: undefined, +}; + +Message.Header = MessageHeader; +Message.Footer = MessageFooter; + +export default Message; diff --git a/src/components/Message/MessageFooter.jsx b/src/components/Message/MessageFooter.jsx new file mode 100644 index 0000000..e9a50a3 --- /dev/null +++ b/src/components/Message/MessageFooter.jsx @@ -0,0 +1,49 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; + +import { prefix } from "../settings"; + +export const MessageFooter = ({ + sender, + sentTime, + children, + className, + ...rest +}) => { + const cName = `${prefix}-message__footer`; + + return ( +
+ {typeof children !== "undefined" ? ( + children + ) : ( + <> +
{sender}
+
{sentTime}
+ + )} +
+ ); +}; + +MessageFooter.displayName = "Message.Footer"; + +MessageFooter.propTypes = { + sender: PropTypes.string, + sentTime: PropTypes.string, + + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +MessageFooter.defaultProps = { + sender: "", + sentTime: "", + children: undefined, +}; + +export default MessageFooter; diff --git a/src/components/Message/MessageHeader.jsx b/src/components/Message/MessageHeader.jsx new file mode 100644 index 0000000..af0ab1f --- /dev/null +++ b/src/components/Message/MessageHeader.jsx @@ -0,0 +1,48 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; + +export const MessageHeader = ({ + sender, + sentTime, + children, + className, + ...rest +}) => { + const cName = `${prefix}-message__header`; + + return ( +
+ {typeof children !== "undefined" ? ( + children + ) : ( + <> +
{sender}
+
{sentTime}
+ + )} +
+ ); +}; + +MessageHeader.displayName = "Message.Header"; + +MessageHeader.propTypes = { + sender: PropTypes.string, + sentTime: PropTypes.string, + + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +MessageHeader.defaultProps = { + sender: "", + sentTime: "", + children: undefined, +}; + +export default MessageHeader; diff --git a/src/components/Message/index.js b/src/components/Message/index.js new file mode 100644 index 0000000..63e59b9 --- /dev/null +++ b/src/components/Message/index.js @@ -0,0 +1,3 @@ +import Message from "./Message"; +export * from "./Message"; +export default Message; diff --git a/src/components/MessageGroup/MessageGroup.jsx b/src/components/MessageGroup/MessageGroup.jsx new file mode 100644 index 0000000..878328a --- /dev/null +++ b/src/components/MessageGroup/MessageGroup.jsx @@ -0,0 +1,133 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { allowedChildren, getChildren } from "../utils"; +import { prefix } from "../settings"; +import MessageGroupHeader from "./MessageGroupHeader"; +import MessageGroupFooter from "./MessageGroupFooter"; +import MessageGroupMessages from "./MessageGroupMessages"; +import Avatar from "../Avatar"; + +export const MessageGroup = ({ + direction, + avatarPosition, + sender, + sentTime, + children, + className, + ...rest +}) => { + const cName = `${prefix}-message-group`; + + const directionClass = (() => { + if (direction === 0 || direction === "incoming") { + return `${cName}--incoming`; + } else if (direction === 1 || direction === "outgoing") { + return `${cName}--outgoing`; + } + })(); + + const avatarPositionClass = (() => { + const prefix = `${cName}--avatar-`; + if (typeof avatarPosition === "string") { + if ( + avatarPosition === "tl" || + avatarPosition === "top-left" || + avatarPosition === "tr" || + avatarPosition === "top-right" || + avatarPosition === "bl" || + avatarPosition === "bottom-right" || + avatarPosition === "br" || + avatarPosition === "bottom-right" || + avatarPosition === "cl" || + avatarPosition === "center-left" || + avatarPosition === "cr" || + avatarPosition === "center-right" + ) { + return `${prefix}${avatarPosition}`; + } + } + })(); + + const [avatar, header, footer, messages] = getChildren(children, [ + Avatar, + MessageGroupHeader, + MessageGroupFooter, + MessageGroupMessages, + ]); + + const ariaLabel = (() => { + if (sender.length > 0 && sentTime.length > 0) { + return `${sender}: ${sentTime}`; + } else if (sender.length > 0 && sentTime.length === 0) { + return sender; + } else { + return null; + } + })(); + + return ( +
+ {typeof avatar !== "undefined" && ( +
{avatar}
+ )} +
+ {header} + {messages} + {footer} +
+
+ ); +}; + +MessageGroup.propTypes = { + /** Direction. */ + direction: PropTypes.oneOf(["incoming", "outgoing", 0, 1]), + + /** Avatar position. */ + avatarPosition: PropTypes.oneOf(["tl", "tr", "br", "bl", "cl", "cr"]), + sentTime: PropTypes.string, + sender: PropTypes.string, + /** + * Primary content. + * Allowed nodes: + * + * * <Avatar /> + * * <MessageGroup.Header /> + * * <MessageGroup.Footer /> + * * <MessageGroup.Messages /> + * + */ + children: allowedChildren([ + Avatar, + MessageGroupHeader, + MessageGroupFooter, + MessageGroupMessages, + ]), + + /** Additional classes. */ + className: PropTypes.string, +}; + +MessageGroup.defaultProps = { + direction: "incoming", + sentTime: "", + sender: "", + avatarPosition: undefined, +}; + +MessageGroup.Header = MessageGroupHeader; +MessageGroup.Footer = MessageGroupFooter; +MessageGroup.Messages = MessageGroupMessages; + +export default MessageGroup; diff --git a/src/components/MessageGroup/MessageGroupFooter.jsx b/src/components/MessageGroup/MessageGroupFooter.jsx new file mode 100644 index 0000000..f268f5c --- /dev/null +++ b/src/components/MessageGroup/MessageGroupFooter.jsx @@ -0,0 +1,30 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; + +export const MessageGroupFooter = ({ children, className, ...rest }) => { + const cName = `${prefix}-message-group__footer`; + + return ( +
+ {children} +
+ ); +}; + +MessageGroupFooter.displayName = "MessageGroup.Footer"; + +MessageGroupFooter.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +MessageGroupFooter.defaultProps = { + children: undefined, +}; + +export default MessageGroupFooter; diff --git a/src/components/MessageGroup/MessageGroupHeader.jsx b/src/components/MessageGroup/MessageGroupHeader.jsx new file mode 100644 index 0000000..ce7e377 --- /dev/null +++ b/src/components/MessageGroup/MessageGroupHeader.jsx @@ -0,0 +1,29 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; + +export const MessageGroupHeader = ({ children, className, ...rest }) => { + const cName = `${prefix}-message-group__header`; + return ( +
+ {children} +
+ ); +}; + +MessageGroupHeader.displayName = "MessageGroup.Header"; + +MessageGroupHeader.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +MessageGroupHeader.defaultProps = { + children: undefined, +}; + +export default MessageGroupHeader; diff --git a/src/components/MessageGroup/MessageGroupMessages.jsx b/src/components/MessageGroup/MessageGroupMessages.jsx new file mode 100644 index 0000000..6bc76c9 --- /dev/null +++ b/src/components/MessageGroup/MessageGroupMessages.jsx @@ -0,0 +1,35 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; + +import { prefix } from "../settings"; + +export const MessageGroupMessages = ({ children, className, ...rest }) => { + const cName = `${prefix}-message-group`; + return ( +
+ {children} +
+ ); +}; + +MessageGroupMessages.displayName = "MessageGroup.Messages"; + +MessageGroupMessages.propTypes = { + /** + * Messages. + * Allowed node: + * + * * <Message /> + */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +MessageGroupMessages.defaultProps = { + children: undefined, +}; + +export default MessageGroupMessages; diff --git a/src/components/MessageGroup/index.js b/src/components/MessageGroup/index.js new file mode 100644 index 0000000..268c1c5 --- /dev/null +++ b/src/components/MessageGroup/index.js @@ -0,0 +1,3 @@ +import MessageGroup from "./MessageGroup"; +export * from "./MessageGroup"; +export default MessageGroup; diff --git a/src/components/MessageInput/MessageInput.jsx b/src/components/MessageInput/MessageInput.jsx new file mode 100644 index 0000000..66271e9 --- /dev/null +++ b/src/components/MessageInput/MessageInput.jsx @@ -0,0 +1,292 @@ +import React, { + Component, + useRef, + useState, + useEffect, + useImperativeHandle, + forwardRef, +} from "react"; +import { noop } from "../utils"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import ContentEditable from "../ContentEditable"; +import SendButton from "../Buttons/SendButton"; +import AttachmentButton from "../Buttons/AttachmentButton"; +import PerfectScrollbar from "react-perfect-scrollbar"; + +// Because container depends on fancyScroll +// it must be wrapped in additional container +function editorContainer() { + class Container extends Component { + render() { + const { + props: { fancyScroll, children, forwardedRef, ...rest }, + } = this; + + return ( + <> + {fancyScroll === true && ( + (forwardedRef.current = elRef)} + {...rest} + options={{ suppressScrollX: true }} + > + {children} + + )} + {fancyScroll === false && ( +
+ {children} +
+ )} + + ); + } + } + + return React.forwardRef((props, ref) => { + return ; + }); +} + +const EditorContainer = editorContainer(); + +const useControllableState = (value, initialValue) => { + const initial = typeof value !== "undefined" ? value : initialValue; + const [stateValue, setStateValue] = useState(initial); + const effectiveValue = typeof value !== "undefined" ? value : stateValue; + + return [ + effectiveValue, + (newValue) => { + setStateValue(newValue); + }, + ]; +}; + +function MessageInputInner( + { + value, + onSend, + onChange, + autoFocus, + placeholder, + fancyScroll, + className, + activateAfterChange, + disabled, + sendDisabled, + attachDisabled, + sendButton, + attachButton, + onAttachClick, + ...rest + }, + ref +) { + const scrollRef = useRef(); + const msgRef = useRef(); + const [stateValue, setStateValue] = useControllableState(value, ""); + const [stateSendDisabled, setStateSendDisabled] = useControllableState( + sendDisabled, + true + ); + + // Public API + const focus = () => { + if (typeof msgRef.current !== "undefined") { + msgRef.current.focus(); + } + }; + + // Return object with public Api + useImperativeHandle(ref, () => ({ + focus, + })); + + // Set focus + useEffect(() => { + if (autoFocus === true) { + focus(); + } + }, []); + + // Update scroll + useEffect(() => { + if (typeof scrollRef.current.updateScroll === "function") { + scrollRef.current.updateScroll(); + } + }); + + const send = () => { + if (stateValue.length > 0) { + // Clear input only when it's uncontrolled mode + if (value === undefined) { + setStateValue(""); + } + + // Disable send button only when it's uncontrolled mode + if (typeof sendDisabled === "undefined") { + setStateSendDisabled(true); + } + + onSend(stateValue); + } + }; + + const handleKeyDown = (evt) => { + if (evt.key === "Enter" && evt.shiftKey === false) { + evt.preventDefault(); + send(); + } + }; + + const handleChange = (val, textContent, innerText) => { + setStateValue(val); + if (typeof sendDisabled === "undefined") { + setStateSendDisabled(textContent.length === 0); + } + + if (typeof scrollRef.current.updateScroll === "function") { + scrollRef.current.updateScroll(); + } + + onChange(val); + }; + + const cName = `${prefix}-message-input`, + ph = typeof placeholder === "string" ? placeholder : ""; + + return ( +
+ {attachButton === true && ( +
+ +
+ )} + +
+ + + +
+ {sendButton === true && ( +
+ +
+ )} +
+ ); +} + +const MessageInput = forwardRef(MessageInputInner); +MessageInput.displayName = "MessageInput"; + +MessageInput.propTypes = { + /** Value. */ + value: PropTypes.string, + + /** Placeholder. */ + placeholder: PropTypes.string, + + /** A input can show it is currently unable to be interacted with. */ + disabled: PropTypes.bool, + + /** Send button can be disabled.
+ * It's state is tracked by component, but it can be forced */ + sendDisabled: PropTypes.bool, + + /** + * Fancy scroll + * This property is set in contructor, and is not changing when component update. + */ + fancyScroll: PropTypes.bool, + + /** + * Sets focus element and caret at the end of input
+ * when value is changed programmatically (e.g) from button click and element is not active + */ + activateAfterChange: PropTypes.bool, + + /** Set focus after mount. */ + autoFocus: PropTypes.bool, + + /** + * onChange handler
+ * @param {String} value + */ + onChange: PropTypes.func, + + /** + * onSend handler
+ * @param {String} value + */ + onSend: PropTypes.func, + + /** Additional classes. */ + className: PropTypes.string, + + /** Show send button */ + sendButton: PropTypes.bool, + + /** Show add attachment button */ + attachButton: PropTypes.bool, + + /** Disable add attachment button */ + attachDisabled: PropTypes.bool, + + /** + * onAttachClick handler + */ + onAttachClick: PropTypes.func, +}; + +MessageInputInner.propTypes = MessageInput.propTypes; + +MessageInput.defaultProps = { + value: undefined, + placeholder: "", + disabled: false, + fancyScroll: true, + activateAfterChange: false, + autoFocus: false, + sendButton: true, + attachButton: true, + attachDisabled: false, + onAttachClick: noop, + onChange: noop, + onSend: noop, +}; + +MessageInputInner.defaultProps = MessageInput.defaultProps; + +export { MessageInput }; + +export default MessageInput; diff --git a/src/components/MessageInput/index.js b/src/components/MessageInput/index.js new file mode 100644 index 0000000..f37fcf5 --- /dev/null +++ b/src/components/MessageInput/index.js @@ -0,0 +1,3 @@ +import MessageInput from "./MessageInput"; +export * from "./MessageInput"; +export default MessageInput; diff --git a/src/components/MessageList/MessageList.jsx b/src/components/MessageList/MessageList.jsx new file mode 100644 index 0000000..f2c70ba --- /dev/null +++ b/src/components/MessageList/MessageList.jsx @@ -0,0 +1,217 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { allowedChildren, getChildren } from "../utils"; +import { prefix } from "../settings"; +import PerfectScrollbar from "react-perfect-scrollbar"; +import Loader from "../Loader"; +import Overlay from "../Overlay"; +import Message from "../Message"; +import MessageGroup from "../MessageGroup"; +import MessageSeparator from "../MessageSeparator"; +import MessageListContent from "./MessageListContent"; + +class MessageListInner extends React.Component { + constructor(props) { + super(props); + this.scrollPointRef = React.createRef(); + this.containerRef = React.createRef(); + this.scrollRef = React.createRef(); + this.lastClientHeight = 0; + } + + getSnapshotBeforeUpdate() { + const list = this.containerRef.current; + + const sticky = list.scrollHeight === list.scrollTop + list.clientHeight; + + return { + sticky, + clientHeight: list.clientHeight, + scrollHeight: list.scrollHeight, + lastMessageOrGroup: this.getLastMessageOrGroup(), + }; + } + + handleResize = () => { + // If container is smaller than before resize - scroll to End + if (this.containerRef.current.clientHeight < this.lastClientHeight) { + this.scrollToEnd(); + } + + this.scrollRef.current.updateScroll(); + }; + + componentDidMount() { + // Set scrollbar to bottom on start (getSnaphotBeforeUpdate is not invoked on mount) + this.scrollToEnd(); + + window.addEventListener("resize", this.handleResize); + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (typeof snapshot !== "undefined") { + const list = this.containerRef.current; + + if (snapshot.sticky === true) { + this.scrollToEnd(); + } else { + if (snapshot.clientHeight < this.lastClientHeight) { + if (list.scrollHeight === list.scrollTop + this.lastClientHeight) { + this.scrollToEnd(); + } + } else { + const last = this.getLastMessageOrGroup(); + + if (last === snapshot.lastMessageOrGroup) { + // New elements were not added at end + + // New elements were added at start + if ( + list.scrollTop === 0 && + list.scrollHeight > snapshot.scrollHeight + ) { + list.scrollTop = list.scrollHeight - snapshot.scrollHeight; + } + } + } + } + + this.lastClientHeight = snapshot.clientHeight; + } + } + + componentWillUnmount() { + window.removeEventListener("resize", this.handleResize); + } + + scrollToEnd() { + const list = this.containerRef.current; + const scrollPoint = this.scrollPointRef.current; + + // https://stackoverflow.com/a/45411081/6316091 + const parentRect = list.getBoundingClientRect(); + const childRect = scrollPoint.getBoundingClientRect(); + + // Scroll by offset relative to parent + const scrollOffset = childRect.top + list.scrollTop - parentRect.top; + + if (list.scrollBy) { + list.scrollBy({ top: scrollOffset }); + } else { + list.scrollTop = scrollOffset; + } + + this.lastClientHeight = list.clientHeight; + } + + getLastMessageOrGroup = () => + this.containerRef.current.querySelector( + `[data-${prefix}-message-list]>[data-${prefix}-message]:last-of-type,[data-${prefix}-message-list]>[data-${prefix}-message-group]:last-of-type` + ); + + render() { + const { + props: { + children, + typingIndicator, + loading, + loadingMore, + onYReachStart, + className, + ...rest + }, + } = this; + + const cName = `${prefix}-message-list`; + + const [customContent] = getChildren(children, [MessageListContent]); + + return ( +
+ {loadingMore && ( +
+ +
+ )} + {loading && ( + + + + )} + (this.containerRef.current = ref)} + options={{ suppressScrollX: true }} + {...{ [`data-${prefix}-message-list`]: "" }} + > + {customContent ? customContent : children} +
+
+ + {typeof typingIndicator !== "undefined" && ( +
+ {typingIndicator} +
+ )} +
+ ); + } +} + +MessageListInner.displayName = "MessageList"; + +const MessageList = (props) => ; + +MessageList.propTypes = { + /** + * Primary content. Message elements + * Allowed components: + * + * * <Message /> + * * <MessageGroup /> + * * <MessageSeparator /> + */ + children: allowedChildren([ + Message, + MessageGroup, + MessageSeparator, + MessageListContent, + ]), + + /** Typing indicator element. */ + typingIndicator: PropTypes.node, + + /** Loading flag. */ + loading: PropTypes.bool, + + /** Loading more flag gor infinity scroll. */ + loadingMore: PropTypes.bool, + + /** + * It is fired when the scrollbar reaches the beginning on the x axis.
+ * This can be used to load previous messages using the infinite scroll. + */ + onYReachStart: PropTypes.func, + + /** Additional classes. */ + className: PropTypes.string, +}; + +MessageList.defaultProps = { + typingIndicator: undefined, + loading: false, + loadingMore: false, +}; + +MessageListInner.propTypes = MessageList.propTypes; +MessageListInner.defaultProps = MessageList.defaultProps; + +MessageList.Content = MessageListContent; + +export default MessageList; diff --git a/src/components/MessageList/MessageListContent.jsx b/src/components/MessageList/MessageListContent.jsx new file mode 100644 index 0000000..0599a75 --- /dev/null +++ b/src/components/MessageList/MessageListContent.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import PropTypes from "prop-types"; + +export const MessageListContent = ({ className, children, ...rest }) => ( +
+ {children} +
+); + +MessageListContent.displayName = "MessageList.Content"; + +MessageListContent.propTypes = { + /** Primary content. Message elements */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +export default MessageListContent; diff --git a/src/components/MessageList/index.js b/src/components/MessageList/index.js new file mode 100644 index 0000000..9e0b640 --- /dev/null +++ b/src/components/MessageList/index.js @@ -0,0 +1,3 @@ +import MessageList from "./MessageList"; +export * from "./MessageList"; +export default MessageList; diff --git a/src/components/MessageSeparator/MessageSeparator.jsx b/src/components/MessageSeparator/MessageSeparator.jsx new file mode 100644 index 0000000..6c3d2b2 --- /dev/null +++ b/src/components/MessageSeparator/MessageSeparator.jsx @@ -0,0 +1,51 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import { isChildrenNil } from "../utils"; + +export const MessageSeparator = ({ + content, + as, + children, + className, + ...rest +}) => { + const cName = `${prefix}-message-separator`; + + const Tag = (() => { + if (typeof as === "string" && as.length > 0) { + return as; + } else { + return MessageSeparator.defaultProps.as; + } + })(); + + return ( + + {isChildrenNil(children) === true ? content : children} + + ); +}; + +MessageSeparator.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Shorthand for primary content. */ + content: PropTypes.node, + + /** An element type to render as. */ + as: PropTypes.elementType, + + /** Additional classes. */ + className: PropTypes.string, +}; + +MessageSeparator.defaultProps = { + children: undefined, + content: undefined, + as: "div", +}; + +export default MessageSeparator; diff --git a/src/components/MessageSeparator/index.js b/src/components/MessageSeparator/index.js new file mode 100644 index 0000000..aa4052e --- /dev/null +++ b/src/components/MessageSeparator/index.js @@ -0,0 +1,3 @@ +import MessageSeparator from "./MessageSeparator"; +export * from "./MessageSeparator"; +export default MessageSeparator; diff --git a/src/components/Overlay/Overlay.jsx b/src/components/Overlay/Overlay.jsx new file mode 100644 index 0000000..907f05d --- /dev/null +++ b/src/components/Overlay/Overlay.jsx @@ -0,0 +1,53 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; + +export const Overlay = ({ className, children, blur, grayscale, ...rest }) => { + const cName = `${prefix}-overlay`; + const blurClass = `${cName}--blur`; + const grayscaleClass = `${cName}--grayscale`; + + return ( +
+
{children}
+
+ ); +}; + +Overlay.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, + + /** + * Blur overlayed content. + * This feature is experimental and have limited browser support + */ + blur: PropTypes.bool, + + /** + * Grayscale overlayed content. + * This feature is experimental and have limited browser support + */ + grayscale: PropTypes.bool, +}; + +Overlay.defaultProps = { + className: "", + children: undefined, + blur: false, + grayscale: false, +}; + +export default Overlay; diff --git a/src/components/Overlay/index.js b/src/components/Overlay/index.js new file mode 100644 index 0000000..05b9ab8 --- /dev/null +++ b/src/components/Overlay/index.js @@ -0,0 +1,3 @@ +import Overlay from "./Overlay"; +export * from "./Overlay"; +export default Overlay; diff --git a/src/components/Search/Search.jsx b/src/components/Search/Search.jsx new file mode 100644 index 0000000..1222ce3 --- /dev/null +++ b/src/components/Search/Search.jsx @@ -0,0 +1,143 @@ +import React, { + useState, + useRef, + useMemo, + useImperativeHandle, + forwardRef, +} from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSearch } from "@fortawesome/free-solid-svg-icons"; +import { faTimes } from "@fortawesome/free-solid-svg-icons"; + +const useControlledOrNot = (initialValue, value) => { + if (typeof value === "undefined") { + // Uncontrolled + return useState(initialValue); + } else { + // Controlled + return [value, () => {}]; + } +}; + +function SearchInner( + { placeholder, value, onChange, onClearClick, className, disabled, ...rest }, + ref +) { + const cName = `${prefix}-search`; + + const isControlled = useMemo(() => typeof value !== "undefined", []); + + const [searchValue, setSearchValue] = useControlledOrNot("", value); + + const [clearActive, setClearActive] = useState( + isControlled ? searchValue.length > 0 : false + ); + + if (isControlled !== (typeof value !== "undefined")) { + throw "Search: Changing from controlled to uncontrolled component and vice versa is not allowed"; + } + + const inputRef = useRef(undefined); + + // Public API + const focus = () => { + if (typeof inputRef.current !== "undefined") { + inputRef.current.focus(); + } + }; + + // Return object with public Api + useImperativeHandle(ref, () => ({ + focus, + })); + + const handleChange = (e) => { + const value = e.target.value; + setClearActive(value.length > 0); + if (isControlled === false) { + setSearchValue(value); + } + onChange(value); + }; + + const handleClearClick = () => { + if (isControlled === false) { + setSearchValue(""); + } + + setClearActive(false); + + onClearClick(); + }; + + return ( +
+ + + +
+ ); +} + +const Search = forwardRef(SearchInner); + +Search.displayName = "Search"; + +Search.propTypes = { + /** Placeholder. */ + placeholder: PropTypes.string, + + /** Current value of the search input. Creates a controlled component */ + value: PropTypes.string, + + /** OnInput handler. */ + onChange: PropTypes.func, + + /** OnClearClick handler. */ + onClearClick: PropTypes.func, + + /** Additional classes. */ + className: PropTypes.string, + + /** Disabled */ + disabled: PropTypes.bool, +}; + +SearchInner.propTypes = Search.propTypes; + +Search.defaultProps = { + placeholder: "", + value: undefined, + onChange: () => {}, + onClearClick: () => {}, + disabled: false, +}; + +SearchInner.defaultProps = Search.defaultProps; + +export { Search }; +export default Search; diff --git a/src/components/Search/index.js b/src/components/Search/index.js new file mode 100644 index 0000000..89062ce --- /dev/null +++ b/src/components/Search/index.js @@ -0,0 +1,3 @@ +import Search from "./Search"; +export * from "./Search"; +export default Search; diff --git a/src/components/Sidebar/Sidebar.jsx b/src/components/Sidebar/Sidebar.jsx new file mode 100644 index 0000000..8e94a8e --- /dev/null +++ b/src/components/Sidebar/Sidebar.jsx @@ -0,0 +1,83 @@ +import React, { useMemo } from "react"; +import PropTypes from "prop-types"; +import { prefix } from "../settings"; +import PerfectScrollbar from "react-perfect-scrollbar"; +import classNames from "classnames"; +import Overlay from "../Overlay"; +import Loader from "../Loader"; + +export const Sidebar = ({ + children, + position, + scrollable, + loading, + className, + ...props +}) => { + const cName = `${prefix}-sidebar`; + + const sideClass = (() => { + if (position === "left") { + return `${cName}--left`; + } else if (position === "right") { + return `${cName}--right`; + } else { + return ``; + } + })(); + + const Tag = useMemo( + () => ({ children, ...rest }) => { + // PerfectScrollbar for now cant be disabled, so render div instead of disabling it + // https://github.com/goldenyz/react-perfect-scrollbar/issues/107 + if (scrollable === false || (scrollable === true && loading === true)) { + return ( +
+ {loading && ( + + + + )} + {children} +
+ ); + } else { + return {children}; + } + }, + [scrollable, loading] + ); + + return ( + + {children} + + ); +}; + +Sidebar.propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Sidebar can be placed on two positions */ + position: PropTypes.oneOf(["left", "right"]), + + /** Sidebar can be scrollable */ + scrollable: PropTypes.bool, + + /** Loading flag. */ + loading: PropTypes.bool, + + /** Additional classes. */ + className: PropTypes.string, +}; + +Sidebar.defaultProps = { + children: undefined, + position: undefined, + scrollable: true, + loading: false, + className: "", +}; + +export default Sidebar; diff --git a/src/components/Sidebar/index.js b/src/components/Sidebar/index.js new file mode 100644 index 0000000..44bf5b6 --- /dev/null +++ b/src/components/Sidebar/index.js @@ -0,0 +1,3 @@ +import Sidebar from "./Sidebar"; +export * from "./Sidebar"; +export default Sidebar; diff --git a/src/components/Status/Status.jsx b/src/components/Status/Status.jsx new file mode 100644 index 0000000..0643fb5 --- /dev/null +++ b/src/components/Status/Status.jsx @@ -0,0 +1,65 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { StatusEnum, SizeEnum } from "../enums"; +import { prefix } from "../settings"; + +export const Status = ({ + status, + size, + className, + name, + selected, + children, + ...rest +}) => { + const cName = `${prefix}-status`; + const bullet =
; + const named = name || children; + + return ( +
+ {bullet} + {named && ( +
{name ? name : children}
+ )} +
+ ); +}; + +Status.propTypes = { + /** Primary content */ + children: PropTypes.node, + + /** Status. */ + status: PropTypes.oneOf(StatusEnum).isRequired, + + /** Size. */ + size: PropTypes.oneOf(SizeEnum), + + /** Name */ + name: PropTypes.node, + + /** Selected */ + selected: PropTypes.bool, + + /** Additional classes. */ + className: PropTypes.string, +}; + +Status.defaultProps = { + size: "md", +}; + +export default Status; diff --git a/src/components/Status/index.js b/src/components/Status/index.js new file mode 100644 index 0000000..67e9c08 --- /dev/null +++ b/src/components/Status/index.js @@ -0,0 +1,3 @@ +import Status from "./Status"; +export * from "./Status"; +export default Status; diff --git a/src/components/StatusList/StatusList.jsx b/src/components/StatusList/StatusList.jsx new file mode 100644 index 0000000..12f1f9a --- /dev/null +++ b/src/components/StatusList/StatusList.jsx @@ -0,0 +1,125 @@ +import React, { useImperativeHandle, forwardRef, useRef } from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { noop, allowedChildren } from "../utils"; +import { SizeEnum, StatusEnum } from "../enums"; +import Status from "../Status"; +import { prefix } from "../settings"; + +function StatusListInner( + { className, children, size, selected, onChange, itemsTabIndex, ...rest }, + ref +) { + const cName = `${prefix}-status-list`; + + const listRef = useRef(); + + // Return object with public Api + useImperativeHandle(ref, () => ({ + focus: (idx) => { + const items = Array.from(listRef.current.querySelectorAll("li")); + // For sure filter only direct children because querySelectorAll cant get only direct children + const directChild = items.filter( + (item) => item.parentNode === listRef.current + ); + if (typeof directChild[idx] !== "undefined") { + directChild[idx].focus(); + } + }, + })); + + let tabIndex = itemsTabIndex; + return ( +
    + {React.Children.map(children, (item) => { + // If active argument is set, clear active flag for all elements except desired + const newProps = {}; + if (selected) { + newProps.selected = item.props.status === selected; + } + + if (onChange) { + newProps.onClick = (evt) => { + onChange(item.props.status); + if (item.onClick) { + item.onClick(evt); + } + }; + } + + const onKeyPress = (evt) => { + if (onChange) { + if ( + evt.key === "Enter" && + evt.shiftKey === false && + evt.altKey === false + ) { + onChange(item.props.status); + } + } + }; + + const tIndex = (() => { + if (typeof tabIndex === "number") { + if (tabIndex > 0) { + return tabIndex++; + } else { + return tabIndex; + } + } else { + return undefined; + } + })(); + + return ( +
  • + {React.cloneElement(item, newProps)} +
  • + ); + })} +
+ ); +} + +const StatusList = forwardRef(StatusListInner); +StatusList.displayName = "StatusList"; + +StatusList.propTypes = { + /** + * Primary content. + * Allowed components: + * + * * <Status /> + */ + children: allowedChildren([Status]), + + /** Selected element */ + selected: PropTypes.oneOf(StatusEnum), + + /** Size */ + size: PropTypes.oneOf(SizeEnum), + + /** tabindex value for items. Any positive integer will be treated as start index for counting. Zero and negative values will be applied to all items */ + itemsTabIndex: PropTypes.number, + + /** Additional classes. */ + className: PropTypes.string, + + /** onChange handler */ + onChange: PropTypes.func, +}; + +StatusListInner.propTypes = StatusList.propTypes; + +StatusList.defaultProps = { + onChange: noop, +}; + +StatusListInner.defaultProps = StatusList.defaultProps; + +export { StatusList }; +export default StatusList; diff --git a/src/components/StatusList/index.js b/src/components/StatusList/index.js new file mode 100644 index 0000000..64a9463 --- /dev/null +++ b/src/components/StatusList/index.js @@ -0,0 +1,3 @@ +import StatusList from "./StatusList"; +export * from "./StatusList"; +export default StatusList; diff --git a/src/components/TypingIndicator/TypingIndicator.jsx b/src/components/TypingIndicator/TypingIndicator.jsx new file mode 100644 index 0000000..12ab20a --- /dev/null +++ b/src/components/TypingIndicator/TypingIndicator.jsx @@ -0,0 +1,33 @@ +import React from "react"; +import { PropTypes } from "prop-types"; +import classNames from "classnames"; +import { prefix } from "../settings"; + +export const TypingIndicator = ({ content, className, ...rest }) => { + const cName = `${prefix}-typing-indicator`; + + return ( +
+
+
+
+
+
+
{content}
+
+ ); +}; + +TypingIndicator.propTypes = { + /** Indicator content. */ + content: PropTypes.node, + + /** Additional classes. */ + className: PropTypes.string, +}; + +TypingIndicator.defaultProps = { + content: "", +}; + +export default TypingIndicator; diff --git a/src/components/TypingIndicator/index.js b/src/components/TypingIndicator/index.js new file mode 100644 index 0000000..5915d4a --- /dev/null +++ b/src/components/TypingIndicator/index.js @@ -0,0 +1,3 @@ +import TypingIndicator from "./TypingIndicator"; +export * from "./TypingIndicator"; +export default TypingIndicator; diff --git a/src/components/enums.js b/src/components/enums.js new file mode 100644 index 0000000..72afbdc --- /dev/null +++ b/src/components/enums.js @@ -0,0 +1,15 @@ +export const StatusEnum = [ + "available", + "unavailable", + "away", + "dnd", + "invisible", + "eager", +]; + +export const SizeEnum = ["xs", "sm", "md", "lg", "fluid"]; + +export default { + SizeEnum, + StatusEnum, +}; diff --git a/src/components/index.js b/src/components/index.js new file mode 100644 index 0000000..9854c79 --- /dev/null +++ b/src/components/index.js @@ -0,0 +1,37 @@ +export { default as Avatar } from "./Avatar"; +export { default as AvatarGroup } from "./AvatarGroup"; +export { default as ChatContainer } from "./ChatContainer"; +export { default as Conversation } from "./Conversation"; +export { default as ConversationHeader } from "./ConversationHeader"; +export { default as ConversationList } from "./ConversationList"; +export { default as ExpansionPanel } from "./ExpansionPanel"; +export { default as InputToolbox } from "./InputToolbox"; +export { default as MainContainer } from "./MainContainer"; +export { default as Message } from "./Message"; +export { default as MessageGroup } from "./MessageGroup"; +export { default as MessageInput } from "./MessageInput"; +export { default as MessageList } from "./MessageList"; +export { default as MessageSeparator } from "./MessageSeparator"; +export { default as Search } from "./Search"; +export { default as Sidebar } from "./Sidebar"; +export { default as Status } from "./Status"; +export { default as TypingIndicator } from "./TypingIndicator"; +export { default as Loader } from "./Loader"; +export { default as Overlay } from "./Overlay"; +export { default as StatusList } from "./StatusList"; + +// Buttons +export { default as Buttons } from "./Buttons"; +export { default as Button } from "./Buttons/Button"; +export { default as ArrowButton } from "./Buttons/ArrowButton"; +export { default as InfoButton } from "./Buttons/InfoButton"; +export { default as VoiceCallButton } from "./Buttons/VoiceCallButton"; +export { default as VideoCallButton } from "./Buttons/VideoCallButton"; +export { default as StarButton } from "./Buttons/StarButton"; +export { default as AddUserButton } from "./Buttons/AddUserButton"; +export { default as EllipsisButton } from "./Buttons/EllipsisButton"; +export { default as SendButton } from "./Buttons/SendButton"; +export { default as AttachmentButton } from "./Buttons/AttachmentButton"; + +// Enums +export { default as Enums } from "./enums"; diff --git a/src/components/settings.js b/src/components/settings.js new file mode 100644 index 0000000..636343f --- /dev/null +++ b/src/components/settings.js @@ -0,0 +1,3 @@ +const prefix = "cs"; + +export { prefix }; diff --git a/src/components/utils.js b/src/components/utils.js new file mode 100644 index 0000000..110bb76 --- /dev/null +++ b/src/components/utils.js @@ -0,0 +1,111 @@ +import React from "react"; + +export const noop = () => {}; + +/** + * Tests if children are nil in React and Preact. + * @param {Object} children The children prop of a component. + * @returns {Boolean} + */ +export const isChildrenNil = (children) => + children === null || + children === undefined || + (Array.isArray(children) && children.length === 0); + +/** + * Gets only specified types children + * @param children + * @param Array types + * @returns {[]} + */ +export const getChildren = (children, types) => { + const ret = []; + const strTypes = types.map((t) => t.name || t.displayName); + + React.Children.toArray(children).forEach((item) => { + const idx = types.indexOf(item.type); + if (idx !== -1) { + ret[idx] = item; + } else { + const is = item?.props?.is; + + if (typeof is === "function") { + const fIdx = types.indexOf(is); + if (fIdx !== -1) { + ret[fIdx] = item; + } + } else if (typeof is === "string") { + const sIdx = strTypes.indexOf(is); + if (sIdx !== -1) { + ret[sIdx] = item; + } + } + } + }); + + return ret; +}; + +export const getComponentName = (component) => { + if ("type" in component) { + if ("displayName" in component.type) { + return component.type.displayName; + } + + if ("name" in component.type) { + return component.type.name; + } + + return "undefined"; + } + + return "undefined"; +}; + +/** + * PropTypes validator. + * Checks if all children is allowed by its types. + * Returns function for propTypes + * @param {Array} allowedTypes + * @return {Function} + */ +export const allowedChildren = (allowedTypes) => ( + props, + propName, + componentName +) => { + const allowedTypesAsStrings = allowedTypes.map( + (t) => t.name || t.displayName + ); + + // Function as Child is not supported by React.Children... functions + // and can be antipattern: https://americanexpress.io/faccs-are-an-antipattern/ + // But we don't check fd function is passed as children and its intentional + // Passing function as children has no effect in chat-ui-kit + const forbidden = React.Children.toArray(props[propName]).find((item) => { + if (allowedTypes.indexOf(item.type) === -1) { + const is = item?.props?.is; + + if (typeof is === "function") { + return allowedTypes.indexOf(is) === -1; + } else if (typeof is === "string") { + return allowedTypesAsStrings.indexOf(is) === -1; + } else { + return true; + } + } + + return undefined; + }); + + if (typeof forbidden !== "undefined") { + const typeName = getComponentName(forbidden); + + const allowedNames = allowedTypes + .map((t) => t.name || t.displayName) + .join(", "); + const errMessage = `"${typeName}" is not a valid child for ${componentName}. Allowed types: ${allowedNames}`; + + return new Error(errMessage); + } +};