fix: portal file action tooltips outside clipped cards
This commit is contained in:
1 parent
a4cd2f57a0
commit
c1ee42cd9c
8 files changed
+54
-21
No files matched your search
@@ -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
|
||||
@@ -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. |
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user