fix(web): flip a tooltip that the viewport has no room for
The bubble clamped horizontally and did nothing vertically, so a long label under an anchor low on the page ran off the bottom. Preset cards made that the common case: custom presets sit at the end of the roster and carry the longest descriptions. A `top` or `bottom` bubble now flips to the other side, and only into a side that genuinely fits — an anchor with room on neither keeps the requested placement rather than oscillating. Sliding the bubble vertically instead would cover the text being read.
This commit is contained in:
+2
-2
@@ -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 .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md
|
||||
2026-08-11-preset-card-description-clamp.md: cd16080f10d5e4b0286babb9476c926993ee3ffa
|
||||
2026-08-11-preset-card-description-clamp.zh.md: cb251f6b0c5b1268e210d01c44738de5a61ff2b1
|
||||
2026-08-11-preset-card-description-clamp.md: 16ebf371d5af7c9e54fcc37819696b380856d5cb
|
||||
2026-08-11-preset-card-description-clamp.zh.md: 5b7a18f41e4b3e8acd681a001f6826b19ca7026d
|
||||
@@ -21,6 +21,7 @@ Three smaller decisions ride along:
|
||||
- `.cardId` takes the card's free space with `margin-top: auto`, and the description no longer grows. A flex-stretched box leaves the clamp height and the box height disagreeing; sizing the clamped box by content alone keeps the behavior independent of that interaction.
|
||||
- The description carries `title=""`. An empty `title` means the element has no advisory information and the lookup stops there, so the card body's native tooltip does not climb to the description and a cut-off description answers with one bubble instead of two.
|
||||
- `Tooltip` gains an optional `maxWidth`. Its default half-viewport cap renders a description as a slab wider than the settings dialog it belongs to, spilling across the application behind it.
|
||||
- `Tooltip` also flips a `top` or `bottom` bubble to the other side when the viewport has no room for it, which its horizontal-only clamp previously left unhandled. Custom presets sit at the bottom of the roster and carry the longest descriptions, so the common case put a tall bubble under an anchor low on the page. The flip only moves into a side that genuinely fits, so an anchor with room on neither side keeps the requested placement rather than oscillating; sliding the bubble vertically instead would cover the text being read.
|
||||
|
||||
A roster row that failed its shape check is badged `Failed to load` (`加载失败`) rather than `Broken` (`已损坏`). Discovery sets `broken` when the composition file is missing, unreadable, or malformed — most often a file the user just edited or deleted — so a damage claim overstates what was observed, and the verbatim reason under the badge already names the file and the fix.
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ preset 自行发布 `description`,长度不限,而设置分区把名单渲
|
||||
- `.cardId` 以 `margin-top: auto` 吃掉卡片的空余空间,描述不再拉伸。被 flex 拉伸的盒子会让截断高度与盒子高度不一致;让截断盒子只按内容定高,行为便不依赖这层交互。
|
||||
- 描述带有 `title=""`。空 `title` 表示该元素没有提示信息,查找就此停止,因此卡片主体的原生 tooltip 不会向上找到描述,被裁切的描述只回应一个气泡而不是两个。
|
||||
- `Tooltip` 新增可选的 `maxWidth`。它默认的半视口上限会把描述渲染成比所属设置弹窗还宽的一整块,溢出到背后的应用界面上。
|
||||
- `Tooltip` 同时在视口放不下时把 `top` 或 `bottom` 气泡翻到另一侧,此前它只做水平收敛。自定义 preset 位于名单末尾、又恰恰承载最长的描述,因此常见情形正是让一个高气泡挂在页面靠下的锚点之下。翻转只会移向确实放得下的一侧,两侧都放不下时保持请求的位置而不来回摆动;改为垂直滑动则会盖住正在阅读的文本。
|
||||
|
||||
形状检查未通过的名单行,徽记从 `Broken`(`已损坏`)改为 `Failed to load`(`加载失败`)。discovery 在组装文件缺失、读不出或格式错误时置位 `broken`——最常见的是用户刚编辑或删除的文件——因此断言损坏超出了观察到的事实,而徽记下方原样展示的原因本就点名了文件与修法。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -48,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).
|
||||
@@ -102,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()
|
||||
@@ -137,8 +156,8 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
<span
|
||||
ref={bubble}
|
||||
className={css.bubble}
|
||||
data-side={side}
|
||||
style={{ left: pos.x, top: pos.y, ...maxWidth === undefined ? {} : { maxWidth } }}
|
||||
data-side={placement}
|
||||
style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
|
||||
role="tooltip"
|
||||
>
|
||||
{resolvedLabel}
|
||||
|
||||
@@ -173,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', () => {
|
||||
|
||||
Reference in New Issue
Block a user