Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/reshaped/src/components/Flyout/Flyout.constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const mouseEnter = 600;
export const mouseLeave = 150;
export const mouseEnter = 0;
export const mouseLeave = 200;

export const defaultStyles: React.CSSProperties = {
left: 0,
Expand Down
40 changes: 18 additions & 22 deletions packages/reshaped/src/components/Flyout/FlyoutControlled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ const FlyoutControlled: React.FC<T.ControlledProps & T.DefaultProps> = (props) =
const hoverTriggeredWithTouchEventRef = React.useRef(false);
// Cleanup function for safe area tracking
const safeAreaRef = React.useRef<{ origin: Coordinates; cleanup: () => void } | null>(null);
// Track open intent to handle async render race condition
const openIntentRef = React.useRef(false);

const originCoordinatesRef = React.useRef<Coordinates | null>(originCoordinates ?? null);
originCoordinatesRef.current = originCoordinates ?? null;
Expand Down Expand Up @@ -140,25 +142,8 @@ const FlyoutControlled: React.FC<T.ControlledProps & T.DefaultProps> = (props) =
if (timerRef.current) clearTimeout(timerRef.current);
}, []);

/**
* Disable all triggers while mouse is moving over the safe area
*/
const disableTriggers = React.useCallback(() => {
if (triggerType !== "hover") return;

document.querySelectorAll("[data-rs-flyout-active]").forEach((el) => {
if (el === triggerElRef.current) return;
(el as HTMLElement).style.pointerEvents = "none";
});
}, [triggerElRef, triggerType]);

const enableTriggers = React.useCallback(() => {
if (triggerType !== "hover") return;

document.querySelectorAll("[data-rs-flyout-active]").forEach((el) => {
(el as HTMLElement).style.removeProperty("pointer-events");
});
}, [triggerType]);
const disableTriggers = React.useCallback(() => {}, []);
const enableTriggers = React.useCallback(() => {}, []);

/**
* Component open/close handlers
Expand All @@ -168,17 +153,19 @@ const FlyoutControlled: React.FC<T.ControlledProps & T.DefaultProps> = (props) =
if (lockedRef.current) return;
if (isRendered && triggerType !== "hover") return;

openIntentRef.current = true;
onOpenRef.current?.();
disableTriggers();
}, [onOpenRef, isRendered, triggerType, disableTriggers]);

const handleClose = React.useCallback<T.ContextProps["handleClose"]>(
(options) => {
const isLocked = triggerType === "click" && !isDismissible();
const canClose = !isLocked && (isRendered || disabled);
const canClose = !isLocked && (isRendered || openIntentRef.current || disabled);

if (!canClose) return;

openIntentRef.current = false;
onCloseRef.current?.({ reason: options.reason });
enableTriggers();

Expand Down Expand Up @@ -231,6 +218,8 @@ const FlyoutControlled: React.FC<T.ControlledProps & T.DefaultProps> = (props) =

const handleContentMouseEnter = React.useCallback(() => {
clearTimer();
safeAreaRef.current?.cleanup();
safeAreaRef.current = null;

if (hoverTriggeredWithTouchEventRef.current) {
handleOpen();
Expand Down Expand Up @@ -274,13 +263,15 @@ const FlyoutControlled: React.FC<T.ControlledProps & T.DefaultProps> = (props) =
cooldown.cool();
clearTimer();

// Read origin before cleanup in case cleanup nulls the ref
const prevOrigin = safeAreaRef.current?.origin;
safeAreaRef.current?.cleanup();

if (triggerType === "hover" && isRendered) {
// Safe area coordinates are defined based on the trigger mouse out, even when returning mouse from content to trigger
const origin =
e.currentTarget === flyoutElRef.current && safeAreaRef.current?.origin
? safeAreaRef.current.origin
e.currentTarget === flyoutElRef.current && prevOrigin
? prevOrigin
: { x: e.clientX, y: e.clientY };
const cleanup = createSafeArea({
contentRef: flyoutElRef,
Expand All @@ -291,6 +282,11 @@ const FlyoutControlled: React.FC<T.ControlledProps & T.DefaultProps> = (props) =
});

safeAreaRef.current = { origin, cleanup };
} else if (triggerType === "hover" && openIntentRef.current) {
// Content hasn't rendered yet but we intend to open — give it time
timerRef.current = setTimeout(() => {
handleClose({});
}, timeouts.mouseLeave);
} else {
handleClose({});
}
Expand Down
82 changes: 79 additions & 3 deletions packages/reshaped/src/components/Flyout/utilities/safeArea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ type SafePolygonOptions = {
origin: Coordinates;
};

const BUFFER = 4;
const GRACE_TIMEOUT = 50;

/**
* Checks if a point is inside a triangle using barycentric coordinates
*/
Expand All @@ -26,6 +29,60 @@ function isPointInTriangle(
return a >= 0 && a <= 1 && b >= 0 && b <= 1 && c >= 0 && c <= 1;
}

