Skip to content

Instantly share code, notes, and snippets.

@jamesarosen
Last active January 16, 2020 02:42
Show Gist options
  • Save jamesarosen/15fd7e0f090e053d386f to your computer and use it in GitHub Desktop.
Save jamesarosen/15fd7e0f090e053d386f to your computer and use it in GitHub Desktop.
A Simple Ember-Popover

I looked around for a good popover library for Ember. I couldn't find one I liked (and was compatible with Ember 1.13 and Glimmer), so I whipped up a little ditty:

{{!-- app/pop-over/template.hbs --}}
{{yield}}
// app/pop-over/component.js
import $ from "jquery";
import Ember from "ember";

const { Component, run } = Ember;
const ESC = 27;

export default Component.extend({

  isVisible: false,

  didInitAttrs() {
    this._closeOnClickOut = this._closeOnClickOut.bind(this);
    this._closeOnEsc = this._closeOnEsc.bind(this);
  },

  didRender() {
    run.next(() => {
      const method = this.get('isVisible') && !this.get('isDestroyed') ? 'on' : 'off';
      $(window)
        [method]('click', this._closeOnClickOut)
        [method]('keyup', this._closeOnEsc);
    });
  },

  _close() {
    if (this.get('isDestroyed')) { return; }
    this.set('isVisible', false);
    this.sendAction('close');
  }

  _closeOnClickOut(e) {
    const clickIsInside = this.$().find(e.target).length > 0;
    if (!clickIsInside) { this._close(); }
  },

  _closeOnEsc(e) {
    if (e.keyCode === ESC) { this._close(); }
  }
});

Usage

{{#pop-over isVisible=isShowingMyMenu close=(action "didHideMyMenu")}}
  Popover content
{{/pop-over}}

If you don't like the component reaching out to $(window) to attach the click handler, you could add an invisible scrim behind the popover contents and attach to that. There's not much you can do about the keyup handler, though, since window (or document) is the only good place to catch it.

Testing

The Ember.run.next made this a little difficult to test. My first attempt was

test('popover works', function(assert) {
  this.set('isOpen', false);
  render(`
<span class='outside'>Outside</span>
{{#pop-over isVisible=isOpen}}
  <span class='inside'>Inside</span>
{{/pop-over}}
  `);

  assert.equal(this.$('.inside').is(':visible'), false)

  Ember.run(this, 'set', 'isOpen', true);
  assert.equal(this.$('.inside').is(':visible'), true);

  Ember.run(this.$('.outside'), 'click');
  assert.equal(this.$('.inside').is(':visible'), false);
});

Unfortunately, that runs the click before the click handler is added. Luckily, ember-qunit lets you return a Promise from your test. Thus,

test('popover works', function(assert) {
  this.set('isOpen', false);
  render(`
<span class='outside'>Outside</span>
{{#pop-over isVisible=isOpen}}
  <span class='inside'>Inside</span>
{{/pop-over}}
  `);
  assert.equal(this.$('.inside').is(':visible'), false)

  Ember.run(this, 'set', 'isOpen', true);
  assert.equal(this.$('.inside').is(':visible'), true);

  return new Ember.RSVP.Promise((resolve) => {
    Ember.run.next(() => {
      Ember.run(this.$('.outside'), 'click');
      assert.equal(this.$('.inside').is(':visible'), false);
      resolve();
    });
  });
});

License

This code is released under the MIT License, copyright James A Rosen 2015.

@mattmcmanus
Copy link

I love how simple this is @jamesarosen! Thanks for sharing.

I ran into serious problems testing this in phantom though. this.$() didn't seem to exist in that environment.

Updating _closeOnClickOut to this seemed to work. (I also added a check so that it would close if I clicked a link inside of the popover)

  _closeOnClickOut(e) {
    const element = this.get('element');
    const clickIsInside = (element === e.target || $.contains(element, e.target));
    const clickIsLink = $(e.target).prop('tagName') === 'A';

    if (!clickIsInside || clickIsLink) { this._close(); }
  }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment