diff --git a/README.md b/README.md index af41c91..c286d2f 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,37 @@ const styles = StyleSheet.create({ Aphrodite will ensure that the global `@font-face` rule for this font is only inserted once, no matter how many times it's referenced. +# Descendant selectors + +Descendant rules (i.e. a rule like `.parent:hover .child { ... }` in CSS) are created by adding values to your styles indicating the styles to apply to the child. To distinguish the styles from normal CSS keys, the keys start with a `>>`, like `'>>child'`. + +```js +const styles = StyleSheet.create({ + parent: { + ":hover": { + ">>my_child": { + // Styles to apply to the child when the parent is + // hovered over. + color: "red", + fontWeight: "bold" + } + } + } +}); +``` + +Then, apply the `parent` style using `css(styles.parent)`, and add the child's style to a descendant of the parent using `css(styles.parent.my_child)`, where `my_child` is the name of the key you used, without the `>>`. + +For example, using React's jsx syntax: + +```jsx +
+
+ This turns red and bold when the parent is hovered over +
+
+``` + # Caveats ## Assigning a string to a content property for a pseudo-element diff --git a/dist/aphrodite.js b/dist/aphrodite.js index 32a42b5..e719c56 100644 --- a/dist/aphrodite.js +++ b/dist/aphrodite.js @@ -60,10 +60,48 @@ module.exports = var _slicedToArray = (function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i['return']) _i['return'](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError('Invalid attempt to destructure non-iterable instance'); } }; })(); + var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; + + function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; return arr2; } else { return Array.from(arr); } } + + function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } + var _util = __webpack_require__(2); var _inject = __webpack_require__(3); + // TODO(emily): Make a 'production' mode which doesn't prepend the class name + // here, to make the generated CSS smaller. + var makeClassName = function makeClassName(key, vals) { + return key + '_' + (0, _util.hashObject)(vals); + }; + + // Find all of the references to descendent styles in a given definition, + // generates a class name for each of them based on the class name of the + // parent, and stores that name in a special `_names` object on the style. + var findAndTagDescendants = function findAndTagDescendants(styles, base) { + var names = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2]; + + Object.keys(styles).forEach(function (key) { + if (key[0] === ':' || key[0] === '@') { + findAndTagDescendants(styles[key], base, names); + } else if (key[0] === '>' && key[1] === '>') { + findAndTagDescendants(styles[key], base, names); + + var _name = base + '__' + key.slice(2); + + names[key.slice(2)] = { + _isPlainClassName: true, + _className: _name + }; + + styles[key]._names = _defineProperty({}, _name, true); + } + }); + + return names; + }; + var StyleSheet = { create: function create(sheetDefinition) { return (0, _util.mapObj)(sheetDefinition, function (_ref) { @@ -72,12 +110,12 @@ module.exports = var key = _ref2[0]; var val = _ref2[1]; - return [key, { - // TODO(emily): Make a 'production' mode which doesn't prepend - // the class name here, to make the generated CSS smaller. - _name: key + '_' + (0, _util.hashObject)(val), + var name = makeClassName(key, val); + + return [key, _extends({ + _name: name, _definition: val - }]; + }, findAndTagDescendants(val, name))]; }); }, @@ -105,6 +143,10 @@ module.exports = } }; + var isPlainClassName = function isPlainClassName(def) { + return def._isPlainClassName; + }; + var css = function css() { for (var _len = arguments.length, styleDefinitions = Array(_len), _key = 0; _key < _len; _key++) { styleDefinitions[_key] = arguments[_key]; @@ -121,14 +163,29 @@ module.exports = return ""; } - var className = validDefinitions.map(function (s) { + // Filter out "plain class name" arguments, which just want us to add a + // classname to the end result, instead of generating styles. + var plainClassNames = validDefinitions.filter(isPlainClassName).map(function (def) { + return def._className; + }); + + var otherDefinitions = validDefinitions.filter(function (def) { + return !isPlainClassName(def); + }); + + // If there are only plain class names, just join those. + if (otherDefinitions.length === 0) { + return plainClassNames.join(" "); + } + + var className = otherDefinitions.map(function (s) { return s._name; }).join("-o_O-"); - (0, _inject.injectStyleOnce)(className, '.' + className, validDefinitions.map(function (d) { + (0, _inject.injectStyleOnce)(className, '.' + className, otherDefinitions.map(function (d) { return d._definition; })); - return className; + return [className].concat(_toConsumableArray(plainClassNames)).join(" "); }; exports['default'] = { @@ -218,6 +275,9 @@ module.exports = */ var isUnitlessNumber = { animationIterationCount: true, + borderImageOutset: true, + borderImageSlice: true, + borderImageWidth: true, boxFlex: true, boxFlexGroup: true, boxOrdinalGroup: true, @@ -243,8 +303,11 @@ module.exports = // SVG-related properties fillOpacity: true, + floodOpacity: true, stopOpacity: true, + strokeDasharray: true, strokeDashoffset: true, + strokeMiterlimit: true, strokeOpacity: true, strokeWidth: true }; @@ -350,13 +413,13 @@ module.exports = }; exports.hashObject = hashObject; - var importantRegexp = /^([^:]+:.*?)( !important)?$/; + var IMPORTANT_RE = /^([^:]+:.*?)( !important)?$/; // Given a style string like "a: b; c: d;", adds !important to each of the // properties to generate "a: b !important; c: d !important;". var importantify = function importantify(string) { return string.split(";").map(function (str) { - return str.replace(importantRegexp, function (_, base, important) { + return str.replace(IMPORTANT_RE, function (_, base, important) { return base + " !important"; }); }).join(";"); @@ -878,6 +941,8 @@ module.exports = var _slicedToArray = (function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i['return']) _i['return'](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError('Invalid attempt to destructure non-iterable instance'); } }; })(); + var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } var _inlineStylePrefixer = __webpack_require__(7); @@ -886,32 +951,75 @@ module.exports = var _util = __webpack_require__(2); + // In a bundle of styles, find all of the different class names that a single + // named descendant selector can have. + var findNamesForDescendants = function findNamesForDescendants(styles, names) { + Object.keys(styles).forEach(function (key) { + if (key[0] === ':' || key[0] === '@') { + // Recurse for pseudo or @media styles + findNamesForDescendants(styles[key], names); + } else if (key[0] === '>' && key[1] === '>') { + // Recurse for descendant styles + findNamesForDescendants(styles[key], names); + + // Pluck out all of the names in the _names object. + Object.keys(styles[key]._names).forEach(function (name) { + names[key] = names[key] || []; + names[key].push(name); + }); + } + }); + }; + var generateCSS = function generateCSS(selector, styleTypes, stringHandlers, useImportant) { var merged = styleTypes.reduce(_util.recursiveMerge); + var classNamesForDescendant = {}; + findNamesForDescendants(merged, classNamesForDescendant); + + return generateCSSInner(selector, merged, stringHandlers, useImportant, classNamesForDescendant); + }; + + exports.generateCSS = generateCSS; + var generateCSSInner = function generateCSSInner(selector, style, stringHandlers, useImportant, classNamesForDescendant) { var declarations = {}; var mediaQueries = {}; + var descendants = {}; var pseudoStyles = {}; - Object.keys(merged).forEach(function (key) { + Object.keys(style).forEach(function (key) { if (key[0] === ':') { - pseudoStyles[key] = merged[key]; + pseudoStyles[key] = style[key]; } else if (key[0] === '@') { - mediaQueries[key] = merged[key]; + mediaQueries[key] = style[key]; + } else if (key[0] === '>' && key[1] === '>') { + (function () { + // So we don't generate weird "_names: [Object object]" styles, + // make a copy of the styles and get rid of the _names value. + var stylesWithoutNames = _extends({}, style[key]); + delete stylesWithoutNames._names; + + // Since our child might have many different names, generate the + // styles for all of the possible ones. + classNamesForDescendant[key].forEach(function (name) { + descendants[name] = stylesWithoutNames; + }); + })(); } else { - declarations[key] = merged[key]; + declarations[key] = style[key]; } }); return generateCSSRuleset(selector, declarations, stringHandlers, useImportant) + Object.keys(pseudoStyles).map(function (pseudoSelector) { - return generateCSSRuleset(selector + pseudoSelector, pseudoStyles[pseudoSelector], stringHandlers, useImportant); + return generateCSSInner(selector + pseudoSelector, pseudoStyles[pseudoSelector], stringHandlers, useImportant, classNamesForDescendant); }).join("") + Object.keys(mediaQueries).map(function (mediaQuery) { - var ruleset = generateCSS(selector, [mediaQueries[mediaQuery]], stringHandlers, useImportant); + var ruleset = generateCSSInner(selector, mediaQueries[mediaQuery], stringHandlers, useImportant, classNamesForDescendant); return mediaQuery + '{' + ruleset + '}'; + }).join("") + Object.keys(descendants).map(function (descendant) { + return generateCSSInner(selector + ' .' + descendant, descendants[descendant], stringHandlers, useImportant, classNamesForDescendant); }).join(""); }; - exports.generateCSS = generateCSS; var runStringHandlers = function runStringHandlers(declarations, stringHandlers) { var result = {}; diff --git a/examples/src/StyleTester.js b/examples/src/StyleTester.js index 38b7269..ae73d9c 100644 --- a/examples/src/StyleTester.js +++ b/examples/src/StyleTester.js @@ -38,6 +38,8 @@ const StyleTester = React.createClass({ This should turn red on hover and ???? (blue or red) on active,
This should be centered inside the outer box, even in IE 10.
, This should animate, + Hover over me and this (which should start green) should turn blue + , ]; return
@@ -144,6 +146,20 @@ const styles = StyleSheet.create({ animationDuration: '2s', animationIterationCount: 'infinite', }, + + hoverbase: { + ':hover': { + '>>hoverchild': { + color: 'blue', + }, + }, + }, + + hoverextra: { + '>>hoverchild': { + color: 'green', + }, + }, }); const styles2 = StyleSheet.create({ diff --git a/src/generate.js b/src/generate.js index d7748fc..79eff4c 100644 --- a/src/generate.js +++ b/src/generate.js @@ -2,24 +2,68 @@ import Prefixer from 'inline-style-prefixer'; import { objectToPairs, kebabifyStyleName, recursiveMerge, stringifyValue, - importantify + importantify, } from './util'; +// In a bundle of styles, find all of the different class names that a single +// named descendant selector can have. +const findNamesForDescendants = (styles, names) => { + Object.keys(styles).forEach(key => { + if (key[0] === ':' || key[0] === '@') { + // Recurse for pseudo or @media styles + findNamesForDescendants(styles[key], names); + } else if (key[0] === '>' && key[1] === '>') { + // Recurse for descendant styles + findNamesForDescendants(styles[key], names); + + // Pluck out all of the names in the _names object. + Object.keys(styles[key]._names).forEach(name => { + names[key] = names[key] || []; + names[key].push(name); + }); + } + }); +}; + export const generateCSS = (selector, styleTypes, stringHandlers, - useImportant) => { + useImportant) => { const merged = styleTypes.reduce(recursiveMerge); + const classNamesForDescendant = {}; + findNamesForDescendants(merged, classNamesForDescendant); + + return generateCSSInner( + selector, merged, stringHandlers, useImportant, + classNamesForDescendant); +} + +const generateCSSInner = (selector, style, stringHandlers, + useImportant, classNamesForDescendant) => { const declarations = {}; const mediaQueries = {}; + const descendants = {}; const pseudoStyles = {}; - Object.keys(merged).forEach(key => { + Object.keys(style).forEach(key => { if (key[0] === ':') { - pseudoStyles[key] = merged[key]; + pseudoStyles[key] = style[key]; } else if (key[0] === '@') { - mediaQueries[key] = merged[key]; + mediaQueries[key] = style[key]; + } else if (key[0] === '>' && key[1] === '>') { + // So we don't generate weird "_names: [Object object]" styles, + // make a copy of the styles and get rid of the _names value. + const stylesWithoutNames = { + ...style[key], + }; + delete stylesWithoutNames._names; + + // Since our child might have many different names, generate the + // styles for all of the possible ones. + classNamesForDescendant[key].forEach(name => { + descendants[name] = stylesWithoutNames; + }); } else { - declarations[key] = merged[key]; + declarations[key] = style[key]; } }); @@ -27,14 +71,20 @@ export const generateCSS = (selector, styleTypes, stringHandlers, generateCSSRuleset(selector, declarations, stringHandlers, useImportant) + Object.keys(pseudoStyles).map(pseudoSelector => { - return generateCSSRuleset(selector + pseudoSelector, - pseudoStyles[pseudoSelector], - stringHandlers, useImportant); + return generateCSSInner( + selector + pseudoSelector, pseudoStyles[pseudoSelector], + stringHandlers, useImportant, classNamesForDescendant); }).join("") + Object.keys(mediaQueries).map(mediaQuery => { - const ruleset = generateCSS(selector, [mediaQueries[mediaQuery]], - stringHandlers, useImportant); + const ruleset = generateCSSInner( + selector, mediaQueries[mediaQuery], stringHandlers, + useImportant, classNamesForDescendant); return `${mediaQuery}{${ruleset}}`; + }).join("") + + Object.keys(descendants).map(descendant => { + return generateCSSInner( + `${selector} .${descendant}`, descendants[descendant], + stringHandlers, useImportant, classNamesForDescendant); }).join("") ); }; diff --git a/src/index.js b/src/index.js index 21b8573..4c14a60 100644 --- a/src/index.js +++ b/src/index.js @@ -5,14 +5,43 @@ import { addRenderedClassNames, getRenderedClassNames } from './inject'; +// TODO(emily): Make a 'production' mode which doesn't prepend the class name +// here, to make the generated CSS smaller. +const makeClassName = (key, vals) => `${key}_${hashObject(vals)}`; + +// Find all of the references to descendent styles in a given definition, +// generates a class name for each of them based on the class name of the +// parent, and stores that name in a special `_names` object on the style. +const findAndTagDescendants = (styles, base, names={}) => { + Object.keys(styles).forEach(key => { + if (key[0] === ':' || key[0] === '@') { + findAndTagDescendants(styles[key], base, names); + } else if (key[0] === '>' && key[1] === '>') { + findAndTagDescendants(styles[key], base, names); + + const name = `${base}__${key.slice(2)}`; + + names[key.slice(2)] = { + _isPlainClassName: true, + _className: name, + }; + + styles[key]._names = { [name]: true }; + } + }); + + return names; +}; + const StyleSheet = { create(sheetDefinition) { return mapObj(sheetDefinition, ([key, val]) => { + const name = makeClassName(key, val); + return [key, { - // TODO(emily): Make a 'production' mode which doesn't prepend - // the class name here, to make the generated CSS smaller. - _name: `${key}_${hashObject(val)}`, - _definition: val + _name: name, + _definition: val, + ...findAndTagDescendants(val, name), }]; }); }, @@ -39,6 +68,8 @@ const StyleSheetServer = { }, }; +const isPlainClassName = (def) => def._isPlainClassName; + const css = (...styleDefinitions) => { // Filter out falsy values from the input, to allow for // `css(a, test && c)` @@ -49,11 +80,23 @@ const css = (...styleDefinitions) => { return ""; } - const className = validDefinitions.map(s => s._name).join("-o_O-"); + // Filter out "plain class name" arguments, which just want us to add a + // classname to the end result, instead of generating styles. + const plainClassNames = validDefinitions.filter(isPlainClassName).map(def => def._className); + + const otherDefinitions = validDefinitions.filter( + def => !isPlainClassName(def)); + + // If there are only plain class names, just join those. + if (otherDefinitions.length === 0) { + return plainClassNames.join(" "); + } + + const className = otherDefinitions.map(s => s._name).join("-o_O-"); injectStyleOnce(className, `.${className}`, - validDefinitions.map(d => d._definition)); + otherDefinitions.map(d => d._definition)); - return className; + return [className, ...plainClassNames].join(" "); }; export default { diff --git a/tests/generate_test.js b/tests/generate_test.js index d4f3f92..0a2523b 100644 --- a/tests/generate_test.js +++ b/tests/generate_test.js @@ -134,4 +134,120 @@ describe('generateCSS', () => { display: 'flex', }], '.foo{display:-webkit-box !important;display:-moz-box !important;display:-ms-flexbox !important;display:-webkit-flex !important;display:flex !important;}'); }); + + it('generates descendant styles', () => { + assertCSS('.foo', [{ + color: 'red', + '>>blue': { + color: 'blue', + '>>green': { + color: 'green', + _names: { + 'foo__green': true, + }, + }, + _names: { + 'foo__blue': true, + }, + } + }], '.foo{color:red !important;}' + + '.foo .foo__blue{color:blue !important;}' + + '.foo .foo__blue .foo__green{color:green !important;}'); + }); + + it('handles merging of descendant styles', () => { + assertCSS('.foo', [{ + '>>blue': { + color: 'blue', + _names: { + 'foo_abcdef__blue': true, + }, + }, + }, { + '>>blue': { + color: 'green', + _names: { + 'foo_123456__blue': true, + }, + }, + }], '.foo .foo_abcdef__blue{color:green !important;}' + + '.foo .foo_123456__blue{color:green !important;}'); + }); + + it('handles multiples of the same descendant name', () => { + assertCSS('.foo', [{ + '>>blue': { + color: 'blue', + _names: { + 'foo__blue': true, + }, + }, + ':hover': { + '>>blue': { + color: 'green', + _names: { + 'foo__blue': true, + }, + }, + }, + }], '.foo:hover .foo__blue{color:green !important;}' + + '.foo .foo__blue{color:blue !important;}'); + }); + + it('handles merging and multiples for descenant styles', () => { + assertCSS('.foo', [{ + '>>child': { + color: 'blue', + _names: { + 'foo_abcdef__child': true, + }, + }, + ':hover': { + '>>child': { + color: 'green', + _names: { + 'foo_abcdef__child': true, + }, + }, + }, + }, { + ':hover': { + '>>child': { + color: 'red', + _names: { + 'foo_123456__child': true, + }, + }, + }, + }], '.foo:hover .foo_abcdef__child{color:red !important;}' + + '.foo:hover .foo_123456__child{color:red !important;}' + + '.foo .foo_abcdef__child{color:blue !important;}' + + '.foo .foo_123456__child{color:blue !important;}'); + }); + + it('generates descendant styles with @media queries', () => { + assertCSS('.foo', [{ + '@media screen': { + '>>blue': { + color: 'blue', + _names: { + 'foo__blue': true, + }, + } + } + }], '@media screen{.foo .foo__blue{color:blue !important;}}'); + }); + + it('generates descendant styles with pseudo-styles', () => { + assertCSS('.foo', [{ + ':hover': { + '>>blue': { + color: 'blue', + _names: { + 'foo__blue': true, + }, + } + } + }], '.foo:hover .foo__blue{color:blue !important;}'); + }); }); diff --git a/tests/index_test.js b/tests/index_test.js index 8df02b1..5251e6f 100644 --- a/tests/index_test.js +++ b/tests/index_test.js @@ -4,6 +4,7 @@ import jsdom from 'jsdom'; import { StyleSheet, StyleSheetServer, css } from '../src/index.js'; import { reset } from '../src/inject.js'; +import { hashObject } from '../src/util.js'; describe('css', () => { beforeEach(() => { @@ -118,6 +119,44 @@ describe('css', () => { done(); }); }); + + it('adds extra class names from descendants', () => { + const sheet = StyleSheet.create({ + base: { + '>>child': { + color: 'red', + } + }, + + blue: { + color: 'blue', + }, + }); + + assert.equal( + css(sheet.blue, sheet.base.child), + `blue_${hashObject({ color: "blue" })} ${sheet.base._name}__child`); + }); + + it('uses the same class name for two different descendants', () => { + const sheet = StyleSheet.create({ + base: { + '>>child': { + color: 'red', + }, + + ":hover": { + '>>child': { + color: 'blue', + }, + }, + }, + }); + + assert.deepEqual( + sheet.base._definition[">>child"]._names, + sheet.base._definition[":hover"][">>child"]._names); + }); }); describe('StyleSheet.create', () => {