Skip to content

Commit

Permalink
refactor(use RAF for animations) (#63)
Browse files Browse the repository at this point in the history
* Revert "fix(more foolproof initial position) (#62)"

This reverts commit 73f2c3c.

* refactor(use RAF for animations)
  • Loading branch information
kybishop committed Sep 7, 2017
1 parent 73f2c3c commit e87f77b
Show file tree
Hide file tree
Showing 12 changed files with 242 additions and 103 deletions.
192 changes: 100 additions & 92 deletions addon/components/ember-attacher-inner.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,15 @@ export default Component.extend({
// Holds the current popper target so event listeners can be removed if the target changes
this._currentTarget = null;

// The debounced _hide() is stored here so it can be cancelled
// if a _show() is triggered before the _hide() is executed
this._delayedHide = null;

// The debounced _show() is stored here so it can be cancelled
// if a _hide() is triggered before the _show() is executed
this._delayedShow = null;
// The debounced _hide() and _show() are stored here so they can be cancelled when necessary
this._delayedVisibilityToggle = null;

// The final source of truth on whether or not all _hide() or _show() actions have completed
this._isHidden = true;

// Holds a delayed function to toggle the visibility of the attachment.
// Used to make sure animations can complete before the attachment is hidden.
this._isVisibleTimeout = null;
this._animationTimeout = null;

// Used to store event listeners so they can be removed when necessary.
this._hideListenersOnDocumentByEvent = {};
Expand All @@ -59,12 +54,11 @@ export default Component.extend({
didInsertElement() {
this._super(...arguments);

next(() => {
// If the attachment is initially hidden it has no width when positioned for the first time.
// This can cause it to be positioned too far to the right, such that it overflows the screen
// when shown for the first time.
// We avoid this issue by removing the initial positioning of attachments which are initially
// hidden. The attachment will then correctly update its position from this._show()
requestAnimationFrame(() => {
// The attachment has no width if initially hidden. This can cause it to be positioned so far
// to the right that it overflows the screen until enough updates fix its position.
// We avoid this issue by positioning initially hidden elements in the top left of the screen.
// The attachment will then correctly update its position from the first this._show()
if (this._isHidden && !this.isDestroying && !this.isDestroyed) {
this.element.parentNode.style.transform = null;
}
Expand Down Expand Up @@ -109,6 +103,9 @@ export default Component.extend({
willDestroyElement() {
this._super(...arguments);

cancelAnimationFrame(this._animationTimeout);
cancel(this._delayedVisibilityToggle);

this._removeEventListeners();
},

Expand Down Expand Up @@ -138,11 +135,11 @@ export default Component.extend({
const isShown = this.get('isShown');

if (isShown === true && this._isHidden) {
this._show();

// Add the hide listeners in the next run loop to avoid conflicts
// where clicking triggers both an isShown toggle and a clickout.
next(this, () => this._addListenersForHideEvents());

this._show();
} else if (isShown === false && !this._isHidden) {
this._hide();
}
Expand All @@ -152,6 +149,13 @@ export default Component.extend({
* ================== PRIVATE IMPLEMENTATION DETAILS ==================
*/

actions: {
// Exposed via the named yield to enable custom hide events
hide() {
this._hide();
}
},

classNameBindings: ['_animation', '_isStartingAnimation:ember-attacher-show:ember-attacher-hide'],
// Part of the Component superclass. isVisible == false sets 'display: none'
isVisible: false,
Expand All @@ -176,19 +180,19 @@ export default Component.extend({
}),

_setIsVisibleAfterDelay(isVisible, delay) {
cancel(this._isVisibleTimeout);

const onChange = this.get('onChange');

if (delay) {
this._isVisibleTimeout = later(this, () => {
if (!this.isDestroyed && !this.isDestroying) {
this.set('isVisible', isVisible);

if (onChange) {
onChange(isVisible);
this._delayedVisibilityToggle = later(this, () => {
this._animationTimeout = requestAnimationFrame(() => {
if (!this.isDestroyed && !this.isDestroying) {
this.set('isVisible', isVisible);

if (onChange) {
onChange(isVisible);
}
}
}
});
}, delay);
} else {
this.set('isVisible', isVisible);
Expand All @@ -204,57 +208,105 @@ export default Component.extend({
*/

_showAfterDelay() {
cancel(this._delayedHide);

// The attachment is already visible
if (!this._isHidden) {
return;
}
cancel(this._delayedVisibilityToggle);

this._addListenersForHideEvents();

const showDelay = parseInt(this.get('showDelay'));

this._delayedShow = debounce(this, this._show, showDelay, !showDelay);
this._delayedVisibilityToggle = debounce(this, this._show, showDelay, !showDelay);
},

_show() {
cancel(this._isVisibleTimeout);
cancelAnimationFrame(this._animationTimeout);

const target = this._currentTarget;

// The target was destroyed
if (!target) {
if (!this._currentTarget) {
return;
}

// Make the attachment visible immediately so transition animations can take place
this._setIsVisibleAfterDelay(true, 0);

this.get('scheduleUpdate')();

this.get('enableEventListeners')();
this._startShowAnimation();
},

_startShowAnimation() {
// Start the show animation on the next cycle so CSS transitions can have an effect
// If we start the animation immediately, the transition won't work because isVisible will
// turn on the same time as our show animation, and `display: none` => `display: anythingElse`
// is not transition-able
next(this, () => {
if (this.isDestroyed || this.isDestroying) {
// All included animations set opaque: 0, so the attachment is still effectively hidden until
// the final RAF occurs.
this._animationTimeout = requestAnimationFrame(() => {
if (this.isDestroyed || this.isDestroying || !this._currentTarget) {
return;
}

// Wait until the element is visible before continuing
if (this.element.style.display === 'none') {
this._startShowAnimation();

return;
}

this.get('scheduleUpdate')();
this.get('enableEventListeners')();
this.get('update')();

// Wait for the above positioning to take effect before starting the show animation,
// else the positioning itself will be animated, causing animation glitches.
this._animationTimeout = requestAnimationFrame(() => {
if (this.isDestroyed || this.isDestroying || !this._currentTarget) {
return;
}

const showDuration = parseInt(this.get('showDuration'));

const showDuration = parseInt(this.get('showDuration'));
this.element.style.transitionDuration = `${showDuration}ms`;
this.set('_transitionDuration', showDuration);

this.element.style.transitionDuration = `${showDuration}ms`;
this.set('_transitionDuration', showDuration);
this.set('_isStartingAnimation', true);

this.set('_isStartingAnimation', true);
this._isHidden = false;
});
});
},

/**
* ================== HIDE ATTACHMENT LOGIC ==================
*/

_hideAfterDelay() {
cancel(this._delayedVisibilityToggle);

const hideDelay = parseInt(this.get('hideDelay'));

this._delayedVisibilityToggle = debounce(this, this._hide, hideDelay, !hideDelay);
},

this._isHidden = false;
_hide() {
cancelAnimationFrame(this._animationTimeout);

this._removeListenersForHideEvents();

this._animationTimeout = requestAnimationFrame(() => {
if (this.isDestroyed || this.isDestroying) {
return;
}

const hideDuration = parseInt(this.get('hideDuration'));

this.element.style.transitionDuration = `${hideDuration}ms`;
this.set('_transitionDuration', hideDuration);

this.set('_isStartingAnimation', false);

// Wait for any animations to complete before hiding the attachment
this._setIsVisibleAfterDelay(false, hideDuration);

this.get('disableEventListeners')();

this._isHidden = true;
});
},

/**
Expand Down Expand Up @@ -422,43 +474,6 @@ export default Component.extend({
}
},

/**
* ================== HIDE ATTACHMENT LOGIC ==================
*/

_hideAfterDelay() {
cancel(this._delayedShow);

// The attachment is already hidden
if (this._isHidden) {
return;
}

const hideDelay = parseInt(this.get('hideDelay'));

this._delayedHide = debounce(this, this._hide, hideDelay, !hideDelay);
},

_hide() {
cancel(this._isVisibleTimeout);

this._removeListenersForHideEvents();

const hideDuration = parseInt(this.get('hideDuration'));

this.element.style.transitionDuration = `${hideDuration}ms`;
this.set('_transitionDuration', hideDuration);

this.set('_isStartingAnimation', false);

// Wait for any animations to complete before hiding the attachment
this._setIsVisibleAfterDelay(false, hideDuration);

this.get('disableEventListeners')();

this._isHidden = true;
},

_removeListenersForHideEvents() {
Object.keys(this._hideListenersOnDocumentByEvent).forEach((eventType) => {
document.removeEventListener(eventType, this._hideListenersOnDocumentByEvent[eventType]);
Expand Down Expand Up @@ -494,12 +509,5 @@ export default Component.extend({
delete this._hideListenersOnTargetByEvent[eventType];
}
});
},

actions: {
// Exposed via the named yield to enable custom hide events
hide() {
this._hide();
}
}
});
2 changes: 1 addition & 1 deletion addon/templates/components/ember-attacher.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
isOffset=isOffset
isShown=isShown
onChange=onChange
scheduleUpdate=emberPopper.scheduleUpdate
update=emberPopper.update
showDelay=showDelay
showDuration=showDuration
showOn=showOn
Expand Down
4 changes: 2 additions & 2 deletions tests/dummy/app/templates/components/attachment-example.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@
<centered class="edit-section">
<vbox lm="fit">
<hbox class="nav-bar">
<centered fit class="nav{{if isConfiguringTooltip ' active'}}" {{action 'setIsConfiguringTooltip' true}}>
<centered xs='6' class="nav{{if isConfiguringTooltip ' active'}}" {{action 'setIsConfiguringTooltip' true}}>
\{{#attach-tooltip}}
</centered>
<centered fit class="nav{{unless isConfiguringTooltip ' active'}}" {{action 'setIsConfiguringTooltip' false}}>
<centered xs='6' class="nav{{unless isConfiguringTooltip ' active'}}" {{action 'setIsConfiguringTooltip' false}}>
\{{#ember-attacher}}
</centered>
</hbox>
Expand Down
20 changes: 15 additions & 5 deletions tests/integration/components/ember-attacher/hide-on-blur-test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import hbs from 'htmlbars-inline-precompile';
import wait from 'ember-test-helpers/wait';
import { click, find } from 'ember-native-dom-helpers';
import { click, find, focus } from 'ember-native-dom-helpers';
import { moduleForComponent, test } from 'ember-qunit';

moduleForComponent('ember-attacher', 'Integration | Component | hideOn "blur"', {
Expand Down Expand Up @@ -29,11 +29,14 @@ test('hides when the target loses focus', async function(assert) {
assert.equal(innerAttacher.style.display, 'none', 'Initially hidden');

await click(find('#click-toggle'));
await wait();
await wait();

assert.equal(innerAttacher.style.display, '', 'Now shown');

document.getElementById('focus-me').focus();
await focus('#focus-me');

await wait();
await wait();

assert.equal(innerAttacher.style.display, 'none', 'hidden again');
Expand Down Expand Up @@ -61,11 +64,14 @@ test('with interactive=false: hides when the attachment gains focus', async func
assert.equal(innerAttacher.style.display, 'none', 'Initially hidden');

await click(find('#click-toggle'));
await wait();
await wait();

assert.equal(innerAttacher.style.display, '', 'Now shown');

document.getElementById('attachment-focus-me').focus();
await focus('#attachment-focus-me');

await wait();
await wait();

assert.equal(innerAttacher.style.display, 'none', 'hidden again');
Expand Down Expand Up @@ -94,17 +100,21 @@ test("with interactive=true: doesn't hide when attachment gains focus", async fu
assert.equal(innerAttacher.style.display, 'none', 'Initially hidden');

await click(find('#click-toggle'));
await wait();
await wait();

assert.equal(innerAttacher.style.display, '', 'Now shown');

document.getElementById('attachment-focus-me').focus();
await focus('#attachment-focus-me');

await wait();
await wait();

assert.equal(innerAttacher.style.display, '', 'Still shown');

document.getElementById('outer-focus-me').focus();
await focus('#outer-focus-me');

await wait();
await wait();

assert.equal(innerAttacher.style.display, '', 'Hidden again');
Expand Down
Loading

0 comments on commit e87f77b

Please sign in to comment.