Skip to content

Commit 9c96fff

Browse files
andresdjassoclaude
andcommitted
refactor(emcn): make the floating tooltip the one canonical Tooltip
Replace the Radix-based emcn Tooltip with the cursor-following floating tooltip so every tooltip in the app uses one consistent style. Built on the shared floating-tooltip engine (relocated into emcn), not a parallel implementation. - Move the floating-tooltip engine into emcn/components/tooltip and export it from the barrel; re-point its consumers (FloatingOverflowText, resource-header) - Extend the FloatingTooltip bubble to render arbitrary children (+ role/id for a11y) so it can back general tooltips, not just overflow text - Rebuild emcn Tooltip (Root/Trigger/Content/Provider/Shortcut/Preview) on useFloatingTooltip — compound API preserved, ~350 call sites unchanged, legacy side/align props accepted and ignored (the tooltip follows the cursor). Removes @radix-ui/react-tooltip usage (package kept for a later cleanup; react-slot retained for asChild) Note: general tooltips now show instantly (no hover delay) and follow the cursor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ad6ced8 commit 9c96fff

5 files changed

Lines changed: 172 additions & 51 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import type React from 'react'
44
import { memo } from 'react'
5-
import { cn } from '@/lib/core/utils/cn'
65
import {
76
FloatingTooltip,
87
isTextClipped,
98
useFloatingTooltip,
109
useIsOverflowing,
11-
} from '@/app/workspace/[workspaceId]/components/resource/components/floating-tooltip'
10+
} from '@/components/emcn'
11+
import { cn } from '@/lib/core/utils/cn'
1212

