Skip to content

Commit 8d0e9dc

Browse files
andresdjassowaleedlatif1
authored andcommitted
improvement(resource): add floating overflow text tooltips
1 parent 621d424 commit 8d0e9dc

12 files changed

Lines changed: 421 additions & 140 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
'use client'
2+
3+
import type React from 'react'
4+
import { memo, useEffect, useRef, useState } from 'react'
5+
import { createPortal } from 'react-dom'
6+
import { cn } from '@/lib/core/utils/cn'
7+
8+
const FLOATING_TOOLTIP_OFFSET = 16
9+
const FLOATING_TOOLTIP_EDGE_GUTTER = 16
10+
const FLOATING_TOOLTIP_EDGE_THRESHOLD = 360
11+
12+
interface FloatingOverflowTextProps {
13+
label: string
14+
children?: React.ReactNode
15+
className?: string
16+
showWhen?: boolean
17+
}
18+
19+
interface FloatingTooltipState {
20+
visible: boolean
21+
x: number
22+
y: number
23+
skew: number
24+
scaleX: number
25+
scaleY: number
26+
alignX: 'left' | 'right'
27+
alignY: 'above' | 'below'
28+
}
29+
30+
interface PointerSnapshot {
31+
x: number
32+
y: number
33+
time: number
34+
}
35+
36+
export const FloatingOverflowText = memo(function FloatingOverflowText({
37+
label,
38+
children,
39+
className,
40+
showWhen,
41+
}: FloatingOverflowTextProps) {
42+
const textRef = useRef<HTMLSpanElement>(null)
43+
const lastPointerRef = useRef<PointerSnapshot | null>(null)
44+
const [isOverflowing, setIsOverflowing] = useState(false)
45+
const [tooltipState, setTooltipState] = useState<FloatingTooltipState>({
46+
visible: false,
47+
x: 0,
48+
y: 0,
49+
skew: 0,
50+
scaleX: 1,
51+
scaleY: 1,
52+
alignX: 'left',
53+
alignY: 'below',
54+
})
55+
56+
useEffect(() => {
57+
const element = textRef.current
58+
if (!element) return
59+
60+
const updateOverflowState = () => {
61+
setIsOverflowing(isTextClipped(element))
62+
}
63+
64+
updateOverflowState()
65+
66+
const resizeObserver = new ResizeObserver(updateOverflowState)
67+
resizeObserver.observe(element)
68+
window.addEventListener('resize', updateOverflowState)
69+
70+
return () => {
71+
resizeObserver.disconnect()
72+
window.removeEventListener('resize', updateOverflowState)
73+
}
74+
}, [])
75+
76+
const canShowTooltip = (element: HTMLSpanElement | null) => {
77+
if (!element || label.length === 0) return false
78+
return Boolean(showWhen) || isTextClipped(element)
79+
}
80+
81+
const handleTooltipMove = (event: React.PointerEvent<HTMLSpanElement>) => {
82+
if (!canShowTooltip(textRef.current)) return
83+
84+
const now = performance.now()
85+
const previous = lastPointerRef.current
86+
const elapsed = previous ? Math.max(now - previous.time, 16) : 16
87+
const velocityX = previous ? ((event.clientX - previous.x) / elapsed) * 16 : 0
88+
const velocityY = previous ? ((event.clientY - previous.y) / elapsed) * 16 : 0
89+
const velocity = Math.hypot(velocityX, velocityY)
90+
const position = getFloatingTooltipPosition(event.clientX, event.clientY)
91+
92+
lastPointerRef.current = { x: event.clientX, y: event.clientY, time: now }
93+
setTooltipState({
94+
visible: true,
95+
...position,
96+
skew: clamp(velocityX * 0.11, -6, 6),
97+
scaleX: 1 + Math.min(0.035, velocity / 1100),
98+
scaleY: 1 - Math.min(0.02, velocity / 1500),
99+
})
100+
}
101+
102+
const showTooltip = (event: React.PointerEvent<HTMLSpanElement>) => {
103+
if (!canShowTooltip(textRef.current)) return
104+
const position = getFloatingTooltipPosition(event.clientX, event.clientY)
105+
lastPointerRef.current = { x: event.clientX, y: event.clientY, time: performance.now() }
106+
setIsOverflowing(true)
107+
setTooltipState({
108+
visible: true,
109+
...position,
110+
skew: 0,
111+
scaleX: 1,
112+
scaleY: 1,
113+
})
114+
}
115+
116+
const showTooltipFromFocus = (event: React.FocusEvent<HTMLSpanElement>) => {
117+
if (!canShowTooltip(textRef.current)) return
118+
const rect = event.currentTarget.getBoundingClientRect()
119+
const position = getFloatingTooltipPosition(rect.left + rect.width / 2, rect.bottom)
120+
lastPointerRef.current = null
121+
setIsOverflowing(true)
122+
setTooltipState({
123+
visible: true,
124+
...position,
125+
skew: 0,
126+
scaleX: 1,
127+
scaleY: 1,
128+
})
129+
}
130+
131+
const hideTooltip = () => {
132+
lastPointerRef.current = null
133+
setTooltipState((current) => ({ ...current, visible: false, skew: 0, scaleX: 1, scaleY: 1 }))
134+
}
135+
136+
return (
137+
<>
138+
<span
139+
ref={textRef}
140+
className={cn(
141+
'min-w-0',
142+
isOverflowing &&
143+
'[mask-image:linear-gradient(to_right,black_calc(100%-18px),transparent)] hover:[mask-image:none] focus-visible:[mask-image:none]',
144+
className
145+
)}
146+
onPointerEnter={showTooltip}
147+
onPointerMove={handleTooltipMove}
148+
onPointerLeave={hideTooltip}
149+
onPointerDown={hideTooltip}
150+
onFocus={showTooltipFromFocus}
151+
onBlur={hideTooltip}
152+
>
153+
{children ?? label}
154+
</span>
155+
<FloatingTooltip label={label} state={tooltipState} />
156+
</>
157+
)
158+
})
159+
160+
function FloatingTooltip({ label, state }: { label: string; state: FloatingTooltipState }) {
161+
if (typeof document === 'undefined' || !state.visible) return null
162+
163+
return createPortal(
164+
<div
165+
aria-hidden='true'
166+
className={cn(
167+
'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',
168+
'motion-reduce:transition-none'
169+
)}
170+
style={{
171+
transform: `${getFloatingTooltipTranslate(state)} skew(${state.skew}deg) scale(${state.scaleX}, ${state.scaleY})`,
172+
transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px',
173+
}}
174+
>
175+
<span className='block whitespace-normal break-words text-left leading-[18px]'>{label}</span>
176+
</div>,
177+
document.body
178+
)
179+
}
180+
181+
function isTextClipped(element: HTMLElement): boolean {
182+
return element.scrollWidth > element.clientWidth + 1
183+
}
184+
185+
function getFloatingTooltipPosition(
186+
clientX: number,
187+
clientY: number
188+
): Pick<FloatingTooltipState, 'x' | 'y' | 'alignX' | 'alignY'> {
189+
if (typeof window === 'undefined') {
190+
return { x: clientX, y: clientY, alignX: 'left', alignY: 'below' }
191+
}
192+
193+
const alignX = window.innerWidth - clientX < FLOATING_TOOLTIP_EDGE_THRESHOLD ? 'right' : 'left'
194+
const alignY =
195+
window.innerHeight - clientY < FLOATING_TOOLTIP_EDGE_THRESHOLD / 2 ? 'above' : 'below'
196+
197+
return {
198+
x: clamp(
199+
clientX,
200+
FLOATING_TOOLTIP_EDGE_GUTTER,
201+
window.innerWidth - FLOATING_TOOLTIP_EDGE_GUTTER
202+
),
203+
y: clamp(
204+
clientY,
205+
FLOATING_TOOLTIP_EDGE_GUTTER,
206+
window.innerHeight - FLOATING_TOOLTIP_EDGE_GUTTER
207+
),
208+
alignX,
209+
alignY,
210+
}
211+
}
212+
213+
function getFloatingTooltipTranslate(state: FloatingTooltipState): string {
214+
const xOffset =
215+
state.alignX === 'left'
216+
? `${FLOATING_TOOLTIP_OFFSET}px`
217+
: `calc(-100% - ${FLOATING_TOOLTIP_OFFSET}px)`
218+
const yOffset =
219+
state.alignY === 'below'
220+
? `${FLOATING_TOOLTIP_OFFSET}px`
221+
: `calc(-100% - ${FLOATING_TOOLTIP_OFFSET}px)`
222+
223+
return `translate3d(${state.x}px, ${state.y}px, 0) translate(${xOffset}, ${yOffset})`
224+
}
225+
226+
function clamp(value: number, min: number, max: number): number {
227+
return Math.max(min, Math.min(max, value))
228+
}

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

