Merge pull request #2245 from deepseek-harness/fix/preset-card-description-clamp

修复 web 端 Agent 预设卡片被长描述顶高
This commit is contained in:
Yichen Jiang
2026-08-11 14:39:29 +08:00
committed by GitHub
14 changed files with 372 additions and 57 deletions

View File

@@ -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-agent-preset/README.md
README.md: 008066114e9c49e5c74299979e24c27a4c9621c9
README.zh.md: e07d5994ae196cd03be7818fe4ade1aafda9aa55
README.md: 3b0db5a3eedca256a00b65a3bd2738f22c0eb62e
README.zh.md: 6f3c350f973119c201572f2c03145338b5cc5b00

View File

@@ -36,6 +36,8 @@ A fourth surface, its own settings page (`settings.section` id `agent-presets`,
The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing.
A preset publishes its own description, of any length, and the grid sizes every card row alike — so an unbounded description would set the height of the whole roster. Cards clamp it to four lines and offer the rest in a tooltip, attached only while the text is actually cut off. The clamp is CSS, so the whole description stays in the accessibility tree whatever the card shows.
A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: duplicate an existing preset and make it yours, or let the agent draft one in Creator mode.
Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default.
@@ -44,7 +46,7 @@ The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and re
Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a "Failed to load" badge (what discovery observed, not a claim that the files are damaged — the usual cause is a composition the user just edited or deleted), the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).

View File

@@ -36,6 +36,8 @@ preset 文件提供一套未国际化的 `name` 与 `description`Web 将其
浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id它将成为目录名所以必须当场取好、事后无法更改与一个可选显示名跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。
preset 自行发布描述,长度不限,而网格让每一行卡片等高——因此不加约束的描述会决定整份名单的高度。卡片把描述截断为四行,其余内容由 tooltip 承载,且仅在文本确实被裁切时才挂载。截断由 CSS 完成,因此无论卡片显示多少,完整描述始终留在无障碍树中。
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。
@@ -44,7 +46,7 @@ preset 文件提供一套未国际化的 `name` 与 `description`Web 将其
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作文件正是修复它的地方而删除正是清掉幽灵目录组装文件被手动删除、目录仍占着 id的方式损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器通用设置行与新会话 chip则完全不列出损坏的 preset它们选的是下一个会话的组装列出无法组装的选项只会把失败推迟到会话启动。
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「加载失败」徽记discovery 观察到的事实,而非断言文件已损坏——常见起因是用户刚编辑或删除了组装文件)、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作文件正是修复它的地方而删除正是清掉幽灵目录组装文件被手动删除、目录仍占着 id的方式损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器通用设置行与新会话 chip则完全不列出损坏的 preset它们选的是下一个会话的组装列出无法组装的选项只会把失败推迟到会话启动。
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。

View File

