Skip to content

Commit

Permalink
Add support for descendant selectors.
Browse files Browse the repository at this point in the history
Summary: It turns out descendant selectors are kinda hard. In particular, it's
difficult to let *descendants have unique classnames* for themselves, at the
same time as allowing *merging between styles which contain descendant
selectors*.

This pull request attempts to do both of these things. The code is a bit messy,
so I'll lay out what's going on in this pull request. Please ask questions, if
something is confusing! The Aphrodite code is nice because it's all fairly
easily understood, and I'm afraid that this adding too much complexity.

An example of the syntax (and an example for the explanation in this commit
message), consider:

```js
const styles = StyleSheet.create({
    parent: {
        '>>child': {
            color: "red"
        },
        ':hover': {
            '>>child': {
                color: "blue"
            },
            '>>otherchild': {
                color: "white"
            }
        },
    },

    altparent: {
        ':hover': {
            '>>child': {
                color: "green"
            }
        }
    }
});

The basic flow of this diff is:

1. In `StyleSheet.create`, we recurse through the passed-in styles, and find
each of the descendant selectors (which have keys that look like `>>blah`). In
the example above, it would find `parent['>>child']`,
`parent[':hover']['>>child']`, `parent[':hover']['>>otherchild']`, and
`altparent[':hover']['>>child']`. In each place, we:

  - generate a class name for that descendant selector. This is based on the
    class name of the parent class, as well as the key name.

    For example, if the class name for `styles.parent` was `parent_abcdef`, we
    might generate the class name `parent_abcdef__child` for `parent['>>child']`.

  - tag the style by adding a `_names` object, with the class name as a key of
    the object.

    For example, `parent['>>child']` would end up looking like `{ color: "red",
    _names: { parent_abcdef__child: true } }`.

  - collect a map of each of the keys (without the `>>` bit) to their class
    names.

    For example, for `styles.parent`, we would generate a map that looks like
    `{ child: "parent_abcdef__child", otherchild: "parent_abcdef__otherchild"
    }`.

We merge in the map from key to class name into the generated style, so that
the class names can be accessed using a syntax like `styles.parent.child`.

2. When *parent* styles are passed into `css()`, their styles are merged
together. If one style overrides another's descendant styling, the `_names`
object will be merged together and will contain all of the associated class
names.

For example, when evaluating `css(styles.parent, styles.altparent)`, we would
end up with merged styles looking like:
```
{
    '>>child': {
        color: "red",
        _names: { parent_abcdef__child: true },
    },
    ':hover': {
        '>>child': {
            color: "green",
            _names: {
                parent_abcdef__child: true,
                altparent_123456__child: true,
            },
        },
        '>>otherchild': {
            color: "white",
            _names: { parent_abcdef__otherchild: true },
        }
    }
}
```

We then generate a map from the descendent keys to all of the class names that
could be associated with a given key by recursing and looking at each of the
`_names` objects. For example, the map would look like:

```
{
    '>>child': ["parent_abcdef__child", "altparent_123456__child"],
    '>>otherchild': ["parent_abcdef__otherchild"]
}
```

When generating the styles, we look at this map and then generate styles for
each of the classnames listed. This is so that these styles will match up with
uses of both `css(styles.parent.child)` and `css(styles.altparent.child)`.

For example, when generating the `style[':hover']['>>child']` styles, we
generate:

```
.parent_abcdef-o_O-altparent_123456:hover .parent_abcdef__child { ... }
.parent_abcdef-o_O-altparent_123456:hover .altparent_123456__child { ... }
```

3. When *descendant* styles are passed into `css()`, like
`css(styles.parent.child)`, we simply return the associated class name (in this
case, `"parent_abcdef__child"`) in the output.

Fixes #10

Test Plan:
 - `npm run test`
 - `cd examples && npm run examples`, then visit http://localhost:4114/ and see
   that the last line starts green and when "Hover over me" is hovered, the
   other part turns blue.

@zgotsch @jlfwong @kentcdodds @montemishkin
  • Loading branch information
xymostech committed May 10, 2016
1 parent 3ba5a3e commit 564fa7f
Show file tree
Hide file tree
Showing 7 changed files with 653 additions and 33 deletions.
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,45 @@ 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({
someContainer: {
":hover": {
">>myChild": {
// Styles to apply to the child when the parent is
// hovered over.
color: "red",
fontWeight: "bold"
}
}
}
});
```

Then, apply the `someContainer` style using `css(styles.someContainer)`, and add the child's style to a descendant of the parent using `css(styles.someContainer.myChild)`, where `myChild` is the name of the key you used, without the `>>`.

For example, using React's jsx syntax:

```jsx
<div className={css(styles.someContainer, ...)}>
<div className={css(styles.someContainer.myChild, ...)}>
This turns red and bold when the parent is hovered over
</div>
</div>
```

This will generate CSS that looks like:
```css
.someContainer_xxx:hover .myChild_xxx {
color: red;
font-weight: bold;
}
```

# Caveats

## Assigning a string to a content property for a pseudo-element
Expand Down
241 changes: 226 additions & 15 deletions dist/aphrodite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 descendant 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) {
Expand All @@ -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))];
});
},

Expand Down Expand Up @@ -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];
Expand All @@ -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'] = {
Expand Down Expand Up @@ -886,38 +943,171 @@ module.exports =

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }

function _objectWithoutProperties(obj, keys) { var target = {}; for (var i in obj) { if (keys.indexOf(i) >= 0) continue; if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; target[i] = obj[i]; } return target; }

