fix(client): yield the enclosing tooltip to a nested one
The collapsed rail's update badge renders inside the expand button, so hovering it showed both the version notice and "Open sidebar". Tooltip now publishes a suppression setter that a nested tooltip claims while its own bubble is visible; the enclosing tooltip keeps its hover state and measured position, so its bubble returns as soon as the nested one is gone.
This commit is contained in:
1 parent
60c39996b9
commit
daf185fd24
6 files changed
+188
-7
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md
|
||||
2026-09-16-nested-tooltip-suppression.md: 1b06a3a1a4f754aa78e821ee8a1479dab19873ff
|
||||
2026-09-16-nested-tooltip-suppression.zh.md: 96063f78b281deb3561045bb73fcffa64a2c1ef8
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Suppress an enclosing tooltip while a nested one is shown
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-nested-tooltip-suppression.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The collapsed sidebar renders its update badge through the `sidebar.toggle.badge` slot inside the expand button, and both the button and the badge attach a `Tooltip`. Hovering the badge showed two bubbles at once — the version notice and "Open sidebar" — because the badge is a DOM descendant of the button: the button's hover state stays true for as long as the pointer rests on the badge, and each tooltip correctly follows its own anchor.
|
||||
|
||||
## Decision
|
||||
|
||||
[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) provides a suppression setter through an internal `TooltipSuppression` context wrapped around its cloned anchor and its bubble. A nested tooltip consumes the nearest setter and announces whenever its own bubble becomes visible. An enclosing tooltip that receives a suppression claim keeps its hover/focus triggers and its measured position but does not render its bubble, so the enclosing bubble returns in the same commit that the nested one disappears.
|
||||
|
||||
The nested tooltip announces synchronously inside `show()` and through a `visible` effect. The synchronous call keeps a nested pair that becomes visible in one React commit from painting both bubbles for a frame; the effect releases the claim on every other path that hides a bubble, including unmount and a `disabled` flip.
|
||||
|
||||
No component in `ui-sidebar` or `ui-settings-general` changed: the badge already renders inside the toggle's anchor, so the shared primitive resolves the overlap for any future nested pair as well.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Disable the toggle tooltip whenever an update exists.** The simplest guard, and wrong: it removes "Open sidebar" from ordinary rail hovers for the whole time an update is available, including hovers that never touch the badge.
|
||||
|
||||
**Disable the toggle tooltip while the pointer is over the badge.** Hover-scoped, but `Tooltip` clears its hover and focus triggers when it is disabled, and no new `mouseenter` fires on the button while the pointer moves from the badge onto the button's own area. The enclosing bubble would stay gone until the pointer left and re-entered the button.
|
||||
|
||||
**Stop the badge's `mouseenter` from reaching the button.** It only helps a pointer that enters the badge directly; a pointer that enters the button first and then moves onto the badge already has the enclosing bubble visible, because the enclosing hover never ended.
|
||||
|
||||
**Render the badge outside the toggle's anchor.** The slot is declared inside the toggle button, and the badge must sit on the button's corner; moving the anchor out of the button would either detach the bubble from the control or change the slot's declared placement.
|
||||
|
||||
## Consequences
|
||||
|
||||
The rule is a property of tooltip nesting, not of the update badge: any tooltip whose anchor contains another tooltip now yields the bubble to the innermost visible one. A tooltip with no nested tooltip is unaffected — it consumes a null context and never announces.
|
||||
|
||||
The enclosing tooltip's bubble is withdrawn only while a descendant is visible; its trigger state survives, so no re-entry is needed to restore it. Suppression does not change what the anchors do: the button still toggles the sidebar and the badge remains a non-interactive marker.
|
||||
|
||||
[Tooltip tests](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx) cover withdrawal, restoration when the nested anchor is left for the enclosing one with a real `relatedTarget`, and release when a shown nested tooltip unmounts. The [sidebar shell test](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx) covers the product wiring: the rail badge replaces the toggle bubble after the toggle's own hover delay has already elapsed.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 嵌套提示显示时抑制外层提示
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-nested-tooltip-suppression.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
侧边栏收起时,更新蓝点通过 `sidebar.toggle.badge` 插槽渲染在展开按钮内部,而按钮和蓝点各自挂了一个 `Tooltip`。hover 蓝点会同时出现两个气泡——版本提示和“打开侧边栏”——因为蓝点是按钮的 DOM 后代:只要指针停在蓝点上,按钮的 hover 状态就保持为真,而两个 tooltip 都在正确地跟随各自的 anchor。
|
||||
|
||||
## 决策
|
||||
|
||||
[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx)通过内部 `TooltipSuppression` context 提供一个抑制 setter,并把该 context 包在克隆后的 anchor 与气泡外层。嵌套的 tooltip 消费最近的 setter,并在自己的气泡可见时上报。收到抑制声明后,外层 tooltip 保留 hover/focus 触发状态与已测量的位置,但不渲染自己的气泡,因此嵌套气泡消失的同一提交里外层气泡就会回来。
|
||||
|
||||
嵌套 tooltip 既在 `show()` 中同步上报,也通过 `visible` effect 上报。同步调用避免在同一 React 提交中同时可见的一对工具提示多绘制一帧的两个气泡;effect 则在其他所有隐藏气泡的路径上释放抑制,包括卸载和 `disabled` 翻转。
|
||||
|
||||
`ui-sidebar` 与 `ui-settings-general` 中的组件无需改动:蓝点本就渲染在 toggle 的 anchor 内,因此共享 primitive 也顺带解决了未来任何嵌套组合。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只要存在更新就禁用 toggle 的 tooltip。** 这是最简单的防护,但也是错的:它会在更新可用的整个期间移除普通 rail hover 的“打开侧边栏”,包括根本不会碰到蓝点的 hover。
|
||||
|
||||
**指针位于蓝点上时禁用 toggle 的 tooltip。** 作用域正确,但 `Tooltip` 被禁用时会清空自己的 hover 与 focus 触发状态,而指针从蓝点移到按钮自身区域时按钮不会再触发新的 `mouseenter`。外层气泡会一直消失,直到指针离开按钮并重新进入。
|
||||
|
||||
**阻止蓝点的 `mouseenter` 传播到按钮。** 这只能帮助直接从蓝点进入的指针;先进入按钮再移到蓝点上的指针,其外层气泡早已可见,因为外层的 hover 从未结束过。
|
||||
|
||||
**把蓝点渲染到 toggle 的 anchor 之外。** 该插槽声明在 toggle 按钮内部,蓝点必须落在按钮角上;把 anchor 移出按钮要么让气泡脱离控件,要么改变插槽声明的放置位置。
|
||||
|
||||
## 结果
|
||||
|
||||
这条规则是 tooltip 嵌套的性质,而不是更新蓝点的性质:任何 anchor 内含另一个 tooltip 的 tooltip 现在都会把气泡让给最内层可见的那个。没有嵌套 tooltip 的 tooltip 不受影响——它消费到的是 null context,永远不会上报。
|
||||
|
||||
外层 tooltip 仅在子级可见期间收回气泡;它的触发状态得以保留,因此不需要重新进入即可恢复。抑制不改变 anchor 的行为:按钮仍然切换侧边栏,蓝点仍然是非交互标记。
|
||||
|
||||
[Tooltip 测试](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx)覆盖收回、用真实 `relatedTarget` 从嵌套 anchor 移到外层 anchor 时的恢复,以及已显示的嵌套 tooltip 卸载时的释放。[侧边栏 shell 测试](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx)覆盖产品接线:在 toggle 自身的 hover 延迟已经过去之后,rail 蓝点气泡仍替换掉 toggle 气泡。
|
||||
@@ -1,13 +1,20 @@
|
||||
// Cloning the anchor preserves its layout context. Fixed positioning lets the
|
||||
// bubble escape ancestor overflow clipping without a portal.
|
||||
|
||||
import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { cloneElement, createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
|
||||
import css from './Tooltip.module.css'
|
||||
|
||||
/** Bubble placement relative to the anchor. */
|
||||
export type TooltipSide = 'right' | 'bottom' | 'top'
|
||||
|
||||
/**
|
||||
* Suppression channel from a tooltip to the tooltips above it: a tooltip hands
|
||||
* this setter to its own descendants, and a visible descendant bubble calls it
|
||||
* so the ancestor withdraws its bubble for as long as the descendant shows one.
|
||||
*/
|
||||
const TooltipSuppression = createContext<((suppressed: boolean) => void) | null>(null)
|
||||
|
||||
/** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */
|
||||
interface AnchorProps {
|
||||
ref?: Ref<HTMLElement> | undefined
|
||||
@@ -93,6 +100,20 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||
const triggers = useRef({ hover: false, focus: false })
|
||||
|
||||
// A nested tooltip's bubble owns the pointer position, so this tooltip
|
||||
// withdraws its own while a descendant shows one; the state below is set by
|
||||
// the descendants this tooltip wraps. Announcing on every visibility change
|
||||
// covers hide, disable, and unmount; show() also announces synchronously so
|
||||
// a nested pair shown in one commit never paints both bubbles.
|
||||
const suppressAncestors = useContext(TooltipSuppression)
|
||||
const [suppressed, setSuppressed] = useState(false)
|
||||
const announce = useCallback((active: boolean) => { suppressAncestors?.(active) }, [suppressAncestors])
|
||||
const visible = pos !== null && !disabled
|
||||
useEffect(() => {
|
||||
announce(visible)
|
||||
return () => { announce(false) }
|
||||
}, [announce, visible])
|
||||
|
||||
// Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
|
||||
// must drop an already-visible bubble: no mouseleave fires.
|
||||
const cancelShow = useCallback(() => {
|
||||
@@ -119,6 +140,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
// where this anchor's position demands it.
|
||||
setPlacement(side)
|
||||
setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom })
|
||||
announce(true)
|
||||
}
|
||||
const showAfterHoverDelay = () => {
|
||||
cancelShow()
|
||||
@@ -131,21 +153,25 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
show()
|
||||
}, delayMs)
|
||||
}
|
||||
const withdraw = () => {
|
||||
setPos(null)
|
||||
announce(false)
|
||||
}
|
||||
const hide = () => {
|
||||
cancelShow()
|
||||
if (!triggers.current.hover && !triggers.current.focus) setPos(null)
|
||||
if (!triggers.current.hover && !triggers.current.focus) withdraw()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipSuppression.Provider value={setSuppressed}>
|
||||
{cloneElement(children, {
|
||||
ref: mergedRef,
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); withdraw() },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() },
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{pos !== null && (
|
||||
{visible && !suppressed && (
|
||||
<span
|
||||
ref={bubble}
|
||||
className={css.bubble}
|
||||
@@ -156,6 +182,6 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</TooltipSuppression.Provider>
|
||||
)
|
||||
}
|
||||
@@ -354,4 +354,55 @@ describe('Tooltip', () => {
|
||||
)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('withdraws the enclosing bubble while a nested tooltip shows its own', () => {
|
||||
render(
|
||||
<Tooltip label="Open sidebar">
|
||||
<button type="button">
|
||||
anchor
|
||||
<Tooltip label="Update — V1.2.3">
|
||||
<span data-testid="badge" />
|
||||
</Tooltip>
|
||||
</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
const anchor = screen.getByText('anchor')
|
||||
const badge = screen.getByTestId('badge')
|
||||
fireEvent.mouseEnter(anchor)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
|
||||
|
||||
// Entering the nested anchor withdraws the enclosing bubble instead of
|
||||
// stacking both; the enclosing anchor stays hovered, so nothing is lost.
|
||||
fireEvent.mouseEnter(badge)
|
||||
expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update — V1.2.3'])
|
||||
|
||||
// Leaving the nested anchor for the enclosing one restores its bubble;
|
||||
// the pointer never left the enclosing anchor, so only the badge is left.
|
||||
fireEvent.mouseLeave(badge, { relatedTarget: anchor })
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
|
||||
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('releases the enclosing bubble when a shown nested tooltip unmounts', () => {
|
||||
const view = render(
|
||||
<Tooltip label="Open sidebar">
|
||||
<button type="button">
|
||||
anchor
|
||||
<Tooltip label="Update"><span data-testid="badge" /></Tooltip>
|
||||
</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
fireEvent.mouseEnter(screen.getByTestId('badge'))
|
||||
expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update'])
|
||||
|
||||
view.rerender(
|
||||
<Tooltip label="Open sidebar">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps,
|
||||
@@ -191,6 +192,33 @@ describe('SidebarRoot shell', () => {
|
||||
expect(b.regionOwner().wide).toBe(false)
|
||||
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows only the badge bubble while the rail badge is hovered inside the toggle', () => {
|
||||
vi.useFakeTimers()
|
||||
render(<SidebarRoot
|
||||
collapsed width={56}
|
||||
useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
|
||||
usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
|
||||
useResource={useResource} useWorkspaces={neverHook}
|
||||
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
|
||||
renderSlot={((key: string) => key === 'sidebar.toggle.badge'
|
||||
? <Tooltip label="Update — V1.2.3"><span data-testid="badge" /></Tooltip>
|
||||
: null) as SidebarRootComponentProps['renderSlot']}
|
||||
/>)
|
||||
const toggle = screen.getByRole('button', { name: 'Open sidebar' })
|
||||
fireEvent.mouseEnter(toggle)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
|
||||
// The badge's own bubble replaces the toggle's rather than stacking on it,
|
||||
// even after the toggle's longer hover delay has elapsed.
|
||||
fireEvent.mouseEnter(screen.getByTestId('badge'))
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update — V1.2.3'])
|
||||
fireEvent.mouseLeave(screen.getByTestId('badge'), { relatedTarget: toggle })
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
|
||||
fireEvent.mouseLeave(toggle)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the macOS sidebar toggle in its top strip', () => {
|
||||
|
||||
Reference in new issue
Block a user