Skip to content
Merged
72 changes: 72 additions & 0 deletions components/ExplorerTooltip/DesktopTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Box, styled, Text } from "@livepeer/design-system";
import { Tooltip } from "radix-ui";
import React from "react";

import { BaseTooltipProps } from "./types";

type DesktopTooltipProps = BaseTooltipProps &
Omit<React.ComponentProps<typeof Tooltip.Root>, keyof BaseTooltipProps>;

const Content = styled(Tooltip.Content, {
length: {},
backgroundColor: "$neutral4",
borderRadius: "$1",
padding: "$1 $2",
zIndex: "4",

variants: {
multiline: {
true: {
maxWidth: 250,
pb: 7,
},
},
},
});

export function DesktopTooltip({
children,
content,
open,
defaultOpen,
onOpenChange,
multiline,
...props
}: DesktopTooltipProps) {
return (
<Tooltip.Root
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>

<Content side="top" align="center" sideOffset={5} multiline {...props}>
<Text
size="1"
as="div"
css={{
fontSize: "$2",
textTransform: "none",
fontWeight: 600,
color: "white",
zIndex: "$4",
lineHeight: multiline ? "20px" : undefined,
}}
>
{content}
</Text>
<Box css={{ color: "$neutral4" }}>
<Tooltip.Arrow
offset={5}
width={11}
height={5}
style={{
fill: "currentColor",
}}
/>
</Box>
</Content>
</Tooltip.Root>
);
}
145 changes: 145 additions & 0 deletions components/ExplorerTooltip/MobileTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { Box, styled, Text } from "@livepeer/design-system";
import { Popover as RadixPopover } from "radix-ui";
import React, { useEffect, useRef } from "react";

import { BaseTooltipProps } from "./types";

type MobileTooltipProps = BaseTooltipProps &
Omit<React.ComponentProps<typeof RadixPopover.Root>, keyof BaseTooltipProps>;

const RadixPopoverContentStyled = styled(RadixPopover.Content, {
length: {},
backgroundColor: "$neutral4 !important",
borderRadius: "$1",
padding: "$1 $2",
zIndex: "4",
border: "none",
outline: "none",
marginLeft: "$2",
marginRight: "$2",
boxShadow: "none",
variants: {
multiline: {
true: {
maxWidth: 250,
pb: 7,
},
},
},
});

export function MobileTooltip({
children,
content,
open,
defaultOpen,
onOpenChange,
multiline,
...props
}: MobileTooltipProps) {
const [isOpen, setIsOpen] = React.useState(defaultOpen ?? false);
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
// Ref to preserve onOpenChange reference value and protect against unstable references
const onOpenChangeRef = useRef(onOpenChange);

// Keep ref updated with latest callback without causing re-renders
useEffect(() => {
onOpenChangeRef.current = onOpenChange;
}, [onOpenChange]);

// Sync internal state with controlled prop
useEffect(() => {
if (open !== undefined) {
setIsOpen(open);
}
}, [open]);

// Touch handlers to close tooltip on finger movement
useEffect(() => {
if (!isOpen) return;

const handleTouchStart = (e: TouchEvent) => {
touchStartRef.current = {
x: e.touches[0].clientX,
y: e.touches[0].clientY,
};
};

const handleTouchMove = (e: TouchEvent) => {
if (!touchStartRef.current) return;

const deltaX = Math.abs(e.touches[0].clientX - touchStartRef.current.x);
const deltaY = Math.abs(e.touches[0].clientY - touchStartRef.current.y);
const threshold = 5; // Minimum movement to trigger close

// If user moved their finger significantly, close the tooltip
if (deltaX > threshold || deltaY > threshold) {
setIsOpen(false);
onOpenChangeRef.current?.(false);
touchStartRef.current = null;
}
};

const handleTouchEnd = () => {
touchStartRef.current = null;
};

// Add touch event listeners to document
document.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
document.addEventListener("touchmove", handleTouchMove, { passive: true });
document.addEventListener("touchend", handleTouchEnd, { passive: true });

return () => {
document.removeEventListener("touchstart", handleTouchStart);
document.removeEventListener("touchmove", handleTouchMove);
document.removeEventListener("touchend", handleTouchEnd);
};
}, [isOpen]);

const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen);
onOpenChangeRef.current?.(newOpen);
};

