perf(ui): fit tooltips from observed sizes

This commit is contained in:
imccyu committed 2026-09-22 21:07:56 +08:00
1 parent 820824edd7
commit c92d5ebe18
6 files changed
+126 -69

No files matched your search

+7
View File
@@ -67,11 +67,18 @@ describe('web e2e: goal bar clear convergence', () => {
declaredTop: Number.parseFloat(element.style.top),
tooltipCenter: tooltip.left + tooltip.width / 2,
tooltipTop: tooltip.top,
left: tooltip.left,
right: tooltip.right,
viewportWidth: window.innerWidth,
visibility: element.style.visibility,
}
})
expect(tooltipGeometry).not.toBeNull()
expect(Math.abs(tooltipGeometry!.tooltipCenter - tooltipGeometry!.declaredLeft)).toBeLessThan(2)
expect(Math.abs(tooltipGeometry!.tooltipTop - tooltipGeometry!.declaredTop)).toBeLessThan(2)
expect(tooltipGeometry!.visibility).toBe('visible')
expect(tooltipGeometry!.left).toBeGreaterThanOrEqual(12)
expect(tooltipGeometry!.right).toBeLessThanOrEqual(tooltipGeometry!.viewportWidth - 12)
await page.mouse.move(0, 0)
await pauseTooltip.waitFor({ state: 'hidden' })
const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
@@ -2,5 +2,5 @@
# 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 packages/client/ui-primitives/README.md
README.md: 43476f2aa531decbe1eff1b6664530f7873723b4
README.zh.md: 44f5c6e123bd7d6e861fec9554beb2fccf827927
README.md: 332845471056e718d49d79a077b76a02c79bafe2
README.zh.md: 598b561e79e4a935f7f033002e68b91c3a3d84de
+2
View File
@@ -79,6 +79,8 @@ Writing your own component in your own package is fine when the need is genuinel
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a cLine truncated
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render.
### Rendering agent output
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
@@ -79,6 +79,8 @@ kind: "package-library"
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一Line truncated
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。
### 渲染 agent 输出
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
+41 -44
View File
@@ -1,6 +1,6 @@
/** Anchor-preserving tooltips with optional body portals for clipping containers. */
import { cloneElement, createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { cloneElement, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
import { createPortal } from 'react-dom'
import css from './Tooltip.module.css'
@@ -53,7 +53,8 @@ if (typeof window !== 'undefined') {
* @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
* half-viewport cap would render a slab wider than the surface the anchor sits on.
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble, optionally portaled to the body; clicking the
* @returns the cloned anchor plus a fixed-position bubble, optionally portaled to the body.
* The bubble stays hidden until ResizeObserver supplies its size for viewport fitting; clicking the
* anchor dismisses the bubble until the next trigger, and focus arriving after a pointer
* interaction (a closing menu refocusing its trigger) never raises it.
*/
@@ -70,50 +71,15 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
// The anchor's edges rather than final coordinates: a vertical flip has to
// re-derive the bubble's own top from the opposite edge.
const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null)
// Where the bubble actually sits, which is the requested side until the
// viewport refuses it.
const [placement, setPlacement] = useState<TooltipSide>(side)
const bubble = useRef<HTMLSpanElement | null>(null)
const resolvedLabel = pos === null
? null
: typeof label === 'function' ? label() : label
const y = pos === null
? 0
: placement === 'right'
: side === 'right'
? pos.top + (pos.bottom - pos.top) / 2
: placement === 'top' ? pos.top - 8 : pos.bottom + 8
const EDGE_MARGIN = 12
// Viewport fit: fixed positioning knows nothing about edges, so a centered
// bubble near the right edge would clip and a long label under an anchor low
// on the page would run off the bottom. Horizontally the bubble slides back
// inside; vertically it flips to the opposite side, which is the only move
// that does not cover the anchor being read. Each measurement resets the base
// position first, so a shorter label or a larger viewport releases a previous
// adjustment without another render.
useLayoutEffect(() => {
if (pos === null) return
const fit = () => {
const el = bubble.current
/* v8 ignore next -- pos is set only while the bubble is mounted. */
if (el === null) return
el.style.left = `${pos.x}px`
const r = el.getBoundingClientRect()
let dx = 0
if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
el.style.left = `${pos.x + dx}px`
if (side === 'right') return
// Flip only into a side that genuinely fits, so an anchor with room on
// neither side keeps the requested placement instead of oscillating.
const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - EDGE_MARGIN
const fitsAbove = pos.top - 8 - r.height >= EDGE_MARGIN
if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top')
if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom')
}
fit()
window.addEventListener('resize', fit)
return () => { window.removeEventListener('resize', fit) }
}, [placement, pos, resolvedLabel, side])
: side === 'top' ? pos.top - 8 : pos.bottom + 8
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it).
@@ -128,6 +94,40 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
const [suppressed, setSuppressed] = useState(false)
const announce = useCallback((active: boolean) => { suppressAncestors?.(active) }, [suppressAncestors])
const visible = pos !== null && !disabled
// ResizeObserver supplies the laid-out border box; fitting never reads
// geometry after a position write or needs a React commit to flip sides.
useEffect(() => {
const el = bubble.current
if (pos === null || !visible || suppressed || el === null) return
const edgeMargin = 12
let size: ResizeObserverSize | undefined
let placement = side
const fit = () => {
if (size === undefined) return
const { inlineSize: width, blockSize: height } = size
const offset = side === 'right' ? 0 : align === 'end' ? width : width / 2
const left = Math.max(edgeMargin, Math.min(pos.x - offset, window.innerWidth - edgeMargin - width))
const fitsBelow = pos.bottom + 8 + height <= window.innerHeight - edgeMargin
const fitsAbove = pos.top - 8 - height >= edgeMargin
if (placement === 'bottom' && !fitsBelow && fitsAbove) placement = 'top'
else if (placement === 'top' && !fitsAbove && fitsBelow) placement = 'bottom'
el.style.left = `${left + offset}px`
el.style.top = `${placement === 'right' ? (pos.top + pos.bottom) / 2
: placement === 'top' ? pos.top - 8 : pos.bottom + 8}px`
el.dataset.side = placement
el.style.visibility = 'visible'
}
const observer = new ResizeObserver((entries) => {
size = entries[0]?.borderBoxSize[0]
fit()
})
observer.observe(el, { box: 'border-box' })
window.addEventListener('resize', fit)
return () => {
observer.disconnect()
window.removeEventListener('resize', fit)
}
}, [align, pos, side, suppressed, visible])
useEffect(() => {
announce(visible)
return () => { announce(false) }
@@ -155,9 +155,6 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
if (el === null) return
const r = el.getBoundingClientRect()
// Every show starts from the requested side; the fit pass flips it only
// where this anchor's position demands it.
setPlacement(side)
setPos({
x: side === 'right' ? r.right + 10 : align === 'end' ? r.right : r.left + r.width / 2,
top: r.top,
@@ -189,10 +186,10 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
<span
ref={bubble}
className={css.bubble}
data-side={placement}
data-side={side}
data-portal={portal || undefined}
data-align={align}
style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
style={{ left: pos.x, top: y, visibility: 'hidden', ...maxWidth === undefined ? {} : { maxWidth } }}
role="tooltip"
>
{resolvedLabel}
@@ -1,11 +1,63 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
let bubbleSize: ResizeObserverSize
let automaticResize: boolean
const observers: TooltipResizeObserver[] = []
class TooltipResizeObserver implements ResizeObserver {
private target: Element | undefined
constructor(private readonly callback: ResizeObserverCallback) { observers.push(this) }
observe(target: Element): void {
this.target = target
if (automaticResize) this.deliver()
}
unobserve(): void { this.target = undefined }
disconnect(): void { this.target = undefined }
deliver(): void {
if (this.target === undefined) return
this.callback([{
target: this.target, borderBoxSize: [bubbleSize], contentBoxSize: [bubbleSize],
devicePixelContentBoxSize: [bubbleSize],
contentRect: new DOMRect(0, 0, bubbleSize.inlineSize, bubbleSize.blockSize),
}], this)
}
}
beforeEach(() => {
bubbleSize = { inlineSize: 0, blockSize: 0 }
automaticResize = true
observers.length = 0
vi.stubGlobal('ResizeObserver', TooltipResizeObserver)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('Tooltip', () => {
it('fits from observed sizes without synchronously measuring the bubble', () => {
automaticResize = false
const measured = vi.spyOn(Element.prototype, 'getBoundingClientRect')
const view = render(<Tooltip label="Observed" side="bottom"><button>anchor</button></Tooltip>)
const anchor = screen.getByText('anchor')
fireEvent.mouseEnter(anchor)
const bubble = view.container.querySelector<HTMLElement>('[role="tooltip"]')!
expect(measured.mock.contexts).toEqual([anchor])
expect(bubble.style.visibility).toBe('hidden')
bubbleSize = { inlineSize: 100, blockSize: 20 }
act(() => { observers[0]!.deliver() })
expect(screen.getByRole('tooltip').style.left).toBe('62px')
fireEvent(window, new Event('resize'))
expect(measured.mock.contexts).toEqual([anchor])
const disconnect = vi.spyOn(observers[0]!, 'disconnect')
view.unmount()
expect(disconnect).toHaveBeenCalledOnce()
})
it('resolves lazy labels only after the bubble becomes visible', () => {
vi.useFakeTimers()
try {
@@ -64,9 +116,7 @@ describe('Tooltip', () => {
const bubble = screen.getByRole('tooltip')
expect(bubble.textContent).toBe('Open sidebar')
expect(bubble.getAttribute('data-side')).toBe('right')
// jsdom rects are all-zero: right placement lands at the +10 gutter, then
// the zero-width measured rect clamps to the 12px edge margin (10 + 12).
expect(bubble.style.left).toBe('22px')
expect(bubble.style.left).toBe('12px')
expect(bubble.style.top).toBe('0px')
fireEvent.mouseLeave(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
@@ -89,13 +139,12 @@ describe('Tooltip', () => {
expect(screen.queryByRole('tooltip')).toBeNull()
})
// jsdom's default rects are all-zero, so the clamp tests stub the measured
// rect (anchor and bubble share the prototype stub) and derive expectations
// from it: pos.x = anchor center, then shifted by the measured overflow.
// Anchor geometry and observed bubble sizes are independent browser inputs.
const rect = (left: number, right: number): DOMRect =>
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
it('aligns the end of a bottom tooltip with the anchor right edge', () => {
bubbleSize = { inlineSize: 100, blockSize: 20 }
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(100, 200))
try {
render(
@@ -125,6 +174,7 @@ describe('Tooltip', () => {
})
it('clamps a bubble overflowing the right viewport edge back inside', () => {
bubbleSize = { inlineSize: 200, blockSize: 20 }
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
try {
render(
@@ -144,10 +194,8 @@ describe('Tooltip', () => {
it('reclamps after label and viewport width changes', () => {
const originalWidth = window.innerWidth
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this.getAttribute('role') !== 'tooltip') return rect(900, 1000)
return this.textContent === 'Wide' ? rect(900, 1100) : rect(850, 950)
})
bubbleSize = { inlineSize: 200, blockSize: 20 }
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1000))
try {
const view = render(
<Tooltip label="Wide" side="bottom">
@@ -155,18 +203,22 @@ describe('Tooltip', () => {
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
expect(screen.getByRole('tooltip').style.left).toBe('862px')
expect(screen.getByRole('tooltip').style.left).toBe('912px')
view.rerender(
<Tooltip label="Short" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
bubbleSize = { inlineSize: 100, blockSize: 20 }
act(() => { observers[0]!.deliver() })
expect(screen.getByRole('tooltip').style.left).toBe('950px')
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 900 })
fireEvent(window, new Event('resize'))
expect(screen.getByRole('tooltip').style.left).toBe('888px')
expect(screen.getByRole('tooltip').style.left).toBe('838px')
expect(observers).toHaveLength(1)
expect(spy).toHaveBeenCalledOnce()
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
spy.mockRestore()
@@ -174,6 +226,7 @@ describe('Tooltip', () => {
})
it('clamps a bubble past the left viewport edge back inside', () => {
bubbleSize = { inlineSize: 100, blockSize: 20 }
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(-20, 80))
try {
render(
@@ -190,16 +243,12 @@ describe('Tooltip', () => {
}
})
/** Anchor and bubble rects, so a placement test measures real room rather than jsdom's all-zero boxes. */
const placed = (anchorTop: number, anchorBottom: number, bubbleHeight: number) =>
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
const [top, bottom] = this.getAttribute('role') === 'tooltip'
? [0, bubbleHeight]
: [anchorTop, anchorBottom]
return {
left: 100, right: 200, top, bottom, width: 100, height: bottom - top, x: 100, y: top, toJSON: () => ({}),
}
const placed = (top: number, bottom: number, bubbleHeight: number) => {
bubbleSize = { inlineSize: 100, blockSize: bubbleHeight }
return vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
left: 100, right: 200, top, bottom, width: 100, height: bottom - top, x: 100, y: top, toJSON: () => ({}),
})
}
it('supports top placement for anchors at the viewport bottom', () => {
const spy = placed(700, 720, 20)