Navigable select

Navigable select

Ember Power Select is built by composing several independent components. This is great for customization, because you can replace any of these independent components with your own.

Usually this is used for customizing appearance, but it can also be used to build completely different behaviors.

Let's see how to build a component that shows grouped options as a multi-leveled, navigable menu with nice animations, in only a few lines of code!

Let's see the result:

Nice? It takes less code than you might think.

Before writing any code, let's install liquid-fire for the animations.

Now let's analyze the problem. The task consists basically of creating a grouped select like this:


But in the new one the groups are regular options that can be highlighted, searched and interacted with. It should take the same arguments so that it behaves like a drop-in replacement.

The first main difference is that the list of options is completely different. We'll use the optionsComponent attribute to let Ember Power Select know that it should render our own component in the dropdown, instead of the default one.

  • Another key difference is that the dropdown should not be closed when we select a group, only when we select a leaf node. We can do that with by passing closeOnSelect=false. That will keep the select open after selecting an option, but it will be up to us to decide when to close it, using the second argument received by the onchange option.
  • Now let's consider filtering. Remember, Ember Power Selects considers any option with a groupName and options attributes to be a group itself, and the default search strategy will ignore it. So, it seems that this component will need to provide a custom search function to the underlying select.
  • We also need to handle the keyboard arrows. By default only leaf options can be highlighted. This along with the search issues commented above makes us think that we should not treat groups as groups at all, so we should internally transform the groups into another data structure that doesn't look as a group for ember-power-select and in which each level keeps a reference to the parent level in order to navigate back to it.

Ok, we've thought through the issues we're solving. It's time to code.

The navigable-select.hbs template renders the select, and the yielded options will be leaf options or groups with more options inside. For avoiding confusion we'll call these transformed groups "levels".

{{#power-select
  options=currentOptions
  selected=selected
  closeOnSelect=false
  onchange=(action "onchange")
  optionsComponent='animated-options'
  search=(action "search")
  as |levelOrOption|
}}
  {{#if levelOrOption.parentLevel}}
    <strong>Back</strong>
  {{else if levelOrOption.levelName}}
    <strong>{{levelOrOption.levelName}} ></strong>
  {{else}}
    {{levelOrOption}}
  {{/if}}
{{/power-select}}

In the block we need to distinguish between 3 possible situations. The yielded object can be either a leaf option, a level, or the special object representing the parent level.

We need to transform the grouped options into this new structure, recursively. A computed property seems perfect because it will update if the options change.

import Component from '@ember/component';
import { computed, get } from '@ember/object';

export default Component.extend({
  transformedOptions: computed('options', function() {
    return (function walker(options, parentLevel = null) {
      let results = [];
      let len = get(options, 'length');
      parentLevel = parentLevel || { root: true };

      for (let i = 0; i < len; i++) {
        let opt = get(options, `${i}`);
        let groupName = get(opt, 'groupName');

        if (groupName) {
          let level = { levelName: groupName };
          let optionsWithBack = [{ parentLevel }, ...get(opt, 'options')];
          level.options = walker(optionsWithBack, level);
          results.push(level);
        } else {
          results.push(opt);
        }
      }

      parentLevel.options = results;
      return results;
    })(this.get('options'));
  }),

  currentOptions: computed.oneWay('transformedOptions'),
});

This tail-recursive solution is not straightforward, but in exchange it supports any depth level. Just trust me on this one, the rest of the code is much simpler.

Just below we create oneWay alias to this result. This is the collection of options that we're going to pass to Ember Power Select, and we will replace it with a different set of options when the user navigates to another level.

The default list doesn't work for us, we need to create that "animated-options".

import Component from '@ember/component';

export default Component.extend({
  didReceiveAttrs({ oldAttrs, newAttrs }) {
    this._super(...arguments);
    if (!oldAttrs || !oldAttrs.options || !newAttrs.options || oldAttrs.options === newAttrs.options) {
      return;
    }

    if (newAttrs.options.fromSearch) {
      this.set('animation', false);
      this.set('enableGrowth', false);
    } else {
      this.set('enableGrowth', true);
      let parentLevel = oldAttrs.options[0] && oldAttrs.options[0].parentLevel;
      let goingBack = !!parentLevel && parentLevel.options === newAttrs.options;

      if (goingBack) {
        this.set('animation', 'toRight');
      } else {
        this.set('animation', 'toLeft');
      }
    }
  }
});

The template uses liquid-fire's liquid-bind component to animate when we swap the list of options, and we use the didReceiveAttrs hook to detect if we're navigating to a lower level, upper level, or just filtering results, and decide the kind of animation.

The last step is to handle handle the select action and swap the options when the selected item isn't a leaf node and provide custom search that we like:

import Controller from '@ember/controller';
import { get } from '@ember/object';
export default Controller.extend({
  actions: {
    onchange(levelOrOption, dropdown) {
      if (get(levelOrOption, 'levelName')) {
        this.set('currentOptions', get(levelOrOption, 'options'));
      } else if (levelOrOption.parentLevel) {
        this.set('currentOptions', levelOrOption.parentLevel.options);
      } else {
        this.get('onchange')(levelOrOption);
        dropdown.actions.close();
        this.set('currentOptions', this.get('transformedOptions'));
      }
    },

    search(term) {
      let normalizedTerm = term.toLowerCase();
      let results = this.get('currentOptions').filter(o => {
        if (o.parentLevel) {
          return normalizedTerm === '';
        } else if (get(o, 'levelName')) {
          return get(o, 'levelName').toLowerCase().indexOf(normalizedTerm) > -1;
        } else {
          return o.toLowerCase().indexOf(normalizedTerm) > -1;
        }
      });
      results.fromSearch = true;
      return results;
    }
  }
});

Check the full picture in github:

That's is it. A fully customized select in ~100LOC.