Repository: tkh44/emotion
Pull request #48 (2017-06-29 03:26:35)

css prop
<!--
Thanks for your interest in the project. I appreciate bugs filed and PRs submitted!

Please make sure that you are familiar with and follow the Code of Conduct for
this project (found in the CODE_OF_CONDUCT.md file).

Also, please make sure you're familiar with and follow the instructions in the
contributing guidelines (found in the CONTRIBUTING.md file).

If you're new to contributing to open source projects, you might find this free
video course helpful: http://kcd.im/pull-request

Please fill out the information below to expedite the review and (hopefully)
merge of your pull request!
-->

<!-- What changes are being made? (What feature/bug is being fixed here?) -->
**What**:
Adding css prop

```
<p css={`color: red;`}>
  hello world
</p>
```
<!-- Why are these changes necessary? -->
**Why**:
dynamic styling

<!-- How were these changes implemented? -->
**How**:
adding a babel plugin.

<!-- Have you done all of these things?  -->
**Checklist**:
<!-- add "N/A" to the end of each line that's irrelevant to your changes -->
<!-- to check an item, place an "x" in the box like so: "- [x] Documentation" -->
- [ ] Documentation
- [x] Tests
- [x] Code complete

<!-- feel free to add additional comments -->


codecov-io: # [Codecov](https://codecov.io/gh/tkh44/emotion/pull/48?src=pr&el=h1) Report
> Merging [#48](https://codecov.io/gh/tkh44/emotion/pull/48?src=pr&el=desc) into [master](https://codecov.io/gh/tkh44/emotion/commit/1269cd6c5c0c3cf4f1b19d92b132c7bffec79ac7?src=pr&el=desc) will **increase** coverage by `0.7%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/tkh44/emotion/pull/48/graphs/tree.svg?src=pr&width=650&token=B1bWuYNE2t&height=150)](https://codecov.io/gh/tkh44/emotion/pull/48?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##           master      #48     +/-   ##
=========================================
+ Coverage   87.15%   87.86%   +0.7%     
=========================================
  Files          13       14      +1     
  Lines         514      544     +30     
  Branches      100      111     +11     
=========================================
+ Hits          448      478     +30     
  Misses         53       53             
  Partials       13       13
```


| [Impacted Files](https://codecov.io/gh/tkh44/emotion/pull/48?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/css-prop.js](https://codecov.io/gh/tkh44/emotion/pull/48?src=pr&el=tree#diff-c3JjL2Nzcy1wcm9wLmpz) | `100% <100%> (ø)` | |
| [src/babel.js](https://codecov.io/gh/tkh44/emotion/pull/48?src=pr&el=tree#diff-c3JjL2JhYmVsLmpz) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tkh44/emotion/pull/48?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tkh44/emotion/pull/48?src=pr&el=footer). Last update [1269cd6...e3204a8](https://codecov.io/gh/tkh44/emotion/pull/48?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/package.json b/package.json
index 3779e1b8..c143c2ae 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
     "format": "prettier-eslint --write \"src/**/*.js\" \"test/**/*.js\" \"example/**/*.js\" \"jest-utils/**/*.js\""
   },
   "dependencies": {
+    "babel-plugin-syntax-jsx": "^6.18.0",
     "styled-components": "2.0.0",
     "touch": "^1.0.0"
   },
diff --git a/src/babel.js b/src/babel.js
index a43e0055..ba84c117 100644
--- a/src/babel.js
+++ b/src/babel.js
@@ -3,6 +3,7 @@ import { basename } from 'path'
 import { touchSync } from 'touch'
 import { inline, keyframes, fontFace, injectGlobal } from './inline'
 import findAndReplaceAttrs from './attrs'
+import cssProps from './css-prop'
 
 function parseDynamicValues (rules, t) {
   return rules.map(rule => {
@@ -63,6 +64,7 @@ export default function (babel) {
 
   return {
     name: 'emotion', // not required
+    inherits: require('babel-plugin-syntax-jsx'),
     visitor: {
       Program: {
         enter (path, state) {
@@ -98,6 +100,9 @@ export default function (babel) {
           }
         }
       },
+      JSXOpeningElement (path, state) {
+        cssProps(path, t)
+      },
       TaggedTemplateExpression (path, state) {
         // in:
         // styled.h1`color:${color};`
@@ -111,8 +116,9 @@ export default function (babel) {
         // });
 
         const parent = path.findParent(p => p.isVariableDeclarator())
-        const identifierName =
-          parent && t.isIdentifier(parent.node.id) ? parent.node.id.name : ''
+        const identifierName = parent && t.isIdentifier(parent.node.id)
+          ? parent.node.id.name
+          : ''
 
         function buildCallExpression (identifier, tag, path) {
           const built = findAndReplaceAttrs(path, t)
diff --git a/src/css-prop.js b/src/css-prop.js
new file mode 100644
index 00000000..fc57c52c
--- /dev/null
+++ b/src/css-prop.js
@@ -0,0 +1,96 @@
+export default function (path, t) {
+  let cssPath
+  let classNamesPath
+
+  path.get('attributes').forEach(openElPath => {
+    if (t.isJSXSpreadAttribute(openElPath.node)) {
+      return
+    }
+
+    const attrPath = openElPath.get('name')
+    const name = attrPath.node.name
+
+    if (name === 'css') {
+      cssPath = attrPath
+    }
+
+    if (name === 'className') {
+      classNamesPath = attrPath
+    }
+  })
+
+  if (!cssPath) return
+
+  let cssPropValue = cssPath.container && cssPath.container.value
+  let classNamesValue =
+    classNamesPath && classNamesPath.container && classNamesPath.container.value
+
+  if (t.isJSXExpressionContainer(cssPropValue)) {
+    cssPropValue = cssPropValue.expression
+  }
+
+  let cssTemplateExpression
+  if (t.isTemplateLiteral(cssPropValue)) {
+    cssTemplateExpression = createCssTemplateExpression(cssPropValue)
+  } else if (t.isStringLiteral(cssPropValue)) {
+    cssTemplateExpression = createCssTemplateExpression(
+      t.templateLiteral(
+        [
+          t.templateElement({
+            raw: cssPropValue.value,
+            cooked: cssPropValue.value
+          })
+        ],
+        []
+      )
+    )
+  } else {
+    throw path.buildCodeFrameError(
+      `${cssPropValue.value} is not a string or template literal`
+    )
+  }
+
+  if (!classNamesValue) {
+    cssPath.parentPath.replaceWith(createClassNameAttr(cssTemplateExpression))
+    return
+  }
+
+  cssPath.parentPath.remove()
+  if (t.isJSXExpressionContainer(classNamesValue)) {
+    classNamesPath.parentPath.replaceWith(
+      createClassNameAttr(
+        add(
+          add(classNamesValue.expression, t.stringLiteral(' ')),
+          cssTemplateExpression
+        )
+      )
+    )
+  } else {
+    classNamesPath.parentPath.replaceWith(
+      createClassNameAttr(
+        add(
+          add(
+            t.stringLiteral(classNamesValue.value || ''),
+            t.stringLiteral(' ')
+          ),
+          cssTemplateExpression
+        )
+      )
+    )
+  }
+
+  function add (a, b) {
+    return t.binaryExpression('+', a, b)
+  }
+
+  function createClassNameAttr (expression) {
+    return t.jSXAttribute(
+      t.jSXIdentifier('className'),
+      t.JSXExpressionContainer(expression)
+    )
+  }
+
+  function createCssTemplateExpression (templateLiteral) {
+    return t.taggedTemplateExpression(t.identifier('css'), templateLiteral)
+  }
+}


Test patch:
diff --git a/test/__snapshots__/css-prop.test.js.snap b/test/__snapshots__/css-prop.test.js.snap
new file mode 100644
index 00000000..f74d2bdc
--- /dev/null
+++ b/test/__snapshots__/css-prop.test.js.snap
@@ -0,0 +1,104 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`css prop react basic 1`] = `
+.css-tree-7sri7n-1yz7ela {
+  color: red;
+  font-size: 1px;
+}
+
+<p
+  className="css-tree-7sri7n-1yz7ela"
+>
+  hello world
+</p>
+`;
+
+exports[`css prop react kitchen sink 1`] = `
+.css-tree-1170hdn-140nofh {
+  display: -webkit-box;
+  display: -moz-box;
+  display: -ms-flexbox;
+  display: -webkit-flex;
+  display: flex;
+  font-weight: bold;
+  display: -webkit-box;
+  display: -moz-box;
+  display: -ms-flexbox;
+  display: -webkit-flex;
+  display: flex;
+  -webkit-justify-content: center;
+  -ms-flex-pack: center;
+  -webkit-box-pack: center;
+  justify-content: center;
+  -webkit-align-items: center;
+  -ms-flex-align: center;
+  -webkit-box-align: center;
+  align-items: center;
+}
+
+.css-tree-wjhr0q-2fv2qw {
+  font-size: 6;
+  color: red;
+}
+
+.css-tree-14jcta0-1fj87hp {
+  color: blue;
+}
+
+.css-tree-1gdxe9e-1vltlgj {
+  display: -webkit-box;
+  display: -moz-box;
+  display: -ms-inline-flexbox;
+  display: -webkit-inline-flex;
+  display: inline-flex;
+}
+
+.css-tree-1g8ltio-i3faqz {
+  color: red;
+  border-radius: 5;
+}
+
+.css-tree-1g8ltio-i3faqz:hover {
+  font-weight: bold;
+  color: gray;
+}
+
+<div
+  className="css__legacy-stuff css-tree-1170hdn-140nofh"
+>
+  <h1
+    className="css-tree-wjhr0q-2fv2qw"
+  >
+    BOOM
+  </h1>
+  <p
+    className="test_class1 css-tree-14jcta0-1fj87hp"
+  >
+    Hello
+  </p>
+  <p
+    className="test_class1 test___class45 css-tree-1gdxe9e-1vltlgj"
+  >
+    World
+  </p>
+  <p
+    className="css-tree-1g8ltio-i3faqz"
+  >
+    hello world
+  </p>
+</div>
+`;
+
+exports[`css prop react string expression 1`] = `
+.css-tree-7sqgip-8upfq4 {
+  color: red;
+  background: blue;
+  font-size: 48px;
+}
+
+<p
+  className="css-tree-7sqgip-8upfq4"
+>
+  hello world
+</p>
+`;
diff --git a/test/babel/__snapshots__/css-prop.test.js.snap b/test/babel/__snapshots__/css-prop.test.js.snap
new file mode 100644
index 00000000..41e11d30
--- /dev/null
+++ b/test/babel/__snapshots__/css-prop.test.js.snap
@@ -0,0 +1,64 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`babel css prop StringLiteral css prop value 1`] = `
+"<div className={css(\\"css-jf1v9l\\", [], function createEmotionRules() {
+  return [\`.css-jf1v9l { color: brown; }\`];
+})}></div>;"
+`;
+
+exports[`babel css prop basic 1`] = `
+"import \\"./css-prop.test.emotion.css\\";
+<div className={\\"a\\" + \\" \\" + css(\\"css-jf1v9l\\", [])}></div>;"
+`;
+
+exports[`babel css prop basic 2`] = `".css-jf1v9l { color: brown; }"`;
+
+exports[`babel css prop basic inline 1`] = `
+"<div className={\\"a\\" + \\" \\" + css(\\"css-jf1v9l\\", [], function createEmotionRules() {
+  return [\`.css-jf1v9l { color: brown; }\`];
+})}></div>;"
+`;
+
+exports[`babel css prop className as expression 1`] = `
+"<div className={variable + \\" \\" + css(\\"css-jf1v9l\\", [], function createEmotionRules() {
+  return [\`.css-jf1v9l { color: brown; }\`];
+})}></div>;"
+`;
+
+exports[`babel css prop className as expression string 1`] = `
+"<div className={\`test__class\` + \\" \\" + css(\\"css-jf1v9l\\", [], function createEmotionRules() {
+  return [\`.css-jf1v9l { color: brown; }\`];
+})} this={\`hello\`}></div>;"
+`;
+
+exports[`babel css prop css empty 1`] = `
+"<div className={css(\\"css-0\\", [], function createEmotionRules() {
+  return [\`.css-0 {  }\`];
+})}></div>;"
+`;
+
+exports[`babel css prop dynamic inline 1`] = `
+"<div className={\\"a\\" + \\" \\" + css(\\"css-jsqoaa\\", [color], function createEmotionRules(x0) {
+  return [\`.css-jsqoaa { color: \${x0}; }\`];
+})}></div>;"
+`;
+
+exports[`babel css prop emptyClassName 1`] = `
+"<div className={\\"\\" + \\" \\" + css(\\"css-jf1v9l\\", [], function createEmotionRules() {
+  return [\`.css-jf1v9l { color: brown; }\`];
+})}></div>;"
+`;
+
+exports[`babel css prop no css attr 1`] = `"<div></div>;"`;
+
+exports[`babel css prop noClassName 1`] = `
+"<div className={css(\\"css-jf1v9l\\", [], function createEmotionRules() {
+  return [\`.css-jf1v9l { color: brown; }\`];
+})}></div>;"
+`;
+
+exports[`babel css prop with spread arg in jsx opening tag 1`] = `
+"<div className={\\"a\\" + \\" \\" + css(\\"css-jf1v9l\\", [], function createEmotionRules() {
+  return [\`.css-jf1v9l { color: brown; }\`];
+})} {...rest}></div>;"
+`;
diff --git a/test/babel/css-prop.test.js b/test/babel/css-prop.test.js
new file mode 100644
index 00000000..d6144e29
--- /dev/null
+++ b/test/babel/css-prop.test.js
@@ -0,0 +1,112 @@
+/* eslint-env jest */
+import * as babel from 'babel-core'
+import plugin from '../../src/babel'
+import * as fs from 'fs'
+
+jest.mock('fs')
+
+fs.existsSync.mockReturnValue(true)
+
+describe('babel css prop', () => {
+  test('basic', () => {
+    const basic = '(<div className="a" css={`color: brown;`}></div>)'
+    const { code } = babel.transform(basic, {
+      plugins: [plugin],
+      filename: __filename,
+      babelrc: false
+    })
+    expect(code).toMatchSnapshot()
+    expect(fs.writeFileSync).toHaveBeenCalledTimes(1)
+    expect(fs.writeFileSync.mock.calls[0][1]).toMatchSnapshot()
+  })
+
+  test('basic inline', () => {
+    const basic = '(<div className="a" css={`color: brown;`}></div>)'
+    const { code } = babel.transform(basic, {
+      plugins: [[plugin, { inline: true }]]
+    })
+    expect(code).toMatchSnapshot()
+  })
+
+  test('dynamic inline', () => {
+    const basic = `(<div className="a" css={\`color: $\{color};\`}></div>)`
+    const { code } = babel.transform(basic, {
+      plugins: [[plugin, { inline: true }]]
+    })
+    expect(code).toMatchSnapshot()
+  })
+
+  test('no css attr', () => {
+    const basic = '(<div></div>)'
+    const { code } = babel.transform(basic, {
+      plugins: [plugin]
+    })
+    expect(code).toMatchSnapshot()
+  })
+
+  test('with spread arg in jsx opening tag', () => {
+    const basic = '(<div className="a" css={`color: brown;`} {...rest}></div>)'
+    const { code } = babel.transform(basic, {
+      plugins: [[plugin, { inline: true }]]
+    })
+    expect(code).toMatchSnapshot()
+  })
+
+  test('css empty', () => {
+    const basic = '(<div css=""></div>)'
+    const { code } = babel.transform(basic, {
+      plugins: [plugin]
+    })
+    expect(code).toMatchSnapshot()
+  })
+
+  test('wrong value type', () => {
+    const basic = '(<div css={5}></div>)'
+    expect(() =>
+      babel.transform(basic, {
+        plugins: [[plugin, { inline: true }]]
+      })
+    ).toThrow()
+  })
+
+  test('StringLiteral css prop value', () => {
+    const basic = `<div css="color: brown;"></div>`
+    const { code } = babel.transform(basic, {
+      plugins: [plugin]
+    })
+    expect(code).toMatchSnapshot()
+  })
+
+  test('noClassName', () => {
+    const basic = '(<div css={`color: brown;`}></div>)'
+    const { code } = babel.transform(basic, {
+      plugins: [plugin]
+    })
+    expect(code).toMatchSnapshot()
+  })
+
+  test('emptyClassName', () => {
+    const basic = '(<div className="" css={`color: brown;`}></div>)'
+    const { code } = babel.transform(basic, {
+      plugins: [plugin]
+    })
+    expect(code).toMatchSnapshot()
+  })
+
+  test('className as expression', () => {
+    const basic = '(<div className={variable} css={`color: brown;`}></div>)'
+    const { code } = babel.transform(basic, {
+      plugins: [plugin]
+    })
+    expect(code).toMatchSnapshot()
+  })
+
+  test('className as expression string', () => {
+    const basic =
+      '(<div className={`test__class`} css={`color: brown;`} this={`hello`}></div>)'
+    const { code } = babel.transform(basic, {
+      plugins: [plugin]
+    })
+    expect(code).toMatchSnapshot()
+  })
+})
diff --git a/test/css-prop.test.js b/test/css-prop.test.js
new file mode 100644
index 00000000..95bb2504
--- /dev/null
+++ b/test/css-prop.test.js
@@ -0,0 +1,103 @@
+/* eslint-disable jsx-quotes,no-useless-escape,no-template-curly-in-string */
+/* eslint-env jest */
+import React from 'react'
+import renderer from 'react-test-renderer'
+import { matcher, serializer } from '../jest-utils'
+
+// eslint-disable-next-line no-unused-vars
+import { css, fragment } from '../src/index'
+
+expect.addSnapshotSerializer(serializer)
+expect.extend(matcher)
+
+describe('css prop react', () => {
+  test('basic', () => {
+    const fontSize = '1px'
+    const tree = renderer
+      .create(
+        <p css={`color: red;font-size:${fontSize}`}>
+          hello world
+        </p>
+      )
+      .toJSON()
+
+    expect(tree).toMatchSnapshotWithEmotion()
+  })
+
+  test('string expression', () => {
+    const tree = renderer
+      .create(
+        <p css="color:red;background:blue;font-size:48px;">
+          hello world
+        </p>
+      )
+      .toJSON()
+
+    expect(tree).toMatchSnapshotWithEmotion()
+  })
+
+  test('kitchen sink', () => {
+    const props = { online: false, error: false, radius: 5 }
+    const huge = 100
+    const tiny = 6
+
+    const bold = fragment`
+        display: flex;
+        font-weight: bold;`
+
+    const big = fragment`
+        @apply ${bold};
+        font-size: ${huge}`
+
+    const small = fragment`
+        font-size: ${tiny}`
+
+    const flexCenter = fragment`
+        display: flex;
+        justify-content: center;
+        align-items: center`
+
+    const tree = renderer
+      .create(
+        <div
+          className="css__legacy-stuff"
+          css={`
+              @apply ${bold}
+              @apply ${flexCenter};
+             `}
+        >
+          <h1
+            css={`
+                @apply ${props.error ? big : small};
+                color: red
+              `}
+          >
+            BOOM
+          </h1>
+          <p className="test_class1" css={`color: blue;`}>Hello</p>
+          <p
+            className="test_class1 test___class45"
+            css={`display: inline-flex`}
+          >
+            World
+          </p>
+          <p
+            css={`
+                color: red;
+                border-radius: ${props.radius};
+                &:hover {
+                  font-weight: bold;
+                  color: ${props.online ? 'green' : 'gray'};
+                }
+              `}
+          >
+            hello world
+          </p>
+
+        </div>
+      )
+      .toJSON()
+
+    expect(tree).toMatchSnapshotWithEmotion()
+  })
+})