/**
* Checks if a point is within the rectangular trough between the trigger and content
*/
function isPointInTrough(
point: Coordinates,
triggerRect: DOMRect,
contentRect: DOMRect,
position: string | null | undefined
): boolean {
if (position?.startsWith("end") || position?.startsWith("right")) {
// Horizontal trough: trigger's right edge to content's left edge
const left = triggerRect.right - BUFFER;
const right = contentRect.left + BUFFER;
const top = Math.max(triggerRect.top, contentRect.top) - BUFFER;
const bottom = Math.min(triggerRect.bottom, contentRect.bottom) + BUFFER;
return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
} else if (position?.startsWith("start") || position?.startsWith("left")) {
// Horizontal trough: content's right edge to trigger's left edge
const left = contentRect.right - BUFFER;
const right = triggerRect.left + BUFFER;
const top = Math.max(triggerRect.top, contentRect.top) - BUFFER;
const bottom = Math.min(triggerRect.bottom, contentRect.bottom) + BUFFER;
return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
} else if (position?.startsWith("bottom")) {
// Vertical trough: trigger's bottom edge to content's top edge
const top = triggerRect.bottom - BUFFER;
const bottom = contentRect.top + BUFFER;
const left = Math.max(triggerRect.left, contentRect.left) - BUFFER;
const right = Math.min(triggerRect.right, contentRect.right) + BUFFER;
return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
} else if (position?.startsWith("top")) {
// Vertical trough: content's bottom edge to trigger's top edge
const top = contentRect.bottom - BUFFER;
const bottom = triggerRect.top + BUFFER;
const left = Math.max(triggerRect.left, contentRect.left) - BUFFER;
const right = Math.min(triggerRect.right, contentRect.right) + BUFFER;
return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
}

return false;
}

/**
* Checks if a point is within the content element's bounding rect
*/
function isPointInContent(point: Coordinates, contentRect: DOMRect): boolean {
return (
point.x >= contentRect.left &&
point.x <= contentRect.right &&
point.y >= contentRect.top &&
point.y <= contentRect.bottom
);
}

/**
* Gets the two closest corners of the content element based on the flyout position
*/
Expand Down Expand Up @@ -61,17 +118,20 @@ export function createSafeArea(options: SafePolygonOptions): () => void {
}

const contentRect = contentRef.current.getBoundingClientRect();
const triggerRect = triggerRef.current?.getBoundingClientRect();
const [corner1, corner2] = getContentCorners(contentRect, position);
const origin = { x: passedOrigin.x, y: passedOrigin.y };

const triangle: [Coordinates, Coordinates, Coordinates] = [origin, corner1, corner2];

let timeoutId: ReturnType<typeof setTimeout> | null = null;
let graceTimeoutId: ReturnType<typeof setTimeout> | null = null;

const cleanup = () => {
document.removeEventListener("mousemove", handleMouseMove);

if (timeoutId) clearTimeout(timeoutId);
if (graceTimeoutId) clearTimeout(graceTimeoutId);
};

// Start timeout for 1 second
Expand All @@ -87,11 +147,27 @@ export function createSafeArea(options: SafePolygonOptions): () => void {
const handleMouseMove = (e: MouseEvent) => {
const currentPoint: Coordinates = { x: e.clientX, y: e.clientY };

if (isPointInTriangle(currentPoint, triangle) && contentRef.current && triggerRef.current) {
const inTriangle = isPointInTriangle(currentPoint, triangle);
const inTrough = triggerRect
? isPointInTrough(currentPoint, triggerRect, contentRect, position)
: false;
const inContent = isPointInContent(currentPoint, contentRect);

if ((inTriangle || inTrough || inContent) && contentRef.current && triggerRef.current) {
// Cursor is in the safe zone — cancel any grace period and restart the idle timeout
if (graceTimeoutId) {
clearTimeout(graceTimeoutId);
graceTimeoutId = null;
}
startTimeout();
} else {
onClose();
cleanup();
// Cursor left the safe zone — start a grace period instead of closing immediately
if (!graceTimeoutId) {
graceTimeoutId = setTimeout(() => {
onClose();
cleanup();
}, GRACE_TIMEOUT);
}
}
};

Expand Down