Material Components for the Web

Web

MDC-Web Architecture Overview

The following is an outline of the MDC-Web architecture. Many of the architectural decisions we made were in response to the problems and feedback the team has received around Material Design Lite (MDL), the predecessor to MDC-Web. The goals of the MDC-Web architecture are to not only provide an easy way to provide a material UI for static websites, but for dynamic websites and frameworks as well. Essentially, the goal of MDC-Web is to be the canonical Material Design implementation for the web platform. We want MDC-Web to be:

No automatic DOM traversal and rendering

The biggest difference between MDC-Web and other Material Design implementations is how we handle component lifecycles and initialization. In MDC-Web there is no DOM-wide traversal, no implicit upgrading/downgrading, and no automatic rendering of DOM elements. All rendering of DOM is left up to the client. In this way, it is very similar to bootstrap. The DOM structure of a component is considered part of its “public API”; any updates to the DOM structure will be considered a breaking change.

In fact, for a lot of components - such as cards - no Javascript is needed at all. Javascript is only needed for components which provide advanced interaction patterns (e.g. elements with ripples), and dynamic functionality (form controls, etc.).

Modular UI Components

In MDC-Web, components are independent of one another. We’ll be providing per-component packages, similar to angular2, as well as one comprehensive overall package. This means more flexibility and less lock-in for users with more complex setups and use cases.

Component Architecture

A little bit of background

As mentioned above, our goal is to have a single UI library that can be used across the web. In order for this to be successful, we have to be able to integrate nicely with the myriad of frameworks, runtimes, and technologies that comprise the web platform, with minimal duplication of logic.

That last part is where it gets really tricky for us. Material Design contains a lot of intricate and subtle interaction patterns that are at best easy to get wrong, at worst intrinsic knowledge and not really documented in the spec at all. Many of these interactions - especially the ones that deal with dynamic controls - require a non-trivial amount of javascript to implement correctly. It would be a shame to have to push the burden of implementing this onto framework teams, whose job should be to focus on developing framework code. Furthermore, it makes changes to the component behaviors very hard to propagate. We need to choose an architecture that allows our material components to be integrated eloquently into different frameworks while not requiring those frameworks reinvent the wheel.

Components, Foundations, Adapters

In order to achieve this goal, we’ve factored our components into three major parts: the component itself, its foundation, and its adapter.

Example: An RGB Square

The best way to explain our architecture is through an example. Suppose we want to create an “RGB Square” component that just rotates through red, green, and blue backgrounds as you click it. Kind of like this.

The html is really straightforward:

<div class="rgb-square"></div>

So is the CSS

.rgb-square {
  width: 125px;
  height: 125px;
  border: 2px solid black;
  border-radius: 4px;
  background: transparent;
  cursor: pointer;
}

.rgb-square--r {
  background: red;
}

.rgb-square--g {
  background: green;
}

.rgb-square--b {
  background: blue;
}

Now let’s look at the javascript. Just using vanilla JS, we can easily code up this functionality.

class RGBSquare {
  constructor(root) {
    this.root_ = root;
    this.colorMods_  = ['r', 'g', 'b'];
    this.counter_ = -1; // no color
    this.root_.addEventListener('click', () => this.update_());
  }

  update_() {
    const oldClass = this.colorClass_(this.counter_);
    const newClass = this.colorClass_(++this.counter_);
    if (oldClass) {
      this.root_.classList.remove(oldClass);
    }
    this.root_.classList.add(newClass);
  }

  colorClass_(i) {
    if (i < 0) {
      return '';
    }
    const mod = this.colorMods_[i % this.colorMods_.length];
    return `rgb-square--${mod}`;
  }
}

We can now easily use this in a very simple site, such as the codepen demo

new RGBSquare(document.querySelector('.rgb-square'));

But what happens when we try to reuse this component in a react app? What about in an angular app? What if we’re rendering our components on the server and don’t even yet have access to the DOM? We could have those frameworks use only the HTML and CSS and re-implement the JS functionality, but that is brittle and wasteful of the developers’ time. So we immediately have some problems to address.

First off, this component has no notion of a lifecycle. It simply assumes that once it is instantiated it should initialize, and it will always be there. This is not the case in many dynamic applications. So in order for frameworks whose components have lifecycles to be able to use this elegantly, this is the first thing we need to implement:

class RGBSquare {
  constructor(root) {
    this.root_ = root;
    this.colorMods_  = ['r', 'g', 'b'];
    this.counter_ = -1; // no color
    this.clickHandler_ = () => this.update_();
  }

  /** initialization lifecycle method */
  init() {
    this.root_.addEventListener('click', this.clickHandler_);
  }

  /** destruction lifecycle method */
  destroy() {
    this.root_.removeEventListener('click', this.clickHandler_);
  }

  update_() {
    const oldClass = this.colorClass_(this.counter_);
    const newClass = this.colorClass_(++this.counter_);
    if (oldClass) {
      this.root_.classList.remove(oldClass);
    }
    this.root_.classList.add(newClass);
  }

  colorClass_(i) {
    if (i < 0) {
      return '';
    }
    const mod = this.colorMods_[i % this.colorMods_.length];
    return `rgb-square--${mod}`;
  }
}

Now, we’re no longer doing any operations within the constructor, and we provide both initialization and destruction methods as exposed lifecycle methods. So theoretically if you wanted an RGBSquare as a react component, you could call init() within componentDidMount() and destroy() within componentWillUnmount().

But there’s still a major problem. Let’s continue with our example of wrapping RGBSquare in a react component. If we were to do this, what would the root parameter be? We could defer initialization until componentWillMount(), but that gets ugly quickly. And it becomes even more complicated with frameworks such as angular2, which have no notion of a “virtual DOM” and use their own abstractions as well. In this case, how do we do things like “add a class”, or “register a click handler”?

