From 7497595b81eb9a4e9366f4442c40895b376d5d5f Mon Sep 17 00:00:00 2001 From: Nathan Bierema Date: Thu, 6 Aug 2020 17:46:08 -0400 Subject: [PATCH] Add react-base16-styling package (#563) --- packages/react-base16-styling/.babelrc | 4 + packages/react-base16-styling/README.md | 91 ++++++++ packages/react-base16-styling/package.json | 43 ++++ .../src/colorConverters.js | 30 +++ packages/react-base16-styling/src/index.js | 212 ++++++++++++++++++ .../react-base16-styling/test/index.test.js | 189 ++++++++++++++++ yarn.lock | 16 +- 7 files changed, 572 insertions(+), 13 deletions(-) create mode 100644 packages/react-base16-styling/.babelrc create mode 100644 packages/react-base16-styling/README.md create mode 100644 packages/react-base16-styling/package.json create mode 100644 packages/react-base16-styling/src/colorConverters.js create mode 100644 packages/react-base16-styling/src/index.js create mode 100644 packages/react-base16-styling/test/index.test.js diff --git a/packages/react-base16-styling/.babelrc b/packages/react-base16-styling/.babelrc new file mode 100644 index 00000000..644dde97 --- /dev/null +++ b/packages/react-base16-styling/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["@babel/preset-env"], + "plugins": ["@babel/plugin-proposal-class-properties"] +} diff --git a/packages/react-base16-styling/README.md b/packages/react-base16-styling/README.md new file mode 100644 index 00000000..982a1398 --- /dev/null +++ b/packages/react-base16-styling/README.md @@ -0,0 +1,91 @@ +# react-base16-styling [![Build Status](https://img.shields.io/travis/alexkuz/react-base16-styling/master.svg)](https://travis-ci.org/alexkuz/react-base16-styling) [![Latest Stable Version](https://img.shields.io/npm/v/react-base16-styling.svg)](https://www.npmjs.com/package/react-base16-styling) + +React styling with base16 color scheme support + +Inspired by [react-themeable](https://github.com/markdalgleish/react-themeable), this utility provides flexible theming for your component with [base16](https://github.com/chriskempson/base16) theme support. + +## Install + +``` +yarn add react-base16-styling +``` + +## Usage + +```jsx +import { createStyling } from 'react-base16-styling'; +import base16Themes from './base16Themes'; + +function getStylingFromBase16(base16Theme) { + return { + myComponent: { + backgroundColor: base16Theme.base00 + }, + + myComponentToggleColor({ style, className }, clickCount) { + return { + style: { + ...style, + backgroundColor: clickCount % 2 ? 'red' : 'blue' + } + }; + } + }; +} + +const createStylingFromTheme = createStyling(getStylingFromBase16, { + defaultBase16: base16Themes.solarized, + base16Themes +}); + +class MyComponent extends Component { + state = { clickCount: 0 }; + render() { + const { theme } = this.props; + const { clickCount } = this.state; + + const styling = createStylingFromTheme(theme); + + return ( +
+ this.setState({ clickCount: clickCount + 1 })}> + Click Me + +
+ {clickCount} +
+
+ ); + } +} +``` + +## `createStyling` + +```js +function(getStylingFromBase16, defaultStylingOptions, themeOrStyling) +``` + +| Argument | Signature | Description | +| ----------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `getStylingFromBase16` | `function(base16Theme)` | creates object with default stylings for your component, using provided base16 theme. | +| `defaultStylingOptions` | `{ defaultBase16, base16Themes }` | optional parameters, allow to set default `base16` theme and additional `base16` themes for component. | +| `themeOrStyling` | `string` or `object` | `base16` theme name, `base16` theme object or styling object. Theme name can have a modifier: `"themeName:inverted"` to invert theme colors (see [[#invertTheme]]) | + +Styling object values could be: - objects (treated as style definitions) - strings (class names) - functions (they may be provided with additional parameters and should return object { style, className }) + +## `getBase16Theme` + +```js +function(themeOrStyling, base16Themes) +``` + +Helper method that returns `base16` theme object if `themeOrStyling` is `base16` theme name or theme object. + +## `invertTheme` + +```js +function(theme) +``` + +Helper method that inverts `base16` theme colors, creating light theme out of dark one or vice versa. diff --git a/packages/react-base16-styling/package.json b/packages/react-base16-styling/package.json new file mode 100644 index 00000000..fa1ee4c9 --- /dev/null +++ b/packages/react-base16-styling/package.json @@ -0,0 +1,43 @@ +{ + "name": "react-base16-styling", + "version": "0.6.0", + "description": "React styling with base16 color scheme support", + "main": "lib/index.js", + "scripts": { + "clean": "rimraf lib", + "build": "babel src --out-dir lib", + "test": "jest", + "prepare": "npm run build", + "prepublishOnly": "npm run test && npm run clean && npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/reduxjs/redux-devtools.git" + }, + "keywords": [ + "react", + "theme", + "base16", + "styling" + ], + "author": "Alexander (http://kuzya.org/)", + "license": "MIT", + "bugs": { + "url": "https://github.com/reduxjs/redux-devtools/issues" + }, + "homepage": "https://github.com/reduxjs/redux-devtools", + "devDependencies": { + "@babel/cli": "^7.10.5", + "@babel/core": "^7.11.0", + "@babel/plugin-proposal-class-properties": "^7.10.4", + "@babel/plugin-transform-runtime": "^7.11.0", + "@babel/preset-env": "^7.11.0", + "rimraf": "^2.7.1" + }, + "dependencies": { + "base16": "^1.0.0", + "lodash.curry": "^4.1.1", + "lodash.flow": "^3.5.0", + "pure-color": "^1.3.0" + } +} diff --git a/packages/react-base16-styling/src/colorConverters.js b/packages/react-base16-styling/src/colorConverters.js new file mode 100644 index 00000000..e2960018 --- /dev/null +++ b/packages/react-base16-styling/src/colorConverters.js @@ -0,0 +1,30 @@ +export function yuv2rgb(yuv) { + var y = yuv[0], + u = yuv[1], + v = yuv[2], + r, + g, + b; + + r = y * 1 + u * 0 + v * 1.13983; + g = y * 1 + u * -0.39465 + v * -0.5806; + b = y * 1 + u * 2.02311 + v * 0; + + r = Math.min(Math.max(0, r), 1); + g = Math.min(Math.max(0, g), 1); + b = Math.min(Math.max(0, b), 1); + + return [r * 255, g * 255, b * 255]; +} + +export function rgb2yuv(rgb) { + var r = rgb[0] / 255, + g = rgb[1] / 255, + b = rgb[2] / 255; + + var y = r * 0.299 + g * 0.587 + b * 0.114; + var u = r * -0.14713 + g * -0.28886 + b * 0.436; + var v = r * 0.615 + g * -0.51499 + b * -0.10001; + + return [y, u, v]; +} diff --git a/packages/react-base16-styling/src/index.js b/packages/react-base16-styling/src/index.js new file mode 100644 index 00000000..621ccf39 --- /dev/null +++ b/packages/react-base16-styling/src/index.js @@ -0,0 +1,212 @@ +import curry from 'lodash.curry'; +import * as base16 from 'base16'; +import rgb2hex from 'pure-color/convert/rgb2hex'; +import parse from 'pure-color/parse'; +import flow from 'lodash.flow'; +import { yuv2rgb, rgb2yuv } from './colorConverters'; + +const DEFAULT_BASE16 = base16.default; + +const BASE16_KEYS = Object.keys(DEFAULT_BASE16); + +// we need a correcting factor, so that a dark, but not black background color +// converts to bright enough inversed color +const flip = x => (x < 0.25 ? 1 : x < 0.5 ? 0.9 - x : 1.1 - x); + +const invertColor = flow( + parse, + rgb2yuv, + ([y, u, v]) => [flip(y), u, v], + yuv2rgb, + rgb2hex +); + +const merger = function merger(styling) { + return prevStyling => ({ + className: [prevStyling.className, styling.className] + .filter(Boolean) + .join(' '), + style: { ...(prevStyling.style || {}), ...(styling.style || {}) } + }); +}; + +const mergeStyling = function mergeStyling(customStyling, defaultStyling) { + if (customStyling === undefined) { + return defaultStyling; + } + if (defaultStyling === undefined) { + return customStyling; + } + + const customType = typeof customStyling; + const defaultType = typeof defaultStyling; + + switch (customType) { + case 'string': + switch (defaultType) { + case 'string': + return [defaultStyling, customStyling].filter(Boolean).join(' '); + case 'object': + return merger({ className: customStyling, style: defaultStyling }); + case 'function': + return (styling, ...args) => + merger({ + className: customStyling + })(defaultStyling(styling, ...args)); + } + break; + case 'object': + switch (defaultType) { + case 'string': + return merger({ className: defaultStyling, style: customStyling }); + case 'object': + return { ...defaultStyling, ...customStyling }; + case 'function': + return (styling, ...args) => + merger({ + style: customStyling + })(defaultStyling(styling, ...args)); + } + break; + case 'function': + switch (defaultType) { + case 'string': + return (styling, ...args) => + customStyling( + merger(styling)({ + className: defaultStyling + }), + ...args + ); + case 'object': + return (styling, ...args) => + customStyling( + merger(styling)({ + style: defaultStyling + }), + ...args + ); + case 'function': + return (styling, ...args) => + customStyling(defaultStyling(styling, ...args), ...args); + } + } +}; + +const mergeStylings = function mergeStylings(customStylings, defaultStylings) { + const keys = Object.keys(defaultStylings); + for (const key in customStylings) { + if (keys.indexOf(key) === -1) keys.push(key); + } + + return keys.reduce( + (mergedStyling, key) => ( + (mergedStyling[key] = mergeStyling( + customStylings[key], + defaultStylings[key] + )), + mergedStyling + ), + {} + ); +}; + +const getStylingByKeys = (mergedStyling, keys, ...args) => { + if (keys === null) { + return mergedStyling; + } + + if (!Array.isArray(keys)) { + keys = [keys]; + } + + const styles = keys.map(key => mergedStyling[key]).filter(Boolean); + + const props = styles.reduce( + (obj, s) => { + if (typeof s === 'string') { + obj.className = [obj.className, s].filter(Boolean).join(' '); + } else if (typeof s === 'object') { + obj.style = { ...obj.style, ...s }; + } else if (typeof s === 'function') { + obj = { ...obj, ...s(obj, ...args) }; + } + + return obj; + }, + { className: '', style: {} } + ); + + if (!props.className) { + delete props.className; + } + + if (Object.keys(props.style).length === 0) { + delete props.style; + } + + return props; +}; + +export const invertTheme = theme => + Object.keys(theme).reduce( + (t, key) => ( + (t[key] = /^base/.test(key) + ? invertColor(theme[key]) + : key === 'scheme' + ? theme[key] + ':inverted' + : theme[key]), + t + ), + {} + ); + +export const createStyling = curry( + (getStylingFromBase16, options = {}, themeOrStyling = {}, ...args) => { + const { defaultBase16 = DEFAULT_BASE16, base16Themes = null } = options; + + const base16Theme = getBase16Theme(themeOrStyling, base16Themes); + if (base16Theme) { + themeOrStyling = { + ...base16Theme, + ...themeOrStyling + }; + } + + const theme = BASE16_KEYS.reduce( + (t, key) => ((t[key] = themeOrStyling[key] || defaultBase16[key]), t), + {} + ); + + const customStyling = Object.keys(themeOrStyling).reduce( + (s, key) => + BASE16_KEYS.indexOf(key) === -1 + ? ((s[key] = themeOrStyling[key]), s) + : s, + {} + ); + + const defaultStyling = getStylingFromBase16(theme); + + const mergedStyling = mergeStylings(customStyling, defaultStyling); + + return curry(getStylingByKeys, 2)(mergedStyling, ...args); + }, + 3 +); + +export const getBase16Theme = (theme, base16Themes) => { + if (theme && theme.extend) { + theme = theme.extend; + } + + if (typeof theme === 'string') { + const [themeName, modifier] = theme.split(':'); + theme = (base16Themes || {})[themeName] || base16[themeName]; + if (modifier === 'inverted') { + theme = invertTheme(theme); + } + } + + return theme && theme.hasOwnProperty('base00') ? theme : undefined; +}; diff --git a/packages/react-base16-styling/test/index.test.js b/packages/react-base16-styling/test/index.test.js new file mode 100644 index 00000000..82bc4469 --- /dev/null +++ b/packages/react-base16-styling/test/index.test.js @@ -0,0 +1,189 @@ +import { createStyling, invertTheme, getBase16Theme } from '../src'; +import apathy from 'base16/lib/apathy'; + +const base16Theme = { + scheme: 'myscheme', + author: 'me', + base00: '#000000', + base01: '#222222', + base02: '#444444', + base03: '#666666', + base04: '#999999', + base05: '#bbbbbb', + base06: '#dddddd', + base07: '#ffffff', + base08: '#ff0000', + base09: '#ff9900', + base0A: '#ffff00', + base0B: '#999900', + base0C: '#009999', + base0D: '#009900', + base0E: '#9999ff', + base0F: '#ff0099' +}; + +const invertedBase16Theme = { + scheme: 'myscheme:inverted', + author: 'me', + base00: '#ffffff', + base01: '#ffffff', + base02: '#a2a1a2', + base03: '#807f80', + base04: '#807f80', + base05: '#5e5d5e', + base06: '#3c3b3c', + base07: '#1a191a', + base08: '#ff4d4d', + base09: '#cb6500', + base0A: '#545400', + base0B: '#a2a20a', + base0C: '#0fa8a8', + base0D: '#32cb32', + base0E: '#6868ce', + base0F: '#ff2ac3' +}; + +const apathyInverted = { + author: 'jannik siebert (https://github.com/janniks)', + base00: '#efffff', + base01: '#e3ffff', + base02: '#daffff', + base03: '#67a49a', + base04: '#66a399', + base05: '#51857c', + base06: '#3c635d', + base07: '#2a3f3c', + base08: '#2f8779', + base09: '#4e89a6', + base0A: '#8391db', + base0B: '#b167bf', + base0C: '#c8707e', + base0D: '#a7994f', + base0E: '#469038', + base0F: '#3a9257', + scheme: 'apathy:inverted' +}; + +const getStylingFromBase16 = base16 => ({ + testClass: 'testClass', + testStyle: { + color: base16.base00 + }, + testFunc: ({ style }, arg) => ({ + className: 'testClass--' + arg, + style: { + ...style, + width: 0, + color: base16.base00 + } + }), + baseStyle: { + color: 'red' + }, + additionalStyle: { + border: 0 + } +}); + +test('invertTheme', () => { + expect(invertTheme(base16Theme)).toEqual(invertedBase16Theme); +}); + +test('getBase16Theme', () => { + expect(getBase16Theme('apathy')).toEqual(apathy); + expect(getBase16Theme({ extend: 'apathy' })).toEqual(apathy); + expect(getBase16Theme('apathy:inverted')).toEqual(apathyInverted); + expect(getBase16Theme({})).toBe(undefined); +}); + +test('createStyling (default)', () => { + const styling = createStyling(getStylingFromBase16, { + defaultBase16: apathy + }); + const defaultStyling = styling(undefined); + + expect(defaultStyling('testClass')).toEqual({ className: 'testClass' }); + expect(defaultStyling('testStyle')).toEqual({ + style: { color: apathy.base00 } + }); + expect(defaultStyling('testFunc', 'mod')).toEqual({ + className: 'testClass--mod', + style: { + width: 0, + color: apathy.base00 + } + }); +}); + +test('createStyling (custom)', () => { + const styling = createStyling(getStylingFromBase16, { + defaultBase16: apathy + }); + let customStyling = styling({ + testClass: 'customClass', + testStyle: { height: 0 }, + testFunc: (styling, arg) => ({ + className: styling.className + ' customClass--' + arg, + style: { + ...styling.style, + border: 0 + } + }) + }); + + expect(customStyling('testClass')).toEqual({ + className: 'testClass customClass' + }); + expect(customStyling('testStyle')).toEqual({ + style: { color: apathy.base00, height: 0 } + }); + expect(customStyling('testFunc', 'mod')).toEqual({ + className: 'testClass--mod customClass--mod', + style: { + width: 0, + color: apathy.base00, + border: 0 + } + }); + + customStyling = styling({ + testClass: () => ({ + className: 'customClass' + }), + testStyle: () => ({ + style: { + border: 0 + } + }) + }); + + expect(customStyling('testClass')).toEqual({ className: 'customClass' }); + expect(customStyling('testStyle')).toEqual({ style: { border: 0 } }); +}); + +test('createStyling (multiple)', () => { + const styling = createStyling(getStylingFromBase16, { + defaultBase16: apathy + }); + let customStyling = styling({ + baseStyle: ({ style }) => ({ style: { ...style, color: 'blue' } }) + }); + + expect(customStyling(['baseStyle', 'additionalStyle'])).toEqual({ + style: { + color: 'blue', + border: 0 + } + }); + + customStyling = styling({ + additionalStyle: ({ style }) => ({ style: { ...style, border: 1 } }) + }); + + expect(customStyling(['baseStyle', 'additionalStyle'])).toEqual({ + style: { + color: 'red', + border: 1 + } + }); +}); diff --git a/yarn.lock b/yarn.lock index 352c788b..47c30c22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10539,7 +10539,7 @@ lodash.clonedeep@4.5.0, lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= -lodash.curry@^4.0.1: +lodash.curry@^4.0.1, lodash.curry@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170" integrity sha1-JI42By7ekGUB11lmIAqG2riyMXA= @@ -10566,7 +10566,7 @@ lodash.flattendeep@^4.4.0: resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI= -lodash.flow@^3.3.0: +lodash.flow@^3.3.0, lodash.flow@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/lodash.flow/-/lodash.flow-3.5.0.tgz#87bf40292b8cf83e4e8ce1a3ae4209e20071675a" integrity sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o= @@ -12956,7 +12956,7 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -pure-color@^1.2.0: +pure-color@^1.2.0, pure-color@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e" integrity sha1-H+Bk+wrIUfDeYTIKi/eWg2Qi8z4= @@ -13136,16 +13136,6 @@ react-base16-styling@^0.5.1, react-base16-styling@^0.5.3: lodash.flow "^3.3.0" pure-color "^1.2.0" -react-base16-styling@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.6.0.tgz#ef2156d66cf4139695c8a167886cb69ea660792c" - integrity sha1-7yFW1mz0E5aVyKFniGy2nqZgeSw= - dependencies: - base16 "^1.0.0" - lodash.curry "^4.0.1" - lodash.flow "^3.3.0" - pure-color "^1.2.0" - react-bootstrap@^0.30.6: version "0.30.10" resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-0.30.10.tgz#dbba6909595f2af4d91937db0f96ec8c2df2d1a8"