diff --git a/.README/react-css-modules.png b/.README/react-css-modules.png new file mode 100644 index 0000000..2614951 Binary files /dev/null and b/.README/react-css-modules.png differ diff --git a/.README/react-css-modules.sketch b/.README/react-css-modules.sketch new file mode 100644 index 0000000..a014d75 Binary files /dev/null and b/.README/react-css-modules.sketch differ diff --git a/.babelrc b/.babelrc index 12606a3..3751ba8 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,19 @@ { - "stage": 0 + "plugins": [ + "add-module-exports", + "lodash", + "transform-class-properties", + [ + "transform-es2015-classes", + { + "loose": true + } + ], + "transform-proto-to-assign" + ], + "presets": [ + "es2015", + "stage-0", + "react" + ] } diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0f17867 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.eslintrc b/.eslintrc index a2a446a..bcaad1b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,393 +1,6 @@ { - "rules": { - "comma-dangle": [ - 2, - "never" - ], - "no-cond-assign": 2, - "no-console": 1, - "no-constant-condition": 1, - "no-control-regex": 2, - "no-debugger": 1, - "no-dupe-args": 2, - "no-dupe-keys": 2, - "no-duplicate-case": 2, - "no-empty-character-class": 2, - "no-empty": 1, - "no-ex-assign": 2, - "no-extra-boolean-cast": 0, - "no-extra-parens": 2, - "no-extra-semi": 2, - "no-func-assign": 2, - "no-inner-declarations": 2, - "no-invalid-regexp": 2, - "no-irregular-whitespace": 2, - "no-negated-in-lhs": 2, - "no-obj-calls": 2, - "no-regex-spaces": 2, - "no-sparse-arrays": 2, - "no-unreachable": 1, - "use-isnan": 2, - "valid-jsdoc": [ - 2, - { - "requireParamDescription": false, - "requireReturnDescription": false - } - ], - "valid-typeof": 2, - "no-unexpected-multiline": 2, - "accessor-pairs": 2, - "block-scoped-var": 2, - "complexity": [ - 1, - 10 - ], - "consistent-return": 2, - "curly": 2, - "default-case": 0, - "dot-notation": 2, - "dot-location": [ - 2, - "property" - ], - "eqeqeq": 2, - "guard-for-in": 2, - "no-alert": 2, - "no-caller": 2, - "no-div-regex": 2, - "no-else-return": 0, - "no-empty-label": 2, - "no-eq-null": 2, - "no-eval": 2, - "no-extend-native": 2, - "no-extra-bind": 2, - "no-fallthrough": 2, - "no-floating-decimal": 2, - "no-implicit-coercion": 2, - "no-implied-eval": 2, - "no-invalid-this": 0, - "no-iterator": 2, - "no-labels": 2, - "no-lone-blocks": 2, - "no-loop-func": 2, - "no-multi-spaces": 2, - "no-multi-str": 2, - "no-native-reassign": 2, - "no-new-func": 2, - "no-new-wrappers": 2, - "no-new": 2, - "no-octal-escape": 2, - "no-octal": 2, - "no-param-reassign": [ - 2, - { - "props": false - } - ], - "no-process-env": 2, - "no-proto": 2, - "no-redeclare": [ - 2, - { - "builtinGlobals": true - } - ], - "no-return-assign": 2, - "no-script-url": 2, - "no-self-compare": 2, - "no-sequences": 2, - "no-throw-literal": 2, - "no-unused-expressions": 2, - "no-useless-call": 2, - "no-void": 2, - "no-warning-comments": [ - 1, - { - "terms": [ - "todo", - "@toto" - ], - "location": "start" - } - ], - "no-with": 2, - "radix": 2, - "vars-on-top": 2, - "wrap-iife": [ - 2, - "inside" - ], - "yoda": 0, - "strict": [ - 2, - "never" - ], - "init-declarations": [ - 2, - "never" - ], - "no-catch-shadow": 2, - "no-delete-var": 2, - "no-label-var": 2, - "no-shadow-restricted-names": 2, - "no-shadow": [ - 2, - { - "builtinGlobals": false, - "hoist": "functions" - } - ], - "no-undef-init": 2, - "no-undef": 2, - "no-undefined": 2, - "no-unused-vars": 2, - "no-use-before-define": 2, - "callback-return": 2, - "handle-callback-err": 2, - "no-mixed-requires": 0, - "no-new-require": 2, - "no-path-concat": 2, - "no-process-exit": 2, - "no-sync": 0, - "array-bracket-spacing": [ - 2, - "never" - ], - "block-spacing": [ - 2, - "always" - ], - "brace-style": [ - 2, - "1tbs", - { - "allowSingleLine": true - } - ], - "camelcase": [ - 2, - { - "properties": "always" - } - ], - "comma-spacing": [ - 2, - { - "before": false, - "after": true - } - ], - "comma-style": [ - 2, - "last" - ], - "computed-property-spacing": [ - 2, - "never" - ], - "consistent-this": [ - 2, - "self" - ], - "eol-last": 2, - "func-names": 0, - "func-style": [ - 2, - "expression" - ], - "id-length": [ - 2, - { - "min": 2, - "max": 30, - "exceptions": [ - "_" - ] - } - ], - "id-match": [ - 2, - "(^[A-Za-z]+(?:[A-Z][a-z]*)*\\d*$)|(^[A-Z]+(_[A-Z]+)*(_\\d$)*$)|(^(_|\\$)$)", - { - "properties": true - } - ], - "indent": [ - 2, - 4 - ], - "key-spacing": [ - 2, - { - "beforeColon": false, - "afterColon": true - } - ], - "lines-around-comment": [ - 2, - { - "beforeBlockComment": true, - "beforeLineComment": false - } - ], - "linebreak-style": [ - 2, - "unix" - ], - "max-nested-callbacks": [ - 1, - 3 - ], - "new-cap": [ - 2, - { - "newIsCap": true, - "capIsNew": false - } - ], - "new-parens": 2, - "newline-after-var": [ - 2, - "always" - ], - "no-array-constructor": 2, - "no-continue": 2, - "no-inline-comments": 2, - "no-lonely-if": 0, - "no-mixed-spaces-and-tabs": 2, - "no-multiple-empty-lines": [ - 2, - { - "max": 2 - } - ], - "no-nested-ternary": 2, - "no-new-object": 2, - "no-spaced-func": 2, - "no-ternary": 0, - "no-trailing-spaces": 2, - "no-underscore-dangle": 2, - "no-unneeded-ternary": 2, - "object-curly-spacing": [ - 2, - "never" - ], - "one-var": [ - 2, - "always" - ], - "operator-assignment": [ - 2, - "always" - ], - "operator-linebreak": [ - 2, - "after" - ], - "padded-blocks": [ - 2, - "never" - ], - "quote-props": [ - 2, - "as-needed" - ], - "quotes": [ - 2, - "single" - ], - "semi-spacing": [ - 2, - { - "before": false, - "after": true - } - ], - "semi": [ - 2, - "always" - ], - "sort-vars": 2, - "space-after-keywords": [ - 2, - "always" - ], - "space-before-blocks": [ - 2, - "always" - ], - "space-before-function-paren": [ - 2, - "always" - ], - "space-in-parens": [ - 2, - "never" - ], - "space-infix-ops": 2, - "space-return-throw-case": 2, - "space-unary-ops": [ - 2, - { - "words": true, - "nonwords": false - } - ], - "spaced-comment": [ - 2, - "always" - ], - "wrap-regex": 0, - "arrow-parens": [ - 2, - "always" - ], - "arrow-spacing": [ - 2, - { - "before": true, - "after": true - } - ], - "constructor-super": 2, - "generator-star-spacing": [ - 2, - { - "before": true, - "after": false - } - ], - "no-class-assign": 2, - "no-const-assign": 2, - "no-dupe-class-members": 2, - "no-this-before-super": 2, - "no-var": 2, - "object-shorthand": [ - 2, - "always" - ], - "prefer-arrow-callback": 2, - "prefer-const": 0, - "prefer-spread": 2, - "prefer-reflect": 2, - "prefer-template": 2, - "require-yield": 2 - }, - "ecmaFeatures": { - "jsx": true, - "modules": true - }, - "plugins": [ - "react" - ], - "parser": "babel-eslint", - "globals": { - "global": true - }, - "env": { - "browser": true, - "mocha": true - // "node": true - } + "extends": [ + "canonical", + "canonical/mocha" + ] } diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2f093a7 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: gajus +patreon: gajus diff --git a/.gitignore b/.gitignore index 6edd850..df978de 100755 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ -/node_modules -npm-debug.log +node_modules +coverage +dist +*.log +.* +!.babelrc +!.editorconfig +!.eslintrc +!.gitignore +!.npmignore +!.README +!.travis.yml diff --git a/.npmignore b/.npmignore new file mode 100755 index 0000000..e8add85 --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +src +tests +coverage +.* +*.log diff --git a/.travis.yml b/.travis.yml index 3ed46a2..a786167 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,14 @@ language: node_js node_js: - - 'iojs-v3.0.0' - - 'iojs-v2.5.0' -install: - - npm install + - node + - 8 +before_install: + - npm config set depth 0 notifications: - email: false + email: false +sudo: false +script: + - npm run test + - npm run lint +after_success: + - semantic-release pre && npm publish && semantic-release post diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..109eb2e --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,5 @@ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..183e8d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2015, Gajus Kuizinas (http://gajus.com/) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Gajus Kuizinas (http://gajus.com/) nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL ANUARY BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 62b0c20..a6b7399 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,66 @@ # React CSS Modules -[![Travis build status](http://img.shields.io/travis/gajus/react-css-modules/master.svg?style=flat)](https://travis-ci.org/gajus/react-css-modules) -[![NPM version](http://img.shields.io/npm/v/react-css-modules.svg?style=flat)](https://www.npmjs.org/package/react-css-modules) +[![GitSpo Mentions](https://gitspo.com/badges/mentions/gajus/react-css-modules?style=flat-square)](https://gitspo.com/mentions/gajus/react-css-modules) +[![Travis build status](http://img.shields.io/travis/gajus/react-css-modules/master.svg?style=flat-square)](https://travis-ci.org/gajus/react-css-modules) +[![NPM version](http://img.shields.io/npm/v/react-css-modules.svg?style=flat-square)](https://www.npmjs.org/package/react-css-modules) +[![js-canonical-style](https://img.shields.io/badge/code%20style-canonical-blue.svg?style=flat-square)](https://github.com/gajus/canonical) + + React CSS Modules implement automatic mapping of CSS modules. Every CSS class is assigned a local-scoped identifier with a global unique name. CSS Modules enable a modular and reusable CSS! +> ## ⚠️⚠️⚠️ DEPRECATION NOTICE ⚠️⚠️⚠️ +> +> If you are considering to use `react-css-modules`, evaluate if [`babel-plugin-react-css-modules`](https://github.com/gajus/babel-plugin-react-css-modules) covers your use case. +> `babel-plugin-react-css-modules` is a lightweight alternative of `react-css-modules`. +> +> `babel-plugin-react-css-modules` is not a drop-in replacement and does not cover all the use cases of `react-css-modules`. +> However, it has a lot smaller performance overhead (0-10% vs +50%; see [Performance](https://github.com/gajus/babel-plugin-react-css-modules#performance)) and a lot smaller size footprint (less than 2kb vs +17kb). +> +> It is easy to get started! See the demo https://github.com/gajus/babel-plugin-react-css-modules/tree/master/demo + +- [CSS Modules](#css-modules) + - [webpack `css-loader`](#webpack-css-loader) - [What's the Problem?](#whats-the-problem) - [The Implementation](#the-implementation) - [Usage](#usage) - [Module Bundler](#module-bundler) - [webpack](#webpack) + - [Development](#development) + - [Production](#production) - [Browserify](#browserify) + - [Extending Component Styles](#extending-component-styles) + - [`styles` Property](#styles-property) + - [Loops and Child Components](#loops-and-child-components) - [Decorator](#decorator) - [Options](#options) - [`allowMultiple`](#allowmultiple) - - [`errorWhenNotFound`](#errorwhennotfound) + - [`handleNotFoundStyleName`](#handlenotfoundstylename) +- [SASS, SCSS, LESS and other CSS Preprocessors](#sass-scss-less-and-other-css-preprocessors) + - [Enable Sourcemaps](#enable-sourcemaps) - [Class Composition](#class-composition) - [What Problems does Class Composition Solve?](#what-problems-does-class-composition-solve) - [Class Composition Using CSS Preprocessors](#class-composition-using-css-preprocessors) -- [SASS, SCSS, LESS and other CSS Preprocessors](#sass-scss-less-and-other-css-preprocessors) - [Global CSS](#global-css) -- [Multiple CSS Classes](#multiple-css-classes) +- [Multiple CSS Modules](#multiple-css-modules) -## What's the Problem? +## CSS Modules -[CSS Modules](https://github.com/css-modules/css-modules) are awesome. If you are not familiar with CSS Modules, it is a concept of using a module bundler such as [webpack](http://webpack.github.io/docs/) to load CSS scoped to a particular document. CSS module loader will generate a unique name for a each CSS class at the time of loading the CSS document ([Interoperable CSS](https://github.com/css-modules/icss) to be precise). To see CSS Modules in practice, [webpack-demo](https://css-modules.github.io/webpack-demo/). +[CSS Modules](https://github.com/css-modules/css-modules) are awesome. If you are not familiar with CSS Modules, it is a concept of using a module bundler such as [webpack](http://webpack.github.io/docs/) to load CSS scoped to a particular document. CSS module loader will generate a unique name for each CSS class at the time of loading the CSS document ([Interoperable CSS](https://github.com/css-modules/icss) to be precise). To see CSS Modules in practice, [webpack-demo](https://css-modules.github.io/webpack-demo/). In the context of React, CSS Modules look like this: ```js import React from 'react'; -import styles from './car.css'; +import styles from './table.css'; -export default class Car extends React.Component { +export default class Table extends React.Component { render () { - return
-
-
+ return
+
+
A0
+
B0
+
; } } @@ -45,9 +69,11 @@ export default class Car extends React.Component { Rendering the component will produce a markup similar to: ```js -
-
front-door
-
back-door
+
+
+
A0
+
B0
+
``` @@ -55,7 +81,13 @@ and a corresponding CSS file that matches those CSS classes. Awesome! -However, this approach has several disadvantages: +### webpack `css-loader` + +[CSS Modules](https://github.com/css-modules/css-modules) is a specification that can be implemented in multiple ways. `react-css-modules` leverages the existing CSS Modules implementation webpack [css-loader](https://github.com/webpack/css-loader#css-modules). + +## What's the Problem? + +webpack [css-loader](https://github.com/webpack/css-loader#css-modules) itself has several disadvantages: * You have to use `camelCase` CSS class names. * You have to use `styles` object whenever constructing a `className`. @@ -66,19 +98,21 @@ React CSS Modules component automates loading of CSS Modules using `styleName` p ```js import React from 'react'; -import styles from './car.css'; import CSSModules from 'react-css-modules'; +import styles from './table.css'; -class Car extends React.Component { +class Table extends React.Component { render () { - return
-
-
+ return
+
+
A0
+
B0
+
; } } -export default CSSModules(Car, styles); +export default CSSModules(Table, styles); ``` Using `react-css-modules`: @@ -91,7 +125,7 @@ Using `react-css-modules`:
``` -* You are warned when `styleName` refers to an undefined CSS Module ([`errorWhenNotFound`](#errorwhennotfound) option). +* You are warned when `styleName` refers to an undefined CSS Module ([`handleNotFoundStyleName`](#handlenotfoundstylename) option). * You can enforce use of a single CSS module per `ReactElement` ([`allowMultiple`](#allowmultiple) option). ## The Implementation @@ -104,38 +138,267 @@ Using `react-css-modules`: Setup consists of: -* Setting up a [module bundler](#modulebundler) to load the [Interoperable CSS](https://github.com/css-modules/icss). +* Setting up a [module bundler](#module-bundler) to load the [Interoperable CSS](https://github.com/css-modules/icss). * [Decorating](#decorator) your component using `react-css-modules`. ### Module Bundler #### webpack -* Install [`style-loader`](https://www.npmjs.com/package/style-loader) and [`css-loader`](https://www.npmjs.com/package/css-loader). -* You need to use [`extract-text-webpack-plugin`](https://www.npmjs.com/package/extract-text-webpack-plugin) to aggregate the CSS into a single file. +##### Development + +In development environment, you want to [Enable Sourcemaps](#enable-sourcemaps) and webpack [Hot Module Replacement](https://webpack.github.io/docs/hot-module-replacement.html) (HMR). [`style-loader`](https://github.com/webpack/style-loader) already supports HMR. Therefore, Hot Module Replacement will work out of the box. + +Setup: + +* Install [`style-loader`](https://www.npmjs.com/package/style-loader). +* Install [`css-loader`](https://www.npmjs.com/package/css-loader). * Setup `/\.css$/` loader: ```js { test: /\.css$/, - loader: ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]') + loaders: [ + 'style-loader?sourceMap', + 'css-loader?modules&importLoaders=1&localIdentName=[path]___[name]__[local]___[hash:base64:5]' + ] } ``` +##### Production + +In production environment, you want to extract chunks of CSS into a single stylesheet file. + +> Advantages: +> +> * Fewer style tags (older IE has a limit) +> * CSS SourceMap (with `devtool: "source-map"` and `css-loader?sourceMap`) +> * CSS requested in parallel +> * CSS cached separate +> * Faster runtime (less code and DOM operations) +> +> Caveats: +> +> * Additional HTTP request +> * Longer compilation time +> * More complex configuration +> * No runtime public path modification +> * No Hot Module Replacement + +– [extract-text-webpack-plugin](https://github.com/webpack/extract-text-webpack-plugin) + +Setup: + +* Install [`style-loader`](https://www.npmjs.com/package/style-loader). +* Install [`css-loader`](https://www.npmjs.com/package/css-loader). +* Use [`extract-text-webpack-plugin`](https://www.npmjs.com/package/extract-text-webpack-plugin) to extract chunks of CSS into a single stylesheet. + +* Setup `/\.css$/` loader: + + * ExtractTextPlugin v1x: + + ```js + { + test: /\.css$/, + loader: ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]') + } + ``` + + * ExtractTextPlugin v2x: + + ```js + { + test: /\.css$/, + use: ExtractTextPlugin.extract({ + fallback: 'style-loader', + use: 'css-loader?modules,localIdentName="[name]-[local]-[hash:base64:6]"' + }), + } + ``` + * Setup `extract-text-webpack-plugin` plugin: -```js -new ExtractTextPlugin('app.css', { - allChunks: true -}) -``` + * ExtractTextPlugin v1x: + + ```js + new ExtractTextPlugin('app.css', { + allChunks: true + }) + ``` + + * ExtractTextPlugin v2x: + + ```js + new ExtractTextPlugin({ + filename: 'app.css', + allChunks: true + }) + ``` Refer to [webpack-demo](https://github.com/css-modules/webpack-demo) or [react-css-modules-examples](https://github.com/gajus/react-css-modules-examples) for an example of a complete setup. -#### Browserify +##### Browserify Refer to [`css-modulesify`](https://github.com/css-modules/css-modulesify). +### Extending Component Styles + +Use `styles` property to overwrite the default component styles. + +Explanation using `Table` component: + +```js +import React from 'react'; +import CSSModules from 'react-css-modules'; +import styles from './table.css'; + +class Table extends React.Component { + render () { + return
+
+
A0
+
B0
+
+
; + } +} + +export default CSSModules(Table, styles); +``` + +In this example, `CSSModules` is used to decorate `Table` component using `./table.css` CSS Modules. When `Table` component is rendered, it will use the properties of the `styles` object to construct `className` values. + +Using `styles` property you can overwrite the default component `styles` object, e.g. + +```js +import customStyles from './table-custom-styles.css'; + +; +``` + +[Interoperable CSS](https://github.com/css-modules/icss) can [extend other ICSS](https://github.com/css-modules/css-modules#dependencies). Use this feature to extend default styles, e.g. + +```css +/* table-custom-styles.css */ +.table { + composes: table from './table.css'; +} + +.row { + composes: row from './table.css'; +} + +/* .cell { + composes: cell from './table.css'; +} */ + +.table { + width: 400px; +} + +.cell { + float: left; width: 154px; background: #eee; padding: 10px; margin: 10px 0 10px 10px; +} +``` + +In this example, `table-custom-styles.css` selectively extends `table.css` (the default styles of `Table` component). + +Refer to the [`UsingStylesProperty` example](https://github.com/gajus/react-css-modules-examples/tree/master/src/UsingStylesProperty) for an example of a working implementation. + +### `styles` Property + +Decorated components inherit `styles` property that describes the mapping between CSS modules and CSS classes. + +```js +class extends React.Component { + render () { +
+

+

+
; + } +} +``` + +In the above example, `styleName='foo'` and `className={this.props.styles.foo}` are equivalent. + +`styles` property is designed to enable component decoration of [Loops and Child Components](#loops-and-child-components). + +### Loops and Child Components + +`styleName` cannot be used to define styles of a `ReactElement` that will be generated by another component, e.g. + +```js +import React from 'react'; +import CSSModules from 'react-css-modules'; +import List from './List'; +import styles from './table.css'; + +class CustomList extends React.Component { + render () { + let itemTemplate; + + itemTemplate = (name) => { + return
  • {name}
  • ; + }; + + return ; + } +} + +export default CSSModules(CustomList, styles); +``` + +The above example will not work. `CSSModules` is used to decorate `CustomList` component. However, it is the `List` component that will render `itemTemplate`. + +For that purpose, the decorated component inherits [`styles` property](#styles-property) that you can use just as a regular CSS Modules object. The earlier example can be therefore rewritten to: + +```js +import React from 'react'; +import CSSModules from 'react-css-modules'; +import List from './List'; +import styles from './table.css'; + +class CustomList extends React.Component { + render () { + let itemTemplate; + + itemTemplate = (name) => { + return
  • {name}
  • ; + }; + + return ; + } +} + +export default CSSModules(CustomList, styles); +``` + +You can use `styleName` property within the child component if you decorate the child component using `CSSModules` before passing it to the rendering component, e.g. + +```js +import React from 'react'; +import CSSModules from 'react-css-modules'; +import List from './List'; +import styles from './table.css'; + +class CustomList extends React.Component { + render () { + let itemTemplate; + + itemTemplate = (name) => { + return
  • {name}
  • ; + }; + + itemTemplate = CSSModules(itemTemplate, this.props.styles); + + return ; + } +} + +export default CSSModules(CustomList, styles); +``` + ### Decorator ```js @@ -143,12 +406,12 @@ Refer to [`css-modulesify`](https://github.com/css-modules/css-modulesify). * @typedef CSSModules~Options * @see {@link https://github.com/gajus/react-css-modules#options} * @property {Boolean} allowMultiple - * @property {Boolean} errorWhenNotFound + * @property {String} handleNotFoundStyleName */ /** * @param {Function} Component - * @param {Object} styles CSS Modules class map. + * @param {Object} defaultStyles CSS Modules class map. * @param {CSSModules~Options} options * @return {Function} */ @@ -158,19 +421,21 @@ You need to decorate your component using `react-css-modules`, e.g. ```js import React from 'react'; -import styles from './car.css'; import CSSModules from 'react-css-modules'; +import styles from './table.css'; -class Car extends React.Component { +class Table extends React.Component { render () { - return
    -
    -
    + return
    +
    +
    A0
    +
    B0
    +
    ; } } -export default CSSModules(Car, styles); +export default CSSModules(Table, styles); ``` Thats it! @@ -179,15 +444,17 @@ As the name implies, `react-css-modules` is compatible with the [ES7 decorators] ```js import React from 'react'; -import styles from './car.css'; import CSSModules from 'react-css-modules'; +import styles from './table.css'; @CSSModules(styles) export default class extends React.Component { render () { - return
    -
    front-door
    -
    back-door
    + return
    +
    +
    A0
    +
    B0
    +
    ; } } @@ -223,11 +490,17 @@ When `false`, the following will cause an error:
    ``` -#### `errorWhenNotFound` +#### `handleNotFoundStyleName` + +Default: `throw`. -Default: `true`. +Defines the desired action when `styleName` cannot be mapped to an existing CSS Module. -Throws an error when `styleName` cannot be mapped to an existing CSS Module. +Available options: + +* `throw` throws an error +* `log` logs a warning using `console.warn` +* `ignore` silently ignores the missing style name ## SASS, SCSS, LESS and other CSS Preprocessors @@ -236,7 +509,28 @@ Throws an error when `styleName` cannot be mapped to an existing CSS Module. ```js { test: /\.scss$/, - loader: ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!sass') + loaders: [ + 'style', + 'css?modules&importLoaders=1&localIdentName=[path]___[name]__[local]___[hash:base64:5]', + 'resolve-url', + 'sass' + ] +} +``` + +### Enable Sourcemaps + +To enable CSS Source maps, add `sourceMap` parameter to the css-loader and to the `sass-loader`: + +```js +{ + test: /\.scss$/, + loaders: [ + 'style?sourceMap', + 'css?modules&importLoaders=1&localIdentName=[path]___[name]__[local]___[hash:base64:5]', + 'resolve-url', + 'sass?sourceMap' + ] } ``` @@ -296,7 +590,7 @@ This pattern emerged with the advent of OOCSS. The biggest disadvantage of this ### Class Composition Using CSS Preprocessors -This section of the document is included as a learning exercise to broaden the understanding about the origin of Class Composition. CSS Modules support a native method of composting CSS Modules using [`composes`](https://github.com/css-modules/css-modules#composition) keyword. CSS Preprocessor is not required. +This section of the document is included as a learning exercise to broaden the understanding about the origin of Class Composition. CSS Modules support a native method of composing CSS Modules using [`composes`](https://github.com/css-modules/css-modules#composition) keyword. CSS Preprocessor is not required. You can write compositions in SCSS using [`@extend`](http://sass-lang.com/documentation/file.SASS_REFERENCE.html#extend) keyword and using [Mixin Directives](http://sass-lang.com/documentation/file.SASS_REFERENCE.html#mixins), e.g. diff --git a/dist/index.js b/dist/index.js deleted file mode 100644 index f7f655d..0000000 --- a/dist/index.js +++ /dev/null @@ -1,80 +0,0 @@ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); - -var _get = function get(_x2, _x3, _x4) { var _again = true; _function: while (_again) { var object = _x2, property = _x3, receiver = _x4; desc = parent = getter = undefined; _again = false; if (object === null) object = Function.prototype; var desc = Object.getOwnPropertyDescriptor(object, property); if (desc === undefined) { var parent = Object.getPrototypeOf(object); if (parent === null) { return undefined; } else { _x2 = parent; _x3 = property; _x4 = receiver; _again = true; continue _function; } } else if ('value' in desc) { return desc.value; } else { var getter = desc.get; if (getter === undefined) { return undefined; } return getter.call(receiver); } } }; - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } - -var _linkClass = require('./linkClass'); - -var _linkClass2 = _interopRequireDefault(_linkClass); - -var _makeConfig = require('./makeConfig'); - -var _makeConfig2 = _interopRequireDefault(_makeConfig); - -var decoratorConstructor = undefined, - functionConstructor = undefined; - -/** - * When used as a function. - * - * @param {Function} Component - * @param {Object} styles CSS Modules class map. - * @param {Object} options {@link https://github.com/gajus/react-css-modules#options} - * @return {Function} - */ -functionConstructor = function (Component, styles) { - var options = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2]; - - return (function (_Component) { - _inherits(_class, _Component); - - function _class() { - _classCallCheck(this, _class); - - _get(Object.getPrototypeOf(_class.prototype), 'constructor', this).apply(this, arguments); - } - - _createClass(_class, [{ - key: 'render', - value: function render() { - return (0, _linkClass2['default'])(_get(Object.getPrototypeOf(_class.prototype), 'render', this).call(this), styles, (0, _makeConfig2['default'])(options)); - } - }]); - - return _class; - })(Component); -}; - -/** - * When used as a ES7 decorator. - * - * @param {Object} styles CSS Modules class map. - * @param {Object} options {@link https://github.com/gajus/react-css-modules#options} - * @return {Function} - */ -decoratorConstructor = function (styles, options) { - return function (Component) { - return functionConstructor(Component, styles, options); - }; -}; - -exports['default'] = function () { - if (typeof arguments[0] === 'function') { - return functionConstructor(arguments[0], arguments[1], arguments[2]); - } else { - return decoratorConstructor(arguments[0], arguments[1]); - } -}; - -module.exports = exports['default']; \ No newline at end of file diff --git a/dist/linkClass.js b/dist/linkClass.js deleted file mode 100644 index 6176156..0000000 --- a/dist/linkClass.js +++ /dev/null @@ -1,98 +0,0 @@ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -var _react = require('react'); - -var _react2 = _interopRequireDefault(_react); - -var linkClass = undefined; - -/** - * @param {ReactElement} element - * @param {Object} styles CSS modules class map. - * @param {CSSModules~Options} options - * @return {ReactElement} - */ -linkClass = function (element) { - var styles = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; - var options = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2]; - - var appendClassName = undefined, - childrenCount = undefined, - clonedElement = undefined, - newChildren = undefined, - newProps = undefined, - styleNames = undefined; - - styleNames = element.props.styleName; - - if (styleNames) { - styleNames = styleNames.split(' '); - - if (options.allowMultiple === false && styleNames.length > 1) { - throw new Error('ReactElement styleName property defines multiple module names ("' + element.props.styleName + '").'); - } - - appendClassName = styleNames.map(function (styleName) { - if (styles[styleName]) { - return styles[styleName]; - } else { - if (options.errorWhenNotFound === true) { - throw new Error('"' + styleName + '" CSS module is undefined.'); - } - - return ''; - } - }); - - appendClassName = appendClassName.filter(function (className) { - return className.length; - }); - - appendClassName = appendClassName.join(' '); - } - - // A child can be either an array, a sole object or a string. - //
    test
    - if (typeof element.props.children !== 'string') { - childrenCount = _react2['default'].Children.count(element.props.children); - - if (childrenCount > 1) { - newChildren = _react2['default'].Children.map(element.props.children, function (node) { - if (_react2['default'].isValidElement(node)) { - return linkClass(node, styles, options); - } else { - return node; - } - }); - } else if (childrenCount === 1) { - newChildren = linkClass(_react2['default'].Children.only(element.props.children), styles, options); - } - } - - if (appendClassName) { - if (element.props.className) { - appendClassName = element.props.className + ' ' + appendClassName; - } - - newProps = { - className: appendClassName - }; - } - - if (newChildren) { - clonedElement = _react2['default'].cloneElement(element, newProps, newChildren); - } else { - clonedElement = _react2['default'].cloneElement(element, newProps); - } - - return clonedElement; -}; - -exports['default'] = linkClass; -module.exports = exports['default']; \ No newline at end of file diff --git a/dist/makeConfig.js b/dist/makeConfig.js deleted file mode 100644 index b286719..0000000 --- a/dist/makeConfig.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -var _utils = require('./utils'); - -var _utils2 = _interopRequireDefault(_utils); - -/** - * @typedef CSSModules~Options - * @see {@link https://github.com/gajus/react-css-modules#options} - * @property {Boolean} allowMultiple - * @property {Boolean} errorWhenNotFound - */ - -/** - * @param {Options} userConfig - * @return {CSSModules~Options} - */ - -exports['default'] = function () { - var userConfig = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; - - var knownProperties = undefined, - unknownProperties = undefined; - - knownProperties = ['allowMultiple', 'errorWhenNotFound']; - - unknownProperties = _utils2['default'].difference(_utils2['default'].keys(userConfig), knownProperties); - - if (unknownProperties.length) { - throw new Error('Unknown config property "' + unknownProperties[0] + '".'); - } - - _utils2['default'].forEach(userConfig, function (value, name) { - if (typeof value !== 'boolean') { - throw new Error('"' + name + '" property value must be a boolean.'); - } - }); - - if (typeof userConfig.allowMultiple === 'undefined') { - userConfig.allowMultiple = false; - } - - if (typeof userConfig.errorWhenNotFound === 'undefined') { - userConfig.errorWhenNotFound = false; - } - - return userConfig; -}; - -module.exports = exports['default']; \ No newline at end of file diff --git a/dist/utils.js b/dist/utils.js deleted file mode 100644 index 94ee160..0000000 --- a/dist/utils.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict'; - -Object.defineProperty(exports, '__esModule', { - value: true -}); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -var _lodashObjectKeys = require('lodash/object/keys'); - -var _lodashObjectKeys2 = _interopRequireDefault(_lodashObjectKeys); - -var _lodashArrayDifference = require('lodash/array/difference'); - -var _lodashArrayDifference2 = _interopRequireDefault(_lodashArrayDifference); - -var _lodashCollectionForEach = require('lodash/collection/forEach'); - -var _lodashCollectionForEach2 = _interopRequireDefault(_lodashCollectionForEach); - -exports['default'] = { - keys: _lodashObjectKeys2['default'], - difference: _lodashArrayDifference2['default'], - forEach: _lodashCollectionForEach2['default'] -}; -module.exports = exports['default']; \ No newline at end of file diff --git a/package.json b/package.json index ad165e2..00e1ffa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-css-modules", "description": "Seamless mapping of class names to CSS modules inside of React components.", - "main": "dist/index.js", + "main": "./dist/index.js", "repository": { "type": "git", "url": "https://github.com/gajus/react-css-modules" @@ -12,30 +12,44 @@ "css", "modules" ], - "version": "3.0.1", + "version": "4.3.0", "author": { "name": "Gajus Kuizinas", - "email": "gk@anuary.com", + "email": "gajus@gajus.com", "url": "http://gajus.com" }, "license": "BSD-3-Clause", "dependencies": { - "lodash": "^3.10.1" + "hoist-non-react-statics": "^2.5.5", + "lodash": "^4.16.6", + "object-unfreeze": "^1.1.0" }, "devDependencies": { - "babel": "^5.8.21", - "babel-eslint": "^4.1.0", - "chai": "^3.2.0", - "eslint": "^1.2.1", - "eslint-plugin-react": "^3.3.0", - "jsdom": "^6.2.0", - "mocha": "^2.2.5", - "react": "^0.14.0-beta3", - "react-addons-test-utils": "^0.14.0-beta3" + "babel-cli": "^6.18.0", + "babel-plugin-add-module-exports": "^0.2.1", + "babel-plugin-lodash": "^3.2.9", + "babel-plugin-transform-proto-to-assign": "^6.9.0", + "babel-preset-es2015": "^6.18.0", + "babel-preset-react": "^6.16.0", + "babel-preset-stage-0": "^6.16.0", + "babel-register": "^6.18.0", + "chai": "^4.0.0-canary.1", + "chai-spies": "^0.7.1", + "eslint": "^3.10.0", + "eslint-config-canonical": "^5.5.0", + "husky": "^0.11.9", + "jsdom": "^9.8.3", + "mocha": "^3.1.2", + "react": "^15.4.0-rc.4", + "react-addons-shallow-compare": "^15.4.0-rc.4", + "react-addons-test-utils": "^15.4.0-rc.4", + "react-dom": "^15.4.0-rc.4", + "semantic-release": "^6.3.2" }, "scripts": { - "test": "./node_modules/.bin/eslint ./src/ ./test/ && mocha", - "build": "babel ./src/ --out-dir ./dist/", - "watch": "babel --watch ./src/ --out-dir ./dist/" + "lint": "eslint ./src ./tests", + "test": "NODE_ENV=development mocha --compilers js:babel-register ./tests/**/*.js && npm run lint && npm run build", + "build": "NODE_ENV=production babel ./src --out-dir ./dist", + "precommit": "npm run test" } } diff --git a/src/SimpleMap.js b/src/SimpleMap.js new file mode 100644 index 0000000..7cf618b --- /dev/null +++ b/src/SimpleMap.js @@ -0,0 +1,21 @@ +export default class { + constructor () { + this.size = 0; + this.keys = []; + this.values = []; + } + + get (key) { + const index = this.keys.indexOf(key); + + return this.values[index]; + } + + set (key, value) { + this.keys.push(key); + this.values.push(value); + this.size = this.keys.length; + + return value; + } +} diff --git a/src/extendReactClass.js b/src/extendReactClass.js new file mode 100644 index 0000000..4741bea --- /dev/null +++ b/src/extendReactClass.js @@ -0,0 +1,74 @@ +/* eslint-disable react/prop-types */ + +import _ from 'lodash'; +import React from 'react'; +import hoistNonReactStatics from 'hoist-non-react-statics'; +import linkClass from './linkClass'; +import renderNothing from './renderNothing'; + +/** + * @param {ReactClass} Component + * @param {Object} defaultStyles + * @param {Object} options + * @returns {ReactClass} + */ +export default (Component: Object, defaultStyles: Object, options: Object) => { + const WrappedComponent = class extends Component { + render () { + let styles; + + const hasDefaultstyles = _.isObject(defaultStyles); + + let renderResult; + + if (this.props.styles || hasDefaultstyles) { + const props = Object.assign({}, this.props); + + if (props.styles) { + styles = props.styles; + } else if (hasDefaultstyles) { + styles = defaultStyles; + delete props.styles; + } + + Object.defineProperty(props, 'styles', { + configurable: true, + enumerable: false, + value: styles, + writable: false + }); + + const originalProps = this.props; + + let renderIsSuccessful = false; + + try { + this.props = props; + + renderResult = super.render(); + + renderIsSuccessful = true; + } finally { + this.props = originalProps; + } + + // @see https://github.com/facebook/react/issues/14224 + if (!renderIsSuccessful) { + renderResult = super.render(); + } + } else { + styles = {}; + + renderResult = super.render(); + } + + if (renderResult) { + return linkClass(renderResult, styles, options); + } + + return renderNothing(React.version); + } + }; + + return hoistNonReactStatics(WrappedComponent, Component); +}; diff --git a/src/generateAppendClassName.js b/src/generateAppendClassName.js new file mode 100644 index 0000000..127aa2a --- /dev/null +++ b/src/generateAppendClassName.js @@ -0,0 +1,49 @@ +import SimpleMap from './SimpleMap'; + +const CustomMap = typeof Map === 'undefined' ? SimpleMap : Map; + +const stylesIndex = new CustomMap(); + +export default (styles, styleNames: Array, handleNotFoundStyleName: "throw" | "log" | "ignore"): string => { + let appendClassName; + let stylesIndexMap; + + stylesIndexMap = stylesIndex.get(styles); + + if (stylesIndexMap) { + const styleNameIndex = stylesIndexMap.get(styleNames); + + if (styleNameIndex) { + return styleNameIndex; + } + } else { + stylesIndexMap = new CustomMap(); + stylesIndex.set(styles, new CustomMap()); + } + + appendClassName = ''; + + for (const styleName in styleNames) { + if (styleNames.hasOwnProperty(styleName)) { + const className = styles[styleNames[styleName]]; + + if (className) { + appendClassName += ' ' + className; + } else { + if (handleNotFoundStyleName === 'throw') { + throw new Error('"' + styleNames[styleName] + '" CSS module is undefined.'); + } + if (handleNotFoundStyleName === 'log') { + // eslint-disable-next-line no-console + console.warn('"' + styleNames[styleName] + '" CSS module is undefined.'); + } + } + } + } + + appendClassName = appendClassName.trim(); + + stylesIndexMap.set(styleNames, appendClassName); + + return appendClassName; +}; diff --git a/src/index.js b/src/index.js index aab47ea..b913b29 100644 --- a/src/index.js +++ b/src/index.js @@ -1,42 +1,56 @@ -import linkClass from './linkClass'; -import makeConfig from './makeConfig'; +import _ from 'lodash'; +import extendReactClass from './extendReactClass'; +import wrapStatelessFunction from './wrapStatelessFunction'; +import makeConfiguration from './makeConfiguration'; -let decoratorConstructor, - functionConstructor; +/** + * @see https://github.com/gajus/react-css-modules#options + */ +type TypeOptions = {}; + +/** + * Determines if the given object has the signature of a class that inherits React.Component. + */ +const isReactComponent = (maybeReactComponent: any): boolean => { + return 'prototype' in maybeReactComponent && _.isFunction(maybeReactComponent.prototype.render); +}; /** * When used as a function. - * - * @param {Function} Component - * @param {Object} styles CSS Modules class map. - * @param {Object} options {@link https://github.com/gajus/react-css-modules#options} - * @return {Function} */ -functionConstructor = (Component, styles, options = {}) => { - return class extends Component { - render () { - return linkClass(super.render(), styles, makeConfig(options)); - } - }; +const functionConstructor = (Component: Function, defaultStyles: Object, options: TypeOptions): Function => { + let decoratedClass; + + const configuration = makeConfiguration(options); + + if (isReactComponent(Component)) { + decoratedClass = extendReactClass(Component, defaultStyles, configuration); + } else { + decoratedClass = wrapStatelessFunction(Component, defaultStyles, configuration); + } + + if (Component.displayName) { + decoratedClass.displayName = Component.displayName; + } else { + decoratedClass.displayName = Component.name; + } + + return decoratedClass; }; /** * When used as a ES7 decorator. - * - * @param {Object} styles CSS Modules class map. - * @param {Object} options {@link https://github.com/gajus/react-css-modules#options} - * @return {Function} */ -decoratorConstructor = (styles, options) => { - return (Component) => { - return functionConstructor(Component, styles, options); - }; +const decoratorConstructor = (defaultStyles: Object, options: TypeOptions): Function => { + return (Component: Function) => { + return functionConstructor(Component, defaultStyles, options); + }; }; export default (...args) => { - if (typeof args[0] === 'function') { - return functionConstructor(args[0], args[1], args[2]); - } else { - return decoratorConstructor(args[0], args[1]); - } + if (_.isFunction(args[0])) { + return functionConstructor(args[0], args[1], args[2]); + } else { + return decoratorConstructor(args[0], args[1]); + } }; diff --git a/src/isIterable.js b/src/isIterable.js new file mode 100644 index 0000000..67d0a94 --- /dev/null +++ b/src/isIterable.js @@ -0,0 +1,24 @@ +import _ from 'lodash'; + +const ITERATOR_SYMBOL = typeof Symbol !== 'undefined' && _.isFunction(Symbol) && Symbol.iterator; +const OLD_ITERATOR_SYMBOL = '@@iterator'; + +/** + * @see https://github.com/lodash/lodash/issues/1668 + * @see https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Iteration_protocols + */ +export default (maybeIterable: any): boolean => { + let iterator; + + if (!_.isObject(maybeIterable)) { + return false; + } + + if (ITERATOR_SYMBOL) { + iterator = maybeIterable[ITERATOR_SYMBOL]; + } else { + iterator = maybeIterable[OLD_ITERATOR_SYMBOL]; + } + + return _.isFunction(iterator); +}; diff --git a/src/linkClass.js b/src/linkClass.js index b058657..4ed3975 100644 --- a/src/linkClass.js +++ b/src/linkClass.js @@ -1,85 +1,106 @@ -import React from 'react'; - -let linkClass; - -/** - * @param {ReactElement} element - * @param {Object} styles CSS modules class map. - * @param {CSSModules~Options} options - * @return {ReactElement} - */ -linkClass = (element, styles = {}, options = {}) => { - let appendClassName, - childrenCount, - clonedElement, - newChildren, - newProps, - styleNames; - - styleNames = element.props.styleName; - - if (styleNames) { - styleNames = styleNames.split(' '); - - if (options.allowMultiple === false && styleNames.length > 1) { - throw new Error(`ReactElement styleName property defines multiple module names ("${element.props.styleName}").`); - } - - appendClassName = styleNames.map((styleName) => { - if (styles[styleName]) { - return styles[styleName]; - } else { - if (options.errorWhenNotFound === true) { - throw new Error(`"${styleName}" CSS module is undefined.`); - } - - return ''; - } - }); - - appendClassName = appendClassName.filter((className) => { - return className.length; - }); - - appendClassName = appendClassName.join(' '); +import _ from 'lodash'; +import React, { + ReactElement +} from 'react'; +import objectUnfreeze from 'object-unfreeze'; +import isIterable from './isIterable'; +import parseStyleName from './parseStyleName'; +import generateAppendClassName from './generateAppendClassName'; + +const linkArray = (array: Array, styles: Object, configuration: Object) => { + _.forEach(array, (value, index) => { + if (React.isValidElement(value)) { + // eslint-disable-next-line no-use-before-define + array[index] = linkElement(React.Children.only(value), styles, configuration); + } else if (_.isArray(value)) { + const unfreezedValue = Object.isFrozen(value) ? objectUnfreeze(value) : value; + + array[index] = linkArray(unfreezedValue, styles, configuration); } + }); + + return array; +}; - // A child can be either an array, a sole object or a string. - //
    test
    - if (typeof element.props.children !== 'string') { - childrenCount = React.Children.count(element.props.children); - - if (childrenCount > 1) { - newChildren = React.Children.map(element.props.children, (node) => { - if (React.isValidElement(node)) { - return linkClass(node, styles, options); - } else { - return node; - } - }); - } else if (childrenCount === 1) { - newChildren = linkClass(React.Children.only(element.props.children), styles, options); - } +const linkElement = (element: ReactElement, styles: Object, configuration: Object): ReactElement => { + let appendClassName; + let elementShallowCopy; + + elementShallowCopy = element; + + if (Array.isArray(elementShallowCopy)) { + return elementShallowCopy.map((arrayElement) => { + return linkElement(arrayElement, styles, configuration); + }); + } + + const elementIsFrozen = Object.isFrozen && Object.isFrozen(elementShallowCopy); + const propsFrozen = Object.isFrozen && Object.isFrozen(elementShallowCopy.props); + const propsNotExtensible = Object.isExtensible && !Object.isExtensible(elementShallowCopy.props); + + if (elementIsFrozen) { + // https://github.com/facebook/react/blob/v0.13.3/src/classic/element/ReactElement.js#L131 + elementShallowCopy = objectUnfreeze(elementShallowCopy); + elementShallowCopy.props = objectUnfreeze(elementShallowCopy.props); + } else if (propsFrozen || propsNotExtensible) { + elementShallowCopy.props = objectUnfreeze(elementShallowCopy.props); + } + + const styleNames = parseStyleName(elementShallowCopy.props.styleName || '', configuration.allowMultiple); + const {children, ...restProps} = elementShallowCopy.props; + + if (React.isValidElement(children)) { + elementShallowCopy.props.children = linkElement(React.Children.only(children), styles, configuration); + } else if (_.isArray(children) || isIterable(children)) { + elementShallowCopy.props.children = linkArray(objectUnfreeze(children), styles, configuration); + } + + _.forEach(restProps, (propValue, propName) => { + if (React.isValidElement(propValue)) { + elementShallowCopy.props[propName] = linkElement(React.Children.only(propValue), styles, configuration); + } else if (_.isArray(propValue)) { + elementShallowCopy.props[propName] = linkArray(propValue, styles, configuration); } + }); + if (styleNames.length) { + appendClassName = generateAppendClassName(styles, styleNames, configuration.handleNotFoundStyleName); if (appendClassName) { - if (element.props.className) { - appendClassName = `${element.props.className} ${appendClassName}`; - } + if (elementShallowCopy.props.className) { + appendClassName = elementShallowCopy.props.className + ' ' + appendClassName; + } - newProps = { - className: appendClassName - }; + elementShallowCopy.props.className = appendClassName; } + } - if (newChildren) { - clonedElement = React.cloneElement(element, newProps, newChildren); - } else { - clonedElement = React.cloneElement(element, newProps); - } + delete elementShallowCopy.props.styleName; - return clonedElement; + if (elementIsFrozen) { + Object.freeze(elementShallowCopy.props); + Object.freeze(elementShallowCopy); + } else if (propsFrozen) { + Object.freeze(elementShallowCopy.props); + } + + if (propsNotExtensible) { + Object.preventExtensions(elementShallowCopy.props); + } + + return elementShallowCopy; }; -export default linkClass; +/** + * @param {ReactElement} element + * @param {Object} styles CSS modules class map. + * @param {CSSModules~Options} configuration + */ +export default (element: ReactElement, styles = {}, configuration = {}): ReactElement => { + // @see https://github.com/gajus/react-css-modules/pull/30 + if (!_.isObject(element)) { + return element; + } + + return linkElement(element, styles, configuration); +}; diff --git a/src/makeConfig.js b/src/makeConfig.js deleted file mode 100644 index f9c8715..0000000 --- a/src/makeConfig.js +++ /dev/null @@ -1,44 +0,0 @@ -import _ from './utils'; - -/** - * @typedef CSSModules~Options - * @see {@link https://github.com/gajus/react-css-modules#options} - * @property {Boolean} allowMultiple - * @property {Boolean} errorWhenNotFound - */ - -/** - * @param {Options} userConfig - * @return {CSSModules~Options} - */ -export default (userConfig = {}) => { - let knownProperties, - unknownProperties; - - knownProperties = [ - 'allowMultiple', - 'errorWhenNotFound' - ]; - - unknownProperties = _.difference(_.keys(userConfig), knownProperties); - - if (unknownProperties.length) { - throw new Error(`Unknown config property "${unknownProperties[0]}".`); - } - - _.forEach(userConfig, (value, name) => { - if (typeof value !== 'boolean') { - throw new Error(`"${name}" property value must be a boolean.`); - } - }); - - if (typeof userConfig.allowMultiple === 'undefined') { - userConfig.allowMultiple = false; - } - - if (typeof userConfig.errorWhenNotFound === 'undefined') { - userConfig.errorWhenNotFound = false; - } - - return userConfig; -}; diff --git a/src/makeConfiguration.js b/src/makeConfiguration.js new file mode 100644 index 0000000..cc3169c --- /dev/null +++ b/src/makeConfiguration.js @@ -0,0 +1,37 @@ +import _ from 'lodash'; + +/** + * @typedef CSSModules~Options + * @see {@link https://github.com/gajus/react-css-modules#options} + * @property {boolean} allowMultiple + * @property {string} handleNotFoundStyleName + */ + +/** + * @param {CSSModules~Options} userConfiguration + * @returns {CSSModules~Options} + */ +export default (userConfiguration = {}) => { + const configuration = { + allowMultiple: false, + handleNotFoundStyleName: 'throw' + }; + + _.forEach(userConfiguration, (value, name) => { + if (_.isUndefined(configuration[name])) { + throw new Error('Unknown configuration property "' + name + '".'); + } + + if (name === 'allowMultiple' && !_.isBoolean(value)) { + throw new Error('"allowMultiple" property value must be a boolean.'); + } + + if (name === 'handleNotFoundStyleName' && !_.includes(['throw', 'log', 'ignore'], value)) { + throw new Error('"handleNotFoundStyleName" property value must be "throw", "log" or "ignore".'); + } + + configuration[name] = value; + }); + + return configuration; +}; diff --git a/src/parseStyleName.js b/src/parseStyleName.js new file mode 100644 index 0000000..937b2d6 --- /dev/null +++ b/src/parseStyleName.js @@ -0,0 +1,22 @@ +import _ from 'lodash'; + +const styleNameIndex = {}; + +export default (styleNamePropertyValue: string, allowMultiple: boolean): Array => { + let styleNames; + + if (styleNameIndex[styleNamePropertyValue]) { + styleNames = styleNameIndex[styleNamePropertyValue]; + } else { + styleNames = _.trim(styleNamePropertyValue).split(/\s+/); + styleNames = _.filter(styleNames); + + styleNameIndex[styleNamePropertyValue] = styleNames; + } + + if (allowMultiple === false && styleNames.length > 1) { + throw new Error('ReactElement styleName property defines multiple module names ("' + styleNamePropertyValue + '").'); + } + + return styleNames; +}; diff --git a/src/renderNothing.js b/src/renderNothing.js new file mode 100644 index 0000000..93675fe --- /dev/null +++ b/src/renderNothing.js @@ -0,0 +1,7 @@ +import React from 'react'; + +export default function (version) { + const major = version.split('.')[0]; + + return parseInt(major, 10) < 15 ? React.createElement('noscript') : null; +} diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index 861dd24..0000000 --- a/src/utils.js +++ /dev/null @@ -1,9 +0,0 @@ -import keys from 'lodash/object/keys'; -import difference from 'lodash/array/difference'; -import forEach from 'lodash/collection/forEach'; - -export default { - keys, - difference, - forEach -}; diff --git a/src/wrapStatelessFunction.js b/src/wrapStatelessFunction.js new file mode 100644 index 0000000..b6b9162 --- /dev/null +++ b/src/wrapStatelessFunction.js @@ -0,0 +1,49 @@ +/* eslint-disable react/prop-types */ + +import _ from 'lodash'; +import React from 'react'; +import linkClass from './linkClass'; +import renderNothing from './renderNothing'; + +/** + * @see https://facebook.github.io/react/blog/2015/09/10/react-v0.14-rc1.html#stateless-function-components + */ +export default (Component: Function, defaultStyles: Object, options: Object): Function => { + const WrappedComponent = (props = {}, ...args) => { + let styles; + let useProps; + const hasDefaultstyles = _.isObject(defaultStyles); + + if (props.styles || hasDefaultstyles) { + useProps = Object.assign({}, props); + + if (props.styles) { + styles = props.styles; + } else { + styles = defaultStyles; + } + + Object.defineProperty(useProps, 'styles', { + configurable: true, + enumerable: false, + value: styles, + writable: false + }); + } else { + useProps = props; + styles = {}; + } + + const renderResult = Component(useProps, ...args); + + if (renderResult) { + return linkClass(renderResult, styles, options); + } + + return renderNothing(React.version); + }; + + _.assign(WrappedComponent, Component); + + return WrappedComponent; +}; diff --git a/test/linkClass.js b/test/linkClass.js deleted file mode 100644 index f88884b..0000000 --- a/test/linkClass.js +++ /dev/null @@ -1,146 +0,0 @@ -import { - expect -} from 'chai'; - -import React from 'react'; -import TestUtils from 'react-addons-test-utils'; -import jsdom from 'jsdom'; -import linkClass from './../dist/linkClass'; - -describe('linkClass', () => { - context('when ReactElement does not define styleName', () => { - it('does not affect element properties', () => { - expect(linkClass(
    )).to.deep.equal(
    ); - }); - - it('does not affect element properties with a single element child', () => { - expect(linkClass(

    )).to.deep.equal(

    ); - }); - - it('does not affect element properties with a single text child', () => { - expect(linkClass(
    test
    )).to.deep.equal(
    test
    ); - }); - - it('does not affect the className', () => { - expect(linkClass(
    )).to.deep.equal(
    ); - }); - - // Using array instead of object causes the following error: - // Warning: Each child in an array or iterator should have a unique "key" prop. - // Check the render method of _class. See https://fb.me/react-warning-keys for more information. - xit('does not affect element with multiple children', () => { - expect(linkClass(

    )).to.deep.equal(

    ); - }); - }); - - context('when styleName matches an existing CSS module', () => { - context('when ReactElement does not have an existing className', () => { - it('uses the generated class name to set the className property', () => { - let subject; - - subject =
    ; - - subject = linkClass(subject, { - foo: 'foo-1' - }); - - expect(subject.props.className).to.deep.equal('foo-1'); - }); - }); - context('when ReactElement has an existing className', () => { - it('appends the generated class name to the className property', () => { - let subject; - - subject =
    ; - - subject = linkClass(subject, { - bar: 'bar-1' - }); - - expect(subject.props.className).to.deep.equal('foo bar-1'); - }); - }); - }); - - describe('options.allowMultiple', () => { - context('when multiple module names are used', () => { - context('when false', () => { - it('throws an error', () => { - expect(() => { - linkClass(
    , {}, {allowMultiple: false}); - }).to.throw(Error, 'ReactElement styleName property defines multiple module names ("foo bar").'); - }); - }); - context('when true', () => { - it('appends a generated class name for every referenced CSS module', () => { - let subject; - - subject =
    ; - - subject = linkClass(subject, { - foo: 'foo-1', - bar: 'bar-1' - }, {allowMultiple: true}); - - expect(subject.props.className).to.deep.equal('foo-1 bar-1'); - }); - }); - }); - }); - - describe('options.errorWhenNotFound', () => { - context('when styleName does not match an existing CSS module', () => { - context('when false', () => { - it('ignores the missing CSS module', () => { - let subject; - - subject =
    ; - - subject = linkClass(subject, {}, {errorWhenNotFound: false}); - - expect(subject.props.className).to.be.an('undefined'); - }); - }); - context('when is true', () => { - it('throws an error', () => { - expect(() => { - linkClass(
    , {}, {errorWhenNotFound: true}); - }).to.throw(Error, '"foo" CSS module is undefined.'); - }); - }); - }); - }); - - context('when ReactElement includes ReactComponent', () => { - let Foo, - nodeList; - - beforeEach(() => { - global.document = jsdom.jsdom(` - - - - - - - - `); - - global.window = document.defaultView; - - Foo = class extends React.Component { - render () { - return
    Hello
    ; - } - }; - - nodeList = TestUtils.renderIntoDocument(linkClass(
    , {foo: 'foo-1'})); - }); - it('processes ReactElement nodes', () => { - expect(nodeList.className).to.equal('foo-1'); - }); - it('does not process ReactComponent nodes', () => { - expect(nodeList.firstChild.className).to.equal(''); - }); - }); -}); diff --git a/test/makeConfig.js b/test/makeConfig.js deleted file mode 100644 index a48bd86..0000000 --- a/test/makeConfig.js +++ /dev/null @@ -1,34 +0,0 @@ -import { - expect -} from 'chai'; - -import makeConfig from './../dist/makeConfig'; - -describe('makeConfig', () => { - describe('when using default config', () => { - let options; - - beforeEach(() => { - options = makeConfig(); - }); - describe('allowMultiple property', () => { - it('defaults to false', () => { - expect(options.allowMultiple).to.equal(false); - }); - }); - describe('errorWhenNotFound property', () => { - it('defaults to true', () => { - expect(options.errorWhenNotFound).to.equal(false); - }); - }); - }); - describe('when unknown property is provided', () => { - it('throws an error', () => { - expect(() => { - makeConfig({ - unknownProperty: true - }); - }).to.throw(Error, 'Unknown config property "unknownProperty".'); - }); - }); -}); diff --git a/test/mocha.opts b/test/mocha.opts deleted file mode 100644 index 13374d0..0000000 --- a/test/mocha.opts +++ /dev/null @@ -1 +0,0 @@ ---compilers js:babel/register diff --git a/tests/SimpleMap.js b/tests/SimpleMap.js new file mode 100644 index 0000000..08ca888 --- /dev/null +++ b/tests/SimpleMap.js @@ -0,0 +1,38 @@ +import { + expect +} from 'chai'; +import SimpleMap from './../src/SimpleMap'; + +describe('SimpleMap', () => { + context('simple map with primitive or object as keys', () => { + const values = [ + [1, 'something'], + ['1', 'somethingElse'], + [{}, []], + [null, null] + ]; + + let map; + + beforeEach(() => { + map = new SimpleMap(); + }); + + it('should set', () => { + values.forEach(([key, value]) => { + map.set(key, value); + }); + expect(map.size).to.equal(values.length); + }); + + it('should get', () => { + values.forEach(([key, value]) => { + map.set(key, value); + }); + + values.forEach(([key, value]) => { + expect(map.get(key)).to.equal(value); + }); + }); + }); +}); diff --git a/tests/extendReactClass.js b/tests/extendReactClass.js new file mode 100644 index 0000000..01733ee --- /dev/null +++ b/tests/extendReactClass.js @@ -0,0 +1,167 @@ +/* eslint-disable max-nested-callbacks, react/prefer-stateless-function, react/prop-types, react/no-multi-comp, class-methods-use-this */ + +import { + expect +} from 'chai'; +import React from 'react'; +import TestUtils from 'react-addons-test-utils'; +import shallowCompare from 'react-addons-shallow-compare'; +import jsdom from 'jsdom'; +import extendReactClass from './../src/extendReactClass'; + +describe('extendReactClass', () => { + beforeEach(() => { + global.document = jsdom.jsdom(''); + + global.window = document.defaultView; + }); + context('using default styles', () => { + it('exposes styles through this.props.styles property', (done) => { + let Component; + + const styles = { + foo: 'foo-1' + }; + + Component = class extends React.Component { + render () { + expect(this.props.styles).to.equal(styles); + done(); + } + }; + + Component = extendReactClass(Component, styles); + + TestUtils.renderIntoDocument(); + }); + it('exposes non-enumerable styles property', (done) => { + let Component; + + const styles = { + foo: 'foo-1' + }; + + Component = class extends React.Component { + render () { + expect(this.props.propertyIsEnumerable('styles')).to.equal(false); + done(); + } + }; + + Component = extendReactClass(Component, styles); + + TestUtils.renderIntoDocument(); + }); + it('does not affect the other instance properties', (done) => { + let Component; + + Component = class extends React.Component { + render () { + expect(this.props.bar).to.equal('baz'); + done(); + } + }; + + const styles = { + foo: 'foo-1' + }; + + Component = extendReactClass(Component, styles); + + TestUtils.renderIntoDocument(); + }); + it('does not affect pure-render logic', (done) => { + let Component; + let rendered; + + rendered = false; + + const styles = { + foo: 'foo-1' + }; + + Component = class extends React.Component { + shouldComponentUpdate (newProps) { + if (rendered) { + expect(shallowCompare(this.props, newProps)).to.equal(true); + + done(); + } + + return true; + } + + render () { + rendered = true; + } + }; + + Component = extendReactClass(Component, styles); + + const instance = TestUtils.renderIntoDocument(); + + // trigger shouldComponentUpdate + instance.setState({}); + }); + }); + context('overwriting default styles using "styles" property of the extended component', () => { + it('overwrites default styles', (done) => { + let Component; + + const styles = { + foo: 'foo-1' + }; + + Component = class extends React.Component { + render () { + expect(this.props.styles).to.equal(styles); + done(); + } + }; + + Component = extendReactClass(Component, { + bar: 'bar-0', + foo: 'foo-0' + }); + + TestUtils.renderIntoDocument(); + }); + }); + context('rendering Component that returns null', () => { + it('generates null', () => { + let Component; + + const shallowRenderer = TestUtils.createRenderer(); + + Component = class extends React.Component { + render () { + return null; + } + }; + + Component = extendReactClass(Component); + + shallowRenderer.render(); + + const component = shallowRenderer.getRenderOutput(); + + expect(component).to.equal(null); + }); + }); + context('target component have static properties', () => { + it('hoists static properties', () => { + const Component = class extends React.Component { + static foo = 'FOO'; + + render () { + return null; + } + }; + + const WrappedComponent = extendReactClass(Component); + + expect(Component.foo).to.equal('FOO'); + expect(WrappedComponent.foo).to.equal(Component.foo); + }); + }); +}); diff --git a/tests/linkClass.js b/tests/linkClass.js new file mode 100644 index 0000000..cf7c289 --- /dev/null +++ b/tests/linkClass.js @@ -0,0 +1,483 @@ +/* eslint-disable max-nested-callbacks, react/prefer-stateless-function, class-methods-use-this, no-console, no-unused-expressions */ + +import chai, { + expect +} from 'chai'; +import spies from 'chai-spies'; +import React from 'react'; +import TestUtils from 'react-addons-test-utils'; +import jsdom from 'jsdom'; +import linkClass from './../src/linkClass'; + +chai.use(spies); + +describe('linkClass', () => { + context('ReactElement does not define styleName', () => { + it('does not affect element properties', () => { + expect(linkClass(
    )).to.deep.equal(
    ); + }); + + it('does not affect element properties with a single element child', () => { + expect(linkClass(

    )).to.deep.equal(

    ); + }); + + it('does not affect element properties with a single element child in non-`children` prop', () => { + expect(linkClass(
    } />)).to.deep.equal(
    } />); + }); + + it('does not affect element properties with a single text child', () => { + expect(linkClass(
    test
    )).to.deep.equal(
    test
    ); + }); + + it('does not affect the className', () => { + expect(linkClass(
    )).to.deep.equal(
    ); + }); + + xit('does not affect element with a single children when that children is contained in an array', () => { + const subject = React.createElement('div', null, [ + React.createElement('p') + ]); + const outcome = React.createElement('div', null, [ + React.createElement('p') + ]); + + expect(linkClass(subject)).to.deep.equal(outcome); + }); + + xit('does not affect element with multiple children', () => { + // Using array instead of object causes the following error: + // Warning: Each child in an array or iterator should have a unique "key" prop. + // Check the render method of _class. See https://fb.me/react-warning-keys for more information. + // @see https://github.com/facebook/react/issues/4723#issuecomment-135555277 + // expect(linkClass(

    )).to.deep.equal(

    ); + + const subject = React.createElement('div', null, [ + React.createElement('p'), + React.createElement('p') + ]); + const outcome = React.createElement('div', null, [ + React.createElement('p'), + React.createElement('p') + ]); + + expect(linkClass(subject)).to.deep.equal(outcome); + }); + }); + + context('called with null instead of ReactElement', () => { + it('returns null', () => { + const subject = linkClass(null); + + expect(subject).to.equal(null); + }); + }); + + context('styleName matches an existing CSS module', () => { + context('when a descendant element has styleName', () => { + it('assigns a generated className', () => { + let subject; + + subject =
    +

    +

    ; + + subject = linkClass(subject, { + foo: 'foo-1' + }); + + expect(subject.props.children.props.className).to.equal('foo-1'); + }); + }); + context('when a descendant element in non-`children` prop has styleName', () => { + it('assigns a generated className', () => { + let subject; + + subject =
    } + els={[

    , [

    ]]} + />; + + subject = linkClass(subject, { + bar: 'bar-1', + baz: 'baz-1', + foo: 'foo-1' + }); + + expect(subject.props.el.props.className).to.equal('foo-1'); + expect(subject.props.els[0].props.className).to.equal('bar-1'); + expect(subject.props.els[1][0].props.className).to.equal('baz-1'); + }); + }); + context('when multiple descendant elements have styleName', () => { + it('assigns a generated className', () => { + let subject; + + subject =

    +

    +

    +

    ; + + subject = linkClass(subject, { + bar: 'bar-1', + foo: 'foo-1' + }); + + expect(subject.props.children[0].props.className).to.equal('foo-1'); + expect(subject.props.children[1].props.className).to.equal('bar-1'); + }); + it('assigns a generated className to elements inside nested arrays', () => { + let subject; + + subject =
    + {[ + [ +

    , +

    + ], + [ +

    , +

    + ] + ]} +

    ; + + subject = linkClass(subject, { + bar: 'bar-1', + foo: 'foo-1' + }); + + expect(subject.props.children[0][0].props.className).to.equal('foo-1'); + expect(subject.props.children[0][1].props.className).to.equal('bar-1'); + + expect(subject.props.children[1][0].props.className).to.equal('foo-1'); + expect(subject.props.children[1][1].props.className).to.equal('bar-1'); + }); + it('styleName is deleted from props', () => { + let subject; + + subject =
    +

    +

    +

    ; + + subject = linkClass(subject, { + bar: 'bar-1', + foo: 'foo-1' + }); + + expect(subject.props.children[0].props).not.to.have.property('styleName'); + expect(subject.props.children[1].props).not.to.have.property('styleName'); + }); + it('preserves original keys', () => { + let subject; + + subject =
    +

    +

    +

    ; + + subject = linkClass(subject, { + bar: 'bar-1', + foo: 'foo-1' + }); + + expect(subject.props.children[0].key).to.equal('1'); + expect(subject.props.children[1].key).to.equal('2'); + }); + }); + context('when multiple descendants have styleName and are iterable', () => { + it('assigns a generated className', () => { + let subject; + + const iterable = { + 0:

    , + 1:

    , + length: 2, + + // eslint-disable-next-line no-use-extend-native/no-use-extend-native + [Symbol.iterator]: Array.prototype[Symbol.iterator] + }; + + subject =

    {iterable}
    ; + + subject = linkClass(subject, { + bar: 'bar-1', + foo: 'foo-1' + }); + + expect(subject.props.children[0].props.className).to.equal('foo-1'); + expect(subject.props.children[1].props.className).to.equal('bar-1'); + }); + }); + context('when non-`children` prop is an iterable', () => { + it('it is left untouched', () => { + let subject; + + const iterable = { + 0:

    , + 1:

    , + length: 2, + + // eslint-disable-next-line no-use-extend-native/no-use-extend-native + [Symbol.iterator]: Array.prototype[Symbol.iterator] + }; + + subject =

    ; + + subject = linkClass(subject, { + bar: 'bar-1', + foo: 'foo-1' + }); + + expect(subject.props.els[0].props.styleName).to.equal('foo'); + expect(subject.props.els[1].props.styleName).to.equal('bar'); + expect(subject.props.els[0].props).not.to.have.property('className'); + expect(subject.props.els[1].props).not.to.have.property('className'); + }); + }); + context('when ReactElement does not have an existing className', () => { + it('uses the generated class name to set the className property', () => { + let subject; + + subject =
    ; + + subject = linkClass(subject, { + foo: 'foo-1' + }); + + expect(subject.props.className).to.deep.equal('foo-1'); + }); + }); + context('when ReactElement has an existing className', () => { + it('appends the generated class name to the className property', () => { + let subject; + + subject =
    ; + + subject = linkClass(subject, { + bar: 'bar-1' + }); + + expect(subject.props.className).to.deep.equal('foo bar-1'); + }); + }); + }); + + context('styleName includes multiple whitespace characters', () => { + it('resolves CSS modules', () => { + let subject; + + subject =
    +

    +

    ; + + subject = linkClass(subject, { + bar: 'bar-1', + foo: 'foo-1' + }, { + allowMultiple: true + }); + + expect(subject.props.children.props.className).to.equal('foo-1 bar-1'); + }); + }); + + context('can\'t write to properties', () => { + context('when the element is frozen', () => { + it('adds className but is still frozen', () => { + let subject; + + subject =
    ; + + Object.freeze(subject); + subject = linkClass(subject, { + foo: 'foo-1' + }); + + expect(subject).to.be.frozen; + expect(subject.props.className).to.equal('foo-1'); + }); + }); + context('when the element\'s props are frozen', () => { + it('adds className and only props are still frozen', () => { + let subject; + + subject =
    ; + + Object.freeze(subject.props); + subject = linkClass(subject, { + foo: 'foo-1' + }); + + expect(subject.props).to.be.frozen; + expect(subject.props.className).to.equal('foo-1'); + }); + }); + context('when the element\'s props are not extensible', () => { + it('adds className and props are still not extensible', () => { + let subject; + + subject =
    ; + + Object.preventExtensions(subject.props); + subject = linkClass(subject, { + foo: 'foo-1' + }); + + expect(subject.props).to.not.be.extensible; + expect(subject.props.className).to.equal('foo-1'); + }); + }); + }); + + context('when element is an array', () => { + it('handles each element individually', () => { + let subject; + + subject = [ +
    , +
    +

    +

    + ]; + + subject = linkClass(subject, { + bar: 'bar-1', + foo: 'foo-1' + }); + + expect(subject).to.be.an('array'); + expect(subject[0].props.className).to.equal('foo-1'); + expect(subject[1].props.children.props.className).to.equal('bar-1'); + }); + }); + + describe('options.allowMultiple', () => { + context('when multiple module names are used', () => { + context('when false', () => { + it('throws an error', () => { + expect(() => { + linkClass(
    , {}, {allowMultiple: false}); + }).to.throw(Error, 'ReactElement styleName property defines multiple module names ("foo bar").'); + }); + }); + context('when true', () => { + it('appends a generated class name for every referenced CSS module', () => { + let subject; + + subject =
    ; + + subject = linkClass(subject, { + bar: 'bar-1', + foo: 'foo-1' + }, { + allowMultiple: true + }); + + expect(subject.props.className).to.deep.equal('foo-1 bar-1'); + }); + }); + }); + }); + + describe('options.handleNotFoundStyleName', () => { + context('when styleName does not match an existing CSS module', () => { + context('when throw', () => { + it('throws an error', () => { + expect(() => { + linkClass(
    , {}, {handleNotFoundStyleName: 'throw'}); + }).to.throw(Error, '"foo" CSS module is undefined.'); + }); + }); + context('when log', () => { + it('logs a warning to the console', () => { + const warnSpy = chai.spy(() => {}); + + console.warn = warnSpy; + linkClass(
    , {}, {handleNotFoundStyleName: 'log'}); + expect(warnSpy).to.have.been.called(); + }); + }); + context('when ignore', () => { + it('does not log a warning', () => { + const warnSpy = chai.spy(() => {}); + + console.warn = warnSpy; + linkClass(
    , {}, {handleNotFoundStyleName: 'ignore'}); + expect(warnSpy).to.not.have.been.called(); + }); + + it('does not throw an error', () => { + expect(() => { + linkClass(
    , {}, {handleNotFoundStyleName: 'ignore'}); + }).to.not.throw(Error, '"foo" CSS module is undefined.'); + }); + }); + }); + }); + + context('when ReactElement includes ReactComponent', () => { + let Foo; + let nodeList; + + beforeEach(() => { + global.document = jsdom.jsdom(''); + global.window = document.defaultView; + + Foo = class extends React.Component { + render () { + return
    Hello
    ; + } + }; + + nodeList = TestUtils.renderIntoDocument(linkClass(
    , {foo: 'foo-1'})); + }); + it('processes ReactElement nodes', () => { + expect(nodeList.className).to.equal('foo-1'); + }); + it('does not process ReactComponent nodes', () => { + expect(nodeList.firstChild.className).to.equal(''); + }); + }); + + it('deletes styleName property from the target element', () => { + let subject; + + subject =
    ; + + subject = linkClass(subject, { + foo: 'foo-1' + }); + + expect(subject.props.className).to.deep.equal('foo-1'); + expect(subject.props).not.to.have.property('styleName'); + }); + + it('deletes styleName property from the target element (deep)', () => { + let subject; + + subject =
    } + els={[, []]} + styleName='foo' + > +
    +
    +
    ; + + subject = linkClass(subject, { + bar: 'bar-1', + baz: 'baz-1', + foo: 'foo-1' + }); + + expect(subject.props.children[0].props.className).to.deep.equal('bar-1'); + expect(subject.props.children[0].props).not.to.have.property('styleName'); + expect(subject.props.el.props.className).to.deep.equal('baz-1'); + expect(subject.props.el.props).not.to.have.property('styleName'); + expect(subject.props.els[0].props.className).to.deep.equal('foo-1'); + expect(subject.props.els[0].props).not.to.have.property('styleName'); + expect(subject.props.els[1][0].props.className).to.deep.equal('bar-1'); + expect(subject.props.els[1][0].props).not.to.have.property('styleName'); + }); +}); diff --git a/tests/makeConfiguration.js b/tests/makeConfiguration.js new file mode 100644 index 0000000..4193311 --- /dev/null +++ b/tests/makeConfiguration.js @@ -0,0 +1,42 @@ +/* eslint-disable max-nested-callbacks */ + +import { + expect +} from 'chai'; +import makeConfiguration from './../src/makeConfiguration'; + +describe('makeConfiguration', () => { + describe('when using default configuration', () => { + let configuration; + + beforeEach(() => { + configuration = makeConfiguration(); + }); + describe('allowMultiple property', () => { + it('defaults to false', () => { + expect(configuration.allowMultiple).to.equal(false); + }); + }); + describe('handleNotFoundStyleName property', () => { + it('defaults to "throw"', () => { + expect(configuration.handleNotFoundStyleName).to.equal('throw'); + }); + }); + }); + describe('when unknown property is provided', () => { + it('throws an error', () => { + expect(() => { + makeConfiguration({ + unknownProperty: true + }); + }).to.throw(Error, 'Unknown configuration property "unknownProperty".'); + }); + }); + it('does not mutate user configuration', () => { + const userConfiguration = {}; + + makeConfiguration(userConfiguration); + + expect(userConfiguration).to.deep.equal({}); + }); +}); diff --git a/tests/reactCssModules.js b/tests/reactCssModules.js new file mode 100644 index 0000000..d484d95 --- /dev/null +++ b/tests/reactCssModules.js @@ -0,0 +1,184 @@ +/* eslint-disable max-nested-callbacks, react/no-multi-comp, react/prop-types, react/prefer-stateless-function, class-methods-use-this */ + +import { + expect +} from 'chai'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import TestUtils from 'react-addons-test-utils'; +import jsdom from 'jsdom'; +import reactCssModules from './../src/index'; + +describe('reactCssModules', () => { + context('a ReactComponent is decorated using react-css-modules', () => { + it('inherits displayName', () => { + let Foo; + + Foo = class extends React.Component {}; + + // @todo https://phabricator.babeljs.io/T2779 + Foo.displayName = 'Bar'; + + Foo = reactCssModules(Foo); + + expect(Foo.displayName).to.equal('Bar'); + }); + context('target component does not name displayName', () => { + it('uses name for displayName', () => { + let Foo; + + Foo = class Bar extends React.Component {}; + + Foo = reactCssModules(Foo); + + expect(Foo.displayName).to.equal('Bar'); + }); + }); + }); + context('a ReactComponent renders an element with the styleName prop', () => { + context('the component is a class that extends React.Component', () => { + let Foo; + let component; + + beforeEach(() => { + const shallowRenderer = TestUtils.createRenderer(); + + Foo = class extends React.Component { + render () { + return
    Hello
    ; + } + }; + + Foo = reactCssModules(Foo, { + foo: 'foo-1' + }); + + shallowRenderer.render(); + + component = shallowRenderer.getRenderOutput(); + }); + it('that element should contain the equivalent className', () => { + expect(component.props.className).to.equal('foo-1'); + }); + it('the styleName prop should be "consumed" in the process', () => { + expect(component.props).not.to.have.property('styleName'); + }); + }); + context('the component is a stateless function component', () => { + let Foo; + let component; + + beforeEach(() => { + const shallowRenderer = TestUtils.createRenderer(); + + Foo = () => { + return
    Hello
    ; + }; + + Foo = reactCssModules(Foo, { + foo: 'foo-1' + }); + + shallowRenderer.render(); + + component = shallowRenderer.getRenderOutput(); + }); + it('that element should contain the equivalent className', () => { + expect(component.props.className).to.equal('foo-1'); + }); + it('the styleName prop should be "consumed" in the process', () => { + expect(component.props).not.to.have.property('styleName'); + }); + }); + }); + context('a ReactComponent renders nothing', () => { + context('the component is a class that extends React.Component', () => { + it('linkClass must not intervene', () => { + let Foo; + + const shallowRenderer = TestUtils.createRenderer(); + + Foo = class extends React.Component { + render () { + return null; + } + }; + + Foo = reactCssModules(Foo, { + foo: 'foo-1' + }); + + shallowRenderer.render(); + + const component = shallowRenderer.getRenderOutput(); + + expect(typeof component).to.equal('object'); + }); + }); + context('the component is a stateless function component', () => { + it('that element should contain the equivalent className', () => { + let Foo; + + const shallowRenderer = TestUtils.createRenderer(); + + Foo = () => { + return null; + }; + + Foo = reactCssModules(Foo, { + foo: 'foo-1' + }); + + shallowRenderer.render(); + + const component = shallowRenderer.getRenderOutput(); + + expect(typeof component).to.equal('object'); + }); + }); + }); + context('rendering element', () => { + beforeEach(() => { + global.document = jsdom.jsdom(''); + + global.window = document.defaultView; + }); + context('parent component is using react-css-modules and interpolates props.children', () => { + // @see https://github.com/gajus/react-css-modules/issues/76 + it('unsets the styleName property', () => { + let Bar; + let Foo; + let subject; + + Foo = class extends React.Component { + render () { + return +
    foo
    +
    ; + } + }; + + Foo = reactCssModules(Foo, { + test: 'foo-0' + }); + + Bar = class extends React.Component { + render () { + return
    {this.props.children}
    ; + } + }; + + Bar = reactCssModules(Bar, { + test: 'bar-0' + }); + + subject = TestUtils.renderIntoDocument(); + + // eslint-disable-next-line react/no-find-dom-node + subject = ReactDOM.findDOMNode(subject); + + expect(subject.firstChild.className).to.equal('foo-0'); + }); + }); + }); +}); diff --git a/tests/renderNothing.js b/tests/renderNothing.js new file mode 100644 index 0000000..7a666e8 --- /dev/null +++ b/tests/renderNothing.js @@ -0,0 +1,16 @@ +import { + expect +} from 'chai'; +import renderNothing from '../src/renderNothing'; + +describe('renderNothing', () => { + context('renderNothing should return different node types for various React versions', () => { + it('should return noscript tag for React v14 or lower', () => { + expect(renderNothing('14.0.0').type).to.equal('noscript'); + }); + + it('should return null for React v15 or higher', () => { + expect(renderNothing('15.0.0')).to.equal(null); + }); + }); +}); diff --git a/tests/wrapStatelessFunction.js b/tests/wrapStatelessFunction.js new file mode 100644 index 0000000..9c71935 --- /dev/null +++ b/tests/wrapStatelessFunction.js @@ -0,0 +1,92 @@ +/* eslint-disable max-nested-callbacks */ + +import { + expect +} from 'chai'; +import React from 'react'; +import TestUtils from 'react-addons-test-utils'; +import wrapStatelessFunction from './../src/wrapStatelessFunction'; + +describe('wrapStatelessFunction', () => { + it('hoists static own properties from the input component to the wrapped component', () => { + const styles = { + foo: 'foo-1' + }; + + const InnerComponent = () => { + return null; + }; + + InnerComponent.propTypes = {}; + InnerComponent.defaultProps = {}; + + const WrappedComponent = wrapStatelessFunction(InnerComponent, styles); + + expect(WrappedComponent.propTypes).to.equal(InnerComponent.propTypes); + expect(WrappedComponent.defaultProps).to.equal(InnerComponent.defaultProps); + expect(WrappedComponent.name).not.to.equal(InnerComponent.name); + }); + context('using default styles', () => { + it('exposes styles through styles property', (done) => { + const styles = { + foo: 'foo-1' + }; + + wrapStatelessFunction((props) => { + expect(props.styles).to.equal(styles); + done(); + }, styles)(); + }); + it('exposes non-enumerable styles property', (done) => { + const styles = { + foo: 'foo-1' + }; + + wrapStatelessFunction((props) => { + expect(props.propertyIsEnumerable('styles')).to.equal(false); + done(); + }, styles)(); + }); + it('does not affect the other instance properties', (done) => { + const styles = { + foo: 'foo-1' + }; + + wrapStatelessFunction((props) => { + expect(props.bar).to.equal('baz'); + done(); + }, styles)({ + bar: 'baz' + }); + }); + }); + context('using explicit styles', () => { + it('exposes styles through styles property', (done) => { + const styles = { + foo: 'foo-1' + }; + + wrapStatelessFunction((props) => { + expect(props.styles).to.equal(styles); + done(); + })({ + styles + }); + }); + }); + context('rendering Component that returns null', () => { + it('generates null', () => { + const shallowRenderer = TestUtils.createRenderer(); + + const Component = wrapStatelessFunction(() => { + return null; + }); + + shallowRenderer.render(); + + const component = shallowRenderer.getRenderOutput(); + + expect(component).to.equal(null); + }); + }); +});