From 6c21bb9d3f4ce66a7045c29e809c591edcdbdca2 Mon Sep 17 00:00:00 2001 From: nvanselow Date: Fri, 20 Oct 2017 19:31:32 -0400 Subject: [PATCH] Pass options to component style and modify css selector --- src/models/ComponentStyle.js | 12 +- src/models/StyleSheet.js | 2 +- src/models/StyledComponent.js | 284 +++++++++--------- src/utils/getComponentCssSelector.js | 17 ++ .../test/getComponentCssSelector.test.js | 30 ++ 5 files changed, 205 insertions(+), 140 deletions(-) create mode 100644 src/utils/getComponentCssSelector.js create mode 100644 src/utils/test/getComponentCssSelector.test.js diff --git a/src/models/ComponentStyle.js b/src/models/ComponentStyle.js index f5c2df179..5c71e5a2b 100644 --- a/src/models/ComponentStyle.js +++ b/src/models/ComponentStyle.js @@ -4,6 +4,7 @@ import hashStr from '../vendor/glamor/hash' import type { RuleSet, NameGenerator, Flattener, Stringifier } from '../types' import StyleSheet from './StyleSheet' import isStyledComponent from '../utils/isStyledComponent' +import getComponentCssSelector from '../utils/getComponentCssSelector' const isStaticRules = (rules: RuleSet): boolean => { for (let i = 0; i < rules.length; i += 1) { @@ -33,7 +34,6 @@ export default (nameGenerator: NameGenerator, flatten: Flattener, stringifyRules isStatic: boolean lastClassName: ?string - constructor(rules: RuleSet, componentId: string) { this.rules = rules this.isStatic = isStaticRules(rules) @@ -49,7 +49,11 @@ export default (nameGenerator: NameGenerator, flatten: Flattener, stringifyRules * Hashes it, wraps the whole chunk in a .hash1234 {} * Returns the hash to be injected on render() * */ - generateAndInjectStyles(executionContext: Object, styleSheet: StyleSheet) { + generateAndInjectStyles( + executionContext: Object, + styleSheet: StyleSheet, + options: Object = {}, + ) { const { isStatic, lastClassName } = this if (isStatic && lastClassName !== undefined) { return lastClassName @@ -74,7 +78,9 @@ export default (nameGenerator: NameGenerator, flatten: Flattener, stringifyRules return name } - const css = `\n${stringifyRules(flatCSS, `.${name}`)}` + const selector = getComponentCssSelector(name, options) + + const css = `\n${stringifyRules(flatCSS, selector)}` // NOTE: this can only be set when we inject the class-name. // For some reason, presumably due to how css is stringifyRules behaves in // differently between client and server, styles break. diff --git a/src/models/StyleSheet.js b/src/models/StyleSheet.js index 01f415b63..b9e3c48b2 100644 --- a/src/models/StyleSheet.js +++ b/src/models/StyleSheet.js @@ -31,7 +31,7 @@ export default class StyleSheet { deferredInjections: { [string]: string } = {} componentTags: { [string]: Tag } // helper for `ComponentStyle` to know when it cache static styles. - // staticly styled-component can not safely cache styles on the server + // statically styled-component can not safely cache styles on the server // without all `ComponentStyle` instances saving a reference to the // the styleSheet instance they last rendered with, // or listening to creation / reset events. otherwise you might create diff --git a/src/models/StyledComponent.js b/src/models/StyledComponent.js index e9bb4acb3..c7736cb10 100644 --- a/src/models/StyledComponent.js +++ b/src/models/StyledComponent.js @@ -43,162 +43,174 @@ export default (ComponentStyle: Function, constructWithOptions: Function) => { : componentId } - class BaseStyledComponent extends Component { - static target: Target - static styledComponentId: string - static attrs: Object - static componentStyle: Object - static warnTooManyClasses: Function - - attrs = {} - state = { - theme: null, - generatedClassName: '', - } - unsubscribeId: number = -1 - - unsubscribeFromContext() { - if (this.unsubscribeId !== -1) { - this.context[CHANNEL_NEXT].unsubscribe(this.unsubscribeId) + const createBaseStyledComponent = (options: Object) => { + class BaseStyledComponent extends Component { + static target: Target + static styledComponentId: string + static attrs: Object + static componentStyle: Object + static warnTooManyClasses: Function + + attrs = {} + state = { + theme: null, + generatedClassName: '', } - } + unsubscribeId: number = -1 - buildExecutionContext(theme: any, props: any) { - const { attrs } = this.constructor - const context = { ...props, theme } - if (attrs === undefined) { - return context + unsubscribeFromContext() { + if (this.unsubscribeId !== -1) { + this.context[CHANNEL_NEXT].unsubscribe(this.unsubscribeId) + } } - this.attrs = Object.keys(attrs).reduce((acc, key) => { - const attr = attrs[key] - // eslint-disable-next-line no-param-reassign - acc[key] = typeof attr === 'function' ? attr(context) : attr - return acc - }, {}) - - return { ...context, ...this.attrs } - } - - generateAndInjectStyles(theme: any, props: any) { - const { attrs, componentStyle, warnTooManyClasses } = this.constructor - const styleSheet = this.context[CONTEXT_KEY] || StyleSheet.instance - - // staticaly styled-components don't need to build an execution context object, - // and shouldn't be increasing the number of class names - if (componentStyle.isStatic && attrs === undefined) { - return componentStyle.generateAndInjectStyles(STATIC_EXECUTION_CONTEXT, styleSheet) - } else { - const executionContext = this.buildExecutionContext(theme, props) - const className = componentStyle.generateAndInjectStyles(executionContext, styleSheet) + buildExecutionContext(theme: any, props: any) { + const { attrs } = this.constructor + const context = { ...props, theme } + if (attrs === undefined) { + return context + } - if (warnTooManyClasses !== undefined) warnTooManyClasses(className) + this.attrs = Object.keys(attrs).reduce((acc, key) => { + const attr = attrs[key] + // eslint-disable-next-line no-param-reassign + acc[key] = typeof attr === 'function' ? attr(context) : attr + return acc + }, {}) - return className + return { ...context, ...this.attrs } } - } - componentWillMount() { - const { componentStyle } = this.constructor - const styledContext = this.context[CHANNEL_NEXT] - - // If this is a staticaly-styled component, we don't need to the theme - // to generate or build styles. - if (componentStyle.isStatic) { - const generatedClassName = this.generateAndInjectStyles( - STATIC_EXECUTION_CONTEXT, - this.props, - ) - this.setState({ generatedClassName }) - // If there is a theme in the context, subscribe to the event emitter. This - // is necessary due to pure components blocking context updates, this circumvents - // that by updating when an event is emitted - } else if (styledContext !== undefined) { - const { subscribe } = styledContext - this.unsubscribeId = subscribe(nextTheme => { - // This will be called once immediately - const theme = determineTheme(this.props, nextTheme, this.constructor.defaultProps) - const generatedClassName = this.generateAndInjectStyles(theme, this.props) - - this.setState({ theme, generatedClassName }) - }) - } else { - // eslint-disable-next-line react/prop-types - const theme = this.props.theme || {} - const generatedClassName = this.generateAndInjectStyles( - theme, - this.props, - ) - this.setState({ theme, generatedClassName }) + generateAndInjectStyles(theme: any, props: any) { + const { attrs, componentStyle, warnTooManyClasses } = this.constructor + const styleSheet = this.context[CONTEXT_KEY] || StyleSheet.instance + + // staticaly styled-components don't need to build an execution context object, + // and shouldn't be increasing the number of class names + if (componentStyle.isStatic && attrs === undefined) { + return componentStyle.generateAndInjectStyles( + STATIC_EXECUTION_CONTEXT, + styleSheet, + options, + ) + } else { + const executionContext = this.buildExecutionContext(theme, props) + const className = componentStyle.generateAndInjectStyles( + executionContext, + styleSheet, + options, + ) + + if (warnTooManyClasses !== undefined) warnTooManyClasses(className) + + return className + } } - } - componentWillReceiveProps(nextProps: { theme?: Theme, [key: string]: any }) { - // If this is a staticaly-styled component, we don't need to listen to - // props changes to update styles - const { componentStyle } = this.constructor - if (componentStyle.isStatic) { - return + componentWillMount() { + const { componentStyle } = this.constructor + const styledContext = this.context[CHANNEL_NEXT] + + // If this is a staticaly-styled component, we don't need to the theme + // to generate or build styles. + if (componentStyle.isStatic) { + const generatedClassName = this.generateAndInjectStyles( + STATIC_EXECUTION_CONTEXT, + this.props, + ) + this.setState({ generatedClassName }) + // If there is a theme in the context, subscribe to the event emitter. This + // is necessary due to pure components blocking context updates, this circumvents + // that by updating when an event is emitted + } else if (styledContext !== undefined) { + const { subscribe } = styledContext + this.unsubscribeId = subscribe(nextTheme => { + // This will be called once immediately + const theme = determineTheme(this.props, nextTheme, this.constructor.defaultProps) + const generatedClassName = this.generateAndInjectStyles(theme, this.props) + + this.setState({ theme, generatedClassName }) + }) + } else { + // eslint-disable-next-line react/prop-types + const theme = this.props.theme || {} + const generatedClassName = this.generateAndInjectStyles( + theme, + this.props, + ) + this.setState({ theme, generatedClassName }) + } } - this.setState((oldState) => { - const theme = determineTheme(nextProps, oldState.theme, this.constructor.defaultProps) - const generatedClassName = this.generateAndInjectStyles(theme, nextProps) - - return { theme, generatedClassName } - }) - } - - componentWillUnmount() { - this.unsubscribeFromContext() - } - - render() { - // eslint-disable-next-line react/prop-types - const { innerRef } = this.props - const { generatedClassName } = this.state - const { styledComponentId, target } = this.constructor + componentWillReceiveProps(nextProps: { theme?: Theme, [key: string]: any }) { + // If this is a staticaly-styled component, we don't need to listen to + // props changes to update styles + const { componentStyle } = this.constructor + if (componentStyle.isStatic) { + return + } - const isTargetTag = isTag(target) + this.setState((oldState) => { + const theme = determineTheme(nextProps, oldState.theme, this.constructor.defaultProps) + const generatedClassName = this.generateAndInjectStyles(theme, nextProps) - const className = [ - // eslint-disable-next-line react/prop-types - this.props.className, - styledComponentId, - this.attrs.className, - generatedClassName, - ].filter(Boolean).join(' ') - - const baseProps = { - ...this.attrs, - className, + return { theme, generatedClassName } + }) } - if (isStyledComponent(target)) { - baseProps.innerRef = innerRef - } else { - baseProps.ref = innerRef + componentWillUnmount() { + this.unsubscribeFromContext() } - const propsForElement = Object - .keys(this.props) - .reduce((acc, propName) => { - // Don't pass through non HTML tags through to HTML elements - // always omit innerRef - if ( - propName !== 'innerRef' && - propName !== 'className' && - (!isTargetTag || validAttr(propName)) - ) { - // eslint-disable-next-line no-param-reassign - acc[propName] = this.props[propName] - } + render() { + // eslint-disable-next-line react/prop-types + const { innerRef } = this.props + const { generatedClassName } = this.state + const { styledComponentId, target } = this.constructor + + const isTargetTag = isTag(target) + + const className = [ + // eslint-disable-next-line react/prop-types + this.props.className, + styledComponentId, + this.attrs.className, + generatedClassName, + ].filter(Boolean).join(' ') + + const baseProps = { + ...this.attrs, + className, + } - return acc - }, baseProps) + if (isStyledComponent(target)) { + baseProps.innerRef = innerRef + } else { + baseProps.ref = innerRef + } - return createElement(target, propsForElement) + const propsForElement = Object + .keys(this.props) + .reduce((acc, propName) => { + // Don't pass through non HTML tags through to HTML elements + // always omit innerRef + if ( + propName !== 'innerRef' && + propName !== 'className' && + (!isTargetTag || validAttr(propName)) + ) { + // eslint-disable-next-line no-param-reassign + acc[propName] = this.props[propName] + } + + return acc + }, baseProps) + + return createElement(target, propsForElement) + } } + + return BaseStyledComponent } const createStyledComponent = ( @@ -209,7 +221,7 @@ export default (ComponentStyle: Function, constructWithOptions: Function) => { const { displayName = isTag(target) ? `styled.${target}` : `Styled(${getComponentName(target)})`, componentId = generateId(options.displayName, options.parentComponentId), - ParentComponent = BaseStyledComponent, + ParentComponent = createBaseStyledComponent(options), rules: extendingRules, attrs, } = options diff --git a/src/utils/getComponentCssSelector.js b/src/utils/getComponentCssSelector.js new file mode 100644 index 000000000..b97bebd4a --- /dev/null +++ b/src/utils/getComponentCssSelector.js @@ -0,0 +1,17 @@ +// @flow + +/** + * Adjusts the css selector for the component's css to increase specificity when needed + */ +export default function getComponentCssSelector(componentName: string, options: Object) { + if (options && options.namespaceClasses) { + let namespaceClass = options.namespaceClasses + if (Array.isArray(options.namespaceClasses)) { + namespaceClass = options.namespaceClasses.join(' .') + } + + return `.${namespaceClass} .${componentName}` + } + + return `.${componentName}` +} diff --git a/src/utils/test/getComponentCssSelector.test.js b/src/utils/test/getComponentCssSelector.test.js new file mode 100644 index 000000000..7433d964b --- /dev/null +++ b/src/utils/test/getComponentCssSelector.test.js @@ -0,0 +1,30 @@ +// @flow +import getComponentCssSelector from '../getComponentCssSelector' + +describe('getComponentCssSelector', () => { + const testComponentName = 'testComponent' + const testNamespaceClass = 'moreSpecific' + + it('returns the name as selector if options are not provided', () => { + expect(getComponentCssSelector(testComponentName)).toEqual(`.${testComponentName}`) + }) + + it('returns the name if the namespace class is not defined on options', () => { + expect(getComponentCssSelector(testComponentName, { displayName: 'test' })).toEqual( + `.${testComponentName}`, + ) + }) + + it('returns the name prepended with the namespace class', () => { + expect( + getComponentCssSelector(testComponentName, { namespaceClasses: testNamespaceClass }), + ).toEqual(`.${testNamespaceClass} .${testComponentName}`) + }) + + it('returns the name prepended with a list of namespace classes', () => { + const testNamespaceClasses = ['aLittleSpecific', 'moreSpecific', 'reallySpecific'] + expect( + getComponentCssSelector(testComponentName, { namespaceClasses: testNamespaceClasses }) + ).toEqual(`.aLittleSpecific .moreSpecific .reallySpecific .${testComponentName}`) + }); +})