@redocly/theme
Version:
Shared UI components lib
142 lines (124 loc) • 3.91 kB
text/typescript
import type { TooltipPlacement, TooltipProps } from '@redocly/theme/core/types';
const PLACEMENT_MARGIN = 10;
const COUNTER_CLOCKWISE: TooltipPlacement[] = ['top', 'left', 'bottom', 'right'];
export function getDefaultFallbackPlacements(placement: TooltipPlacement): TooltipPlacement[] {
const index = COUNTER_CLOCKWISE.indexOf(placement);
const result: TooltipPlacement[] = [];
for (let i = 1; i < COUNTER_CLOCKWISE.length; i++) {
result.push(COUNTER_CLOCKWISE[(index + i) % COUNTER_CLOCKWISE.length]);
}
return result;
}
export function calcAnchorPoint(
triggerRect: DOMRect,
placement: TooltipPlacement,
arrowPosition: TooltipProps['arrowPosition'],
): { top: number; left: number } {
const horizontalLeft = (): number =>
arrowPosition === 'left'
? triggerRect.left - 24
: arrowPosition === 'right'
? triggerRect.right + 24
: triggerRect.left + triggerRect.width / 2;
const verticalTop = (): number => triggerRect.top + triggerRect.height / 2;
switch (placement) {
case 'top':
return { top: triggerRect.top, left: horizontalLeft() };
case 'bottom':
return { top: triggerRect.bottom, left: horizontalLeft() };
case 'left':
return { top: verticalTop(), left: triggerRect.left };
case 'right':
return { top: verticalTop(), left: triggerRect.right };
}
}
type FitsInViewportParams = {
anchor: { top: number; left: number };
tooltipWidth: number;
tooltipHeight: number;
placement: TooltipPlacement;
arrowPosition: TooltipProps['arrowPosition'];
};
export function fitsInViewport({
anchor,
tooltipWidth,
tooltipHeight,
placement,
arrowPosition,
}: FitsInViewportParams): boolean {
const horizontalLeft = (): number =>
arrowPosition === 'left'
? anchor.left
: arrowPosition === 'right'
? anchor.left - tooltipWidth
: anchor.left - tooltipWidth / 2;
const verticalTop = (): number => anchor.top - tooltipHeight / 2;
let top: number;
let left: number;
switch (placement) {
case 'top':
top = anchor.top - tooltipHeight - PLACEMENT_MARGIN;
left = horizontalLeft();
break;
case 'bottom':
top = anchor.top + PLACEMENT_MARGIN;
left = horizontalLeft();
break;
case 'left':
top = verticalTop();
left = anchor.left - tooltipWidth - PLACEMENT_MARGIN;
break;
case 'right':
top = verticalTop();
left = anchor.left + PLACEMENT_MARGIN;
break;
}
return (
top >= 0 &&
left >= 0 &&
left + tooltipWidth <= window.innerWidth &&
top + tooltipHeight <= window.innerHeight
);
}
type ResolvePlacementParams = {
triggerRect: DOMRect;
tooltipWidth: number;
tooltipHeight: number;
placement: TooltipPlacement;
arrowPosition: TooltipProps['arrowPosition'];
fallbackPlacements: TooltipPlacement[] | undefined;
};
/**
* Given the trigger rect, tooltip dimensions, primary placement/arrow, and
* fallback list, returns the first placement that keeps the tooltip fully
* inside the viewport. Falls back to the primary when nothing fits.
*/
export function resolvePlacement({
triggerRect,
tooltipWidth,
tooltipHeight,
placement,
arrowPosition,
fallbackPlacements,
}: ResolvePlacementParams): TooltipPlacement {
if (!fallbackPlacements?.length || tooltipWidth === 0 || tooltipHeight === 0) {
return placement;
}
const candidates: TooltipPlacement[] = [placement, ...fallbackPlacements];
for (const candidate of candidates) {
const candidateArrow = candidate === placement ? arrowPosition : 'center';
const pos = calcAnchorPoint(triggerRect, candidate, candidateArrow);
if (
fitsInViewport({
anchor: pos,
tooltipWidth,
tooltipHeight,
placement: candidate,
arrowPosition: candidateArrow,
})
) {
return candidate;
}
}
return placement;
}