├── .eslintignore ├── .prettierignore ├── .npmignore ├── src ├── constants.ts ├── index.ts ├── utils │ ├── normalizeModifiers.ts │ ├── __tests__ │ │ ├── normalizeModifiers.js │ │ └── modifiedStyles.js │ ├── isResponsiveModifiersProp.ts │ └── modifiedStyles.ts ├── types.ts ├── applyStyleModifiers.ts ├── responsiveStyleModifierPropTypes.ts ├── applyResponsiveStyleModifiers.ts ├── __tests__ │ ├── applyStyleModifiers.js │ ├── applyResponsiveStyleModifiers.js │ ├── responsiveModifierPropTypes.js │ └── styleModifierPropTypes.js └── styleModifierPropTypes.ts ├── .prettierrc ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── CONTRIBUTING.md ├── tsconfig.json ├── babel.config.js ├── .circleci └── config.yml ├── LICENSE ├── .eslintrc.js ├── .gitignore ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | *.tsbuildinfo 2 | .eslint* 3 | package.json 4 | tsconfig.json 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **-lock.json 2 | *.tsbuildinfo 3 | coverage 4 | lib/** 5 | node_modules 6 | tmp 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | src 3 | .babelrc 4 | .eslintrc 5 | .github 6 | *npm-debug.log 7 | *.DS_Store 8 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | export const DEFAULT_MODIFIERS_KEY = '_'; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "printWidth": 80, 5 | "proseWrap": "always", 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "trailingComma": "all" 9 | } 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## EXPECTED BEHAVIOR 2 | 3 | _What's the behavior you're expecting to see?_ 4 | 5 | ## ACTUAL BEHAVIOR 6 | 7 | _What's actually happening instead?_ 8 | 9 | ## STEPS TO REPRODUCE 10 | 11 | _Please provide some simple steps to reproduce the issue._ \* \* \* 12 | 13 | ## SUGGESTED SOLUTION 14 | 15 | _Do you have any feedback on how this problem should be solved?_ 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import applyResponsiveStyleModifiers from './applyResponsiveStyleModifiers'; 2 | import applyStyleModifiers from './applyStyleModifiers'; 3 | import styleModifierPropTypes from './styleModifierPropTypes'; 4 | import responsiveStyleModifierPropTypes from './responsiveStyleModifierPropTypes'; 5 | 6 | export * from './types'; 7 | 8 | export { 9 | applyResponsiveStyleModifiers, 10 | applyStyleModifiers, 11 | styleModifierPropTypes, 12 | responsiveStyleModifierPropTypes, 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/normalizeModifiers.ts: -------------------------------------------------------------------------------- 1 | import { ModifierKeys, ModifierNames } from '../types'; 2 | 3 | /** 4 | * Normalizes string modifier props to be an array. 5 | * @export 6 | * @param {ModifierKeys} modifierKeys 7 | * @returns {ModifierNames} 8 | */ 9 | export default function normalizeModifiers( 10 | modifierKeys: ModifierKeys, 11 | ): ModifierNames { 12 | return (Array.isArray(modifierKeys) ? modifierKeys : [modifierKeys]).filter( 13 | (i): boolean => typeof i === 'string' && !!i, 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/__tests__/normalizeModifiers.js: -------------------------------------------------------------------------------- 1 | import normalizeModifiers from '../normalizeModifiers'; 2 | 3 | test('returns an array with a modifier if passed a string', () => { 4 | expect(normalizeModifiers('foo')).toEqual(['foo']); 5 | }); 6 | 7 | test('removes any non string elements from the modifiers array', () => { 8 | expect( 9 | normalizeModifiers([ 10 | 'foo', 11 | '', 12 | 1, 13 | NaN, 14 | null, 15 | true, 16 | false, 17 | undefined, 18 | new Date(), 19 | ]), 20 | ).toEqual(['foo']); 21 | }); 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "declarationMap": true, 7 | "emitDeclarationOnly": true, 8 | "esModuleInterop": true, 9 | "lib": ["esnext", "dom"], 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "outDir": "lib", 13 | "removeComments": true, 14 | "rootDir": "src", 15 | "skipLibCheck": true, 16 | "sourceMap": true, 17 | "strict": true, 18 | "target": "es5", 19 | "watch": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/isResponsiveModifiersProp.ts: -------------------------------------------------------------------------------- 1 | import isObject from 'lodash/isObject'; 2 | 3 | import { ResponsiveModifiersProp, ModifiersConfig } from '../types'; 4 | 5 | /** 6 | * Evaluates if the first argument is of the ResponsiveModifiersProp type 7 | * @export 8 | * @param {*} val 9 | * @returns {val is ResponsiveModifiersProp} 10 | */ 11 | export default function isResponsiveModifiersProp( 12 | val: any, // eslint-disable-line @typescript-eslint/no-explicit-any 13 | ): val is ResponsiveModifiersProp { 14 | return isObject(val) && !Array.isArray(val); 15 | } 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## OVERVIEW 2 | 3 | _Give a brief description of what this PR does._ 4 | 5 | ## WHERE SHOULD THE REVIEWER START? 6 | 7 | _e.g. `/src/components/SomeComponent.js`_ 8 | 9 | ## HOW CAN THIS BE MANUALLY TESTED? 10 | 11 | _List steps to test this locally._ 12 | 13 | ## ANY NEW DEPENDENCIES ADDED? 14 | 15 | _List any new dependencies added._ 16 | 17 | ## CHECKLIST 18 | 19 | _Be sure all items are_ ✅ _before submitting a PR for review._ 20 | 21 | - [ ] Verify the linter and tests pass: `npm run review` 22 | - [ ] Verify this branch is rebased with the latest master 23 | 24 | ## GIF 25 | 26 | _Share a fun GIF to say thanks to your reviewer:_ https://giphy.com 27 | 28 | ![](https://media.giphy.com/media/xTiTnfkt9wCx4fuWhW/giphy.gif) 29 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // https://www.wisdomgeek.com/development/web-development/how-to-setup-jest-typescript-babel-webpack-project/ 2 | module.exports = (api) => { 3 | /** 4 | * Cache the returned value forever and don't call this function again. This is the default behavior but since we 5 | * are reading the env value above, we need to explicitly set it after we are done doing that, else we get a 6 | * caching was left unconfigured error. 7 | */ 8 | api.cache(true); 9 | 10 | return { 11 | presets: [ 12 | [ 13 | // Allows smart transpilation according to target environments 14 | '@babel/preset-env', 15 | { 16 | // Specifying which browser versions you want to transpile down to 17 | targets: '> 0.25%, not dead', 18 | 19 | modules: 'commonjs', 20 | }, 21 | ], 22 | // Enabling Babel to understand TypeScript 23 | '@babel/preset-typescript', 24 | ], 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | - image: circleci/node:8 10 | working_directory: ~/styled-components-modifiers 11 | steps: 12 | - checkout 13 | - restore_cache: 14 | keys: 15 | - v1-dependencies-{{ checksum "package.json" }} 16 | # fallback to using the latest cache if no exact match is found 17 | - v1-dependencies- 18 | - run: yarn install 19 | - save_cache: 20 | paths: 21 | - node_modules 22 | key: v1-dependencies-{{ checksum "package.json" }} 23 | - run: 24 | name: Linting 25 | command: yarn lint:ci 26 | - run: 27 | name: Tests 28 | command: yarn test:ci 29 | - store_test_results: 30 | path: reports/junit 31 | - store_artifacts: 32 | path: reports/junit 33 | - store_artifacts: 34 | path: coverage 35 | prefix: coverage 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Decisiv Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { SimpleInterpolation, StyledProps } from 'styled-components'; 2 | 3 | export type ModifierName = string; 4 | 5 | export type ModifierNames = ModifierName[]; 6 | 7 | /** 8 | * The single modifier key or array of modifier keys 9 | */ 10 | export type ModifierKeys = ModifierNames | ModifierName; 11 | 12 | /** 13 | * An object where the keys are breakpoint sizes and the values are valid ModifierKeys. 14 | */ 15 | export type ResponsiveModifiersProp = { 16 | _?: keyof MC | (keyof MC)[]; 17 | } & { [key in keyof S]?: keyof MC | (keyof MC)[] }; 18 | 19 | /** 20 | * The prop passed to the component when it is rendered. 21 | */ 22 | export type ModifiersProp = 23 | | keyof MC 24 | | (keyof MC)[] 25 | | ResponsiveModifiersProp; 26 | 27 | export interface ModifierObjValue { 28 | styles: SimpleInterpolation; 29 | } 30 | 31 | export type ModifierConfigValue = ( 32 | props: ComponentProps, 33 | ) => SimpleInterpolation | ModifierObjValue; 34 | 35 | /** 36 | * An object declaring modifiers for use within a component. 37 | */ 38 | export interface ModifiersConfig { 39 | [key: string]: ModifierConfigValue; 40 | } 41 | 42 | /** 43 | * The component's props. 44 | */ 45 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 46 | export type ComponentProps = StyledProps; 47 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['import', '@typescript-eslint', 'jest'], 5 | extends: [ 6 | 'airbnb-base', 7 | 'plugin:prettier/recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | ], 10 | env: { 11 | commonjs: true, 12 | es6: true, 13 | jest: true, 14 | node: true, 15 | }, 16 | parserOptions: { 17 | ecmaVersion: 6, 18 | sourceType: 'module', 19 | ecmaFeatures: { 20 | jsx: true, 21 | }, 22 | project: './tsconfig.json', 23 | tsconfigRootDir: '.', 24 | }, 25 | settings: { 26 | 'import/resolver': { 27 | typescript: {}, 28 | }, 29 | }, 30 | rules: { 31 | 'prettier/prettier': 'error', 32 | // Note regarding rule severity, the available values are: 33 | // 'off' or 0 - turn the rule off 34 | // 'warn' or 1 - turn the rule on as a warning (doesn't effect exit code) 35 | // 'error' or 2 - turn the rule on as an error (exit code is 1 when triggered) 36 | //------------------------------------------------------------------------------------------- 37 | 'import/no-extraneous-dependencies': 0, 38 | 'import/no-named-as-default': 0, 39 | 'no-console': 1, 40 | 'spaced-comment': [ 41 | 'error', 42 | 'always', 43 | { 44 | exceptions: ['=', '-'], 45 | }, 46 | ], 47 | 48 | indent: 'off', 49 | '@typescript-eslint/indent': ['error', 2], 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /src/applyStyleModifiers.ts: -------------------------------------------------------------------------------- 1 | import { InterpolationFunction, SimpleInterpolation } from 'styled-components'; 2 | 3 | import { DEFAULT_MODIFIERS_KEY } from './constants'; 4 | 5 | import isResponsiveModifiersProp from './utils/isResponsiveModifiersProp'; 6 | import modifiedStyles from './utils/modifiedStyles'; 7 | import { 8 | ComponentProps, 9 | ModifierKeys, 10 | ModifiersConfig, 11 | ModifiersProp, 12 | } from './types'; 13 | 14 | /** 15 | * Returns a function that evaluates a modifiersConfig object against a component's props. 16 | * This function will return a string of CSS styles based on those inputs. 17 | * @export 18 | * @param {ModifiersConfig} modifiersConfig 19 | * @param {string} [modifiersPropName="modifiers"] 20 | * @returns 21 | */ 22 | export default function applyStyleModifiers( 23 | modifiersConfig: ModifiersConfig, 24 | modifiersPropName: string = 'modifiers', 25 | ): InterpolationFunction { 26 | return ( 27 | props: ComponentProps & { 28 | size: string; 29 | [modifiersPropName: string]: ModifiersProp; 30 | }, 31 | ): SimpleInterpolation => { 32 | const modifiers = props[modifiersPropName]; 33 | 34 | if (isResponsiveModifiersProp(modifiers)) { 35 | return modifiedStyles( 36 | (modifiers as ModifierKeys)[props.size || DEFAULT_MODIFIERS_KEY], 37 | modifiersConfig, 38 | props, 39 | ); 40 | } 41 | 42 | return modifiedStyles(modifiers, modifiersConfig, props); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/responsiveStyleModifierPropTypes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TODO: (DEPRECATED) The functionality provided here is now incorporated into 3 | * `styleModifierPropTypes`. This should be removed before the v2 release. 4 | */ 5 | 6 | import { Validator } from 'prop-types'; 7 | 8 | import isResponsiveModifiersProp from './utils/isResponsiveModifiersProp'; 9 | import { validateResponsiveModifiers } from './styleModifierPropTypes'; 10 | import { 11 | ComponentProps, 12 | ModifierKeys, 13 | ModifiersConfig, 14 | ModifiersProp, 15 | } from './types'; 16 | 17 | /** 18 | * Evaluates the modifiers prop against the modifier config. Throws invalid proptype error 19 | * if a modifier is supplied in prop and not found in modifier config. 20 | * @export 21 | * @param {ModifiersConfig} modifierConfig 22 | * @returns {Validator} 23 | */ 24 | export default function responsiveStyleModifierPropTypes( 25 | modifierConfig: ModifiersConfig, 26 | ): Validator { 27 | const validator = ( 28 | props: ComponentProps & { 29 | [propName: string]: ModifiersProp; 30 | }, 31 | propName: string, 32 | componentName: string, 33 | ): Error | null => { 34 | const responsiveModifiers = props[propName]; 35 | 36 | if (isResponsiveModifiersProp(responsiveModifiers)) { 37 | return validateResponsiveModifiers( 38 | propName, 39 | componentName, 40 | responsiveModifiers, 41 | modifierConfig, 42 | ); 43 | } 44 | 45 | return null; 46 | }; 47 | 48 | return validator as Validator; 49 | } 50 | -------------------------------------------------------------------------------- /src/applyResponsiveStyleModifiers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TODO: (DEPRECATED) The functionality provided here is now incorporated into 3 | * `styleModifierPropTypes`. This should be removed before the v2 release. 4 | */ 5 | 6 | import { InterpolationFunction, SimpleInterpolation } from 'styled-components'; 7 | 8 | import { DEFAULT_MODIFIERS_KEY } from './constants'; 9 | 10 | import isResponsiveModifiersProp from './utils/isResponsiveModifiersProp'; 11 | import modifiedStyles from './utils/modifiedStyles'; 12 | import { 13 | ComponentProps, 14 | ModifierKeys, 15 | ModifiersConfig, 16 | ModifiersProp, 17 | } from './types'; 18 | 19 | /** 20 | * Returns a function that evaluates a modifiersConfig object against a component's props, 21 | * including a size prop. This function will return a string of CSS styles based on those inputs. 22 | * @export 23 | * @param {ModifiersConfig} modifiersConfig 24 | * @param {string} [modifiersPropName="responsiveModifiers"] 25 | * @returns 26 | */ 27 | export default function applyResponsiveStyleModifiers( 28 | modifiersConfig: ModifiersConfig, 29 | modifiersPropName: string = 'responsiveModifiers', 30 | ): InterpolationFunction { 31 | return ( 32 | props: ComponentProps & { 33 | size: string; 34 | [modifiersPropName: string]: ModifiersProp; 35 | }, 36 | ): SimpleInterpolation => { 37 | const responsiveModifiers = props[modifiersPropName]; 38 | 39 | if (isResponsiveModifiersProp(responsiveModifiers)) { 40 | return modifiedStyles( 41 | (responsiveModifiers as ModifierKeys)[ 42 | props.size || DEFAULT_MODIFIERS_KEY 43 | ], 44 | modifiersConfig, 45 | props, 46 | ); 47 | } 48 | 49 | return null; 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/__tests__/applyStyleModifiers.js: -------------------------------------------------------------------------------- 1 | import applyStyleModifiers from '../applyStyleModifiers'; 2 | 3 | const defaultModifierConfig = { 4 | test: () => ({ 5 | styles: 'display: relative;', 6 | }), 7 | themeTest: ({ theme }) => ({ 8 | styles: `background-color: ${theme.colors.text};`, 9 | }), 10 | stringTest: () => 'color: blue;', 11 | }; 12 | 13 | const theme = { 14 | colors: { 15 | text: 'black', 16 | }, 17 | }; 18 | 19 | test('returns the expect styles based on modifier prop', () => { 20 | const props = { 21 | modifiers: ['test', 'stringTest'], 22 | theme, 23 | }; 24 | 25 | const styles = applyStyleModifiers(defaultModifierConfig)(props); 26 | 27 | expect(styles).toEqual('display: relative; color: blue;'); 28 | }); 29 | 30 | test('returns the expected styles when modifier interpolates from theme', () => { 31 | const props = { 32 | modifiers: 'themeTest', 33 | theme, 34 | }; 35 | 36 | const styles = applyStyleModifiers(defaultModifierConfig)(props); 37 | 38 | expect(styles).toEqual('background-color: black;'); 39 | }); 40 | 41 | test('returns a style string with styles based on size prop', () => { 42 | const props = { 43 | modifiers: { 44 | XS: ['test'], 45 | SM: ['themeTest'], 46 | }, 47 | size: 'SM', 48 | theme, 49 | }; 50 | 51 | const styles = applyStyleModifiers(defaultModifierConfig)(props); 52 | 53 | expect(styles).toContain('background-color: black;'); 54 | expect(styles).not.toContain('display: relative;'); 55 | }); 56 | 57 | test('returns default modifiers if size prop does not match', () => { 58 | const props = { 59 | modifiers: { 60 | _: ['test'], 61 | XS: 'themeTest', 62 | }, 63 | size: undefined, 64 | theme, 65 | }; 66 | 67 | const styles = applyStyleModifiers(defaultModifierConfig)(props); 68 | 69 | expect(styles).toContain('display: relative;'); 70 | expect(styles).not.toContain('background-color: black;'); 71 | }); 72 | -------------------------------------------------------------------------------- /src/utils/modifiedStyles.ts: -------------------------------------------------------------------------------- 1 | import isFunction from 'lodash/isFunction'; 2 | import isObject from 'lodash/isObject'; 3 | import { SimpleInterpolation } from 'styled-components'; 4 | 5 | import normalizeModifiers from './normalizeModifiers'; 6 | 7 | import { 8 | ComponentProps, 9 | ModifierKeys, 10 | ModifierName, 11 | ModifierObjValue, 12 | ModifiersConfig, 13 | } from '../types'; 14 | 15 | /** 16 | * Evaluates if the first argument is of the ModifierObjValue type 17 | * @param {*} val 18 | * @returns {val is ModifierObjValue} 19 | */ 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | function isModifierObjValue(val: any): val is ModifierObjValue { 22 | return isObject(val) && !!(val as ModifierObjValue).styles; 23 | } 24 | 25 | /** 26 | * Extracts and builds the required style string based on the provided values. 27 | * @export 28 | * @param {ModifierKeys} [modifierKeys=[]] 29 | * @param {ModifiersConfig} [modifierConfig={}] 30 | * @param {ComponentProps} [componentProps] 31 | * @returns {SimpleInterpolation} 32 | */ 33 | export default function modifiedStyles( 34 | modifierKeys: ModifierKeys = [], 35 | modifierConfig: ModifiersConfig = {}, 36 | componentProps: ComponentProps, 37 | ): SimpleInterpolation { 38 | const stylesArr = normalizeModifiers(modifierKeys).reduce( 39 | ( 40 | acc: SimpleInterpolation[], 41 | modifierName: ModifierName, 42 | ): SimpleInterpolation[] => { 43 | const modifierConfigValue = modifierConfig[modifierName]; 44 | 45 | if (isFunction(modifierConfigValue)) { 46 | const config = modifierConfigValue(componentProps); 47 | 48 | const styles = isModifierObjValue(config) ? config.styles : config; 49 | 50 | return Array.isArray(styles) 51 | ? acc.concat((styles as SimpleInterpolation[]).join('')) 52 | : acc.concat(styles); 53 | } 54 | 55 | return acc; 56 | }, 57 | [], 58 | ); 59 | 60 | return stylesArr.join(' '); 61 | } 62 | -------------------------------------------------------------------------------- /src/__tests__/applyResponsiveStyleModifiers.js: -------------------------------------------------------------------------------- 1 | import applyResponsiveStyleModifiers from '../applyResponsiveStyleModifiers'; 2 | 3 | const defaultModifierConfig = { 4 | test: () => ({ 5 | styles: 'display: relative;', 6 | }), 7 | themeTest: ({ theme }) => ({ 8 | styles: `background-color: ${theme.colors.text};`, 9 | }), 10 | stringTest: () => 'color: blue;', 11 | }; 12 | 13 | const theme = { 14 | colors: { 15 | text: 'black', 16 | }, 17 | }; 18 | 19 | test('returns a style string with styles based on size prop', () => { 20 | const props = { 21 | responsiveModifiers: { 22 | XS: ['test'], 23 | SM: ['themeTest'], 24 | }, 25 | size: 'SM', 26 | theme, 27 | }; 28 | 29 | const styles = applyResponsiveStyleModifiers(defaultModifierConfig)(props); 30 | 31 | expect(styles).toContain('background-color: black;'); 32 | expect(styles).not.toContain('display: relative;'); 33 | }); 34 | 35 | test('returns a style string with styles based on size prop and config styles is a string', () => { 36 | const props = { 37 | responsiveModifiers: { 38 | XS: 'stringTest', 39 | SM: ['themeTest'], 40 | }, 41 | size: 'XS', 42 | theme, 43 | }; 44 | 45 | const styles = applyResponsiveStyleModifiers(defaultModifierConfig)(props); 46 | 47 | expect(styles).toEqual('color: blue;'); 48 | }); 49 | 50 | test('returns default modifiers if size prop does not match', () => { 51 | const props = { 52 | responsiveModifiers: { 53 | _: ['test'], 54 | XS: 'themeTest', 55 | }, 56 | size: undefined, 57 | theme, 58 | }; 59 | 60 | const styles = applyResponsiveStyleModifiers(defaultModifierConfig)(props); 61 | 62 | expect(styles).toContain('display: relative;'); 63 | expect(styles).not.toContain('background-color: black;'); 64 | }); 65 | 66 | test('returns null when non responsive modifiers are provided', () => { 67 | const props = { 68 | responsiveModifiers: 'badModifiers', 69 | size: 'XS', 70 | theme, 71 | }; 72 | 73 | expect(applyResponsiveStyleModifiers(defaultModifierConfig)(props)).toEqual( 74 | null, 75 | ); 76 | }); 77 | -------------------------------------------------------------------------------- /src/utils/__tests__/modifiedStyles.js: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components'; 2 | 3 | import modifiedStyles from '../modifiedStyles'; 4 | 5 | const rebeccapurple = 'rebeccapurple'; 6 | 7 | const defaultModifierConfig = { 8 | objectTest: () => ({ styles: 'color: green;' }), 9 | styleString: () => 'color: blue;', 10 | themeTest: ({ theme }) => `background-color: ${theme.colors.text};`, 11 | cssUtilTest: () => 12 | css` 13 | background-color: ${rebeccapurple}; 14 | `, 15 | }; 16 | 17 | const theme = { 18 | colors: { 19 | text: 'black', 20 | }, 21 | }; 22 | 23 | test('returns an empty string with no args', () => { 24 | expect(modifiedStyles()).toEqual(''); 25 | }); 26 | 27 | test('returns a string with styles when modifier given and config object supplied', () => { 28 | const styles = modifiedStyles(['objectTest'], defaultModifierConfig, { 29 | theme, 30 | }); 31 | expect(styles).toContain('color: green'); 32 | }); 33 | 34 | test('returns a string with styles when modifier given and config string supplied', () => { 35 | const styles = modifiedStyles(['styleString'], defaultModifierConfig, { 36 | theme, 37 | }); 38 | expect(styles).toContain('color: blue'); 39 | }); 40 | 41 | test('returns a string with values from theme', () => { 42 | const styles = modifiedStyles(['themeTest'], defaultModifierConfig, { 43 | theme, 44 | }); 45 | expect(styles).toContain('background-color: black;'); 46 | }); 47 | 48 | test('returns an empty string if modifierName is not in modifierConfig', () => { 49 | const styles = modifiedStyles(['notFound'], defaultModifierConfig, { 50 | theme, 51 | }); 52 | expect(styles).toEqual(''); 53 | }); 54 | 55 | test('supports receiving the modifiers prop as a string', () => { 56 | const styles = modifiedStyles('themeTest', defaultModifierConfig, { 57 | theme, 58 | }); 59 | expect(styles).toContain('background-color: black;'); 60 | }); 61 | 62 | test('filters out non String entries from the modifiers prop', () => { 63 | const styles = modifiedStyles( 64 | ['styleString', '', {}, [''], true, false, null, undefined], 65 | defaultModifierConfig, 66 | { theme }, 67 | ); 68 | expect(styles).toContain('color: blue;'); 69 | }); 70 | 71 | test('supports the css util from styled components', () => { 72 | const styles = modifiedStyles(['cssUtilTest'], defaultModifierConfig, { 73 | theme, 74 | }); 75 | expect(styles).toContain('background-color: rebeccapurple;'); 76 | }); 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Editor stuff ### 2 | .vscode 3 | 4 | ### Project build artifacts ### 5 | dist 6 | lib 7 | reports 8 | *.tsbuildinfo 9 | ### Standard .gitignore stuff... ### 10 | # Created by https://www.gitignore.io/api/osx,git,node,linux,windows 11 | 12 | ### Git ### 13 | *.orig 14 | 15 | ### Linux ### 16 | *~ 17 | 18 | # temporary files which can be created if a process still has a handle open of a deleted file 19 | .fuse_hidden* 20 | 21 | # KDE directory preferences 22 | .directory 23 | 24 | # Linux trash folder which might appear on any partition or disk 25 | .Trash-* 26 | 27 | # .nfs files are created when an open file is removed but is still being accessed 28 | .nfs* 29 | 30 | ### Node ### 31 | # Logs 32 | logs 33 | *.log 34 | npm-debug.log* 35 | yarn-debug.log* 36 | yarn-error.log* 37 | 38 | # Runtime data 39 | pids 40 | *.pid 41 | *.seed 42 | *.pid.lock 43 | 44 | # Directory for instrumented libs generated by jscoverage/JSCover 45 | lib-cov 46 | 47 | # Coverage directory used by tools like istanbul 48 | coverage 49 | 50 | # nyc test coverage 51 | .nyc_output 52 | 53 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 54 | .grunt 55 | 56 | # Bower dependency directory (https://bower.io/) 57 | bower_components 58 | 59 | # node-waf configuration 60 | .lock-wscript 61 | 62 | # Compiled binary addons (http://nodejs.org/api/addons.html) 63 | build/Release 64 | 65 | # Dependency directories 66 | node_modules/ 67 | jspm_packages/ 68 | 69 | # Typescript v1 declaration files 70 | typings/ 71 | 72 | # Optional npm cache directory 73 | .npm 74 | 75 | # Optional eslint cache 76 | .eslintcache 77 | 78 | # Optional REPL history 79 | .node_repl_history 80 | 81 | # Output of 'npm pack' 82 | *.tgz 83 | 84 | # Yarn Integrity file 85 | .yarn-integrity 86 | 87 | # dotenv environment variables file 88 | .env 89 | 90 | 91 | ### OSX ### 92 | *.DS_Store 93 | .AppleDouble 94 | .LSOverride 95 | 96 | # Icon must end with two \r 97 | Icon 98 | 99 | # Thumbnails 100 | ._* 101 | 102 | # Files that might appear in the root of a volume 103 | .DocumentRevisions-V100 104 | .fseventsd 105 | .Spotlight-V100 106 | .TemporaryItems 107 | .Trashes 108 | .VolumeIcon.icns 109 | .com.apple.timemachine.donotpresent 110 | 111 | # Directories potentially created on remote AFP share 112 | .AppleDB 113 | .AppleDesktop 114 | Network Trash Folder 115 | Temporary Items 116 | .apdisk 117 | 118 | ### Windows ### 119 | # Windows thumbnail cache files 120 | Thumbs.db 121 | ehthumbs.db 122 | ehthumbs_vista.db 123 | 124 | # Folder config file 125 | Desktop.ini 126 | 127 | # Recycle Bin used on file shares 128 | $RECYCLE.BIN/ 129 | 130 | # Windows Installer files 131 | *.cab 132 | *.msi 133 | *.msm 134 | *.msp 135 | 136 | # Windows shortcuts 137 | *.lnk 138 | 139 | # End of https://www.gitignore.io/api/osx,git,node,linux,windows 140 | -------------------------------------------------------------------------------- /src/__tests__/responsiveModifierPropTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import responsiveStyleModifierPropTypes from '../responsiveStyleModifierPropTypes'; 4 | 5 | const defaultResponsiveModifierConfig = { 6 | '1_per_row': () => ({ 7 | styles: ` 8 | width: 100%; 9 | `, 10 | }), 11 | '2_per_row': () => ({ 12 | styles: ` 13 | width: 50%; 14 | `, 15 | }), 16 | '3_per_row': () => ({ 17 | styles: ` 18 | width: 33.33%; 19 | `, 20 | }), 21 | }; 22 | 23 | const noop = () => {}; 24 | 25 | test('responsiveStyleModifierPropTypes does not log error with valid modifiers', () => { 26 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); 27 | 28 | const testPropTypes = { 29 | responsiveModifiers: responsiveStyleModifierPropTypes( 30 | defaultResponsiveModifierConfig, 31 | ), 32 | }; 33 | const goodProps = { 34 | responsiveModifiers: { 35 | XS: ['1_per_row'], 36 | SM: ['2_per_row', '3_per_row'], 37 | }, 38 | }; 39 | PropTypes.checkPropTypes(testPropTypes, goodProps, 'prop', 'MyComponent'); 40 | 41 | expect(consoleSpy).not.toHaveBeenCalled(); 42 | 43 | consoleSpy.mockReset(); 44 | consoleSpy.mockRestore(); 45 | }); 46 | 47 | test('responsiveStyleModifierPropTypes logs error with invalid modifier key', () => { 48 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); 49 | 50 | const testPropTypes = { 51 | responsiveModifiers: responsiveStyleModifierPropTypes( 52 | defaultResponsiveModifierConfig, 53 | ), 54 | }; 55 | const badProps = { 56 | responsiveModifiers: { 57 | XS: ['1_per_row', 'wrongModifier'], 58 | SM: ['2_per_row'], 59 | }, 60 | }; 61 | PropTypes.checkPropTypes(testPropTypes, badProps, 'prop', 'MyComponent'); 62 | 63 | const expectedErrMsg = 64 | "Invalid modifier wrongModifier used in prop 'responsiveModifiers' (size key XS) and supplied to MyComponent. Validation failed."; 65 | expect(consoleSpy).toHaveBeenCalled(); 66 | const errorMsg = consoleSpy.mock.calls[0][0]; 67 | expect(errorMsg).toContain(expectedErrMsg); 68 | 69 | consoleSpy.mockReset(); 70 | consoleSpy.mockRestore(); 71 | }); 72 | 73 | test('responsiveStyleModifierPropTypes logs error with invalid modifier keys', () => { 74 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); 75 | 76 | const testPropTypes = { 77 | responsiveModifiers: responsiveStyleModifierPropTypes( 78 | defaultResponsiveModifierConfig, 79 | ), 80 | }; 81 | const badProps = { 82 | responsiveModifiers: { 83 | XS: ['1_per_row', 'firstWrongModifier'], 84 | SM: ['2_per_row', 'secondWrongModifier'], 85 | }, 86 | }; 87 | PropTypes.checkPropTypes(testPropTypes, badProps, 'prop', 'MyComponent'); 88 | 89 | const expectedErrMsg = 90 | "Invalid modifiers firstWrongModifier, secondWrongModifier used in prop 'responsiveModifiers' (size keys XS, SM) and supplied to MyComponent. Validation failed."; 91 | expect(consoleSpy).toHaveBeenCalled(); 92 | const errorMsg = consoleSpy.mock.calls[0][0]; 93 | expect(errorMsg).toContain(expectedErrMsg); 94 | 95 | consoleSpy.mockReset(); 96 | consoleSpy.mockRestore(); 97 | }); 98 | 99 | test('responsiveStyleModifierPropTypes returns null if non responsiveModifiers are provided', () => { 100 | const nonResponsiveModifiers = { 101 | test: () => ({ styles: 'display: relative;' }), 102 | defaultTest: () => ({ 103 | styles: 'color: blue;', 104 | defaultStyles: 'color: red;', 105 | }), 106 | }; 107 | 108 | const result = responsiveStyleModifierPropTypes( 109 | defaultResponsiveModifierConfig, 110 | )(nonResponsiveModifiers, 'prop', 'MyComponent'); 111 | 112 | expect(result).toEqual(null); 113 | }); 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "styled-components-modifiers", 3 | "version": "1.2.5", 4 | "description": "A library that enables BEM flavored modifiers to styled components", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "files": [ 8 | "lib/*" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/Decisiv/styled-components-modifiers.git" 13 | }, 14 | "keywords": [ 15 | "styled-components", 16 | "bem", 17 | "blocks", 18 | "elements", 19 | "modifiers", 20 | "responsive", 21 | "css", 22 | "react" 23 | ], 24 | "contributors": [ 25 | "UI Platform Development Team at Decisiv, Inc." 26 | ], 27 | "license": "MIT", 28 | "scripts": { 29 | "build": "yarn build:types && yarn build:js", 30 | "build:js": "babel -d lib src --extensions \".js,.ts\" --ignore \"**/__tests__\"", 31 | "build:types": "tsc --build --force", 32 | "build:watch": "concurrently \" yarn build:types -w\" \"yarn build:js -w\"", 33 | "build:clean": "rimraf ./lib", 34 | "lint": "eslint src/**/*.{js*,ts*}", 35 | "prebuild": "yarn build:clean && yarn lint && yarn test", 36 | "prepublish": "yarn build", 37 | "prettier": "prettier --write '**/*.{js*,ts*,md}'", 38 | "review": "yarn lint && yarn test", 39 | "test": "jest", 40 | "test:coverage:report": "open-cli coverage/lcov-report/index.html", 41 | "lint:ci": "eslint --format junit -o reports/junit/js-lint-results.xml src/**", 42 | "test:ci": "jest --ci" 43 | }, 44 | "peerDependencies": { 45 | "prop-types": "^15.4.0", 46 | "styled-components": "^2 || ^3 || ^4" 47 | }, 48 | "dependencies": { 49 | "@types/prop-types": "^15.7.1", 50 | "@types/styled-components": "^4.1.15", 51 | "lodash": "^4.17.15" 52 | }, 53 | "devDependencies": { 54 | "@babel/cli": "^7.4.4", 55 | "@babel/core": "^7.4.4", 56 | "@babel/preset-env": "^7.4.4", 57 | "@babel/preset-typescript": "^7.3.3", 58 | "@types/lodash": "^4.14.144", 59 | "@typescript-eslint/eslint-plugin": "^1.9.0", 60 | "@typescript-eslint/parser": "^1.9.0", 61 | "babel-eslint": "^10.0.1", 62 | "babel-jest": "^24.7.1", 63 | "concurrently": "^4.1.0", 64 | "eslint": "^5.16.0", 65 | "eslint-config-airbnb-base": "^13.1.0", 66 | "eslint-config-prettier": "^4.2.0", 67 | "eslint-import-resolver-typescript": "^1.1.1", 68 | "eslint-plugin-import": "^2.17.2", 69 | "eslint-plugin-jest": "^22.5.1", 70 | "eslint-plugin-prettier": "^3.1.0", 71 | "husky": "^3.0.0", 72 | "jest": "^24.8.0", 73 | "jest-junit": "^6.4.0", 74 | "lint-staged": "^9.2.0", 75 | "opn-cli": "^5.0.0", 76 | "prettier": "^1.17.1", 77 | "prop-types": "^15.6.0", 78 | "react": "^16.8.6", 79 | "react-dom": "^16.8.6", 80 | "rimraf": "^2.6.2", 81 | "styled-components": "^4.2.0", 82 | "typescript": "^3.4.5" 83 | }, 84 | "sideEffects": false, 85 | "husky": { 86 | "hooks": { 87 | "pre-commit": "lint-staged" 88 | } 89 | }, 90 | "jest": { 91 | "collectCoverage": true, 92 | "collectCoverageFrom": [ 93 | "!lib/**", 94 | "!src/index.ts", 95 | "!src/types.ts", 96 | "src/**" 97 | ], 98 | "coverageThreshold": { 99 | "global": { 100 | "branches": 90, 101 | "functions": 90, 102 | "lines": 90, 103 | "statements": 90 104 | } 105 | }, 106 | "reporters": [ 107 | "default", 108 | [ 109 | "jest-junit", 110 | { 111 | "output": "reports/junit/js-test-results.xml" 112 | } 113 | ] 114 | ] 115 | }, 116 | "lint-staged": { 117 | "*.{js*,ts*}": [ 118 | "yarn run lint", 119 | "git add" 120 | ], 121 | "*.{js*,ts*,md}": [ 122 | "yarn run prettier", 123 | "git add" 124 | ] 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish 4 | to make by creating an issue. Please note we have a code of conduct. Please 5 | follow it in all your interactions with the project. 6 | 7 | ## Pull Request Process 8 | 9 | - Create a fork of this repository, then clone the fork to your local 10 | development environment: 11 | `git clone https://github.com/YOUR_USERNAME/styled-components-modifiers.git` 12 | - Create a branch with a meaningful name reflecting the bug fix or new feature: 13 | `git checkout -b my-new-feature` 14 | - Make your changes (including updates/additions to tests) and commit: `git add` 15 | and `git commit` 16 | - Make sure that the tests still pass: `yarn run review` 17 | - Push your branch: `git push -u origin my-new-feature` 18 | - [Submit a pull request](https://github.com/Decisiv/styled-components-modifiers/compare) 19 | to the upstream `styled-components-modifiers` repository. 20 | - Enter a descriptive title for the pull request and fill in the PR template, 21 | describing the proposed changes. 22 | - Wait for a maintainer to review your PR. Follow up on any comments from the 23 | reviewer, and wait for a maintainer to merge the PR. 24 | 25 | ## Contributor Covenant Code of Conduct 26 | 27 | ### Our Pledge 28 | 29 | In the interest of fostering an open and welcoming environment, we as 30 | contributors and maintainers pledge to making participation in our project and 31 | our community a harassment-free experience for everyone, regardless of age, body 32 | size, disability, ethnicity, gender identity and expression, level of 33 | experience, nationality, personal appearance, race, religion, or sexual identity 34 | and orientation. 35 | 36 | ### Our Standards 37 | 38 | Examples of behavior that contributes to creating a positive environment 39 | include: 40 | 41 | - Using welcoming and inclusive language 42 | - Being respectful of differing viewpoints and experiences 43 | - Gracefully accepting constructive criticism 44 | - Focusing on what is best for the community 45 | - Showing empathy towards other community members 46 | 47 | Examples of unacceptable behavior by participants include: 48 | 49 | - The use of sexualized language or imagery and unwelcome sexual attention or 50 | advances 51 | - Trolling, insulting/derogatory comments, and personal or political attacks 52 | - Public or private harassment 53 | - Publishing others' private information, such as a physical or electronic 54 | address, without explicit permission 55 | - Other conduct which could reasonably be considered inappropriate in a 56 | professional setting 57 | 58 | ### Our Responsibilities 59 | 60 | Project maintainers are responsible for clarifying the standards of acceptable 61 | behavior and are expected to take appropriate and fair corrective action in 62 | response to any instances of unacceptable behavior. 63 | 64 | Project maintainers have the right and responsibility to remove, edit, or reject 65 | comments, commits, code, wiki edits, issues, and other contributions that are 66 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 67 | contributor for other behaviors that they deem inappropriate, threatening, 68 | offensive, or harmful. 69 | 70 | ### Scope 71 | 72 | This Code of Conduct applies both within project spaces and in public spaces 73 | when an individual is representing the project or its community. Examples of 74 | representing a project or community include using an official project e-mail 75 | address, posting via an official social media account, or acting as an appointed 76 | representative at an online or offline event. Representation of a project may be 77 | further defined and clarified by project maintainers. 78 | 79 | ### Enforcement 80 | 81 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 82 | reported by contacting the project team npmjs@decsiv.com. All complaints will be 83 | reviewed and investigated and will result in a response that is deemed necessary 84 | and appropriate to the circumstances. The project team is obligated to maintain 85 | confidentiality with regard to the reporter of an incident. Further details of 86 | specific enforcement policies may be posted separately. 87 | 88 | Project maintainers who do not follow or enforce the Code of Conduct in good 89 | faith may face temporary or permanent repercussions as determined by other 90 | members of the project's leadership. 91 | 92 | ### Attribution 93 | 94 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 95 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 96 | 97 | [homepage]: http://contributor-covenant.org 98 | [version]: http://contributor-covenant.org/version/1/4/ 99 | -------------------------------------------------------------------------------- /src/styleModifierPropTypes.ts: -------------------------------------------------------------------------------- 1 | import difference from 'lodash/difference'; 2 | import flatten from 'lodash/flatten'; 3 | import forIn from 'lodash/forIn'; 4 | import keys from 'lodash/keys'; 5 | import uniq from 'lodash/uniq'; 6 | import { Validator } from 'prop-types'; 7 | 8 | import normalizeModifiers from './utils/normalizeModifiers'; 9 | import isResponsiveModifiersProp from './utils/isResponsiveModifiersProp'; 10 | 11 | import { 12 | ComponentProps, 13 | ModifierKeys, 14 | ModifiersConfig, 15 | ModifiersProp, 16 | ResponsiveModifiersProp, 17 | } from './types'; 18 | 19 | /** 20 | * Returns an error if we have invalid modifiers or sizes with errors 21 | * 22 | * @param {string} modifiersPropName 23 | * @param {string} componentName 24 | * @param {ModifierNames} invalidModifiers 25 | * @param {string[]} sizesWithErrors 26 | * @returns {Error} 27 | */ 28 | function generateError( 29 | modifiersPropName: string, 30 | componentName: string, 31 | invalidModifiers: string[], 32 | sizesWithErrors?: string[], 33 | ): Error { 34 | const m = invalidModifiers.length > 1 ? 'modifiers' : 'modifier'; 35 | const modifierList = invalidModifiers.join(', '); 36 | const k = sizesWithErrors && sizesWithErrors.length > 1 ? 'keys' : 'key'; 37 | 38 | return new Error( 39 | `Invalid ${m} ${modifierList} used in prop '${modifiersPropName}'${ 40 | sizesWithErrors ? ` (size ${k} ${sizesWithErrors.join(', ')})` : '' 41 | } and supplied to ${componentName}. Validation failed.`, 42 | ); 43 | } 44 | 45 | /** 46 | * Returns the invalid modifiers 47 | * 48 | * @param {ModifierKeys} modifierKeys 49 | * @param {ModifiersConfig} modifiersConfig 50 | * @returns {string[]} 51 | */ 52 | function getInvalidModifiers( 53 | modifierKeys: ModifierKeys, 54 | modifiersConfig: ModifiersConfig, 55 | ): string[] { 56 | return difference(normalizeModifiers(modifierKeys), keys(modifiersConfig)); 57 | } 58 | 59 | /** 60 | * Checks for invalid modifiers 61 | * 62 | * @param {string} modifiersPropName 63 | * @param {string} componentName 64 | * @param {ModifierKeys} modifierKeys 65 | * @param {ModifiersConfig} modifierConfig 66 | * @returns {Error|Null} 67 | */ 68 | function validateModifiers( 69 | modifiersPropName: string, 70 | componentName: string, 71 | modifierKeys: ModifierKeys, 72 | modifierConfig: ModifiersConfig, 73 | ): Error | null { 74 | const invalidModifiers = getInvalidModifiers(modifierKeys, modifierConfig); 75 | 76 | if (invalidModifiers.length > 0) { 77 | return generateError(modifiersPropName, componentName, invalidModifiers); 78 | } 79 | 80 | return null; 81 | } 82 | 83 | /** 84 | * Checks for invalid modfiers for responsive modifiers 85 | * 86 | * @export 87 | * @param {string} modifiersPropName 88 | * @param {string} componentName 89 | * @param {ResponsiveModifiersProp} responsiveModifiers 90 | * @param {ModifiersConfig} modifierConfig 91 | * @returns {Error|Null} 92 | */ 93 | export function validateResponsiveModifiers( 94 | modifiersPropName: string, 95 | componentName: string, 96 | responsiveModifiers: ResponsiveModifiersProp, 97 | modifierConfig: ModifiersConfig, 98 | ): Error | null { 99 | const rawInvalidModifiers: string[][] = []; 100 | const rawSizesWithErrors: string[] = []; 101 | 102 | forIn(responsiveModifiers, (modifiers, size): void => { 103 | const invalidModifiers = getInvalidModifiers( 104 | modifiers as ModifierKeys, 105 | modifierConfig, 106 | ); 107 | 108 | if (invalidModifiers.length > 0) { 109 | rawInvalidModifiers.push(invalidModifiers); 110 | rawSizesWithErrors.push(size); 111 | } 112 | }); 113 | 114 | const invalidModifiers = uniq(flatten(rawInvalidModifiers)); 115 | const sizesWithErrors = uniq(rawSizesWithErrors); 116 | 117 | if (invalidModifiers.length > 0) { 118 | return generateError( 119 | modifiersPropName, 120 | componentName, 121 | invalidModifiers, 122 | sizesWithErrors, 123 | ); 124 | } 125 | 126 | return null; 127 | } 128 | 129 | /** 130 | * Evaluates the modifiers prop against the modifier config. Throws invalid proptype error 131 | * if a modifier is supplied in prop and not found in modifier config. 132 | * @export 133 | * @param {ModifiersConfig} modifierConfig 134 | * @returns {Validator} 135 | */ 136 | export default function styleModifierPropTypes( 137 | modifierConfig: ModifiersConfig, 138 | ): Validator { 139 | const validator = ( 140 | props: ComponentProps & { 141 | [propName: string]: ModifiersProp; 142 | }, 143 | modifiersPropName: string, 144 | componentName: string, 145 | ): Error | null => { 146 | const modifiers = props[modifiersPropName]; 147 | 148 | if (isResponsiveModifiersProp(modifiers)) { 149 | return validateResponsiveModifiers( 150 | modifiersPropName, 151 | componentName, 152 | modifiers, 153 | modifierConfig, 154 | ); 155 | } 156 | 157 | return validateModifiers( 158 | modifiersPropName, 159 | componentName, 160 | modifiers as ModifierKeys, 161 | modifierConfig, 162 | ); 163 | }; 164 | 165 | return validator as Validator; 166 | } 167 | -------------------------------------------------------------------------------- /src/__tests__/styleModifierPropTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import styleModifierPropTypes from '../styleModifierPropTypes'; 4 | 5 | const defaultModifierConfig = { 6 | test: () => ({ styles: 'display: relative;' }), 7 | defaultTest: () => ({ styles: 'color: blue;', defaultStyles: 'color: red;' }), 8 | '1_per_row': () => ({ 9 | styles: ` 10 | width: 100%; 11 | `, 12 | }), 13 | '2_per_row': () => ({ 14 | styles: ` 15 | width: 50%; 16 | `, 17 | }), 18 | '3_per_row': () => ({ 19 | styles: ` 20 | width: 33.33%; 21 | `, 22 | }), 23 | }; 24 | 25 | const noop = () => {}; 26 | 27 | test('styleModifierPropTypes does not log error if modifier array is not present', () => { 28 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); 29 | 30 | const testPropTypes = { 31 | modifiers: styleModifierPropTypes(defaultModifierConfig), 32 | }; 33 | const goodProps = {}; 34 | PropTypes.checkPropTypes(testPropTypes, goodProps, 'prop', 'MyComponent'); 35 | 36 | expect(consoleSpy).not.toHaveBeenCalled(); 37 | 38 | consoleSpy.mockReset(); 39 | consoleSpy.mockRestore(); 40 | }); 41 | 42 | test('styleModifierPropTypes does not log error with only valid modifiers', () => { 43 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); 44 | 45 | const testPropTypes = { 46 | modifiers: styleModifierPropTypes(defaultModifierConfig), 47 | }; 48 | const goodProps = { modifiers: 'defaultTest' }; 49 | 50 | PropTypes.checkPropTypes(testPropTypes, goodProps, 'prop', 'MyComponent'); 51 | 52 | expect(consoleSpy).not.toHaveBeenCalled(); 53 | 54 | consoleSpy.mockReset(); 55 | consoleSpy.mockRestore(); 56 | }); 57 | 58 | test('styleModifierPropTypes logs error to console with invalid modifier', () => { 59 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); 60 | 61 | const testPropTypes = { 62 | modifiers: styleModifierPropTypes(defaultModifierConfig), 63 | }; 64 | const badProps = { modifiers: 'invalidModifier' }; 65 | 66 | PropTypes.checkPropTypes(testPropTypes, badProps, 'prop', 'MyComponent'); 67 | 68 | const expectedErrMsg = 69 | "Invalid modifier invalidModifier used in prop 'modifiers' and supplied to MyComponent. Validation failed."; 70 | expect(consoleSpy).toHaveBeenCalled(); 71 | const errorMsg = consoleSpy.mock.calls[0][0]; 72 | expect(errorMsg).toContain(expectedErrMsg); 73 | 74 | consoleSpy.mockReset(); 75 | consoleSpy.mockRestore(); 76 | }); 77 | 78 | test('styleModifierPropTypes logs error to console with invalid modifiers', () => { 79 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); 80 | 81 | const testPropTypes = { 82 | modifiers: styleModifierPropTypes(defaultModifierConfig), 83 | }; 84 | const badProps = { modifiers: ['invalidModifier', 'secondInvalidModifier'] }; 85 | 86 | PropTypes.checkPropTypes(testPropTypes, badProps, 'prop', 'MyComponent'); 87 | 88 | const expectedErrMsg = 89 | "Invalid modifiers invalidModifier, secondInvalidModifier used in prop 'modifiers' and supplied to MyComponent. Validation failed."; 90 | expect(consoleSpy).toHaveBeenCalled(); 91 | const errorMsg = consoleSpy.mock.calls[0][0]; 92 | expect(errorMsg).toContain(expectedErrMsg); 93 | 94 | consoleSpy.mockReset(); 95 | consoleSpy.mockRestore(); 96 | }); 97 | 98 | test('styleModifierPropTypes does not log error with valid modifiers when responsive', () => { 99 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); 100 | 101 | const testPropTypes = { 102 | modifiers: styleModifierPropTypes(defaultModifierConfig), 103 | }; 104 | const goodProps = { 105 | modifiers: { 106 | _: ['1_per_row'], 107 | SM: ['2_per_row', '3_per_row'], 108 | }, 109 | }; 110 | PropTypes.checkPropTypes(testPropTypes, goodProps, 'prop', 'MyComponent'); 111 | 112 | expect(consoleSpy).not.toHaveBeenCalled(); 113 | 114 | consoleSpy.mockReset(); 115 | consoleSpy.mockRestore(); 116 | }); 117 | 118 | test('styleModifierPropTypes logs error with invalid modifier key when responsive', () => { 119 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); 120 | 121 | const testPropTypes = { 122 | modifiers: styleModifierPropTypes(defaultModifierConfig), 123 | }; 124 | const badProps = { 125 | modifiers: { 126 | XS: ['1_per_row', 'test'], 127 | SM: 'wrongModifier', 128 | }, 129 | }; 130 | PropTypes.checkPropTypes(testPropTypes, badProps, 'prop', 'MyComponent'); 131 | 132 | const expectedErrMsg = 133 | "Invalid modifier wrongModifier used in prop 'modifiers' (size key SM) and supplied to MyComponent. Validation failed."; 134 | expect(consoleSpy).toHaveBeenCalled(); 135 | const errorMsg = consoleSpy.mock.calls[0][0]; 136 | expect(errorMsg).toContain(expectedErrMsg); 137 | 138 | consoleSpy.mockReset(); 139 | consoleSpy.mockRestore(); 140 | }); 141 | 142 | test('styleModifierPropTypes logs error with invalid modifier keys when responsive', () => { 143 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); 144 | 145 | const testPropTypes = { 146 | modifiers: styleModifierPropTypes(defaultModifierConfig), 147 | }; 148 | const badProps = { 149 | modifiers: { 150 | XS: ['1_per_row', 'firstWrongModifier'], 151 | SM: ['2_per_row', 'secondWrongModifier'], 152 | }, 153 | }; 154 | PropTypes.checkPropTypes(testPropTypes, badProps, 'prop', 'MyComponent'); 155 | 156 | const expectedErrMsg = 157 | "Invalid modifiers firstWrongModifier, secondWrongModifier used in prop 'modifiers' (size keys XS, SM) and supplied to MyComponent. Validation failed."; 158 | expect(consoleSpy).toHaveBeenCalled(); 159 | const errorMsg = consoleSpy.mock.calls[0][0]; 160 | expect(errorMsg).toContain(expectedErrMsg); 161 | 162 | consoleSpy.mockReset(); 163 | consoleSpy.mockRestore(); 164 | }); 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Styled Components Modifiers 2 | 3 | [![CircleCI master build](https://img.shields.io/circleci/project/github/Decisiv/styled-components-modifiers/master.svg)](https://circleci.com/gh/Decisiv/styled-components-modifiers) 4 | [![npm version](https://img.shields.io/npm/v/styled-components-modifiers.svg)](https://www.npmjs.com/package/styled-components-modifiers) 5 | [![npm downloads](https://img.shields.io/npm/dt/styled-components-modifiers.svg)](https://www.npmjs.com/package/styled-components-modifiers) 6 | 7 | Styled Components are incredibly useful when building an application, but the 8 | community lacks guidelines and best practices for how to structure, organize, 9 | and modify a component library. Fortunately, the CSS ecosystem has several 10 | solutions for this, including the very well-thought-out 11 | [Block, Element, Modifier (BEM) conventions](http://getbem.com). 12 | 13 | This library enhances [`styled-components`](https://www.styled-components.com/) 14 | by allowing you to use BEM-flavored conventions when building your components. 15 | 16 | ## Contents 17 | 18 | - [Styled Components Modifiers](#styled-components-modifiers) 19 | - [Contents](#contents) 20 | - [Overview](#overview) 21 | - [Blocks and Elements](#blocks-and-elements) 22 | - [Modifiers](#modifiers) 23 | - [Installation](#installation) 24 | - [Using Styled Components Modifiers](#using-styled-components-modifiers) 25 | - [Defining Modifiers](#defining-modifiers) 26 | - [Validating Modifiers](#validating-modifiers) 27 | - [Applying Modifiers](#applying-modifiers) 28 | - [Responsive Modifiers _(deprecated)_](#responsive-modifiers-deprecated) 29 | - [Alternative Prop Names](#alternative-prop-names) 30 | - [Built with Styled Components Modifiers](#built-with-styled-components-modifiers) 31 | - [Contributing](#contributing) 32 | - [License](#license) 33 | 34 | ## Overview 35 | 36 | ### Blocks and Elements 37 | 38 | Our method for structuring Blocks and Elements doesn’t actually require any 39 | special tooling. It’s just a simple convention we use for namespacing the 40 | components, add the Elements as properties of a Block component: 41 | 42 | ```jsx 43 | // Define your Button styled component (the Block) 44 | const Button = styled.button``; 45 | 46 | // Define your Icon styled component (the Element) 47 | const Icon = styled(IconComponent)``; 48 | 49 | // Add the Icon as a property of the Button 50 | Button.Icon = Icon; 51 | 52 | // To render these components... 53 | render() { 54 | return ( 55 | 58 | ) 59 | } 60 | ``` 61 | 62 | This gives us a nice namespacing that's easy to visualize in a Blocks and 63 | Elements structure. 64 | 65 | But what about _modifiers_? 66 | 67 | ### Modifiers 68 | 69 | This tool allows you to implement modifiers and apply them to 70 | `styled-components` like this: 71 | 72 | ```jsx 73 | 74 | ``` 75 | 76 | or as a single string like this: 77 | 78 | ```jsx 79 | 80 | ``` 81 | 82 | The modifiers are passed in as an array of flags or a single flag. Each flag 83 | changes the appearance of the Block or Element component. When passing in an 84 | array, the values are filtered and only strings are used, which means that it is 85 | safe to do the following: 86 | 87 | ```jsx 88 | 89 | ``` 90 | 91 | which, if `isLoading` is `false`, resolves to: 92 | 93 | ```jsx 94 | 95 | ``` 96 | 97 | In this case only `large` will be used. 98 | 99 | ## Installation 100 | 101 | This package is 102 | [available on npm as `styled-components-modifiers`](https://www.npmjs.com/package/styled-components-modifiers). 103 | 104 | To install the latest stable version with `npm`: 105 | 106 | ```sh 107 | $ npm install styled-components-modifiers --save 108 | ``` 109 | 110 | ...or with `yarn`: 111 | 112 | ```sh 113 | $ yarn add styled-components-modifiers 114 | ``` 115 | 116 | ## Using Styled Components Modifiers 117 | 118 | ### Defining Modifiers 119 | 120 | The core of `styled-components-modifiers` is a modifier configuration object. 121 | The _keys_ in this object become the available flags that can be passed to the 122 | component's `modifiers` prop. Each _value_ defines a function that returns a CSS 123 | style string. 124 | 125 | For our demo, let's first set up a modifier configuration object: 126 | 127 | ```jsx 128 | const MODIFIER_CONFIG = { 129 | // The functions receive the props of the component as the only argument. 130 | // Here, we destructure the theme from the argument for use within the modifier styling. 131 | disabled: ({ theme }) => ` 132 | // These styles are applied any time this modifier is used. 133 | background-color: ${theme.colors.chrome_400}; 134 | color: ${theme.colors.chrome_100}; 135 | `, 136 | 137 | // Alternatively, you can return an object with your styles under the key `styles`. 138 | success: ({ theme }) => ({ 139 | styles: ` 140 | background-color: ${theme.colors.success}; 141 | `, 142 | }), 143 | 144 | // Styled Components exports a `css` util that enables some nice linting and interpolation 145 | // features. You can use it directly or with the `styles` object pattern. 146 | warning: ({ theme }) => css` 147 | background-color: ${theme.colors.warning}; 148 | `, 149 | 150 | large: () => ` 151 | height: 3em; 152 | width: 6em; 153 | `, 154 | }; 155 | ``` 156 | 157 | Then, we need to apply the modifier configuration object (`MODIFIER_CONFIG`) to 158 | the styled component we want to modify: 159 | 160 | ```jsx 161 | import styled from 'styled-components'; 162 | import { applyStyleModifiers } from 'styled-components-modifiers'; 163 | 164 | const Button = styled.button` 165 | // Any styles that won't change or may be overruled can go above where you 166 | // apply the style modifiers. In BEM, these would be the styles you apply in 167 | // either the Block or Element class's primary definition. 168 | font-size: 24px; 169 | padding: 16px; 170 | 171 | // Then apply the modifier configuration. 172 | ${applyStyleModifiers(MODIFIER_CONFIG)}; 173 | 174 | // You can apply as many modifier configurations as you like, but remember that 175 | // the last modifiers applied take priority in the event of colliding styles. 176 | `; 177 | 178 | export default Button; 179 | ``` 180 | 181 | The end result is a block (`Button`) with four available modifiers (`disabled`, 182 | `success`, `warning`, and `large`). 183 | 184 | ### Validating Modifiers 185 | 186 | Because the modifiers are an arbitrary array of flags, it is very easy to pass a 187 | value as a modifier that is not found in the component's modifier configuration 188 | object. Fortunately, we have a tool to help with that: you can validate the 189 | `modifiers` prop with `styleModifierPropTypes`: 190 | 191 | ```jsx 192 | // In the Button component's file 193 | import { styleModifierPropTypes } from 'styled-components-modifiers'; 194 | 195 | // ...the Button definition, as seen above, goes here... 196 | 197 | Button.propTypes = { 198 | modifiers: styleModifierPropTypes(MODIFIER_CONFIG), 199 | }; 200 | ``` 201 | 202 | This will validate that only keys found within our `MODIFIER_CONFIG` are 203 | supplied to the styled component. It will also throw a `PropTypes` error if an 204 | invalid modifier is used. 205 | 206 | ### Applying Modifiers 207 | 208 | Applying modifiers when rendering the component is as simple as providing a 209 | `modifiers` prop. The value of this prop can be either a string or an array of 210 | strings. Each string value should correspond to keys in the modifier 211 | configuration object applied to the component. 212 | 213 | ```jsx 214 | function Form() { 215 | return ( 216 |
217 | {/* ...the rest of form goes here... */} 218 | {/* Render a button, and give it a `modifiers` prop with the desired modifier. */} 219 |
224 | ); 225 | } 226 | ``` 227 | 228 | You can also apply the modifiers to be responsive. For this, the value of the 229 | `modifiers` prop should be an object where each value is either a string or 230 | array of strings that match a modifier name. Which set of modifiers is chosen 231 | will be based on the value of a `size` prop that you must also provide to the 232 | component. 233 | 234 | ```jsx 235 |