var _inlineStylePrefixAll = __webpack_require__(7);

var _inlineStylePrefixAll2 = _interopRequireDefault(_inlineStylePrefixAll);

var _util = __webpack_require__(2);

/**
* Generate a map from descendant selector names to all of the classnames that
* could be used for that selector.
*
* In StyleSheet.create, we add a special `_names` object to each place where a
* descendant style is used containing the classname associated with that
* descenant. Here, we traverse the styles and make a map from the descendant
* selectors to all of the classnames in those `_names` objects. (Since we
* merge together many styles that come out of StyleSheet.create, and we store
* the classnames as keys on the `_names` object, the classnames that we want
* all end up as keys on the final merged object.)
*
* For example, given styles looking like:
* ```
* {
* ">>child": {
* _names: { "parent1_child_x": true, "parent2_child_y": true },
* color: "red"
* },
* ":hover": {
* ">>child": {
* _names: { "parent2_child_z": true },
* color: "blue",
* },
* ">>otherchild": {
* _names: { "parent1_otherchild_w": true },
* color: "green"
* }
* }
* }
* ```
* this will generate the mapping
* ```
* {
* ">>child": ["parent1_child_x", "parent2_child_y", "parent2_child_z"],
* ">>otherchild": ["parent1_otherchild_w"]
* }
*
* @returns {Object.<string, string[]>}
*/
var findNamesForDescendants = function findNamesForDescendants(styles) {
var names = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];

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);
});
}
});

return names;
};

var generateCSS = function generateCSS(selector, styleTypes, stringHandlers, useImportant) {
var merged = styleTypes.reduce(_util.recursiveMerge);
var classNamesForDescendant = findNamesForDescendants(merged);

return generateCSSInner(selector, merged, stringHandlers, useImportant, classNamesForDescendant);
};

exports.generateCSS = generateCSS;
/**
* Generate CSS for a selector and some styles.
*
* This function handles the media queries, pseudo selectors, and descendant
* styles that can be used in aphrodite styles. To actually generate the CSS,
* special-construct-less styles are passed to `generateCSSRuleset`.
*
* For instance, a call to
* ```
* generateCSSInner(".foo", {
* color: "red",
* "@media screen": {
* height: 20,
* ":hover": {
* backgroundColor: "black"
* }
* },
* ":active": {
* fontWeight: "bold",
* ">>bar": {
* _names: { "foo_bar": true },
* height: 10,
* }
* }
* }, ...);
* ```
* will make 5 calls to `generateCSSRuleset`:
* ```
* generateCSSRuleset(".foo", { color: "red" }, ...)
* generateCSSRuleset(".foo:active", { fontWeight: "bold" }, ...)
* generateCSSRuleset(".foo:active .foo_bar", { height: 10 }, ...)
* // These 2 will be wrapped in @media screen {}
* generateCSSRuleset(".foo", { height: 20 }, ...)
* generateCSSRuleset(".foo:hover", { backgroundColor: "black" }, ...)
* ```
*
* @param {string} selector: A base CSS selector for the styles to be generated
* with.
* @param {Object} style: An object containing aphrodite styles to be
* generated.
* @param stringHandlers: See `generateCSSRuleset`
* @param useImportant: See `generateCSSRuleset`
* @param {Object.<string, string[]>} classNamesForDescendant: A map from
* descendent selectors in the styles to a list of classnames that are used
* to identify that descendant. See `findNamesForDescendants`.
*/
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,
// pull the `_names` value out of the styles.
var _style$key = style[key];
var _names = _style$key._names;

var stylesWithoutNames = _objectWithoutProperties(_style$key, ['_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 = {};

Expand All @@ -934,6 +1124,27 @@ module.exports =
return result;
};

/**
* Generate a CSS ruleset with the selector and containing the declarations.
*
* This function assumes that the given declarations don't contain any special
* children (such as media queries, pseudo-selectors, or descendant styles).
*
* Example:
* ```
* generateCSSRuleset(".blah", { color: "red" });
* // -> ".blah{color: red !important;}"
* ```
*
* @param {string} selector: the selector associated with the ruleset
* @param {Object} declarations: a map from camelCased CSS property name to CSS
* property value.
* @param {Object.<string, function>} stringHandlers: a map from camelCased CSS
* property name to a function which will map the given value to the value
* that is output.
* @param {bool} useImportant: A boolean saying whether to append "!important"
* to each of the CSS declarations.
*/
var generateCSSRuleset = function generateCSSRuleset(selector, declarations, stringHandlers, useImportant) {
var handledDeclarations = runStringHandlers(declarations, stringHandlers);

Expand Down
16 changes: 16 additions & 0 deletions examples/src/StyleTester.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const StyleTester = React.createClass({
<a href="javascript: void 0" className={css(styles.pseudoSelectors)}>This should turn red on hover and ???? (blue or red) on active</a>,
<div className={css(styles.flexCenter)}><div className={css(styles.flexInner)}>This should be centered inside the outer box, even in IE 10.</div></div>,
<span className={css(styles.animate)}>This should animate</span>,
<span className={css(styles.hoverbase, styles.hoverextra)}>Hover over me and <span className={css(styles.red, styles.hoverbase.hoverchild)}>this (which should start green) should turn blue</span>
</span>,
];

return <div>
Expand Down Expand Up @@ -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({
Expand Down
Loading

0 comments on commit 564fa7f

Please sign in to comment.