1313
interface FloatingOverflowTextProps {
1414
/** Full text shown in the tooltip and used as the default visible content. */

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,20 @@ import {
99
DropdownMenuContent,
1010
DropdownMenuItem,
1111
DropdownMenuTrigger,
12+
FloatingTooltip,
1213
Plus,
1314
POPOVER_ANIMATION_CLASSES,
1415
Popover,
1516
PopoverAnchor,
1617
PopoverContent,
1718
PopoverItem,
1819
PopoverSection,
20+
useFloatingTooltip,
21+
useIsOverflowing,
1922
} from '@/components/emcn'
2023
import { cn } from '@/lib/core/utils/cn'
2124
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
2225
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
23-
import {
24-
FloatingTooltip,
25-
useFloatingTooltip,
26-
useIsOverflowing,
27-
} from '@/app/workspace/[workspaceId]/components/resource/components/floating-tooltip'
2826

2927
const HEADER_PLUS_ICON = <Plus className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
3028

apps/sim/components/emcn/components/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,5 +183,15 @@ export { Textarea } from './textarea/textarea'
183183
export { TimePicker, timePickerVariants } from './time-picker/time-picker'
184184
export { CountdownRing } from './toast/countdown-ring'
185185
export { ToastProvider, toast, useToast } from './toast/toast'
186+
export {
187+
clamp,
188+
FloatingTooltip,
189+
type FloatingTooltipHandlers,
190+
type FloatingTooltipState,
191+
isFocusVisible,
192+
isTextClipped,
193+
useFloatingTooltip,
194+
useIsOverflowing,
195+
} from './tooltip/floating-tooltip'
186196
export { Tooltip } from './tooltip/tooltip'
187197
export { Wizard } from './wizard/wizard'

apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-tooltip.tsx renamed to apps/sim/components/emcn/components/tooltip/floating-tooltip.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,26 +214,45 @@ export function isFocusVisible(element: Element): boolean {
214214
*/
215215
export const FloatingTooltip = memo(function FloatingTooltip({
216216
label,
217+
children,
217218
state,
219+
className,
220+
role,
221+
id,
218222
}: {
219-
label: string
223+
/** Text shown when no `children` are provided (the overflow-tooltip case). */
224+
label?: string
225+
/** Arbitrary tooltip content; overrides `label` when provided (general tooltips). */
226+
children?: React.ReactNode
220227
state: FloatingTooltipState
228+
className?: string
229+
/** Set to `"tooltip"` for described/general tooltips; omit for decorative overflow tooltips. */
230+
role?: 'tooltip'
231+
/** Element id, used to wire `aria-describedby` on the trigger for general tooltips. */
232+
id?: string
221233
}) {
222234
if (typeof document === 'undefined' || !state.visible) return null
223235

224236
return createPortal(
225237
<div
226-
aria-hidden='true'
238+
id={id}
239+
role={role}
240+
aria-hidden={role ? undefined : 'true'}
227241
className={cn(
228242
'pointer-events-none fixed top-0 left-0 z-[var(--z-tooltip)] w-fit max-w-[min(16rem,calc(100vw-2rem))] rounded-lg border border-[var(--border)] bg-[var(--bg)] px-2 py-1.5 text-[var(--text-body)] text-xs opacity-100 shadow-sm transition-[opacity,filter,transform] duration-150 ease-out',
229-
'motion-reduce:transition-none'
243+
'motion-reduce:transition-none',
244+
className
230245
)}
231246
style={{
232247
transform: `${getTooltipTranslate(state)} skew(${state.skew}deg) scale(${state.scaleX}, ${state.scaleY})`,
233248
transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px',
234249
}}
235250
>
236-
<span className='block whitespace-normal break-words text-left leading-[18px]'>{label}</span>
251+
{children ?? (
252+
<span className='block whitespace-normal break-words text-left leading-[18px]'>
253+
{label}
254+
</span>
255+
)}
237256
</div>,
238257
document.body
239258
)

apps/sim/components/emcn/components/tooltip/tooltip.tsx

Lines changed: 134 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,160 @@
11
'use client'
22

33
import * as React from 'react'
4-
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
4+
import { Slot } from '@radix-ui/react-slot'
5+
import {
6+
FloatingTooltip,
7+
type FloatingTooltipHandlers,
8+
type FloatingTooltipState,
9+
useFloatingTooltip,
10+
} from '@/components/emcn/components/tooltip/floating-tooltip'
511
import { cn } from '@/lib/core/utils/cn'
612

713
/**
8-
* Tooltip provider component that must wrap your app or tooltip usage area.
14+
* Kept for API compatibility with the previous tooltip. The floating tooltip has no shared hover
15+
* delay, so this is a passthrough — props are accepted but unused.
916
*/
1017
const Provider = ({
11-
delayDuration = 400,
12-
...props
13-
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Provider>) => (
14-
<TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />
15-
)
18+
children,
19+
}: {
20+
children: React.ReactNode
21+
delayDuration?: number
22+
skipDelayDuration?: number
23+
disableHoverableContent?: boolean
24+
}) => <>{children}</>
25+
Provider.displayName = 'Tooltip.Provider'
1626

17-
/**
18-
* Root tooltip component that wraps trigger and content.
19-
*/
20-
const Root = TooltipPrimitive.Root
27+
const ALWAYS_SHOW = () => true
2128

22-
/**
23-
* Trigger element that activates the tooltip on hover.
24-
*/
25-
const Trigger = TooltipPrimitive.Trigger
29+
interface TooltipContextValue {
30+
state: FloatingTooltipState
31+
handlers: FloatingTooltipHandlers
32+
contentId: string
33+
}
34+
35+
const TooltipContext = React.createContext<TooltipContextValue | null>(null)
36+
37+
function useTooltipContext(component: string): TooltipContextValue {
38+
const context = React.useContext(TooltipContext)
39+
if (!context) {
40+
throw new Error(`Tooltip.${component} must be rendered within a Tooltip.Root`)
41+
}
42+
return context
43+
}
44+
45+
interface RootProps {
46+
children: React.ReactNode
47+
/** Accepted for API compatibility; the floating tooltip has no hover delay. */
48+
delayDuration?: number
49+
}
2650

2751
/**
28-
* Tooltip content component with consistent styling.
52+
* Root of a single tooltip. Coordinates a cursor-following floating bubble between its `Trigger`
53+
* and `Content`.
2954
*
3055
* @example
3156
* ```tsx
3257
* <Tooltip.Root>
3358
* <Tooltip.Trigger asChild>
3459
* <Button>Hover me</Button>
3560
* </Tooltip.Trigger>
36-
* <Tooltip.Content>
37-
* <p>Tooltip text</p>
38-
* </Tooltip.Content>
61+
* <Tooltip.Content>Tooltip text</Tooltip.Content>
3962
* </Tooltip.Root>
4063
* ```
4164
*/
42-
const Content = React.forwardRef<
43-
React.ElementRef<typeof TooltipPrimitive.Content>,
44-
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
45-
>(({ className, sideOffset = 6, children, ...props }, ref) => (
46-
<TooltipPrimitive.Portal>
47-
<TooltipPrimitive.Content
48-
ref={ref}
49-
sideOffset={sideOffset}
50-
collisionPadding={8}
51-
avoidCollisions
52-
className={cn(
53-
'z-[var(--z-tooltip)] max-w-[260px] rounded-[4px] bg-[var(--tooltip-bg)] px-2 py-[3.5px] text-white text-xs shadow-sm dark:text-black',
54-
className
55-
)}
56-
{...props}
57-
>
65+
function Root({ children }: RootProps) {
66+
const contentId = React.useId()
67+
const { state, handlers } = useFloatingTooltip(ALWAYS_SHOW)
68+
const value = React.useMemo<TooltipContextValue>(
69+
() => ({ state, handlers, contentId }),
70+
[state, handlers, contentId]
71+
)
72+
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>
73+
}
74+
Root.displayName = 'Tooltip.Root'
75+
76+
function composeHandlers<E extends React.SyntheticEvent>(
77+
theirHandler: ((event: E) => void) | undefined,
78+
ourHandler: (event: E) => void
79+
) {
80+
return (event: E) => {
81+
theirHandler?.(event)
82+
if (!event.defaultPrevented) ourHandler(event)
83+
}
84+
}
85+
86+
interface TriggerProps extends React.ComponentPropsWithoutRef<'button'> {
87+
/** Merge tooltip behavior onto the single child element instead of rendering a button. */
88+
asChild?: boolean
89+
}
90+
91+
/**
92+
* Element that activates the tooltip on hover/focus. Use `asChild` to project onto your own element.
93+
*/
94+
const Trigger = React.forwardRef<HTMLButtonElement, TriggerProps>(
95+
({ asChild = false, ...props }, ref) => {
96+
const ctx = useTooltipContext('Trigger')
97+
const Comp = asChild ? Slot : 'button'
98+
99+
return (
100+
<Comp
101+
ref={ref as React.Ref<HTMLButtonElement>}
102+
aria-describedby={ctx.state.visible ? ctx.contentId : undefined}
103+
{...props}
104+
onPointerEnter={composeHandlers(props.onPointerEnter, (event) =>
105+
ctx.handlers.onPointerEnter(event)
106+
)}
107+
onPointerMove={composeHandlers(props.onPointerMove, (event) =>
108+
ctx.handlers.onPointerMove(event)
109+
)}
110+
onPointerLeave={composeHandlers(props.onPointerLeave, () => ctx.handlers.onPointerLeave())}
111+
onPointerDown={composeHandlers(props.onPointerDown, () => ctx.handlers.onPointerDown())}
112+
onFocus={composeHandlers(props.onFocus, (event) => ctx.handlers.onFocus(event))}
113+
onBlur={composeHandlers(props.onBlur, () => ctx.handlers.onBlur())}
114+
/>
115+
)
116+
}
117+
)
118+
Trigger.displayName = 'Tooltip.Trigger'
119+
120+
interface ContentProps extends React.HTMLAttributes<HTMLDivElement> {
121+
/**
122+
* Legacy positioning props from the previous Radix tooltip. Accepted for drop-in compatibility
123+
* but ignored — the tooltip now follows the cursor.
124+
*/
125+
side?: 'top' | 'right' | 'bottom' | 'left'
126+
sideOffset?: number
127+
align?: 'start' | 'center' | 'end'
128+
alignOffset?: number
129+
avoidCollisions?: boolean
130+
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>
131+
collisionBoundary?: unknown
132+
arrowPadding?: number
133+
sticky?: 'partial' | 'always'
134+
hideWhenDetached?: boolean
135+
asChild?: boolean
136+
forceMount?: boolean
137+
}
138+
139+
/**
140+
* Tooltip content, rendered in a cursor-following floating bubble.
141+
*
142+
* @example
143+
* ```tsx
144+
* <Tooltip.Content>
145+
* <p>Tooltip text</p>
146+
* </Tooltip.Content>
147+
* ```
148+
*/
149+
function Content({ className, children }: ContentProps) {
150+
const ctx = useTooltipContext('Content')
151+
return (
152+
<FloatingTooltip state={ctx.state} role='tooltip' id={ctx.contentId} className={className}>
58153
{children}
59-
<TooltipPrimitive.Arrow className='fill-[var(--tooltip-bg)]' />
60-
</TooltipPrimitive.Content>
61-
</TooltipPrimitive.Portal>
62-
))
63-
Content.displayName = TooltipPrimitive.Content.displayName
154+
</FloatingTooltip>
155+
)
156+
}
157+
Content.displayName = 'Tooltip.Content'
64158

65159
interface ShortcutProps {
66160
/** The keyboard shortcut keys to display (e.g., "⌘D", "⌘K") */

0 commit comments

Comments
 (0)