diff --git a/.babelrc b/.babelrc
index 3ca8c4d..6a415f8 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,21 +1,7 @@
{
"ignore": ["node_modules/**"],
"env": {
- "development": {
- "presets": [
- [
- "env",
- {
- "targets": {
- "node": 8
- },
- "debug": false,
- "useBuiltIns": true,
- "modules": "commonjs"
- }
- ]
- ]
- },
+ "development": { "presets": [["env"]] },
"production": {
"presets": [
[
diff --git a/README.md b/README.md
index 7500bd8..18fdd53 100644
--- a/README.md
+++ b/README.md
@@ -101,16 +101,43 @@ const MyComponent = ({ option, ...rest }) =>
## CSS Modules
-`classier-react` supports using CSS Modules through dynamic object keys:
+`classier-react` can wrap CSS Modules with `elementModule` and `boxedModule`.
+
+`boxedModule` creates an Element that checks the provided module for a mapping after transformation:
```jsx
+import { createModuleElement } from 'classier-react'
import styles from './styles.css'
+const MBox = boxedModule(styles)
+
...
-
+
+```
+
+`elementModule` will expose the optionally listed blocks as Elements from the module:
+
+```jsx
+import { createModuleElement } from 'classier-react'
+import styles from './styles.css'
+
+const Block = elementModule(styles, ['element'])
+
+
+...
+
+
+```
+
+## Passing down styles
+
+`` only injects the props it merges, it can't make sure they are rendered. If you're wrapping or writing a component, it's a good idea to pass the `style` and `className` props onward to what is rendered.
+
+Most components are already written this way.
+
+```jsx
+const MyComponent = ({ option, ...rest }) =>
```
## Avoiding mixing domains
@@ -127,8 +154,10 @@ const containerClassName = cx(props.styled)
## API
+### React
+
```js
-import { Box, Text, Comp, cx, configure } from 'classier-react'
+import { Box, Text, Comp, createElement } from 'classier-react'
```
---
@@ -151,7 +180,51 @@ Renders a span with all its props translated to CSS classes.
### ``
-A _HOC_ for injecting or "composing" style props. Merges its `style`, `className`, and the rest of its props as classes into the props of a child component.
+A declarative wrapper for injecting or "composing" style props. Merges its `style`, `className`, and the rest of its props as classes into the props of a child component.
+
+---
+
+### `createElement(name)`
+
+Returns a customized `Box` that nests its CSS classes under the {name} block.
+
+#### members
+
+- **Comp** - a `Comp` with the same customization.
+
+---
+
+### CSS Modules
+
+```js
+import { boxedModule, createModuleElement, elementModule } from 'classier-react'
+```
+
+---
+
+### `boxedModule(module)`
+
+Returns an `Element` that tries to map its CSS classes to the ones in the provided module.
+
+---
+
+### `createModuleElement(module, name)`
+
+Returns an `Element` that tries to map its CSS classes to the ones in the provided module under the {name} block.
+
+---
+
+### `elementModule(module, pick)`
+
+Returns an object that picks selectors of `module` to `createModuleElement`.
+
+---
+
+### Utility
+
+```js
+import { cx, configure } from 'classier-react'
+```
---
@@ -167,7 +240,9 @@ Lets you change the global behavior of `cx`
#### opts
-- **kebabCase** - Transform names and values from camelCase. Reverses `style-loader`. (_default: true_)
+- **transformFn** - A function mapping an AST Node to a CSS class name. (_default: mappers.tailwind_)
+
+- **kebabCase** - Transform names and values from camelCase. You might want to turn it off with `postcss-modules`. (_default: true_)
- **keepSentence** - When kebabing, lower-case everything but the first word (_default: true_)
diff --git a/src/components/Box.js b/src/components/Box.js
index 7980d7e..1e68c4e 100644
--- a/src/components/Box.js
+++ b/src/components/Box.js
@@ -1,29 +1,35 @@
import React from 'react'
-import cx from '../transform'
+import { classnames } from '../transform'
/*
Creates an element that uses its unknown props as css class names.
Defaults to a div.
*/
-export default function Box({
- style,
- className,
- onClick,
- title,
- children,
- is = 'div',
- ...propClasses
-}) {
- const classes = cx(propClasses, className)
-
- const props = {
+export const factory = mapper =>
+ function Box({
title,
style,
onClick,
+ className,
children,
- className: classes,
- role: onClick ? 'button' : undefined
+ is = 'div',
+ ...propClasses
+ }) {
+ const classes = mapper(propClasses, className)
+ const Tag = is
+
+ const props = {
+ title,
+ style,
+ onClick,
+ children,
+ className: classes,
+ role: onClick ? 'button' : undefined
+ }
+
+ return
}
- return React.createElement(is, props)
-}
+export const Box = factory(classnames)
+
+export default Box
diff --git a/src/components/Comp.js b/src/components/Comp.js
index b5f17ef..b4e24e0 100644
--- a/src/components/Comp.js
+++ b/src/components/Comp.js
@@ -1,19 +1,24 @@
import React from 'react'
-import cx from '../transform'
+import { classnames } from '../transform'
/*
A wrapper that injects its children with style and className
using shallow merging and classNameTransform
*/
-export default function Comp({ style, className, children, ...propClasses }) {
- return React.Children.map(
- children,
- child =>
- child
- ? React.cloneElement(child, {
- style: { ...style, ...child.props.style },
- className: cx(propClasses, className, child.props.className)
- })
- : null
- )
-}
+export const factory = mapper =>
+ function Comp({ style, className, children, ...propClasses }) {
+ return React.Children.map(
+ children,
+ child =>
+ child
+ ? React.cloneElement(child, {
+ style: { ...style, ...child.props.style },
+ className: mapper(propClasses, className, child.props.className)
+ })
+ : null
+ )
+ }
+
+export const Comp = factory(classnames)
+
+export default Comp
diff --git a/src/components/Text.js b/src/components/Text.js
index e9960f3..f1558d5 100644
--- a/src/components/Text.js
+++ b/src/components/Text.js
@@ -1,9 +1,16 @@
import React from 'react'
-import cx from '../transform'
+import { classnames } from '../transform'
/*
Creates a span element that uses its unknown props as css class names.
*/
-export default function Text({ className, children, ...propClasses }) {
- return
-}
+export const factory = mapper =>
+ function Text({ className, children, ...propClasses }) {
+ return (
+
+ )
+ }
+
+export const Text = factory(classnames)
+
+export default Text
diff --git a/src/config.js b/src/config.js
index 911f10b..6b0b6d7 100644
--- a/src/config.js
+++ b/src/config.js
@@ -1,4 +1,18 @@
-export const config = {
+import tailwind from './mappers/tailwind'
+
+export const config = {}
+
+export function configure(opts) {
+ Object.assign(config, {
+ ...opts,
+ mapFn: opts.mapFn ? opts.mapFn(config) : config.mapFn,
+ join: { ...config.join, ...opts.join }
+ })
+}
+
+// Default configuration
+configure({
+ mapFn: tailwind,
keepSentence: true,
kebabCase: true,
join: {
@@ -8,11 +22,6 @@ export const config = {
value: '-',
words: '-'
}
-}
+})
-export function configure (opts) {
- Object.assign(config, {
- ...opts,
- join: { ...config.join, ...opts.join }
- })
-}
+export default config
diff --git a/src/index.js b/src/index.js
index de59460..25b1dcc 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,3 +1,4 @@
-export { default as cx } from './transform'
-export * from './components'
export * from './config'
+export * from './modules'
+export * from './transform'
+export * from './components'
diff --git a/src/mappers/tailwind.js b/src/mappers/tailwind.js
new file mode 100644
index 0000000..63b10c9
--- /dev/null
+++ b/src/mappers/tailwind.js
@@ -0,0 +1,10 @@
+export default function tailwind(config) {
+ return ({ variant, block, modifier, value }) =>
+ (variant || '') +
+ ((variant && config.join.variant) || '') +
+ (block || '') +
+ ((block && modifier && config.join.modifier) || '') +
+ (modifier || '') +
+ ((value && config.join.value) || '') +
+ (value || '')
+}
diff --git a/src/modules.js b/src/modules.js
new file mode 100644
index 0000000..d3f6204
--- /dev/null
+++ b/src/modules.js
@@ -0,0 +1,26 @@
+import { classnamesWithBase, classnamesWithMapper } from './transform'
+import { factory as makeBox } from './components/Box'
+import { factory as makeComp } from './components/Comp'
+
+export const makeElement = mapper => {
+ const El = makeBox(mapper)
+ El.Comp = makeComp(mapper)
+ return El
+}
+
+export const createElement = block => makeElement(classnamesWithBase({ block }))
+
+export const createModuleElement = (module, key) =>
+ makeElement(
+ classnamesWithMapper(name => module[name] || name, { block: key })
+ )
+
+export const boxedModule = module =>
+ makeElement(classnamesWithMapper(name => module[name] || name))
+
+export const elementModule = (module, pick) =>
+ (pick || Object.keys(module)).reduce((res, key) => {
+ const capped = key.charAt(0).toUpperCase() + key.substring(1)
+ res[capped] = createModuleElement(module, key)
+ return res
+ }, {})
diff --git a/src/syntax.js b/src/syntax.js
new file mode 100644
index 0000000..c52b650
--- /dev/null
+++ b/src/syntax.js
@@ -0,0 +1,86 @@
+import config from './config'
+
+function mapFlat(array, fn) {
+ const result = []
+ for (const value of array) {
+ const out = fn(value)
+ if (Array.isArray(out)) {
+ result.push(...out)
+ } else {
+ result[result.length] = out
+ }
+ }
+ return result
+}
+
+function kebab(camel, sentenceCase) {
+ // Might need to customize the separator
+ // This is aZ | aXYZ
+ return camel
+ .toString()
+ .match(/[A-Z]?[^A-Z]+|([A-Z](?![^A-Z]))+/g)
+ .reduce(
+ (result, word, index) =>
+ result +
+ (index //not first
+ ? config.join.words + word.toLowerCase()
+ : sentenceCase
+ ? word
+ : word.toLowerCase()),
+ ''
+ )
+}
+
+const getNodes = (value, env) => {
+ if (Array.isArray(value)) {
+ return mapFlat(value, inner => getNodes(inner, env))
+ }
+
+ if (typeof value === 'object') {
+ return mapFlat(
+ Object.keys(value),
+ variant => getNodes(value[variant], { variant, ...env }) //overwrite with existing
+ )
+ }
+
+ return {
+ ...env,
+ value:
+ value !== true ? (config.kebabCase ? kebab(value) : value) : undefined
+ }
+}
+
+/**
+ * Build a list of AST nodes from props and env
+ * pass it to mapper
+ * join the parts to one string
+ */
+export const mapAstToString = (mapper, env) => (props, ...extraClassNames) => {
+ const list = []
+
+ if (env) {
+ list.push(env)
+ }
+
+ for (const name of Object.keys(props)) {
+ if (props[name] !== false) {
+ const nodes = getNodes(props[name], {
+ ...env,
+ modifier: config.kebabCase ? kebab(name, config.keepSentence) : name
+ })
+ if (Array.isArray(nodes)) {
+ list.push(...nodes)
+ } else {
+ list.push(nodes)
+ }
+ }
+ }
+
+ for (const i in list) {
+ list[i] = mapper(list[i])
+ }
+
+ list.push(...extraClassNames.filter(i => i))
+
+ return list.join(' ')
+}
diff --git a/src/transform.js b/src/transform.js
index ffd42d8..b87f631 100644
--- a/src/transform.js
+++ b/src/transform.js
@@ -1,59 +1,17 @@
-import { config } from './config'
+import config from './config'
+import { mapAstToString } from './syntax'
-function kebab(camel, sentenceCase) {
- // Might need to customize the separator
- // This is aZ | aXYZ
- return camel
- .toString()
- .match(/[A-Z]?[^A-Z]+|([A-Z](?![^A-Z]))+/g)
- .reduce(
- (result, word, index) =>
- result +
- (index //not first
- ? config.join.words + word.toLowerCase()
- : sentenceCase
- ? word
- : word.toLowerCase()),
- ''
- )
-}
+export const classnamesWithMapper = (mapper, base) =>
+ mapAstToString(node => mapper(config.mapFn(node)), base)
-function toStyleName(modifier, value) {
- let tm = config.kebabCase ? kebab(modifier, config.keepSentence) : modifier
+export const classnamesWithBase = base => mapAstToString(config.mapFn, base)
- if (value === true) {
- return tm
- }
+export const classnames = classnamesWithBase(undefined)
- let tv = config.kebabCase ? kebab(value) : value
+export const cx = classnames
- return `${tm}${config.join.value}${tv}`
-}
+export const cxb = classnamesWithBase
-function toClassNames(name, value) {
- if (Array.isArray(value)) {
- return value.map(inner => toClassNames(name, inner))
- }
-
- if (typeof value === 'object') {
- return Object.keys(value).map(variant =>
- toClassNames(`${variant}${config.join.variant}${name}`, value[variant])
- )
- }
-
- return toStyleName(name, value)
-}
-
-function cx(props, ...extraClassNames) {
- return Object.keys(props)
- .reduce(
- (classes, name) =>
- props[name] !== false
- ? classes.concat(toClassNames(name, props[name]))
- : classes,
- extraClassNames
- )
- .join(' ')
-}
+export const cxm = classnamesWithMapper
export default cx
diff --git a/test/transform.test.js b/test/classnames.test.js
similarity index 72%
rename from test/transform.test.js
rename to test/classnames.test.js
index 1ad44dd..408f149 100644
--- a/test/transform.test.js
+++ b/test/classnames.test.js
@@ -1,8 +1,8 @@
-import { configure, cx } from '../src'
+import { classnames, classnamesWithBase } from '../src'
-describe('cx:', () => {
+describe('classnames:', () => {
test('should transform propClasses with booleans', () => {
- const res = cx({
+ const res = classnames({
chicken: true,
dinner: true
})
@@ -11,7 +11,7 @@ describe('cx:', () => {
})
test('should transform propClasses with normal values', () => {
- const res = cx({
+ const res = classnames({
chicken: 'dinner',
sum: 41
})
@@ -20,7 +20,7 @@ describe('cx:', () => {
})
test('should transform propClasses with arrays', () => {
- const res = cx({
+ const res = classnames({
chicken: ['tasty', 'dinner']
})
@@ -28,7 +28,7 @@ describe('cx:', () => {
})
test('should transform propClasses with variants', () => {
- const res = cx({
+ const res = classnames({
chicken: ['dinner', { morning: 'breakfast' }]
})
@@ -36,20 +36,29 @@ describe('cx:', () => {
})
test('should transform propClasses with CamelCase names', () => {
- const res = cx({
+ const res = classnames({
CamelHumps: 'LovelyCamelHumps'
})
expect(res).toEqual('Camel-humps-lovely-camel-humps')
})
+
+ test('classnamesWithBase', () => {
+ const res = classnamesWithBase({ block: 'wakka' })({
+ chicken: 'dinner',
+ sum: 41
+ })
+
+ expect(res).toEqual('wakka wakka-chicken-dinner wakka-sum-41')
+ })
})
describe('configure:', () => {
- let configure, cx
+ let configure, classnames
beforeEach(() => {
jest.resetModules()
const lib = require('../src')
- cx = lib.cx
+ classnames = lib.classnames
configure = lib.configure
})
@@ -59,7 +68,7 @@ describe('configure:', () => {
value: '__'
}
})
- const res = cx({
+ const res = classnames({
George: 'Foreman'
})
@@ -70,7 +79,7 @@ describe('configure:', () => {
configure({
keepSentence: false
})
- const res = cx({
+ const res = classnames({
George: 'Foreman'
})
@@ -81,7 +90,7 @@ describe('configure:', () => {
configure({
kebabCase: false
})
- const res = cx({
+ const res = classnames({
GrillinMachine: 'LeanMean'
})
diff --git a/test/components.test.js b/test/components.test.js
deleted file mode 100644
index ed0b893..0000000
--- a/test/components.test.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from 'react'
-import { shallow } from 'enzyme'
-
-import { Box, Comp } from '../src'
-
-describe('Box:', () => {
- test('should render with class name', () => {
- const wrapper = shallow()
- expect(wrapper.hasClass('pass-test')).toBe(true)
- })
-
- test('should render as span with class name', () => {
- const wrapper = shallow()
- expect(wrapper.hasClass('pass-test')).toBe(true)
- expect(wrapper.type()).toBe('span')
- })
-
- test('should render with prop values in class names', () => {
- const wrapper = shallow()
- expect(wrapper.hasClass('text-red')).toBe(true)
- expect(wrapper.hasClass('py-5')).toBe(true)
- })
-
- test('should ignore onClick', () => {
- const onClick = jest.fn()
- const wrapper = shallow()
- wrapper.simulate('click')
- expect(onClick.mock.calls.length).toBe(1)
- })
-})
-
-describe('Comp:', () => {
- test('should add parametric class names', () => {
- const wrapper = shallow(} />)
- expect(wrapper.type()).toBe('div')
- expect(wrapper.hasClass('text-red')).toBe(true)
- expect(wrapper.hasClass('py-5')).toBe(true)
- })
-
- test('should merge classNames', () => {
- const wrapper = shallow(
- } />
- )
- expect(wrapper.type()).toBe('div')
- expect(wrapper.hasClass('inner-test')).toBe(true)
- expect(wrapper.hasClass('pass-test')).toBe(true)
- })
-
- test('should merge style', () => {
- const wrapper = shallow(
- }
- />
- )
- expect(wrapper.type()).toBe('div')
- expect(wrapper.prop('style')).toEqual({ color: 'red', background: 'blue' })
- })
-})
diff --git a/test/globalStyles.test.js b/test/globalStyles.test.js
new file mode 100644
index 0000000..19e6b11
--- /dev/null
+++ b/test/globalStyles.test.js
@@ -0,0 +1,91 @@
+import React from 'react'
+import { shallow } from 'enzyme'
+
+import { Text, Box, Comp, createElement } from '../src'
+
+describe('Text:', () => {
+ test('should render with class name', () => {
+ const wrapper = shallow()
+ expect(wrapper.prop('className')).toEqual('props-test')
+ expect(wrapper.text()).toBe('test')
+ })
+
+ test('should render with prop values in class names', () => {
+ const wrapper = shallow()
+ expect(wrapper.prop('className')).toEqual('text-red py-5')
+ expect(wrapper.text()).toBe('test')
+ })
+})
+
+describe('Box:', () => {
+ test('should render with class name', () => {
+ const wrapper = shallow()
+ expect(wrapper.prop('className')).toEqual('props-test')
+ })
+
+ test('should render as header with class name', () => {
+ const wrapper = shallow()
+ expect(wrapper.prop('className')).toEqual('props-test')
+ expect(wrapper.type()).toBe('h1')
+ })
+
+ test('should render with prop values in class names', () => {
+ const wrapper = shallow()
+ expect(wrapper.prop('className')).toEqual('text-red py-5')
+ })
+
+ test('should ignore onClick', () => {
+ const onClick = jest.fn()
+ const wrapper = shallow()
+ wrapper.simulate('click')
+ expect(onClick.mock.calls.length).toBe(1)
+ expect(wrapper.prop('className')).toEqual('text-red')
+ })
+})
+
+describe('Comp:', () => {
+ test('should add parametric class names', () => {
+ const wrapper = shallow(} />)
+ expect(wrapper.prop('className')).toEqual('text-red py-5')
+ expect(wrapper.type()).toBe('div')
+ })
+
+ test('should merge classNames', () => {
+ const wrapper = shallow(
+ } />
+ )
+ expect(wrapper.type()).toBe('div')
+ expect(wrapper.prop('className')).toEqual('props-test inner-test')
+ })
+
+ test('should merge style', () => {
+ const wrapper = shallow(
+ }
+ />
+ )
+ expect(wrapper.type()).toBe('div')
+ expect(wrapper.prop('style')).toEqual({ color: 'red', background: 'blue' })
+ })
+})
+
+describe('createElement', () => {
+ test('should create an element that renders with class name', () => {
+ const Tester = createElement('tester')
+ const wrapper = shallow()
+ expect(wrapper.prop('className')).toEqual('tester tester-props-test')
+ })
+
+ test('should create an element that composes with class name', () => {
+ const Tester = createElement('tester')
+ const wrapper = shallow(
+
+
+
+ )
+ expect(wrapper.prop('className')).toEqual(
+ 'tester tester-props-test inner-test'
+ )
+ })
+})
diff --git a/test/moduleStyles.test.js b/test/moduleStyles.test.js
new file mode 100644
index 0000000..e313d35
--- /dev/null
+++ b/test/moduleStyles.test.js
@@ -0,0 +1,26 @@
+import React from 'react'
+import { shallow } from 'enzyme'
+
+import { boxedModule, elementModule } from '../src'
+
+// Probably need to make this more complex
+const mockedModule = {
+ element: 'ocy2n5fe2w',
+ 'element-modifier-value': 'xxb7hghzte'
+}
+
+describe('boxedModule', () => {
+ test('should create an element that maps to module', () => {
+ const NSBox = boxedModule(mockedModule)
+ const wrapper = shallow()
+ expect(wrapper.prop('className')).toEqual('xxb7hghzte')
+ })
+})
+
+describe('elementModule', () => {
+ test('should create an object with an element that maps to module with a base block', () => {
+ const NS = elementModule(mockedModule, ['element'])
+ const wrapper = shallow()
+ expect(wrapper.prop('className')).toEqual('ocy2n5fe2w xxb7hghzte')
+ })
+})