Skip to content

Commit

Permalink
Refactor Themed component + add support for ad-hoc theme composition
Browse files Browse the repository at this point in the history
  • Loading branch information
aribouius committed Apr 7, 2017
1 parent fc49786 commit 4970baf
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 35 deletions.
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,17 @@ const theme = {

**Step 2.** Use the `ThemeProvider` component to make the theme available via context.
```javascript
import { render } from 'react-dom'
import { ThemeProvider } from 'react-themed'
import MyRootComponent from './MyRootComponent'
import theme from './theme'

render(
const App = (props) => (
<ThemeProvider theme={theme}>
<MyRootComponent />
</ThemeProvider>,
document.getElementById('root')
{props.children}
</ThemeProvider>
)
```

**Step 3.** Create a component that defines a theme interface, and generate a _themed_ version of it by using the `themed` decorator to select which part(s) of the context theme should be provided as a prop.
**Step 3.** Create a component that defines a theme interface, and export a _themed_ version of it by using the `themed` decorator to select which part(s) of the context theme should be provided as a prop.
```javascript
import React, { Component, PropTypes } from 'react'
import { themed } from 'react-themed'
Expand Down Expand Up @@ -75,16 +72,25 @@ export const ThemedButton = themed(theme => theme.Button)(Button)

## API
### `<ThemeProvider theme [compose]>`
Adds a theme to the context of a component tree, making it available to `themed()` calls. Optionally composes provided theme with theme(s) already added to the context by a separate `ThemeProvider` higher up.
Adds a theme to the context of a component tree, making it available to `themed()` calls. Optionally composes provided theme with theme(s) already added to the context by a separate `ThemeProvider` higher up the tree. *Note:* This also gets exported under a `Theme` alias.

### `themed([theme], [options])`
Creates a new [HOC](https://facebook.github.io/react/docs/higher-order-components.html) that returns a _themed_ component.
Creates a new [HOC](https://facebook.github.io/react/docs/higher-order-components.html) that returns a `Themed` component.

- [`identifier|selector(theme):theme`] \(*String|Function*): A string identifier, or selector function, used to pluck out parts of the context theme that should be provided as a prop to the component. If not specified, the entire context theme is provided.
- [`options`] \(*Object*): A configuration object.
- [`options`] \(*Object*): Configures the default options for the `Themed` component.
- [`propName = "theme"`] \(*String*): The name of the prop the theme gets assigned to.
- [`compose = false`] \(*Bool|Func*): Specifies how to handle a prop passed to the `Themed` component that matches the configured `propName`. When `false`, the prop replaces the context theme. When `true`, the two themes get composed. If the prop is a function, it is passed the prop theme and the context theme, and is expected to return a merged plain object.
- [`mergeProps(ownProps, themeProps): props`] \(*Function*): If specified, it is passed the parent props and an object containing the theme prop. The returned plain object is passed as props to the wrapped component.

### `themed.setDefaults(options)`
Sets the default options provided by the `themed` decorator globally.

### `<Themed [theme] [themeConfig]>`
The *themed* component that gets created by the `themed` decorator.
- [`theme`] \(*Object*): A custom theme to replace or compose with the context theme.
- [`themeConfig`] \(*Object*): A configuration object that overrides the components default options.

### `composeTheme(...themes)`
Recursively merges theme objects. Values for overlapping keys are concatenated.

Expand Down
12 changes: 5 additions & 7 deletions src/ThemeProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,27 @@ export default class ThemeProvider extends PureComponent {

constructor(props, context) {
super(props, context)
this.theme = this.buildTheme(props, context)
this.buildTheme(props, context)
}

componentWillReceiveProps(props, context) {
if (this.context.theme !== context.theme || this.props.theme !== props.theme) {
this.theme = this.buildTheme(props, context)
this.buildTheme(props, context)
}
}

buildTheme(props, context) {
let theme = props.theme
this.theme = props.theme
const { compose } = this.props
const parentTheme = context.theme

if (parentTheme) {
if (typeof compose === 'function') {
theme = compose(parentTheme, theme)
this.theme = compose(parentTheme, this.theme)
} else if (compose) {
theme = composeTheme(parentTheme, theme)
this.theme = composeTheme(parentTheme, this.theme)
}
}

return theme
}

getChildContext() {
Expand Down
75 changes: 75 additions & 0 deletions src/ThemedComponent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createElement, Component, PropTypes } from 'react'
import composeTheme from './composeTheme'

export default class ThemedComponent extends Component {
static contextTypes = {
theme: PropTypes.object,
}

constructor(...args) {
super(...args)
this.config = this.constructor.themeConfig
this.buildTheme()
}

componentWillReceiveProps(props, context) {
if (this.propsChanged(props) || this.contextChanged(context)) {
this.buildTheme()
}
}

propsChanged(props) {
const {
propName,
configKey,
} = this.config

return (
this.props[propName] !== props[propName] ||
this.props[configKey].compose !== props[configKey].compose
)
}

contextChanged(context) {
return this.context.theme !== context.theme
}

buildTheme() {
const {
propName,
configKey,
selectTheme,
} = this.config

const {
[propName]: propTheme,
} = this.props

const {
compose = this.config.compose,
} = this.props[configKey]

this.theme = selectTheme(this.context.theme || {})

if (propTheme) {
if (typeof compose === 'function') {
this.theme = compose(this.theme, propTheme)
} else if (compose) {
this.theme = composeTheme(this.theme, propTheme)
} else {
this.theme = propTheme
}
}
}

render() {
const props = { ...this.props }

delete props[this.config.propName]
delete props[this.config.configKey]

return createElement(this.config.component, this.config.mergeProps(props, {
[this.config.propName]: this.theme,
}))
}
}
25 changes: 24 additions & 1 deletion src/themed-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,36 @@ describe('themed', () => {
expect(wrapper.find(Foo).prop('bar')).to.equal('bar')
})

it('allows theme prop override', () => {
it('overrides theme prop by default', () => {
const Bar = themed()(Foo)
const theme = { bar: 'bar' }
const wrapper = shallow(<Bar theme={theme} />, { context })
expect(wrapper.find(Foo).prop('theme')).to.eql(theme)
})

it('can be configured to compose passed in theme', () => {
const Bar = themed('Foo')(Foo)
const theme = { foo: 'bar' }
const config = { compose: true }
const wrapper = shallow(<Bar theme={theme} themeConfig={config} />, { context })
expect(wrapper.find(Foo).prop('theme')).to.eql({
foo: 'foo bar',
})
})

it('can be configured to compose passed in theme using a custom function', () => {
const Bar = themed('Foo')(Foo)
const theme = { bar: 'bar' }
const merge = (...args) => Object.assign({ baz: 'baz' }, ...args)
const config = { compose: merge }
const wrapper = shallow(<Bar theme={theme} themeConfig={config} />, { context })
expect(wrapper.find(Foo).prop('theme')).to.eql({
foo: 'foo',
bar: 'bar',
baz: 'baz',
})
})

it('supports custom theme prop name', () => {
const Bar = themed(null, { propName: 'styles' })(Foo)
const wrapper = shallow(<Bar />, { context })
Expand Down
43 changes: 26 additions & 17 deletions src/themed.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createElement, PropTypes } from 'react'
import themeContext from './themeContext'
import { PropTypes } from 'react'
import ThemedComponent from './ThemedComponent'

const mergeProps = (
ownProps,
Expand All @@ -11,38 +11,47 @@ const mergeProps = (

const defaultOptions = {
mergeProps,
compose: false,
propName: 'theme',
}

const selectTheme = (selector, theme) => {
const themeSelector = selector => {
switch (typeof selector) {
case 'function':
return selector(theme)
return selector
case 'string':
return theme[selector]
return theme => theme[selector]
default:
return theme
return theme => theme
}
}

const themed = (selector, options) => target => {
const themed = (selector, options) => component => {
const config = {
...defaultOptions,
...options,
selectTheme: themeSelector(selector),
component,
}

const Themed = (props, { theme = {} }) => (
createElement(target, config.mergeProps(props, {
[config.propName]: selectTheme(selector, theme),
}))
)
Object.assign(config, {
configKey: `${config.propName}Config`,
})

return class Themed extends ThemedComponent {
static displayName = `Themed(${component.displayName || component.name})`

static themeConfig = config

return Object.assign(themeContext(Themed), {
displayName: `Themed(${target.displayName || target.name})`,
propTypes: {
static propTypes = {
[config.propName]: PropTypes.object,
},
})
[config.configKey]: PropTypes.object,
}

static defaultProps = {
[config.configKey]: {},
}
}
}

themed.setDefaults = options => {
Expand Down

0 comments on commit 4970baf

Please sign in to comment.