Skip to content

Commit 894a8dc

Browse files
committed
fix(ui): preserve overflow server boundary
1 parent 3f25fdf commit 894a8dc

4 files changed

Lines changed: 29 additions & 27 deletions

File tree

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ReactNode, useId, useState } from 'react'
1+
import { type ReactNode, useId } from 'react'
22
import { cn, OverflowText } from '@sim/emcn'
33
import { ArrowRight } from '@sim/emcn/icons'
44
import Link from 'next/link'
@@ -135,7 +135,6 @@ export function SettingsResourceRow({
135135
flush = false,
136136
disabled = false,
137137
}: SettingsResourceRowProps) {
138-
const [control, setControl] = useState<HTMLAnchorElement | HTMLButtonElement | null>(null)
139138
const describedById = useId()
140139
const isTile = iconVariant === 'tile'
141140
const isActivatable = !disabled && Boolean(onClick || href)
@@ -163,7 +162,7 @@ export function SettingsResourceRow({
163162
<OverflowText
164163
label={title}
165164
className='text-[var(--text-body)] text-sm'
166-
focusTarget={isActivatable ? control : undefined}
165+
focusTarget={isActivatable ? 'nearest-interactive' : undefined}
167166
/>
168167
) : (
169168
<span className='truncate text-[var(--text-body)] text-sm'>{title}</span>
@@ -232,7 +231,6 @@ export function SettingsResourceRow({
232231
>
233232
{href ? (
234233
<Link
235-
ref={setControl}
236234
href={href}
237235
aria-label={clickLabel}
238236
aria-describedby={description != null ? describedById : undefined}
@@ -242,7 +240,6 @@ export function SettingsResourceRow({
242240
</Link>
243241
) : (
244242
<button
245-
ref={setControl}
246243
type='button'
247244
onClick={onClick}
248245
aria-label={clickLabel}

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

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment jsdom
33
*/
4-
import { act, useState } from 'react'
4+
import { act } 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'
@@ -138,19 +138,13 @@ describe('OverflowText', () => {
138138
})
139139

140140
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-
</>
141+
act(() =>
142+
root.render(
143+
<button type='button'>
144+
<OverflowText label='A long workflow name' focusTarget='nearest-interactive' />
145+
</button>
150146
)
151-
}
152-
153-
act(() => root.render(<CompositeLabel />))
147+
)
154148
const button = host.querySelector('button')
155149
const label = host.querySelector<HTMLElement>('[data-overflow-text]')
156150
if (!button || !label) throw new Error('Composite label did not render')

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import type { ReactNode } from 'react'
4-
import { memo } from 'react'
4+
import { memo, useCallback } from 'react'
55
import { cn } from '../../lib/cn'
66
import {
77
FloatingTooltip,
@@ -25,8 +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
28+
/** Lets the nearest interactive ancestor own keyboard focus for this label. */
29+
focusTarget?: 'nearest-interactive'
3030
}
3131

3232
/**
@@ -47,13 +47,24 @@ export const OverflowText = memo(function OverflowText({
4747
}: OverflowTextProps) {
4848
const { ref: textRef, node, isOverflowing } = useIsOverflowing<HTMLSpanElement>(label)
4949
const tooltipEligible = tooltipEnabled && label.length > 0 && (Boolean(showWhen) || isOverflowing)
50+
const getFocusTarget = useCallback(() => {
51+
if (focusTarget !== 'nearest-interactive') return null
52+
return (
53+
node.current?.closest<HTMLElement>(
54+
'a[href], button, [role="button"], [tabindex]:not([tabindex="-1"])'
55+
) ?? null
56+
)
57+
}, [focusTarget, node])
5058
const { state, handlers } = useFloatingTooltip(
5159
() => {
5260
const element = node.current
5361
if (!tooltipEnabled || !element || label.length === 0) return false
5462
return Boolean(showWhen) || isTextClipped(element)
5563
},
56-
{ focusTarget, revalidateKey: tooltipEligible }
64+
{
65+
getFocusTarget: focusTarget === 'nearest-interactive' ? getFocusTarget : undefined,
66+
revalidateKey: tooltipEligible,
67+
}
5768
)
5869

5970
return (

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,16 @@ const HIDDEN_STATE: FloatingTooltipState = {
8686
* Drives a pointer-reactive floating tooltip. `canShow` is checked on each
8787
* gesture and while visible, allowing callers to dismiss the tooltip when its
8888
* eligibility changes. Returns the current state and stable pointer/focus
89-
* handlers; `focusTarget` may supply a separate keyboard-focus trigger.
89+
* handlers; `getFocusTarget` may supply a separate keyboard-focus trigger.
9090
*/
9191
export interface UseFloatingTooltipOptions {
9292
/**
9393
* Prefer placing the bubble above the cursor. Still flips below when the
9494
* pointer is too close to the top of the viewport.
9595
*/
9696
preferAbove?: boolean
97-
/** External control whose keyboard focus should reveal this tooltip. */
98-
focusTarget?: HTMLElement | null
97+
/** Resolves an external control whose keyboard focus should reveal this tooltip. */
98+
getFocusTarget?: () => HTMLElement | null
9999
/** Semantic value whose changes should revalidate a visible tooltip. */
100100
revalidateKey?: unknown
101101
}
@@ -218,7 +218,7 @@ export function useFloatingTooltip(
218218
}, [apply, hide, showFromElement, showFromPointer])
219219

220220
React.useEffect(() => {
221-
const target = options.focusTarget
221+
const target = options.getFocusTarget?.()
222222
if (!target) return undefined
223223
const show = () => {
224224
if (!canShowRef.current(target) || !isFocusVisible(target)) return
@@ -231,7 +231,7 @@ export function useFloatingTooltip(
231231
target.removeEventListener('focus', show)
232232
target.removeEventListener('blur', hide)
233233
}
234-
}, [hide, options.focusTarget, showFromElement])
234+
}, [hide, options.getFocusTarget, showFromElement])
235235

236236
React.useEffect(() => {
237237
const trigger = triggerRef.current

0 commit comments

Comments
 (0)