fix(web): manage Team panel keyboard focus
This commit is contained in:
1 parent
59b5a4ac0f
commit
35489c0f5c
6 files changed
+59
-9
No files matched your search
@@ -135,12 +135,30 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
await page.mouse.click(2, 2)
|
||||
await panel.waitFor({ state: 'detached' })
|
||||
await page.setViewportSize(viewport)
|
||||
await trigger.click()
|
||||
await trigger.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor()
|
||||
await panel.getByRole('button', { name: 'Refresh Team' }).focus()
|
||||
expect(await panel.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await panel.getByRole('button', { name: 'Refresh Team' })
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await panel.getByRole('button', { name: 'Close', exact: true })
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await panel.waitFor({ state: 'detached' })
|
||||
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.waitFor()
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.waitFor({ state: 'detached' })
|
||||
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.waitFor()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await panel.waitFor({ state: 'detached' })
|
||||
} finally {
|
||||
await page.setViewportSize(viewport)
|
||||
}
|
||||
|
||||
@@ -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/experimental/client-ui-agent-team/README.md
|
||||
README.md: 24eaa13a248980fcf683403e54fd6b138fdfcf27
|
||||
README.zh.md: 51ed00b31791e33d8cdbc57c2aa5491c127113e7
|
||||
README.md: 253c243a680a989988ea65e553c14eff9a699155
|
||||
README.zh.md: 143c855b8b8a45a7d3777700aefa6a378401f20d
|
||||
@@ -47,7 +47,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso
|
||||
|
||||
The Client export mounts the generated `ctx.remote.agentTeams` contribution from [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.md), then registers its locale dictionaries and one conversation-header slot through Cordis effects. Disposing the plugin fiber removes both registrations.
|
||||
|
||||
The panel renders outside the conversation container and stays within the viewport. Clicking outside or pressing Escape closes it. Opening or refreshing the panel reads the complete Team view. Overlapping refreshes keep the newest response, and responses for a previous conversation are ignored.
|
||||
The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. Opening or refreshing the panel reads the complete Team view. Overlapping refreshes keep the newest response, and responses for a previous conversation are ignored.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
|
||||
@@ -47,7 +47,7 @@ kind: "package-reference"
|
||||
|
||||
Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.zh.md) 的生成的 `ctx.remote.agentTeams` contribution,然后通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot。Dispose plugin fiber 会移除这两项 registration。
|
||||
|
||||
面板渲染在会话容器外,并保持在视口范围内。点击外部或按 Escape 会关闭面板。打开或刷新面板会读取完整 Team view。并行刷新只保留最新响应,属于上一个会话的响应会被忽略。
|
||||
面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。打开或刷新面板会读取完整 Team view。并行刷新只保留最新响应,属于上一个会话的响应会被忽略。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
|
||||
@@ -85,6 +85,7 @@ export function TeamAction({
|
||||
const position = useAnchoredPosition({
|
||||
open, anchorRef: triggerRef, panelRef, gap: 5, margin: 16,
|
||||
})
|
||||
const positioned = position !== null
|
||||
useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef)
|
||||
const sessionRef = useRef(sessionId)
|
||||
const refreshGeneration = useRef(0)
|
||||
@@ -98,6 +99,15 @@ export function TeamAction({
|
||||
setError(null)
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && positioned) panelRef.current?.focus()
|
||||
}, [open, positioned])
|
||||
|
||||
const close = (): void => {
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
const requestedSession = sessionId
|
||||
const generation = ++refreshGeneration.current
|
||||
@@ -119,8 +129,12 @@ export function TeamAction({
|
||||
<div ref={rootRef} className={css.root} data-team-action onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape' || !open) return
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
close()
|
||||
}} onBlur={(event) => {
|
||||
const target = event.relatedTarget
|
||||
if (target instanceof Node && !event.currentTarget.contains(target) && !panelRef.current?.contains(target)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -144,6 +158,7 @@ export function TeamAction({
|
||||
className={css.panel}
|
||||
style={position ?? { visibility: 'hidden', left: 0, top: 0 }}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
aria-label={t('trigger')}
|
||||
data-team-panel
|
||||
>
|
||||
@@ -156,7 +171,7 @@ export function TeamAction({
|
||||
<button type="button" className={css.iconButton} aria-label={t('refresh')} onClick={() => { void refresh() }}>
|
||||
<IconRefreshOutlineRegular size={14} />
|
||||
</button>
|
||||
<button type="button" className={css.iconButton} aria-label={t('close')} onClick={() => { setOpen(false) }}>
|
||||
<button type="button" className={css.iconButton} aria-label={t('close')} onClick={close}>
|
||||
<IconCloseOutlineRegular size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -186,6 +186,7 @@ describe('TeamAction', () => {
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.close }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: /Agent Team/u }))
|
||||
})
|
||||
|
||||
it('shows load failures', async () => {
|
||||
@@ -237,6 +238,7 @@ describe('TeamAction', () => {
|
||||
fireEvent.click(trigger)
|
||||
const panel = await screen.findByRole('dialog')
|
||||
expect(rendered.container.contains(panel)).toBe(false)
|
||||
expect(document.activeElement).toBe(panel)
|
||||
fireEvent.pointerDown(panel)
|
||||
expect(screen.getByRole('dialog')).toBe(panel)
|
||||
fireEvent.pointerDown(trigger)
|
||||
@@ -254,4 +256,19 @@ describe('TeamAction', () => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps focus within the trigger or panel and closes when focus moves elsewhere', async () => {
|
||||
render(<><TeamAction {...props(actions())} /><button>Outside</button></>)
|
||||
const trigger = screen.getByRole('button', { name: /Agent Team/u })
|
||||
fireEvent.click(trigger)
|
||||
const panel = await screen.findByRole('dialog')
|
||||
fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: zh.refresh }) })
|
||||
expect(screen.getByRole('dialog')).toBe(panel)
|
||||
fireEvent.blur(panel, { relatedTarget: trigger })
|
||||
expect(screen.getByRole('dialog')).toBe(panel)
|
||||
fireEvent.blur(panel, { relatedTarget: null })
|
||||
expect(screen.getByRole('dialog')).toBe(panel)
|
||||
fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: 'Outside' }) })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
})
|
||||
Reference in new issue
Block a user