Skip to content

Instantly share code, notes, and snippets.

@jamesarosen
Last active January 16, 2020 02:42
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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.

@jamesarosen
Copy link
Author

Hat tip to @juggy for his help with this 😃

@cibernox
Copy link

cibernox commented Jul 1, 2015

Shouldn't you remove the event handlers when the component is destroyed?

@jamesarosen
Copy link
Author

@cibernox yup! Updating now!

@jamesarosen
Copy link
Author

@mixonic recommended I use didRender instead of didReceiveAttrs.

@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