Skip to content

Commit

Permalink
Merge pull request #227 from styled-components/per-component-classnam…
Browse files Browse the repository at this point in the history
…e-generation

Per-component classnames
  • Loading branch information
geelen authored Jan 11, 2017
2 parents c288569 + 9095ea5 commit def275e
Show file tree
Hide file tree
Showing 19 changed files with 1,227 additions and 638 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. If a contri

*The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/).*

## [Upcoming Major Release]

- Added per-component class names (see [#227](https://github.com/styled-components/styled-components/pull/227)).
- Added the ability to override one component's styles from another.
- Injecting an empty class for each instance of a component.

## [Unreleased]

### Added
Expand Down
20 changes: 20 additions & 0 deletions src/constructors/constructWithOptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// @flow
import css from './css'
import type { Interpolation, Target } from '../types'

const constructWithOptions = (componentConstructor: Function,
tag: Target,
options: Object = {}) => {
/* This is callable directly as a template function */
const templateFunction =
(strings: Array<string>, ...interpolations: Array<Interpolation>) =>
componentConstructor(tag, options, css(strings, ...interpolations))

/* If withConfig is called, wrap up a new template function and merge options */
templateFunction.withConfig = config =>
constructWithOptions(componentConstructor, tag, { ...options, ...config })

return templateFunction
}

export default constructWithOptions
8 changes: 3 additions & 5 deletions src/constructors/styled.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
// @flow
import css from './css'
import type { Interpolation, Target } from '../types'
import constructWithOptions from './constructWithOptions'
import type { Target } from '../types'
import domElements from '../utils/domElements'

export default (styledComponent: Function) => {
const styled = (tag: Target) =>
(strings: Array<string>, ...interpolations: Array<Interpolation>) =>
styledComponent(tag, css(strings, ...interpolations))
const styled = (tag: Target) => constructWithOptions(styledComponent, tag)

// Shorthands for all valid HTML Elements
domElements.forEach(domElement => {
Expand Down
3 changes: 2 additions & 1 deletion src/constructors/test/injectGlobal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ describe('injectGlobal', () => {
`

expectCSSMatches(`
.a {
.sc-a {}
.b {
${rule3}
}
html {
Expand Down
14 changes: 10 additions & 4 deletions src/models/ComponentStyle.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,18 @@ export default (nameGenerator: NameGenerator) => {

class ComponentStyle {
rules: RuleSet
componentId: string
insertedRule: Object

constructor(rules: RuleSet) {
constructor(rules: RuleSet, componentId: string) {
this.rules = rules
this.componentId = componentId
if (!styleSheet.injected) styleSheet.inject()
this.insertedRule = styleSheet.insert('')
this.insertedRule = styleSheet.insert(`.${componentId} {}`)
}

static generateName(str: string) {
return nameGenerator(hashStr(str))
}

/*
Expand All @@ -34,14 +40,14 @@ export default (nameGenerator: NameGenerator) => {
generateAndInjectStyles(executionContext: Object) {
const flatCSS = flatten(this.rules, executionContext).join('')
.replace(/^\s*\/\/.*$/gm, '') // replace JS comments
const hash = hashStr(flatCSS)
const hash = hashStr(this.componentId + flatCSS)
if (!inserted[hash]) {
const selector = nameGenerator(hash)
inserted[hash] = selector
const root = parse(`.${selector} { ${flatCSS} }`)
postcssNested(root)
autoprefix(root)
this.insertedRule.appendRule(root.toResult().css)
this.insertedRule.appendRule(`\n${root.toResult().css}`)
}
return inserted[hash]
}
Expand Down
34 changes: 27 additions & 7 deletions src/models/StyledComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,40 @@ import AbstractStyledComponent from './AbstractStyledComponent'
import { CHANNEL } from './ThemeProvider'

export default (ComponentStyle: Function) => {
// eslint-disable-next-line no-undef
const createStyledComponent = (target: Target, rules: RuleSet, parent?: ReactClass<*>) => {
/* We depend on components having unique IDs */
const identifiers = {}
const generateId = (_displayName: string) => {
const displayName = _displayName
.replace(/[[\].#*$><+~=|^:(),"'`]/g, '-') // Replace all possible CSS selectors
.replace(/--+/g, '-') // Replace multiple -- with single -
const nr = (identifiers[displayName] || 0) + 1
identifiers[displayName] = nr
const hash = ComponentStyle.generateName(displayName + nr)
return `${displayName}-${hash}`
}

const createStyledComponent = (target: Target,
options: Object,
rules: RuleSet,
// eslint-disable-next-line no-undef
parent?: ReactClass<*>) => {
/* Handle styled(OtherStyledComponent) differently */
const isStyledComponent = AbstractStyledComponent.isPrototypeOf(target)
if (!isTag(target) && isStyledComponent) {
return createStyledComponent(target.target, target.rules.concat(rules), target)
return createStyledComponent(target.target, options, target.rules.concat(rules), target)
}

const componentStyle = new ComponentStyle(rules)
const {
displayName = isTag(target) ? `styled.${target}` : `Styled(${target.displayName})`,
componentId = generateId(options.displayName || 'sc'),
} = options
const componentStyle = new ComponentStyle(rules, componentId)
const ParentComponent = parent || AbstractStyledComponent

class StyledComponent extends ParentComponent {
static rules: RuleSet
static target: Target
static styledComponentId: string

constructor() {
super()
Expand Down Expand Up @@ -92,7 +112,7 @@ export default (ComponentStyle: Function) => {
.forEach(propName => {
propsForElement[propName] = this.props[propName]
})
propsForElement.className = [className, generatedClassName].filter(x => x).join(' ')
propsForElement.className = [className, componentId, generatedClassName].filter(x => x).join(' ')
if (innerRef) {
propsForElement.ref = innerRef
delete propsForElement.innerRef
Expand All @@ -102,11 +122,11 @@ export default (ComponentStyle: Function) => {
}
}

StyledComponent.displayName = displayName
StyledComponent.styledComponentId = componentId
StyledComponent.target = target
StyledComponent.rules = rules

StyledComponent.displayName = isTag(target) ? `styled.${target}` : `Styled(${target.displayName})`

return StyledComponent
}

Expand Down
12 changes: 9 additions & 3 deletions src/models/StyledNativeComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ import { CHANNEL } from './ThemeProvider'
import InlineStyle from './InlineStyle'
import AbstractStyledComponent from './AbstractStyledComponent'

const createStyledNativeComponent = (target: Target, rules: RuleSet, parent?: Target) => {
const createStyledNativeComponent = (target: Target,
options: Object,
rules: RuleSet,
parent?: Target) => {
/* Handle styled(OtherStyledNativeComponent) differently */
const isStyledNativeComponent = AbstractStyledComponent.isPrototypeOf(target)
if (isStyledNativeComponent && !isTag(target)) {
return createStyledNativeComponent(target.target, target.rules.concat(rules), target)
return createStyledNativeComponent(target.target, options, target.rules.concat(rules), target)
}

const {
displayName = isTag(target) ? `styled.${target}` : `Styled(${target.displayName})`,
} = options
const inlineStyle = new InlineStyle(rules)
const ParentComponent = parent || AbstractStyledComponent

Expand Down Expand Up @@ -96,7 +102,7 @@ const createStyledNativeComponent = (target: Target, rules: RuleSet, parent?: Ta
/* Used for inheritance */
StyledNativeComponent.rules = rules
StyledNativeComponent.target = target
StyledNativeComponent.displayName = isTag(target) ? `styled.${target}` : `Styled(${target.displayName})`
StyledNativeComponent.displayName = displayName

return StyledNativeComponent
}
Expand Down
7 changes: 3 additions & 4 deletions src/native/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,15 @@
/* eslint-disable import/no-unresolved */
import reactNative from 'react-native'

import constructWithOptions from '../constructors/constructWithOptions'
import css from '../constructors/css'

import styledNativeComponent from '../models/StyledNativeComponent'
import ThemeProvider from '../models/ThemeProvider'
import withTheme from '../hoc/withTheme'
import type { Interpolation, Target } from '../types'
import type { Target } from '../types'

const styled = (tag: Target) =>
(strings: Array<string>, ...interpolations: Array<Interpolation>) =>
styledNativeComponent(tag, css(strings, ...interpolations))
const styled = (tag: Target) => constructWithOptions(styledNativeComponent, tag)

/* React native lazy-requires each of these modules for some reason, so let's
* assume it's for a good reason and not eagerly load them all */
Expand Down
12 changes: 6 additions & 6 deletions src/test/basic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,29 @@ describe('basic', () => {
expect(styleSheet.injected).toBe(true)
})

it('should not generate any styles by default', () => {
it('should generate only component class by default', () => {
styled.div``
expectCSSMatches('')
expectCSSMatches('.sc-a {}')
})

it('should generate an empty tag once rendered', () => {
it('should add an empty class once rendered', () => {
const Comp = styled.div``
shallow(<Comp />)
expectCSSMatches('.a { }')
expectCSSMatches('.sc-a {} .b { }')
})

/* TODO: we should probably pretty-format the output so this test might have to change */
it('should pass through all whitespace', () => {
const Comp = styled.div` \n `
shallow(<Comp />)
expectCSSMatches('.a { \n }', { ignoreWhitespace: false })
expectCSSMatches('.sc-a {}\n.b { \n }', { ignoreWhitespace: false })
})

it('should inject only once for a styled component, no matter how often it\'s mounted', () => {
const Comp = styled.div``
shallow(<Comp />)
shallow(<Comp />)
expectCSSMatches('.a { }')
expectCSSMatches('.sc-a {} .b { }')
})

describe('jsdom tests', () => {
Expand Down
24 changes: 13 additions & 11 deletions src/test/css.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('css features', () => {
transition: opacity 0.3s;
`
shallow(<Comp />)
expectCSSMatches('.a { transition: opacity 0.3s; -webkit-transition: opacity 0.3s; }')
expectCSSMatches('.sc-a {} .b { -webkit-transition: opacity 0.3s; transition: opacity 0.3s; }')
})

it('should add vendor prefixes for display', () => {
Expand All @@ -27,21 +27,22 @@ describe('css features', () => {
`
shallow(<Comp />)
expectCSSMatches(`
.a {
.sc-a {}
.b {
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
flex-direction: column;
-webkit-box-direction: normal;
-webkit-box-orient: vertical;
-ms-flex-direction: column;
-webkit-flex-direction: column;
align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
-ms-flex-direction: column;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
flex-direction: column;
-webkit-align-items: center;
-ms-flex-align: center;
-webkit-box-align: center;
align-items: center;
}
`)
})
Expand All @@ -52,7 +53,8 @@ describe('css features', () => {
`
shallow(<Comp />)
expectCSSMatches(`
.a {
.sc-a {}
.b {
margin-bottom: -webkit-calc(15px - 0.5rem) !important;
margin-bottom: -moz-calc(15px - 0.5rem) !important;
margin-bottom: calc(15px - 0.5rem) !important;
Expand All @@ -65,6 +67,6 @@ describe('css features', () => {
--custom-prop: some-val;
`
shallow(<Comp />)
expectCSSMatches('.a { --custom-prop: some-val; }')
expectCSSMatches('.sc-a {} .b { --custom-prop: some-val; }')
})
})
78 changes: 78 additions & 0 deletions src/test/expanded-api.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// @flow
import React, { Component } from 'react'
import expect from 'expect'
import { shallow } from 'enzyme'

import { resetStyled } from './utils'

let styled

describe('expanded api', () => {
/**
* Make sure the setup is the same for every test
*/
beforeEach(() => {
styled = resetStyled()
})

describe('displayName', () => {
it('should be auto-generated if none passed', () => {
const Comp = styled.div``
expect(Comp.displayName).toBe('styled.div')
})

it('should be attached if supplied', () => {
const Comp = styled.div.withConfig({ displayName: 'Comp' })``
expect(Comp.displayName).toBe('Comp')
})
})

describe('componentId', () => {
it('should be generated as "sc" + hash', () => {
const Comp = styled.div``
const Comp2 = styled.div``
expect(Comp.styledComponentId).toBe('sc-a')
expect(shallow(<Comp />).prop('className')).toInclude('sc-a')
expect(Comp2.styledComponentId).toBe('sc-b')
expect(shallow(<Comp2 />).prop('className')).toInclude('sc-b')
})

it('should be generated from displayName + hash', () => {
const Comp = styled.div.withConfig({ displayName: 'Comp' })``
const Comp2 = styled.div.withConfig({ displayName: 'Comp2' })``
expect(Comp.styledComponentId).toBe('Comp-a')
expect(shallow(<Comp />).prop('className')).toInclude('Comp-a')
expect(Comp2.styledComponentId).toBe('Comp2-b')
expect(shallow(<Comp2 />).prop('className')).toInclude('Comp2-b')
})

it('should be attached if passed in', () => {
const Comp = styled.div.withConfig({ displayName: 'Comp', componentId: 'LOLOMG' })``
const Comp2 = styled.div.withConfig({ displayName: 'Comp2', componentId: 'OMGLOL' })``
expect(Comp.styledComponentId).toBe('LOLOMG')
expect(shallow(<Comp />).prop('className')).toInclude('LOLOMG')
expect(Comp2.styledComponentId).toBe('OMGLOL')
expect(shallow(<Comp2 />).prop('className')).toInclude('OMGLOL')
})
})

describe('chaining', () => {
it('should merge the options strings', () => {
const Comp = styled.div
.withConfig({ componentId: 'id-1' })
.withConfig({ displayName: 'dn-2' })
``
expect(Comp.displayName).toBe('dn-2')
expect(shallow(<Comp />).prop('className')).toBe('id-1 a')
})

it('should keep the last value passed in when merging', () => {
const Comp = styled.div
.withConfig({ displayName: 'dn-2', componentId: 'id-3' })
.withConfig({ displayName: 'dn-5', componentId: 'id-4' })
``
expect(Comp.displayName).toBe('dn-5')
expect(shallow(<Comp />).prop('className')).toBe('id-4 a')
})
})
})
Loading

0 comments on commit def275e

Please sign in to comment.