@ussebastian/kitdigital
Version:
Kit Digital de la Universidad San Sebastián
292 lines (264 loc) • 8.66 kB
JavaScript
import {
computePosition,
autoPlacement,
flip,
shift,
offset,
arrow,
autoUpdate,
} from '@floating-ui/dom';
export class Tooltip {
constructor(element, key) {
this.key = key;
this.element = element;
this.setupInitialState();
}
setupInitialState() {
this.tooltip = null;
this.arrow = null;
this.cleanup = () => {};
this.delay = 350;
this.placement = 'top';
this.isOpen = false;
this.isOverTrigger = false;
this.shiftPadding = 0;
this.autoPlacement = false;
this.startOpen = false;
this.validPlacements = [
'top',
'right',
'bottom',
'left',
'top-start',
'top-end',
'right-start',
'right-end',
'bottom-start',
'bottom-end',
'left-start',
'left-end',
];
}
setAriaAttributes() {
this.element.setAttribute('aria-describedby', this.key);
this.tooltip.setAttribute('id', this.key);
this.tooltip.setAttribute('role', 'tooltip');
}
mount() {
this.handleDataSet();
this.buildTooltipElement();
this.setAriaAttributes();
this.updateTooltipPosition();
this.setEventListeners();
this.hideTooltip(true);
}
handleDataSet() {
const {
ussTooltipDelay,
ussTooltipPlacement,
ussTooltipShiftPadding,
ussTooltipAutoPlacement,
ussTooltipStartOpen,
} = this.element.dataset;
const validPlacement = this.validPlacements.includes(ussTooltipPlacement);
if (ussTooltipPlacement && !validPlacement) {
throw new Error(
`El valor de data-uss-tooltip-placement: ${ussTooltipPlacement} no es válido.`,
);
}
this.delay = ussTooltipDelay ? Number(ussTooltipDelay) : this.delay;
this.placement = ussTooltipPlacement || this.placement;
this.shiftPadding = Number(ussTooltipShiftPadding) || this.shiftPadding;
this.autoPlacement = Boolean(ussTooltipAutoPlacement) || this.autoPlacement;
this.startOpen = Boolean(ussTooltipStartOpen) || this.startOpen;
}
buildTooltipElement() {
const contentElementId = this.element.getAttribute('data-uss-tooltip-content-element-id');
const contentText = this.element.getAttribute('data-uss-tooltip-content');
this.validateContent(contentText, contentElementId);
this.tooltip = this.findOrCreateTooltip(contentElementId, contentText);
this.cannotHaveButtonsOrLinks();
this.buildArrow();
}
// eslint-disable-next-line class-methods-use-this
validateContent(contentText, contentElementId) {
if (!contentText && !contentElementId)
throw new Error('El Tooltip debe tener contenido o un elemento asociado.');
}
findOrCreateTooltip(contentElementId, contentText) {
if (contentElementId) {
const tooltipElements = document.querySelectorAll(
`[data-uss-tooltip-content-id="${contentElementId}"]`,
);
if (tooltipElements.length !== 1) {
throw new Error(
`Elemento con data-uss-tooltip-content-id: ${contentElementId} ${
tooltipElements.length > 1 ? 'no es único.' : 'no existe.'
}`,
);
}
return tooltipElements[0];
}
if (contentText.includes('<') || contentText.includes('>')) {
throw new Error("El attributo 'data-uss-tooltip-content' no puede contener HTML.");
}
const tooltip = document.createElement('div');
tooltip.innerHTML = contentText;
this.element.parentNode.insertBefore(tooltip, this.element.nextSibling);
tooltip.setAttribute('data-uss-tooltip-content-id', this.key);
this.element.removeAttribute('data-uss-tooltip-content');
return tooltip;
}
cannotHaveButtonsOrLinks() {
const buttons = this.tooltip.querySelectorAll('button');
const links = this.tooltip.querySelectorAll('a');
const inputs = this.tooltip.querySelectorAll('input');
const tabindex = this.tooltip.querySelectorAll('[tabindex]');
if (buttons.length || links.length || inputs.length || tabindex.length) {
throw new Error(
'El tooltip no puede contener botones, inputs, links ni nada que lleve tabindex. Evaluar otras soluciones',
);
}
}
buildArrow() {
this.arrow = document.createElement('div');
this.arrow.setAttribute('data-uss-tooltip-arrow', this.key);
this.tooltip.appendChild(this.arrow);
}
getAntiOverflowStrategy() {
return this.autoPlacement ? autoPlacement : flip;
}
updateTooltipPosition() {
computePosition(this.element, this.tooltip, {
placement: this.placement,
middleware: [
offset(12),
this.getAntiOverflowStrategy()({
padding: 10,
}),
shift({ padding: this.shiftPadding }),
arrow({ element: this.arrow }),
],
}).then(({ x, y, placement, middlewareData }) => {
this.positionTooltip({ x, y });
this.positionArrow(middlewareData.arrow, placement);
});
}
positionTooltip({ x, y }) {
Object.assign(this.tooltip.style, { left: `${x}px`, top: `${y}px` });
}
positionArrow({ x: arrowX, y: arrowY }, placement) {
const staticSide = this.getStaticSide(placement);
Object.assign(this.arrow.style, this.getArrowStyle(arrowX, arrowY, staticSide, placement));
}
// eslint-disable-next-line class-methods-use-this
getStaticSide(placement) {
return { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]];
}
getArrowStyle(arrowX, arrowY, staticSide, placement) {
const side = placement.split('-')[0];
const borderStyle = this.getArrowBorderStyle(side);
const x = arrowX ? `${arrowX}px` : 'auto';
const y = arrowY ? `${arrowY}px` : 'auto';
return {
left: x,
top: y,
right: '',
bottom: '',
[staticSide]: '-4px',
...borderStyle,
};
}
// eslint-disable-next-line class-methods-use-this
getArrowBorderStyle(side) {
const styles = {
top: {
borderTop: 'none',
borderRight: 'inherit',
borderBottom: 'inherit',
borderLeft: 'none',
},
bottom: {
borderTop: 'inherit',
borderRight: 'none',
borderBottom: 'none',
borderLeft: 'inherit',
},
left: {
borderTop: 'inherit',
borderRight: 'inherit',
borderBottom: 'none',
borderLeft: 'none',
},
right: {
borderTop: 'none',
borderRight: 'none',
borderBottom: 'inherit',
borderLeft: 'inherit',
},
};
return styles[side];
}
setEventListeners() {
this.element.addEventListener('mouseenter', this.handleMouseEnter.bind(this));
this.element.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
this.element.addEventListener('focus', () => this.showTooltip(true));
this.element.addEventListener('blur', () => this.hideTooltip());
this.element.addEventListener('keydown', this.handleKeydown.bind(this));
}
handleMouseEnter() {
this.isOverTrigger = true;
setTimeout(() => {
if (this.isOverTrigger) this.showTooltip();
}, this.delay);
}
handleMouseLeave() {
this.isOverTrigger = false;
setTimeout(() => {
if (!this.isOverTrigger) this.hideTooltip();
}, this.delay / 2);
}
handleKeydown(event) {
if (['Escape', 'Enter', ' '].includes(event.key)) {
this.hideTooltip();
}
}
showTooltip(isFocus = false) {
if (!isFocus) {
this.tooltip.addEventListener('mouseenter', () => {
this.isOverTrigger = true;
});
this.tooltip.addEventListener('mouseleave', () => {
this.isOverTrigger = false;
setTimeout(() => {
if (!this.isOverTrigger) this.hideTooltip();
}, this.delay / 4);
});
setTimeout(() => {
if (!this.isOverTrigger) this.hideTooltip();
}, this.delay / 4);
this.tooltip.addEventListener('mouseenter', () => {
this.isOverTrigger = true;
});
this.tooltip.addEventListener('mouseleave', () => {
this.isOverTrigger = false;
});
setTimeout(() => {
if (!this.isOverTrigger) this.hideTooltip();
}, this.delay / 4);
}
document.addEventListener('keydown', this.hideOnEscape.bind(this));
document.body.append(this.tooltip);
this.cleanup = autoUpdate(this.element, this.tooltip, () => this.updateTooltipPosition());
this.isOpen = true;
}
hideTooltip(noCleanup = false) {
document.removeEventListener('keydown', this.hideOnEscape.bind(this));
if (this.tooltip) this.tooltip.remove();
this.isOpen = false;
if (!noCleanup) this.cleanup();
}
hideOnEscape(e) {
if (e.key === 'Escape') this.hideTooltip();
}
}