@@ -161,15 +161,28 @@
color: var(--dsw-alias-bg-layer-3);
}
/* Bounded to four lines. A preset publishes its own description, so one long
one would otherwise stretch every card in its grid row (`.cards` sizes rows
1fr). Clamping is CSS alone: the whole text stays in the DOM for assistive
tech, and the card offers it on hover when it is actually cut off. The
description does not grow to fill the card — `-webkit-line-clamp` on a
flex-stretched box leaves the clamp height and the box height disagreeing,
so `.cardId` takes the free space with an auto margin instead. */
.cardDesc {
font-size: 13px;
line-height: 1.55;
color: var(--dsw-alias-label-secondary);
flex: 1;
min-height: 42px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
/* A user-authored description may carry an unbreakable path or URL. */
overflow-wrap: anywhere;
}
.cardId {
margin-top: auto;
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
color: var(--dsw-alias-label-dimmed);

View File

@@ -10,10 +10,10 @@
* mounted once at session creation and nothing re-reads the file.
*/
import { useEffect } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import {
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal,
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -137,6 +137,39 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
)
}
/**
* Render one card's description, clamped by CSS and offered in full on hover.
* The tooltip is attached only while the text is actually cut off, so a short
* description does not answer a hover with a bubble repeating the card.
* @param props.text - the description as rendered, already localized.
* @returns the description element, tooltip-anchored while it overflows.
*/
function CardDescription({ text }: { text: string }): ReactNode {
const ref = useRef<HTMLSpanElement | null>(null)
const [truncated, setTruncated] = useState(false)
useLayoutEffect(() => {
const el = ref.current
/* v8 ignore next -- the ref is attached before layout effects run. */
if (el === null) return
const measure = () => { setTruncated(el.scrollHeight > el.clientHeight) }
measure()
// Card width follows the settings pane, which resizes with the window.
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => { observer.disconnect() }
}, [text])
return (
// Capped near the card's own width: the default half-viewport bubble would
// spill a description out of the settings dialog and across the app behind it.
<Tooltip label={text} side="bottom" delayMs={400} disabled={!truncated} maxWidth={360}>
{/* The empty title stops the card body's native tooltip from climbing to
this span: a cut-off description answers with one bubble, not two. */}
<span ref={ref} className={css.cardDesc} title="">{text}</span>
</Tooltip>
)
}
/**
* Render the Agent presets section content column.
* @param props - composed slot props.
@@ -247,7 +280,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</span>
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
<CardDescription text={text.description ?? t('noDescription')} />
{row.broken === undefined
? null
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}

View File

@@ -57,8 +57,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
builtInGroup: 'Built-in',
customGroup: 'Custom',
noDescription: 'No description.',
brokenBadge: 'Broken',
brokenNoCopy: 'Broken presets cannot be duplicated',
brokenBadge: 'Failed to load',
brokenNoCopy: 'A preset that failed to load cannot be duplicated',
copyOf: 'Copied from',
composition: 'Composition (agent.cordis.yml)',
cancel: 'Cancel',
@@ -117,8 +117,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
builtInGroup: '内置',
customGroup: '自定义',
noDescription: '暂无描述。',
brokenBadge: '已损坏',
brokenNoCopy: '预设已损坏,无法复制',
brokenBadge: '加载失败',
brokenNoCopy: '预设加载失败,不能复制',
copyOf: '复制自',
composition: '组装agent.cordis.yml',
cancel: '取消',

View File

@@ -6,8 +6,8 @@
* action follows the host's desktop capability.
*/
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
@@ -452,3 +452,68 @@ describe('deleting a preset', () => {
expect(actions.remove).not.toHaveBeenCalled()
})
})
describe('a long card description', () => {
/** jsdom has no ResizeObserver; the description watches its own box through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
const LONG = '始终用简体中文交流的友好通用助手,提供持久 bash 与文件编辑能力。'.repeat(8)
/** Force the clamp to report an overflow: jsdom lays nothing out, so both heights are 0. */
function clamp(overflowing: boolean): void {
vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(overflowing ? 400 : 80)
vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(80)
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('offers the whole description on hover once the card cuts it off', () => {
clamp(true)
vi.useFakeTimers()
try {
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: LONG }] })
fireEvent.mouseEnter(within(rowFor('zh')).getByText(LONG))
act(() => { vi.advanceTimersByTime(400) })
expect(screen.getByRole('tooltip').textContent).toBe(LONG)
} finally {
vi.useRealTimers()
}
})
it('stays quiet when the description already fits', () => {
clamp(false)
vi.useFakeTimers()
try {
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: '短描述。' }] })
fireEvent.mouseEnter(within(rowFor('zh')).getByText('短描述。'))
act(() => { vi.advanceTimersByTime(400) })
// A bubble repeating what is already fully on the card is noise.
expect(screen.queryByRole('tooltip')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('renders where the runtime has no ResizeObserver', () => {
vi.unstubAllGlobals()
clamp(true)
expect(() => {
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, description: LONG }] })
}).not.toThrow()
// The first measurement does not depend on the observer.
expect(within(rowFor('zh')).getByText(LONG).getAttribute('title')).toBe('')
})
})

View File

