Skip to content

Instantly share code, notes, and snippets.

@jd1378
Last active July 1, 2023 13:44
Show Gist options
  • Save jd1378/f579d304c18f54c388380f6fcfd19ef2 to your computer and use it in GitHub Desktop.
Save jd1378/f579d304c18f54c388380f6fcfd19ef2 to your computer and use it in GitHub Desktop.
A floating UI tooltip directive for Vue 3 that reuses the created tooltip (good for when you have hundreds of components). uses tailwind, make sure to include the file in your settings or change classes to suit your needs.
/**
* Author: @jd1378 (https://github.com/jd1378)
* License: MIT
*/
import {
Placement,
arrow,
autoUpdate,
computePosition,
flip,
hide,
offset,
shift,
} from '@floating-ui/dom';
import type {DirectiveBinding} from 'vue';
const possiblePlacements = {
'top-start': true,
'top-end': true,
'right-start': true,
'right-end': true,
'bottom-start': true,
'bottom-end': true,
'left-start': true,
'left-end': true,
top: true,
right: true,
bottom: true,
left: true,
};
const evtOpts: AddEventListenerOptions & EventListenerOptions = {passive: true};
type ContainerHTMLElement = HTMLElement & {
_focused?: HTMLElement;
};
type ExtendedHTMLElement = HTMLElement & {
_tip_m_in?: () => void;
_tip_m_out?: () => void;
_tip_click?: () => void;
_cleanup?: () => void;
_cancelAutoUpdate?: () => void;
_tooltipDestroy?: () => void;
};
function getTooltipContainer(id: string): ContainerHTMLElement {
return document.getElementById(id) as ContainerHTMLElement;
}
function insertTooltipContainer(id: string): ContainerHTMLElement {
// el itself
const el = document.createElement('div');
el.id = id;
el.className =
'absolute will-change-transform drop-shadow-[black_0px_6px_7px] top-0 left-0 max-w-[16rem]';
el.style.visibility = 'hidden';
el.style.transform = 'translate3d(0,0,0)';
el.style.zIndex = '9999';
// arrow
const arrow = document.createElement('div');
arrow.id = id + '-arrow';
arrow.className = 'absolute bg-[#292928] pointer-events-none w-3 h-3';
el.appendChild(arrow);
// content
const content = document.createElement('div');
content.className =
'relative bg-[#292928] rounded-md p-1 text-white-3 text-center';
el.appendChild(content);
document.body.appendChild(el);
return el;
}
function initializeTooltipContainer(id: string) {
const container = getTooltipContainer(id);
if (!container) {
return insertTooltipContainer(id);
}
return container;
}
function getPlacement(binding: DirectiveBinding) {
let placement: Placement = 'top';
for (const key in binding.modifiers) {
// this is an efficient way to get the first key in modifiers for placement
if (key in possiblePlacements) {
placement = key as Placement;
break;
}
}
return placement;
}
function roundByDPR(value: number) {
const dpr = window.devicePixelRatio || 1;
return Math.round(value * dpr) / dpr;
}
function directiveMount(el: ExtendedHTMLElement, binding: DirectiveBinding) {
if (!binding.value && binding.value !== undefined) return;
if (typeof binding.value?.delay === 'number') {
setTimeout(() => {
directive(el, binding);
}, binding.value.delay);
} else {
directive(el, binding);
}
}
function directive(el: ExtendedHTMLElement, binding: DirectiveBinding) {
if (!binding.value && binding.value !== undefined) return;
const bindingValue = binding.value || '';
const tooltipId = bindingValue.id || 'tooltip-ctr';
const placement = getPlacement(binding);
const forceShow = 'force' in binding.modifiers || bindingValue.force;
if (tooltipId && binding.oldValue?.id && binding.oldValue?.id !== tooltipId) {
if (el._tooltipDestroy) {
el._tooltipDestroy();
el._tooltipDestroy = undefined;
}
}
const container = initializeTooltipContainer(tooltipId);
const arrowEl = document.getElementById(tooltipId + '-arrow')!;
const arrowLen = arrowEl.offsetWidth;
const floatingOffset = Math.sqrt(2 * arrowLen ** 2) / 2;
const state = {
isHovering: false,
get isVisible() {
return forceShow || container._focused || this.isHovering;
},
};
function updateDisplay() {
if (state.isVisible) {
container.style.visibility = '';
} else {
container.style.visibility = 'hidden';
}
}
function updatePosition() {
computePosition(container._focused || el, container, {
middleware: [
offset(floatingOffset),
flip(),
hide(),
shift(),
arrow({element: arrowEl}),
],
placement,
}).then(({x, y, middlewareData: {hide, arrow}, placement}) => {
const style: Record<string, string> = {
transform: `translate3d(${roundByDPR(x)}px,${roundByDPR(y)}px,0)`,
};
if (!hide?.referenceHidden && state.isVisible) {
style.visibility = '';
// arrow placement
const side = placement.split('-')[0];
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[side];
if (arrow) {
const {x, y} = arrow;
Object.assign(arrowEl.style, {
// Ensure the static side gets unset when
// flipping to other placements' axes.
top: y != null ? roundByDPR(y) + 'px' : '',
left: x != null ? roundByDPR(x) + 'px' : '',
right: '',
bottom: '',
transform: 'rotate(45deg)',
// the - 2 below is to ensure the arrow's point does not land on element exactly
[staticSide as string]: `${-(arrowLen - 2) / 2}px`,
});
}
} else {
style.visibility = 'hidden';
style.transform = `translate3d(0px,0px,0)`;
Object.assign(arrowEl.style, {
// to prevent page overflow caused by the rotation
top: '0',
left: '0',
transform: '',
});
}
Object.assign(container.style, style);
});
}
if (el._cleanup) el._cleanup();
function setupAutoUpdate() {
return autoUpdate(el, container, updatePosition, {
animationFrame: true,
layoutShift: false,
});
}
function updateTooltipContent() {
if (bindingValue) {
container.children[1].innerHTML = bindingValue.label || bindingValue;
} else {
container.children[1].innerHTML =
el.querySelector('img')?.getAttribute('alt') ||
el.getAttribute('aria-label') ||
'';
}
}
function onClickOut(event: Event | null) {
if (event) {
if (
event.target === el ||
event.target === container ||
(event.target !== el && event.composedPath().includes(el)) ||
(event.target !== container && event.composedPath().includes(container))
) {
return;
}
}
window.removeEventListener('click', onClickOut);
el._cancelAutoUpdate && el._cancelAutoUpdate();
state.isHovering = false;
if (container._focused === el) {
container._focused = undefined;
}
updateDisplay();
}
if (forceShow) {
if (!el._cancelAutoUpdate) {
el._cancelAutoUpdate = setupAutoUpdate();
}
updateTooltipContent();
updatePosition();
} else {
if (el._cancelAutoUpdate) {
el._cancelAutoUpdate();
}
if (!el._tip_m_in) {
el._tip_m_in = function onMouseEnter() {
if (container._focused) {
return;
}
updateTooltipContent();
state.isHovering = true;
el._cancelAutoUpdate = setupAutoUpdate();
updatePosition();
};
}
if (!el._tip_m_out) {
el._tip_m_out = function onMouseLeave() {
if (container._focused) {
return;
}
state.isHovering = false;
if (el._cancelAutoUpdate) {
el._cancelAutoUpdate();
el._cancelAutoUpdate = undefined;
}
updatePosition();
};
}
if (!el._tip_click && !('no-focus' in binding.modifiers)) {
el._tip_click = function onClick() {
if (container._focused === el) {
onClickOut(null);
return;
}
if (container._focused) {
if (el._cancelAutoUpdate) {
el._cancelAutoUpdate();
el._cancelAutoUpdate = undefined;
}
}
container._focused = el;
window.addEventListener('click', onClickOut, evtOpts);
updateTooltipContent();
el._cancelAutoUpdate = setupAutoUpdate();
updatePosition();
};
}
el.addEventListener('mouseenter', el._tip_m_in, evtOpts);
el.addEventListener('mouseleave', el._tip_m_out, evtOpts);
if (el._tip_click) {
el.addEventListener('click', el._tip_click, evtOpts);
}
}
updatePosition();
el._cleanup = function cleanupDirective() {
if (el._cancelAutoUpdate) el._cancelAutoUpdate();
el._tip_m_in && el.removeEventListener('mouseenter', el._tip_m_in, evtOpts);
el._tip_m_out &&
el.removeEventListener('mouseleave', el._tip_m_out, evtOpts);
el._tip_click && el.removeEventListener('click', el._tip_click, evtOpts);
window.removeEventListener('click', onClickOut, evtOpts);
delete el._tip_click;
delete el._tip_m_in;
delete el._tip_m_out;
};
if (!el._tooltipDestroy) {
el._tooltipDestroy = function destroyDirective() {
if (container.id !== 'tooltip-ctr') {
container.remove();
}
};
}
}
export const tooltipDirective = {
mounted: directiveMount,
updated: directive,
beforeUnmount(el: ExtendedHTMLElement) {
if (el._cleanup) el._cleanup();
if (el._tooltipDestroy) el._tooltipDestroy();
},
getSSRProps(_: any, __: any) {
return {};
},
};
export default tooltipDirective;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment