Files
Oxicloud/static/js/utils/tooltip.js
T

233 lines
9.0 KiB
JavaScript
Raw Normal View History

// @ts-check
/**
* Generic tooltip helper used app-wide.
*
* Two flavours:
*
* - {@link attachTooltip} — short text label, optionally populated
* from a `data-tooltip` attribute. Use
* for one-liners (the email-on-hover on
* a user vignette, the title-text on a
* chip / button, etc.).
* - {@link attachRichTooltip} — structured DOM populated lazily on
* first hover. Use when the content is
* multi-line, async-fetched, or needs
* inner styling (e.g. the member list
* in a group vignette).
*
* Both portal the popover to `document.body` and position it with
* `position: fixed` so it escapes every ancestor `overflow: hidden`
* clip in the page. This is the only reliable cross-browser way to
* keep tooltips fully visible from triggers buried inside list rows,
* scroll containers, or modal panels.
*
* The class toggle (`oxi-tooltip-popover--visible`) is JS-driven on
* `mouseenter` / `mouseleave` / `focusin` / `focusout`. The hover-intent
* delay (250 ms before fade-in, 0 ms before fade-out) lives entirely
* in the CSS transition rules — never in a JS `setTimeout`. See
* `static/css/components/tooltip.css` for the timing source of truth.
*
* Layout helpers exported for the rich variant:
* - `OxiTooltipClass.LINE` — apply to each row inside the popover
* - `OxiTooltipClass.OVERFLOW` — small "+N" badge for truncated lists
* - `OxiTooltipClass.PLACEHOLDER` — italic dimmed text for loading /
* empty states
*/
const POPOVER_CLASS = 'oxi-tooltip-popover';
const VISIBLE_CLASS = 'oxi-tooltip-popover--visible';
const SIMPLE_CLASS = 'oxi-tooltip-popover--simple';
/** Class names exported so callers can build the popover body with the
* layout helpers without dragging in private CSS module conventions. */
export const OxiTooltipClass = Object.freeze({
LINE: 'oxi-tooltip-popover__line',
OVERFLOW: 'oxi-tooltip-popover__overflow',
PLACEHOLDER: 'oxi-tooltip-popover__placeholder'
});
/** Distance between the tooltip and the trigger edge, in pixels. */
const GAP = 6;
/** Inset from the viewport edges when clamping the tooltip position. */
const MARGIN = 8;
/**
* Position `popover` above (or below, when there isn't room above) the
* given trigger element. Uses `position: fixed` so it escapes any
* ancestor `overflow: hidden`. Clamps horizontally and vertically into
* the viewport so tooltips near the edges still read cleanly.
*
* @param {HTMLElement} popover
* @param {HTMLElement} triggerEl
*/
function _positionPopover(popover, triggerEl) {
const triggerRect = triggerEl.getBoundingClientRect();
// Measure after content has been added so we know the final size.
const popRect = popover.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// Vertical: prefer above the trigger. Flip below when there's not
// enough room above.
let top = triggerRect.top - popRect.height - GAP;
if (top < MARGIN) {
top = triggerRect.bottom + GAP;
}
// Horizontal: center on the trigger, clamp into the viewport.
let left = triggerRect.left + triggerRect.width / 2 - popRect.width / 2;
if (left < MARGIN) left = MARGIN;
if (left + popRect.width > vw - MARGIN) left = vw - popRect.width - MARGIN;
// Final vertical clamp — covers the (very rare) case where the
// tooltip is taller than the visible viewport.
if (top + popRect.height > vh - MARGIN) top = vh - popRect.height - MARGIN;
if (top < MARGIN) top = MARGIN;
popover.style.top = `${top}px`;
popover.style.left = `${left}px`;
}
/**
* Internal: wire mouseenter/leave + focusin/out listeners on `triggerEl`,
* lazily create the popover element on first hover, and call `populate`
* once to fill it. Returns a cleanup function that removes the
* listeners and the popover element.
*
* @param {HTMLElement} triggerEl
* @param {(popover: HTMLElement) => void | Promise<void>} populate
* Called exactly once when the popover is first shown. Synchronous
* populates take effect immediately; async populates show the
* placeholder span (if you created one) until the promise resolves,
* after which the popover is re-positioned to account for size
* changes.
* @param {{ simple?: boolean }} [opts]
* `simple`: add the `--simple` modifier so the popover uses the
* single-line label style (white-space: nowrap, no min-width).
* @returns {() => void} Cleanup; idempotent.
*/
function _attach(triggerEl, populate, opts = {}) {
/** @type {HTMLElement | null} */
let popover = null;
let populated = false;
let detached = false;
const ensurePopover = () => {
if (popover) return popover;
popover = document.createElement('div');
popover.className = POPOVER_CLASS + (opts.simple ? ` ${SIMPLE_CLASS}` : '');
// ARIA: behave like a tooltip for screen readers — though we
// also rely on `aria-label` / surrounding text since hover
// isn't reachable via keyboard-only assistive tech.
popover.setAttribute('role', 'tooltip');
document.body.appendChild(popover);
return popover;
};
const show = () => {
if (detached) return;
const pop = ensurePopover();
if (!populated) {
populated = true;
// Synchronous populate paths render immediately. Async
// populates (those returning a Promise) re-position after
// resolve so the tooltip catches up to its final size —
// important when the placeholder text is much narrower
// than the eventual content.
const result = populate(pop);
if (result && typeof (/** @type {Promise<void>} */ (result).then) === 'function') {
/** @type {Promise<void>} */ (result).then(() => {
if (popover?.classList.contains(VISIBLE_CLASS)) {
_positionPopover(popover, triggerEl);
}
});
}
}
_positionPopover(pop, triggerEl);
pop.classList.add(VISIBLE_CLASS);
};
const hide = () => {
if (popover) popover.classList.remove(VISIBLE_CLASS);
};
triggerEl.addEventListener('mouseenter', show);
triggerEl.addEventListener('mouseleave', hide);
triggerEl.addEventListener('focusin', show);
triggerEl.addEventListener('focusout', hide);
return () => {
if (detached) return;
detached = true;
triggerEl.removeEventListener('mouseenter', show);
triggerEl.removeEventListener('mouseleave', hide);
triggerEl.removeEventListener('focusin', show);
triggerEl.removeEventListener('focusout', hide);
popover?.remove();
popover = null;
};
}
/**
* Attach a simple single-line tooltip to `triggerEl`.
*
* @param {HTMLElement} triggerEl
* @param {string} text The label to display.
* @returns {() => void} Cleanup function; idempotent.
*
* @example
* attachTooltip(emailBadgeEl, 'alice@example.com');
*/
export function attachTooltip(triggerEl, text) {
return _attach(
triggerEl,
(pop) => {
pop.textContent = text;
},
{ simple: true }
);
}
/**
* Attach a rich tooltip with structured DOM populated lazily on first
* hover. The `populate` callback receives the popover element and can
* append whatever children it wants. Return a Promise to populate
* async — the popover re-positions on resolve.
*
* @param {HTMLElement} triggerEl
* @param {(popover: HTMLElement) => void | Promise<void>} populate
* @returns {() => void} Cleanup function; idempotent.
*
* @example
* attachRichTooltip(groupEl, async (pop) => {
* const placeholder = document.createElement('div');
* placeholder.className = OxiTooltipClass.PLACEHOLDER;
* placeholder.textContent = i18n.t('groups.members_loading');
* pop.appendChild(placeholder);
* const members = await fetchMembers(groupId);
* pop.replaceChildren(); // drop the placeholder
* for (const name of members.slice(0, 8)) {
* const line = document.createElement('div');
* line.className = OxiTooltipClass.LINE;
* line.textContent = name;
* pop.appendChild(line);
* }
* if (members.length > 8) {
* const overflow = document.createElement('div');
* overflow.className = OxiTooltipClass.LINE;
* const badge = document.createElement('span');
* badge.className = OxiTooltipClass.OVERFLOW;
* badge.textContent = `+${members.length - 8}`;
* overflow.append('… ', badge);
* pop.appendChild(overflow);
* }
* });
*/
export function attachRichTooltip(triggerEl, populate) {
return _attach(triggerEl, populate);
}