Constraining the Menu's Header

See how additional contextual components can be used to simplify and constrain our component interfaces.

Summary

View the code diff on GitHub


Our Dropdown's button and menu are now customizable – but in some sense, they're maximally customizable. Our users can put in whatever HTML they want.

This flexibility is sometimes a good thing, and sometimes a bad thing. In applications and design systems, UI components are more beneficial when they help developers adhere consistently to the design language, as long as they don't restrict them too much. Our current API doesn't provide much help, and makes it pretty easy for developers to end up with a poorly styled Dropdown that violates the intended design.

For example, the first part of our menu looks like this

<d.Menu>
  <div class="px-4 py-3">
    <p class="text-sm leading-5">
    {{@firstLine}} 
    </p>
    <p class="text-sm leading-5 font-medium text-gray-900">
      {{@secondLine}} 
    </p>
  </div>

  <div class="border-t border-gray-100"></div>
</d.Menu>

and if any one of these elements or classes were forgotten, or too much new HTML was added, the Dropdown wouldn't match its intended look.

In the same way that we used Contextual Components to preserve the look and feel of our overall button and menu while still letting developers customize the content, we can do the same thing for the individual parts of our actual menu.

Let's extract this "header" treatment of our menu to a new Contextual Component.

As always, we'll start from the outside in. How would we want to render a Menu Header if we were the application developer? Maybe with something like this:

<d.Menu as |m|>
  <m.Header />
    <p class="text-sm leading-5">
      Signed in as
    </p>
    <p class="text-sm leading-5 font-medium text-gray-900">
      tom@example.com
    </p>
  </m.Header>

  {{! rest of HTML }}
</d.Menu>

Here we're using a new Contextual Component that's being yielded by our <d.Menu>. That's right – a contextual component within a contextual component! There's no reason to stop at one level deep.

And this is a good start. The Header is easier to use, there's less structure for us to remember, and yet we can still customize the contents easily.

Let's implement this.

We'll come to our existing <Dropdown::Menu /> component, which is currently just exposing a {{yield}}, and use the (hash) and (component) helpers again to yield out a new Header contextual component:

{{! dropdown/menu.hbs }}
{{#if @isOpen}}
  <div class="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg">
    <div class="rounded-md bg-white shadow-xs">
      {{yield (hash
        Header=(component 'dropdown/menu/header')
      )}}
    </div>
  </div>
{{/if}}

We'll then create this new component via a dropdown/menu/header.hbs template-only file, and move our Header template code there:

{{! dropdown/menu/header.hbs }}
<div class="px-4 py-3">
  <p class="text-sm leading-5">
    Signed in as
  </p>
  <p class="text-sm leading-5 font-medium text-gray-900">
    tom@example.com
  </p>
</div>

<div class="border-t border-gray-100"></div>

Notice that we're nesting the Header within the Menu, so it's clear that this Header is specific to this part of the Dropdown.

Finally, let's add a {{yield}} statement to our Header, so the caller can customize it like we saw above:

  {{! dropdown/menu/header.hbs }}
  <div class="px-4 py-3">
-   <p class="text-sm leading-5">
-     Signed in as
-   </p>
-   <p class="text-sm leading-5 font-medium text-gray-900">
-     tom@example.com
-   </p>
+   {{yield}}
  </div>

  <div class="border-t border-gray-100"></div>

Now the content of the Header is fully customizable, but the padding and divider treatment is built in to our yielded component. We've made our Dropdown easier to reuse consistently by adding some constraints to it.

For example, the caller can customize the Header to show both lines, or just one:

{{! Original example }}
<m.Header />
  <p class="text-sm leading-5">
    Signed in as
  </p>
  <p class="text-sm leading-5 font-medium text-gray-900">
    tom@example.com
  </p>
</m.Header>

{{! One line only }}
<m.Header />
  <p class="text-sm leading-5">
    Welcome to the app!
  </p>
</m.Header>

What if we wanted to take this idea further? We could come up with an even more constrained API than this, to prevent our callers from adding more HTML that might result in a misuse of our Dropdown.

That API might look something like this:

<d.Menu as |m|>
  <m.Header @firstLine='Signed in as' @secondLine='tom@example.com' />
</d.Menu>

@firstLine and @secondLine are now just string arguments, and our users can omit one if they like. You could also imagine calling these arguments something like primaryText and secondaryText. Let's go ahead and implement this.

In this case we're going to pull the two paragraph tags out of our calling context, and make them internal details of our Header component. We'll then render the arguments in the correct place.

{{! dropdown/menu/header.hbs }}
<div class="px-4 py-3">
  <p class="text-sm leading-5">
   {{@firstLine}} 
  </p>
  <p class="text-sm leading-5 font-medium text-gray-900">
    {{@secondLine}} 
  </p>
</div>

<div class="border-t border-gray-100"></div>

And with that, our API works!

This is an example of a much more constrained API. It makes the Header easier for our users to use consistently, but it also could restrict them in a way that makes them unable to use our Dropdown for some future use case that we hadn't anticipated.

How constrained vs. how flexible your component interfaces should be is one of those tricky questions without a cut-and-dry answer. Instead, you should get comfortable with being able to add different levels of constraints to your component systems, to suit different situations. Then, elicit feedback from your users and incorporate that feedback into your APIs, so that your contextual components are helping your users be more productive, and not slowing them down.

Questions?

Send us a tweet:

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