_getElementSize(triggerElement, { contentOnly } = {}) {
let size = {
height: triggerElement.getAttribute(this.attrName('height')),
width: triggerElement.getAttribute(this.attrName('width')),
};
if (!size.height || !size.width) {
this._updateElementContent(triggerElement);
this._withStealthRender(() => {
triggerElement.setAttribute(this.attrName('height'),
(size.height = this.element.offsetHeight));
triggerElement.setAttribute(this.attrName('width'),
(size.width = this.element.offsetWidth));
});
}
if (contentOnly) {
const { paddingTop, paddingLeft, paddingBottom, paddingRight } =
getComputedStyle(this._contentElement);
size.height -= parseFloat(paddingTop) + parseFloat(paddingBottom);
size.width -= parseFloat(paddingLeft) + parseFloat(paddingRight);
}
return size;
}
_getStemSize() {
let size = this._stemSize;
if (size != null) { return size; }
let stemElement = this.selectByClass('stem', this.element);
if (!stemElement) {
size = 0;
} else {
this._withStealthRender(() => {
let margin = getComputedStyle(stemElement).margin.replace(/0px/g, '');
size = Math.abs(parseInt(margin));
});
}
this._stemSize = size;
return size;
}
_getTriggerOffset(triggerElement) {
const { position } = getComputedStyle(triggerElement);
if (position === 'fixed' || position === 'absolute') {
const triggerRect = triggerElement.getBoundingClientRect();
const viewportRect = this.viewportElement.getBoundingClientRect();
return {
left: triggerRect.left - viewportRect.left,
top: triggerRect.top - viewportRect.top,
};
} else {
return {
left: triggerElement.offsetLeft, top: triggerElement.offsetTop
};
}
}
_isTriggerDirection(directionComponent, triggerElement) {
if (this.element.classList.contains(this.className(directionComponent))) {
return true;
}
if (
(!triggerElement || !triggerElement.hasAttribute(this.attrName('direction'))) &&
this.defaultDirection.indexOf(directionComponent) !== -1
) {
return true;
}
return false;
}
_onContextMutation(mutations) {
let newTriggerElements = [];
const allTriggerElements = Array.from(this.querySelector(this.contextElement));
mutations.forEach((mutation) => {
let triggerElements = Array.from(mutation.addedNodes)
.filter(n => n instanceof HTMLElement)
.map((n) => {
let result = this.querySelector(n);
return result.length ? result[0] : n;
})
.filter(n => allTriggerElements.indexOf(n) !== -1);
newTriggerElements = newTriggerElements.concat(triggerElements);
});
this._updateTriggerElements(newTriggerElements);
this.elements = this.elements.concat(newTriggerElements);
this.hoverIntent.elements = this.elements;
}
_onContentElementMouseEnter(event) {
this.debugLog('enter tip');
let triggerElement = this._currentTriggerElement;
if (!triggerElement) { return; }
this.wake({ triggerElement, event });
}
_onContentElementMouseLeave(event) {
this.debugLog('leave tip');
let triggerElement = this._currentTriggerElement;
if (!triggerElement) { return; }
this.sleep({ triggerElement, event });
}
_onTriggerElementMouseEnter(event) {
this.wake({ triggerElement: event.target, event });
}
_onTriggerElementMouseLeave(event) {
this.sleep({ triggerElement: event.target, event });
}
_onTriggerElementMouseMove(event) {
const { target } = event;
if (target.classList.contains(this.className('trigger'))) {
this._updateCurrentTriggerElement(target);
}
if (
this.isAsleep || !this._currentTriggerElement || (
target !== this._currentTriggerElement &&
target !== this._currentTriggerElement.parentElement
)
) {
return;
}
this._updateElementPosition(this._currentTriggerElement, event);
}
_renderElement() {
if (this.element.innerHTML.length) { return; }
this.element.innerHTML = this.template();
this.element.classList.add(
this.className('tip'), this.className('follow'), this.className('hidden'),
...(this.defaultDirection.map(this.className))
);
this._contentElement = this.selectByClass('content', this.element);
this.viewportElement.insertBefore(this.element, this.viewportElement.firstChild);
if (this.snapTo) {
this.element.classList.add(this.className((() => {
if (this.snapToTrigger) { return 'snap-trigger'; }
else if (this.snapToXAxis) { return 'snap-x-side'; }
else if (this.snapToYAxis) { return 'snap-y-side'; }
})()));
}
}
_toggleContextMutationObserver(on) {
if (!this._contextObserver) {
this._contextObserver = new MutationObserver(this._onContextMutation);
}
if (on) {
const options = { childList: true, subtree: true };
this._contextObserver.observe(this.contextElement, options);
} else {
this._contextObserver.disconnect();
}
}
_toggleElement(visible, completion) {
if (this._toggleAnimation) { return; }
const duration = this.cssVariableDuration('toggle-duration', this.element);
let { classList, style } = this.element;
classList.toggle(this.className('visible'), visible);
if (visible) {
classList.remove(this.className('hidden'));
}
this.setTimeout('_toggleAnimation', duration, () => {
if (!visible) {
classList.add(this.className('hidden'));
style.transform = 'none';
}
completion();
});
}
_toggleElementEventListeners(on) {
if (this.elementHoverIntent || !on) {
this.elementHoverIntent.remove();
this.elementHoverIntent = null;
}
if (on) {
this.elementHoverIntent = HoverIntent.extend(this._contentElement);
}
const { eventName } = HoverIntent;
let listeners = {};
listeners[eventName('enter')] = this._onContentElementMouseEnter;
listeners[eventName('leave')] = this._onContentElementMouseLeave;
this.toggleEventListeners(on, listeners, this._contentElement);
}
_toggleTriggerElementEventListeners(on) {
if (this.hoverIntent || !on) {
this.hoverIntent.remove();
this.hoverIntent = null;
}
if (on) {
const { contextElement } = this;
this.hoverIntent = HoverIntent.extend(this.elements, { contextElement });
}
const { eventName } = HoverIntent;
let listeners = {};
listeners[eventName('enter')] = this._onTriggerElementMouseEnter;
listeners[eventName('leave')] = this._onTriggerElementMouseLeave;
listeners[eventName('track')] = this._onTriggerElementMouseMove;
this.toggleEventListeners(on, listeners, this.contextElement);
}
_updateCurrentTriggerElement(triggerElement) {
if (triggerElement == this._currentTriggerElement) { return; }
this._updateElementContent(triggerElement);
let contentSize = this._getElementSize(triggerElement, { contentOnly: true });
this._contentElement.style.height = `${contentSize.height}px`;
this._contentElement.style.width = `${contentSize.width + 1}px`;
let { classList } = this.element;
let compoundDirection = triggerElement.hasAttribute(this.attrName('direction')) ?
triggerElement.getAttribute(this.attrName('direction')).split(' ') :
this.defaultDirection;
let directionClassNames = compoundDirection.map(this.className);
if (!directionClassNames.reduce((memo, className) => {
return memo && classList.contains(className);
}, true)) {
this.debugLog('update direction class', compoundDirection);
classList.remove(...(['top', 'bottom', 'right', 'left'].map(this.className)));
classList.add(...directionClassNames);
}
this._currentTriggerElement = triggerElement;
}
_updateElementContent(triggerElement) {
const content = triggerElement.getAttribute(this.attrName('content'));
this._contentElement.textContent = content;
}
_updateElementPosition(triggerElement, event) {
let cursorHeight = this.snapTo ? 0 : this.cursorHeight;
let offset = { left: event.detail.pageX, top: event.detail.pageY };
if (this.snapTo) {
let triggerOffset = this._getTriggerOffset(triggerElement);
if (this.snapToXAxis || this.snapToTrigger) {
offset.top = triggerOffset.top;
if (this._isTriggerDirection('bottom', triggerElement)) {
offset.top += triggerElement.offsetHeight;
}
if (!this.snapToTrigger) {
offset.left -= this.element.offsetWidth / 2;
}
}
if (this.snapToYAxis || this.snapToTrigger) {
offset.left = triggerOffset.left;
if (!this.snapToTrigger) {
if (this._isTriggerDirection('right', triggerElement)) {
offset.left += triggerElement.offsetWidth + this._getStemSize();
} else if (this._isTriggerDirection('left', triggerElement)) {
offset.left -= this._getStemSize();
}
offset.top -= this.element.offsetHeight / 2 + this._getStemSize();
}
}
}
if (this._isTriggerDirection('top', triggerElement)) {
offset.top -= this.element.offsetHeight + this._getStemSize();
} else if (this._isTriggerDirection('bottom', triggerElement)) {
offset.top += cursorHeight * 2 + this._getStemSize();
}
if (this._isTriggerDirection('left', triggerElement)) {
offset.left -= this.element.offsetWidth;
if (this.element.offsetWidth > triggerElement.offsetWidth) {
offset.left += triggerElement.offsetWidth;
}
}
this.element.style.transform = `translate(${offset.left}px, ${offset.top}px)`;
}
_updateState(state, { event } = {}) {
if (state === this._state) { return; }
if (this._state) {
if (state === 'asleep' && !this.isAsleep) { return; }
if (state === 'awake' && !this.isWaking) { return; }
}
this._state = state;
this.debugLog(state);
if (this.hasListeners && this._currentTriggerElement) {
this._currentTriggerElement.dispatchEvent(
this.createCustomEvent(this._state)
);
}
if (this.isAsleep || this.isAwake) {
if (this._currentTriggerElement) {
this._currentTriggerElement.setAttribute(
this.attrName('has-tip-focus'), this.isAwake
);
}
if (this.hoverIntent) {
this.hoverIntent.configure({ interval: this.isAwake ? 100 : 'default' });
}
} else if (this.isSleeping) {
this._sleepingPosition = { x: event.detail.pageX, y: event.detail.pageY };
} else if (this.isWaking) {
this._sleepingPosition = null;
}
}
_updateTriggerAnchoring(triggerElement) {
let offset = this._getTriggerOffset(triggerElement);
let height = triggerElement.offsetHeight;
let width = triggerElement.offsetWidth;
let tip = this._getElementSize(triggerElement);
this.debugLog({ offset, height, width, tip });
const viewportRect = this.viewportElement.getBoundingClientRect();
let newDirection = this.defaultDirection.map((d) => {
let edge, fits;
if (d === 'bottom') {
fits = (edge = offset.top + height + tip.height) && edge <= viewportRect.height;
} else if (d === 'right') {
fits = (edge = offset.left + tip.width) && edge <= viewportRect.width;
} else if (d === 'top') {
fits = (edge = offset.top - tip.height) && edge >= 0;
} else if (d === 'left') {
fits = (edge = offset.left - tips.width) && edge >= 0;
} else {
fits = true;
}
this.debugLog('check-direction-component', { d, edge });
if (!fits) {
if (d === 'bottom') { return 'top'; }
if (d === 'right') { return 'left'; }
if (d === 'top') { return 'bottom'; }
if (d === 'left') { return 'right'; }
}
return d;
});
triggerElement.setAttribute(this.attrName('direction'), newDirection.join(' '));
}
_updateTriggerContent(triggerElement) {
const { triggerContent } = this;
let content;
if (typeof triggerContent === 'function') {
content = triggerContent(triggerElement);
} else {
let contentAttribute;
let shouldRemoveAttribute = true;
if (triggerElement.hasAttribute(triggerContent)) {
contentAttribute = triggerContent;
} else if (triggerElement.hasAttribute('title')) {
contentAttribute = 'title';
} else if (triggerElement.hasAttribute('alt')) {
contentAttribute = 'alt';
shouldRemoveAttribute = false;
} else {
return console.error('Unsupported trigger.');
}
content = triggerElement.getAttribute(contentAttribute);
if (shouldRemoveAttribute) {
triggerElement.removeAttribute(contentAttribute);
}
}
triggerElement.setAttribute(this.attrName('content'), content);
}
_updateTriggerElements(triggerElements) {
if (!triggerElements) {
triggerElements = this.elements;
}
triggerElements.forEach((triggerElement) => {
triggerElement.classList.add(this.className('trigger'));
this._updateTriggerContent(triggerElement);
this._updateTriggerAnchoring(triggerElement);
});
}
_withStealthRender(fn) {
if (getComputedStyle(this.element).display !== 'none') {
return fn();
}
this.swapClasses('hidden', 'visible', this.element);
this.element.style.visibility = 'hidden';
let result = fn();
this.swapClasses('visible', 'hidden', this.element);
this.element.style.visibility = 'visible';
return result;
}
}
HLF.buildExtension(Tip, {
autoBind: true,
compactOptions: true,
mixinNames: ['css', 'event', 'selection'],
});
Object.assign(HLF, { Tip });
return Tip;
});
HLF Tip Extension
Styles | Tests
The base
tip
plugin does several things. It does basic parsing of trigger element attributes for the tip content. It can anchor itself to a trigger by selecting the best direction. It can follow the cursor. It toggles its appearance by fading in and out and resizing, all via configurable animation options. It can display custom tip content. It uses thehlf.hoverIntent
event extension to prevent over-queueing of appearance handlers. Last, the tip object attaches to the context element. It acts as tip for the the current jQuery selection via event delegation.The extended
snapTip
plugin extends the base tip. It allows the tip to snap to the trigger element. And by default the tip locks into place. But turn on only one axis of snapping, and the tip will follow the mouse only on the other axis. For example, snapping to the x-axis will only allow the tip to shift along the y-axis. The x will remain constant.