@fleetbase/ember-ui
Version:
Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.
459 lines (364 loc) • 16.6 kB
JavaScript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { computed, action, set } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import { htmlSafe } from '@ember/template';
import { cancel, debounce, later, run } from '@ember/runloop';
export default class AttachPopoverComponent extends Component {
animation = 'fill';
arrow = false;
flip = null;
hideDelay = 0;
hideDuration = 300;
hideOn = 'mouseleave blur escapekey';
interactive = false;
isOffset = false;
isShown = false;
lazyRender = false;
modifiers = null;
placement = 'top';
parentNode;
floatingContainer = '.ember-application';
floatingOptions = null;
floatingTarget = null;
renderInPlace = false;
currentTarget = null;
showDelay = 0;
showDuration = 300;
showOn = 'mouseenter focus';
style = null;
useCapture = false;
transitionDuration = 0;
isStartingAnimation = false;
mustRender = false;
onChange = null;
get circleTransitionDuration() {
const { transitionDuration } = this;
return htmlSafe(`transition-duration: ${Math.round(transitionDuration / 1.25)}ms`);
}
get className() {
const showOrHideClass = `ember-attacher-${this.isStartingAnimation ? 'show' : 'hide'}`;
const arrowClass = `ember-attacher-${this.arrow ? 'with' : 'without'}-arrow`;
return `ember-attacher-${this.animation} ${showOrHideClass} ${arrowClass}`;
}
get computedStyle() {
const { style, transitionDuration, isShown } = this;
return htmlSafe(`transition-duration: ${transitionDuration}ms; pointer-events: ${isShown ? 'auto' : 'none'}; ${style ?? ''}`);
}
get showEvents() {
let { showOn } = this;
if (showOn === undefined) {
showOn = 'mouseenter focus';
}
return showOn === null ? [] : showOn.split(' ');
}
get hideEvents() {
let { hideOn } = this;
if (hideOn === undefined) {
hideOn = 'mouseleave blur escapekey';
}
return hideOn === null ? [] : hideOn.split(' ');
}
registerAPI(api) {
this.floatingElement = api.floatingElement;
this.floatingTarget = api.floatingTarget;
this.computePosition = api.computePosition;
}
setDefaultOptions() {
for (const option in this.args) {
if (this.args[option] === undefined) {
continue;
}
set(this, option, this.args[option]);
}
}
setupComponent(element) {
// apply default arguments
this.setDefaultOptions();
// set last used capture arg
this.lastUseCaptureArgumentValue = this.useCapture;
// Used to determine the attachments initial parent element
this.parentNode = this.floatingTarget?.parentNode ?? element.parentNode;
// The debounced _hide() and _how() are stored here so they can be cancelled when necessary
this.delayedVisibilityToggle = null;
// id for this element
this.id = this.id || `${guidFor(this)}-floating-ui`;
// 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.animationTimeout = null;
// Used to store event listeners so they can be removed when necessary.
this.hideListenersOnDocumentByEvent = {};
this.hideListenersOnTargetByEvent = {};
this.showListenersOnTargetByEvent = {};
// Let's go
this.initializeAttacher();
}
initializeAttacher() {
this.removeEventListeners();
this.currentTarget = this.floatingTarget || this.parentNode;
this.addListenersForShowEvents();
this.addListenersForHideEvents();
if (!this.isHidden || this.isShown) {
// Even if the attachment is already shown, we still want to
// call this.show() to make sure its position is updated for a potentially new target.
this.show();
}
}
debouncedHideIfMouseOutsideTargetOrAttachment(event) {
debounce(this, this.hideIfMouseOutsideTargetOrAttachment, event, 10);
}
hide() {
const { floatingElement } = this;
if (!floatingElement) {
this.animationTimeout = requestAnimationFrame(() => {
this.animationTimeout = this.hide();
});
return;
}
cancelAnimationFrame(this.animationTimeout);
this.animationTimeout = requestAnimationFrame(() => {
// Avoid a race condition where we attempt to hide after the component is being destroyed.
if (this.isDestroyed || this.isDestroying) {
return;
}
const hideDuration = parseInt(this.hideDuration);
run(() => {
if (this.isDestroyed || this.isDestroying) {
return;
}
this.transitionDuration = hideDuration;
this.isStartingAnimation = false;
this.floatingElement.setAttribute('aria-hidden', 'true');
// Wait for any animations to complete before hiding the attachment
this.setIsVisibleAfterDelay(false, hideDuration);
});
this.isHidden = true;
});
}
hideAfterDelay() {
cancel(this.delayedVisibilityToggle);
const hideDelay = parseInt(this.hideDelay);
this.delayedVisibilityToggle = debounce(this, this.hide, hideDelay, !hideDelay);
}
hideIfMouseOutsideTargetOrAttachment(event) {
const target = this.currentTarget;
if (!target) {
return;
}
// If cursor is not on the attachment or target, hide the popover
if (!target.contains(event.target) && !(this.isOffset && this.isCursorBetweenTargetAndAttachment(event)) && this.floatingElement && !this.floatingElement.contains(event.target)) {
// Remove this listener before hiding the attachment
delete this.hideListenersOnDocumentByEvent.mousemove;
document.removeEventListener('mousemove', this.hideIfMouseOutsideTargetOrAttachment, this.useCapture);
this.hideAfterDelay();
}
}
hideOnClickOut(event) {
const targetReceivedClick = this.currentTarget.contains(event.target);
if (this.interactive) {
if (!targetReceivedClick && !this.floatingElement.contains(event.target)) {
this.hideAfterDelay();
}
} else if (!targetReceivedClick) {
this.hideAfterDelay();
}
}
hideOnEscapeKey(event) {
if (event.keyCode === 27) {
return this.hideAfterDelay();
}
}
hideOnLostFocus(event) {
if (event.relatedTarget === null) {
this.hideAfterDelay();
}
if (!this.currentTarget) {
return;
}
const targetContainsFocus = this.currentTarget.contains(event.relatedTarget);
if (this.interactive) {
if (!targetContainsFocus && !this.floatingElement.contains(event.relatedTarget)) {
this.hideAfterDelay();
}
} else if (!targetContainsFocus) {
this.hideAfterDelay();
}
}
removeEventListeners() {
const { currentTarget } = this;
Object.keys(this.hideListenersOnDocumentByEvent).forEach((eventType) => {
document.removeEventListener(eventType, this.hideListenersOnDocumentByEvent[eventType], this.useCapture);
delete this.hideListenersOnDocumentByEvent[eventType];
});
if (!currentTarget) {
return;
}
[this.hideListenersOnTargetByEvent, this.showListenersOnTargetByEvent].forEach((eventToListener) => {
Object.keys(eventToListener).forEach((event) => {
this.currentTarget.removeEventListener(event, eventToListener[event], this.useCapture);
});
});
}
addListenersForHideEvents() {
const hideOn = this.hideEvents;
const target = this.currentTarget;
// Target or component was destroyed
if (!target || this.isDestroyed || this.isDestroying) {
return;
}
if (hideOn.includes('click')) {
const showOnClickListener = this.showListenersOnTargetByEvent.click;
if (showOnClickListener) {
target.removeEventListener('click', showOnClickListener, this.useCapture);
delete this.showListenersOnTargetByEvent.click;
}
this.hideListenersOnTargetByEvent.click = this.hideAfterDelay;
target.addEventListener('click', this.hideAfterDelay, this.useCapture);
}
if (hideOn.includes('clickout')) {
const clickoutEvent = 'ontouchstart' in window ? 'touchend' : 'click';
this.hideListenersOnDocumentByEvent[clickoutEvent] = this.hideOnClickOut;
document.addEventListener(clickoutEvent, this.hideOnClickOut, this.useCapture);
}
if (hideOn.includes('escapekey')) {
this.hideListenersOnDocumentByEvent.keydown = this.hideOnEscapeKey;
document.addEventListener('keydown', this.hideOnEscapeKey, this.useCapture);
}
// Hides the attachment when the mouse leaves the target
// (or leaves both target and attachment for interactive attachments)
if (hideOn.includes('mouseleave')) {
this.hideListenersOnTargetByEvent.mouseleave = this.hideOnMouseLeaveTarget;
target.addEventListener('mouseleave', this.hideOnMouseLeaveTarget, this.useCapture);
}
// Hides the attachment when focus is lost on the target
['blur', 'focusout'].forEach((eventType) => {
if (hideOn.includes(eventType)) {
this.hideListenersOnTargetByEvent[eventType] = this.hideOnLostFocus;
target.addEventListener(eventType, this.hideOnLostFocus, this.useCapture);
}
});
}
hideOnMouseLeaveTarget() {
if (this.interactive) {
// TODO(kjb) Should debounce this, but hiding appears sluggish if you debounce.
// - If you debounce with immediate fire, you get a bug where you can move out of the
// attachment and not trigger the hide because the hide check was debounced
// - Ideally we would debounce with an immediate run, then instead of debouncing, we would
// queue another fire at the end of the debounce period
if (!this.hideListenersOnDocumentByEvent.mousemove) {
this.hideListenersOnDocumentByEvent.mousemove = this.hideIfMouseOutsideTargetOrAttachment;
document.addEventListener('mousemove', this.hideIfMouseOutsideTargetOrAttachment, this.useCapture);
}
} else {
this.hideAfterDelay();
}
}
showAfterDelay() {
cancel(this.delayedVisibilityToggle);
this.mustRender = true;
this.addListenersForHideEvents();
const showDelay = parseInt(this.showDelay);
this.delayedVisibilityToggle = debounce(this, this.show, showDelay, !showDelay);
}
show() {
const { currentTarget } = this;
cancelAnimationFrame(this.animationTimeout);
if (!currentTarget) {
return;
}
this.mustRender = true;
// Make the attachment visible immediately so transition animations can take place
this.setIsVisibleAfterDelay(true, 0);
this.startShowAnimation();
}
startShowAnimation() {
// Recompute position before showing animation
if (typeof this.computePosition === 'function') {
this.computePosition(this.floatingTarget, this.floatingElement);
}
// 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
// `display: none` => `display: ''` is not transition-able.
// 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;
}
const floatingElement = this.floatingElement;
// Wait until the element is visible before continuing
if (!floatingElement || floatingElement.style.display === 'none') {
this.animationTimeout = this.startShowAnimation();
return;
}
// 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;
}
run(() => {
if (this.isDestroyed || this.isDestroying || !this.currentTarget) {
return;
}
// Make the popper element visible now that it has been positioned
floatingElement.style.visibility = '';
this.transitionDuration = parseInt(this.showDuration);
this.isStartingAnimation = true;
floatingElement.setAttribute('aria-hidden', 'false');
});
this.isHidden = false;
});
});
}
setIsVisibleAfterDelay(isVisible, delay) {
const { floatingElement } = this;
if (!floatingElement) {
this.animationTimeout = requestAnimationFrame(() => {
this.animationTimeout = this.setIsVisibleAfterDelay(isVisible, delay);
});
return;
}
const onChange = this.onChange;
if (delay) {
this.delayedVisibilityToggle = later(
this,
() => {
this.animationTimeout = requestAnimationFrame(() => {
if (!this.isDestroyed && !this.isDestroying) {
this.floatingElement.style.display = isVisible ? '' : 'none';
// Prevent jank by making the attachment invisible until positioned.
// The visibility style will be toggled by this.startShowAnimation()
this.floatingElement.style.visibility = isVisible ? 'hidden' : '';
if (onChange) {
onChange(isVisible);
}
}
});
},
delay
);
} else {
this.floatingElement.style.display = isVisible ? '' : 'none';
// Prevent jank by making the attachment invisible until positioned.
// The visibility style will be toggled by this.startShowAnimation()
this.floatingElement.style.visibility = isVisible ? 'hidden' : '';
if (onChange) {
onChange(isVisible);
}
}
}
addListenersForShowEvents() {
const { currentTarget } = this;
if (!currentTarget) {
return;
}
this.showEvents.forEach((event) => {
this.showListenersOnTargetByEvent[event] = this.showAfterDelay;
this.currentTarget.addEventListener(event, this.showAfterDelay, this.useCapture);
});
}
}