├── .flowconfig ├── .travis.yml ├── src ├── hocs │ ├── index.js │ └── updateWithQuery.jsx ├── ui │ ├── Icon │ │ ├── icon.scss │ │ ├── index.js │ │ ├── glyphs.js │ │ ├── icons │ │ │ ├── tick.svg │ │ │ ├── add.svg │ │ │ ├── heart.svg │ │ │ ├── copy.svg │ │ │ └── view.svg │ │ └── Icon.jsx │ ├── Checkbox │ │ ├── index.js │ │ ├── Checkbox.jsx │ │ └── checkbox.scss │ ├── Button │ │ ├── index.js │ │ ├── Button.story.jsx │ │ ├── ButtonWithIcon.jsx │ │ ├── Button.jsx │ │ └── button.scss │ ├── Layout │ │ ├── styles │ │ │ ├── container.scss │ │ │ └── layout.scss │ │ ├── index.js │ │ ├── Layout.jsx │ │ ├── Container.jsx │ │ ├── Main.jsx │ │ └── Sidebar.jsx │ ├── index.js │ └── Burger │ │ ├── Burger.jsx │ │ └── burger.scss ├── styles │ ├── index.js │ ├── resources │ │ ├── mixins.scss │ │ └── variables.scss │ └── main.scss ├── modules │ ├── color │ │ ├── types.js │ │ ├── actions.js │ │ └── reducer.js │ ├── selection │ │ ├── types.js │ │ ├── actions.js │ │ └── reducer.js │ ├── explore │ │ ├── types.js │ │ ├── actions.js │ │ └── reducer.js │ └── index.js ├── pages │ ├── index.js │ ├── ExportPage │ │ └── ExportPage.jsx │ ├── ExplorePage │ │ └── ExplorePage.jsx │ └── IndexPage │ │ ├── index-page.scss │ │ └── IndexPage.jsx ├── containers │ ├── index.js │ ├── LuminosityGroup.jsx │ ├── Presets.jsx │ ├── ColorPicker.jsx │ └── MixedGroup.jsx ├── components │ ├── Paginator │ │ ├── paginator.scss │ │ └── Paginator.jsx │ ├── Logo │ │ ├── Logo.story.jsx │ │ ├── logo.scss │ │ └── Logo.jsx │ ├── index.js │ ├── Navbar │ │ ├── NavbarLink.jsx │ │ ├── Navbar.jsx │ │ └── navbar.scss │ ├── Presets │ │ ├── presets.scss │ │ └── Presets.jsx │ ├── Footer │ │ ├── footer.scss │ │ └── Footer.jsx │ ├── ColorSelection │ │ ├── color-selection.scss │ │ └── ColorSelection.jsx │ ├── ColorPicker │ │ ├── ColorPicker.test.jsx │ │ ├── ColorPicker.jsx │ │ └── color-picker.scss │ ├── ColorDisplayGroup │ │ ├── ColorSelectionControl.jsx │ │ ├── ColorDisplayGroup.test.jsx │ │ ├── color-display-group.scss │ │ └── ColorDisplayGroup.jsx │ ├── AppContainer │ │ └── AppContainer.jsx │ └── ColorDisplay │ │ ├── ColorDisplay.test.jsx │ │ ├── ColorDisplay.jsx │ │ └── color-display.scss ├── lib │ ├── index.js │ ├── colors │ │ ├── gradient.js │ │ ├── groups.js │ │ ├── Colorizr.js │ │ ├── splitted.js │ │ └── hex.js │ └── utils │ │ ├── StoriesWrapper.jsx │ │ └── HotRouter.jsx ├── routes.jsx ├── tests │ └── lib │ │ ├── Colorizr.test.js │ │ ├── splitted-module.test.js │ │ └── hex-module.test.js ├── index.jsx └── store.js ├── favicon.png ├── repo-header.png ├── .stylelintrc ├── storybook ├── webpack.config.js └── config.js ├── .editorconfig ├── template.ejs ├── scripts ├── start.js └── generate.js ├── .gitignore ├── .eslintrc ├── README.md ├── .babelrc ├── webpack.config.js └── package.json /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | */src/\(test\|spec\)/.* 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "stable" 5 | -------------------------------------------------------------------------------- /src/hocs/index.js: -------------------------------------------------------------------------------- 1 | export updateWithQuery from './updateWithQuery'; 2 | -------------------------------------------------------------------------------- /src/ui/Icon/icon.scss: -------------------------------------------------------------------------------- 1 | .icon { 2 | @include color-switch(fill); 3 | } 4 | -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rtivital/react-challenge-colorizr/HEAD/favicon.png -------------------------------------------------------------------------------- /src/ui/Icon/index.js: -------------------------------------------------------------------------------- 1 | export Icon from './Icon'; 2 | export * as glyphs from './glyphs'; 3 | -------------------------------------------------------------------------------- /src/ui/Checkbox/index.js: -------------------------------------------------------------------------------- 1 | import './checkbox.scss'; 2 | 3 | export Checkbox from './Checkbox'; 4 | -------------------------------------------------------------------------------- /repo-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rtivital/react-challenge-colorizr/HEAD/repo-header.png -------------------------------------------------------------------------------- /src/styles/index.js: -------------------------------------------------------------------------------- 1 | import 'node_modules/normalize.css/normalize.css'; 2 | import 'react-color-picker/index.css'; 3 | import './main.scss'; 4 | -------------------------------------------------------------------------------- /src/ui/Button/index.js: -------------------------------------------------------------------------------- 1 | import './button.scss'; 2 | 3 | export Button from './Button'; 4 | export ButtonWithIcon from './ButtonWithIcon'; 5 | -------------------------------------------------------------------------------- /src/modules/color/types.js: -------------------------------------------------------------------------------- 1 | export default { 2 | SET_LEAD_COLOR: 'COLOR/SET_LEAD_COLOR', 3 | SET_MIXED_COLOR: 'COLOR/SET_MIXED_COLOR', 4 | }; 5 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | export IndexPage from './IndexPage/IndexPage'; 2 | export ExplorePage from './ExplorePage/ExplorePage'; 3 | export ExportPage from './ExportPage/ExportPage'; 4 | -------------------------------------------------------------------------------- /src/modules/selection/types.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ADD_SELECTION_COLOR: 'SELECTION/ADD_SELECTION_COLOR', 3 | REMOVE_SELECTION_COLOR: 'SELECTION/REMOVE_SELECTION_COLOR', 4 | }; 5 | -------------------------------------------------------------------------------- /src/modules/explore/types.js: -------------------------------------------------------------------------------- 1 | export default { 2 | REQUEST_START: 'PRESETS/REQUEST_START', 3 | REQUEST_SUCCESS: 'PRESETS/REQUEST_SUCCESS', 4 | REQUEST_ERROR: 'PRESETS/REQUEST_ERROR', 5 | }; 6 | -------------------------------------------------------------------------------- /src/ui/Layout/styles/container.scss: -------------------------------------------------------------------------------- 1 | $site-width: 1400px; 2 | $container-spacing: 2rem; 3 | 4 | .container { 5 | @include center($site-width); 6 | 7 | padding: 0 $container-spacing; 8 | } 9 | -------------------------------------------------------------------------------- /src/containers/index.js: -------------------------------------------------------------------------------- 1 | export ColorPicker from './ColorPicker'; 2 | export LuminosityGroup from './LuminosityGroup'; 3 | export MixedGroup from './MixedGroup'; 4 | export Presets from './Presets'; 5 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "plugins": ["stylelint-scss"], 4 | "rules": { 5 | "number-leading-zero": "never", 6 | "color-hex-case": "upper" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/pages/ExportPage/ExportPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ExportPage = () => ( 4 |
5 | Export 6 |
7 | ); 8 | 9 | export default ExportPage; 10 | -------------------------------------------------------------------------------- /src/components/Paginator/paginator.scss: -------------------------------------------------------------------------------- 1 | .paginator__controls { 2 | display: flex; 3 | justify-content: center; 4 | padding: 2.5rem; 5 | } 6 | 7 | .paginator__button { 8 | margin: 0 .75rem; 9 | } 10 | -------------------------------------------------------------------------------- /src/ui/Icon/glyphs.js: -------------------------------------------------------------------------------- 1 | export view from './icons/view.svg'; 2 | export copy from './icons/copy.svg'; 3 | export tick from './icons/tick.svg'; 4 | export add from './icons/add.svg'; 5 | export heart from './icons/heart.svg'; 6 | -------------------------------------------------------------------------------- /src/ui/Layout/index.js: -------------------------------------------------------------------------------- 1 | import './styles/container.scss'; 2 | import './styles/layout.scss'; 3 | 4 | export Container from './Container'; 5 | export Layout from './Layout'; 6 | export Main from './Main'; 7 | export Sidebar from './Sidebar'; 8 | -------------------------------------------------------------------------------- /src/modules/color/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions'; 2 | import types from './types'; 3 | 4 | export default { 5 | setLeadColor: createAction(types.SET_LEAD_COLOR), 6 | setMixedColor: createAction(types.SET_MIXED_COLOR), 7 | }; 8 | -------------------------------------------------------------------------------- /src/ui/index.js: -------------------------------------------------------------------------------- 1 | export { Container, Main, Sidebar, Layout } from './Layout'; 2 | export { Button, ButtonWithIcon } from './Button'; 3 | export { Icon, glyphs } from './Icon'; 4 | export { Checkbox } from './Checkbox'; 5 | export Burger from './Burger/Burger'; 6 | -------------------------------------------------------------------------------- /src/modules/selection/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions'; 2 | import types from './types'; 3 | 4 | export default { 5 | addSelectionColor: createAction(types.ADD_SELECTION_COLOR), 6 | removeSelectionColor: createAction(types.REMOVE_SELECTION_COLOR), 7 | }; 8 | -------------------------------------------------------------------------------- /src/ui/Layout/Layout.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const Layout = ({ children }) => ( 4 |
5 | {children} 6 |
7 | ); 8 | 9 | Layout.propTypes = { 10 | children: PropTypes.any.isRequired, 11 | }; 12 | 13 | export default Layout; 14 | -------------------------------------------------------------------------------- /storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const config = require('../webpack.config'); 2 | 3 | module.exports = { 4 | module: { loaders: config.module.loaders }, 5 | plugins: process.env.NODE_ENV === 'production' ? config.plugins : [], 6 | sassResources: config.sassResources, 7 | postcss: config.postcss, 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/Logo/Logo.story.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@kadira/storybook'; 3 | import { StoriesWrapper } from 'lib'; 4 | import Logo from './Logo'; 5 | 6 | storiesOf('Logo', module) 7 | .add('Basic example', () => ( 8 | 9 | )); 10 | -------------------------------------------------------------------------------- /src/pages/ExplorePage/ExplorePage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Presets } from 'containers'; 3 | import { Container } from 'ui'; 4 | 5 | export default class ExplorePage extends Component { 6 | render() { 7 | return ( 8 | 9 | ); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@kadira/storybook'; 2 | import 'styles'; 3 | 4 | const requireContext = require.context('../src', true, /.story.jsx/); 5 | 6 | function loadStories() { 7 | requireContext.keys().forEach((filename) => requireContext(filename)); 8 | } 9 | 10 | configure(loadStories, module); 11 | -------------------------------------------------------------------------------- /src/styles/resources/mixins.scss: -------------------------------------------------------------------------------- 1 | @import '~breakpoint-sass/stylesheets/breakpoint'; 2 | 3 | @mixin center($width) { 4 | margin-left: auto; 5 | margin-right: auto; 6 | max-width: $width; 7 | } 8 | 9 | @mixin color-switch($prop) { 10 | &--light { #{$prop}: $color-white; } 11 | &--dark { #{$prop}: $color-true-black; } 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | export HotRouter from './utils/HotRouter'; 2 | export StoriesWrapper from './utils/StoriesWrapper'; 3 | 4 | export * as hex from './colors/hex'; 5 | export * as gradient from './colors/gradient'; 6 | export * as groups from './colors/groups'; 7 | export * as splitted from './colors/splitted'; 8 | export Colorizr from './colors/Colorizr'; 9 | -------------------------------------------------------------------------------- /src/ui/Layout/Container.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import cx from 'classnames'; 3 | 4 | const Container = ({ children, className }) => ( 5 |
6 | {children} 7 |
8 | ); 9 | 10 | Container.propTypes = { 11 | children: PropTypes.any.isRequired, 12 | className: PropTypes.string, 13 | }; 14 | 15 | export default Container; 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org/ 2 | # Atom – https://github.com/sindresorhus/atom-editorconfig 3 | # Sublime Text – https://github.com/sindresorhus/editorconfig-sublime 4 | # Visual Studio Code – https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = false 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /src/ui/Icon/icons/tick.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/ui/Button/Button.story.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf, action } from '@kadira/storybook'; 3 | import { Button } from './index'; 4 | 5 | storiesOf('Button', module) 6 | .add('with text', () => ( 7 | 8 | )) 9 | .add('with some emoji', () => ( 10 | 11 | )); 12 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | font-size: 62.5%; 4 | -webkit-font-smoothing: antialiased; 5 | -webkit-tap-highlight-color: transparent; 6 | } 7 | 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: inherit; 12 | } 13 | 14 | body { 15 | font-size: 1.6em; 16 | line-height: 1.6; 17 | font-family: $font-face-base; 18 | color: $color-black; 19 | } 20 | 21 | .page { 22 | min-height: 100vh; 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/colors/gradient.js: -------------------------------------------------------------------------------- 1 | export function createGradient(colors, direction = 'right') { 2 | let gradient = `linear-gradient(to ${direction},`; 3 | const { length } = colors; 4 | 5 | colors.forEach((color, index) => { 6 | const percent = 100 * (index + 1) / length; 7 | gradient += `${color} ${percent}%,`; 8 | }); 9 | 10 | gradient = gradient.slice(0, gradient.length - 1); 11 | gradient += ')'; 12 | 13 | return gradient; 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/utils/StoriesWrapper.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const styles = { 4 | minHeight: '50vh', 5 | display: 'flex', 6 | alignItems: 'center', 7 | justifyContent: 'center', 8 | }; 9 | 10 | const StoriesWrapper = ({ children }) => ( 11 |
{children}
12 | ); 13 | 14 | StoriesWrapper.propTypes = { 15 | children: PropTypes.any.isRequired, 16 | }; 17 | 18 | export default StoriesWrapper; 19 | -------------------------------------------------------------------------------- /src/ui/Icon/icons/add.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /template.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlWebpackPlugin.options.title %> 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/ui/Layout/Main.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import cx from 'classnames'; 3 | 4 | const Main = ({ children, sidebarOpened }) => { 5 | const className = cx('main', { 6 | 'main--sidebar-opened': sidebarOpened, 7 | }); 8 | 9 | return ( 10 |
11 | {children} 12 |
13 | ); 14 | }; 15 | 16 | Main.propTypes = { 17 | sidebarOpened: PropTypes.bool.isRequired, 18 | children: PropTypes.any.isRequired, 19 | }; 20 | 21 | export default Main; 22 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export AppContainer from './AppContainer/AppContainer'; 2 | export ColorDisplay from './ColorDisplay/ColorDisplay'; 3 | export ColorDisplayGroup from './ColorDisplayGroup/ColorDisplayGroup'; 4 | export ColorPicker from './ColorPicker/ColorPicker'; 5 | export ColorSelection from './ColorSelection/ColorSelection'; 6 | export Logo from './Logo/Logo'; 7 | export Navbar from './Navbar/Navbar'; 8 | export Footer from './Footer/Footer'; 9 | export Paginator from './Paginator/Paginator'; 10 | export Presets from './Presets/Presets'; 11 | -------------------------------------------------------------------------------- /src/modules/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { routerReducer as routing } from 'react-router-redux'; 3 | 4 | // Reducers 5 | import explore from './explore/reducer'; 6 | import selection from './selection/reducer'; 7 | import color from './color/reducer'; 8 | 9 | // Actions 10 | export colorActions from './color/actions'; 11 | export selectionActions from './selection/actions'; 12 | export exploreActions from './explore/actions'; 13 | 14 | // Root Reducer 15 | export default combineReducers({ routing, color, selection, explore }); 16 | -------------------------------------------------------------------------------- /src/modules/selection/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions'; 2 | import { hex } from 'lib'; 3 | import types from './types'; 4 | 5 | const initialState = []; 6 | 7 | export default handleActions({ 8 | [types.ADD_SELECTION_COLOR](state, { payload }) { 9 | return state.concat(hex.toLongHex(payload, true)); 10 | }, 11 | 12 | [types.REMOVE_SELECTION_COLOR](state, { payload }) { 13 | const colorToRemove = hex.toLongHex(payload, true); 14 | return state.filter((color) => color === !colorToRemove); 15 | }, 16 | }, initialState); 17 | -------------------------------------------------------------------------------- /src/containers/LuminosityGroup.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { ColorDisplayGroup } from 'components'; 4 | 5 | @connect(state => ({ colors: state.color.luminosityGroup })) 6 | export default class LuminosityGroupContainer extends PureComponent { 7 | static propTypes = { 8 | colors: PropTypes.array.isRequired, 9 | } 10 | 11 | render() { 12 | return ( 13 | 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/explore/actions.js: -------------------------------------------------------------------------------- 1 | import { createAction } from 'redux-actions'; 2 | import axios from 'axios'; 3 | import types from './types'; 4 | 5 | const start = createAction(types.REQUEST_START); 6 | const success = createAction(types.REQUEST_SUCCESS); 7 | const error = createAction(types.REQUEST_ERROR); 8 | 9 | export default { 10 | fetchPresets() { 11 | return (dispatch) => { 12 | dispatch(start()); 13 | 14 | axios 15 | .get('./presets.json') 16 | .then(response => dispatch(success(response.data))) 17 | .catch(requestError => dispatch(error(requestError))); 18 | }; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/modules/explore/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions'; 2 | import types from './types'; 3 | 4 | const initialState = { 5 | error: false, 6 | loading: false, 7 | data: [], 8 | }; 9 | 10 | export default handleActions({ 11 | [types.REQUEST_START](state) { 12 | return { ...state, loading: true, error: false }; 13 | }, 14 | 15 | [types.REQUEST_SUCCESS](state, { payload }) { 16 | return { loading: false, error: false, data: payload }; 17 | }, 18 | 19 | [types.REQUEST_ERROR](state, { payload }) { 20 | return { ...state, loading: false, error: payload }; 21 | }, 22 | }, initialState); 23 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const webpack = require('webpack'); 3 | const WebpackDevServer = require('webpack-dev-server'); 4 | const webpackConfig = require('../webpack.config'); 5 | const chalk = require('chalk'); 6 | 7 | 8 | const PORT = 3002; 9 | 10 | const serverConfig = { 11 | contentBase: './public', 12 | publicPath: '/', 13 | hot: true, 14 | historyApiFallback: true, 15 | }; 16 | 17 | new WebpackDevServer(webpack(webpackConfig), serverConfig) 18 | .listen(PORT, 'localhost', err => { 19 | err && console.error(err); 20 | console.log(`Listening at ${chalk.bold.cyan(`http://localhost:${PORT}/`)}`); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/Navbar/NavbarLink.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Link, IndexLink } from 'react-router'; 3 | 4 | const NavbarLink = ({ index, to, children }) => { 5 | const LinkComponent = index ? IndexLink : Link; 6 | 7 | return ( 8 | 13 | {children} 14 | 15 | ); 16 | }; 17 | 18 | NavbarLink.propTypes = { 19 | index: PropTypes.bool, 20 | to: PropTypes.string.isRequired, 21 | children: PropTypes.string.isRequired, 22 | }; 23 | 24 | export default NavbarLink; 25 | -------------------------------------------------------------------------------- /src/components/Presets/presets.scss: -------------------------------------------------------------------------------- 1 | .presets__paginator { 2 | display: flex; 3 | flex-wrap: wrap; 4 | justify-content: space-around; 5 | margin-top: 3rem; 6 | } 7 | 8 | .preset { 9 | display: flex; 10 | margin: 1.5rem 1rem; 11 | padding: 1.5rem; 12 | border: .1rem solid $color-white-gray; 13 | box-shadow: 0 0 .3rem rgba(0, 0, 0, .1); 14 | border-radius: $radius; 15 | transition: background-color 200ms; 16 | cursor: pointer; 17 | 18 | &:hover { 19 | background-color: $color-white-gray; 20 | } 21 | } 22 | 23 | .preset__item { 24 | border: 0; 25 | outline: 0; 26 | height: 5rem; 27 | width: 5rem; 28 | cursor: pointer; 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/Icon/Icon.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import cx from 'classnames'; 3 | import './icon.scss'; 4 | 5 | const Icon = ({ glyph, theme, className }) => ( 6 | ` }} 12 | /> 13 | ); 14 | 15 | Icon.propTypes = { 16 | glyph: PropTypes.string.isRequired, 17 | theme: PropTypes.string, 18 | className: PropTypes.string, 19 | }; 20 | 21 | Icon.defaultProps = { 22 | theme: 'dark', 23 | }; 24 | 25 | export default Icon; 26 | -------------------------------------------------------------------------------- /src/ui/Icon/icons/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | 9 | -------------------------------------------------------------------------------- /src/routes.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Route, IndexRoute } from 'react-router'; 3 | import { HotRouter } from 'lib'; 4 | 5 | import { IndexPage, ExportPage, ExplorePage } from 'pages'; 6 | import { AppContainer } from 'components'; 7 | 8 | const AppRouter = ({ history }) => ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | 18 | AppRouter.propTypes = { history: PropTypes.object.isRequired }; 19 | export default AppRouter; 20 | -------------------------------------------------------------------------------- /src/lib/utils/HotRouter.jsx: -------------------------------------------------------------------------------- 1 | import { Children, createElement } from 'react'; 2 | import { Router } from 'react-router'; 3 | 4 | export default class HotRouter extends Router { 5 | componentWillReceiveProps(nextProps) { 6 | const components = []; 7 | function grabComponents(element) { 8 | if (element.props && element.props.component) { 9 | components.push(element.props.component); 10 | } 11 | 12 | if (element.props && element.props.children) { 13 | Children.forEach(element.props.children, grabComponents); 14 | } 15 | } 16 | 17 | grabComponents(nextProps.routes || nextProps.children); 18 | components.forEach(createElement); // force patching 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ui/Button/ButtonWithIcon.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import cx from 'classnames'; 3 | 4 | import { Icon, glyphs } from '../Icon'; 5 | import Button from './Button'; 6 | 7 | const ButtonWithIcon = ({ glyph, children, className, ...others }) => ( 8 | 10 | {children} 11 | 12 | ); 13 | 14 | ButtonWithIcon.propTypes = { 15 | glyph: PropTypes.oneOf(Object.keys(glyphs)).isRequired, 16 | children: PropTypes.any, 17 | className: PropTypes.string, 18 | }; 19 | 20 | export default ButtonWithIcon; 21 | -------------------------------------------------------------------------------- /src/components/Footer/footer.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | background-color: $color-black; 3 | color: $color-white; 4 | padding: 5rem 0; 5 | } 6 | 7 | .footer__logo { 8 | display: inline-block; 9 | margin-right: .5rem; 10 | 11 | .logo__link { 12 | font-size: 1.8rem; 13 | } 14 | } 15 | 16 | .footer__title { 17 | display: flex; 18 | align-items: center; 19 | margin: 0; 20 | padding: 0; 21 | line-height: 1; 22 | font-weight: bold; 23 | } 24 | 25 | .footer__heart { 26 | fill: $color-red; 27 | width: 1.2rem; 28 | height: 1.2rem; 29 | margin: 0 .3rem; 30 | } 31 | 32 | .footer__link { 33 | color: $color-blue; 34 | text-decoration: none; 35 | 36 | &:hover { 37 | text-decoration: underline; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/styles/resources/variables.scss: -------------------------------------------------------------------------------- 1 | $color-white: #FFF; 2 | $color-white-gray: #F5F5F5; 3 | $color-light-gray: #D7D7D7; 4 | $color-gray: #AAA; 5 | $color-dark-gray: #444; 6 | $color-black: #222; 7 | $color-true-black: #000; 8 | 9 | $color-green: #4CAF50; 10 | $color-dark-green: #43A047; 11 | 12 | $color-red: #EB4010; 13 | $color-dark-red: #C8310C; 14 | 15 | $color-blue: #119CE5; 16 | $color-dark-blue: #138DCE; 17 | 18 | $light-border: .1rem solid $color-white-gray; 19 | 20 | $font-face-base: 'source-sans-pro', Helvetica, Arial, sans-serif; 21 | $font-family-mono: 'inconsolata', 'Lucida Console', Monaco, monospace; 22 | 23 | $box-shadow: .1rem .2rem .5rem rgba(0, 0, 0, .05), -.1rem -.2rem .5rem rgba(0, 0, 0, .05); 24 | $radius: .3rem; 25 | -------------------------------------------------------------------------------- /src/ui/Button/Button.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import cx from 'classnames'; 3 | 4 | const BUTTON_TYPES = ['white', 'green', 'red']; 5 | 6 | const Button = ({ children, className, disabled, theme, ...others }) => { 7 | const buttonClassName = cx('button', `button--${theme}`, className, { 8 | 'button--disabled': disabled, 9 | }); 10 | 11 | return ( 12 | 13 | ); 14 | }; 15 | 16 | Button.propTypes = { 17 | children: PropTypes.any.isRequired, 18 | className: PropTypes.string, 19 | theme: PropTypes.oneOf(BUTTON_TYPES), 20 | disabled: PropTypes.bool, 21 | }; 22 | 23 | Button.defaultProps = { theme: 'white' }; 24 | 25 | export default Button; 26 | -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Container } from 'ui'; 3 | import { Logo } from 'components'; 4 | import NavbarLink from './NavbarLink'; 5 | import './navbar.scss'; 6 | 7 | const Navbar = () => ( 8 | 18 | ); 19 | 20 | export default Navbar; 21 | -------------------------------------------------------------------------------- /src/components/Logo/logo.scss: -------------------------------------------------------------------------------- 1 | .logo { 2 | line-height: 0; 3 | font-family: $font-face-base; 4 | cursor: pointer; 5 | } 6 | 7 | .logo__link { 8 | text-decoration: none; 9 | font-weight: bold; 10 | text-transform: uppercase; 11 | font-size: 2.2rem; 12 | letter-spacing: .1rem; 13 | user-select: none; 14 | transition: letter-spacing 100ms ease; 15 | 16 | &:hover { 17 | letter-spacing: .3rem; 18 | 19 | .logo__letter { 20 | color: $color-black !important; 21 | } 22 | } 23 | 24 | &--light { 25 | &:hover { 26 | .logo__letter { 27 | color: $color-white !important; 28 | } 29 | } 30 | } 31 | } 32 | 33 | .logo__letter { 34 | transition: color 300ms ease; 35 | transition-delay: 150ms; 36 | } 37 | -------------------------------------------------------------------------------- /src/containers/Presets.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes, PureComponent } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { selectionActions, exploreActions } from 'modules'; 4 | import { Presets } from 'components'; 5 | 6 | @connect(state => state.explore, { ...selectionActions, ...exploreActions }) 7 | export default class PresetsContainer extends PureComponent { 8 | static propTypes = { 9 | data: PropTypes.array.isRequired, 10 | fetchPresets: PropTypes.func.isRequired, 11 | } 12 | 13 | componentDidMount() { 14 | if (!this.props.data.length) { 15 | this.props.fetchPresets(); 16 | } 17 | } 18 | 19 | render() { 20 | return ( 21 | 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/Burger/Burger.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import cx from 'classnames'; 3 | 4 | import './burger.scss'; 5 | 6 | const Burger = ({ active, className, theme, onClick }) => { 7 | const classNames = cx('burger', `burger--${theme}`, className, { 8 | 'burger--active': active, 9 | }); 10 | 11 | return ( 12 | 15 | ); 16 | }; 17 | 18 | Burger.propTypes = { 19 | active: PropTypes.bool, 20 | onClick: PropTypes.func, 21 | className: PropTypes.string, 22 | theme: PropTypes.oneOf(['light', 'dark']), 23 | }; 24 | 25 | Burger.defaultProps = { 26 | active: true, 27 | theme: 'light', 28 | }; 29 | 30 | export default Burger; 31 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Logo } from 'components'; 3 | import { Container, Icon, glyphs } from 'ui'; 4 | import './footer.scss'; 5 | 6 | const Footer = () => ( 7 |
8 | 9 |
10 | is a part of React Challenge 11 |
12 | 13 |

14 | Build with 15 | by Vitaly Rtishchev 16 |

17 |
18 |
19 | ); 20 | 21 | export default Footer; 22 | -------------------------------------------------------------------------------- /src/components/Navbar/navbar.scss: -------------------------------------------------------------------------------- 1 | $navbar-spacing: 2rem; 2 | 3 | .navbar { 4 | background-color: $color-white; 5 | border-bottom: $light-border; 6 | box-shadow: $box-shadow; 7 | } 8 | 9 | .navbar__inner { 10 | display: flex; 11 | align-items: center; 12 | justify-content: space-between; 13 | padding: $navbar-spacing; 14 | } 15 | 16 | .navbar__link { 17 | padding: .5rem 1rem; 18 | font-size: .9em; 19 | text-decoration: none; 20 | color: $color-dark-gray; 21 | transition: background-color 200ms ease; 22 | 23 | &:not(&--active):hover { 24 | background-color: $color-white-gray; 25 | } 26 | 27 | &:not(:last-of-type) { 28 | margin-right: 1rem; 29 | } 30 | 31 | &--active { 32 | background-color: $color-blue; 33 | color: $color-white; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/ColorSelection/color-selection.scss: -------------------------------------------------------------------------------- 1 | .color-selection { 2 | background-color: $color-black; 3 | } 4 | 5 | .color-selection__title { 6 | color: $color-white; 7 | margin-top: 0; 8 | padding-left: 2rem; 9 | padding-right: 2rem; 10 | font-size: 1.7rem; 11 | letter-spacing: .3rem; 12 | text-transform: uppercase; 13 | } 14 | 15 | .color-selection__samples { 16 | padding: 2rem; 17 | padding-right: 0; 18 | padding-bottom: 0; 19 | } 20 | 21 | .color-selection__sample { 22 | display: flex; 23 | justify-content: space-between; 24 | align-items: center; 25 | color: $color-white; 26 | } 27 | 28 | .color-selection__display { 29 | width: 8rem; 30 | height: 5rem; 31 | } 32 | 33 | .color-selection__value { 34 | font-family: $font-family-mono; 35 | font-size: 1.8rem; 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/Checkbox/Checkbox.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { v4 } from 'node-uuid'; 3 | import cx from 'classnames'; 4 | 5 | const Checkbox = ({ checked, onChange, className, label }) => { 6 | const id = v4(); 7 | 8 | return ( 9 |
10 | 17 | 18 | 19 |
20 | ); 21 | }; 22 | 23 | Checkbox.propTypes = { 24 | checked: PropTypes.bool, 25 | onChange: PropTypes.func, 26 | label: PropTypes.string, 27 | className: PropTypes.string, 28 | }; 29 | 30 | export default Checkbox; 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # Because Mac 40 | .DS_Store 41 | 42 | # Project Files 43 | public 44 | docs 45 | .eslintcache 46 | -------------------------------------------------------------------------------- /src/components/ColorPicker/ColorPicker.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'tape-catch'; 3 | import { shallow } from 'enzyme'; 4 | import { ColorPicker } from 'components'; 5 | 6 | test(' render', (t) => { 7 | let onChangeTest = '#000'; 8 | 9 | const wrapper = shallow( { onChangeTest = color; }} />); 10 | const ColorPickerComponent = wrapper.find('ColorPicker'); 11 | 12 | // Default prop test 13 | t.equal(wrapper.state('currentColor'), '#000', '#000 is a default color'); 14 | 15 | // Simulation of onDrag event of third party ColorPicker component 16 | // test state changes and onChange callback calls 17 | ColorPickerComponent.simulate('drag', '#444'); 18 | t.equal(wrapper.state('currentColor'), '#444', '#444 is a set as color'); 19 | t.equal(onChangeTest, '#444', 'Handles onChange function'); 20 | 21 | t.end(); 22 | }); 23 | -------------------------------------------------------------------------------- /src/lib/colors/groups.js: -------------------------------------------------------------------------------- 1 | import { Colorizr } from 'lib'; 2 | 3 | export function getLuminosityGroup(value) { 4 | const color = new Colorizr(value); 5 | 6 | const luminosity = parseInt(color.luminosity() / 10, 10); 7 | const lightened = []; 8 | const darkened = []; 9 | 10 | for (let ii = 1; ii <= 10 - luminosity; ii++) { 11 | lightened.push(color.clone().lighten(ii * 10).hex()); 12 | } 13 | 14 | for (let ii = 1; ii <= luminosity; ii++) { 15 | darkened.push(color.clone().darken(ii * 10).hex()); 16 | } 17 | 18 | return darkened.reverse().concat(color.hex()).concat(lightened.slice(1)); 19 | } 20 | 21 | export function getMixedGroup(value, mixer) { 22 | const color = new Colorizr(value); 23 | const colorToMix = new Colorizr(mixer); 24 | const mixed = []; 25 | 26 | for (let i = 0; i < 10; i++) { 27 | mixed.push(color.clone().mix(colorToMix, i * 10).hex()); 28 | } 29 | 30 | return mixed; 31 | } 32 | -------------------------------------------------------------------------------- /src/ui/Icon/icons/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["airbnb", "plugin:flowtype/recommended"], 4 | "plugins": ["flowtype", "flowtype-errors"], 5 | "env": { 6 | "browser": true, 7 | "node": true 8 | }, 9 | "rules": { 10 | "flowtype-errors/show-errors": 2, 11 | "linebreak-style": 0, 12 | "import/prefer-default-export": 0, 13 | "consistent-return": 0, 14 | "react/prefer-stateless-function": 0, 15 | "react/sort-comp": 0, 16 | "no-unused-expressions": 0, 17 | "react/forbid-prop-types": 0, 18 | "react/no-danger": 0, 19 | "import/no-extraneous-dependencies": 0, 20 | "arrow-parens": 0, 21 | "no-mixed-operators": 0, 22 | "no-confusing-arrow": 0, 23 | "no-plusplus": 0, 24 | "new-cap": 0, 25 | "global-require": 0, 26 | "class-methods-use-this": 0, 27 | "spaced-comment": 0, 28 | }, 29 | 30 | "settings": { 31 | "import/resolver": { 32 | "babel-module": {} 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Второй выпуск React Challenge 2 | 3 | ![React Challenge Colorizr](repo-header.png) 4 | 5 | [![Build Status](https://travis-ci.org/rtivital/react-challenge-colorizr.svg?branch=master)](https://travis-ci.org/rtivital/react-challenge-colorizr) 6 | [![Dependency Status](https://dependencyci.com/github/rtivital/react-challenge-colorizr/badge)](https://dependencyci.com/github/rtivital/react-challenge-colorizr) 7 | [![Coverage Status](https://coveralls.io/repos/github/rtivital/react-challenge-colorizr/badge.svg?branch=master)](https://coveralls.io/github/rtivital/react-challenge-colorizr?branch=master) 8 | 9 | Второй React Challenge находится в статусе обновления. В скором времени Colorizr будет значительно обновлён. Обновления коснутся как процесса сборки приложения, так и самого приложения. Также будут выпущены новые пошаговые задания для выполнения челленджа и обновлены списки материалов для изучения React, Redux и React Router. В ветке master будет находиться решение челленджа. 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", ["es2015", { "loose" : true }], "stage-0"], 3 | "plugins": [ 4 | "lodash", 5 | "transform-decorators-legacy", 6 | "syntax-flow", 7 | "transform-flow-strip-types", 8 | 9 | ["module-resolver", { 10 | "root": ["./src"], 11 | "alias": { 12 | "node_modules": "./node_modules", 13 | }, 14 | }], 15 | 16 | ["transform-imports", { 17 | "react-router": { 18 | "transform": "react-router/lib/${member}", 19 | "preventFullImport": true 20 | } 21 | }] 22 | ], 23 | 24 | "env": { 25 | "production": { 26 | "plugins": [ 27 | "transform-runtime", 28 | "transform-react-inline-elements", 29 | "transform-react-remove-prop-types", 30 | "transform-react-constant-elements", 31 | "transform-react-pure-class-to-function" 32 | ] 33 | }, 34 | 35 | "development": { 36 | "plugins": [ 37 | "react-hot-loader/babel", 38 | ] 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/tests/lib/Colorizr.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape-catch'; 2 | import { Colorizr } from 'lib'; 3 | 4 | const CHANELS = ['r', 'g', 'b']; 5 | const validColors = ['#fff', 'ccc', { r: 3, g: 56, b: 167 }, [34, 78, 56]]; 6 | 7 | test('Colorizr constructor tests', (t) => { 8 | validColors.forEach((validColor) => { 9 | const instance = new Colorizr(validColor); 10 | const { color } = instance; 11 | 12 | const chanels = Object.keys(color).sort(); 13 | const values = chanels.map((chanel) => color[chanel]); 14 | 15 | t.deepEqual( 16 | chanels, CHANELS.slice(0).sort(), 17 | `Colorizr produces splitted color value with ${CHANELS.join(', ')} keys` 18 | ); 19 | 20 | values.forEach((chanelValue) => { 21 | const valid = ( 22 | typeof chanelValue === 'number' 23 | && chanelValue % 1 === 0 24 | && chanelValue >= 0 25 | && chanelValue <= 255 26 | ); 27 | 28 | t.equal(valid, true, 'Colorizr produces valid color chanel'); 29 | }); 30 | }); 31 | 32 | t.end(); 33 | }); 34 | -------------------------------------------------------------------------------- /src/ui/Layout/Sidebar.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent, PropTypes } from 'react'; 2 | import onClickOutside from 'react-onclickoutside'; 3 | import cx from 'classnames'; 4 | import { Burger } from 'ui'; 5 | 6 | @onClickOutside 7 | export default class Sidebar extends PureComponent { 8 | static propTypes = { 9 | children: PropTypes.any.isRequired, 10 | sidebarOpened: PropTypes.bool.isRequired, 11 | toggleSidebar: PropTypes.func.isRequired, 12 | closeSidebar: PropTypes.func.isRequired, 13 | } 14 | 15 | handleClickOutside = () => { 16 | this.props.closeSidebar(); 17 | } 18 | 19 | render() { 20 | const { children, sidebarOpened, toggleSidebar } = this.props; 21 | 22 | const className = cx('sidebar', { 23 | 'sidebar--opened': sidebarOpened, 24 | }); 25 | 26 | return ( 27 | 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/components/Presets/Presets.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Paginator } from 'components'; 3 | import './presets.scss'; 4 | 5 | const PrestPropType = PropTypes.arrayOf(PropTypes.string); 6 | 7 | const Preset = ({ data, ...others }) => { 8 | const colors = data.map((color, index) => ( 9 | 69 | 70 | 77 | 78 | 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/components/Logo/Logo.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes, Component } from 'react'; 2 | import { IndexLink } from 'react-router'; 3 | import cx from 'classnames'; 4 | import { random } from 'lodash'; 5 | import './logo.scss'; 6 | 7 | const baseColors = [ 8 | ['#F12509', '#D72031', '#CA1D45', '#BD1B59', '#B0186D', '#A31681', '#961395', '#8911A9'], 9 | ['#074C6C', '#095B82', '#0A6B97', '#0C7AAD', '#0C7AAD', '#0D89C3', '#0F99D9', '#13C6FF'], 10 | ['#E6BF0E', '#D0B219', '#BAA624', '#A49A2F', '#8E8D3A', '#788146', '#627551', '#4C685C'], 11 | ['#1DB718', '#2FBC17', '#41C116', '#53C615', '#66CC14', '#78D112', '#8AD611', '#8AD611'], 12 | ['#9D18BA', '#AD2A94', '#B53381', '#BD3C6E', '#C5455A', '#CD4E47', '#D55734', '#DD6021'], 13 | ]; 14 | 15 | const logoColors = baseColors.reduce((result, pallete) => { 16 | result.push(pallete.slice(0)); 17 | result.push(pallete.slice(0).reverse()); 18 | return result; 19 | }, []); 20 | 21 | export default class Logo extends Component { 22 | static propTypes = { 23 | children: PropTypes.string, 24 | className: PropTypes.string, 25 | light: PropTypes.bool, 26 | } 27 | 28 | static defaultProps = { 29 | children: 'Colorizr', 30 | } 31 | 32 | state = { colors: logoColors[0] } 33 | interval = null 34 | 35 | componentWillMount() { 36 | this.applyInterval(); 37 | } 38 | 39 | componentWillUnmount() { 40 | this.clearInterval(); 41 | } 42 | 43 | applyColors = () => { 44 | const pallete = logoColors[random(0, logoColors.length - 1)]; 45 | this.setState({ colors: pallete }); 46 | } 47 | 48 | applyInterval = () => { 49 | this.clearInterval(); 50 | this.interval = setInterval(this.applyColors, 2500); 51 | } 52 | 53 | clearInterval = () => { 54 | clearInterval(this.interval); 55 | } 56 | 57 | render() { 58 | const { colors } = this.state; 59 | 60 | const letters = this.props.children.split('').map((letter, index) => ( 61 | 66 | {letter} 67 | 68 | )); 69 | 70 | return ( 71 |
72 | 78 | {letters} 79 | 80 |
81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/colors/splitted.js: -------------------------------------------------------------------------------- 1 | import { clamp, isPlainObject } from 'lodash'; 2 | 3 | const CHANELS = ['r', 'g', 'b']; 4 | 5 | function validateChanel(chanel) { 6 | return ( 7 | typeof chanel === 'number' // should always be a number 8 | && chanel % 1 === 0 // integer number, also detects NaN 9 | && chanel >= 0 10 | && chanel <= 255 11 | ); 12 | } 13 | 14 | export function isSplittedColor(value) { 15 | if (Array.isArray(value)) { 16 | return value.every(validateChanel); 17 | } 18 | 19 | if (isPlainObject(value)) { 20 | return CHANELS.every( 21 | (chanel) => chanel in value && validateChanel(value[chanel]) 22 | ); 23 | } 24 | 25 | return false; 26 | } 27 | 28 | export function convertSplittedToObject(value) { 29 | if (isPlainObject(value)) { return value; } 30 | if (Array.isArray(value)) { 31 | return { 32 | r: value[0], 33 | g: value[1], 34 | b: value[2], 35 | }; 36 | } 37 | 38 | return value; 39 | } 40 | 41 | export function convertSplittedToArray(value) { 42 | if (Array.isArray(value)) { return value; } 43 | if (isPlainObject(value)) { 44 | return [value.r, value.g, value.b]; 45 | } 46 | 47 | return value; 48 | } 49 | 50 | function lumChanel(chanel, percent, factor = 1) { 51 | return parseInt(clamp(chanel - (chanel * factor * percent / 100), 0, 255), 10); 52 | } 53 | 54 | function applyToChanels(value, callback, ...args) { 55 | if (Array.isArray(value)) { 56 | return value.map((chanel) => callback.call(null, chanel, ...args)); 57 | } 58 | 59 | const color = {}; 60 | Object.keys(value).forEach((chanel) => { 61 | color[chanel] = callback.call(null, value[chanel], ...args); 62 | }); 63 | 64 | return color; 65 | } 66 | 67 | export function darken(value, percent) { 68 | return applyToChanels(value, lumChanel, percent, 1); 69 | } 70 | 71 | export function lighten(value, percent) { 72 | return applyToChanels(value, lumChanel, percent, -1); 73 | } 74 | 75 | export function getLuminosity(value) { 76 | const color = convertSplittedToObject(value); 77 | const { r, g, b } = color; 78 | return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 * 100; 79 | } 80 | 81 | export function mix(value, mixer, percent, array = false) { 82 | const color = convertSplittedToObject(value); 83 | const colorToMix = convertSplittedToObject(mixer); 84 | const mixed = {}; 85 | 86 | Object.keys(color).forEach((chanel) => { 87 | const delimeter = percent / 100; 88 | mixed[chanel] = parseInt(color[chanel] * delimeter + colorToMix[chanel] * (1 - delimeter), 10); 89 | }); 90 | 91 | return array ? convertSplittedToArray(mixed) : mixed; 92 | } 93 | -------------------------------------------------------------------------------- /src/components/ColorDisplay/ColorDisplay.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import Clipboard from 'react-copy-to-clipboard'; 3 | import { block } from 'rbem'; 4 | 5 | import { Colorizr } from 'lib'; 6 | import { Icon, glyphs, Button, ButtonWithIcon } from 'ui'; 7 | import './color-display.scss'; 8 | 9 | const Chanel = ({ value, name }) => ( 10 |
11 | {name.toUpperCase()} 12 | {value} 13 |
14 | ); 15 | 16 | Chanel.propTypes = { 17 | value: PropTypes.number.isRequired, 18 | name: PropTypes.string.isRequired, 19 | }; 20 | 21 | 22 | export default class ColorDisplay extends Component { 23 | static propTypes = { 24 | colorValue: PropTypes.string.isRequired, 25 | hideInfo: PropTypes.bool, 26 | } 27 | 28 | static defaultProps = { 29 | hideInfo: false, 30 | } 31 | 32 | state = { copied: false } 33 | timeout = null 34 | 35 | handleCopy = () => { 36 | if (this.timeout) { clearTimeout(this.timeout); } 37 | this.timeout = setTimeout(() => this.setState({ copied: false }), 2000); 38 | this.setState({ copied: true }); 39 | } 40 | 41 | shouldComponentUpdate(nextProps, nextState) { 42 | return ( 43 | this.props.colorValue !== nextProps.colorValue 44 | || this.state.copied !== nextState.hideInfo 45 | ); 46 | } 47 | 48 | render() { 49 | const component = block('color-display'); 50 | const transformedColor = new Colorizr(this.props.colorValue); 51 | const hex = transformedColor.hex(); 52 | const rgb = transformedColor.clone().color; 53 | 54 | const chanels = Object.keys(rgb).map( 55 | (chanel, index) => 56 | ); 57 | 58 | const { copied } = this.state; 59 | const iconTheme = transformedColor.luminosity() >= 50 ? 'dark' : 'light'; 60 | const buttonTheme = copied ? 'green' : 'white'; 61 | const buttonText = copied ? 'Copied' : 'Copy HEX'; 62 | const buttonGlyph = copied ? 'tick' : 'copy'; 63 | 64 | return ( 65 |
66 | 71 |
72 | 73 | 74 | 75 |
76 | {!this.props.hideInfo && ( 77 |
78 |
{chanels}
79 |
80 | HEX: 81 | {hex} 82 |
83 | 84 | 89 | {buttonText} 90 | 91 | 92 |
93 | )} 94 |
95 | ); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/components/ColorDisplayGroup/ColorDisplayGroup.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { block, applyModifiers } from 'rbem'; 3 | import { chunk } from 'lodash'; 4 | 5 | import { gradient } from 'lib'; 6 | import { Button, Checkbox } from 'ui'; 7 | import { ColorDisplay } from 'components'; 8 | import ColorSelectionControl from './ColorSelectionControl'; 9 | import './color-display-group.scss'; 10 | 11 | export default class ColorDisplayGroup extends Component { 12 | static propTypes = { 13 | colors: PropTypes.array.isRequired, 14 | title: PropTypes.string.isRequired, 15 | enableSelection: PropTypes.bool, 16 | selectionColor: PropTypes.string, 17 | handleChange: PropTypes.func, 18 | } 19 | 20 | state = { 21 | gradient: false, 22 | info: false, 23 | } 24 | 25 | toggleGradient = () => { 26 | this.setState({ gradient: !this.state.gradient }); 27 | } 28 | 29 | toggleInfo = () => { 30 | this.setState({ info: !this.state.info }); 31 | } 32 | 33 | toggleMixer = () => { 34 | this.setState({ showColorPicker: !this.state.mixer }); 35 | } 36 | 37 | render() { 38 | const component = block('color-display-group'); 39 | 40 | const { colors } = this.props; 41 | const chunks = chunk(colors, colors.length / 2); 42 | 43 | const colorsRows = chunks.map((colorsChunk, chunkIndex) => { 44 | const chunkContent = colorsChunk.map((color, index) => ( 45 | 50 | )); 51 | 52 | return ( 53 |
58 | {chunkContent} 59 |
60 | ); 61 | }); 62 | 63 | const displaysClassName = applyModifiers(component('displays'), { 64 | gradient: this.state.gradient, 65 | }); 66 | 67 | return ( 68 |
69 |
70 |

{this.props.title}

71 | 72 | {do { 73 | if (this.props.enableSelection) { 74 | ; 78 | } 79 | }} 80 |
81 |
82 | {colorsRows} 83 |
84 |
85 | 91 | 97 | 98 | 99 |
100 |
101 | ); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/components/ColorDisplay/color-display.scss: -------------------------------------------------------------------------------- 1 | $display-height: 10rem; 2 | 3 | .color-display { 4 | min-width: 11.5rem; 5 | 6 | & + & { 7 | .color-display__info { 8 | border-left: 0; 9 | } 10 | 11 | .color-display__add { 12 | border-left: 0; 13 | } 14 | } 15 | } 16 | 17 | .color-display__info { 18 | background-color: $color-white; 19 | border: $light-border; 20 | border-top: 0; 21 | } 22 | 23 | .color-display__add { 24 | width: 100%; 25 | display: block; 26 | border-bottom: 0; 27 | border-radius: 0; 28 | 29 | &-icon { 30 | height: 1.5rem; 31 | width: 1.5rem; 32 | fill: $color-black; 33 | } 34 | } 35 | 36 | .color-display__display { 37 | height: $display-height; 38 | border-bottom: 0; 39 | cursor: pointer; 40 | transition: border 250ms ease, transform 250ms ease, background-color 250ms ease; 41 | display: flex; 42 | flex-direction: column; 43 | justify-content: space-between; 44 | position: relative; 45 | 46 | &::before, 47 | &::after { 48 | opacity: 0; 49 | transition: opacity 300ms ease; 50 | display: block; 51 | font-weight: bold; 52 | text-align: center; 53 | } 54 | 55 | &::before { 56 | content: 'White'; 57 | color: $color-white; 58 | } 59 | 60 | &::after { 61 | content: 'Black'; 62 | color: $color-true-black; 63 | } 64 | 65 | &:hover { 66 | z-index: 100; 67 | 68 | .color-display__icon { 69 | opacity: 1; 70 | } 71 | } 72 | 73 | &:active { 74 | transform: scale(3.5); 75 | border-radius: 0; 76 | border-color: transparent; 77 | box-shadow: $box-shadow; 78 | z-index: 300; 79 | 80 | .color-display__icon { 81 | opacity: 0; 82 | } 83 | 84 | &::before, 85 | &::after { 86 | opacity: 1; 87 | } 88 | } 89 | } 90 | 91 | .color-display__chanels { 92 | display: flex; 93 | } 94 | 95 | .color-display__hex { 96 | display: flex; 97 | justify-content: space-between; 98 | padding: .3rem .5rem; 99 | font-size: .9em; 100 | font-family: $font-family-mono; 101 | } 102 | 103 | .color-display__hex-name { 104 | font-weight: bold; 105 | font-family: $font-face-base; 106 | } 107 | 108 | .chanel { 109 | display: flex; 110 | flex-direction: column; 111 | flex: 1; 112 | border: $light-border; 113 | border-right: 0; 114 | 115 | &:first-of-type { 116 | border-left: 0; 117 | } 118 | } 119 | 120 | .chanel__name { 121 | display: block; 122 | width: 100%; 123 | padding: .3rem 1.3rem; 124 | text-align: center; 125 | border-bottom: $light-border; 126 | font-weight: bold; 127 | font-size: .8em; 128 | } 129 | 130 | .chanel__value { 131 | padding: .3rem .5rem; 132 | text-align: center; 133 | font-family: $font-family-mono; 134 | } 135 | 136 | .color-display__clipboard { 137 | width: 100%; 138 | border: 0; 139 | border-top: $light-border; 140 | border-radius: 0 !important; 141 | } 142 | 143 | .color-display__icon { 144 | opacity: 0; 145 | position: absolute; 146 | top: 30%; 147 | align-self: center; 148 | transition: opacity 200ms ease; 149 | 150 | .icon { 151 | width: 4rem; 152 | height: 4rem; 153 | transition: fill 200ms; 154 | } 155 | } 156 | 157 | .color-display__wrapper { 158 | position: relative; 159 | 160 | &:hover { 161 | .color-display__add { 162 | opacity: 1; 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/tests/lib/splitted-module.test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape-catch'; 2 | import { splitted } from 'lib'; 3 | 4 | const validSplittedColors = [ 5 | [1, 45, 168], 6 | [255, 255, 255], 7 | [0, 0, 0], 8 | { r: 1, g: 45, b: 168 }, 9 | { r: 255, g: 255, b: 255 }, 10 | { r: 0, g: 0, b: 0 }, 11 | ]; 12 | 13 | const invalidSplittedColors = [ 14 | [1, 45, 563], 15 | [255, 255, 256], 16 | [0, 0, -1], 17 | { r: 1, g: 45 }, 18 | { r: 255, g: 255, d: 255 }, 19 | { r: '34', g: 0, b: 0 }, 20 | ]; 21 | 22 | const hasProp = (object, prop) => !!object && Object.prototype.hasOwnProperty.call(object, prop); 23 | const validateChanel = (chanel) => ( 24 | typeof chanel === 'number' 25 | && chanel % 1 === 0 26 | && chanel >= 0 27 | && chanel <= 255 28 | ); 29 | 30 | test('Splitted color module - isSplittedColor function', (t) => { 31 | validSplittedColors.forEach((color) => { 32 | t.equal(splitted.isSplittedColor(color), true, `Treats ${color} as splitted`); 33 | }); 34 | 35 | invalidSplittedColors.forEach((color) => { 36 | t.equal(splitted.isSplittedColor(color), false, `Treats ${color} as not splitted`); 37 | }); 38 | 39 | t.end(); 40 | }); 41 | 42 | test('Splitted color module - convertSplittedToObject and convertSplittedToArray functions', (t) => { 43 | validSplittedColors.forEach((color) => { 44 | const object = splitted.convertSplittedToObject(color); 45 | const array = splitted.convertSplittedToArray(color); 46 | 47 | const isConvertedArray = ( 48 | Array.isArray(array) 49 | && array.length === 3 50 | && array.every(validateChanel) 51 | ); 52 | 53 | const isConvertedObject = ( 54 | hasProp(object, 'r') 55 | && hasProp(object, 'g') 56 | && hasProp(object, 'b') 57 | && Object.keys(object).every((chanel) => validateChanel(object[chanel])) 58 | ); 59 | 60 | t.equal(isConvertedArray, true, 'Converts valid splitted color to array'); 61 | t.equal(isConvertedObject, true, 'Converts valid splitted color to object'); 62 | }); 63 | 64 | t.end(); 65 | }); 66 | 67 | const percent = 10; 68 | const delimiter = percent / 100; 69 | const darkener = (chanel) => parseInt(chanel - chanel / percent, 10); 70 | const lightener = (chanel) => parseInt(chanel + chanel / percent, 10); 71 | 72 | const mixChanels = (chanel, mixer) => 73 | parseInt(chanel * delimiter + mixer * (1 - delimiter), 10); 74 | 75 | const mixColors = (color, mixer) => 76 | color.map((chanel, index) => mixChanels(chanel, mixer[index])); 77 | 78 | const black = [0, 0, 0]; 79 | const white = [255, 255, 255]; 80 | const gray = [70, 70, 70]; 81 | const red = [200, 0, 0]; 82 | const green = [0, 200, 0]; 83 | const blue = [0, 0, 200]; 84 | 85 | const lightenBlack = black.map(lightener); 86 | const darkenedWhite = white.map(darkener); 87 | const lightenedGray = gray.map(lightener); 88 | const darkenedGray = gray.map(darkener); 89 | 90 | const mixed = [ 91 | { colors: [black, white] }, 92 | { colors: [black, blue] }, 93 | { colors: [white, green] }, 94 | { colors: [white, red] }, 95 | { colors: [red, blue] }, 96 | { colors: [green, red] }, 97 | { colors: [blue, red] }, 98 | ]; 99 | 100 | const mixedTests = mixed.map((sample) => { 101 | const result = {}; 102 | result.expected = mixColors(...sample.colors, percent); 103 | result.result = splitted.mix(...sample.colors, percent, true); 104 | return result; 105 | }); 106 | 107 | test('Splitted color module - lighten function', (t) => { 108 | const { lighten } = splitted; 109 | t.deepEqual(lighten(white, percent), white, 'Does not lighten white color'); 110 | t.deepEqual(lighten(black, percent), lightenBlack, 'Lightens black color'); 111 | t.deepEqual(lighten(gray, percent), lightenedGray, 'Lightens gray color'); 112 | t.end(); 113 | }); 114 | 115 | test('Splitted color module - darken function', (t) => { 116 | const { darken } = splitted; 117 | t.deepEqual(darken(black, percent), black, 'Does not darken black color'); 118 | t.deepEqual(darken(white, percent), darkenedWhite, 'Darkens light color'); 119 | t.deepEqual(darken(gray, percent), darkenedGray, 'Darkens gray color'); 120 | t.end(); 121 | }); 122 | 123 | test('Splitted color module - mix function', (t) => { 124 | mixedTests.forEach(({ expected, result }) => { 125 | t.deepEqual(expected, result, `Mixed ${expected} and ${result} colors`); 126 | }); 127 | 128 | t.end(); 129 | }); 130 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const autoprefixer = require('autoprefixer'); 4 | 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); 7 | const LodashModuleReplacementPlugin = require('lodash-webpack-plugin'); 8 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 9 | const DashboardPlugin = require('webpack-dashboard/plugin'); 10 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 11 | 12 | const production = process.env.NODE_ENV === 'production'; 13 | const pagesBuild = process.env.BUILD === 'pages'; 14 | 15 | /*********************************** Loaders ***********************************/ 16 | const loaders = [ 17 | { // react-hot is implemented as babel plugin now 18 | test: /\.(js|jsx)$/, 19 | loader: 'babel', 20 | include: path.join(__dirname, 'src'), 21 | exclude: /node_modules/, 22 | }, 23 | 24 | { // used for all project files and some dependencies 25 | test: /\.scss$/, 26 | loader: production 27 | ? ExtractTextPlugin.extract(['css', 'postcss', 'sass', 'sass-resources']) 28 | : ['style', 'css?sourceMap', 'postcss', 'sass?sourceMap', 'sass-resources'].join('!'), 29 | include: path.join(__dirname, 'src'), 30 | }, 31 | 32 | { // used for dependencies that don't support sass 33 | test: /\.css$/, 34 | loaders: ['style', 'css', 'postcss'], 35 | }, 36 | 37 | { // svg sprites generated only for icons 38 | test: /\.svg$/, 39 | loader: `svg-sprite?${JSON.stringify({ name: '[hash]', prefixize: true })}`, 40 | include: path.join(__dirname, 'src/ui/Icon'), 41 | }, 42 | 43 | { // other svg images will processed as normal 44 | test: /\.svg$/, 45 | loader: 'file', 46 | include: path.join(__dirname, 'src'), 47 | exclude: path.join(__dirname, 'src/ui/Icon'), 48 | }, 49 | ]; 50 | 51 | // Plugins used in all builds 52 | const pluginsBase = [ 53 | new HtmlWebpackPlugin({ 54 | title: 'Colorizr', 55 | template: 'template.ejs', 56 | }), 57 | 58 | new FaviconsWebpackPlugin({ 59 | logo: './favicon.png', 60 | background: '#ffeeee', 61 | icons: { 62 | android: false, 63 | appleIcon: false, 64 | appleStartup: false, 65 | coast: false, 66 | favicons: true, 67 | firefox: false, 68 | opengraph: false, 69 | twitter: false, 70 | yandex: false, 71 | windows: false, 72 | }, 73 | }), 74 | 75 | new webpack.DefinePlugin({ 76 | 'process.env': { // build is used for gh-pages 77 | NODE_ENV: JSON.stringify(process.env.NODE_ENV || ''), 78 | BUILD: JSON.stringify(process.env.BUILD || ''), 79 | }, 80 | }), 81 | ]; 82 | 83 | const developmentPlugins = [ 84 | ...pluginsBase, 85 | new BundleAnalyzerPlugin({ analyzerPort: 3004, openAnalyzer: false }), 86 | new DashboardPlugin({ port: 3005 }), 87 | new webpack.HotModuleReplacementPlugin(), 88 | ]; 89 | 90 | const productionPlugins = [ 91 | ...pluginsBase, 92 | new ExtractTextPlugin('style.css'), 93 | new LodashModuleReplacementPlugin(), 94 | new webpack.optimize.OccurrenceOrderPlugin(), 95 | new webpack.optimize.DedupePlugin(), 96 | new webpack.optimize.UglifyJsPlugin({ 97 | beautify: false, 98 | comments: false, 99 | compress: { 100 | sequences: true, 101 | booleans: true, 102 | loops: true, 103 | unused: false, 104 | warnings: false, 105 | drop_console: true, 106 | unsafe: true, 107 | }, 108 | }), 109 | ]; 110 | 111 | module.exports.loaders = loaders; 112 | module.exports.plugins = { 113 | base: pluginsBase, 114 | development: developmentPlugins, 115 | production: productionPlugins, 116 | }; 117 | 118 | module.exports = { 119 | devtool: production ? 'cheap-module-source-map' : 'eval', 120 | 121 | entry: production 122 | ? ['babel-polyfill', './src/index'] 123 | : [ 124 | 'react-hot-loader/patch', 125 | 'webpack-dev-server/client?http://localhost:3002', 126 | 'webpack/hot/only-dev-server', 127 | 'babel-polyfill', 128 | './src/index', 129 | ], 130 | 131 | output: { 132 | path: path.join(__dirname, 'public'), 133 | filename: 'bundle.js', 134 | publicPath: pagesBuild ? '/react-challenge-colorizr' : '/', 135 | }, 136 | 137 | resolve: { 138 | root: [ 139 | path.resolve(__dirname, 'src'), 140 | path.resolve(__dirname, 'node_modules'), 141 | ], 142 | extensions: ['', '.js', '.jsx'], 143 | }, 144 | 145 | module: { loaders }, 146 | plugins: production ? productionPlugins : developmentPlugins, 147 | 148 | sassResources: './src/styles/resources/**/*.scss', 149 | postcss: [autoprefixer({ browsers: ['last 4 versions'] })], 150 | }; 151 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-challenge-colorizr", 3 | "version": "1.0.0", 4 | "description": "Boilerplate for second React Challenge", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npm run lint:eslint && npm run lint:stylelint && npm run test:only", 8 | "test:cache": "npm run lint:eslint-cache && npm run lint:stylelint && npm run test:only", 9 | "test:only": "cross-env NODE_ENV=development tape --require babel-register --require ignore-styles src/**/*.test.{js,jsx} | tap-notify | tap-spec", 10 | "test:flow": "flow", 11 | "start": "npm run presets && cross-env NODE_ENV=development webpack-dashboard -- node scripts/start", 12 | "start:storybook": "start-storybook -p 3003 -c storybook", 13 | "build:storybook": "build-storybook -c storybook -o public/storybook", 14 | "build": "cross-env NODE_ENV=production webpack --progress --colors && npm run build:storybook", 15 | "clean": "rimraf public", 16 | "lint": "npm run lint:eslint-fix && npm run lint:stylelint", 17 | "lint:eslint": "eslint src --ext .js --ext .jsx", 18 | "lint:eslint-fix": "eslint src --ext .js --ext .jsx --fix", 19 | "lint:eslint-cache": "eslint src --ext .js --ext .jsx --cache", 20 | "lint:stylelint": "stylelint **/*.scss", 21 | "gh:build": "npm run clean && npm run presets && npm run build:storybook && cross-env NODE_ENV=production BUILD=pages webpack --progress --colors", 22 | "gh:deploy": "npm run gh:build && gh-pages -d public", 23 | "deploy": "npm run gh:deploy", 24 | "presets": "node scripts/generate" 25 | }, 26 | "pre-push": [ 27 | "test:cache" 28 | ], 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/rtivital/react-challenge-colorizr.git" 32 | }, 33 | "keywords": [ 34 | "react", 35 | "react-challenge", 36 | "react-router", 37 | "redux", 38 | "react-redux" 39 | ], 40 | "author": "Vitaly Rtishchev (http://github.com/rtivital)", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/rtivital/react-challenge-colorizr/issues" 44 | }, 45 | "homepage": "https://github.com/rtivital/react-challenge-colorizr#readme", 46 | "dependencies": { 47 | "axios": "^0.15.2", 48 | "breakpoint-sass": "^2.7.0", 49 | "classnames": "^2.2.5", 50 | "lodash": "^4.16.6", 51 | "node-uuid": "^1.4.7", 52 | "normalize.css": "^5.0.0", 53 | "rbem": "^1.0.1", 54 | "react": "^15.3.2", 55 | "react-color-picker": "^4.0.2", 56 | "react-copy-to-clipboard": "^4.2.3", 57 | "react-dom": "^15.3.2", 58 | "react-fastclick": "^2.1.2", 59 | "react-onclickoutside": "^5.7.0", 60 | "react-redux": "^4.4.5", 61 | "react-router": "^3.0.0-beta.1", 62 | "react-router-redux": "^4.0.7", 63 | "redux": "^3.5.2", 64 | "redux-actions": "^0.13.0", 65 | "redux-thunk": "^2.1.0" 66 | }, 67 | "devDependencies": { 68 | "@kadira/storybook": "^2.21.0", 69 | "autoprefixer": "^6.3.6", 70 | "babel-cli": "^6.14.0", 71 | "babel-core": "^6.18.2", 72 | "babel-eslint": "^7.1.0", 73 | "babel-loader": "^6.2.4", 74 | "babel-plugin-add-module-exports": "^0.2.1", 75 | "babel-plugin-lodash": "^3.2.9", 76 | "babel-plugin-module-resolver": "^2.0.0", 77 | "babel-plugin-syntax-flow": "^6.18.0", 78 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 79 | "babel-plugin-transform-flow-strip-types": "^6.18.0", 80 | "babel-plugin-transform-imports": "^1.1.0", 81 | "babel-plugin-transform-react-constant-elements": "^6.9.1", 82 | "babel-plugin-transform-react-inline-elements": "^6.8.0", 83 | "babel-plugin-transform-react-pure-class-to-function": "^1.0.1", 84 | "babel-plugin-transform-react-remove-prop-types": "^0.2.9", 85 | "babel-plugin-transform-runtime": "^6.12.0", 86 | "babel-polyfill": "^6.13.0", 87 | "babel-preset-es2015": "^6.6.0", 88 | "babel-preset-react": "^6.5.0", 89 | "babel-preset-stage-0": "^6.5.0", 90 | "babel-register": "^6.14.0", 91 | "babel-runtime": "^6.11.6", 92 | "bluebird": "^3.4.6", 93 | "chalk": "^1.1.3", 94 | "cross-env": "^3.1.3", 95 | "css-loader": "^0.25.0", 96 | "enzyme": "^2.4.1", 97 | "eslint": "^3.9.1", 98 | "eslint-config-airbnb": "^12.0.0", 99 | "eslint-import-resolver-babel-module": "^2.0.1", 100 | "eslint-plugin-flowtype": "^2.25.0", 101 | "eslint-plugin-flowtype-errors": "^1.5.0", 102 | "eslint-plugin-import": "^2.1.0", 103 | "eslint-plugin-jsx-a11y": "^2.2.2", 104 | "eslint-plugin-react": "^6.5.0", 105 | "extract-text-webpack-plugin": "^1.0.1", 106 | "favicons-webpack-plugin": "0.0.7", 107 | "file-loader": "^0.9.0", 108 | "flow-bin": "^0.34.0", 109 | "gh-pages": "^0.11.0", 110 | "html-webpack-plugin": "^2.24.1", 111 | "ignore-styles": "^5.0.1", 112 | "jsonfile": "^2.4.0", 113 | "lodash-webpack-plugin": "^0.10.3", 114 | "mkdirp": "^0.5.1", 115 | "node-sass": "^3.11.1", 116 | "postcss-loader": "^1.1.0", 117 | "pre-push": "^0.1.1", 118 | "react-addons-test-utils": "^15.3.2", 119 | "react-hot-loader": "^3.0.0-beta.4", 120 | "request": "^2.78.0", 121 | "request-promise": "^4.1.1", 122 | "rimraf": "^2.5.4", 123 | "sass-loader": "^4.0.0", 124 | "sass-resources-loader": "^1.1.0", 125 | "style-loader": "^0.13.1", 126 | "stylelint": "^7.3.1", 127 | "stylelint-config-standard": "^14.0.0", 128 | "stylelint-scss": "^1.3.4", 129 | "svg-sprite-loader": "0.0.31", 130 | "tap-notify": "^1.0.0", 131 | "tap-spec": "^4.1.1", 132 | "tape": "^4.6.0", 133 | "tape-catch": "^1.0.6", 134 | "webpack": "^1.13.0", 135 | "webpack-bundle-analyzer": "^1.4.2", 136 | "webpack-dashboard": "^0.2.0", 137 | "webpack-dev-server": "^3.1.11" 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/lib/colors/hex.js: -------------------------------------------------------------------------------- 1 | /** @module HEX */ 2 | 3 | /** 4 | * isUnprefixedHex - Tests if provided HEX color does not contain hash 5 | * 6 | * @param {string} value - HEX color to test 7 | * @return {boolean} 8 | * 9 | * @example 10 | * isUnprefixedHex('#fff'); // false 11 | * isUnprefixedHex('#000000'); // false 12 | * isUnprefixedHex('fff'); // true 13 | * isUnprefixedHex('000000'); // true 14 | */ 15 | export function isUnprefixedHex(value) { 16 | return /(^[0-9A-F]{3}$)|(^[0-9A-F]{6}$)/i.test(value); 17 | } 18 | 19 | /** 20 | * isPrefixedHex - Tests if provided HEX color does contain hash 21 | * 22 | * @param {string} value - HEX color to test 23 | * @return {boolean} 24 | * 25 | * @example 26 | * isPrefixedHex('#fff'); // true 27 | * isPrefixedHex('#000000'); // true 28 | * isPrefixedHex('fff'); // false 29 | * isPrefixedHex('000000'); // false 30 | */ 31 | export function isPrefixedHex(value) { 32 | return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(value); 33 | } 34 | 35 | /** 36 | * isHex -Tests if provided color value is HEX color, both xxx and #xxx values are accepted 37 | * 38 | * @param {string} value 39 | * @return {boolean} 40 | * 41 | * @example 42 | * isHex('#fff'); // true 43 | * isHex('#000000'); // true 44 | * isHex('fff'); // true 45 | * isHex('000000'); // true 46 | */ 47 | export function isHex(value) { 48 | if (typeof value !== 'string') { return false; } 49 | return isUnprefixedHex(value) || isPrefixedHex(value); 50 | } 51 | 52 | /** 53 | * validateHex - Utility function for HEX color validation that throws errors while development 54 | * 55 | * @param {string} value - value to test 56 | * 57 | * @example 58 | * validateHex('#fff'); // it's ok 59 | * validateHex('#00000'); // TypeError 60 | * isUnprefixedHex('zzz'); // TypeError 61 | * isUnprefixedHex(); // TypeError 62 | */ 63 | export function validateHex(value) { 64 | if (!isHex(value) && process.env.NODE_ENV === 'development') { 65 | throw new TypeError(`Recieved value ${value} is not a valid HEX color`); 66 | } 67 | } 68 | 69 | /** 70 | * unprefixHex - Used to unprefix HEX color value (remove hash character at position 0) 71 | * 72 | * @param {string} value - HEX color with (#fff) or without hash (fff) 73 | * @return {string} hex color value formated like fff or fff 74 | * 75 | * @example 76 | * unprefixHex('#ccc'); // ccc 77 | * unprefixHex('#000000'); // 000000 78 | * unprefixHex('ccc'); // ccc 79 | * unprefixHex('#zzz'); // TypeError 80 | * unprefixHex(); // TypeError 81 | */ 82 | export function unprefixHex(value) { 83 | validateHex(value); 84 | return isUnprefixedHex(value) ? value : value.slice(1); 85 | } 86 | 87 | /** 88 | * prefixHex - Used to prefix HEX color value (add hash character to position 0) 89 | * 90 | * @param {string} value - HEX color with (#fff) or without hash (fff) 91 | * @return {string} hex color value formated like fff or #fff 92 | * 93 | * @example 94 | * prefixHex('#ccc'); // #ccc 95 | * prefixHex('000000'); // #000000 96 | * prefixHex('ccc'); // #ccc 97 | * prefixHex('#zzz'); // TypeError 98 | * prefixHex(); // TypeError 99 | */ 100 | export function prefixHex(value) { 101 | validateHex(value); 102 | return isPrefixedHex(value) ? value : `#${value}`; 103 | } 104 | 105 | /** 106 | * createLongHex - Creates long hex value from provided HEX color 107 | * 108 | * @param {string} value - color to work with 109 | * @param {boolean} [prefixed=false] - passed if value should be prefixed 110 | * @return {string} HEX color with format ffffff or #ffffff 111 | * 112 | * @example 113 | * createLongHex('#fff', true); // #ffffff 114 | * createLongHex('#ccc'); // cccccc 115 | * createLongHex('#cccccc'); // cccccc 116 | * createLongHex('#zzz'); // TypeError 117 | * createLongHex(); // TypeError 118 | */ 119 | export function createLongHex(value, prefixed = false) { 120 | validateHex(value); 121 | 122 | const hex = unprefixHex(value); 123 | if (hex.length === 6) { return prefixed ? prefixHex(value) : hex; } 124 | 125 | const longHex = hex.split('').map((chr) => chr + chr).join(''); 126 | return prefixed ? prefixHex(longHex) : longHex; 127 | } 128 | 129 | /** 130 | * splitHex - Splits hex color to separate chanels (r, g, b) 131 | * 132 | * @param {string} value - color to work with 133 | * @param {boolean} [splitType='object'] - array or object split type 134 | * @return {Object|Array} - splitted hex value 135 | * 136 | * @example 137 | * splitHex('#fff'); // { r: 255, g: 255, b: 255 } 138 | * splitHex('#ccc', 'array'); // [204, 204, 204] 139 | * splitHex('000000'); // { r: 0, g: 0, b: 0 } 140 | * splitHex('#zzz'); // TypeError 141 | * splitHex(); // TypeError 142 | */ 143 | export function splitHex(color, splitType = 'object') { 144 | validateHex(color); 145 | const hex = createLongHex(color); 146 | const chanels = []; 147 | 148 | for (let i = 0; i < hex.length; i += 2) { 149 | chanels.push(parseInt(hex.slice(i, i + 2), 16)); 150 | } 151 | 152 | return splitType === 'object' 153 | ? { r: chanels[0], g: chanels[1], b: chanels[2] } 154 | : chanels; 155 | } 156 | 157 | /** 158 | * chanelToHex - Turns decimal chanel value to hex 159 | * 160 | * @param {number} chanel - red, green or blue chanel 161 | * @return {string} 162 | * 163 | * @example 164 | * chanelToHex(255); // 'ff' 165 | * chanelToHex(15); // '0f' 166 | * chanelToHex(0); // '00' 167 | */ 168 | export function chanelToHex(chanel) { 169 | const value = chanel.toString(16); 170 | return value.length === 2 ? value : `0${value}`; 171 | } 172 | 173 | /** 174 | * mergeHex - Merges splited hex value to string 175 | * 176 | * @param {Array|Object} value - object with keys r, g, b or array with three numbers 177 | * @param {boolean} [prefix = true] - passed if hex should be prefixed 178 | * @return {string} - HEX string 179 | */ 180 | export function mergeHex(value, prefix = true) { 181 | let color = ''; 182 | 183 | if (Array.isArray(value)) { 184 | color = value.map(chanelToHex).join(''); 185 | } else if (typeof value === 'object' && value !== null) { 186 | color += chanelToHex(value.r); 187 | color += chanelToHex(value.g); 188 | color += chanelToHex(value.b); 189 | } 190 | 191 | const result = prefix ? `#${color}` : color; 192 | return result.toUpperCase(); 193 | } 194 | -------------------------------------------------------------------------------- /src/tests/lib/hex-module.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import test from 'tape-catch'; 3 | import { hex } from 'lib'; 4 | 5 | 6 | // ------------------------------------------------------- 7 | // validateHex development utility and isHex function tests 8 | // ------------------------------------------------------- 9 | const randomValues = ['', NaN, null, {}, [1, 3, 4], 'hello', 29, new Date()]; 10 | const invalidColors = ['#f', '#ff', '#ffff', '#fffff', '#zzz', 'f', 'f0', 'f000']; 11 | const validColors = ['#7986cb', '#009688', '#f45', 'fdd835', 'a1887f', 'c22']; 12 | 13 | test('HEX module - validateHex utility', (t) => { 14 | invalidColors.forEach((invalidColor) => { 15 | if (process.env.NODE_ENV === 'development') { 16 | t.throws( 17 | () => hex.validateHex(invalidColor), 18 | `Recieved value ${invalidColor} is not a valid HEX color`, 19 | `Throw an error in dev environment with invalid color: ${invalidColor}` 20 | ); 21 | } else { 22 | t.doesNotThrow( 23 | () => hex.validateHex(invalidColor), 24 | `Recieved value ${invalidColor} is not a valid HEX color`, 25 | `Do not throw an error out of dev environment with invalid color: ${invalidColor}` 26 | ); 27 | } 28 | }); 29 | 30 | validColors.forEach((validColor) => { 31 | t.doesNotThrow( 32 | () => hex.validateHex(validColor), 33 | `Recieved value ${validColor} is not a valid HEX color`, 34 | `Do not throw an error with valid color: ${validColor}` 35 | ); 36 | }); 37 | 38 | t.end(); 39 | }); 40 | 41 | test('HEX module - isHex function', (t) => { 42 | invalidColors.forEach((invalidColor) => { 43 | t.equal(hex.isHex(invalidColor), false, `Invalid color ${invalidColor} is not treated as hex`); 44 | }); 45 | 46 | validColors.forEach((validColor) => { 47 | t.equal(hex.isHex(validColor), true, `Valid color ${validColor} is treated as hex`); 48 | }); 49 | 50 | randomValues.forEach((randomValue) => { 51 | t.equal(hex.isHex(randomValue), false, `Random value ${randomValue} is not treated as hex`); 52 | }); 53 | 54 | t.end(); 55 | }); 56 | 57 | 58 | // ------------------------------------------------------- 59 | // isPrefixedHex and isUnprefixedHex tests 60 | // ------------------------------------------------------- 61 | const prefixedHex = ['#7986cb', '#009688', '#f45']; 62 | const unprefixedHex = ['fdd835', 'a1887f', 'c22']; 63 | 64 | test('HEX module - isUnprefixedHex and isPrefixedHex functions', (t) => { 65 | unprefixedHex.forEach((unprefixed) => { 66 | const message = `${unprefixed} is detected as unprefixed hex value`; 67 | t.equal(hex.isUnprefixedHex(unprefixed), true, message); 68 | t.equal(hex.isPrefixedHex(unprefixed), false, message); 69 | }); 70 | 71 | prefixedHex.forEach((prefixed) => { 72 | const message = `${prefixed} is not detected as prefixed hex value`; 73 | t.equal(hex.isUnprefixedHex(prefixed), false, message); 74 | t.equal(hex.isPrefixedHex(prefixed), true, message); 75 | }); 76 | 77 | t.end(); 78 | }); 79 | 80 | test('HEX module - unprefixHex and prefixHex functions', (t) => { 81 | unprefixedHex.forEach((unprefixed) => { 82 | t.equal(hex.unprefixHex(unprefixed), unprefixed, 'unprefixHex does not modify unprefixed values'); 83 | t.equal(hex.prefixHex(unprefixed), `#${unprefixed}`, 'prefixHex adds prefix to unprefixedHex'); 84 | }); 85 | 86 | prefixedHex.forEach((prefixed) => { 87 | t.equal(hex.unprefixHex(prefixed), prefixed.slice(1), 'unprefixHex does not modify prefixed values'); 88 | t.equal(hex.prefixHex(prefixed), prefixed, 'prefixHex do modify prefixed values'); 89 | }); 90 | 91 | t.end(); 92 | }); 93 | 94 | 95 | // ------------------------------------------------------- 96 | // createLongHex function tests 97 | // ------------------------------------------------------- 98 | const shortPrefixed = ['#ccc', '#ddd', '#d41']; 99 | const shortUnprefixed = ['ccc', 'ddd', 'd41']; 100 | const short = [...shortPrefixed, ...shortUnprefixed]; 101 | 102 | const longPrefixed = ['#cccccc', '#dddddd', '#dd4411']; 103 | const longUnprefixed = ['cccccc', 'dddddd', 'dd4411']; 104 | const long = [...longPrefixed, ...longUnprefixed]; 105 | 106 | test('HEX module - createLongHex function', (t) => { 107 | short.forEach((shortHex, index) => { 108 | t.equal( 109 | hex.createLongHex(shortHex, true), 110 | hex.prefixHex(long[index]), 111 | `Creates long prefixed value from short ${shortHex}` 112 | ); 113 | 114 | t.equal( 115 | hex.createLongHex(shortHex, false), 116 | hex.unprefixHex(long[index]), 117 | `Creates long unprefixed value from short ${shortHex}` 118 | ); 119 | }); 120 | 121 | long.forEach((longHex) => { 122 | t.equal( 123 | hex.createLongHex(longHex, true), 124 | hex.prefixHex(longHex), 125 | `Creates long prefixed value from long ${longHex}` 126 | ); 127 | 128 | t.equal( 129 | hex.createLongHex(longHex, false), 130 | hex.unprefixHex(longHex), 131 | `Creates long unprefixed value from long ${longHex}` 132 | ); 133 | }); 134 | 135 | t.end(); 136 | }); 137 | 138 | 139 | // ------------------------------------------------------- 140 | // splitHex, createLongHex and chanelToHex functions tests 141 | // ------------------------------------------------------- 142 | function assignSplittedHex(object, hexString, chanels) { 143 | Object.defineProperty(object, hexString, { 144 | value: { 145 | array: chanels, 146 | object: { r: chanels[0], g: chanels[1], b: chanels[2] }, 147 | }, 148 | }); 149 | } 150 | 151 | function createChanel(chanel, converted) { 152 | return { chanel, converted }; 153 | } 154 | 155 | const splittedHex = {}; 156 | assignSplittedHex(splittedHex, '#fff', [255, 255, 255]); 157 | assignSplittedHex(splittedHex, 'c56', [204, 85, 102]); 158 | assignSplittedHex(splittedHex, '#34f68c', [52, 246, 140]); 159 | assignSplittedHex(splittedHex, '50ff4c', [80, 255, 76]); 160 | 161 | // mergeHex function will always return long hex value 162 | const mergedHex = {}; 163 | assignSplittedHex(mergedHex, '#ffffff', [255, 255, 255]); 164 | assignSplittedHex(mergedHex, 'ffffff', [255, 255, 255]); 165 | assignSplittedHex(mergedHex, '#34f68c', [52, 246, 140]); 166 | assignSplittedHex(mergedHex, '34f68c', [52, 246, 140]); 167 | 168 | const chanels = [ 169 | createChanel(255, 'ff'), 170 | createChanel(0, '00'), 171 | createChanel(15, '0f'), 172 | ]; 173 | 174 | test('HEX module - splitHex function', (t) => { 175 | Object.keys(splittedHex).forEach((hexString) => { 176 | const { array, object } = splittedHex[hexString]; 177 | t.deepEqual(hex.splitHex(hexString, 'object'), object, `Splits hex ${hexString} to object`); 178 | t.deepEqual(hex.splitHex(hexString, 'array'), array, `Splits hex ${hexString} to array`); 179 | }); 180 | 181 | t.end(); 182 | }); 183 | 184 | test('HEX module - chanelToHex function', (t) => { 185 | chanels.forEach(({ chanel, converted }) => { 186 | t.equal(hex.chanelToHex(chanel), converted, `Converts chanel ${chanel} to hex format`); 187 | }); 188 | 189 | t.end(); 190 | }); 191 | 192 | test('HEX module - mergeHex function', (t) => { 193 | Object.keys(mergedHex).forEach((hexString) => { 194 | const { array, object } = mergedHex[hexString]; 195 | 196 | t.equal( 197 | hex.mergeHex(array, true), 198 | hex.prefixHex(hexString), 199 | `Merges hex ${hexString} with prefix from array ${array}` 200 | ); 201 | 202 | t.equal( 203 | hex.mergeHex(array, false), 204 | hex.unprefixHex(hexString), 205 | `Merges hex ${hexString} without prefix from array ${array}` 206 | ); 207 | 208 | t.equal( 209 | hex.mergeHex(object, true), 210 | hex.prefixHex(hexString), 211 | `Merges hex ${hexString} with prefix from object ${object}` 212 | ); 213 | 214 | t.equal( 215 | hex.mergeHex(object, false), 216 | hex.unprefixHex(hexString), 217 | `Merges hex ${hexString} without prefix from object ${object}` 218 | ); 219 | }); 220 | 221 | t.end(); 222 | }); 223 | --------------------------------------------------------------------------------