Files
paseo/packages/app/src/components/ui/tooltip.tsx
T
Mohamed Boudra 0f7fa55a0f Centralize platform gating and fix iPad/tablet support
Add `packages/app/src/constants/platform.ts` with canonical gates
(isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc
Platform.OS checks across 69 files to use shared imports.

Fix sidebar hover crash on native (onPointerEnter → onHoverIn),
fix tooltip to use breakpoint instead of platform detection, make
sidebar action buttons always visible on native where hover doesn't
work, and document the platform gating decision matrix in CLAUDE.md.
2026-04-13 13:24:00 +07:00

550 lines
14 KiB
TypeScript

import {
Children,
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
type Ref,
} from "react";
import {
Dimensions,
Platform,
Modal,
Pressable,
StatusBar,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
type TooltipContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
enabled: boolean;
openOnPress: boolean;
delayDuration: number;
};
const TooltipContext = createContext<TooltipContextValue | null>(null);
function useTooltipContext(componentName: string): TooltipContextValue {
const ctx = useContext(TooltipContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <Tooltip />`);
}
return ctx;
}
function composeEventHandlers<E>(
original?: (event: E) => void,
injected?: (event: E) => void,
): (event: E) => void {
return (event: E) => {
original?.(event);
injected?.(event);
};
}
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref && typeof ref === "object") {
(ref as { current: T }).current = value;
}
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? Boolean(open) : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
return [value, setValue];
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computePosition({
triggerRect,
contentSize,
displayArea,
side,
align,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
side: Side;
align: Align;
offset: number;
}): { x: number; y: number; actualSide: Side } {
const { width: contentWidth, height: contentHeight } = contentSize;
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceLeft = triggerRect.x - displayArea.x;
const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
let actualSide = side;
if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
actualSide = "top";
} else if (side === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
actualSide = "bottom";
} else if (side === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) {
actualSide = "right";
} else if (side === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) {
actualSide = "left";
}
let x = 0;
let y = 0;
if (actualSide === "bottom") {
y = triggerRect.y + triggerRect.height + offset;
if (align === "start") {
x = triggerRect.x;
} else if (align === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
} else if (actualSide === "top") {
y = triggerRect.y - contentHeight - offset;
if (align === "start") {
x = triggerRect.x;
} else if (align === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
} else if (actualSide === "left") {
x = triggerRect.x - contentWidth - offset;
if (align === "start") {
y = triggerRect.y;
} else if (align === "end") {
y = triggerRect.y + triggerRect.height - contentHeight;
} else {
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
}
} else {
x = triggerRect.x + triggerRect.width + offset;
if (align === "start") {
y = triggerRect.y;
} else if (align === "end") {
y = triggerRect.y + triggerRect.height - contentHeight;
} else {
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
}
}
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y),
);
return { x, y, actualSide };
}
export function Tooltip({
open,
defaultOpen,
onOpenChange,
delayDuration = 0,
enabledOnDesktop = true,
enabledOnMobile = false,
children,
}: PropsWithChildren<{
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
delayDuration?: number;
enabledOnDesktop?: boolean;
enabledOnMobile?: boolean;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const isCompact = useIsCompactFormFactor();
const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
const value = useMemo<TooltipContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
triggerRef,
enabled,
openOnPress: isCompact,
delayDuration,
}),
[isOpen, setIsOpen, enabled, isCompact, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}
export function TooltipTrigger({
children,
disabled,
onHoverIn,
onHoverOut,
onFocus,
onBlur,
onPress,
asChild = false,
triggerRefProp = "ref",
...props
}: PropsWithChildren<
PressableProps & {
asChild?: boolean;
triggerRefProp?: string;
}
>): ReactElement {
const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const scheduleOpen = useCallback(() => {
if (!ctx.enabled || disabled) return;
clearOpenTimer();
if (ctx.delayDuration <= 0) {
ctx.setOpen(true);
return;
}
openTimerRef.current = setTimeout(() => {
ctx.setOpen(true);
openTimerRef.current = null;
}, ctx.delayDuration);
}, [clearOpenTimer, ctx, disabled]);
const close = useCallback(() => {
clearOpenTimer();
ctx.setOpen(false);
}, [clearOpenTimer, ctx]);
useEffect(() => {
return () => {
clearOpenTimer();
};
}, [clearOpenTimer]);
const handleHoverIn = useCallback(
(e?: any) => {
onHoverIn?.(e);
scheduleOpen();
},
[onHoverIn, scheduleOpen],
);
const handleHoverOut = useCallback(
(e?: any) => {
onHoverOut?.(e);
close();
},
[onHoverOut, close],
);
const handleFocus = useCallback(
(e: any) => {
onFocus?.(e);
if (!ctx.enabled || disabled) return;
clearOpenTimer();
ctx.setOpen(true);
},
[clearOpenTimer, ctx, disabled, onFocus],
);
const handleBlur = useCallback(
(e: any) => {
onBlur?.(e);
close();
},
[close, onBlur],
);
const handlePress = useCallback(
(e: any) => {
onPress?.(e);
if (!ctx.enabled || disabled) {
return;
}
if (ctx.openOnPress) {
clearOpenTimer();
ctx.setOpen(true);
return;
}
close();
},
[clearOpenTimer, close, ctx, disabled, onPress],
);
const triggerProps = {
...props,
disabled,
onHoverIn: handleHoverIn,
onHoverOut: handleHoverOut,
onFocus: handleFocus,
onBlur: handleBlur,
onPress: handlePress,
...(isWeb
? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn,
onPointerLeave: handleHoverOut,
onMouseEnter: handleHoverIn,
onMouseLeave: handleHoverOut,
} as any)
: null),
};
if (asChild) {
const child = Children.only(children);
if (!isValidElement(child)) {
throw new Error("TooltipTrigger with asChild expects a single React element child");
}
const childProps = child.props as Record<string, any>;
const mergedProps = {
...childProps,
...triggerProps,
onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn),
onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut),
onFocus: composeEventHandlers(childProps.onFocus, handleFocus),
onBlur: composeEventHandlers(childProps.onBlur, handleBlur),
onPress: composeEventHandlers(childProps.onPress, handlePress),
onPointerEnter: composeEventHandlers(childProps.onPointerEnter, handleHoverIn),
onPointerLeave: composeEventHandlers(childProps.onPointerLeave, handleHoverOut),
onMouseEnter: composeEventHandlers(childProps.onMouseEnter, handleHoverIn),
onMouseLeave: composeEventHandlers(childProps.onMouseLeave, handleHoverOut),
} as Record<string, any>;
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
mergedProps[triggerRefProp] = (node: View | null) => {
assignRef(existingRefProp, node);
assignRef(ctx.triggerRef, node);
};
return cloneElement(child, mergedProps);
}
return (
<Pressable {...triggerProps} ref={ctx.triggerRef} collapsable={false}>
{children}
</Pressable>
);
}
export function TooltipContent({
children,
side = "top",
align = "center",
offset = 6,
style,
testID,
maxWidth = 280,
}: PropsWithChildren<{
side?: Side;
align?: Align;
offset?: number;
style?: StyleProp<ViewStyle>;
testID?: string;
maxWidth?: number;
}>): ReactElement | null {
const ctx = useTooltipContext("TooltipContent");
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!ctx.open || !ctx.enabled || !ctx.triggerRef.current) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
measureElement(ctx.triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
});
return () => {
cancelled = true;
};
}, [ctx.enabled, ctx.open, ctx.triggerRef]);
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
const result = computePosition({
triggerRect,
contentSize,
displayArea,
side,
align,
offset,
});
setPosition({ x: result.x, y: result.y });
}, [triggerRect, contentSize, side, align, offset]);
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[],
);
if (!ctx.open || !ctx.enabled) return null;
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops.
if (isWeb) {
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
{children}
</Animated.View>
</View>
</Portal>
);
}
return (
<Modal
visible={ctx.open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={() => ctx.setOpen(false)}
>
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
{children}
</Animated.View>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: { flex: 1 },
portalOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
},
content: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
...theme.shadow.md,
zIndex: 1000,
},
}));