Skip to content

Commit 934f29b

Browse files
Feature/tooltip (#20)
* generated * add basic tooltip * generated * remove import * remove space * Add delay * Patch onHoverIn/onHoverOut * move to root of components * Remove // * lint fix * Remove slashes * Remove slashes * Use stable ref for portal name * Verify that child is an instance of pressable * Use a combined state
1 parent 4efd8e5 commit 934f29b

File tree

7 files changed

+468
-94
lines changed

7 files changed

+468
-94
lines changed

app/app.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Titlebar } from "./components/Titlebar"
1414
import { Sidebar } from "./components/Sidebar/Sidebar"
1515
import { useSidebar } from "./state/useSidebar"
1616
import { AppHeader } from "./components/AppHeader"
17+
import { PortalHost } from "./components/Portal"
1718

1819
if (__DEV__) {
1920
// This is for debugging Reactotron with ... Reactotron!
@@ -24,7 +25,6 @@ if (__DEV__) {
2425
function App(): React.JSX.Element {
2526
const { colors } = useTheme()
2627
const { toggleSidebar } = useSidebar()
27-
2828
const menuConfig = useMemo(
2929
() => ({
3030
remove: ["File", "Edit", "Format"],
@@ -83,6 +83,7 @@ function App(): React.JSX.Element {
8383
<TimelineScreen />
8484
</View>
8585
</View>
86+
<PortalHost />
8687
</View>
8788
)
8889
}

app/components/DetailPanel.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { themed } from "../theme/theme"
1313
import { TimelineItem } from "../types"
1414
import { TreeViewWithProvider } from "./TreeView"
1515
import ActionButton from "./ActionButton"
16+
import { Tooltip } from "./Tooltip"
1617
import IRClipboard from "../native/IRClipboard/NativeIRClipboard"
1718
import { $flex } from "../theme/basics"
1819
import { formatTime } from "../utils/formatTime"
@@ -82,10 +83,14 @@ export function DetailPanel({ selectedItem, onClose }: DetailPanelProps) {
8283
</View>
8384
</View>
8485
<View style={$headerActions()}>
85-
<ActionButton
86-
icon={({ size }) => <Text style={{ fontSize: size }}>📋</Text>}
87-
onClick={() => IRClipboard.setString(JSON.stringify(selectedItem.payload))}
88-
/>
86+
<Tooltip label="Copy payload">
87+
<Pressable
88+
style={$copyButton()}
89+
onPress={() => IRClipboard.setString(JSON.stringify(selectedItem.payload))}
90+
>
91+
<Text style={$copyButtonText()}>📋</Text>
92+
</Pressable>
93+
</Tooltip>
8994
{onClose && (
9095
<ActionButton
9196
icon={({ size }) => <Text style={{ fontSize: size }}></Text>}
@@ -442,3 +447,17 @@ const $image = themed<ImageStyle>(() => ({
442447
height: 200,
443448
resizeMode: "contain",
444449
}))
450+
451+
const $copyButton = themed<ViewStyle>(({ spacing }) => ({
452+
marginHorizontal: spacing.sm,
453+
padding: spacing.sm,
454+
justifyContent: "center",
455+
alignItems: "center",
456+
cursor: "pointer",
457+
borderRadius: spacing.xs,
458+
backgroundColor: "transparent",
459+
}))
460+
461+
const $copyButtonText = themed<TextStyle>(() => ({
462+
fontSize: 20,
463+
}))

app/components/Portal.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { View, type ViewStyle } from "react-native"
2+
import { useState, useEffect, type ReactNode } from "react"
3+
4+
// Global registry for portal content and their listeners
5+
const portals = new Map<string, ReactNode>()
6+
const listeners = new Set<() => void>()
7+
8+
/**
9+
* Notifies all PortalHost components that portal content has changed
10+
*/
11+
function emit() {
12+
listeners.forEach((listener) => listener())
13+
}
14+
15+
/**
16+
* A portal component that renders its children in a different location in the component tree.
17+
* Instead of rendering where it's declared, the content appears in the PortalHost.
18+
* Useful for modals, tooltips, and other overlays that need to appear above other content.
19+
*/
20+
export function Portal({ name, children }: { name: string; children: ReactNode }) {
21+
useEffect(() => {
22+
// Register this portal's content in the global registry
23+
portals.set(name, children)
24+
emit() // Notify PortalHost to re-render with new content
25+
26+
return () => {
27+
// Clean up when portal unmounts
28+
portals.delete(name)
29+
emit() // Notify PortalHost to re-render without this content
30+
}
31+
}, [name, children])
32+
33+
// Portal doesn't render anything in its original location
34+
return null
35+
}
36+
37+
/**
38+
* The host component that renders all portal content.
39+
* Should be placed once in your app tree, typically at the root level.
40+
* All Portal components will render their content here instead of their original location.
41+
*/
42+
export function PortalHost() {
43+
const [, forceUpdate] = useState({})
44+
45+
useEffect(() => {
46+
// Listen for portal changes and force re-render when they occur
47+
const listener = () => forceUpdate({})
48+
listeners.add(listener)
49+
return () => listeners.delete(listener)
50+
}, [])
51+
52+
return (
53+
<View style={$overlay} pointerEvents="box-none">
54+
{Array.from(portals.values())}
55+
</View>
56+
)
57+
}
58+
59+
// Overlay that covers the entire screen for portal content
60+
// Uses high z-index to appear above other content
61+
// pointerEvents="box-none" allows touches to pass through to underlying content
62+
const $overlay: ViewStyle = {
63+
position: "absolute",
64+
top: 0,
65+
left: 0,
66+
right: 0,
67+
bottom: 0,
68+
zIndex: 9999,
69+
pointerEvents: "box-none",
70+
}

app/components/Tooltip.tsx

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import {
2+
useState,
3+
cloneElement,
4+
isValidElement,
5+
useRef,
6+
type ComponentPropsWithRef,
7+
type ReactElement,
8+
} from "react"
9+
import {
10+
View,
11+
Text,
12+
Pressable,
13+
type ViewStyle,
14+
type TextStyle,
15+
type LayoutChangeEvent,
16+
type PressableProps,
17+
} from "react-native"
18+
import { themed } from "../theme/theme"
19+
import { Portal } from "./Portal"
20+
import { getUUID } from "../utils/random/getUUID"
21+
22+
type TooltipState = "hidden" | "showing" | "positioned"
23+
24+
type TooltipProps = {
25+
/**
26+
* The text to display in the tooltip
27+
*/
28+
label: string
29+
/**
30+
* The trigger element that will show the tooltip on hover
31+
*/
32+
children: ReactElement<PressableProps>
33+
/**
34+
* Delay in milliseconds before showing the tooltip
35+
* @default 500
36+
*/
37+
delay?: number
38+
}
39+
40+
/**
41+
* A tooltip component that displays helpful text when the user long-presses a trigger element.
42+
* The tooltip appears positioned below the trigger and is automatically centered.
43+
* Uses the Portal system to render outside the normal component tree for proper layering.
44+
*/
45+
export function Tooltip({ label, children, delay = 500 }: TooltipProps) {
46+
const [tooltipState, setTooltipState] = useState<TooltipState>("hidden")
47+
const [position, setPosition] = useState({ x: 0, y: 0 })
48+
const triggerRef = useRef<View>(null)
49+
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
50+
const portalNameRef = useRef<string>(`tooltip-${getUUID()}`)
51+
52+
if (!isValidElement(children)) {
53+
return <>{children}</>
54+
}
55+
56+
const child = children
57+
58+
if (__DEV__) {
59+
const isPressable = child?.type === Pressable
60+
if (!isPressable) {
61+
console.warn("Tooltip: child should be a Pressable to support onHoverIn/onHoverOut handlers.")
62+
}
63+
}
64+
65+
// Shows the tooltip for measurement after delay
66+
const showTooltip = () => {
67+
if (timeoutRef.current || tooltipState !== "hidden") return
68+
timeoutRef.current = setTimeout(() => {
69+
timeoutRef.current = null
70+
setTooltipState("showing")
71+
}, delay)
72+
}
73+
74+
// Cancels any pending tooltip show
75+
const cancelTooltip = () => {
76+
if (!timeoutRef.current) return
77+
clearTimeout(timeoutRef.current)
78+
timeoutRef.current = null
79+
}
80+
81+
// Hides the tooltip immediately
82+
const hideTooltip = () => {
83+
cancelTooltip()
84+
setTooltipState("hidden")
85+
}
86+
87+
// Updates tooltip position when the tooltip is laid out
88+
const onTooltipLayout = (event: LayoutChangeEvent) => {
89+
if (!triggerRef.current) return
90+
const { width } = event.nativeEvent.layout
91+
// Calculate proper centered position
92+
triggerRef.current.measureInWindow((x, y, triggerWidth, height) => {
93+
setPosition({
94+
x: x + triggerWidth / 2 - width / 2, // Center tooltip under trigger
95+
y: y + height + 2, // Position below with small gap
96+
})
97+
setTooltipState("positioned") // Now show the tooltip
98+
})
99+
}
100+
101+
const enhancedChild = cloneElement<ComponentPropsWithRef<typeof Pressable>>(child, {
102+
...child.props,
103+
ref: triggerRef,
104+
onHoverIn: (e) => {
105+
showTooltip()
106+
child.props?.onHoverIn?.(e)
107+
},
108+
onHoverOut: (e) => {
109+
hideTooltip()
110+
child.props?.onHoverOut?.(e)
111+
},
112+
onPress: (e) => {
113+
child.props?.onPress?.(e)
114+
},
115+
})
116+
117+
return (
118+
<>
119+
{enhancedChild}
120+
{tooltipState !== "hidden" && (
121+
<Portal name={portalNameRef.current}>
122+
<View
123+
onLayout={onTooltipLayout}
124+
style={[$tooltipBubble(), $tooltipPosition(position, tooltipState === "positioned")]}
125+
pointerEvents="none"
126+
>
127+
<Text style={$tooltipText()}>{label}</Text>
128+
</View>
129+
</Portal>
130+
)}
131+
</>
132+
)
133+
}
134+
135+
const $tooltipBubble = themed<ViewStyle>(({ colors, spacing }) => ({
136+
position: "absolute",
137+
backgroundColor: colors.cardBackground,
138+
borderColor: colors.border,
139+
borderWidth: 1,
140+
paddingHorizontal: spacing.xs,
141+
paddingVertical: spacing.xs,
142+
borderRadius: spacing.xs,
143+
alignItems: "center",
144+
justifyContent: "center",
145+
}))
146+
147+
const $tooltipPosition = (position: { x: number; y: number }, positioned: boolean) => ({
148+
left: position.x,
149+
top: position.y,
150+
opacity: positioned ? 1 : 0,
151+
})
152+
153+
const $tooltipText = themed<TextStyle>(({ colors }) => ({
154+
color: colors.mainText,
155+
fontSize: 12,
156+
textAlign: "center",
157+
}))

0 commit comments

Comments
 (0)