fix(client): position approval risks with CSS anchors

This commit is contained in:
Yixiang Tu
2026-11-03 21:05:16 +08:00
committed by 涂轶翔
parent 0f05518e69
commit dca8cfb77e
7 changed files with 55 additions and 31 deletions
+6 -5
View File
@@ -118,8 +118,8 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
await page.keyboard.press('Escape')
await warning.waitFor({ state: 'hidden' })
expect(await approval.count()).toBe(1)
for (const width of [1000, 390]) {
await page.setViewportSize({ width, height: 800 })
for (const { width, height } of [{ width: 1000, height: 800 }, { width: 390, height: 800 }, { width: 390, height: 480 }]) {
await page.setViewportSize({ width, height })
for (const colorScheme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme })
await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark')
@@ -128,15 +128,16 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
await warning.waitFor()
await expect.poll(async () => warning.evaluate((element) => {
const bounds = element.getBoundingClientRect()
return bounds.left >= 0 && bounds.top >= 0 && bounds.right <= innerWidth && bounds.bottom <= innerHeight
return bounds.left >= 12 && bounds.top >= 12 && bounds.right <= innerWidth - 12 && bounds.bottom <= innerHeight - 12
})).toBe(true)
expect(await warning.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
expect(await approval.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
if (webSnapshotMode() === 'refresh') {
const directory = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
await mkdir(directory, { recursive: true })
await page.screenshot({ path: join(directory, `cua-popover-${width}-${colorScheme}.png`), animations: 'disabled' })
await page.screenshot({ path: join(directory, `cua-popover-${width}-${height}-${colorScheme}.png`), animations: 'disabled' })
}
await approval.locator('p').click()
await page.mouse.click(4, height / 2)
await warning.waitFor({ state: 'hidden' })
}
}
+2 -2
View File
@@ -12,8 +12,8 @@
en: 8d6c7c6e63f849f6
zh: d89fa273e30b99d3
/deepseek-ai-dsh-client-ui-approval/table-of-contents:
en: 17557deda58d1946
zh: 5e4f62d88ef2a1eb
en: 4dba952a4e1f1d6b
zh: a33ee85e0b0cb212
/deepseek-ai-dsh-client-ui-approval/model-experience:
en: 8161dce49e9f899c
zh: a17401e1e78ba6be
+1 -1
View File
@@ -22,7 +22,7 @@ Focus the approval detail region to approve with Enter or reject with Escape. Th
When offered, **Always allow** is the highlighted primary button. Otherwise, the current-request allow action is primary. Enter in the detail region always grants the limited scope: once for a tool approval, or the current session for CUA. Persistent access requires explicitly activating Always allow. The shortcut reference uses Allow because the caller supplies that scope.
Callers can supply localized warning copy through the shared card's optional `warning` field, with paragraphs separated by blank lines (`\n\n`). Hovering or keyboard-focusing the tag opens a formatted nonmodal card; clicking or tapping also opens it. The pointer can cross into the card to read or select its text. Leaving both surfaces closes it after a short grace period unless keyboard focus remains inside. Moving focus outside, clicking outside, and Escape also dismiss it without rejecting the request. Hover does not move focus. Moving focus elsewhere also closes a hover-open card, including when a foreground modal takes focus. Switching away from the window or hiding the browser tab closes it without restoring focus. Foreground modals and input-method composition retain their keyboard handling.
Callers can supply localized warning copy through the shared card's optional `warning` field, with paragraphs separated by blank lines (`\n\n`). Hovering or keyboard-focusing the tag opens a formatted nonmodal card; clicking or tapping also opens it. The pointer can cross into the card to read or select its text. Leaving both surfaces closes it after a short grace period unless keyboard focus remains inside. Moving focus outside, clicking outside, and Escape also dismiss it without rejecting the request. Hover does not move focus. Moving focus elsewhere also closes a hover-open card, including when a foreground modal takes focus. Switching away from the window or hiding the browser tab closes it without restoring focus. Foreground modals and input-method composition retain their keyboard handling. CSS anchors position the card above the tag with window-edge fallbacks and internal scrolling when space is limited.
<a id="model-experience"></a>
## Model Experience
+1 -1
View File
@@ -22,7 +22,7 @@ kind: "package-reference"
提供“始终允许”时,将它显示为高亮主按钮;否则高亮当前请求的允许按钮。在详情区域按 Enter 始终授予有限范围的许可:工具审批为单次,CUA 为当前会话。持久授权需要显式操作“始终允许”按钮。快捷键说明使用“允许”,具体范围由调用方提供。
调用方可以通过共享卡片的可选 `warning` 字段提供本地化风险说明,段落以空行(`\n\n`)分隔。悬停或通过键盘聚焦标签会打开保留格式的非模态卡片;点击或轻触也可打开。指针可以移入卡片阅读或选择文字。离开标签与卡片后,卡片会在短暂宽限期后关闭,但键盘焦点仍在其中时保持打开。焦点移出、点击外部或按 Escape 也可关闭卡片,不会拒绝请求。悬停不会移动焦点。悬停打开后,焦点转移到其他位置也会关闭卡片,包括前景模态框取得焦点时。切换到其他窗口或隐藏浏览器标签页会关闭卡片,不会将焦点拉回。前景模态框和输入法组合保留各自的键盘处理。
调用方可以通过共享卡片的可选 `warning` 字段提供本地化风险说明,段落以空行(`\n\n`)分隔。悬停或通过键盘聚焦标签会打开保留格式的非模态卡片;点击或轻触也可打开。指针可以移入卡片阅读或选择文字。离开标签与卡片后,卡片会在短暂宽限期后关闭,但键盘焦点仍在其中时保持打开。焦点移出、点击外部或按 Escape 也可关闭卡片,不会拒绝请求。悬停不会移动焦点。悬停打开后,焦点转移到其他位置也会关闭卡片,包括前景模态框取得焦点时。切换到其他窗口或隐藏浏览器标签页会关闭卡片,不会将焦点拉回。前景模态框和输入法组合保留各自的键盘处理。 卡片通过 CSS 锚点优先显示在标签上方;空间不足时会调整位置以避开窗口边缘,并在卡片内部滚动。
<a id="model-experience"></a>
## 模型体验
@@ -74,16 +74,27 @@
}
.warningPopover {
--dsh-warning-window-top: max(12px, var(--dsh-frame-overlay-top, 12px));
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
border: 0;
box-shadow: var(--dsw-elevation-panel);
position: fixed;
position-area: none;
top: var(--dsh-warning-window-top);
bottom: max(12px, calc(anchor(top) + 8px));
left: 12px;
right: max(12px, anchor(right));
align-self: end;
justify-self: end;
margin: 0;
position-try-fallbacks: --dsh-warning-wide-above, --dsh-warning-below, --dsh-warning-wide-below, --dsh-warning-window;
z-index: 1100;
box-sizing: border-box;
width: 420px;
max-width: var(--dsh-anchored-max-width, calc(100vw - 24px));
max-height: var(--dsh-anchored-max-height, calc(100vh - 24px));
max-width: calc(100vw - 24px);
height: fit-content;
max-height: calc(100dvh - 12px - var(--dsh-warning-window-top));
overflow-y: auto;
padding: 24px;
color: var(--dsw-alias-label-secondary);
@@ -91,6 +102,33 @@
line-height: 20px;
}
@position-try --dsh-warning-wide-above {
right: 12px;
justify-self: safe end;
}
@position-try --dsh-warning-below {
top: max(var(--dsh-warning-window-top), calc(anchor(bottom) + 8px));
bottom: 12px;
align-self: start;
}
@position-try --dsh-warning-wide-below {
top: max(var(--dsh-warning-window-top), calc(anchor(bottom) + 8px));
bottom: 12px;
right: 12px;
align-self: start;
justify-self: safe end;
}
@position-try --dsh-warning-window {
top: var(--dsh-warning-window-top);
bottom: 12px;
right: 12px;
align-self: safe start;
justify-self: safe end;
}
.warningPopover p { margin: 16px 0 0; }
.warningPopover p + p:last-child {
@@ -1,15 +1,13 @@
/** Formatted hover notice that keeps the pending approval unanswered. */
import { useEffect, useId, useRef, useState, type CSSProperties, type FocusEvent, type PointerEvent } from 'react'
import { useEffect, useId, useRef, useState, type FocusEvent, type PointerEvent } from 'react'
import { createPortal } from 'react-dom'
import {
Button, IconWarningOutlineRegular, MenuSurface, Tag,
isBehindModal, observeComposition, useAnchoredPosition, useDismissOnOutsidePointer, usePointerGrace,
isBehindModal, observeComposition, useDismissOnOutsidePointer, usePointerGrace,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ApprovalCardInput } from './contract/slots.ts'
import css from './ApprovalPanel.module.css'
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/**
* Present caller-owned risks on hover, keyboard focus, or touch activation.
* @param props Localized warning content.
@@ -23,9 +21,7 @@ export function ApprovalWarning({ warning }: {
const panel = useRef<HTMLDivElement>(null)
const id = useId()
const titleId = useId()
const position = useAnchoredPosition({
open, anchorRef: anchor, panelRef: panel, side: 'top', align: 'end', gap: 8, margin: 12,
})
const anchorName = `--dsh-approval-warning-${id.replaceAll(':', '')}`
useDismissOnOutsidePointer(anchor, open, setOpen, panel)
const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => {
if (anchor.current?.matches(':focus-visible')
@@ -81,7 +77,7 @@ export function ApprovalWarning({ warning }: {
}
}, [open, cancelClose])
return <>
<Button ref={anchor} size="sm" className={css.warningTrigger} aria-haspopup="dialog"
<Button ref={anchor} size="sm" className={css.warningTrigger} style={{ anchorName }} aria-haspopup="dialog"
aria-expanded={open} aria-controls={open ? id : undefined} onClick={show}
onPointerEnter={enter} onPointerLeave={leave}
onFocus={(event) => { if (event.currentTarget.matches(':focus-visible')) show() }} onBlur={blur}>
@@ -91,7 +87,7 @@ export function ApprovalWarning({ warning }: {
</Button>
{open && createPortal(
<MenuSurface ref={panel} id={id} role="dialog" aria-labelledby={titleId} tabIndex={-1}
className={css.warningPopover} style={position ?? MEASURE_STYLE}
className={css.warningPopover} style={{ positionAnchor: anchorName }}
onPointerEnter={enter} onPointerLeave={leave} onFocus={cancelClose} onBlur={blur}>
<h3 id={titleId} className={css.warningTitle}>{warning.title}</h3>
{warning.detail.split('\n\n').map(paragraph => <p key={paragraph}>{paragraph}</p>)}
@@ -547,10 +547,6 @@ it.each(['allow', 'reject', 'always', 'keyboard'] as const)('settles a fixed app
})
it('opens and dismisses risk details without answering the approval', () => {
vi.stubGlobal('ResizeObserver', class {
observe() {}
disconnect() {}
})
const onAllow = vi.fn(async () => {}), onReject = vi.fn(async () => {})
const props = {
requestKey: 'risk-approval', title: 'Control this app?',
@@ -584,7 +580,6 @@ it('opens and dismisses risk details without answering the approval', () => {
it('keeps risk details readable across the pointer gap and after a touch tap', () => {
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true, addEventListener() {}, removeEventListener() {} })))
render(<ApprovalWarning warning={{ label: 'Risks', title: 'Computer use risks', detail: 'Background control.' }} />)
const trigger = screen.getByRole('button', { name: 'Risks' })
@@ -613,7 +608,6 @@ it('keeps risk details readable across the pointer gap and after a touch tap', (
})
it('leaves Escape to composition and dismisses risk details when a modal takes focus', () => {
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
const onClose = vi.fn()
const warning = <ApprovalWarning warning={{ label: 'Risks', title: 'Computer use risks', detail: 'Background control.' }} />
const view = render(warning)
@@ -640,7 +634,6 @@ it('leaves Escape to composition and dismisses risk details when a modal takes f
it('keeps risk details open for keyboard focus and closes when focus leaves', () => {
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
render(<>
<ApprovalWarning warning={{ label: 'Risks', title: 'Computer use risks', detail: 'Background control.' }} />
<button>Outside</button>
@@ -673,7 +666,6 @@ it('keeps risk details open for keyboard focus and closes when focus leaves', ()
it('dismisses pointer-focused risk details when the pointer leaves', () => {
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
render(<ApprovalWarning warning={{ label: 'Risks', title: 'Computer use risks', detail: 'Background control.' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Risks' }))
const dialog = screen.getByRole('dialog', { name: 'Computer use risks' })
@@ -685,7 +677,6 @@ it('dismisses pointer-focused risk details when the pointer leaves', () => {
})
it('ignores repeated Escape and restores focus when dismissing focused risk details', () => {
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
render(<ApprovalWarning warning={{ label: 'Risks', title: 'Computer use risks', detail: 'Background control.' }} />)
const trigger = screen.getByRole('button', { name: 'Risks' })
fireEvent.click(trigger)
@@ -700,7 +691,6 @@ it('ignores repeated Escape and restores focus when dismissing focused risk deta
})
it('dismisses hover-open risks when focus moves elsewhere without taking focus on open', () => {
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
render(<>
<button>Before</button>
<ApprovalWarning warning={{ label: 'Risks', title: 'Computer use risks', detail: 'Background control.' }} />
@@ -718,7 +708,6 @@ it('dismisses hover-open risks when focus moves elsewhere without taking focus o
})
it.each(['window blur', 'hidden tab'] as const)('dismisses risk details on %s without restoring focus', (reason) => {
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
render(<ApprovalWarning warning={{ label: 'Risks', title: 'Computer use risks', detail: 'Background control.' }} />)
const trigger = screen.getByRole('button', { name: 'Risks' })
fireEvent.click(trigger)