Lines changed: 50 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import {
1515
PopoverContent,
1616
PopoverItem,
1717
PopoverSection,
18-
Tooltip,
1918
} from '@/components/emcn'
2019
import { cn } from '@/lib/core/utils/cn'
2120
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
21+
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
2222

2323
const HEADER_PLUS_ICON = <Plus className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
2424
const TERMINAL_BREADCRUMB_LABELS = /^(Chunk #|New Chunk|Loading\.\.\.)/
@@ -113,64 +113,55 @@ export const ResourceHeader = memo(function ResourceHeader({
113113
)}
114114
>
115115
<div className='flex min-w-0 items-center justify-between gap-3'>
116-
<Tooltip.Provider>
117-
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
118-
{hasBreadcrumbs ? (
119-
breadcrumbs.map((crumb, i) => {
120-
const segmentClassName = getBreadcrumbSegmentClassName(
121-
i,
122-
breadcrumbs.length,
123-
currentResourceIndex,
124-
terminalBreadcrumbIndex
125-
)
126-
const LocationIcon = i === 0 ? (crumb.icon ?? Icon) : undefined
127-
128-
return (
129-
<Fragment key={`${crumb.label}-${i}`}>
130-
{i > 0 && (
131-
<span className='mx-0.5 shrink-0 select-none text-[var(--text-icon)] text-sm'>
132-
/
133-
</span>
134-
)}
135-
{LocationIcon ? (
136-
<BreadcrumbLocationPopover
137-
icon={LocationIcon}
138-
breadcrumbs={breadcrumbs}
139-
className={segmentClassName}
140-
veilBoundaryRef={headerRef}
141-
/>
142-
) : (
143-
<BreadcrumbSegment
144-
icon={crumb.icon}
145-
label={crumb.label}
146-
onClick={crumb.onClick}
147-
dropdownItems={crumb.dropdownItems}
148-
editing={crumb.editing}
149-
className={segmentClassName}
150-
/>
151-
)}
152-
</Fragment>
153-
)
154-
})
155-
) : (
156-
<>
157-
{Icon && <Icon className='size-[14px] shrink-0 text-[var(--text-icon)]' />}
158-
{title && (
159-
<Tooltip.Root>
160-
<Tooltip.Trigger asChild>
161-
<h1 className='truncate font-medium text-[var(--text-body)] text-sm'>
162-
{title}
163-
</h1>
164-
</Tooltip.Trigger>
165-
<Tooltip.Content className='max-w-[min(520px,calc(100vw-2rem))] whitespace-normal break-words px-2.5 py-2 text-left leading-5'>
166-
{title}
167-
</Tooltip.Content>
168-
</Tooltip.Root>
169-
)}
170-
</>
171-
)}
172-
</div>
173-
</Tooltip.Provider>
116+
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
117+
{hasBreadcrumbs ? (
118+
breadcrumbs.map((crumb, i) => {
119+
const segmentClassName = getBreadcrumbSegmentClassName(
120+
i,
121+
breadcrumbs.length,
122+
currentResourceIndex,
123+
terminalBreadcrumbIndex
124+
)
125+
const LocationIcon = i === 0 ? (crumb.icon ?? Icon) : undefined
126+
127+
return (
128+
<Fragment key={`${crumb.label}-${i}`}>
129+
{i > 0 && (
130+
<span className='mx-0.5 shrink-0 select-none text-[var(--text-icon)] text-sm'>
131+
/
132+
</span>
133+
)}
134+
{LocationIcon ? (
135+
<BreadcrumbLocationPopover
136+
icon={LocationIcon}
137+
breadcrumbs={breadcrumbs}
138+
className={segmentClassName}
139+
veilBoundaryRef={headerRef}
140+
/>
141+
) : (
142+
<BreadcrumbSegment
143+
icon={crumb.icon}
144+
label={crumb.label}
145+
onClick={crumb.onClick}
146+
dropdownItems={crumb.dropdownItems}
147+
editing={crumb.editing}
148+
className={segmentClassName}
149+
/>
150+
)}
151+
</Fragment>
152+
)
153+
})
154+
) : (
155+
<>
156+
{Icon && <Icon className='size-[14px] shrink-0 text-[var(--text-icon)]' />}
157+
{title && (
158+
<h1 className='min-w-0 flex-1 font-medium text-[var(--text-body)] text-sm'>
159+
<FloatingOverflowText label={title} className='block truncate' />
160+
</h1>
161+
)}
162+
</>
163+
)}
164+
</div>
174165
<div className='flex shrink-0 items-center gap-1.5'>
175166
{leadingActions}
176167
{actions?.map((action) => {

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@/components/emcn'
1717
import { POPOVER_ANIMATION_CLASSES } from '@/components/emcn/components/chip-date-picker/chip-date-picker'
1818
import { cn } from '@/lib/core/utils/cn'
19+
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
1920

2021
const SEARCH_ICON = (
2122
<Search className='pointer-events-none size-[14px] shrink-0 text-[var(--text-icon)]' />
@@ -123,7 +124,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
123124
className='max-w-[280px] px-2 py-1 text-caption'
124125
onClick={tag.onRemove}
125126
>
126-
<span className='truncate'>{tag.label}</span>
127+
<FloatingOverflowText label={tag.label} className='block truncate' />
127128
<span className='ml-1 shrink-0 text-[var(--text-icon)] text-micro'></span>
128129
</Button>
129130
))}
@@ -206,12 +207,14 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
206207
key={`${tag.label}-${tag.value}`}
207208
variant='subtle'
208209
className={cn(
209-
'shrink-0 px-2 py-1 text-caption',
210+
'max-w-[280px] shrink-0 px-2 py-1 text-caption',
210211
search.highlightedTagIndex === i && 'ring-1 ring-[var(--border-focus)] ring-offset-1'
211212
)}
212213
onClick={tag.onRemove}
213214
>
214-
{tag.label}: {tag.value}
215+
<FloatingOverflowText label={`${tag.label}: ${tag.value}`} className='block truncate'>
216+
{tag.label}: {tag.value}
217+
</FloatingOverflowText>
215218
<span className='ml-1 text-[var(--text-icon)] text-micro'></span>
216219
</Button>
217220
))}

0 commit comments

Comments
 (0)