return (
<RadixPopover.Root open={isOpen} onOpenChange={handleOpenChange}>
<RadixPopover.Trigger asChild>{children}</RadixPopover.Trigger>
<RadixPopover.Portal>
<RadixPopoverContentStyled
side="top"
align="center"
sideOffset={5}
multiline
{...props}
>
<Text
size="1"
as="div"
css={{
fontSize: "$2",
textTransform: "none",
fontWeight: 400,
color: "white",
zIndex: "$4",
lineHeight: multiline ? "20px" : undefined,
}}
>
{content}
</Text>
<Box css={{ color: "$neutral4" }}>
<RadixPopover.Arrow
offset={5}
width={11}
height={5}
style={{
fill: "currentColor",
}}
/>
</Box>
</RadixPopoverContentStyled>
</RadixPopover.Portal>
</RadixPopover.Root>
);
}
80 changes: 10 additions & 70 deletions components/ExplorerTooltip/index.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,14 @@
import { Box, styled, Text } from "@livepeer/design-system";
import { Tooltip } from "radix-ui";
import React from "react";
import { isMobile } from "react-device-detect";

type TooltipProps = React.ComponentProps<typeof Tooltip.Root> &
Omit<React.ComponentProps<typeof Tooltip.Content>, "content"> & {
children: React.ReactElement;
content: React.ReactNode;
multiline?: boolean;
};
import { DesktopTooltip } from "./DesktopTooltip";
import { MobileTooltip } from "./MobileTooltip";
import { TooltipProps } from "./types";

const Content = styled(Tooltip.Content, {
length: {},
backgroundColor: "$neutral4",
borderRadius: "$1",
padding: "$1 $2",
zIndex: "4",
export type { TooltipProps };

variants: {
multiline: {
true: {
maxWidth: 250,
pb: 7,
},
},
},
});

export function ExplorerTooltip({
children,
content,
open,
defaultOpen,
onOpenChange,
multiline,
...props
}: TooltipProps) {
return (
<Tooltip.Root
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>

<Content side="top" align="center" sideOffset={5} multiline {...props}>
<Text
size="1"
as="p"
css={{
fontSize: "$2",
textTransform: "none",
fontWeight: 600,
color: "white",
zIndex: "$4",
lineHeight: multiline ? "20px" : undefined,
}}
>
{content}
</Text>
<Box css={{ color: "$neutral4" }}>
<Tooltip.Arrow
offset={5}
width={11}
height={5}
style={{
fill: "currentColor",
}}
/>
</Box>
</Content>
</Tooltip.Root>
);
export function ExplorerTooltip(props: TooltipProps) {
if (isMobile) {
return <MobileTooltip {...props} />;
}
return <DesktopTooltip {...props} />;
}
27 changes: 27 additions & 0 deletions components/ExplorerTooltip/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Tooltip } from "radix-ui";
import React from "react";

// Base props that both tooltips share
export interface BaseTooltipProps {
children: React.ReactElement;
content: React.ReactNode;
multiline?: boolean;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
sideOffset?: number;
open?: boolean;
defaultOpen?: boolean;
/**
* Callback when tooltip open state changes.
* For optimal performance, memoize this function with useCallback.
*/
onOpenChange?: (open: boolean) => void;
}

// Public API type (what consumers use)
export type TooltipProps = BaseTooltipProps &
Omit<React.ComponentProps<typeof Tooltip.Root>, keyof BaseTooltipProps> &
Omit<
React.ComponentProps<typeof Tooltip.Content>,
"content" | keyof BaseTooltipProps
>;