Essentially, we can’t assume we’ll have a root “element” at initialization, and we have no idea what the API for working with the host platform will look like. If we were to factor all of these platform-specific features out of what we already have, here’s a sketch of what the RGBSquare would look like:

class RGBSquare {
  constructor(/*???*/) {
    this.colorMods_  = ['r', 'g', 'b'];
    this.counter_ = -1; // no color
    this.clickHandler_ = () => this.update_();
  }

  /** initialization lifecycle method */
  init() {
    SOMEHOW_REGISTER_CLICK_HANDLER(this.clickHandler_);
  }

  /** destruction lifecycle method */
  destroy() {
    SOMEHOW_DEREGISTER_CLICK_HANDLER(this.clickHandler_);
  }

  update_() {
    const oldClass = this.colorClass_(this.counter_);
    const newClass = this.colorClass_(++this.counter_);
    if (oldClass) {
      SOMEHOW_REMOVE_CLASS(oldClass);
    }
    SOMEHOW_ADD_CLASS(newClass);
  }

  colorClass_(i) {
    if (i < 0) {
      return '';
    }
    const mod = this.colorMods_[i % this.colorMods_.length];
    return `rgb-square--${mod}`;
  }
}

This is essentially the foundation class for our RGBSquare. It contains all of the internal state and business logic needed to make the UI function correctly, but no assumptions about how to operate on the host environment.

So now the question is: how do we tell the foundation how to do those things it needs to do? This is where an adapter comes in. Our RGBSquare needs to know how to do four things within its host environment:

Therefore, if we are provided with an adapter that can do these things, we can reuse this code within any framework that wants to use it. Let’s write our final RGBSquareFoundation class:

class RGBSquareFoundation {
  /**
    * All of the MDC-Web foundation classes have a static defaultAdapter getter property, which is
    * very similar to propTypes within react.
    */
  static get defaultAdapter() {
    return {
      addClass: (/* className: string */) => {},
      removeClass: (/* className: string */) => {},
      registerClickHandler: (/* handler: Function */) => {},
      deregisterClickHandler: (/* handler: Function */) => {}
    };
  }

  /** The constructor is given an adapter by its caller. */
  constructor(adapter) {
    /** We extend the adapter using our defaults so that it will always work. */
    this.adapter_ = Object.assign(RGBSquareFoundation.defaultAdapter, adapter);
    this.colorMods_  = ['r', 'g', 'b'];
    this.counter_ = -1; // no color
    this.clickHandler_ = () => this.update_();
  }

  /** initialization lifecycle method */
  init() {
    this.adapter_.registerClickHandler(this.clickHandler_);
  }

  /** destruction lifecycle method */
  destroy() {
    this.adapter_.deregisterClickHandler(this.clickHandler_);
  }

  update_() {
    const oldClass = this.colorClass_(this.counter_);
    const newClass = this.colorClass_(++this.counter_);
    if (oldClass) {
      this.adapter_.removeClass(oldClass);
    }
    this.adapter_.addClass(newClass);
  }

  colorClass_(i) {
    if (i < 0) {
      return '';
    }
    const mod = this.colorMods_[i % this.colorMods_.length];
    return `rgb-square--${mod}`;
  }
}

So now, we have an implementation of RGBSquare which contains all of the functionality and an interface for operating on the host platform. The last step is to plug it in. In our original Vanilla JS host environment, here’s what that would look like:

class RGBSquare {
  constructor(root) {
    this.root_ = root;
    this.foundation_ = new RGBSquareFoundation({
      addClass: className => this.root_.classList.add(className),
      removeClass: className => this.root_.classList.remove(className),
      registerClickHandler: handler => this.root_.addEventListener('click', handler),
      deregisterClickHandler: handler => this.root_.removeEventListener('click', handler)
    });
    this.foundation_.init();
  }
}

You can see a working demo of this here.

Similarly, a react component wouldn’t be much more difficult:

// A *very crude* react component
class RGBSquare extends React.Component {
  constructor(props, context) {
    super(props, context);
    this.state = {
      classNames: []
    };
    this.foundation_ = new RGBSquareFoundation({
      addClass: className => this.setState(({classNames}) => ({
        classNames: classNames.concat([className])
      })),
      removeClass: className => this.setState(({classNames}) => ({
        classNames: classNames.filter(cn => cn !== className)
      })),
      registerClickHandler: handler => this.refs.root.addEventListener('click', handler),
      deregisterClickHandler: handler => this.refs.root.removeEventListener('click', handler)
    });
  }

  getInitialState() {
    return {classNames: []};
  }

  componentDidMount() {
    this.foundation_.init();
  }

  componentWillUnmount() {
    this.foundation_.destroy();
  }

  render() {
    return (
      <div ref="root" className={['rgb-square'].concat(this.state.classNames).join(' ')}></div>
    );
  }
}

You can see a working example here.

To sum it all up, here’s a crude diagram of the architecture:

Although this may seem unwieldy at first, since adapters are essentially interfaces it would be easy to reuse common code either through inheritance or composition.

It is also worth mentioning that most of this will be opaque to end users. We’re shipping MDC-Web with vanilla components that are built on top of the foundations, and can be used without requiring any knowledge of foundations, adapters, etc. We designed foundations/adapters with framework/library authors in mind, because we’re aware of the pain they went through adapting MDL for their frameworks and want to ensure a more ergonomic experience this time around. It’s complicated for sure, but the state of the web right now is complicated, and thus mandates the complexity. In the future, as specifications like Custom Elements begin to stabilize and gain adoption, we can start to consider those solutions instead.

Resources