Flexible interfaces

Learn how to use contextual components to write more flexible and expressive interfaces for your components.

Summary

Contextual components can improve your component interfaces. We can refactor

{{#collapsible-panel}}
  {{#collapsible-panel-header on-click=(action (toggle 'isOpen' this))}}
    Section 1
    {{fa-icon 'arrow'}}
  {{/collapsible-panel-header}}

  {{#collapsible-panel-body isOpen=isOpen}}
    <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit</p>
  {{/collapsible-panel-body}}
{{/collapsible-panel}}

to use contextual components, giving us this final interface:

{{#collapsible-panel as |panel|}}
  {{#panel.header on-click=(action (toggle 'isOpen' this))}}
    Section 1
    {{fa-icon 'arrow'}}
  {{/panel.header}}

  {{#panel.body isOpen=isOpen}}
    <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit</p>
  {{/panel.body}}
{{/collapsible-panel}}

This gives us two main benefits:

  1. Improved clarity - it's clear the header and body components belong to the parent collapsible-panel.
  2. Better encapsulation - we can change the implementation of panel.header and panel.body without changing the invocation.

Transcript

Certain interface elements are complex and made up of multiple components. This usually happens when you want to give other developers control over a component's template.

Take this collapsible panel:

[animation]

You've probably seen this before - it lets users show or hide some content by clicking the header.

Right now, developers use this panel by writing out three components: there's a wrapper {{collapsible-panel}} component; a {{collapsible-panel-header}} component, which exposes an on-click action, and a {{collapsible-panel-body}} component, which takes an isOpen property and shows its contents when that value is true.

{{#collapsible-panel}}
  {{#collapsible-panel-header on-click=(action (toggle 'isOpen' this))}}
    Section 1
    {{fa-icon 'arrow'}}
  {{/collapsible-panel-header}}

  {{#collapsible-panel-body isOpen=isOpen}}
    <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit</p>
  {{/collapsible-panel-body}}
{{/collapsible-panel}}

These components are a nice start. They're pre-styled, so other developers can just add a panel whenever they need one without worrying about styling; and they expose the actions and state needed to wire up the behavior.

In this series, we'll take a look at how contextual components can improve the interface of our collapsible panel. Let's start small, and look first at how our components are being invoked.

The current interface requires developers to type out the full name of all three components: the wrapper, header and body. Besides just being a lot of characters to type, there's nothing fundamentally related about these components, other than the fact that their name all begins with collapsible-panel.

Furthermore, because the current interface uses the full component names, it makes it difficult to do certain refactorings in the future. What if you end up adding additional behavior or styling, and decide it would be better to replace the <collapsible-panel-body> with two components, say a <collapsible-panel-outer-body> and a <collapsible-panel-inner-body>? If you made this change, you'd need to look throughout your app to find everywhere the panel is being used, and update its invocation. You can think of this as a breaking change to your component's API.

Contextual components are a perfect solution to these two problems. They let us group together related components, and they also give us flexibility to change and refactor each component's implementation. Let's see what our final collapsible panel will look like.

We'll invoke the outer {{collapsible-panel}} just like before, but add "as |panel|" to the end. This is called a yielded property, and you might recognize it from the each helper.

Now we can use the panel property to replace the full header and body components with the contextual components panel.header and panel.body.

{{#collapsible-panel as |panel|}}
  {{#panel.header on-click=(action (toggle 'isOpen' this))}}
    Section 1
    {{fa-icon 'arrow'}}
  {{/panel.header}}

  {{#panel.body isOpen=isOpen}}
    <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit</p>
  {{/panel.body}}
{{/collapsible-panel}}

This is our final API. You can see that callers no longer need to use the full component name, and it's now much more obvious by reading this template that the header and body belong to the parent component.

Let's walk through how to make this.

First, we'll open the template for collapsible-panel. Right now it just has a simple {{yield}}, but we want to yield a value, so our callers can access it through the component's yielded property.

You can yield all kinds of values. For example, we can yield a string:

{{yield 'Dunder Mifflin'}}

The caller can then access that string through the yielded property. Just like with {{each}}, we can name this property whatever we want - think of it as a local variable. Let's call it string:

{{#collapsible-panel as |string|}}
  {{string}}
{{/collapsible-panel}}

And now we see the value in the rendered output. So this is how we can wire up yielded properties.

We can even yield objects as properties, using the hash helper that's included in Ember. For example, we could yield an object with the keys firstName and lastName

{{yield (hash
  firstName='Michael'
  lastName='Scott'
)}}

and the caller could access this data from the yielded property, using dot syntax:

{{#collapsible-panel as |person|}}
  {{person.firstName}} {{person.lastName}}
{{/collapsible-panel}}

Now, we're just missing one final piece. We want our yielded property to expose components. To do this, we'll use another helper that's included in Ember: the {{component}} helper.

This helper lets you render a component by passing in the name of the component as a string. Let's first try it out on its own by rendering our {{collapsible-panel-header}} component:

{{component 'collapsible-panel-header'}}

We can see that the header component was rendered in our output.

We now have all the pieces we need to give our collapsible panel the API that we want. Let's go back to its template, and we'll update its yield to be an object with a header property that uses the component helper to render {{collapsible-panel-header}}, and a body property that renders {{collapsible-panel-body}}.

{{yield (hash
  header=(component 'collapsible-panel-header')
  body=(component 'collapsible-panel-body')
)}}

Now, our final API works! Using dot syntax to access our two component helpers essentially lets us use those components just like before.

{{#collapsible-panel as |panel|}}
  {{#panel.header on-click=(action (toggle 'isOpen' this))}}
    Section 1
    {{fa-icon 'arrow'}}
  {{/panel.header}}

  {{#panel.body isOpen=isOpen}}
    <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit</p>
  {{/panel.body}}
{{/collapsible-panel}}

Yielding a hash of component helpers has given us a more concise and expressive API. It has also encapsulated the implementation of the various pieces of our panel - callers no longer have to know that the body is actually using the collapsible-panel-body component under the hood. If that were to change, this invocation would stay exactly the same.

Finally, note how we worked through this: we started with our ideal component API, and then worked backwards to implement it. This is the best way to use contextual components: think about what you want the final interface of your component to be, and then yield only the parts that your caller will need. By restricting what you yield you'll be able to change more of the implementation in the future, without causing any breaking changes to your consumers.

In the next video, we'll learn how to set up our yielded components with pre-wired data and actions.

Questions?

Send us a tweet:

Or ask us in Inside EmberMap, our private Slack workspace for subscribers.