diff --git a/.gitignore b/.gitignore index a66f59479d71c2ea5915fe743a79299375f7a8f5..199f0a59d0e6a23b27d39a38db0c070b0fbfa5c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +node_modules +*.log # Created by https://www.gitignore.io/api/osx,linux diff --git a/README.md b/README.md index 42410a581cf48e8fca3afa81deb620bcd8568469..3de0db95cfa17859cbbaa8be8f834d8adf3d23fb 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ It's a modular and flexible framework consisting of a **server** and **client** ## PubSweet packages (managed with Lerna) -| repository | description | +| package | description | | :-------- | :-------- | | [ pubsweet/pubsweet-server](https://gitlab.coko.foundation/pubsweet/packages/pubsweet-server) | an extensible RESTful API that runs on the server | | [ pubsweet/pubsweet-client](https://gitlab.coko.foundation/pubsweet/packages/pubsweet-client) | an extensible frontend app that runs in the browser | diff --git a/package.json b/package.json index 5f33f6f062e7c74358c34393d9666ce939ee7fcb..9742b63c2749ff2e1d7d3a23d2de956a5ff36829 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,5 @@ { + "name": "pubsweet-monorepo", "private": true, "license": "MIT", "devDependencies": { @@ -35,5 +36,9 @@ ], "*.css": "stylelint", "*.scss": "stylelint" - } + }, + "workspaces": [ + "packages/*", + "packages/components/packages/*" + ] } diff --git a/packages/styleguide/package.json b/packages/styleguide/package.json new file mode 100644 index 0000000000000000000000000000000000000000..46e9ef0f565e8c56f72f81f8b9d9a244fd37ae89 --- /dev/null +++ b/packages/styleguide/package.json @@ -0,0 +1,38 @@ +{ + "name": "@pubsweet/styleguide", + "version": "0.1.0", + "files": [ + "src", + "dist" + ], + "main": "src", + "dependencies": { + "@pubsweet/theme": "^0.1.0", + "react": "^15.6.1", + "react-dom": "^15.6.1", + "react-redux": "^5.0.2", + "react-router-dom": "^4.2.2", + "recompose": "^0.26.0", + "redux": "^3.6.0", + "redux-form": "^7.0.3" + }, + "devDependencies": { + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-preset-env": "^1.6.0", + "babel-preset-react": "^6.24.1", + "babel-preset-stage-2": "^6.24.1", + "css-loader": "^0.28.4", + "node-sass": "^4.5.3", + "rimraf": "^2.6.1", + "sass-loader": "^6.0.6", + "style-loader": "^0.19.0", + "webpack": "^3.8.1", + "webpack-node-externals": "^1.6.0" + }, + "scripts": { + "clean": "rimraf dist", + "prebuild": "npm run clean && npm run lint", + "build": "webpack --progress --profile" + } +} diff --git a/packages/styleguide/src/components/StyleGuideRenderer.js b/packages/styleguide/src/components/StyleGuideRenderer.js new file mode 100644 index 0000000000000000000000000000000000000000..b1a65700dfd22533f14e9ac59fc07e47849c3114 --- /dev/null +++ b/packages/styleguide/src/components/StyleGuideRenderer.js @@ -0,0 +1,18 @@ +import React from 'react' +import classes from './StyleGuideRenderer.local.scss' + +const StyleGuideRenderer = ({ title, children, toc }) => ( + <div className={classes.root}> + <div className={classes.sidebar}> + <header className={classes.header}> + <h1 className={classes.title}>{title}</h1> + </header> + + <nav className={classes.nav}>{toc}</nav> + </div> + + <div className={classes.content}>{children}</div> + </div> +) + +export default StyleGuideRenderer diff --git a/packages/styleguide/src/components/StyleGuideRenderer.local.scss b/packages/styleguide/src/components/StyleGuideRenderer.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..bdd3e8097da381478d55ed6ac804cf701de0422d --- /dev/null +++ b/packages/styleguide/src/components/StyleGuideRenderer.local.scss @@ -0,0 +1,37 @@ +.root { + display: grid; + grid-template-areas: "side content"; + grid-template-columns: 1fr 3fr; + height: 100vh; + width: 100vw; +} + +.sidebar { + display: flex; + flex-direction: column; + grid-area: side; + overflow-y: hidden; +} + +.content { + grid-area: content; + overflow-y: auto; + padding: 1rem; +} + +.header { + padding: 0.5rem; +} + +.nav { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.title { + font-family: "Fira Sans", sans-serif; + font-size: 1rem; + margin-bottom: 0; + padding: 0 1rem; +} diff --git a/packages/styleguide/src/components/Wrapper.js b/packages/styleguide/src/components/Wrapper.js new file mode 100644 index 0000000000000000000000000000000000000000..497248b4afba4bbb31f63f76235e38809b210a16 --- /dev/null +++ b/packages/styleguide/src/components/Wrapper.js @@ -0,0 +1,28 @@ +import React from 'react' +import { Provider } from 'react-redux' +import { BrowserRouter as Router } from 'react-router-dom' +import { reducer as formReducer } from 'redux-form' +import { createStore, combineReducers } from 'redux' + +import "@pubsweet/theme" + +import classes from './Wrapper.local.scss' + +const rootReducer = combineReducers({ + form: formReducer, +}) + +const store = createStore( + rootReducer, + window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), +) + +const Wrapper = ({ children }) => ( + <Provider store={store}> + <Router> + <div className={classes.root}>{children}</div> + </Router> + </Provider> +) + +export default Wrapper diff --git a/packages/styleguide/src/components/Wrapper.local.scss b/packages/styleguide/src/components/Wrapper.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..7f3a1cd9c7537ff5770c04584c6c0372a5f0f062 --- /dev/null +++ b/packages/styleguide/src/components/Wrapper.local.scss @@ -0,0 +1,7 @@ +:global(body) { + overflow: hidden; +} + +.root { + font-family: 'Fira Sans', sans-serif; +} diff --git a/packages/styleguide/src/components/index.js b/packages/styleguide/src/components/index.js new file mode 100644 index 0000000000000000000000000000000000000000..6c609447f503d184ae88fb2abd76b6cd9eba9690 --- /dev/null +++ b/packages/styleguide/src/components/index.js @@ -0,0 +1,4 @@ +module.exports = { + StyleGuideRenderer: require('./StyleGuideRenderer'), + Wrapper: require('./Wrapper'), +} diff --git a/packages/styleguide/src/index.js b/packages/styleguide/src/index.js new file mode 100644 index 0000000000000000000000000000000000000000..88b6ebcddf409aced437479f935853774ae078b3 --- /dev/null +++ b/packages/styleguide/src/index.js @@ -0,0 +1 @@ +module.exports = require('./components') diff --git a/packages/styleguide/src/webpack-config.js b/packages/styleguide/src/webpack-config.js new file mode 100644 index 0000000000000000000000000000000000000000..01800d6d9758f9d214bc0242de86c7fd882250b2 --- /dev/null +++ b/packages/styleguide/src/webpack-config.js @@ -0,0 +1,126 @@ +process.env.BABEL_ENV = 'development' +process.env.NODE_ENV = 'development' + +const path = require('path') +const webpack = require('webpack') +// const nodeExternals = require('webpack-node-externals') + +module.exports = dir => { + const include = [ + path.join(dir, 'src'), + /pubsweet-[^/]+\/src/, + /xpub-[^/]+\/src/, + /wax-[^/]+\/src/, + /@pubsweet\/[^/]+\/src/, + /styleguide\/src/, + /ui\/src/ + ] + + return { + devtool: 'cheap-module-source-map', + entry: './src/index.js', + // externals: [nodeExternals({ + // whitelist: [/\.(?!js$).{1,5}$/i] + // })], + module: { + rules: [ + { + oneOf: [ + // ES6 modules + { + test: /\.js$/, + include, + loader: 'babel-loader', + options: { + presets: [ + [require('babel-preset-env'), { modules: false }], + require('babel-preset-react'), + require('babel-preset-stage-2'), + ], + cacheDirectory: true, + }, + }, + + // CSS modules + { + test: /\.local\.css$/, + include, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: true, + // sourceMap: true, + localIdentName: '[name]_[local]-[hash:base64:8]', + }, + }, + ], + }, + + // SCSS modules + { + test: /\.local\.scss$/, + include, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: true, + importLoaders: 1, + // sourceMap: true, + localIdentName: '[name]_[local]-[hash:base64:8]', + }, + }, + 'sass-loader', + ], + }, + + // global CSS + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, + + // global SCSS + { + test: /\.scss$/, + use: [ + 'style-loader', + 'css-loader', + // { + // loader: 'css-loader', + // options: { + // importLoaders: 1, + // } + // }, + 'sass-loader', + ], + }, + + // Files + { + exclude: [/\.js$/, /\.html$/, /\.json$/], + loader: 'file-loader', + options: { + name: 'static/media/[name].[hash:8].[ext]', + }, + }, + ], + }, + ], + }, + output: { + filename: 'index.js', + path: path.join(dir, 'dist'), + }, + plugins: [ + // mock constants + new webpack.DefinePlugin({ + PUBSWEET_COMPONENTS: '[]', + }), + ], + watch: true, + } +} diff --git a/packages/styleguide/webpack.config.js b/packages/styleguide/webpack.config.js new file mode 100644 index 0000000000000000000000000000000000000000..f4cc52969a202b0eeeb0a2d67475312813a3b1dc --- /dev/null +++ b/packages/styleguide/webpack.config.js @@ -0,0 +1,3 @@ +const webpackConfig = require('./src/webpack-config') + +module.exports = webpackConfig(__dirname) diff --git a/packages/theme/package.json b/packages/theme/package.json new file mode 100644 index 0000000000000000000000000000000000000000..60e26613673b04479739971680fb2582d1fc4e41 --- /dev/null +++ b/packages/theme/package.json @@ -0,0 +1,17 @@ +{ + "name": "@pubsweet/theme", + "version": "0.1.0", + "description": "CSS for PubSweet apps and styleguides", + "main": "src", + "license": "MIT", + "dependencies": { + "cokourier-prime-sans": "git+https://gitlab.coko.foundation/julientaq/cokourier-sans-prime.git", + "typeface-fira-mono": "^0.0.43", + "typeface-fira-sans": "^0.0.43", + "typeface-fira-sans-condensed": "^0.0.43", + "typeface-vollkorn": "^0.0.43" + }, + "dependencies_disabled": { + "pubsweet-fira": "^0.0.3" + } +} diff --git a/packages/theme/src/index.js b/packages/theme/src/index.js new file mode 100644 index 0000000000000000000000000000000000000000..ed8411374c5b4ac841be7ec2ed8447e2931850f9 --- /dev/null +++ b/packages/theme/src/index.js @@ -0,0 +1,8 @@ +import 'typeface-fira-mono' +import 'typeface-fira-sans' +import 'typeface-fira-sans-condensed' +import 'typeface-vollkorn' +// import 'pubsweet-fira' +import 'cokourier-prime-sans' + +import './variables.css' diff --git a/packages/theme/src/variables.css b/packages/theme/src/variables.css new file mode 100644 index 0000000000000000000000000000000000000000..fb261f0b2492e96de17e642d20c9b8c4461f7e25 --- /dev/null +++ b/packages/theme/src/variables.css @@ -0,0 +1,16 @@ +:root { + /* brand colors */ + --color-primary: #0d78f2; + + /* colors for interactions */ + --color-danger: #ff2d1a; + --color-warning: #ee7600; + --color-valid: #00bf05; + --color-pending: #aaa; + + /* fonts for the different views */ + --font-author: 'Vollkorn', serif; + --font-reviewer: 'Kocourier Prime Sans', monospace; + --font-interface: 'Fira Sans Condensed', sans-serif; + --font-mono: 'Fira Mono', monospace; +} diff --git a/packages/ui/.eslintrc b/packages/ui/.eslintrc new file mode 100644 index 0000000000000000000000000000000000000000..db7f6d5a866b0a472df10aef07f037b74313aff1 --- /dev/null +++ b/packages/ui/.eslintrc @@ -0,0 +1,7 @@ +{ + "globals": { + "initialState": true, + "state": false, + "setState": false + } +} diff --git a/packages/ui/docs/colors.md b/packages/ui/docs/colors.md new file mode 100644 index 0000000000000000000000000000000000000000..4f66808b4c9f6b308d598e21cc58ab723aa8de17 --- /dev/null +++ b/packages/ui/docs/colors.md @@ -0,0 +1,45 @@ +CSS variables are used to define the theme's color scheme. + +## Brand colors + +`--color-primary` + +```js +<div style={{ color: 'var(--color-primary)' }}> +{faker.lorem.sentence(5)} +</div> +``` + +## Colors for interactions + +`--color-danger` + +```js +<div style={{ color: 'var(--color-danger)' }}> +{faker.lorem.sentence(5)} +</div> +``` + +`--color-valid` + +```js +<div style={{ color: 'var(--color-valid)' }}> +{faker.lorem.sentence(5)} +</div> +``` + +`--color-warning` + +```js +<div style={{ color: 'var(--color-warning)' }}> +{faker.lorem.sentence(5)} +</div> +``` + +`--color-pending` + +```js +<div style={{ color: 'var(--color-pending)' }}> +{faker.lorem.sentence(5)} +</div> +``` diff --git a/packages/ui/docs/fonts.md b/packages/ui/docs/fonts.md new file mode 100644 index 0000000000000000000000000000000000000000..e3d69cb97261e1f84da01f2d5a58042a738a6025 --- /dev/null +++ b/packages/ui/docs/fonts.md @@ -0,0 +1,33 @@ +CSS variables are used to define font families. + +`--font-author` + +```js +<div style={{ fontFamily: 'var(--font-author)' }}> +{faker.lorem.sentence(5)} +</div> +``` + +`--font-reviewer` + +```js +<div style={{ fontFamily: 'var(--font-reviewer)' }}> +{faker.lorem.sentence(5)} +</div> +``` + +`--font-interface` + +```js +<div style={{ fontFamily: 'var(--font-interface)' }}> +{faker.lorem.sentence(5)} +</div> +``` + +`--font-mono` + +```js +<div style={{ fontFamily: 'var(--font-mono)' }}> +{faker.lorem.sentence(5)} +</div> +``` diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000000000000000000000000000000000000..c88fb6264ce298122a6863c6073aa3c1f1e910f0 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,69 @@ +{ + "name": "@pubsweet/ui", + "version": "0.1.0", + "files": [ + "docs", + "dist", + "src" + ], + "main": "src", + "jsnext:main": "src", + "dependencies": { + "babel-jest": "^21.2.0", + "classnames": "^2.2.5", + "enzyme": "^3.2.0", + "enzyme-adapter-react-15": "^1.0.5", + "lodash": "^4.17.4", + "humps": "^2.0.1", + "prop-types": "^15.5.10", + "react": "^15.6.1", + "react-dom": "^15.6.1", + "react-feather": "^1.0.7", + "react-redux": "^5.0.2", + "react-router-dom": "^4.2.2", + "react-tag-autocomplete": "^5.4.1", + "recompose": "^0.26.0", + "redux": "^3.6.0", + "redux-form": "^7.0.3" + }, + "devDependencies": { + "@pubsweet/styleguide": "^0.1.0", + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-preset-env": "^1.6.0", + "babel-preset-react": "^6.24.1", + "babel-preset-stage-2": "^6.24.1", + "css-loader": "^0.28.4", + "faker": "^4.1.0", + "file-loader": "^1.1.5", + "identity-obj-proxy": "^3.0.0", + "jest": "^21.2.1", + "node-sass": "^4.5.3", + "react-styleguidist": "^6.0.8", + "react-test-renderer": "^15.6.1", + "sass-loader": "^6.0.6", + "style-loader": "^0.19.0", + "webpack": "^3.8.1", + "webpack-node-externals": "^1.6.0" + }, + "jest": { + "moduleNameMapper": { + "\\.s?css$": "identity-obj-proxy" + }, + "setupTestFrameworkScriptFile": "<rootDir>/test/setup/enzyme.js", + "transform": { + "\\.js$": "<rootDir>/test/config/transform.js" + } + }, + "scripts": { + "styleguide": "styleguidist server", + "styleguide:build": "styleguidist build", + "clean": "rimraf dist", + "prebuild": "npm run clean && npm run lint", + "build": "webpack --progress --profile", + "test": "jest", + "test:watch": "npm test -- --watch", + "test:cover": "npm test -- --coverage", + "test:u": "npm test -- --updateSnapshot" + } +} diff --git a/packages/ui/src/atoms/Attachment.js b/packages/ui/src/atoms/Attachment.js new file mode 100644 index 0000000000000000000000000000000000000000..9f9be1d4982afa1ac866ecaf7c6ab965693612f6 --- /dev/null +++ b/packages/ui/src/atoms/Attachment.js @@ -0,0 +1,14 @@ +import React from 'react' +import Icon from './Icon' +import classes from './Attachment.local.scss' + +const Attachment = ({ value }) => ( + <a download={value.name} href={value.url}> + <span className={classes.icon}> + <Icon color="var(--color-primary)">paperclip</Icon> + </span> + <span className={classes.filename}>{value.name}</span> + </a> +) + +export default Attachment diff --git a/packages/ui/src/atoms/Attachment.local.scss b/packages/ui/src/atoms/Attachment.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..4a6f520208438c03d42cdc043dfd0b51e284112d --- /dev/null +++ b/packages/ui/src/atoms/Attachment.local.scss @@ -0,0 +1,13 @@ +.icon { + color: var(--color-primary); + display: inline-flex; + margin-right: 10px; +} + +.filename { + font-size: 0.7em; + height: 2em; + max-width: 25ch; + overflow-wrap: break-word; + padding: 0; +} diff --git a/packages/ui/src/atoms/Attachment.md b/packages/ui/src/atoms/Attachment.md new file mode 100644 index 0000000000000000000000000000000000000000..3156671c60e5ad5582b67a7a9c8e51471750767f --- /dev/null +++ b/packages/ui/src/atoms/Attachment.md @@ -0,0 +1,10 @@ +A file attached to a note. + +```js +const value = { + name: faker.system.commonFileName(), + url: faker.internet.url() +}; + +<Attachment value={value}/> +``` diff --git a/packages/ui/src/atoms/Avatar.js b/packages/ui/src/atoms/Avatar.js new file mode 100644 index 0000000000000000000000000000000000000000..d30de999d717c895bce2afb93ef35a5877afd533 --- /dev/null +++ b/packages/ui/src/atoms/Avatar.js @@ -0,0 +1,44 @@ +import React from 'react' +import classes from './Avatar.local.scss' + +const Avatar = ({ status, width, height, reviewerLetter }) => { + const classValue = + status && classes[status.toLowerCase()] ? status.toLowerCase() : 'default' + + return ( + <svg + className={classes[classValue]} + height={height || '70'} + viewBox={`0 0 ${width ? width + 5 : '105'} ${height || '70'}`} + width={width || '100'} + xmlns="http://www.w3.org/2000/svg" + > + <path + className={classes.persona} + d=" M 47.666 50.14 C 44.947 49 41.588 47.535 41.588 46.395 L 41.588 39.07 C 45.587 35.977 47.986 31.093 47.986 26.047 L 47.986 16.279 C 47.986 7.326 40.788 0 31.991 0 C 23.193 0 15.995 7.326 15.995 16.279 L 15.995 26.047 C 15.995 31.093 18.395 36.14 22.393 39.07 L 22.393 46.395 C 22.393 47.372 19.034 48.837 16.315 50.14 C 9.757 52.907 0 57.14 0 68.372 L 0 70 L 63.981 70 L 63.981 68.372 C 63.981 57.14 54.224 52.907 47.666 50.14 Z " + /> + <path + className={classes.check} + d=" M 60.106 37.467 C 59.299 36.645 58.895 35.617 58.895 34.486 C 58.895 33.458 59.299 32.43 60.106 31.608 C 60.813 30.888 61.823 30.375 62.934 30.375 C 64.045 30.375 65.055 30.888 65.762 31.608 L 74.246 40.242 L 93.132 21.021 C 93.839 20.301 94.95 19.89 95.96 19.89 C 97.071 19.89 98.081 20.301 98.788 21.021 C 99.596 21.843 100 22.871 100 24.002 C 100 25.03 99.596 26.057 98.788 26.88 L 74.246 51.857 L 60.106 37.467 Z " + /> + <path + className={classes.x} + d="M 70.964 37.518 L 62.025 46.615 C 61.217 47.54 60.712 48.671 60.712 49.904 C 60.712 51.138 61.217 52.268 62.025 53.091 C 62.934 54.016 64.045 54.427 65.257 54.427 C 66.469 54.427 67.58 54.016 68.388 53.091 L 77.326 43.994 L 86.265 53.091 C 87.173 54.016 88.284 54.427 89.496 54.427 C 90.708 54.427 91.819 54.016 92.627 53.091 C 93.536 52.268 93.94 51.138 93.94 49.904 C 93.94 48.671 93.536 47.54 92.627 46.615 L 83.689 37.518 L 92.627 28.422 C 93.536 27.599 93.94 26.469 93.94 25.235 C 93.94 24.002 93.536 22.871 92.627 21.946 C 91.819 21.124 90.708 20.61 89.496 20.61 C 88.284 20.61 87.173 21.124 86.265 21.946 L 77.326 31.043 L 68.388 21.946 C 67.58 21.124 66.469 20.61 65.257 20.61 C 64.045 20.61 62.934 21.124 62.025 21.946 C 61.217 22.871 60.712 24.002 60.712 25.235 C 60.712 26.469 61.217 27.599 62.025 28.422 L 70.964 37.518 Z" + /> + <path + className={classes['question-mark']} + d=" M 79.674 23.203 L 79.674 23.203 Q 83.397 23.203 85.424 25.077 L 85.424 25.077 L 85.424 25.077 Q 87.451 26.95 87.451 29.771 L 87.451 29.771 L 87.451 29.771 Q 87.451 31.75 86.728 33.14 L 86.728 33.14 L 86.728 33.14 Q 86.003 34.529 85.011 35.371 L 85.011 35.371 L 85.011 35.371 Q 84.018 36.214 82.404 37.224 L 82.404 37.224 L 82.404 37.224 Q 80.625 38.361 79.798 39.182 L 79.798 39.182 L 79.798 39.182 Q 78.97 40.003 78.97 41.308 L 78.97 41.308 L 78.97 41.308 Q 78.97 41.94 79.094 42.319 L 79.094 42.319 L 72.971 43.287 L 72.971 43.287 Q 72.64 42.024 72.64 41.098 L 72.64 41.098 L 72.64 41.098 Q 72.64 39.203 73.282 37.898 L 73.282 37.898 L 73.282 37.898 Q 73.923 36.593 74.833 35.814 L 74.833 35.814 L 74.833 35.814 Q 75.743 35.035 77.15 34.108 L 77.15 34.108 L 77.15 34.108 Q 78.681 33.056 79.405 32.298 L 79.405 32.298 L 79.405 32.298 Q 80.129 31.54 80.129 30.403 L 80.129 30.403 L 80.129 30.403 Q 80.129 29.603 79.653 29.203 L 79.653 29.203 L 79.653 29.203 Q 79.177 28.803 78.35 28.803 L 78.35 28.803 L 78.35 28.803 Q 76.405 28.803 74.006 31.245 L 74.006 31.245 L 70.282 27.708 L 70.282 27.708 Q 74.171 23.203 79.674 23.203 L 79.674 23.203 Z M 75.371 53.94 L 75.371 53.94 Q 73.84 53.94 72.93 52.951 L 72.93 52.951 L 72.93 52.951 Q 72.02 51.961 72.02 50.445 L 72.02 50.445 L 72.02 50.445 Q 72.02 48.635 73.24 47.33 L 73.24 47.33 L 73.24 47.33 Q 74.461 46.024 76.24 46.024 L 76.24 46.024 L 76.24 46.024 Q 77.77 46.024 78.681 47.014 L 78.681 47.014 L 78.681 47.014 Q 79.591 48.003 79.591 49.561 L 79.591 49.561 L 79.591 49.561 Q 79.591 51.414 78.37 52.677 L 78.37 52.677 L 78.37 52.677 Q 77.15 53.94 75.371 53.94 L 75.371 53.94 Z " + /> + <g transform="matrix(1.01,0,0,1.028,64.651,6.065)"> + <text + className={classes['reviewer-number']} + transform="matrix(1,0,0,1,0,46.75)" + > + {reviewerLetter} + </text> + </g> + </svg> + ) +} + +export default Avatar diff --git a/packages/ui/src/atoms/Avatar.local.scss b/packages/ui/src/atoms/Avatar.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..9ff6dfcf5eb84c71ce575f2f439da0e92fd5802a --- /dev/null +++ b/packages/ui/src/atoms/Avatar.local.scss @@ -0,0 +1,141 @@ +figure { + margin: 0 auto 2px; + text-align: center; + width: auto; +} + +svg { + height: auto; + max-height: 100vh; + max-width: 100%; + width: auto; +} + +.default { + .persona { + display: block; + fill: var(--color-primary); + } + + .check { + display: none; + } + + .x { + display: none; + } + + .question-mark { + display: none; + } + + .reviewer-number { + display: none; + } +} + +.accepted { + .persona { + display: block; + fill: var(--color-primary); + } + + .check { + display: block; + fill: var(--color-primary); + } + + .x { + display: none; + } + + .question-mark { + display: none; + } + + .reviewer-number { + display: none; + } +} + +.declined { + .persona { + display: block; + fill: var(--color-danger); + } + + .check { + display: none; + } + + .x { + display: block; + fill: var(--color-danger); + } + + .question-mark { + display: none; + } + + .reviewer-number { + display: none; + } +} + +.pending { + .persona { + display: block; + fill: var(--color-pending); + } + + .check { + display: none; + } + + .x { + display: none; + } + + .question-mark { + display: block; + fill: var(--color-pending); + } + + .reviewer-number { + display: none; + } +} + +.submitted { + .persona { + display: block; + fill: var(--color-primary); + } + + .check { + display: none; + } + + .x { + display: none; + } + + .question-mark { + display: none; + } + + .reviewer-number { + fill: var(--color-primary); + font-family: 'Fira Sans Condensed', sans-serif; + font-size: 50px; + font-style: normal; + font-weight: 600; + stroke: none; + text-transform: uppercase; + } +} + +.fullname { + color: red; + font-family: var(--font-reviewer); +} diff --git a/packages/ui/src/atoms/Avatar.md b/packages/ui/src/atoms/Avatar.md new file mode 100644 index 0000000000000000000000000000000000000000..e4928b9d74b3981947bf7c38797fc0ad6b0628fc --- /dev/null +++ b/packages/ui/src/atoms/Avatar.md @@ -0,0 +1,10 @@ +A general purpose Avatar element. + +```js +const statusFactory = () => { + const statuses = ['Accepted', 'Pending', 'Declined', 'Submitted'] + return statuses[Math.floor(Math.random() * statuses.length)] +}; + +<Avatar status={statusFactory()}/> +``` diff --git a/packages/ui/src/atoms/Badge.js b/packages/ui/src/atoms/Badge.js new file mode 100644 index 0000000000000000000000000000000000000000..eb6f00420bc27789af4fc134841df0fd81c1ef54 --- /dev/null +++ b/packages/ui/src/atoms/Badge.js @@ -0,0 +1,13 @@ +import React from 'react' +import classes from './Badge.local.scss' + +const Badge = ({ count, label, plural }) => ( + <span className={classes.root}> + <span className={classes.count}>{count}</span> + <span className={classes.label}> + {plural && count !== 1 ? plural : label} + </span> + </span> +) + +export default Badge diff --git a/packages/ui/src/atoms/Badge.local.scss b/packages/ui/src/atoms/Badge.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..b5eb36513d24d1164aa00e4eabec4cc6c5369c8d --- /dev/null +++ b/packages/ui/src/atoms/Badge.local.scss @@ -0,0 +1,25 @@ +.root { + align-items: center; + // background: lightgrey; + background: linear-gradient(#fff 0, #fff 1.1em, grey 1.1em, grey 1.15em, #fff 1.15em, #fff 2em); + color: inherit; + display: inline-flex; + font-size: 0.8rem; + margin-right: 1em; + padding-bottom: 1em; +} + +.count { + border-radius: 50%; + color: grey; + font-size: 1em; + font-weight: 600; + padding-right: 0.5em; + text-align: center; +} + +.label { + display: inline-block; + padding: 0; + text-shadow: 0.05em 0.05em 0 #fff, -0.05em -0.05em 0 #fff, -0.05em 0.05em 0 #fff, 0.05em -0.05em 0 #fff; +} diff --git a/packages/ui/src/atoms/Badge.md b/packages/ui/src/atoms/Badge.md new file mode 100644 index 0000000000000000000000000000000000000000..ed418eaab3bd6d585c9e3cf629251605c3d289c1 --- /dev/null +++ b/packages/ui/src/atoms/Badge.md @@ -0,0 +1,16 @@ +A badge that displays a count and a label. + +```js +<Badge count={5} label="created"/> +``` + +A plural form of the label can be provided. + +```js +<div> + <Badge count={1} label="thing" plural="things"/> + <Badge count={99} label="thing" plural="things"/> + <Badge count={0} label="thing" plural="things"/> + <Badge count={299} label="thing" plural="things"/> +</div> +``` diff --git a/packages/ui/src/atoms/Button.js b/packages/ui/src/atoms/Button.js new file mode 100644 index 0000000000000000000000000000000000000000..3f8163badc80d4292438e582d3bebb7bd00f1ace --- /dev/null +++ b/packages/ui/src/atoms/Button.js @@ -0,0 +1,26 @@ +import React from 'react' +import classnames from 'classnames' +import classes from './Button.local.scss' + +const Button = ({ + className, + children, + type = 'button', + disabled, + primary, + onClick, +}) => ( + <button + className={classnames(className, classes.root, { + [classes.disabled]: disabled, + [classes.primary]: primary, + })} + disabled={disabled} + onClick={onClick} + type={type} + > + {children} + </button> +) + +export default Button diff --git a/packages/ui/src/atoms/Button.local.scss b/packages/ui/src/atoms/Button.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..75b469bc14603e396f885531617c2fd83e869c56 --- /dev/null +++ b/packages/ui/src/atoms/Button.local.scss @@ -0,0 +1,103 @@ +.root { + background: #ddd; + border: none; + cursor: pointer; + font-family: var(--font-interface); + font-size: inherit; + letter-spacing: 0.05em; + padding: 10px 20px; + position: relative; + text-transform: uppercase; +} + +.root:hover, +.root:focus { + background: #777; + color: white; + outline: 1px solid transparent; +} + +// this will be added to the button that need a feedback to the user. +// &::after { +// content: "Saved!"; +// top: 20%; +// left: 115%; +// position: absolute; +// background: var(--color-primary); +// color: white; +// padding: 0.1em 0.3em; +// opacity: 0; +// } + +.root :active { + transform: scale(0.8); +} + +.root ::after { + animation: 1s warning; + opacity: 1; +} + +.primary { + background-color: var(--color-primary); + border: 2px solid transparent; + border-bottom: 4px solid var(--color-primary); + color: white; +} + +.primary:hover { + background: white; + border: 2px solid var(--color-primary); + border-bottom: 4px solid var(--color-primary); + color: var(--color-primary); + outline: 1px solid transparent; +} + +.primary:focus { + background: white; + border: 2px solid var(--color-primary); + border-bottom: 4px solid var(--color-primary); + box-shadow: 0 2px 0 0 var(--color-primary); + color: var(--color-primary); + outline: 1px solid transparent; +} + +.disabled { + background: white; + border: 2px solid transparent; + border-bottom: 2px solid #bbb; + color: #bbb; +} + +.disabled:hover { + background: transparent; + border: 2px solid transparent; + border-bottom: 2px solid #bbb; + color: #aaa; + cursor: not-allowed; +} + +.disabled:hover::after { + color: var(--color-danger); + content: "sorry, this action is not possible"; + display: inline; + font-size: 0.9em; + font-style: italic; + left: 115%; + letter-spacing: 0; + opacity: 1; + position: absolute; + text-align: left; + text-transform: lowercase; + top: 30%; + // width: 30ch; +} + +.addFile { + background: none; + border: none; + font-style: normal; + letter-spacing: 0; + padding: 0; + text-transform: none; +} diff --git a/packages/ui/src/atoms/Button.md b/packages/ui/src/atoms/Button.md new file mode 100644 index 0000000000000000000000000000000000000000..a7871d1b755b82b856d71378e689454757dacaeb --- /dev/null +++ b/packages/ui/src/atoms/Button.md @@ -0,0 +1,18 @@ +A button. + +```js + +<Button>Save</Button> +``` + +A button can be disabled. + +```js +<Button disabled>Save</Button> +``` + +A button can be marked as the "primary" action. + +```js +<Button primary>Save</Button> +``` diff --git a/packages/ui/src/atoms/Checkbox.js b/packages/ui/src/atoms/Checkbox.js new file mode 100644 index 0000000000000000000000000000000000000000..987849bf2ccc01165d1b585750da116b0223fd08 --- /dev/null +++ b/packages/ui/src/atoms/Checkbox.js @@ -0,0 +1,32 @@ +import React from 'react' +import classnames from 'classnames' +import classes from './Checkbox.local.scss' + +const Checkbox = ({ + inline, + name, + value, + label, + checked, + required, + onChange, +}) => ( + <label + className={classnames(classes.root, { + [classes.inline]: inline, + })} + > + <input + checked={checked || false} + className={classes.input} + name={name} + onChange={onChange} + required={required} + type="checkbox" + value={value} + /> + <span>{label}</span> + </label> +) + +export default Checkbox diff --git a/packages/ui/src/atoms/Checkbox.local.scss b/packages/ui/src/atoms/Checkbox.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..3c66c3b3a2ed0f1472126755f2e553b155a3ac39 --- /dev/null +++ b/packages/ui/src/atoms/Checkbox.local.scss @@ -0,0 +1,56 @@ +.root { + align-items: center; + display: flex; + font-family: var(--font-author); + font-size: 1em; + font-style: italic; + letter-spacing: 1px; + transition: all 2s; +} + +.root.inline { + display: inline-flex; +} + +.root.inline:not(:last-child) { + margin-right: 2.7em; +} + +.root:not(.inline):not(:last-child) { + margin-bottom: 0.5rem; +} + +.root .input { + display: none; + margin-right: 0.25rem; +} + +.root span::before { + background-size: 0; + border: 1px solid black; // border-radius: 20px; + content: ' '; + display: inline-block; + height: 9px; + margin-right: 0.3em; + transition: border 0.5s ease, background-size 0.3s ease; + vertical-align: middle; + width: 9px; +} + +.root:hover span::before { + //background-size: 100%; + background: var(--color-primary); + box-shadow: inset 1px 1px 0 0 white, inset -1px -1px 0 0 white; +} + +.root input:checked + span { + font-weight: 600; + + &::before { + background: black; + border: 1px solid black; + box-shadow: inset 1px 1px 0 0 white, inset -1px -1px 0 0 white; + transition: border 0.5s ease, background-size 0.3s ease; + } +} + diff --git a/packages/ui/src/atoms/Checkbox.md b/packages/ui/src/atoms/Checkbox.md new file mode 100644 index 0000000000000000000000000000000000000000..ceffd9ae8740dc66ddeb773f2a4099ef7fee2be6 --- /dev/null +++ b/packages/ui/src/atoms/Checkbox.md @@ -0,0 +1,33 @@ +A checkbox. + +```js +initialState = { checked: null }; + +<Checkbox + name="checkbox" + checked={state.checked} + onChange={event => setState({ checked: event.target.checked })}/> +``` + +A checked checkbox. + +```js +initialState = { checked: true }; + +<Checkbox + name="checkbox-checked" + checked={state.checked} + onChange={event => setState({ checked: event.target.checked })}/> +``` + +A checkbox with a label. + +```js +initialState = { checked: false }; + +<Checkbox + name="checkbox-labelled" + checked={state.checked} + label="Foo" + onChange={event => setState({ checked: event.target.checked })}/> +``` diff --git a/packages/ui/src/atoms/File.js b/packages/ui/src/atoms/File.js new file mode 100644 index 0000000000000000000000000000000000000000..93e5ef6d12136c7b25db379dc6c1d24fd32a3637 --- /dev/null +++ b/packages/ui/src/atoms/File.js @@ -0,0 +1,20 @@ +import React from 'react' +import classes from './File.local.scss' + +const extension = ({ name }) => name.replace(/^.+\./, '') + +const File = ({ value }) => ( + <div className={classes.root}> + <div className={classes.icon}> + <div className={classes.extension}>{extension(value)}</div> + </div> + + <div className={classes.name}> + <a download={value.name} href={value.url}> + {value.name} + </a> + </div> + </div> +) + +export default File diff --git a/packages/ui/src/atoms/File.local.scss b/packages/ui/src/atoms/File.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..b751734209305fadec52e1127df5c48d4ae7f83c --- /dev/null +++ b/packages/ui/src/atoms/File.local.scss @@ -0,0 +1,101 @@ +.root { + align-items: center; + display: inline-flex; + flex-direction: column; + margin-bottom: 2em; + margin-right: 3em; + position: relative; + width: 20ch; + + &::before, + &::after { + cursor: pointer; + transition: transform 0.3s; + } + + &::after { + background: var(--color-danger); + border: 1px solid white; + color: white; + content: 'remove'; + cursor: pointer; + font-size: 0.8em; + left: 70%; + letter-spacing: 0.5px; + padding: 0.2em 0.4em; + position: absolute; + text-transform: uppercase; + top: 4em; + transform: scaleX(0); + transform-origin: 0 0; + z-index: 2; + } + + &::before { + background: var(--color-primary); + border: 1px solid white; + color: white; + content: 'replace'; + cursor: pointer; + font-size: 0.8em; + left: 70%; + letter-spacing: 0.5px; + padding: 0.2em 0.4em; + position: absolute; + text-transform: uppercase; + top: 6em; + transform: scaleX(0); + transform-origin: 0 0; + z-index: 3; + } + + .icon { + background: #ddd; + height: 100px; + padding: 5px; + position: relative; + transition: transform 0.3s ease; + width: 70px; + } + + .extension { + background: #888; + color: white; + font-size: 12px; + left: 20px; + padding: 2px; + position: absolute; + right: 0; + text-align: center; + text-transform: uppercase; + top: 20px; + } + + .name { + color: #aaa; + font-size: 0.9em; + font-style: italic; + margin: 5px; + max-width: 15ch; + text-align: center; + word-break: break-all; /* to divide into lines */ + } + + &:hover { + .extension { + background: white; + border-right: 2px solid #ddd; + color: var(--color-primary); + } + + .icon { + background: var(--color-primary); + transform: skewY(6deg) rotate(-6deg); + } + + &::after, + &::before { + transform: scaleX(1); + } + } +} diff --git a/packages/ui/src/atoms/File.md b/packages/ui/src/atoms/File.md new file mode 100644 index 0000000000000000000000000000000000000000..fe482efec8cf6a7c0a415456133adfde90930f7e --- /dev/null +++ b/packages/ui/src/atoms/File.md @@ -0,0 +1,31 @@ +A file. + +```js +const value = { + name: faker.system.commonFileName(), + // type: faker.system.commonFileType(), + // size: faker.random.number(), +}; + +<File value={value}/> +``` + +Upload progress is displayed as an overlay. + +```js +const value = { + name: faker.system.commonFileName(), +}; + +<File value={value} progress={0.5}/> +``` + +An upload error is displayed above the file. + +```js +const value = { + name: faker.system.commonFileName(), +}; + +<File value={value} error="There was an error"/> +``` diff --git a/packages/ui/src/atoms/Icon.js b/packages/ui/src/atoms/Icon.js new file mode 100644 index 0000000000000000000000000000000000000000..26a904615c5d9d4477665a7fe0b268d440657177 --- /dev/null +++ b/packages/ui/src/atoms/Icon.js @@ -0,0 +1,16 @@ +import React from 'react' +import { pascalize } from 'humps' +import * as icons from 'react-feather' +import classes from './Icon.local.scss' + +const Icon = ({ children, color = 'black', size = 24 }) => { + // convert `arrow_left` to `ArrowLeft` + const name = pascalize(children) + + // select the icon + const icon = icons[name] + + return <span className={classes.root}>{icon({ color, size })}</span> +} + +export default Icon diff --git a/packages/ui/src/atoms/Icon.local.scss b/packages/ui/src/atoms/Icon.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..dfc0a60d1bb3b49d4f363268b978b8339adb0fa2 --- /dev/null +++ b/packages/ui/src/atoms/Icon.local.scss @@ -0,0 +1,3 @@ +.root { + display: inline-flex; +} diff --git a/packages/ui/src/atoms/Icon.md b/packages/ui/src/atoms/Icon.md new file mode 100644 index 0000000000000000000000000000000000000000..bbee0593695c749fbedce849fd13fcc740629a74 --- /dev/null +++ b/packages/ui/src/atoms/Icon.md @@ -0,0 +1,17 @@ +An icon, from the [Feather](https://feathericons.com/) icon set. + +```js +<Icon>arrow_right</Icon> +``` + +The color can be changed. + +```js +<Icon color="red">arrow_right</Icon> +``` + +The size can be changed. + +```js +<Icon size={48}>arrow_right</Icon> +``` diff --git a/packages/ui/src/atoms/Menu.js b/packages/ui/src/atoms/Menu.js new file mode 100644 index 0000000000000000000000000000000000000000..e1e23a051b40c41c37b6c5da0dd3991f39f1ed39 --- /dev/null +++ b/packages/ui/src/atoms/Menu.js @@ -0,0 +1,90 @@ +import React from 'react' +import classnames from 'classnames' +import classes from './Menu.local.scss' + +// TODO: match the width of the container to the width of the widest option? +// TODO: use a <select> element instead of divs? + +class Menu extends React.Component { + constructor(props) { + super(props) + + this.state = { + open: false, + selected: props.value, + } + } + + toggleMenu = () => { + this.setState({ + open: !this.state.open, + }) + } + + handleSelect = selected => { + this.setState({ + open: false, + selected, + }) + + this.props.onChange(selected) + } + + optionLabel = value => { + const { options } = this.props + + return options.find(option => option.value === value).label + } + + render() { + const { label, options, placeholder = 'Choose in the list' } = this.props + const { open, selected } = this.state + + return ( + <div + className={classnames(classes.root, { + [classes.open]: open, + })} + > + {label && <span className={classes.label}>{label}</span>} + + <div className={classes.main}> + <div className={classes.openerContainer}> + <button + className={classes.opener} + onClick={this.toggleMenu} + type="button" + > + {selected ? ( + <span>{this.optionLabel(selected)}</span> + ) : ( + <span className={classes.placeholder}>{placeholder}</span> + )} + <span className={classes.arrow}>▼</span> + </button> + </div> + + <div className={classes.optionsContainer}> + {open && ( + <div className={classes.options}> + {options.map(option => ( + <div + className={classnames(classes.option, { + [classes.active]: option.value === selected, + })} + key={option.value} + onClick={() => this.handleSelect(option.value)} + > + {option.label || option.value} + </div> + ))} + </div> + )} + </div> + </div> + </div> + ) + } +} + +export default Menu diff --git a/packages/ui/src/atoms/Menu.local.scss b/packages/ui/src/atoms/Menu.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..67555549e2164db6720decd22af548b8411ac0cf --- /dev/null +++ b/packages/ui/src/atoms/Menu.local.scss @@ -0,0 +1,92 @@ +.root { + align-items: center; + display: flex; +} + +.label { + margin-right: 0.5em; +} + +.main { + position: relative; +} + +.optionsContainer { + position: absolute; +} + +.opener { + align-items: center; + background: transparent; + border: none; + border-left: 2px solid transparent; + cursor: pointer; + display: flex; + font-family: var(--font-author); + font-size: inherit; + outline: none; +} + +.opener:hover { + color: var(--color-primary); +} + +.open .opener { + border-left: 2px solid var(--color-primary); + color: var(--color-primary); +} + +.placeholder { + color: #aaa; + font-family: var(--font-interface); + font-weight: 400; +} + +.opener:hover .placeholder { + color: var(--color-primary); +} + +.arrow { + font-size: 50%; + margin-left: 10px; + transform: scaleY(1.2) scaleX(2.2); + transition: transform 0.2s; +} + +.open .arrow { + transform: scaleX(2.2) scaleY(-1.2); +} + +.options { + background-color: white; + border-bottom: 2px solid var(--color-primary); + border-left: 2px solid var(--color-primary); + // columns: 2 auto; + left: 0; + min-width: 10em; + opacity: 0; + padding-top: 0.5em; + position: absolute; + top: 0; + z-index: 10; +} + +.open .options { + opacity: 1; +} + +.option { + cursor: pointer; + font-family: var(--font-author); + padding: 10px; + white-space: nowrap; +} + +.option:hover { + color: var(--color-primary); +} + +.active { + color: black; + font-weight: 600; /* placeholder for the semibold */ +} diff --git a/packages/ui/src/atoms/Menu.md b/packages/ui/src/atoms/Menu.md new file mode 100644 index 0000000000000000000000000000000000000000..0b26206190ff90dd143dce60b5a8551f330da004 --- /dev/null +++ b/packages/ui/src/atoms/Menu.md @@ -0,0 +1,44 @@ +A menu for selecting one of a list of options. + +```js +const options = [ + { value: 'foo', label: 'Foo' }, + { value: 'bar', label: 'Bar' }, + { value: 'baz', label: 'Baz' } +]; + +<Menu + options={options} + onChange={value => console.log(value)}/> +``` + +When an option is selected, it replaces the placeholder. + +```js +const options = [ + { value: 'foo', label: 'Foo' }, + { value: 'bar', label: 'Bar' }, + { value: 'baz', label: 'Baz' } +]; + +<Menu + options={options} + value="foo" + onChange={value => console.log(value)}/> +``` + +A menu can have a label + +```js +const options = [ + { value: 'foo', label: 'Foo' }, + { value: 'bar', label: 'Bar' }, + { value: 'baz', label: 'Baz' } +]; + +<Menu + options={options} + label="Title" + value="foo" + onChange={value => console.log(value)}/> +``` diff --git a/packages/ui/src/atoms/Radio.js b/packages/ui/src/atoms/Radio.js new file mode 100644 index 0000000000000000000000000000000000000000..2a706cd17c580eaf2f6f3fd66961021c56faf49e --- /dev/null +++ b/packages/ui/src/atoms/Radio.js @@ -0,0 +1,50 @@ +import React from 'react' +import classnames from 'classnames' +import classes from './Radio.local.scss' + +const inputGradient = color => + `radial-gradient(closest-corner at center, ${color} 0%, ${color} 45%, + white 45%, white 100%)` + +const Radio = ({ + className, + color = 'black', + inline, + name, + value, + label, + checked, + required, + onChange, +}) => ( + <label + className={classnames( + classes.root, + { + [classes.inline]: inline, + [classes.checked]: checked, + }, + className, + )} + style={{ color }} + > + <input + checked={checked} + className={classes.input} + name={name} + onChange={onChange} + required={required} + type="radio" + value={value} + /> + <span + className={classes.pseudoInput} + style={{ background: checked ? inputGradient(color) : 'transparent' }} + > + {' '} + </span> + <span className={classes.label}>{label}</span> + </label> +) + +export default Radio diff --git a/packages/ui/src/atoms/Radio.local.scss b/packages/ui/src/atoms/Radio.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..49f699cb136a177a36311a8d45eba9118c982e38 --- /dev/null +++ b/packages/ui/src/atoms/Radio.local.scss @@ -0,0 +1,59 @@ +.root { + align-items: center; + cursor: pointer; + display: flex; + transition: all 2s; +} + +.root.inline { + display: inline-flex; +} + +.root.inline:not(:last-child) { + margin-right: 2.7em; +} + +.root:not(.inline):not(:last-child) { + margin-bottom: 0.5rem; +} + +/* label text */ + +.label { + display: inline-block; + font-family: inherit; + font-size: 1em; + font-style: italic; + letter-spacing: 1px; +} + +.checked .label { + font-weight: 600; +} + +// hide the input + +.input { + display: none; +} + +/* pseudo-input */ + +.pseudoInput { + background-size: 0; + border-color: transparent; + border-radius: 10px; + box-shadow: 0 0 0 1px; + content: " "; + display: inline-block; + height: 10px; + margin-right: 0.3em; + transition: border 0.5s ease, background-size 0.3s ease; + vertical-align: center; + width: 10px; +} + +.root:not(.checked):hover .pseudoInput { + background: radial-gradient(closest-corner at center, var(--color-primary) 0%, var(--color-primary) 30%, white 30%, white 100%); + box-shadow: 0 0 0 1px var(--color-primary); +} diff --git a/packages/ui/src/atoms/Radio.md b/packages/ui/src/atoms/Radio.md new file mode 100644 index 0000000000000000000000000000000000000000..96aeba898363cbf87a549e7f35f33f0f57277caa --- /dev/null +++ b/packages/ui/src/atoms/Radio.md @@ -0,0 +1,54 @@ +A radio button. + +```js +initialState = { + value: undefined +}; + +<Radio + name="radio" + checked={state.value === 'on'} + onChange={event => setState({ value: event.target.value })}/> +``` + +A checked radio button. + +```js +initialState = { + value: 'on' +}; + +<Radio + name="radio-checked" + checked={state.value === 'on'} + onChange={event => setState({ value: event.target.value })}/> +``` + +A radio button with a label. + +```js +initialState = { + value: undefined +}; + +<Radio + name="radio-checked" + label="Foo" + checked={state.value === 'on'} + onChange={event => setState({ value: event.target.value })}/> +``` + +A radio button with a color. + +```js +initialState = { + value: undefined +}; + +<Radio + name="radio-color" + label="Foo" + color="red" + checked={state.value === 'on'} + onChange={event => setState({ value: event.target.value })}/> +``` diff --git a/packages/ui/src/atoms/Tags.js b/packages/ui/src/atoms/Tags.js new file mode 100644 index 0000000000000000000000000000000000000000..c16f599471e153782d0baaba22f6b4cc8e2af537 --- /dev/null +++ b/packages/ui/src/atoms/Tags.js @@ -0,0 +1,59 @@ +import React from 'react' +import ReactTags from 'react-tag-autocomplete' +import './Tags.scss' + +// TODO: separate tags when pasted +// TODO: allow tags to be edited + +class Tags extends React.Component { + constructor(props) { + super(props) + + this.state = { + tags: props.value || [], + } + } + + handleDelete = index => { + const { tags } = this.state + + tags.splice(index, 1) + + this.setState({ tags }) + + this.props.onChange(tags) + } + + handleAddition = tag => { + const { tags } = this.state + + tags.push(tag) + + this.setState({ tags }) + + this.props.onChange(tags) + } + + render() { + const { tags } = this.state + const { name, suggestions, placeholder } = this.props + + return ( + <ReactTags + allowNew + autofocus={false} + // TODO: enable these when react-tag-autocomplete update is released + // delimiters={[]} + // delimiterChars={[',', ';']} + handleAddition={this.handleAddition} + handleDelete={this.handleDelete} + name={name} + placeholder={placeholder} + suggestions={suggestions} + tags={tags} + /> + ) + } +} + +export default Tags diff --git a/packages/ui/src/atoms/Tags.md b/packages/ui/src/atoms/Tags.md new file mode 100644 index 0000000000000000000000000000000000000000..9bfba763b5edb93e7b82586909ec789ddca94ab2 --- /dev/null +++ b/packages/ui/src/atoms/Tags.md @@ -0,0 +1,21 @@ +A form input for a list of tags. + +```js +<Tags + onChange={value => console.log(value)}/> +``` + +Existing values can be passed in, and the placeholder can be customized. + +```js +const value = [ + {name: 'foo'}, + {name: 'bar'}, + {name: 'baz'} +]; + +<Tags + value={value} + placeholder="Add new keyword" + onChange={value => console.log(value)}/> +``` diff --git a/packages/ui/src/atoms/Tags.scss b/packages/ui/src/atoms/Tags.scss new file mode 100644 index 0000000000000000000000000000000000000000..991d4395c9aeecf405f5e19ca396f6ac346151ed --- /dev/null +++ b/packages/ui/src/atoms/Tags.scss @@ -0,0 +1,129 @@ +/* stylelint-disable */ + +/* trying to reuse some parts frome pubsweet website: the mixins for the underlines for the tags */ + +$color: var(--color-primary); +$color-back: white; + +@mixin realBorder($color, $colorback) { + background: linear-gradient($colorback 0, $colorback 1.2em, $color 1.2em, $color 1.25em, $colorback 1.25em, $colorback 2em) no-repeat; + text-shadow: 0.05em 0.05em 0 $colorback, -0.05em -0.05em 0 $colorback, -0.05em 0.05em 0 $colorback, 0.05em -0.05em 0 $colorback; +} + +.root { + font-family: "Fira Sans Condensed", sans-serif; +} + +.react-tags { + position: relative; + padding: 6px 0 0 6px; + font-size: 1em; + line-height: 1.2; + + /* clicking anywhere will focus the input */ + cursor: text; +} + +.react-tags.is-focused { + border-color: var(--color-primary); +} + +.react-tags__selected { + display: inline; +} + +.react-tags__selected-tag { + font-family: "Vollkorn", serif; + display: inline-block; + box-sizing: border-box; + margin: 0 1em 1em 0; + padding: 0.1em 0.3em; + border: 0 solid transparent; + @include realBorder(#aaa, white); + + /* match the font styles */ + font-size: inherit; + line-height: inherit; + cursor: pointer; +} + +.react-tags__selected-tag::after { + content: '\2715'; + margin-left: 8px; + padding: 3px 0 0; + // margin: 0; + display: inline-block; + width: 13px; + height: 10px; + font-size: 0.9em; + background: white; + color: #aaa; + font-weight: 600; + text-shadow: none; + + &:hover { + background: var(--color-primary); + } +} + +.react-tags__selected-tag:hover, +.react-tags__selected-tag:focus { + @include realBorder(transparent, white); + + text-decoration: line-through; + + &::after { + color: var(--color-danger); + } +} + +.react-tags__search { + display: inline-block; + + /* match tag layout */ + margin: 0 1em 1em 0; + padding: 0.1em 0.3em; + + /* prevent autoresize overflowing the container */ + max-width: 100px; +} + +@media screen and (min-width: 30em) { + .react-tags__search { + /* this will become the offsetParent for suggestions */ + position: relative; + } +} + +.react-tags__search input { + /* prevent autoresize overflowing the container */ + max-width: 100%; + + /* remove styles and layout from this element */ + margin: 0; + padding: 0; + border: 0; + outline: none; + + /* match the font styles */ + font-size: inherit; + line-height: inherit; + border-bottom: 1px dashed grey; + min-width: 15ch; + font-family: "Vollkorn", serif; + color: black; // color: red; + + &::placeholder { + font-family: "Fira Sans Condensed", sans-serif; + opacity: 0.5; + } + + &:focus, + &:hover { + border-bottom: 1px dashed var(--color-primary); + } +} + +.react-tags__search input::-ms-clear { + display: none; +} diff --git a/packages/ui/src/atoms/TextField.js b/packages/ui/src/atoms/TextField.js new file mode 100644 index 0000000000000000000000000000000000000000..443c52b5c2c74efcec233a5d5554fcc307f54946 --- /dev/null +++ b/packages/ui/src/atoms/TextField.js @@ -0,0 +1,29 @@ +import React from 'react' +import classes from './TextField.local.scss' + +const TextField = ({ + label, + name, + placeholder, + required, + type = 'text', + value = '', + onBlur, + onChange, +}) => ( + <label className={classes.root}> + {label && <span className={classes.text}>{label}</span>} + <input + className={classes.input} + name={name} + onBlur={onBlur} + onChange={onChange} + placeholder={placeholder} + required={required} + type={type} + value={value} + /> + </label> +) + +export default TextField diff --git a/packages/ui/src/atoms/TextField.local.scss b/packages/ui/src/atoms/TextField.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..b857af22d142c3148eb332d2017759bb0575ad3f --- /dev/null +++ b/packages/ui/src/atoms/TextField.local.scss @@ -0,0 +1,35 @@ +.root { + align-items: center; + display: flex; +} + +.text { + margin-right: 10px; +} + +.input { + flex: 1; + font-size: inherit; + padding: 0.5em; +} + +.root input { + border: 0 none; + border-bottom: 1px dashed #aaa; + font-family: "Vollkorn", serif; + padding: 0; + + &:hover, + &:focus { + border-bottom: 1px dashed var(--color-primary); + border-color: transparent; + box-shadow: none; + outline-style: none; + } +} + +.root input::placeholder { + color: #777; + font-family: var(--font-interface); + font-style: italic; +} diff --git a/packages/ui/src/atoms/TextField.md b/packages/ui/src/atoms/TextField.md new file mode 100644 index 0000000000000000000000000000000000000000..4c574319195f1bff587ffa8c94961b5d38c5df37 --- /dev/null +++ b/packages/ui/src/atoms/TextField.md @@ -0,0 +1,23 @@ +A form input for plain text. + + +```js +initialState = { value: '' }; + +<TextField + value={state.value} + placeholder="so you can write some in here" + onChange={event => setState({ value: event.target.value })}/> +``` + +The input can have a label. + +```js +initialState = { value: '' }; + +<TextField + label="Foo" + value={state.value} + placeholder="so you can write some in here" + onChange={event => setState({ value: event.target.value })}/> +``` diff --git a/packages/ui/src/atoms/UploadingFile.js b/packages/ui/src/atoms/UploadingFile.js new file mode 100644 index 0000000000000000000000000000000000000000..b5f6b6ad6ec878e14af98a0760cdbb81a0b3b28e --- /dev/null +++ b/packages/ui/src/atoms/UploadingFile.js @@ -0,0 +1,27 @@ +import React from 'react' +import classes from './UploadingFile.local.scss' + +// TODO: cancel button + +const extension = ({ name }) => name.replace(/^.+\./, '') + +const UploadingFile = ({ file, error, progress }) => ( + <div className={classes.root}> + {!!error && <div className={classes.error}>{error}</div>} + + <div className={classes.icon}> + {!!progress && ( + <div + className={classes.progress} + style={{ top: `${progress * 100}%` }} + /> + )} + + <div className={classes.extension}>{extension(file)}</div> + </div> + + <div className={classes.name}>{file.name}</div> + </div> +) + +export default UploadingFile diff --git a/packages/ui/src/atoms/UploadingFile.local.scss b/packages/ui/src/atoms/UploadingFile.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..895148f991ee7a40d95a9b28975b0510ed529111 --- /dev/null +++ b/packages/ui/src/atoms/UploadingFile.local.scss @@ -0,0 +1,113 @@ +.root { + align-items: center; + display: inline-flex; + flex-direction: column; + margin-bottom: 2em; + margin-right: 3em; + position: relative; + width: 20ch; +} + +.icon { + background: #ddd; + height: 100px; + margin: 5px; + opacity: 0.5; + position: relative; + width: 70px; +} + +.progress { + background-image: + linear-gradient( + var(--color-primary-light) 50%, + var(--color-primary) 75%, + to top + ); + bottom: 0; + content: ''; + display: block; + left: 0; + opacity: 1; + position: absolute; + right: 0; + transform-origin: 0 0; + + &::after { + /* we can use a data attribute for the numbering below */ + bottom: 2px; + color: white; + content: "00%"; + display: block; + position: absolute; + right: 2px; + } +} + +.error { + background: var(--color-danger); + border: 2px solid white; + color: white; + font-size: 0.8em; + letter-spacing: 0.01em; + opacity: 1; + padding: 0.3em 0.5em; + position: absolute; + top: 25%; + z-index: 4; +} + +.extension { + background: #888; + color: white; + font-size: 12px; + left: 20px; + padding: 2px; + position: absolute; + right: 0; + text-align: center; + text-transform: uppercase; + top: 20px; +} + +.name { + color: gray; + font-size: 90%; + font-style: italic; + margin: 5px; + max-width: 20ch; +} + +// clock experiment, on hold. +//.progress { +// opacity: 1; +// background: var(--color-primary); +// position: absolute; +// bottom: 10%; +// right: 10%; +// content: ''; +// width: 3px; +// height: 1em; +// display: block; +// // margin-left: 30%; +// transform-origin: 0 0; +// animation: rotate 1s infinite ease-in-out ; +// background-image: +// &:after { +// content: "uploading"; +// display: block; +// position: absolute; +// width: 1em; +// height: 1em; +// } +//} +// +// +//@keyframes rotate { +// 0% { +// transform: rotate(0) +// } +// 100% { +// transform: rotate(360deg); +// } +//} diff --git a/packages/ui/src/atoms/UploadingFile.md b/packages/ui/src/atoms/UploadingFile.md new file mode 100644 index 0000000000000000000000000000000000000000..7a9db24c9529226c34bc9262ed3ec23ad1a5e091 --- /dev/null +++ b/packages/ui/src/atoms/UploadingFile.md @@ -0,0 +1,29 @@ +A file that's being uploaded. + +```js +const file = { + name: faker.system.commonFileName() +}; + +<UploadingFile file={file}/> +``` + +Upload progress is displayed as an overlay. + +```js +const file = { + name: faker.system.commonFileName(), +}; + +<UploadingFile file={file} progress={0.5}/> +``` + +An upload error is displayed above the file. + +```js +const file = { + name: faker.system.commonFileName(), +}; + +<UploadingFile file={file} error="There was an error"/> +``` diff --git a/packages/ui/src/atoms/ValidatedField.js b/packages/ui/src/atoms/ValidatedField.js new file mode 100644 index 0000000000000000000000000000000000000000..379accfcf98dbe0dbf5613f9bf08deadcdb4b875 --- /dev/null +++ b/packages/ui/src/atoms/ValidatedField.js @@ -0,0 +1,40 @@ +import React from 'react' +import { compose, withHandlers } from 'recompose' +import classnames from 'classnames' +import { Field } from 'redux-form' +import classes from './ValidatedField.local.scss' + +// TODO: pass ...props.input to children automatically? + +const ValidatedFieldComponent = ({ component }) => ({ meta, input }) => ( + <div> + {component(input)} + + {meta.touched && + (meta.error || meta.warning) && ( + <div className={classes.messages}> + {meta.error && ( + <div className={classnames(classes.message, classes.error)}> + {meta.error} + </div> + )} + + {meta.warning && ( + <div className={classnames(classes.message, classes.warning)}> + {meta.warning} + </div> + )} + </div> + )} + </div> +) + +const ValidatedField = ({ fieldComponent, ...rest }) => ( + <Field {...rest} component={fieldComponent} /> +) + +export default compose( + withHandlers({ + fieldComponent: ValidatedFieldComponent, + }), +)(ValidatedField) diff --git a/packages/ui/src/atoms/ValidatedField.local.scss b/packages/ui/src/atoms/ValidatedField.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..3ba44df3b8f9ad4dc2fabec0743c8b7040960b3b --- /dev/null +++ b/packages/ui/src/atoms/ValidatedField.local.scss @@ -0,0 +1,28 @@ +.root { + font-family: var(--font-author); +} + +.messages { + display: inline-block; + font-style: italic; + margin-left: 1em; + margin-top: 10px; +} + +.message:not(:last-child) { + margin-bottom: 10px; +} + +.error, +.warning { + font-size: 0.9em; + letter-spacing: 0.01em; +} + +.error { + color: var(--color-danger); +} + +.warning { + color: var(--color-warning); +} diff --git a/packages/ui/src/atoms/ValidatedField.md b/packages/ui/src/atoms/ValidatedField.md new file mode 100644 index 0000000000000000000000000000000000000000..136df6151838d3a57fea4c08e20b4feabaa49aa2 --- /dev/null +++ b/packages/ui/src/atoms/ValidatedField.md @@ -0,0 +1,33 @@ +A form field that displays the results of validation. + +```js +const { reduxForm } = require('redux-form'); + +const ValidatedFieldForm = reduxForm({ + form: 'validated-field-error', + onChange: values => console.log(values) +})(ValidatedField); + +const TextInput = input => <TextField {...input}/>; + +<ValidatedFieldForm + name="error" + validate={() => 'Required'} + component={TextInput}/> +``` + +```js +const { reduxForm } = require('redux-form'); + +const ValidatedFieldForm = reduxForm({ + form: 'validated-field-warning', + onChange: values => console.log(values) +})(ValidatedField); + +const TextInput = input => <TextField {...input}/>; + +<ValidatedFieldForm + name="warning" + warn={() => 'Expected'} + component={TextInput}/> +``` diff --git a/packages/ui/src/index.js b/packages/ui/src/index.js new file mode 100644 index 0000000000000000000000000000000000000000..699a30066e284dfea5a98d1645c10b6dbb4c74ef --- /dev/null +++ b/packages/ui/src/index.js @@ -0,0 +1,23 @@ +/* atoms */ +export { default as Attachment } from './atoms/Attachment' +export { default as Avatar } from './atoms/Avatar' +export { default as Badge } from './atoms/Badge' +export { default as Button } from './atoms/Button' +export { default as Checkbox } from './atoms/Checkbox' +export { default as File } from './atoms/File' +export { default as Icon } from './atoms/Icon' +export { default as Menu } from './atoms/Menu' +export { default as Radio } from './atoms/Radio' +export { default as Tags } from './atoms/Tags' +export { default as TextField } from './atoms/TextField' +export { default as ValidatedField } from './atoms/ValidatedField' + +/* molecules */ +export { default as AppBar } from './molecules/AppBar' +export { default as Attachments } from './molecules/Attachments' +export { default as CheckboxGroup } from './molecules/CheckboxGroup' +export { default as Files } from './molecules/Files' +export { default as PlainButton } from './molecules/PlainButton' +export { default as Supplementary } from './molecules/Supplementary' +export { default as RadioGroup } from './molecules/RadioGroup' +export { default as YesOrNo } from './molecules/YesOrNo' diff --git a/packages/ui/src/lib/animation.scss b/packages/ui/src/lib/animation.scss new file mode 100644 index 0000000000000000000000000000000000000000..21b94773710e9976eb746ef7a3e142ebf53c7999 --- /dev/null +++ b/packages/ui/src/lib/animation.scss @@ -0,0 +1,29 @@ +/* This file is here to share animations between modules, not used yet.. */ + +@keyframes bounce { + 33% { + transform: translateY(-20px); + } + + 66% { + transform: translateY(0); + } +} + +.bounce { + animation: bounce 1s infinite ease-in-out; +} + +@keyframes rotate { + from { + transform: rotate(0); + } + + to { + transform: rotate(360deg); + } +} + +.rotate { + animation: rotate 1s infinite ease-in-out; +} diff --git a/packages/ui/src/lib/colors.local.scss b/packages/ui/src/lib/colors.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..e06cf2a6b7113312ebe42ec117ee71a92416e3b7 --- /dev/null +++ b/packages/ui/src/lib/colors.local.scss @@ -0,0 +1,4 @@ +.primary { + background-color: cornflowerblue; + color: white; +} diff --git a/packages/ui/src/molecules/AppBar.js b/packages/ui/src/molecules/AppBar.js new file mode 100644 index 0000000000000000000000000000000000000000..2601c8fb971969089183b0146b6ac84ecc8cf1c5 --- /dev/null +++ b/packages/ui/src/molecules/AppBar.js @@ -0,0 +1,40 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import classnames from 'classnames' +import classes from './AppBar.local.scss' +import Icon from '../atoms/Icon' + +const AppBar = ({ brandLink, brandName, loginLink, logoutLink, userName }) => ( + <div className={classes.root}> + <Link + className={classnames(classes.link, classes.logo)} + to={brandLink || '/'} + > + {brandName} + </Link> + + <div className={classes.actions}> + {userName && ( + <span className={classes.item}> + <Icon size={16}>user</Icon> + <span className={classes.username}>{userName}</span> + </span> + )} + + {userName ? ( + <Link + className={classnames(classes.item, classes.link)} + to={logoutLink} + > + logout + </Link> + ) : ( + <Link className={classnames(classes.item, classes.link)} to={loginLink}> + login + </Link> + )} + </div> + </div> +) + +export default AppBar diff --git a/packages/ui/src/molecules/AppBar.local.scss b/packages/ui/src/molecules/AppBar.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..bfe123d50b041f6973b9582e40a1f6f3c1506925 --- /dev/null +++ b/packages/ui/src/molecules/AppBar.local.scss @@ -0,0 +1,43 @@ +.root { + display: flex; + justify-content: space-between; +} + +.link { + color: var(--color-primary); + + &::before { + color: #aaa; + display: inline-block; + height: 1em; + margin-right: 0.3em; + text-align: center; + } +} + +.link:hover { + cursor: pointer; + text-decoration: underline; + + &::before { + color: var(--color-primary); + } +} + +.item { + align-items: center; + display: inline-flex; + padding: 0 1rem; +} + +.actions { + display: flex; +} + +.username { + margin-left: 0.3em; +} + +.logo::before { + content: ""; +} diff --git a/packages/ui/src/molecules/AppBar.md b/packages/ui/src/molecules/AppBar.md new file mode 100644 index 0000000000000000000000000000000000000000..a2b7acebcb00a8a399caf7ba6d1e28fe9a39946a --- /dev/null +++ b/packages/ui/src/molecules/AppBar.md @@ -0,0 +1,20 @@ +The app bar appears at the top of every page of the application. + +It displays the name of the application (as a link to the home page), the username of the current user, and a link to sign out. + +```js +<AppBar + brandName="xpub" + loginLink="/login" + logoutLink="/logout" + userName="foo"/> +``` + +When the user is not signed in, only the login link is displayed. + +```js +<AppBar + brandName="xpub" + loginLink="/login" + logoutLink="/logout"/> +``` diff --git a/packages/ui/src/molecules/Attachments.js b/packages/ui/src/molecules/Attachments.js new file mode 100644 index 0000000000000000000000000000000000000000..c05eb487934d3afd84eb781165042e96655e562e --- /dev/null +++ b/packages/ui/src/molecules/Attachments.js @@ -0,0 +1,25 @@ +import React from 'react' +import Files from './Files' +import Attachment from '../atoms/Attachment' +import classes from './Attachments.local.scss' +import Icon from '../atoms/Icon' + +// TODO: show upload progress + +const Attachments = props => ( + <Files + {...props} + buttonText="Attach file" + uploadedFile={value => <Attachment key={value.url} value={value} />} + uploadingFile={({ file, progress, error }) => ( + <div className={classes.uploading}> + <span className={classes.icon}> + <Icon color="var(--color-primary)">paperclip</Icon> + </span> + <span className={classes.filename}>{error || 'Uploading…'}</span> + </div> + )} + /> +) + +export default Attachments diff --git a/packages/ui/src/molecules/Attachments.local.scss b/packages/ui/src/molecules/Attachments.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..67c036fe4dcea0013b463abcf34e2e5442dea10c --- /dev/null +++ b/packages/ui/src/molecules/Attachments.local.scss @@ -0,0 +1,13 @@ +.uploading { + align-items: center; + display: flex; +} + +.icon { + color: gray; + margin-right: 10px; +} + +.filename { + color: gray; +} diff --git a/packages/ui/src/molecules/Attachments.md b/packages/ui/src/molecules/Attachments.md new file mode 100644 index 0000000000000000000000000000000000000000..01dbde245a9731b110829726f65d2ad3a928c44f --- /dev/null +++ b/packages/ui/src/molecules/Attachments.md @@ -0,0 +1,18 @@ +A list of files attached to a note, and a button to attach a new file. + +```js +const value = [ + { + name: faker.system.commonFileName(), + url: faker.internet.url() + }, + { + name: faker.system.commonFileName(), + url: faker.internet.url() + } +]; + +<Attachments + value={value} + uploadFile={file => new XMLHttpRequest()}/> +``` diff --git a/packages/ui/src/molecules/CheckboxGroup.js b/packages/ui/src/molecules/CheckboxGroup.js new file mode 100644 index 0000000000000000000000000000000000000000..152bf26ce5e01870b61d93588c33245c92dcf4d7 --- /dev/null +++ b/packages/ui/src/molecules/CheckboxGroup.js @@ -0,0 +1,52 @@ +import React from 'react' +import Checkbox from '../atoms/Checkbox' + +class CheckboxGroup extends React.Component { + constructor(props) { + super(props) + + this.state = { + values: props.value || [], + } + } + + handleChange = event => { + const { values } = this.state + + const { value } = event.target + + if (event.target.checked) { + values.push(value) + } else { + values.splice(values.indexOf(value), 1) + } + + this.setState({ values }) + + this.props.onChange(values) + } + + render() { + const { inline, name, options, required } = this.props + const { values } = this.state + + return ( + <div> + {options.map(option => ( + <Checkbox + checked={values.includes(option.value)} + inline={inline} + key={option.value} + label={option.label} + name={name} + onChange={this.handleChange} + required={required} + value={option.value} + /> + ))} + </div> + ) + } +} + +export default CheckboxGroup diff --git a/packages/ui/src/molecules/CheckboxGroup.md b/packages/ui/src/molecules/CheckboxGroup.md new file mode 100644 index 0000000000000000000000000000000000000000..7b43e13ec6e62ce36d12f745fc02a7272ca5c0ef --- /dev/null +++ b/packages/ui/src/molecules/CheckboxGroup.md @@ -0,0 +1,54 @@ +A group of checkboxes. + +```js +const options = [ + { + value: 'one', + label: 'One' + }, + { + value: 'two', + label: 'Two' + }, + { + value: 'three', + label: 'Three' + } +]; + +initialState = { value: [] }; + +<CheckboxGroup + name="checkboxgroup" + options={options} + value={state.value} + onChange={value => setState({ value })}/> +``` + +The checkboxes can be displayed inline. + +```js +const options = [ + { + value: 'one', + label: 'One' + }, + { + value: 'two', + label: 'Two' + }, + { + value: 'three', + label: 'Three' + } +]; + +initialState = { value: [] }; + +<CheckboxGroup + name="checkboxgroup-inline" + options={options} + value={state.value} + inline={true} + onChange={value => setState({ value })}/> +``` diff --git a/packages/ui/src/molecules/Files.js b/packages/ui/src/molecules/Files.js new file mode 100644 index 0000000000000000000000000000000000000000..d5d52d1b47c2205fdecca3cd3f3712d3e4a4574a --- /dev/null +++ b/packages/ui/src/molecules/Files.js @@ -0,0 +1,91 @@ +import React from 'react' +import classes from './Files.local.scss' +import Upload from './Upload' + +class Files extends React.Component { + constructor(props) { + super(props) + + this.state = { + values: props.value || [], + uploads: [], + } + } + + handleClick = () => { + this.fileInput.click() + } + + handleChange = event => { + const { uploads } = this.state + + Array.from(event.target.files).forEach(file => { + uploads.push({ + file, + request: this.props.uploadFile(file), + }) + }) + + this.setState({ uploads }) + } + + handleUploadedFile = ({ file, url }) => { + const values = this.state.values.concat({ + name: file.name, + url, + }) + + const uploads = this.state.uploads.filter( + item => item.file.name !== file.name, + ) + + this.setState({ values, uploads }) + + this.props.onChange(values) + } + + render() { + const { name, buttonText, uploadingFile, uploadedFile } = this.props + const { values, uploads } = this.state + + return ( + <div className={classes.root}> + <div className={classes.upload}> + <button + className={classes.attach} + onClick={() => this.fileInput.click()} + type="button" + > + {buttonText} + </button> + + <input + className={classes.input} + multiple + name={name} + onChange={this.handleChange} + ref={input => (this.fileInput = input)} + type="file" + /> + </div> + + <div className={classes.files}> + {uploads && + uploads.map(upload => ( + <Upload + file={upload.file} + handleUploadedFile={this.handleUploadedFile} + key={upload.file.name} + render={uploadingFile} + request={upload.request} + /> + ))} + + {values && values.map(uploadedFile)} + </div> + </div> + ) + } +} + +export default Files diff --git a/packages/ui/src/molecules/Files.local.scss b/packages/ui/src/molecules/Files.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..b6e58a85f85b3148688af89b7f95a724b630b35b --- /dev/null +++ b/packages/ui/src/molecules/Files.local.scss @@ -0,0 +1,34 @@ +.input { + display: none; +} + +.button { + background: transparent; + border: 1px dashed grey; + cursor: pointer; + font-family: inherit; + font-size: inherit; + margin-bottom: 2em; + padding: 10px; +} + +.button:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.files { + font-size: 0.9em; + font-style: italic; + line-height: 1.5; +} + +.attach { + background: transparent; + border: 1px dashed grey; + cursor: pointer; + font-family: inherit; + font-size: inherit; + margin-bottom: 2em; + padding: 10px; +} diff --git a/packages/ui/src/molecules/Files.md b/packages/ui/src/molecules/Files.md new file mode 100644 index 0000000000000000000000000000000000000000..c13062d6641b1f23c7c24cea004b05c2c70a7973 --- /dev/null +++ b/packages/ui/src/molecules/Files.md @@ -0,0 +1,22 @@ +A list of uploaded files, a list of uploading files and a button to upload more files. + +```js +const file = () => ({ + name: faker.system.commonFileName(), + type: faker.system.commonFileType(), + size: faker.random.number(), +}); + +const value = [ + file(), + file(), + file() +]; + +<Files + value={value} + buttonText="↑ Choose a file to upload" + uploadingFile={({ file, progress, error }) => <div style={{color:'gray'}}>{file.name}</div>} + uploadedFile={value => <div>{value.name}</div>} + uploadFile={file => new XMLHttpRequest()}/> +``` diff --git a/packages/ui/src/molecules/PlainButton.js b/packages/ui/src/molecules/PlainButton.js new file mode 100644 index 0000000000000000000000000000000000000000..dc71790905fe3b0defa827ba2580c5795a096deb --- /dev/null +++ b/packages/ui/src/molecules/PlainButton.js @@ -0,0 +1,25 @@ +import React from 'react' +import classnames from 'classnames' +import Button from '../atoms/Button' +import classes from './PlainButton.local.scss' + +const PlainButton = ({ + className, + children, + type, + disabled, + primary, + onClick, +}) => ( + <Button + className={classnames(classes.root, className)} + disabled={disabled} + onClick={onClick} + primary={primary} + type={type} + > + {children} + </Button> +) + +export default PlainButton diff --git a/packages/ui/src/molecules/PlainButton.local.scss b/packages/ui/src/molecules/PlainButton.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..2b196748161d96f86cfa06052ace9ef19932740e --- /dev/null +++ b/packages/ui/src/molecules/PlainButton.local.scss @@ -0,0 +1,21 @@ +.root.root { + background: none; + border: 0; + border-bottom: 2px solid #777; + font-style: italic; + letter-spacing: 0; + padding: 0; + text-transform: none; +} + +.root.root:hover, +.root.root:focus { + background: transparent; + border: 0; + border-bottom: 2px solid var(--color-primary); + color: var(--color-primary); +} + +.root.root:active { + transform: scale(0.99); +} diff --git a/packages/ui/src/molecules/PlainButton.md b/packages/ui/src/molecules/PlainButton.md new file mode 100644 index 0000000000000000000000000000000000000000..97f05703cedc8b627544470e8ca68473a8d682dd --- /dev/null +++ b/packages/ui/src/molecules/PlainButton.md @@ -0,0 +1,5 @@ +A button that is styled as a link. + +```js +<PlainButton>Take me back.</PlainButton> +``` diff --git a/packages/ui/src/molecules/RadioGroup.js b/packages/ui/src/molecules/RadioGroup.js new file mode 100644 index 0000000000000000000000000000000000000000..75b2f4f65659459c7f9ef642514d4c3140eaf74e --- /dev/null +++ b/packages/ui/src/molecules/RadioGroup.js @@ -0,0 +1,43 @@ +import React from 'react' +import Radio from '../atoms/Radio' + +class RadioGroup extends React.Component { + constructor(props) { + super(props) + + this.state = { + value: props.value, + } + } + + handleChange = event => { + const { value } = event.target + this.setState({ value }) + this.props.onChange(value) + } + + render() { + const { inline, name, options, required } = this.props + const { value } = this.state + + return ( + <div> + {options.map(option => ( + <Radio + checked={option.value === value} + color={option.color} + inline={inline} + key={option.value} + label={option.label} + name={name} + onChange={this.handleChange} + required={required} + value={option.value} + /> + ))} + </div> + ) + } +} + +export default RadioGroup diff --git a/packages/ui/src/molecules/RadioGroup.md b/packages/ui/src/molecules/RadioGroup.md new file mode 100644 index 0000000000000000000000000000000000000000..1a753db59c4690d172d23adc208908ff691c9e88 --- /dev/null +++ b/packages/ui/src/molecules/RadioGroup.md @@ -0,0 +1,48 @@ +A group of radio buttons. + +```js +const options = [ + { + value: 'one', + label: 'One' + }, + { + value: 'two', + label: 'Two' + }, + { + value: 'three', + label: 'Three' + } +]; + +<RadioGroup + options={options} + name="radiogroup" + onChange={value => console.log(value)}/> +``` + +The buttons can be displayed inline + +```js +const options = [ + { + value: 'one', + label: 'One' + }, + { + value: 'two', + label: 'Two' + }, + { + value: 'three', + label: 'Three' + } +]; + +<RadioGroup + options={options} + name="radiogroup-inline" + inline={true} + onChange={value => console.log(value)}/> +``` diff --git a/packages/ui/src/molecules/Supplementary.js b/packages/ui/src/molecules/Supplementary.js new file mode 100644 index 0000000000000000000000000000000000000000..b876ff92e2202bae6aa5f51dedfa53e2b600edc7 --- /dev/null +++ b/packages/ui/src/molecules/Supplementary.js @@ -0,0 +1,22 @@ +import React from 'react' +import Files from './Files' +import UploadingFile from '../atoms/UploadingFile' +import File from '../atoms/File' + +const Supplementary = props => ( + <Files + {...props} + buttonText="↑ Upload files" + uploadedFile={value => <File key={value.url} value={value} />} + uploadingFile={({ file, progress, error }) => ( + <UploadingFile + error={error} + file={file} + key={file.name} + progress={progress} + /> + )} + /> +) + +export default Supplementary diff --git a/packages/ui/src/molecules/Supplementary.md b/packages/ui/src/molecules/Supplementary.md new file mode 100644 index 0000000000000000000000000000000000000000..c95a11fb4ef2b02637b55caebb0463ab8b451126 --- /dev/null +++ b/packages/ui/src/molecules/Supplementary.md @@ -0,0 +1,18 @@ +A list of supplementary files, and a button to upload a new file. + +```js +const value = [ + { + name: faker.system.commonFileName(), + url: faker.internet.url() + }, + { + name: faker.system.commonFileName(), + url: faker.internet.url() + } +]; + +<Supplementary + value={value} + uploadFile={file => new XMLHttpRequest()}/> +``` diff --git a/packages/ui/src/molecules/Upload.js b/packages/ui/src/molecules/Upload.js new file mode 100644 index 0000000000000000000000000000000000000000..cb1e244bade4c5619baebc3e60e18cc073fc372d --- /dev/null +++ b/packages/ui/src/molecules/Upload.js @@ -0,0 +1,67 @@ +import React from 'react' + +// TODO: retry on error +// TODO: make this a HOC for <UploadingFile>? + +class Upload extends React.Component { + state = { + error: undefined, + progress: 0, + } + + componentDidMount() { + const { request } = this.props + + request.addEventListener('progress', this.handleProgress) + request.addEventListener('load', this.handleLoad) + request.addEventListener('error', this.handleError) + request.addEventListener('abort', this.handleAbort) + } + + // TODO: 'progress' event not being fired often enough? + handleProgress = event => { + if (!event.lengthComputable) return + + this.setState({ + progress: event.loaded / event.total, + }) + } + + handleLoad = event => { + if (this.props.request.status === 200) { + this.setState({ + progress: 1, + }) + + this.props.handleUploadedFile({ + file: this.props.file, + url: this.props.request.responseText, + }) + } else { + this.setState({ + error: 'There was an error', + }) + } + } + + handleError = event => { + this.setState({ + error: 'There was an error', + }) + } + + handleAbort = event => { + this.setState({ + error: 'The upload was cancelled', + }) + } + + render() { + const { file, render } = this.props + const { progress, error } = this.state + + return render({ file, progress, error }) + } +} + +export default Upload diff --git a/packages/ui/src/molecules/YesOrNo.js b/packages/ui/src/molecules/YesOrNo.js new file mode 100644 index 0000000000000000000000000000000000000000..31cac4ddde4d536dfa59e556a50e60bc57bc53e4 --- /dev/null +++ b/packages/ui/src/molecules/YesOrNo.js @@ -0,0 +1,28 @@ +import React from 'react' +import RadioGroup from './RadioGroup' +import classes from './YesOrNo.local.scss' + +const options = [ + { + label: 'Yes', + value: 'yes', + }, + { + label: 'No', + value: 'no', + }, +] + +const YesOrNo = ({ name, value, required, onChange }) => ( + <RadioGroup + className={classes.root} + inline + name={name} + onChange={onChange} + options={options} + required={required} + value={value} + /> +) + +export default YesOrNo diff --git a/packages/ui/src/molecules/YesOrNo.local.scss b/packages/ui/src/molecules/YesOrNo.local.scss new file mode 100644 index 0000000000000000000000000000000000000000..46dab18015cd6719c15572af877419dc61c91ba9 --- /dev/null +++ b/packages/ui/src/molecules/YesOrNo.local.scss @@ -0,0 +1,3 @@ +.root { + color: darkgreen; +} diff --git a/packages/ui/src/molecules/YesOrNo.md b/packages/ui/src/molecules/YesOrNo.md new file mode 100644 index 0000000000000000000000000000000000000000..1d410998ef2012670f7702352ac4530c31964b9b --- /dev/null +++ b/packages/ui/src/molecules/YesOrNo.md @@ -0,0 +1,17 @@ +A group of radio buttons that provides just two options: "Yes" or "No" + +```js +<YesOrNo + name="yesorno" + onChange={value => console.log(value)}/> +``` + +If a value is set, one option is selected. + +```js +<YesOrNo + name="yesorno-value" + value="yes" + onChange={value => console.log(value)}/> +``` + diff --git a/packages/ui/styleguide.config.js b/packages/ui/styleguide.config.js new file mode 100644 index 0000000000000000000000000000000000000000..00c0f2396fc676020a0e9c4807aeba98f3101ef6 --- /dev/null +++ b/packages/ui/styleguide.config.js @@ -0,0 +1,39 @@ +module.exports = { + context: { + faker: 'faker', + }, + sections: [ + { + content: 'docs/colors.md', + name: 'Colors', + }, + { + content: 'docs/fonts.md', + name: 'Fonts', + }, + { + components: 'src/atoms/*.js', + name: 'Atoms', + }, + { + components: 'src/molecules/*.js', + name: 'Molecules', + }, + ], + skipComponentsWithoutExample: true, + styleguideComponents: { + StyleGuideRenderer: require.resolve( + '@pubsweet/styleguide/src/components/StyleGuideRenderer', + ), + Wrapper: require.resolve('@pubsweet/styleguide/src/components/Wrapper'), + }, + theme: { + color: { + link: 'cornflowerblue', + }, + fontFamily: { + base: '"Fira Sans", sans-serif', + }, + }, + title: 'PubSweet UI style guide', +} diff --git a/packages/ui/test/AppBar.test.js b/packages/ui/test/AppBar.test.js new file mode 100644 index 0000000000000000000000000000000000000000..25c7946b6adf8fa703dd27701b83e14b55d46443 --- /dev/null +++ b/packages/ui/test/AppBar.test.js @@ -0,0 +1,103 @@ +import React from 'react' +import { shallow } from 'enzyme' +import { Link, MemoryRouter } from 'react-router-dom' +import renderer from 'react-test-renderer' + +import AppBar from '../src/molecules/AppBar' + +const props = { + brandLink: 'some link', + brandName: 'some brand', + loginLink: 'login link', + logoutLink: 'logout link', + userName: 'some name', +} + +function makeWrapper(extraProps = {}) { + return shallow( + <MemoryRouter> + <AppBar {...props} {...extraProps} /> + </MemoryRouter>, + ) + .dive() + .dive() +} + +describe('AppBar', () => { + test('Snapshot', () => { + const tree = renderer + .create( + <MemoryRouter> + <AppBar {...props} /> + </MemoryRouter>, + ) + .toJSON() + expect(tree).toMatchSnapshot() + }) + + test("Should link the brand to '/' if no brand link is given", () => { + const wrapper = makeWrapper({ brandLink: undefined }) + + const brand = wrapper.childAt(0) + expect(brand.prop('to')).toBe('/') + }) + + test('Should link the brand to the given prop', () => { + const wrapper = makeWrapper() + const brand = wrapper.childAt(0) + expect(brand.prop('to')).toBe(props.brandLink) + }) + + test('Should display the brand name', () => { + const wrapper = makeWrapper() + const brand = wrapper.childAt(0) + const brandName = brand.childAt(0) + + expect(brandName.text()).toBe(props.brandName) + }) + + test('Should not display the username if there is none given', () => { + const wrapper = makeWrapper({ userName: undefined }) + + const rightArea = wrapper.childAt(1) + + // If the username does not display, there is only child (login / logout) + expect(rightArea.children).toHaveLength(1) + }) + + test('Should display the username', () => { + const wrapper = makeWrapper() + const rightArea = wrapper.childAt(1) + expect(rightArea.children()).toHaveLength(2) + + const userName = rightArea.childAt(0) + expect(userName.text()).toBe('<Icon />some name') + }) + + test('Should display the login link if no username is given', () => { + const wrapper = makeWrapper({ userName: undefined }) + + const rightArea = wrapper.childAt(1) + const logLink = rightArea.childAt(0) // first el if there is no username + + expect(logLink.is(Link)).toBeTruthy() + expect(logLink.prop('to')).toBe(props.loginLink) + expect(logLink.children()).toHaveLength(1) + + const logLinkText = logLink.childAt(0) + expect(logLinkText.text()).toBe('login') + }) + + test('Should display the logout link if a username is found', () => { + const wrapper = makeWrapper() + const rightArea = wrapper.childAt(1) + const logLink = rightArea.childAt(1) // 2nd el if there is a username + + expect(logLink.is(Link)).toBeTruthy() + expect(logLink.prop('to')).toBe(props.logoutLink) + expect(logLink.children()).toHaveLength(1) + + const logLinkText = logLink.childAt(0) + expect(logLinkText.text()).toBe('logout') + }) +}) diff --git a/packages/ui/test/Menu.test.js b/packages/ui/test/Menu.test.js new file mode 100644 index 0000000000000000000000000000000000000000..b71f3a4d45edc01ed42381be5777f074fcbe3e16 --- /dev/null +++ b/packages/ui/test/Menu.test.js @@ -0,0 +1,24 @@ +import React from 'react' +import { shallow } from 'enzyme' +import renderer from 'react-test-renderer' + +import Menu from '../src/atoms/Menu' + +const props = { + options: [{ label: 'Foo', value: 'foo' }, { label: 'Bar', value: 'bar' }], + value: 'foo', +} + +const wrapper = shallow(<Menu {...props} />) + +describe('Menu', () => { + test('Snapshot', () => { + const tree = renderer.create(<Menu {...props} />).toJSON() + expect(tree).toMatchSnapshot() + }) + + test('Renders a Menu', () => { + expect(wrapper.is('div')).toBeTruthy() + expect(wrapper).toHaveLength(1) + }) +}) diff --git a/packages/ui/test/Radio.test.js b/packages/ui/test/Radio.test.js new file mode 100644 index 0000000000000000000000000000000000000000..35d3309950474493fef25b8ca1ab14dc0d792ab6 --- /dev/null +++ b/packages/ui/test/Radio.test.js @@ -0,0 +1,31 @@ +import React from 'react' +import { shallow } from 'enzyme' +import renderer from 'react-test-renderer' + +import Radio from '../src/atoms/Radio' + +const props = { + checked: false, + label: 'TestLabel', + name: 'TestName', + required: true, + value: 'TestValue', +} + +const wrapper = shallow(<Radio {...props} />) + +describe('Radio', () => { + test('Snapshot', () => { + const tree = renderer.create(<Radio {...props} />).toJSON() + expect(tree).toMatchSnapshot() + }) + + test('Input gets the correct props', () => { + const input = wrapper.find('input') + + expect(input.prop('name')).toBe(props.name) + expect(input.prop('value')).toBe(props.value) + expect(input.prop('checked')).toBe(props.checked) + expect(input.prop('required')).toBe(props.required) + }) +}) diff --git a/packages/ui/test/RadioGroup.test.js b/packages/ui/test/RadioGroup.test.js new file mode 100644 index 0000000000000000000000000000000000000000..b6ff69ef39461862f79ff197e6d755e935fef36a --- /dev/null +++ b/packages/ui/test/RadioGroup.test.js @@ -0,0 +1,110 @@ +import React from 'react' +import { clone } from 'lodash' +import { shallow } from 'enzyme' +import renderer from 'react-test-renderer' + +import Radio from '../src/atoms/Radio' +import RadioGroup from '../src/molecules/RadioGroup' + +const props = { + name: 'TestName', + options: [ + { + label: 'Yes', + value: 'yes', + }, + { + label: 'No', + value: 'no', + }, + { + label: 'Maybe', + value: 'maybe', + }, + ], + required: true, + value: undefined, +} + +const wrapper = shallow(<RadioGroup {...props} />) +const radios = wrapper.find(Radio) + +describe('Radio Group', () => { + test('Snapshot', () => { + const tree = renderer.create(<RadioGroup {...props} />).toJSON() + expect(tree).toMatchSnapshot() + }) + + test('Renders a number of radio buttons', () => { + expect(wrapper.is('div')).toBeTruthy() + + const len = props.options.length + expect(wrapper.children()).toHaveLength(len) + + let i = 0 + + while (i < len) { + const child = wrapper.childAt(i) + expect(child.is(Radio)).toBeTruthy() + + i += 1 + } + + expect(radios).toHaveLength(len) + }) + + test('Radios get the correct props', () => { + const radioComps = radios.getElements() + let i = 0 + + while (i < props.options.length) { + const radio = radioComps[i] + const radioProps = radio.props + + expect(radioProps.label).toEqual(props.options[i].label) + expect(radioProps.value).toEqual(props.options[i].value) + expect(radioProps.name).toEqual(props.name) + expect(radioProps.required).toEqual(props.required) + + i += 1 + } + }) + + test('Value should match the checked radio button', () => { + // With no radio button selected + const radioComps = radios.getElements() + let i = 0 + + while (i < props.options.length) { + const radio = radioComps[i] + const radioProps = radio.props + + expect(radioProps.checked).toBeFalsy() + i += 1 + } + + // With the first radio button selected + // (re-initialise the wrapper with changed props) + const newProps = clone(props) + newProps.value = 'yes' + + const newWrapper = shallow(<RadioGroup {...newProps} />) + const newRadios = newWrapper.find(Radio) + const newRadioComps = newRadios.getElements() + + i = 0 + + while (i < props.options.length) { + const radio = newRadioComps[i] + const radioProps = radio.props + + if (i === 0) { + expect(radioProps.checked).toBeTruthy() + } else { + expect(radioProps.checked).toBeFalsy() + } + + i += 1 + } + }) +}) diff --git a/packages/ui/test/YesOrNo.test.js b/packages/ui/test/YesOrNo.test.js new file mode 100644 index 0000000000000000000000000000000000000000..b893b903cce438c5fe4b62cf43c34e2cc8cde713 --- /dev/null +++ b/packages/ui/test/YesOrNo.test.js @@ -0,0 +1,47 @@ +import React from 'react' +import { shallow } from 'enzyme' +import renderer from 'react-test-renderer' + +import YesOrNo from '../src/molecules/YesOrNo' +import RadioGroup from '../src/molecules/RadioGroup' + +const props = { + name: 'TestName', + value: 'Maybe', +} + +const wrapper = shallow(<YesOrNo {...props} />) +const radio = wrapper.find(RadioGroup) + +describe('Yes or No', () => { + test('Snapshot', () => { + const tree = renderer.create(<YesOrNo {...props} />).toJSON() + expect(tree).toMatchSnapshot() + }) + + test('Renders a RadioGroup', () => { + expect(wrapper.is('RadioGroup')).toBeTruthy() + expect(radio).toHaveLength(1) + }) + + test('Passes the correct options', () => { + const { options } = radio.props() + expect(options).toHaveLength(2) + + expect(options[0].value).toEqual('yes') + expect(options[0].label).toEqual('Yes') + + expect(options[1].value).toEqual('no') + expect(options[1].label).toEqual('No') + }) + + test('Passes down the correct name', () => { + const { name } = radio.props() + expect(name).toEqual(props.name) + }) + + test('Passes down the correct value', () => { + const { value } = radio.props() + expect(value).toEqual(props.value) + }) +}) diff --git a/packages/ui/test/__snapshots__/AppBar.test.js.snap b/packages/ui/test/__snapshots__/AppBar.test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..df74fa0cbcd219aab3fe3f049f69271b1350509a --- /dev/null +++ b/packages/ui/test/__snapshots__/AppBar.test.js.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AppBar Snapshot 1`] = ` +<div + className="root" +> + <a + className="link logo" + href="some link" + onClick={[Function]} + > + some brand + </a> + <div + className="actions" + > + <span + className="item" + > + <span + className="root" + > + <svg + height={16} + viewBox="0 0 24 24" + width={16} + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" + fill="none" + stroke="black" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth="2" + /> + <circle + cx="12" + cy="7" + fill="none" + r="4" + stroke="black" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth="2" + /> + </svg> + </span> + <span + className="username" + > + some name + </span> + </span> + <a + className="item link" + href="logout link" + onClick={[Function]} + > + logout + </a> + </div> +</div> +`; diff --git a/packages/ui/test/__snapshots__/Menu.test.js.snap b/packages/ui/test/__snapshots__/Menu.test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..dfbe9ad318925c3ed9e42427e4ac528348d3e5e5 --- /dev/null +++ b/packages/ui/test/__snapshots__/Menu.test.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Menu Snapshot 1`] = ` +<div + className="root" +> + <div + className="main" + > + <div + className="openerContainer" + > + <button + className="opener" + onClick={[Function]} + type="button" + > + <span> + Foo + </span> + <span + className="arrow" + > + ▼ + </span> + </button> + </div> + <div + className="optionsContainer" + /> + </div> +</div> +`; diff --git a/packages/ui/test/__snapshots__/Radio.test.js.snap b/packages/ui/test/__snapshots__/Radio.test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..cac26b5df927cf7e71f647293fe3426618ecbabb --- /dev/null +++ b/packages/ui/test/__snapshots__/Radio.test.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Radio Snapshot 1`] = ` +<label + className="root" + style={ + Object { + "color": "black", + } + } +> + <input + checked={false} + className="input" + name="TestName" + onChange={undefined} + required={true} + type="radio" + value="TestValue" + /> + <span + className="pseudoInput" + style={ + Object { + "background": "transparent", + } + } + > + + </span> + <span + className="label" + > + TestLabel + </span> +</label> +`; diff --git a/packages/ui/test/__snapshots__/RadioGroup.test.js.snap b/packages/ui/test/__snapshots__/RadioGroup.test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..9302b211fb65bc0bd451d4a0b63910a5ee503bfb --- /dev/null +++ b/packages/ui/test/__snapshots__/RadioGroup.test.js.snap @@ -0,0 +1,105 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Radio Group Snapshot 1`] = ` +<div> + <label + className="root" + style={ + Object { + "color": "black", + } + } + > + <input + checked={false} + className="input" + name="TestName" + onChange={[Function]} + required={true} + type="radio" + value="yes" + /> + <span + className="pseudoInput" + style={ + Object { + "background": "transparent", + } + } + > + + </span> + <span + className="label" + > + Yes + </span> + </label> + <label + className="root" + style={ + Object { + "color": "black", + } + } + > + <input + checked={false} + className="input" + name="TestName" + onChange={[Function]} + required={true} + type="radio" + value="no" + /> + <span + className="pseudoInput" + style={ + Object { + "background": "transparent", + } + } + > + + </span> + <span + className="label" + > + No + </span> + </label> + <label + className="root" + style={ + Object { + "color": "black", + } + } + > + <input + checked={false} + className="input" + name="TestName" + onChange={[Function]} + required={true} + type="radio" + value="maybe" + /> + <span + className="pseudoInput" + style={ + Object { + "background": "transparent", + } + } + > + + </span> + <span + className="label" + > + Maybe + </span> + </label> +</div> +`; diff --git a/packages/ui/test/__snapshots__/YesOrNo.test.js.snap b/packages/ui/test/__snapshots__/YesOrNo.test.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..13fee7b65d9c8447fe223f9098e3fd89f819b9fe --- /dev/null +++ b/packages/ui/test/__snapshots__/YesOrNo.test.js.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Yes or No Snapshot 1`] = ` +<div> + <label + className="root inline" + style={ + Object { + "color": "black", + } + } + > + <input + checked={false} + className="input" + name="TestName" + onChange={[Function]} + required={undefined} + type="radio" + value="yes" + /> + <span + className="pseudoInput" + style={ + Object { + "background": "transparent", + } + } + > + + </span> + <span + className="label" + > + Yes + </span> + </label> + <label + className="root inline" + style={ + Object { + "color": "black", + } + } + > + <input + checked={false} + className="input" + name="TestName" + onChange={[Function]} + required={undefined} + type="radio" + value="no" + /> + <span + className="pseudoInput" + style={ + Object { + "background": "transparent", + } + } + > + + </span> + <span + className="label" + > + No + </span> + </label> +</div> +`; diff --git a/packages/ui/test/config/transform.js b/packages/ui/test/config/transform.js new file mode 100644 index 0000000000000000000000000000000000000000..7cc533c7f6c6251ce1b71bb5171ee6ef28991c4d --- /dev/null +++ b/packages/ui/test/config/transform.js @@ -0,0 +1,3 @@ +module.exports = require('babel-jest').createTransformer({ + presets: ['env', 'react', 'stage-2'], +}) diff --git a/packages/ui/test/setup/enzyme.js b/packages/ui/test/setup/enzyme.js new file mode 100644 index 0000000000000000000000000000000000000000..7ae191afcbbe1d62f2497f8634fcfe952bb6b520 --- /dev/null +++ b/packages/ui/test/setup/enzyme.js @@ -0,0 +1,4 @@ +import Enzyme from 'enzyme' +import Adapter from 'enzyme-adapter-react-15' + +Enzyme.configure({ adapter: new Adapter() }) diff --git a/packages/ui/webpack.config.js b/packages/ui/webpack.config.js new file mode 100644 index 0000000000000000000000000000000000000000..4709531b59eca773c8d2f8bb2d141e79d51f2b05 --- /dev/null +++ b/packages/ui/webpack.config.js @@ -0,0 +1,3 @@ +const webpackConfig = require('@pubsweet/styleguide/src/webpack-config') + +module.exports = webpackConfig(__dirname)