Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,42 @@ export default withStyles(({ color, unit }) => ({

Some components depend on previous styles to be ready in the component tree when mounting (e.g. dimension calculations). Some interfaces add styles to the page asynchronously, which is an obstacle for this. So, we provide the option of flushing the buffered styles before the rendering cycle begins. It is up to the interface to define what this means.

#### `extendableStyles` (default: `{}`)

By default, components created using `withStyles()` will not be extendable. To extend a component's style or styles, you must define the paths and predicates that dictate which styles can be extended and with what values. This is useful if your component wants to restrict some styles, while allowing consumers of the component to have flexibility around others. See the `extendStyles()` section for more info on how to extend styles.

```jsx
import React from 'react';
import { css, withStyles } from './withStyles';

function MyComponent({ withStylesStyles }) {
return (
<div {...css(withStylesStyles.container)}>
Try to be a rainbow in someone's cloud.
</div>
);
}

export default withStyles(
({ color, unit }) => ({
container: {
color: color.primary,
background: color.secondary,
marginBottom: 2 * unit,
},
}),
{
extendableStyles: {
container: {
color: (value, theme) => true,
background: (value, theme) => value === theme.color.primary || value === theme.color.secondary
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good if there was a babel plugin to remove these validator functions in production builds.

},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be really great to have an eslint rule that ensures that the shape of the extendableStyles object stays in sync with the shape of the styles object.

https://github.com/airbnb/eslint-plugin-react-with-styles

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a rule for the above.
airbnb/eslint-plugin-react-with-styles#37

},
},
)(MyComponent);
```

The `container.color` predicate allows for any value to be passed in. The `container.background` predicate only allows for the primary and secondary color to be passed.

## `css(...styles)`

Expand Down Expand Up @@ -287,6 +323,57 @@ export default withStyles(({ color, unit }) => ({

`className` and `style` props must not be used on the same elements as `css()`.

## `extendStyles()`

Components that define an "extendableStyles" option allow consumers to extend certain styles via the `extendStyles()` static property. This function takes in a styles thunk, exactly like `withStyles()`, and should return the extended styles. Any styles that are not explicitly defined in the "extendableStyles" option or do not pass the style's predicate function will throw an error.

```jsx
import React from 'react';
import { css, withStyles } from './withStyles';

function MyComponent({ withStylesStyles }) {
return (
<div {...css(withStylesStyles.container)}>
Try to be a rainbow in someone's cloud.
</div>
);
}

const BaseMyComponent = withStyles(
({ color, unit }) => ({
container: {
color: color.primary,
background: color.secondary,
marginBottom: 2 * unit,
},
}),
{
extendableStyles: {
container: {
color: (value, theme) => true,
background: (value, { color }) => value === color.primary || color.secondary,
},
},
},
)(MyComponent);

const ExtendedMyComponent = BaseMyComponent.extendStyles(({ color }) => ({
container: {
color: color.secondary,
background: color.primary,
},
}));
```

You can extend a Component that already extending another Component. All validation will still occur.
```jsx
const NestedExtendedMyComponent = ExtendedMyComponent.extendStyles(() => ({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that TypeScript currently has some troubles with HOCs, but it looks like it might be getting better soon (microsoft/TypeScript#30215). Have you given any thought into how this might work with TypeScript?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah! I think TS's ability to autocomplete and validate arguments will be super nice with extendStyles. I'm sure we could re-use a lot of the PropsType work that Brie has already setup and apply it to extendStyles argument. This should be a super nice for developers so they don't have to dig into the component to know which styles can be extended.

container: {
color: 'red',
},
}));
```

## Examples
### With React Router's `Link`
[React Router][react-router]'s [`<Link/>`][react-router-link] and [`<IndexLink/>`][react-router-index-link] components accept `activeClassName='...'` and `activeStyle={{...}}` as props. As previously stated, `css(...styles)` must spread to JSX, so simply passing `styles.thing` or even `css(styles.thing)` directly will not work. In order to mimic `activeClassName`/`activeStyles` you can use React Router's [`withRouter()`][react-router-with-router] Higher Order Component to pass `router` as prop to your component and toggle styles based on [`router.isActive(pathOrLoc, indexOnly)`](react-router-is-active). This works because `<Link />` passes down the generated `className` from `css(..styles)` down through to the final leaf.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"react-with-direction": "^1.1.0"
},
"dependencies": {
"deepmerge": "^3.2.0",
"hoist-non-react-statics": "^3.2.1",
"object.assign": "^4.1.0",
"prop-types": "^15.6.2",
Expand Down
56 changes: 56 additions & 0 deletions src/extendStyles.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/* eslint react/forbid-foreign-prop-types: off */

import deepmerge from 'deepmerge';

// Recursively iterate through the style object, validating the same path exists in the
// extendableStyles object.
function validateStyle(style, extendableStyles, theme, path = '') {
if (process.env.NODE_ENV !== 'production') {
// Stop recursively validating when we hit a style's value and validate the value passes the
// style's predicate
if (!style || Array.isArray(style) || typeof style !== 'object') {
const stylePredicate = extendableStyles;
if (typeof stylePredicate !== 'function') {
throw new Error(`withStyles() style predicate should be a function: "${path}". Check the component's "extendableStyles" option.`);
}

const isValid = stylePredicate(style, theme);
if (!isValid) {
throw new Error(`withStyles() style did not pass the predicate: "${path}": ${style}. Check the component's "extendableStyles" option.`);
}

return;
}

const styleKeys = Object.keys(style);
if (styleKeys.length > 0) {
styleKeys.forEach((styleKey) => {
const currentPath = `${path}.${styleKey}`;
const isValid = extendableStyles[styleKey];
if (!isValid) {
throw new Error(
`withStyles() extending style is invalid: "${currentPath}". If this style is expected, add it to the component's "extendableStyles" option.`,
);
}
validateStyle(style[styleKey], extendableStyles[styleKey], theme, currentPath);
});
}
}
}

export default function extendStyles(
baseStyleFn,
extendingStyleFn,
extendableStyles,
) {
return (theme) => {
const baseStyle = baseStyleFn(theme);
const extendingStyle = extendingStyleFn(theme);

validateStyle(extendingStyle, extendableStyles, theme);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you think of a good way to surface this validation before runtime (e.g. in the editor and at build time)--maybe via TypeScript or ESLint? Most linter rules don't cross module boundaries, but there are some in eslint-plugin-import that do.


const styles = deepmerge(baseStyle, extendingStyle);

return styles;
};
}
17 changes: 17 additions & 0 deletions src/withStyles.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import hoistNonReactStatics from 'hoist-non-react-statics';
import { CHANNEL, DIRECTIONS } from 'react-with-direction/dist/constants';
import brcastShape from 'react-with-direction/dist/proptypes/brcast';

import extendStyles from './extendStyles';
import ThemedStyleSheet from './ThemedStyleSheet';

// Add some named exports to assist in upgrading and for convenience
Expand Down Expand Up @@ -44,6 +45,7 @@ export function withStyles(
stylesPropName = 'styles',
themePropName = 'theme',
cssPropName = 'css',
extendableStyles = {},
flushBefore = false,
pureComponent = false,
} = {},
Expand Down Expand Up @@ -197,6 +199,7 @@ export function withStyles(
WithStyles.WrappedComponent = WrappedComponent;
WithStyles.displayName = `withStyles(${wrappedComponentName})`;
WithStyles.contextTypes = contextTypes;

if (WrappedComponent.propTypes) {
WithStyles.propTypes = { ...WrappedComponent.propTypes };
delete WithStyles.propTypes[stylesPropName];
Expand All @@ -207,6 +210,20 @@ export function withStyles(
WithStyles.defaultProps = { ...WrappedComponent.defaultProps };
}

if (extendableStyles && Object.keys(extendableStyles).length !== 0) {
WithStyles.extendStyles = extendStyleFn => withStyles(
extendStyles(styleFn, extendStyleFn, extendableStyles),
{
stylesPropName,
themePropName,
cssPropName,
extendableStyles,
flushBefore,
pureComponent,
},
)(WrappedComponent);
}

return hoistNonReactStatics(WithStyles, WrappedComponent);
};
}
Loading