Skip to content

Commit

Permalink
Merge pull request #104 from a15n/a15n/lazy-render
Browse files Browse the repository at this point in the history
A15n/lazy render
  • Loading branch information
a15n authored Nov 5, 2016
2 parents 2f07edd + c82b805 commit d684275
Show file tree
Hide file tree
Showing 35 changed files with 841 additions and 278 deletions.
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Documentation for usage is below:

- [Demo](http://sir-dunxalot.github.io/ember-tooltips/)
- [1.0.0 Release](#100-release)
- [2.4.0 Release](#240-release)
- [Usage](#usage)
- [tooltip-on-component](#tooltip-on-component)
- [tooltip-on-element](#tooltip-on-element)
Expand All @@ -31,6 +32,10 @@ Version 1.0.0 removed <a href="http://darsa.in/tooltip/" target="_blank">darsain

You can use and see the pre-1.0 version on [this branch](https://github.com/sir-dunxalot/ember-tooltips/tree/pre-1.0). Alternatively, install `"ember-tooltips": "0.7.0"` in your `package.json`.

## 2.4.0 Release

Version 2.4.0 introduces lazy rendering. Tooltips and popovers generally don't need to be rendered until the user has interacted with the `$target` element. Adding `enableLazyRendering=true` to your component will enable this feature. In version 3.0.0 `enableLazyRendering` will default to `true` and you'll be able to opt-out of lazy rendering as necessary.

## Usage

### Tooltip on Component
Expand Down Expand Up @@ -120,6 +125,7 @@ Options are set as attributes on the tooltip/popover components. Current tooltip
- [spacing](#spacing)
- [isShown](#is-shown)
- [hideDelay (popover only)](#hide-delay)
- [enableLazyRendering](#enable-lazy-rendering)

#### Class

Expand Down Expand Up @@ -318,7 +324,7 @@ Sets the number of pixels the tooltip will render from the target element. A hig
{{tooltip-on-component spacing=20}}
```

#### Tooltip is shown
#### Is Shown

| Type | Boolean |
|---------|---------|
Expand Down Expand Up @@ -347,6 +353,14 @@ This can be useful alongside `event='none'` when you only want to toolip to show

![popover-hover](https://cloud.githubusercontent.com/assets/7050871/18113238/e010ee64-6ee2-11e6-9ff1-a0c674a6d702.gif)

#### Enable Lazy Rendering

| Type | Boolean |
|---------|---------|
| Default | false (will be true in 3.0.0) |

If enabled tooltips and popovers will only be rendered when a user has interacted with the `$target` element or when `isShown=true`. This delay in render time is especially useful when many tooltips exist in a page.

### Setting Defaults

You can set the default for any option by extending the `{{tooltip-on-element}}` component:
Expand All @@ -359,6 +373,7 @@ import TooltipOnElementComponent from 'ember-tooltips/components/tooltip-on-elem
export default TooltipOnElementComponent.extend({
effect: 'fade',
side: 'bottom',
enableLazyRendering: true,
});
```

Expand Down
156 changes: 156 additions & 0 deletions addon/components/lazy-render-wrapper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import Ember from 'ember';
import layout from 'ember-tooltips/templates/components/lazy-render-wrapper';

const { computed, get, run, $ } = Ember;

// https://github.com/emberjs/rfcs/issues/168
// https://github.com/emberjs/ember.js/pull/12500
function getParent(view) {
if (get(view, 'tagName') === '') {
// Beware: use of private API! :(
if (Ember.ViewUtils && Ember.ViewUtils.getViewBounds) {
return $(Ember.ViewUtils.getViewBounds(view).parentElement);
} else {
return $(view._renderNode.contextualElement);
}
} else {
return view.$().parent();
}
}

// this const is also used in lazy-render-test.js
// to ensure each interaction type causes a render
export const INTERACTION_EVENT_TYPES = ['mouseenter', 'click', 'focusin'];

const PASSABLE_PROPERTIES = [
'delay',
'delayOnChange',
'duration',
'effect',
'event',
'hideOn',
'keepInWindow',
'side',
'showOn',
'spacing',
'isShown',
'tooltipIsVisible',
'hideDelay',
'target',

// non-publicized attributes
'updateFor',
'targetAttachment',
'attachment',
'role',
'tabindex',
];

const PASSABLE_ACTIONS = [
'onDestroy',
'onHide',
'onRender',
'onShow',

// deprecated lifecycle actions
'onTooltipDestroy',
'onTooltipHide',
'onTooltipRender',
'onTooltipShow',
];

const PASSABLE_OPTIONS = PASSABLE_PROPERTIES.concat(PASSABLE_ACTIONS);

export default Ember.Component.extend({
tagName: '',
layout,

passedPropertiesObject: computed(...PASSABLE_OPTIONS, function() {
return PASSABLE_OPTIONS.reduce((passablePropertiesObject, key) => {
// if a property has been declared by Component extension ( TooltipOnElement.extend )
// or by handlebars instantiation ( {{tooltip-on-element}} ) then that property needs
// to be passed from this wrapper to the lazy-rendered tooltip or popover component

let value = this.get(key);

if (!Ember.isNone(value)) {
if (PASSABLE_ACTIONS.indexOf(key) >= 0) {
// if a user has declared a lifecycle action property (onShow='someFunc')
// then we must pass down the correctly-scoped action instead of value

passablePropertiesObject[key] = () => this.sendAction(key);
} else {
passablePropertiesObject[key] = value;
}
}

return passablePropertiesObject;
}, {});
}),

enableLazyRendering: false,
_hasUserInteracted: false,
_hasRendered: false,
_shouldRender: computed('isShown', 'tooltipIsVisible', 'enableLazyRendering', '_hasUserInteracted', function() {
// if isShown, tooltipIsVisible, !enableLazyRendering, or _hasUserInteracted then
// we return true and set _hasRendered to true because
// there is never a scenario where this wrapper should destroy the tooltip

if (this.get('_hasRendered')) {

return true;

} else if (this.get('isShown') || this.get('tooltipIsVisible')) {

this.set('_hasRendered', true);
return true;

} else if (!this.get('enableLazyRendering')) {

this.set('_hasRendered', true);
return true;

} else if (this.get('_hasUserInteracted')) {

this.set('_hasRendered', true);
return true;

}

return false;
}),

didInsertElement() {
this._super(...arguments);

if (this.get('_shouldRender')) {
// if the tooltip _shouldRender then we don't need
// any special $parent event handling
return;
}

const $parent = getParent(this);

INTERACTION_EVENT_TYPES.forEach((eventType) => {
$parent.on(`${eventType}.lazy-ember-popover`, () => {
if (this.get('_hasUserInteracted')) {
$parent.off(`${eventType}.lazy-ember-popover`);
} else {
this.set('_hasUserInteracted', true);
run.next(() => {
$parent.trigger(eventType);
});
}
});
});
},

willDestroyElement() {
this._super(...arguments);

const $parent = getParent(this);
INTERACTION_EVENT_TYPES.forEach((eventType) => {
$parent.off(`${eventType}.lazy-ember-popover`);
});
},
});
5 changes: 1 addition & 4 deletions addon/components/popover-on-component.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import PopoverOnElementComponent from 'ember-tooltips/components/popover-on-element';
import { onComponentTarget } from 'ember-tooltips/utils';

export default PopoverOnElementComponent.extend({

target: onComponentTarget,

tetherComponentName: 'tether-popover-on-component',
});
128 changes: 10 additions & 118 deletions addon/components/popover-on-element.js
Original file line number Diff line number Diff line change
@@ -1,122 +1,14 @@
import Ember from 'ember';
import TooltipAndPopoverComponent from 'ember-tooltips/components/tooltip-and-popover';
import layout from 'ember-tooltips/templates/components/popover-on-element';
import LazyRenderWrapperComponent from 'ember-tooltips/components/lazy-render-wrapper';

const { $, run } = Ember;
export default LazyRenderWrapperComponent.extend({

export default TooltipAndPopoverComponent.extend({
tetherComponentName: 'tether-popover-on-element',

/* Options */
hideDelay: 250,

/* Properties */
layout,
classNames: ['ember-popover'],
_isMouseInside: false,
didInsertElement() {
this._super(...arguments);

const event = this.get('event');
const target = this.get('target');
const $target = $(target);
const $popover = this.$();

if (event === 'none') {

return;

} else if (event === 'hover') {

const _showOn = this.get('_showOn');
const _hideOn = this.get('_hideOn');

// _showOn == 'mouseenter'
$target.on(_showOn, () => this.show());

// _hideOn == 'mouseleave'
$target.add($popover).on(_hideOn, () => {
run.later(() => {
if (!this.get('_isMouseInside')) {
this.hide();
}
}, +this.get('hideDelay'));
});

// we must use mouseover/mouseout because they correctly
// register hover interactivity when spacing='0'
$target.add($popover).on('mouseover', () => this.set('_isMouseInside', true));
$target.add($popover).on('mouseout', () => this.set('_isMouseInside', false));

} else if (event === 'click') {

$(document).on(`click.${target}`, (event) => {
// this lightweight, name-spaced click handler is necessary to determine
// if a click is NOT on $target and NOT an ancestor of $target.
// If so then it must be a click elsewhere and should close the popover
// see... https://css-tricks.com/dangers-stopping-event-propagation/
const isClickedElementElsewhere = this._isElementElsewhere(event.target);
const isPopoverShown = this.get('isShown');

if (isClickedElementElsewhere && isPopoverShown) {
this.hide();
}
});

// we use mousedown because it occurs before the focus event
$target.on('mousedown', (event) => {
// $target.on('mousedown') is called when the $popover is
// clicked because the $popover is contained within the $target.
// This will ignores those types of clicks.
const isMouseDownElementInPopover = this._isElementInPopover(event.target);
if (isMouseDownElementInPopover) {
return;
}
this.toggle();
});
}

$target.on('focus', () => this.show());

$popover.on('focusout', () => {
// use a run.later() to allow the 'focusout' event to finish handling
run.later(() => {
const isFocusedElementElsewhere = this._isElementElsewhere(document.activeElement);
if (isFocusedElementElsewhere) {
this.hide();
}
});
});
},
willDestroyElement() {
this._super(...arguments);

const target = this.get('target');
const $target = $(target);
const $popover = this.$();
const _showOn = this.get('_showOn');
const _hideOn = this.get('_hideOn');

$target.add($popover).off(`${_showOn} mouseover ${_hideOn} mouseout mousedown focus focusout`);

$(document).off(`click.${target}`);
},
_isElementInPopover(newElement) {
// determines if newElement is $popover or contained within $popover
const $popover = this.$();
return $popover.is(newElement) || $popover.find(newElement).length;
},
_isElementElsewhere(newElement) {
// determines if newElement is not $target, not $popover, and not contained within either
const $target = $(this.get('target'));

const isNewElementOutsideTarget = !$target.is(newElement) && !$target.find(newElement).length;
const isNewElementOutsidePopover = !this._isElementInPopover(newElement);

return isNewElementOutsideTarget && isNewElementOutsidePopover;
},
actions: {
hide() {
this.hide();
}
},
childView: null, // this is set during the childView's didRender and is needed for the hide action
actions: {
hide() {
const childView = this.get('childView');
childView.send('hide');
},
},
});
6 changes: 6 additions & 0 deletions addon/components/some-component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Ember from 'ember';

// empty component used for `-on-component` tests
export default Ember.Component.extend({
classNames: 'some-component',
});
8 changes: 8 additions & 0 deletions addon/components/tether-popover-on-component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import TetherPopoverOnElement from 'ember-tooltips/components/tether-popover-on-element';
import { onComponentTarget } from 'ember-tooltips/utils';

export default TetherPopoverOnElement.extend({

target: onComponentTarget,

});
Loading

0 comments on commit d684275

Please sign in to comment.