carbon-components-svelte
Version:
Svelte implementation of the Carbon Design System
205 lines (192 loc) • 6.78 kB
JavaScript
// @ts-check
/**
* @typedef {{ top: number, bottom: number, left: number, right: number, width: number, height: number }} RectLike
*/
/**
* Place a floating element next to an anchor. Caller passes both rects and the
* viewport. Flips direction when the preferred side does not fit.
*
* @param {Object} options
* @param {RectLike} options.anchorRect - The anchor's `getBoundingClientRect()`.
* @param {RectLike} options.floatingRect - The floating element's `getBoundingClientRect()`.
* @param {{ innerWidth: number, innerHeight: number, scrollX: number, scrollY: number }} options.viewport
* @param {"bottom" | "top" | "left" | "right"} options.direction - Preferred direction.
* @param {"bottom" | "top" | "left" | "right"} [options.lockedDirection] - When set, skip flip detection and place on this side. Used to keep a side stable across content (width/height) changes while the floating element stays open.
* @param {boolean} [options.useFixedPosition=false] - When true, scroll offsets are zeroed (the caller positions with `position: fixed`).
* @param {boolean} [options.intrinsicWidth=false] - Use the floating element's own width instead of matching the anchor.
* @param {"start" | "center" | "end"} [options.intrinsicAlign="center"] - Alignment along the anchor edge when `intrinsicWidth` is true.
* @param {number} [options.gapTop=0]
* @param {number} [options.gapBottom=0]
* @param {number} [options.horizontalGapLeft=0]
* @param {number} [options.horizontalGapRight=0]
* @param {number} [options.verticalAlignOffsetLeft=0]
* @param {number} [options.verticalAlignOffsetRight=0]
* @returns {{ top: number, left: number, width?: number, actualDirection: "bottom" | "top" | "left" | "right", caretNudgePx?: number }}
*/
export function floatingPosition({
anchorRect: rect,
floatingRect,
viewport,
direction,
lockedDirection,
useFixedPosition = false,
intrinsicWidth = false,
intrinsicAlign = "center",
gapTop = 0,
gapBottom = 0,
horizontalGapLeft = 0,
horizontalGapRight = 0,
verticalAlignOffsetLeft = 0,
verticalAlignOffsetRight = 0,
}) {
const scrollYOffset = useFixedPosition ? 0 : viewport.scrollY;
const scrollXOffset = useFixedPosition ? 0 : viewport.scrollX;
const isVertical = direction === "top" || direction === "bottom";
let actualDirection = direction;
if (lockedDirection) {
// Caller is keeping a previously-chosen side stable (e.g. a tooltip whose
// text — and thus width/height — swaps while open). Skip flip detection so
// the floating element does not jump to the opposite side mid-session.
actualDirection = lockedDirection;
} else if (isVertical) {
const spaceBelow = viewport.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (
direction === "bottom" &&
spaceBelow < floatingRect.height &&
spaceAbove > spaceBelow
) {
actualDirection = "top";
} else if (
direction === "top" &&
spaceAbove < floatingRect.height &&
spaceBelow > spaceAbove
) {
actualDirection = "bottom";
}
} else {
const spaceRight = viewport.innerWidth - rect.right;
const spaceLeft = rect.left;
if (
direction === "right" &&
spaceRight < floatingRect.width + horizontalGapRight &&
spaceLeft > spaceRight
) {
actualDirection = "left";
} else if (
direction === "left" &&
spaceLeft < floatingRect.width + horizontalGapLeft &&
spaceRight > spaceLeft
) {
actualDirection = "right";
}
}
/** @type {number} */
let top;
/** @type {number} */
let left;
/** @type {number | undefined} */
let width;
if (actualDirection === "bottom") {
top = rect.bottom + scrollYOffset + gapBottom;
left = rect.left + scrollXOffset;
width = rect.width;
} else if (actualDirection === "top") {
top = rect.top + scrollYOffset - floatingRect.height - gapTop;
left = rect.left + scrollXOffset;
width = rect.width;
} else if (actualDirection === "right") {
if (intrinsicWidth) {
if (intrinsicAlign === "start") {
top = rect.top + scrollYOffset + verticalAlignOffsetRight;
} else if (intrinsicAlign === "end") {
top =
rect.bottom +
scrollYOffset -
floatingRect.height +
verticalAlignOffsetRight;
} else {
top =
rect.top +
scrollYOffset +
rect.height / 2 -
floatingRect.height / 2 +
verticalAlignOffsetRight;
}
} else {
top =
rect.top +
scrollYOffset +
rect.height / 2 -
floatingRect.height / 2 +
verticalAlignOffsetRight;
}
left = rect.right + scrollXOffset + horizontalGapRight;
} else {
// left
if (intrinsicWidth) {
if (intrinsicAlign === "start") {
top = rect.top + scrollYOffset + verticalAlignOffsetLeft;
} else if (intrinsicAlign === "end") {
top =
rect.bottom +
scrollYOffset -
floatingRect.height +
verticalAlignOffsetLeft;
} else {
top =
rect.top +
scrollYOffset +
rect.height / 2 -
floatingRect.height / 2 +
verticalAlignOffsetLeft;
}
} else {
top =
rect.top +
scrollYOffset +
rect.height / 2 -
floatingRect.height / 2 +
verticalAlignOffsetLeft;
}
left = rect.left + scrollXOffset - floatingRect.width - horizontalGapLeft;
}
let posLeft = left;
/** @type {number | undefined} */
let posWidth = width;
if (
intrinsicWidth &&
(actualDirection === "top" || actualDirection === "bottom")
) {
if (intrinsicAlign === "center") {
posLeft = rect.left + scrollXOffset + rect.width / 2;
} else if (intrinsicAlign === "start") {
posLeft = rect.left + scrollXOffset;
} else {
posLeft = rect.right + scrollXOffset;
}
posWidth = undefined;
}
/** @type {number | undefined} */
let caretNudgePx;
if (
intrinsicWidth &&
(actualDirection === "top" || actualDirection === "bottom") &&
(intrinsicAlign === "start" || intrinsicAlign === "end")
) {
// Distance from the box edge that sits on the anchor (left edge for
// `start`, right edge for `end`) to the anchor center. This is half the
// anchor width and does NOT depend on the floating width, so the caret
// stays centered on the trigger when the tooltip text (and thus width)
// changes — no recompute, no bounce. The CSS applies it from the matching
// edge per alignment.
caretNudgePx = rect.width / 2;
}
return {
top,
left: posLeft,
...(posWidth !== undefined && { width: posWidth }),
actualDirection,
caretNudgePx,
};
}