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') + }) +})