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 Apr 20, 2016
1 parent 9f286ed commit 827c851
Show file tree
Hide file tree
Showing 7 changed files with 438 additions and 35 deletions.
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div className={css(styles.parent, ...)}>
<div className={css(styles.parent.my_child, ...)}>
This turns red and bold when the parent is hovered over
</div>
</div>
```

# Caveats

## Assigning a string to a content property for a pseudo-element
Expand Down
142 changes: 125 additions & 17 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 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) {
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 @@ -218,6 +275,9 @@ module.exports =
*/
var isUnitlessNumber = {
animationIterationCount: true,
borderImageOutset: true,
borderImageSlice: true,
borderImageWidth: true,
boxFlex: true,
boxFlexGroup: true,
boxOrdinalGroup: true,
Expand All @@ -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
};
Expand Down Expand Up @@ -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(";");
Expand Down Expand Up @@ -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);
Expand All @@ -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 = {};

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 827c851

Please sign in to comment.