Skip to content

Commit e449090

Browse files
raveclassicdavid-slayte
authored andcommitted
Feature/theme spreads (javivelasco#47)
* refactoring * theme spreads * typings * fix themr typing * fix themr typing * fix undefined mixin value cases * fix state type incompatibility in javivelasco#39 * revert typing changes * accept sfc as component * typo * also accept symbols and numbers as identifier * merge sfc fix
1 parent d1e5990 commit e449090

File tree

4 files changed

+215
-78
lines changed

4 files changed

+215
-78
lines changed

index.d.ts

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,35 @@
11
import * as React from "react";
22

3-
declare module "react-css-themr"
4-
{
5-
export interface IThemrOptions
6-
{
7-
/** @default "deeply" */
8-
composeTheme?: "deeply" | "softly" | false,
9-
}
10-
11-
export interface ThemeProviderProps
12-
{
13-
innerRef?: Function,
14-
theme: {}
15-
}
3+
declare module "react-css-themr" {
4+
type TReactCSSThemrTheme = {
5+
[key: string]: string | TReactCSSThemrTheme
6+
}
7+
8+
export function themeable(...themes: Array<TReactCSSThemrTheme>): TReactCSSThemrTheme;
169

17-
export class ThemeProvider extends React.Component<ThemeProviderProps, any>
18-
{
10+
export interface IThemrOptions {
11+
/** @default "deeply" */
12+
composeTheme?: "deeply" | "softly" | false,
13+
}
1914

20-
}
15+
export interface ThemeProviderProps {
16+
innerRef?: Function,
17+
theme: {}
18+
}
2119

22-
interface ThemedComponent<P, S> extends React.Component<P, S>
23-
{
20+
export class ThemeProvider extends React.Component<ThemeProviderProps, any> {
21+
}
2422

25-
}
23+
interface ThemedComponent<P, S> extends React.Component<P, S> {
24+
}
2625

27-
interface ThemedComponentClass<P, S> extends React.ComponentClass<P>
28-
{
29-
new(props?: P, context?: any): ThemedComponent<P, S>;
30-
}
26+
interface ThemedComponentClass<P, S> extends React.ComponentClass<P> {
27+
new(props?: P, context?: any): ThemedComponent<P, S>;
28+
}
3129

32-
export function themr(
33-
identifier: string,
34-
defaultTheme?: {},
35-
options?: IThemrOptions
36-
): <P, S>(component: new(props?: P, context?: any) => React.Component<P, S>) => ThemedComponentClass<P, S>;
30+
export function themr(
31+
identifier: string | number | symbol,
32+
defaultTheme?: {},
33+
options?: IThemrOptions
34+
): <P, S>(component: (new(props?: P, context?: any) => React.Component<P, S>) | React.SFC<P>) => ThemedComponentClass<P, S>;
3735
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"invariant": "^2.2.1"
2828
},
2929
"devDependencies": {
30+
"@types/react": "~15.0.4",
3031
"babel-cli": "^6.7.7",
3132
"babel-core": "^6.18.0",
3233
"babel-eslint": "^7.1.1",

src/components/themr.js

Lines changed: 87 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export default (componentName, localTheme, options = {}) => (ThemedComponent) =>
8484
getNamespacedTheme(props) {
8585
const { themeNamespace, theme } = props
8686
if (!themeNamespace) return theme
87-
if (themeNamespace && !theme) throw new Error('Invalid themeNamespace use in react-css-themr. ' +
87+
if (themeNamespace && !theme) throw new Error('Invalid themeNamespace use in react-css-themr. ' +
8888
'themeNamespace prop should be used only with theme prop.')
8989

9090
return Object.keys(theme)
@@ -153,57 +153,99 @@ export default (componentName, localTheme, options = {}) => (ThemedComponent) =>
153153
}
154154

155155
/**
156-
* Merges two themes by concatenating values with the same keys
157-
* @param {TReactCSSThemrTheme} [original] - Original theme object
158-
* @param {TReactCSSThemrTheme} [mixin] - Mixing theme object
159-
* @returns {TReactCSSThemrTheme} - Merged resulting theme
156+
* Merges passed themes by concatenating string keys and processing nested themes
157+
* @param {...TReactCSSThemrTheme} themes - Themes
158+
* @returns {TReactCSSThemrTheme} - Resulting theme
160159
*/
161-
export function themeable(original = {}, mixin) {
162-
//don't merge if no mixin is passed
163-
if (!mixin) return original
164-
165-
//merge themes by concatenating values with the same keys
166-
return Object.keys(mixin).reduce(
167-
168-
//merging reducer
169-
(result, key) => {
170-
171-
const originalValue = typeof original[key] !== 'function'
172-
? (original[key] || '')
173-
: ''
174-
const mixinValue = typeof mixin[key] !== 'function'
175-
? (mixin[key] || '')
176-
: ''
177-
178-
let newValue
160+
export function themeable(...themes) {
161+
return themes.reduce((acc, theme) => merge(acc, theme), {})
162+
}
179163

180-
//when you are mixing an string with a object it should fail
181-
invariant(!(typeof originalValue === 'string' && typeof mixinValue === 'object'),
182-
`You are merging a string "${originalValue}" with an Object,` +
183-
'Make sure you are passing the proper theme descriptors.'
184-
)
164+
/**
165+
* @param {TReactCSSThemrTheme} [original] - Original theme
166+
* @param {TReactCSSThemrTheme} [mixin] - Mixin theme
167+
* @returns {TReactCSSThemrTheme} - resulting theme
168+
*/
169+
function merge(original = {}, mixin = {}) {
170+
//make a copy to avoid mutations of nested objects
171+
//also strip all functions injected by isomorphic-style-loader
172+
const result = Object.keys(original).reduce((acc, key) => {
173+
const value = original[key]
174+
if (typeof value !== 'function') {
175+
acc[key] = value
176+
}
177+
return acc
178+
}, {})
179+
180+
//traverse mixin keys and merge them to resulting theme
181+
Object.keys(mixin).forEach(key => {
182+
//there's no need to set any defaults here
183+
const originalValue = result[key]
184+
const mixinValue = mixin[key]
185+
186+
switch (typeof mixinValue) {
187+
case 'object': {
188+
//possibly nested theme object
189+
switch (typeof originalValue) {
190+
case 'object': {
191+
//exactly nested theme object - go recursive
192+
result[key] = merge(originalValue, mixinValue)
193+
break
194+
}
195+
196+
case 'undefined': {
197+
//original does not contain this nested key - just take it as is
198+
result[key] = mixinValue
199+
break
200+
}
201+
202+
default: {
203+
//can't merge an object with a non-object
204+
throw new Error(`You are merging object ${key} with a non-object ${originalValue}`)
205+
}
206+
}
207+
break
208+
}
185209

186-
//check if values are nested objects
187-
if (typeof originalValue === 'object' && typeof mixinValue === 'object') {
188-
//go recursive
189-
newValue = themeable(originalValue, mixinValue)
190-
} else {
191-
//either concat or take mixin value
192-
newValue = originalValue.split(' ')
193-
.concat(mixinValue.split(' '))
194-
.filter((item, pos, self) => self.indexOf(item) === pos && item !== '')
195-
.join(' ')
210+
case 'undefined': //fallthrough - handles accidentally unset values which may come from props
211+
case 'function': {
212+
//this handles issue when isomorphic-style-loader addes helper functions to css-module
213+
break //just skip
196214
}
197215

198-
return {
199-
...result,
200-
[key]: newValue
216+
default: {
217+
//plain values
218+
switch (typeof originalValue) {
219+
case 'object': {
220+
//can't merge a non-object with an object
221+
throw new Error(`You are merging non-object ${mixinValue} with an object ${key}`)
222+
}
223+
224+
case 'undefined': {
225+
//mixin key is new to original theme - take it as is
226+
result[key] = mixinValue
227+
break
228+
}
229+
case 'function': {
230+
//this handles issue when isomorphic-style-loader addes helper functions to css-module
231+
break //just skip
232+
}
233+
234+
default: {
235+
//finally we can merge
236+
result[key] = originalValue.split(' ')
237+
.concat(mixinValue.split(' '))
238+
.filter((item, pos, self) => self.indexOf(item) === pos && item !== '')
239+
.join(' ')
240+
break
241+
}
242+
}
243+
break
201244
}
202-
},
245+
}
246+
})
203247

204-
//use original theme as an acc
205-
original
206-
)
248+
return result
207249
}
208250

209251
/**

test/components/themr.spec.js

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -510,17 +510,113 @@ describe('themeable function', () => {
510510
expect(result).toEqual(expected)
511511
})
512512

513-
it('should skip dupplicated keys classNames', () => {
513+
it('should skip duplicated keys classNames', () => {
514514
const themeA = { test: 'test' }
515515
const themeB = { test: 'test test2' }
516516
const expected = { test: 'test test2' }
517517
const result = themeable(themeA, themeB)
518518
expect(result).toEqual(expected)
519519
})
520520

521-
it('throws an exception when its called mixing a string with an object', () => {
522-
expect(() => {
523-
themeable('fail', { test: { foo: 'baz' } })
524-
}).toThrow(/sure you are passing the proper theme descriptors/)
521+
it('should take mixin value if original does not contain one', () => {
522+
const themeA = {}
523+
const themeB = {
524+
test: 'test',
525+
nested: {
526+
bar: 'bar'
527+
}
528+
}
529+
const expected = themeB
530+
const result = themeable(themeA, themeB)
531+
expect(result).toEqual(expected)
532+
})
533+
534+
it('should take original value if mixin does not contain one', () => {
535+
const themeA = {
536+
test: 'test',
537+
nested: {
538+
bar: 'bar'
539+
}
540+
}
541+
const themeB = {}
542+
const expected = themeA
543+
const result = themeable(themeA, themeB)
544+
expect(result).toEqual(expected)
545+
})
546+
547+
it('should skip function values for usage with isomorphic-style-loader', () => {
548+
const themeA = {
549+
test: 'test',
550+
foo() {
551+
}
552+
}
553+
554+
const themeB = {
555+
test: 'test2',
556+
bar() {
557+
}
558+
}
559+
560+
const expected = {
561+
test: [
562+
themeA.test, themeB.test
563+
].join(' ')
564+
}
565+
566+
const result = themeable(themeA, themeB)
567+
expect(result).toEqual(expected)
568+
})
569+
570+
it('should throw when merging objects with non-objects', () => {
571+
const themeA = {
572+
test: 'test'
573+
}
574+
const themeB = {
575+
test: {
576+
}
577+
}
578+
expect(() => themeable(themeA, themeB)).toThrow()
579+
})
580+
581+
it('should throw when merging non-objects with objects', () => {
582+
const themeA = {
583+
test: {
584+
}
585+
}
586+
const themeB = {
587+
test: 'test'
588+
}
589+
expect(() => themeable(themeA, themeB)).toThrow()
590+
})
591+
592+
it('should support theme spreads', () => {
593+
const a = {
594+
test: 'a'
595+
}
596+
const b = {
597+
test: 'b'
598+
}
599+
const c = {
600+
test: 'foo',
601+
foo: 'foo'
602+
}
603+
const expected = {
604+
test: 'a b foo',
605+
foo: 'foo'
606+
}
607+
const result = themeable(a, b, c)
608+
expect(result).toEqual(expected)
609+
})
610+
611+
it('should skip undefined mixin values', () => {
612+
const a = {
613+
test: 'a'
614+
}
615+
const b = {
616+
test: undefined
617+
}
618+
const expected = a
619+
const result = themeable(a, b)
620+
expect(result).toEqual(expected)
525621
})
526622
})

0 commit comments

Comments
 (0)