chrisui / react-cig

React Component Interoperability Guidelines
8 stars 0 forks source link

Styling helper proposal #1

Open alexkirsz opened 9 years ago

alexkirsz commented 9 years ago

Instead of having two different props stylemap and classmap, have a single styles prop that covers both cases.

The styles prop is designed for inter-compatibility with most existing React styling solutions (react-style, css-loader, etc.).

Here's a (very) contrived example to illustrate its use on the component consumer side:

<ThirdPartyButton
  styles={[
    // key -> classNames map
    { btn: 'Button Button--standard' },
    // falsy values are ignored
    green && { btn: 'Button--green' },
    {
      // key -> style map
      container: {
        position: 'absolute',
        top: position.top,
        left: position.left,
      },
      // mix of both
      btnActive: [
        'Button--active',
        // falsy values are ignored
        glow && 'Button--glow',
        {
          outline: '1px solid #FF0000',
        },
      ],
    },
  ]}
/>

The component author can then consume the styles props via a standard applyStyles(styles, keys[, overrides]) => { className, style } helper:

<div {...applyStyles(styles, 'container')}>
  <button
    {...applyStyles(styles, [
      'btn',
      active && 'btnActive',
    ])}
  />
</div>

Here's an example implementation of applyStyles, where keys order defines precedence:

import assign from 'object-assign';
import isArray from 'is-array';

function extractStylesValues(stylesMap, key) {
  if (isArray(stylesMap)) {
    return stylesMap.filter(item => item).map(item => extractStylesValues(item, key));
  }
  return stylesMap[key];
}

function concatStylesValuesMut(stylesValues, classNames, styles) {
  stylesValues.filter(item => item).forEach(item => {
    if (isArray(item)) {
      concatStylesValuesMut(item, classNames, styles);
    } else if (typeof item === 'object') {
      assign(styles, item);
    } else {
      classNames.push(item);
    }
  });
}

export function applyStyles(stylesMap, keys, overrides) {
  if (!isArray(keys)) {
    keys = [keys];
  }
  let stylesValues = keys.reduce((result, key) => {
    return result.concat(extractStylesValues(stylesMap, key));
  }, []);
  let classNames = [];
  let styles = {};
  concatStylesValuesMut(stylesValues, classNames, styles);
  return {
    className: classNames.join(' '),
    styles: assign(styles, overrides),
  };
}

And here's a very similar implementation where stylesValues order defines precedence:

import assign from 'object-assign';
import isArray from 'is-array';

function extractStylesValues(stylesMap, keys) {
  if (isArray(stylesMap)) {
    return stylesMap.filter(item => item).map(item => extractStylesValues(item, keys));
  }
  return keys.map(key => map[key]);
}

function concatStylesValuesMut(stylesValues, classNames, styles) {
  stylesValues.filter(item => item).forEach(item => {
    if (isArray(item)) {
      concatStylesValuesMut(item, classNames, styles);
    } else if (typeof item === 'object') {
      assign(styles, item);
    } else {
      classNames.push(item);
    }
  });
}

export function applyStyles(stylesMap, keys, overrides) {
  if (!isArray(keys)) {
    keys = [keys];
  }
  let stylesValues = extractStylesValues(stylesMap, keys);
  let classNames = [];
  let styles = {};
  concatStylesValuesMut(stylesValues, classNames, styles);
  return {
    className: classNames.join(' '),
    styles: assign(styles, overrides),
  };
}

TODO: determine which of the two is best.

The styles PropType is defined recursively as either a stylesMap or a list of styles where falsy values are ignored.

function falsyPropType(props, propName) {
  if (props[propName]) {
    return new Error('Not falsy');
  }
}

function stylesPropType(props, propName) {
   return oneOfType([falsyPropType, stylesMapPropType, arrayOf(stylesPropType)])(props, propName);
}

The stylesMap PropType is defined as an object that maps keys to stylesValue.

const stylesMapPropType = objectOf(stylesValue);

The stylesValues PropType is defined recursively as either a String, an object that maps CSS properties to CSS property values, or a list of stylesValues where falsy values are ignored.

function stylesValuesPropType(props, propName) {
   return oneOfType([falsyPropType, string, object, arrayOf(stylesValuesPropType)])(props, propName);
}
alexkirsz commented 9 years ago

Since styles is a prop, default stylesheets can be provided via defaultProps.

class Button extends React.Component {
  static defaultProps = {
    styles: {
      btn: { color: 'red' },
    },
  };
  render() {
    return <div {...applyStyles(this.props.styles, 'button')} />;
  }
}

Providing a styles prop will thus override the default stylesheet. However, the format of the styles prop makes it easy to extend the default stylesheet:

<Button styles={[Button.defaultProps.styles, { btn: { backgroundColor: 'blue' } }]} />
alexkirsz commented 9 years ago

On the consumer side, styling a third-party component with a custom stylesheet is as simple as providing the two to a helper HoC, much like the one I built for react-classmap:

import ThirdPartyButton from 'react-component-lib';

export default provideStyles(ThirdPartyButton, { btn: { color: 'red' } });

provideStyles(Component, styles) creates a new component that renders

<Component {...this.props} styles={[styles, this.props.styles]} />
alexkirsz commented 9 years ago

Taking example from react-classmap, this proposal can be "polyfilled" in already existing third-party components that don't support it. The className prop of DOM elements would serve as key(s), and style would be propagated through context.

// ThirdPartyButton::render
return <div className="btn" />;
polyfillProvideStyles(ThirdPartyButton, { btn: { color: 'red' } });
// renders
<div className="btn" style={{ color: 'red' }} />
alexkirsz commented 9 years ago

See also https://github.com/markdalgleish/react-themeable and https://github.com/pluralsight/react-styleable.