Using in-element in Ember.js

09 October 2020

The in-element helper has eluded me for a long time. I've seen it mentioned in forum discussions and RFC comments but since I didn't come across a situation where I needed it (or, it's possible I just didn't realize I could've used it) I didn't get familiar with it.

I decided to learn more about it by building something and sharing my knowledge with you.

What are we building?

Being the author of Rock and Roll with Ember Octane it's only natural that the feature I decided to build has to do with the app in the book.

In the app we develop in the book, you can create bands and songs, but you can't edit their names or delete them. So let's allow users to delete songs and make them confirm such a devastating operation.

List of songs

Here's the rough, non-visual UI flow:

Let's start by adding the visuals for the trash can that appears on hover.

The hovering trash can

The app uses TailwindCSS and has the following snippet to show the list of songs:

 1{{!-- songs.hbs --}}
 2<ul>
 3  {{#each this.sortedSongs as |song|}}
 4    <li class="mb-2">
 5      {{capitalize song.title}}
 6      <span class="float-right">
 7        <StarRating
 8          @rating={{song.rating}}
 9          @onUpdate={{fn this.updateRating song}}
10        />
11      </span>
12    </li>
13  {{/each}}
14</ul>

As a first step, we'll add a background hover for each song item in the list:

 1{{!-- songs.hbs --}}
 2<ul>
 3  {{#each this.sortedSongs as |song|}}
 4-    <li class="mb-2">
 5+    <li class="-mx-2 hover:bg-blue-700">
 6+      <div class="mb-2 px-2 py-1">
 7        {{capitalize song.title}}
 8        <span class="float-right">
 9          <StarRating
10            @rating={{song.rating}}
11            @onUpdate={{fn this.updateRating song}}
12          />
13        </span>
14+      </div>
15    </li>
16  {{/each}}
17</ul>

We should show a trash can icon on hover, clicking on which will be taken as wanting to delete the song. What we want here is a "group hover". The trash can should be shown not only if its direct parent is hovered but if the song item itself (the li tag) is.

Tailwind supports this functionality. The group itself should have the group class and the element that should have the hover state needs the group-hover class:

 1{{!-- songs.hbs --}}
 2-  <ul>
 3+  <ul class="-mx-8">
 4     {{#each this.sortedSongs as |song|}}
 5-      <li class="-mx-2 hover:bg-blue-700"> 
 6+      <li class="cursor-pointer group hover:bg-blue-700">
 7         <div class="mb-2 px-2 py-1">
 8+          <button type="button" class="inline-block pr-2 text-transparent group-hover:text-current"
 9+            {{on "click" (set this.songToDelete song)}}
10+          >
11+            <FaIcon
12+              @icon="trash-alt"
13+              @prefix="far"
14+            />
15+          </button>
16           {{capitalize song.title}}
17           <span class="float-right">
18             <StarRating
19               @rating={{song.rating}}
20               @onUpdate={{fn this.updateRating song}}
21             />
22           </span>
23+      </div>
24      </li>
25    {{/each}}
26  </ul>

The songToDelete property holds the song that the user is about to delete but hasn't confirmed yet.

The set helper is provided by the ember-set-helper add-on. It is similar to mut but it's a lot cleaner and is my preferred way of setting properties in template-land. You can read more about this topic in pzuraq's "On {{mut}} and 2-Way-Binding".

For the group hover to work, we need to make a change in Tailwind's configuration file. group-hover is a Tailwind variant that CSS classes can have which is not enabled by default for any categories. We need it for textColor so let's edit our config file:

 1// tailwind.config.js
 2module.exports = {
 3  purge: [],
 4  theme: {
 5    extend: {},
 6  },
 7  variants: {
 8    textColor: ['responsive', 'hover', 'focus', 'group-hover'],
 9  },
10  plugins: [],
11} 

If you haven't yet created the Tailwind config file in your project, you can do so by running npx tailwindcss init.

You also need to point your CSS tool (I use PostCSS) to the config file:

 1// ember-cli-build.js
 2// (...)
 3  let app = new EmberApp(defaults, {
 4    // Add options here
 5    postcssOptions: {
 6      compile: {
 7        plugins: [
 8          require('tailwindcss')('./tailwind.config.js')
 9        ]
10      }
11    }
12  });

(You can read about the Tailwind+PostCSS setup here.)

Now everything is in place, the trash can appears in front of each song item when hovered:

Trash can appears on hover

Adding the dialog

Ok, let's move forward and add the modal dialog to confirm the deletion.

We only want to show the dialog if they are about to delete a song, hence the wrapping {{#if this.songToDelete}}.

 1{{!-- songs.hbs --}}
 2{{#if this.songToDelete}}
 3  <div class="fixed bottom-0 inset-x-0 px-4 pb-4 sm:inset-0 sm:flex sm:items-center sm:justify-center">
 4    {{!-- Overlay --}}
 5    <div
 6      class="fixed inset-0 transition-opacity"
 7      role="button"
 8      {{on "click" (set this.songToDelete null)}}
 9    >
10      <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
11    </div>
12
13    {{!-- Panel --}}
14    <div class="bg-white rounded-lg px-4 pt-5 pb-4 overflow-hidden shadow-xl transform sm:max-w-lg sm:w-full sm:p-6" role="dialog" aria-modal="true" aria-labelledby="modal-headline">
15      <div class="sm:flex sm:items-start">
16        <div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
17          <svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
18            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
19          </svg>
20        </div>
21        <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
22          <h3 class="text-lg leading-6 font-medium text-gray-900">
23            Delete song
24          </h3>
25          <div class="mt-2">
26            <p class="text-sm leading-5 text-gray-500">
27              Are you sure you want to delete {{this.songToDelete.title}}?
28            </p>
29          </div>
30        </div>
31      </div>
32      <div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
33        <span class="flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto">
34          <button
35            type="button"
36            class="inline-flex justify-center w-full rounded-md border border-transparent px-4 py-2 bg-red-600 leading-6 font-medium text-white shadow-sm hover:bg-red-500"
37            {{on "click" this.deleteSong}}
38          >
39            Delete
40          </button>
41        </span>
42        <span class="mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto">
43          <button
44            type="button"
45            class="inline-flex justify-center w-full rounded-md border border-gray-300 px-4 py-2 bg-white leading-6 font-medium text-gray-700 shadow-sm hover:text-gray-500"
46            {{on "click" (set this.songToDelete null)}}
47          >
48            Cancel
49          </button>
50        </span>
51      </div>
52    </div>
53  </div>
54{{/if}}

Don't be discouraged by the markup and CSS classes, let's just focus on the "behaviorial bits". If either the Cancel button or the overlay is clicked, we should reset this.songToDelete to null. That will unrender the dialog as it's wrapped in {{#if this.songToDelete}}.

Deleting the song

If the user clicks "Delete", we should go ahead and delete the song by calling the this.deleteSong action, so let's add this action to the controller:

 1// songs.js
 2import Controller from '@ember/controller';
 3import { tracked } from '@glimmer/tracking';
 4import { action } from '@ember/object';
 5
 6export default class BandsBandSongsController extends Controller {
 7  // (...)
 8  @tracked songToDelete = null;
 9
10  @action
11  async deleteSong() {
12    await this.catalog.delete('song', this.songToDelete);
13    this.catalog.fetchRelated(this.model, 'songs');
14    this.songToDelete = null;
15  }
16}

If you haven't read the book, the catalog is a store-like service that keeps track of bands and songs in the app.

After the deletion, we re-fetch the songs of the band to refresh the list. Finally, we set songToDelete to close the modal. Since the template-layer needs to be aware of changes in the value of songToDelete, we mark it as tracked.

The modal to confirm deletion

Now everything is in place: users can delete songs – or change their minds, and cancel.

Right, so where is the in-element?

This works as it is but sometimes it's a good idea to render modal dialogs in a top-level container. This is where in-element helps us: it lets us render any template block totally elsewhere. The only parameter the helper takes is the DOM element where the block should be rendered.

Let's set this up now by first creating a modal container in the application template:

1{{!-- application.hbs --}}
2<div id="modal-container">
3</div>

We should now use the in-element helper to render our modal dialog in the application template, in the space reserved for well-behaved modal dialogs:

1{{!-- application.hbs --}}
2{{!-- ... --}}
3{{#in-element this.modalContainer}}
4  {{#if this.songToDelete}}
5    {{!-- ... --}}
6  {{/if}}
7{{/in-element}}

We still have to define the modalContainer property as it needs to return a DOM element. Let's pop open the controller again to add it:

1// songs.js
2export default class BandsBandSongsController extends Controller {
3  // (...)
4  get modalContainer() {
5    return document.getElementById('modal-container');
6  }
7}

If you now bring up the modal dialog again and inspect the DOM tree, you'll find that it's now rendered within the container:

in-element content in the modal-container element

What else can in-element do?

The in-element helper can be handy every time you want to render something outside of the piece of UI (the template) where you carry out the operation, like flash messages, sidebars, or chat widgets.

Finally, a great add-on that provides a similar functionality but is more flexible is ember-elsewhere so I recommend you check it out if you find in-element too limiting.

Share on Twitter