@@ -1,7 +1,7 @@
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
// TODO: interaction is a placeholder (horizontal overflow clamps, but there
// is no vertical flip on viewport collision and no arrow) — visuals and
// behavior get a proper pass later.
// TODO: interaction is a placeholder (horizontal overflow clamps and a
// vertical collision flips the bubble to the other side, but there is no
// arrow) — visuals and behavior get a proper pass later.
// The anchor is the child element itself (cloneElement, no wrapper node), so
// attaching a tooltip never changes the anchor's layout context. The bubble is
// position:fixed and coordinates come from the anchor's rect at show time, so
@@ -33,10 +33,12 @@ type TooltipLabel = string | (() => string)
* @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.
*/
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: 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.
@@ -46,33 +48,53 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
if (typeof childRef === 'function') childRef(el)
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
}, [childRef])
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
// 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
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
// a centered bubble near the right edge would clip. Each measurement resets
// the base position before applying a direct style offset, allowing a shorter
// label or wider viewport to release a previous clamp without another render.
const y = pos === null
? 0
: placement === '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 clamp = () => {
const fit = () => {
const el = bubble.current
/* v8 ignore next -- pos is set only while the bubble is mounted. */
if (el === null) return
const EDGE_MARGIN = 12
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')
}
clamp()
window.addEventListener('resize', clamp)
return () => { window.removeEventListener('resize', clamp) }
}, [pos, resolvedLabel])
fit()
window.addEventListener('resize', fit)
return () => { window.removeEventListener('resize', fit) }
}, [placement, pos, resolvedLabel, side])
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).
@@ -100,11 +122,10 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
if (el === null) return
const r = el.getBoundingClientRect()
setPos(side === 'right'
? { x: r.right + 10, y: r.top + r.height / 2 }
: side === 'top'
? { x: r.left + r.width / 2, y: r.top - 8 }
: { x: r.left + r.width / 2, y: r.bottom + 8 })
// 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 : r.left + r.width / 2, top: r.top, bottom: r.bottom })
}
const showAfterHoverDelay = () => {
cancelShow()
@@ -132,7 +153,13 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})}
{pos !== null && (
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
<span
ref={bubble}
className={css.bubble}
data-side={placement}
style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
role="tooltip"
>
{resolvedLabel}
</span>
)}

View File

@@ -95,6 +95,18 @@ describe('Tooltip', () => {
const rect = (left: number, right: number): DOMRect =>
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
it('caps the bubble width where the label would otherwise slab across the surface', () => {
render(
<Tooltip label="A description long enough to need a cap" side="bottom" maxWidth={360}>
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
// The stylesheet's half-viewport cap stays the default; this one overrides it.
expect(screen.getByRole('tooltip').style.maxWidth).toBe('360px')
})
it('clamps a bubble overflowing the right viewport edge back inside', () => {
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
try {
@@ -161,19 +173,88 @@ 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: () => ({}),
}
})
it('supports top placement for anchors at the viewport bottom', () => {
render(
<Tooltip label="Above" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('top')
// jsdom rects are all-zero: top placement lands at the -8 gutter and the
// zero-width measured rect clamps left to the 12px edge margin.
expect(bubble.style.left).toBe('12px')
expect(bubble.style.top).toBe('-8px')
const spy = placed(700, 720, 20)
try {
render(
<Tooltip label="Above" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
// There is room above, so the requested side stands: the bubble's own
// top sits at the anchor's top less the 8px gutter.
expect(bubble.getAttribute('data-side')).toBe('top')
expect(bubble.style.top).toBe('692px')
expect(bubble.style.left).toBe('150px')
} finally {
spy.mockRestore()
}
})
it('flips a bottom bubble above an anchor with no room below', () => {
// jsdom's viewport is 768 tall: a 300px bubble under an anchor ending at
// 700 would run off, and there is room for it above.
const spy = placed(600, 700, 300)
try {
render(
<Tooltip label="Tall" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('top')
expect(bubble.style.top).toBe('592px')
} finally {
spy.mockRestore()
}
})
it('flips a top bubble below an anchor with no room above', () => {
const spy = placed(10, 40, 100)
try {
render(
<Tooltip label="Tall" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('bottom')
expect(bubble.style.top).toBe('48px')
} finally {
spy.mockRestore()
}
})
it('keeps the requested side when neither side fits', () => {
// A bubble taller than the viewport has no home; oscillating between the
// two would be worse than honouring the request.
const spy = placed(300, 400, 900)
try {
render(
<Tooltip label="Huge" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom')
} finally {
spy.mockRestore()
}
})
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {