fix: portal file action tooltips outside clipped cards

This commit is contained in:
creatixchu committed 2026-09-20 14:28:15 +08:00
1 parent a4cd2f57a0
commit c1ee42cd9c
8 files changed
+54 -21

No files matched your search

+12
View File
@@ -132,6 +132,18 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
await row.waitFor()
await expect.poll(() => row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2)
expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt')
const card = row.locator('[data-presented-file]').filter({ hasText: 'report.txt' })
await card.getByRole('button', { name: 'Open in Test Editor', exact: true }).hover()
await page.getByRole('tooltip', { name: 'Open in Test Editor', exact: true }).waitFor()
expect(await page.getByRole('tooltip').evaluate((tooltip) => {
const rect = tooltip.getBoundingClientRect()
const previous = tooltip.style.pointerEvents
tooltip.style.pointerEvents = 'auto'
const visible = document.elementFromPoint(rect.left + rect.width / 2, rect.bottom - 1) === tooltip
tooltip.style.pointerEvents = previous
return tooltip.parentElement === document.body && visible
})).toBe(true)
await page.mouse.move(0, 0)
const beforePreview = (await opened()).length
const column = page.locator('[data-rightbar-col]')
for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) {
@@ -113,7 +113,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode {
anchor={(
<div className={css.split} data-open-target={kind} data-size={props.prominent ? 'large' : 'compact'}
data-open-path={kind === 'file' && !props.prominent ? '' : undefined} data-state={disabled ? 'busy' : 'idle'}>
<Tooltip label={primaryLabel} side="bottom" delayMs={500}>
<Tooltip portal label={primaryLabel} side="bottom" delayMs={500}>
<button type="button" className={css.main} disabled={disabled} aria-label={props.prominent ? undefined : primaryLabel}
data-open-path-open={kind === 'file' && !props.prominent ? '' : undefined}
data-open-path-unpreviewable={props.prominent ? '' : undefined} onClick={primary}>
@@ -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: 4bc5d53f1fde5ac65015ef023134b163b351797f
README.zh.md: 481a84150f480c87568a91af0311a3d4fcf2c637
README.md: 5ea78f223e848effb566605eefa7be75c3a3ba9b
README.zh.md: 78cc45d60b8642b41bc33bd1ed0c9a1d7bf78cdf
+1 -1
View File
@@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `Modal` | Centered dialog over a page mask. |
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
| `OnboardingSurface` | First-run stage that holds the application root inert. |
| `Tooltip` | Hover text on a cloned anchor, placed right, bottom, or top. |
| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers. |
| `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
| `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
+1 -1
View File
@@ -49,7 +49,7 @@ kind: "package-library"
| `Modal` | 页面遮罩之上的居中对话框。 |
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
| `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 |
| `Tooltip` | 克隆锚点上的悬停文本,可置于右、下、上三个方向。 |
| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪。 |
| `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
| `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
@@ -19,6 +19,8 @@
animation: tooltip-in 150ms var(--ds-ease-in-out);
}
.bubble[data-portal] { z-index: 1100; }
.bubble[data-side='right'] {
transform: translateY(-50%);
}
+20 -16
View File
@@ -1,8 +1,8 @@
// Cloning the anchor preserves its layout context. Fixed positioning lets the
// bubble escape ancestor overflow clipping without a portal.
/** Anchor-preserving tooltips with optional body portals for clipping containers. */
import { cloneElement, createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
import { createPortal } from 'react-dom'
import css from './Tooltip.module.css'
/** Bubble placement relative to the anchor. */
@@ -46,17 +46,18 @@ if (typeof window !== 'undefined') {
* @param props.align - horizontal anchor-edge alignment for 'bottom'/'top' bubbles: 'end' pins
* the bubble's right edge to the anchor's (for anchors beside other hover surfaces the centered
* bubble would overlap); default 'center'. Ignored for side 'right'.
* @param props.portal - render the bubble under document.body to escape containing blocks and clipping ancestors.
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
* toggling never remounts it (which would cut its CSS transitions).
* @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 while hovered/focused; clicking the
* @returns the cloned anchor plus a fixed-position bubble, optionally portaled to the body; 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.
*/
export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref.
@@ -184,6 +185,20 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
if (!triggers.current.hover && !triggers.current.focus) withdraw()
}
const content = visible && !suppressed && (
<span
ref={bubble}
className={css.bubble}
data-side={placement}
data-portal={portal || undefined}
data-align={align}
style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
role="tooltip"
>
{resolvedLabel}
</span>
)
return (
<TooltipSuppression.Provider value={setSuppressed}>
{cloneElement(children, {
@@ -197,18 +212,7 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality) return; triggers.current.focus = true; cancelShow(); show() },
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})}
{visible && !suppressed && (
<span
ref={bubble}
className={css.bubble}
data-side={placement}
data-align={align}
style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
role="tooltip"
>
{resolvedLabel}
</span>
)}
{portal ? (content !== false && createPortal(content, document.body)) : content}
</TooltipSuppression.Provider>
)
}
@@ -466,3 +466,18 @@ describe('Tooltip', () => {
expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar')
})
})
it('keeps the anchor in its clipping container and portals only the tooltip', () => {
const view = render(<div style={{ overflow: 'hidden', contain: 'layout' }}>
<Tooltip portal label="Open in Music" side="bottom"><button type="button">File action</button></Tooltip>
</div>)
const anchor = screen.getByRole('button', { name: 'File action' })
fireEvent.mouseEnter(anchor)
const tooltip = screen.getByRole('tooltip')
expect(tooltip.parentElement).toBe(document.body)
expect(view.container.contains(anchor)).toBe(true)
expect(view.container.contains(tooltip)).toBe(false)
view.unmount()
expect(screen.queryByRole('tooltip')).toBeNull()
})