Skip to content

Commit 3f25fdf

Browse files
committed
fix(ui): keep overflow tooltips in sync
1 parent 566f778 commit 3f25fdf

4 files changed

Lines changed: 120 additions & 46 deletions

File tree

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ReactNode, useId } from 'react'
1+
import { type ReactNode, useId, useState } from 'react'
22
import { cn, OverflowText } from '@sim/emcn'
33
import { ArrowRight } from '@sim/emcn/icons'
44
import Link from 'next/link'
@@ -135,8 +135,10 @@ export function SettingsResourceRow({
135135
flush = false,
136136
disabled = false,
137137
}: SettingsResourceRowProps) {
138+
const [control, setControl] = useState<HTMLAnchorElement | HTMLButtonElement | null>(null)
138139
const describedById = useId()
139140
const isTile = iconVariant === 'tile'
141+
const isActivatable = !disabled && Boolean(onClick || href)
140142
const cluster = (
141143
<>
142144
{icon == null ? null : iconVariant === 'custom' ? (
@@ -158,7 +160,11 @@ export function SettingsResourceRow({
158160
)}
159161
<div className='relative z-10 flex min-w-0 flex-col justify-center gap-[1px] text-left'>
160162
{typeof title === 'string' ? (
161-
<OverflowText label={title} className='text-[var(--text-body)] text-sm' />
163+
<OverflowText
164+
label={title}
165+
className='text-[var(--text-body)] text-sm'
166+
focusTarget={isActivatable ? control : undefined}
167+
/>
162168
) : (
163169
<span className='truncate text-[var(--text-body)] text-sm'>{title}</span>
164170
)}
@@ -226,6 +232,7 @@ export function SettingsResourceRow({
226232
>
227233
{href ? (
228234
<Link
235+
ref={setControl}
229236
href={href}
230237
aria-label={clickLabel}
231238
aria-describedby={description != null ? describedById : undefined}
@@ -235,6 +242,7 @@ export function SettingsResourceRow({
235242
</Link>
236243
) : (
237244
<button
245+
ref={setControl}
238246
type='button'
239247
onClick={onClick}
240248
aria-label={clickLabel}

packages/emcn/src/components/overflow-text/overflow-text.test.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment jsdom
33
*/
4-
import { act } from 'react'
4+
import { act, useState } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77
import { OverflowText } from './overflow-text'
@@ -100,6 +100,9 @@ describe('OverflowText', () => {
100100
expect(document.querySelector('[data-native-surface-overlay]')?.textContent).toBe(
101101
'A long workflow name'
102102
)
103+
104+
setWidths(label, 200, 180)
105+
expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
103106
})
104107

105108
it('leaves a fitting label unmasked and does not open a tooltip', () => {
@@ -134,6 +137,33 @@ describe('OverflowText', () => {
134137
expect(document.querySelector('[data-native-surface-overlay]')).toBeNull()
135138
})
136139

140+
it('opens from an external composite control keyboard focus', () => {
141+
function CompositeLabel() {
142+
const [focusTarget, setFocusTarget] = useState<HTMLButtonElement | null>(null)
143+
return (
144+
<>
145+
<button ref={setFocusTarget} type='button'>
146+
Open workflow
147+
</button>
148+
<OverflowText label='A long workflow name' focusTarget={focusTarget} />
149+
</>
150+
)
151+
}
152+
153+
act(() => root.render(<CompositeLabel />))
154+
const button = host.querySelector('button')
155+
const label = host.querySelector<HTMLElement>('[data-overflow-text]')
156+
if (!button || !label) throw new Error('Composite label did not render')
157+
158+
setWidths(label, 80, 180)
159+
vi.spyOn(button, 'matches').mockReturnValue(true)
160+
act(() => button.focus())
161+
162+
expect(document.querySelector('[data-native-surface-overlay]')?.textContent).toBe(
163+
'A long workflow name'
164+
)
165+
})
166+
137167
it('keeps decorated visible content out of the plain tooltip label', () => {
138168
act(() =>
139169
root.render(

packages/emcn/src/components/overflow-text/overflow-text.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface OverflowTextProps {
2525
showWhen?: boolean
2626
/** Whether the full-value tooltip may open. Disable for visual mirror layers. */
2727
tooltipEnabled?: boolean
28+
/** External composite control whose keyboard focus should reveal the tooltip. */
29+
focusTarget?: HTMLElement | null
2830
}
2931

3032
/**
@@ -41,13 +43,18 @@ export const OverflowText = memo(function OverflowText({
4143
className,
4244
showWhen,
4345
tooltipEnabled = true,
46+
focusTarget,
4447
}: OverflowTextProps) {
4548
const { ref: textRef, node, isOverflowing } = useIsOverflowing<HTMLSpanElement>(label)
46-
const { state, handlers } = useFloatingTooltip(() => {
47-
const element = node.current
48-
if (!tooltipEnabled || !element || label.length === 0) return false
49-
return Boolean(showWhen) || isTextClipped(element)
50-
})
49+
const tooltipEligible = tooltipEnabled && label.length > 0 && (Boolean(showWhen) || isOverflowing)
50+
const { state, handlers } = useFloatingTooltip(
51+
() => {
52+
const element = node.current
53+
if (!tooltipEnabled || !element || label.length === 0) return false
54+
return Boolean(showWhen) || isTextClipped(element)
55+
},
56+
{ focusTarget, revalidateKey: tooltipEligible }
57+
)
5158

5259
return (
5360
<>

packages/emcn/src/components/tooltip/tooltip.tsx

Lines changed: 67 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -83,18 +83,21 @@ const HIDDEN_STATE: FloatingTooltipState = {
8383
}
8484

8585
/**
86-
* Drives a pointer-reactive floating tooltip. `canShow` is queried on every
87-
* gesture with the event target, letting the caller gate the tooltip on its own
88-
* overflow measurement. Returns the current {@link FloatingTooltipState} to feed
89-
* a {@link FloatingTooltip} and a stable set of {@link FloatingTooltipHandlers}
90-
* to spread onto the trigger element.
86+
* Drives a pointer-reactive floating tooltip. `canShow` is checked on each
87+
* gesture and while visible, allowing callers to dismiss the tooltip when its
88+
* eligibility changes. Returns the current state and stable pointer/focus
89+
* handlers; `focusTarget` may supply a separate keyboard-focus trigger.
9190
*/
9291
export interface UseFloatingTooltipOptions {
9392
/**
9493
* Prefer placing the bubble above the cursor. Still flips below when the
9594
* pointer is too close to the top of the viewport.
9695
*/
9796
preferAbove?: boolean
97+
/** External control whose keyboard focus should reveal this tooltip. */
98+
focusTarget?: HTMLElement | null
99+
/** Semantic value whose changes should revalidate a visible tooltip. */
100+
revalidateKey?: unknown
98101
}
99102

100103
export function useFloatingTooltip(
@@ -126,41 +129,49 @@ export function useFloatingTooltip(
126129
setState((current) => (current.visible ? HIDDEN_STATE : current))
127130
}, [reset])
128131

129-
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
130-
const apply = (clientX: number, clientY: number, motion: TooltipMotion) => {
131-
const next = { ...getTooltipPosition(clientX, clientY, preferAboveRef.current), ...motion }
132-
setState((current) =>
133-
current.visible &&
134-
current.x === next.x &&
135-
current.y === next.y &&
136-
current.alignX === next.alignX &&
137-
current.alignY === next.alignY &&
138-
current.skew === next.skew &&
139-
current.scaleX === next.scaleX &&
140-
current.scaleY === next.scaleY
141-
? current
142-
: { visible: true, ...next }
143-
)
144-
}
132+
const apply = React.useCallback((clientX: number, clientY: number, motion: TooltipMotion) => {
133+
const next = { ...getTooltipPosition(clientX, clientY, preferAboveRef.current), ...motion }
134+
setState((current) =>
135+
current.visible &&
136+
current.x === next.x &&
137+
current.y === next.y &&
138+
current.alignX === next.alignX &&
139+
current.alignY === next.alignY &&
140+
current.skew === next.skew &&
141+
current.scaleX === next.scaleX &&
142+
current.scaleY === next.scaleY
143+
? current
144+
: { visible: true, ...next }
145+
)
146+
}, [])
145147

146-
/** Reveals the tooltip at the pointer, seeding velocity tracking from it. */
147-
const showFromPointer = (clientX: number, clientY: number) => {
148+
const showFromPointer = React.useCallback(
149+
(clientX: number, clientY: number) => {
148150
reset()
149151
lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() }
150152
apply(clientX, clientY, NEUTRAL_MOTION)
151-
}
153+
},
154+
[apply, reset]
155+
)
152156

153-
/**
154-
* Reveals the tooltip anchored to an element's box rather than the pointer.
155-
* Velocity tracking stays cleared: seeding it from the box would make the next
156-
* `pointermove` read the box-to-cursor delta as velocity and spike the flourish
157-
* when the pointer already happens to be over the trigger.
158-
*/
159-
const showFromElement = (clientX: number, clientY: number) => {
157+
/**
158+
* Anchors the tooltip to an element without seeding pointer velocity; using the
159+
* box position would make the next pointer move produce a false motion spike.
160+
*/
161+
const showFromElement = React.useCallback(
162+
(target: HTMLElement) => {
160163
reset()
161-
apply(clientX, clientY, NEUTRAL_MOTION)
162-
}
164+
const rect = target.getBoundingClientRect()
165+
apply(
166+
rect.left + rect.width / 2,
167+
preferAboveRef.current ? rect.top : rect.bottom,
168+
NEUTRAL_MOTION
169+
)
170+
},
171+
[apply, reset]
172+
)
163173

174+
const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
164175
return {
165176
onPointerEnter: (event) => {
166177
if (!canShowRef.current(event.currentTarget)) return
@@ -200,14 +211,32 @@ export function useFloatingTooltip(
200211
if (!canShowRef.current(target)) return
201212
if (!isFocusVisible(target)) return
202213
triggerRef.current = target
203-
const rect = target.getBoundingClientRect()
204-
/* Anchor on the edge the bubble grows away from, so a `preferAbove`
205-
tooltip measures from the trigger's top rather than its bottom. */
206-
showFromElement(rect.left + rect.width / 2, preferAboveRef.current ? rect.top : rect.bottom)
214+
showFromElement(target)
207215
},
208216
onBlur: hide,
209217
}
210-
}, [hide, reset])
218+
}, [apply, hide, showFromElement, showFromPointer])
219+
220+
React.useEffect(() => {
221+
const target = options.focusTarget
222+
if (!target) return undefined
223+
const show = () => {
224+
if (!canShowRef.current(target) || !isFocusVisible(target)) return
225+
triggerRef.current = target
226+
showFromElement(target)
227+
}
228+
target.addEventListener('focus', show)
229+
target.addEventListener('blur', hide)
230+
return () => {
231+
target.removeEventListener('focus', show)
232+
target.removeEventListener('blur', hide)
233+
}
234+
}, [hide, options.focusTarget, showFromElement])
235+
236+
React.useEffect(() => {
237+
const trigger = triggerRef.current
238+
if (state.visible && (!trigger || !canShowRef.current(trigger))) hide()
239+
}, [hide, options.revalidateKey, state.visible])
211240

212241
/**
213242
* A keyboard- or script-driven UI change can hide the trigger with no pointer or focus event —

0 commit comments

Comments
 (0)