diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml new file mode 100644 index 0000000000..b87f5de3b1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-07-30-hover-popup-pointer-grace.md +2026-07-30-hover-popup-pointer-grace.md: e999fdea482c14b3b7864df4ba4cba55a89cd7b2 +2026-07-30-hover-popup-pointer-grace.zh.md: 100dfc5b37ed547a8615b2f0f9c3c225a1b592b5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md new file mode 100644 index 0000000000..e999fdea48 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md @@ -0,0 +1,35 @@ +# Agent Note: Hover popup pointer grace + +Status: implemented + +English | [中文](2026-07-30-hover-popup-pointer-grace.zh.md) + +## Problem + +Both popups the workspace browser rows raise floated out of reach of the pointer. `HoverCard` closed on the first `pointerleave` from its anchor and rendered its card `pointer-events: none`, but the card sits 8px off the anchor's right edge, so every path to it crossed ground belonging to neither and killed the card before it arrived — the full workspace path and session title it exists to show could be read only in passing. The row action menus passed `closeOnPointerLeave`, whose handler sat on the portaled list: aiming back at the `...` trigger that opened the list closed it, and so did any overshoot past a list edge, with no window to come back. + +## Decision + +`usePointerGrace` ([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts)) owns one cancelable delayed close, shared by both atoms, with `POINTER_GRACE_MS` at 200. Leaving arms the close; coming back cancels it. Transit through an anchor-to-popup gap is therefore survivable, while a pointer that has genuinely moved on still dismisses the popup. + +`HoverCard` arms the grace on leave instead of closing, and its card no longer sets `pointer-events: none`, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press inside the anchor and an owner flipping `disabled` still dismiss immediately, ahead of the grace. + +`Menu` moves pointer-leave dismissal from the portaled list to the wrapper span. React's enter/leave traversal runs over the React tree, so the trigger and the portaled list are one region there: crossing the 4px gap between them, or aiming back at the trigger, no longer counts as leaving. Leaving is only armed while the list is open, and an owner-driven close (selection, Escape, outside click) disarms a pending grace close in an effect keyed on `open` alone — folding that into the outside-click effect would cancel the grace on every re-render, since owners pass a fresh `onClose` closure each time. + +## Alternatives considered + +**Close the popups only on outside click and Escape.** Rejected because both popups are hover-raised and unlabeled as dismissible; leaving them up after the pointer has moved to another row would strand a card over unrelated content. + +**Widen the anchor's hit area to abut the popup.** Rejected because the 8px and 4px offsets are the design's, and an invisible bridge element would have to track every reposition the fixed-positioned popups already do on scroll and resize. + +**Keep the hover card `pointer-events: none` and only add the grace.** Rejected because the pointer resting on the card would then hit whatever is behind it, so the grace would expire and close the card the user had just reached. + +**Give each atom its own timer.** Rejected because the two closes are the same behavior with the same tuning; a shared hook keeps them from drifting apart. + +## Consequences + +The hover card is now hit-testable and covers 244px of whatever it overlays while shown, which is the price of being reachable; it still lives only as long as the pointer is on the row or the card. Row menus survive the round trip between trigger and list, and a menu that closes for its own reason cannot be reopened into a stale pending close. Menus without `closeOnPointerLeave` are untouched — the wrapper handlers are only attached when it is set. + +## Testing + +`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md new file mode 100644 index 0000000000..100dfc5b37 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 悬浮弹层的指针宽限期 + +Status: implemented + +[English](2026-07-30-hover-popup-pointer-grace.md) | 中文 + +## 问题 + +工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭,其卡片还设置了 `pointer-events: none`;但卡片位于锚点右边缘外 8px 处,因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域,卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 `closeOnPointerLeave`,而其处理器挂在传送后的列表上:把指针移回打开该列表的 `...` 触发按钮会关闭列表,越过列表边缘的任何一次抖动同样如此,且没有任何折返窗口。 + +## 决策 + +`usePointerGrace`([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))持有唯一一个可取消的延迟关闭,由两个原子组件共享,`POINTER_GRACE_MS` 为 200。离开会启动关闭,折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙,而真正移开的指针仍会关闭弹层。 + +`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在锚点内按下指针以及所有者将 `disabled` 置真,仍会抢在宽限期之前立即关闭卡片。 + +`Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮,都不再算作离开。只有在列表打开时才会启动离开关闭;由所有者驱动的关闭(选择、Escape、外部点击)会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect,则每次重新渲染都会取消宽限期,因为所有者每次都传入新的 `onClose` 闭包。 + +## 考虑过的替代方案 + +**仅通过外部点击和 Escape 关闭这两种弹层。** 之所以否决:两者都由悬停唤起,且没有可见的关闭标识;在指针已移到其他行之后仍让它们停留,会把卡片遗留在无关内容之上。 + +**扩大锚点的命中区域,使其与弹层相接。** 之所以否决:8px 与 4px 的偏移来自设计稿,而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和缩放时执行的每一次重新定位。 + +**保留悬浮卡片的 `pointer-events: none`,只加入宽限期。** 之所以否决:那样指针停在卡片上时命中的是卡片背后的元素,宽限期仍会到期,并关闭用户刚刚够到的卡片。 + +**让两个原子组件各自持有计时器。** 之所以否决:这两处关闭是同一种行为、同一套调参;共享 hook 可以防止它们各自漂移。 + +## 后果 + +悬浮卡片现在可被命中,显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价;它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返,而因自身原因关闭的菜单也不会被残留的待执行关闭重新关掉。未设置 `closeOnPointerLeave` 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。 + +## 测试 + +`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index e6a7f31f18..da09aaa77a 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -1,7 +1,8 @@ // Web e2e scenarios: workspace management — the create-by-name dialog, the // rename round trip over the real wire (workspace.rename RPC + durable // registry), duplicate-name pre-check, the flat "In one list" view with its -// persisted group-by preference, and the session hover card. Zero model +// persisted group-by preference, and the pointer-reachability of the session +// hover card and the row action menu. Zero model // calls: workspace.create/rename are host RPCs with no model involvement, // and the one session row the flat/hover scenarios need comes from a seeded // fixture (the seeded-history seed reused verbatim — no new recording). @@ -26,7 +27,7 @@ const MODE = webSnapshotMode() const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md') const SEED_ID = 'workspace-management-web-e2e' -describe('web e2e: workspace management (create / rename / flat view / hover card)', () => { +describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -384,14 +385,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('shows the session hover card after a dwell on the row', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) - // Expand Ungrouped to reveal the seeded session row, then dwell on it - // (the card opens after a 500ms hover delay, portaled to body). + /** + * Expand Ungrouped and return its seeded session row. The only visible child + * is the non-blank persisted Session; the blank Session created while + * adopting the Workspace stays hidden. + * @returns the session row locator, already present. + */ + async function seededSessionRow() { const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') const ungroupedSection = ungroupedRow.locator('..') - // Initial-current auto-expansion can race this following test's gesture; - // converge on expanded rather than assuming which update wins first. + // Initial-current auto-expansion can race this gesture; converge on + // expanded rather than assuming which update wins first. await expect.poll(async () => { if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { await page.getByText('Ungrouped', { exact: true }).click() @@ -399,20 +403,62 @@ describe('web e2e: workspace management (create / rename / flat view / hover car } return await ungroupedRow.getAttribute('aria-expanded') }, { timeout: 5_000 }).toBe('true') - // The only visible child is the non-blank persisted Session; the blank - // Session created while adopting the Workspace remains hidden. - const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) + const row = ungroupedSection.locator('[role="treeitem"]').nth(1) + await row.waitFor({ timeout: 10_000 }) + return row + } + + it('shows the session hover card after a dwell on the row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) + // Dwell on the seeded row; the card opens after a 500ms hover delay, + // portaled to body. + const sessionRow = await seededSessionRow() await sessionRow.hover() - // Card content: the full title plus the Idle status line (display-only - // card; no aria role — text anchors are the stable selector). + // Card content: the full title plus the Idle status line (no aria role — + // text anchors are the stable selector). await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1) - // Leaving the anchor closes it with no delay. + // The card is REACHABLE: it sits 8px off the row, so getting to it means + // crossing ground that belongs to neither. Hovering it must not dismiss + // it — the regression this scenario guards. + const card = page.getByText('Idle', { exact: true }).locator('../../..') + await card.hover() + await page.waitForTimeout(600) + expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1) + // Leaving anchor and card together closes it after the grace. await page.getByRole('button', { name: 'Settings' }).hover() await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('keeps an open row menu up while the pointer moves between trigger and list', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu')) + const sessionRow = await seededSessionRow() + // The trigger is display:none until its row hovers. + await sessionRow.hover() + const trigger = sessionRow.locator('button[aria-label^="Session actions for "]') + await trigger.click() + const item = page.getByRole('menuitem', { name: 'Rename' }) + await item.waitFor({ timeout: 5_000 }) + // Into the list, then back up to the trigger across the 4px gap below it: + // that return trip used to fire the list's pointerleave and close the + // menu, so a hesitating pointer lost it. Order matters — clicking leaves + // the pointer ON the trigger, so entering the list has to come first for + // the return to be a real departure. + await item.hover() + await page.waitForTimeout(300) + await trigger.hover() + await page.waitForTimeout(600) + expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) + // ...and back down into the list, which must still be there to enter. + await item.hover() + await page.waitForTimeout(600) + expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) + // Pointer-leave dismissal still applies once the pointer genuinely leaves. + await page.getByRole('button', { name: 'Settings' }).hover() + await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.warnings).toEqual([]) // The directory-browser aria golden is this spec's one owned artifact; diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 8d8a52100e..ff1ac5509d 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -7,7 +7,9 @@ /* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the * menu card's elevation. Surface is #2C2C2E in both themes (figma value, - * light/dark identical), so a component-level variable, not a theme token. */ + * light/dark identical), so a component-level variable, not a theme token. + * Hit-testable on purpose: resting the pointer on the card holds it open + * (HoverCard's grace close), which a `pointer-events: none` card cannot do. */ .card { --dsw-hovercard-bg: #2C2C2E; position: fixed; @@ -18,5 +20,4 @@ border-radius: 12px; background: var(--dsw-hovercard-bg); box-shadow: var(--dsw-shadow-lv3); - pointer-events: none; } diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 1720a0b79c..3a1ce462b3 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,18 +1,24 @@ // HoverCard: delayed hover-preview card portaled to document.body. // Same portal mechanics as Menu: the wrapper span supplies the anchor rect, // the card is fixed-positioned at its right edge and repositions on -// scroll/resize while open. Display-only — the card ignores pointer events -// and closes the instant the pointer leaves the anchor (no close delay). +// scroll/resize while open. The card is reachable: it takes pointer events, +// and leaving the anchor only arms a grace-delayed close, so the pointer can +// cross the 8px gap and settle on the card to read a clipped path or title. +// The portaled card is a React child of the wrapper, so React's enter/leave +// traversal already treats it as inside — one pair of wrapper handlers covers +// anchor and card alike. import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' +import { usePointerGrace } from './pointer-grace.ts' import css from './HoverCard.module.css' /** * Render an anchor with a hover-triggered preview card. * @param props.anchor - the hover target (rendered in place inside a wrapper span). - * @param props.content - card content (display-only, no pointer interaction). + * @param props.content - card content; the pointer may rest on it, so it is + * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.disabled - suppress opening; turning true closes an open card. * @returns anchor wrapper with the conditional portaled card. @@ -29,6 +35,8 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false const [open, setOpen] = useState(false) const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { setOpen(false) }) + const clearTimer = () => { if (timerRef.current !== null) { clearTimeout(timerRef.current) @@ -40,8 +48,9 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false useEffect(() => { if (!disabled) return clearTimer() + cancelClose() setOpen(false) - }, [disabled]) + }, [disabled, cancelClose]) useEffect(() => clearTimer, []) @@ -91,17 +100,22 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false className={css.root} onPointerEnter={() => { if (disabled) return + // Coming back inside during the grace (the gap, or the card itself) + // keeps the current card rather than restarting the dwell. + cancelClose() + if (open) return clearTimer() timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs) }} onPointerLeave={() => { clearTimer() - setOpen(false) + armClose() }} // Any press inside the anchor (row click, menu trigger) dismisses the // card immediately, without waiting for the owner to flip `disabled`. onPointerDownCapture={() => { clearTimer() + cancelClose() setOpen(false) }} > diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 747750363d..ea7e51b478 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -13,6 +13,7 @@ import type { CSSProperties, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCheckOutline16 } from './icons/index.tsx' +import { usePointerGrace } from './pointer-grace.ts' import css from './Menu.module.css' /** Selectable row (optionally with a nested submenu). */ @@ -69,8 +70,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * from the anchor rect (repositions on scroll/resize while open). Use when an * ancestor's overflow clipping would crop the in-place list; default false * keeps the pure-CSS in-place behavior. - * @param props.closeOnPointerLeave - close the list when the pointer leaves - * it (default false keeps it open until outside click/Escape/selection). + * @param props.closeOnPointerLeave - close the list once the pointer has left + * both trigger and list for the pointer grace (default false keeps it open + * until outside click/Escape/selection). The grace makes the 4px trigger->list + * gap and a brief overshoot survivable; coming back cancels the close. * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the @@ -102,6 +105,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const listRef = useRef(null) const [openSubmenuId, setOpenSubmenuId] = useState(null) const [fixedPos, setFixedPos] = useState(null) + const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose) // Portal mode: fixed-position the list from the anchor rect before paint; // track the anchor while open (capture-phase scroll catches nested panes). @@ -179,6 +183,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } }, [open, onClose]) + // A close from selection/Escape/outside click outruns a pending grace close; + // left armed it would shut a list reopened inside the grace window. Its own + // effect, not the listener effect above: that one re-runs on every `onClose` + // identity change and would cancel the grace mid-transit. + useEffect(() => { + if (!open) cancelClose() + }, [open, cancelClose]) + // The submenu card is absolutely positioned outside the list box; the // scroll clip would crop it, so only submenu-free menus get the height cap. const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0) @@ -251,7 +263,6 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)} style={portal ? fixedPos ?? MEASURE_STYLE : undefined} role="menu" - onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined} // React portals bubble synthetic events through the REACT tree: without // this stop, an item click re-fires the anchor row's own onClick // (open/toggle) after onSelect. @@ -268,8 +279,17 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align ) + // Pointer-leave dismissal watches the WRAPPER, not the list: React's + // enter/leave traversal runs over the React tree, so trigger and portaled + // list are one region here. Aiming back at the trigger, or crossing the 4px + // gap between them, therefore never counts as leaving. return ( - + { if (open) armClose() } : undefined} + > {anchor} {portal ? (list !== false && createPortal(list, document.body)) : list} diff --git a/packages/client/ui-primitives/src/pointer-grace.ts b/packages/client/ui-primitives/src/pointer-grace.ts new file mode 100644 index 0000000000..1619cfe66e --- /dev/null +++ b/packages/client/ui-primitives/src/pointer-grace.ts @@ -0,0 +1,53 @@ +// Shared close timing for pointer-dismissed popups (HoverCard, hover-closing +// Menu). Both float free of their anchor, so the pointer has to cross ground +// that belongs to neither on its way in; closing on the first pointerleave +// makes the popup unreachable. The grace turns that transit into a cancelable +// pending close. + +import { useCallback, useEffect, useRef } from 'react' + +/** + * Grace before a pointer-dismissed popup closes. Covers the anchor->popup gap + * (8px for HoverCard, 4px for Menu) at a hand's travel speed without leaving a + * popup lingering once the pointer has genuinely moved on. + */ +export const POINTER_GRACE_MS = 200 + +/** Cancelable delayed close for a pointer-dismissed popup. */ +export interface PointerGrace { + /** Schedule the close {@link POINTER_GRACE_MS} from now, replacing any pending one. */ + arm: () => void + /** Abort a pending close (the pointer came back). */ + cancel: () => void +} + +/** + * Delay a pointer-dismissed popup's close so the pointer can cross the gap + * between anchor and popup. A pending close is dropped on unmount. + * @param close - runs when the grace elapses with no re-entry; read at fire + * time, so callers may pass a fresh closure each render. + * @returns the {@link PointerGrace} handle. + */ +export function usePointerGrace(close: () => void): PointerGrace { + const timerRef = useRef | null>(null) + const closeRef = useRef(close) + closeRef.current = close + + const cancel = useCallback(() => { + if (timerRef.current === null) return + clearTimeout(timerRef.current) + timerRef.current = null + }, []) + + const arm = useCallback(() => { + cancel() + timerRef.current = setTimeout(() => { + timerRef.current = null + closeRef.current() + }, POINTER_GRACE_MS) + }, [cancel]) + + useEffect(() => cancel, [cancel]) + + return { arm, cancel } +} diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 51b14faa17..8a9791a411 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' +import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) @@ -160,16 +161,77 @@ describe('Menu', () => { expect(onSelect).toHaveBeenCalledWith('del') }) - it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => { - const onClose = vi.fn() - const { rerender } = render( - trigger} items={items} onSelect={() => {}} onClose={onClose} />) - fireEvent.pointerLeave(screen.getByRole('menu')) - expect(onClose).toHaveBeenCalledTimes(1) - rerender( - trigger} items={items} onSelect={() => {}} onClose={onClose} />) - fireEvent.pointerLeave(screen.getByRole('menu')) - expect(onClose).toHaveBeenCalledTimes(1) + it('closeOnPointerLeave closes a grace after the pointer leaves trigger and list; default never does', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + const wrapper = screen.getByText('trigger').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + // Still open through the grace: the pointer may be crossing the gap. + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) }) + expect(onClose).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(onClose).toHaveBeenCalledTimes(1) + rerender( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('coming back inside the grace keeps the list open (trigger and list are one region)', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + const wrapper = screen.getByText('trigger').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('a close from selection disarms the pending grace close', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + const wrapper = screen.getByText('trigger').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + // The owner closes for its own reason (selection/Escape) mid-grace; the + // armed timer must not survive to shut a list reopened right after. + rerender( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('leaving a closed list arms nothing', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByText('trigger').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } }) it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => { diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index ce599c0258..3826fdf79a 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives' +import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) beforeEach(() => { vi.useFakeTimers() }) @@ -54,18 +55,47 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) - it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => { + it('pointerleave closes an open card a grace later; re-enter after that restarts the dwell', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('card body')).toBeTruthy() fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) }) + expect(screen.getByText('card body')).toBeTruthy() + act(() => { vi.advanceTimersByTime(1) }) expect(screen.queryByText('card body')).toBeNull() fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('card body')).toBeTruthy() }) + it('reaching the card inside the grace keeps it open without restarting the dwell', () => { + // The portaled card is a React child of the wrapper, so the pointer + // arriving on it re-enters the wrapper — the gesture the 8px anchor gap + // used to make impossible. + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('re-entering while open does not queue a second dwell', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.pointerEnter(wrapper) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + // A dwell restarted by the redundant enter would reopen the card here. + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByText('card body')).toBeNull() + }) + it('a press inside the anchor dismisses the card without waiting for disabled', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) @@ -135,6 +165,7 @@ describe('HoverCard', () => { expect(card.style.left).toBe('308px') expect(card.style.top).toBe('90px') fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) expect(screen.queryByText('card body')).toBeNull() })