export interface PositionOptions {
reference: HTMLElement;
element: HTMLElement;
placement?: 'top' | 'bottom' | 'left' | 'right';
offset?: [number, number];
checkBoundary?: boolean | {
top?: boolean;
right?: boolean;
bottom?: boolean;
left?: boolean;
};
width?: number;
height?: number;
safetyDistance?: number;
}
export interface PositionResult {
placement: 'top' | 'bottom' | 'left' | 'right';
style: {
top: string;
left: string;
position: 'absolute' | 'fixed';
};
adjusted: boolean;
}
export const calculatePosition = (options: PositionOptions): PositionResult => {
const {
reference,
element,
placement = 'bottom',
offset = [0, 0],
checkBoundary = {
top: false,
right: true,
bottom: false,
left: false
},
width,
height,
safetyDistance = 18
} = options;
const referenceRect = reference.getBoundingClientRect();
const elementWidth = width || element.offsetWidth;
const elementHeight = height || element.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let finalPlacement = placement;
let finalTop = 0;
let finalLeft = 0;
let adjusted = false;
const positions = {
top: {
top: referenceRect.top - elementHeight - offset[1],
left: referenceRect.left + referenceRect.width / 2 - elementWidth / 2 + offset[0]
},
bottom: {
top: referenceRect.bottom + offset[1],
left: referenceRect.left + referenceRect.width / 2 - elementWidth / 2 + offset[0]
},
left: {
top: referenceRect.top + referenceRect.height / 2 - elementHeight / 2 + offset[1],
left: referenceRect.left - elementWidth - offset[0]
},
right: {
top: referenceRect.top + referenceRect.height / 2 - elementHeight / 2 + offset[1],
left: referenceRect.right + offset[0]
}
};
const checkBoundaryConfig = typeof checkBoundary === 'boolean'
? {
top: checkBoundary,
right: checkBoundary,
bottom: checkBoundary,
left: checkBoundary
}
: {
top: true,
right: true,
bottom: true,
left: true,
...checkBoundary
};
if (checkBoundaryConfig.top || checkBoundaryConfig.right || checkBoundaryConfig.bottom || checkBoundaryConfig.left) {
const preferredPosition = positions[placement];
const isInViewport = {
top: !checkBoundaryConfig.top || preferredPosition.top >= safetyDistance,
left: !checkBoundaryConfig.left || preferredPosition.left >= safetyDistance,
bottom: !checkBoundaryConfig.bottom || preferredPosition.top + elementHeight <= viewportHeight - safetyDistance,
right: !checkBoundaryConfig.right || preferredPosition.left + elementWidth <= viewportWidth - safetyDistance
};
if (isInViewport.top && isInViewport.left && isInViewport.bottom && isInViewport.right) {
finalTop = preferredPosition.top;
finalLeft = preferredPosition.left;
} else {
adjusted = true;
let adjustedPosition = { ...preferredPosition };
if (checkBoundaryConfig.left || checkBoundaryConfig.right) {
const maxLeft = checkBoundaryConfig.right ? viewportWidth - elementWidth - safetyDistance : Infinity;
const minLeft = checkBoundaryConfig.left ? safetyDistance : -Infinity;
if (adjustedPosition.left < minLeft) {
adjustedPosition.left = minLeft;
} else if (adjustedPosition.left > maxLeft) {
adjustedPosition.left = maxLeft;
}
}
if (checkBoundaryConfig.top || checkBoundaryConfig.bottom) {
const maxTop = checkBoundaryConfig.bottom ? viewportHeight - elementHeight - safetyDistance : Infinity;
const minTop = checkBoundaryConfig.top ? safetyDistance : -Infinity;
if (adjustedPosition.top < minTop) {
adjustedPosition.top = minTop;
} else if (adjustedPosition.top > maxTop) {
adjustedPosition.top = maxTop;
}
}
const adjustedIsInViewport = {
top: !checkBoundaryConfig.top || adjustedPosition.top >= safetyDistance,
left: !checkBoundaryConfig.left || adjustedPosition.left >= safetyDistance,
bottom: !checkBoundaryConfig.bottom || adjustedPosition.top + elementHeight <= viewportHeight - safetyDistance,
right: !checkBoundaryConfig.right || adjustedPosition.left + elementWidth <= viewportWidth - safetyDistance
};
if (adjustedIsInViewport.top && adjustedIsInViewport.left && adjustedIsInViewport.bottom && adjustedIsInViewport.right) {
finalTop = adjustedPosition.top;
finalLeft = adjustedPosition.left;
} else {
const getPlacementPriority = (altPlacement: string) => {
const similarity: Record<string, Record<string, number>> = {
top: { top: 0, bottom: 1, left: 2, right: 2 },
bottom: { top: 1, bottom: 0, left: 2, right: 2 },
left: { top: 2, bottom: 2, left: 0, right: 1 },
right: { top: 2, bottom: 2, left: 1, right: 0 }
};
return similarity[placement][altPlacement];
};
const otherPlacements = ['top', 'bottom', 'left', 'right']
.filter(p => p !== placement)
.sort((a, b) => getPlacementPriority(a) - getPlacementPriority(b));
let foundValidPosition = false;
for (const altPlacement of otherPlacements) {
const altPosition = positions[altPlacement as keyof typeof positions];
const altIsInViewport = {
top: !checkBoundaryConfig.top || altPosition.top >= safetyDistance,
left: !checkBoundaryConfig.left || altPosition.left >= safetyDistance,
bottom: !checkBoundaryConfig.bottom || altPosition.top + elementHeight <= viewportHeight - safetyDistance,
right: !checkBoundaryConfig.right || altPosition.left + elementWidth <= viewportWidth - safetyDistance
};
if (altIsInViewport.top && altIsInViewport.left && altIsInViewport.bottom && altIsInViewport.right) {
finalPlacement = altPlacement as any;
finalTop = altPosition.top;
finalLeft = altPosition.left;
foundValidPosition = true;
break;
}
}
if (!foundValidPosition) {
finalTop = adjustedPosition.top;
finalLeft = adjustedPosition.left;
}
}
}
} else {
const preferredPosition = positions[placement];
finalTop = preferredPosition.top;
finalLeft = preferredPosition.left;
}
return {
placement: finalPlacement,
style: {
top: `${finalTop}px`,
left: `${finalLeft}px`,
position: 'fixed',
},
adjusted
};
};
export const applyPosition = (options: PositionOptions): PositionResult => {
const result = calculatePosition(options);
Object.assign(options.element.style, result.style);
return result;
};
export const calculateArrowPosition = (
options: PositionOptions,
arrowSize: number = 8
) => {
const { reference, element, placement = 'bottom', offset = [0, 0] } = options;
const referenceRect = reference.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const styles: Record<string, string> = {};
switch (placement) {
case 'top':
styles.bottom = `-${arrowSize}px`;
styles.left = `${referenceRect.left + referenceRect.width / 2 - elementRect.left - arrowSize / 2 + offset[0]}px`;
styles.transform = 'translateX(-50%)';
break;
case 'bottom':
styles.top = `-${arrowSize}px`;
styles.left = `${referenceRect.left + referenceRect.width / 2 - elementRect.left - arrowSize / 2 + offset[0]}px`;
styles.transform = 'translateX(-50%)';
break;
case 'left':
styles.right = `-${arrowSize}px`;
styles.top = `${referenceRect.top + referenceRect.height / 2 - elementRect.top - arrowSize / 2 + offset[1]}px`;
styles.transform = 'translateY(-50%)';
break;
case 'right':
styles.left = `-${arrowSize}px`;
styles.top = `${referenceRect.top + referenceRect.height / 2 - elementRect.top - arrowSize / 2 + offset[1]}px`;
styles.transform = 'translateY(-50%)';
break;
}
return styles;
};
export const applyArrowPosition = (
options: PositionOptions,
arrow: HTMLElement,
arrowSize: number = 8
) => {
const styles = calculateArrowPosition(options, arrowSize);
Object.assign(arrow.style, styles);
};