fix(web): restore list focus after managed rows disappear

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-11-04 10:05:23 +08:00
parent d841fca194
commit 2fad28f7cf
4 changed files with 65 additions and 1 deletions

View file

@ -28,6 +28,8 @@
技能标题进入实际详情路由。返回上下文包含已有 `returnTo` 和列表滚动位置;导航状态不得跨技能、跨筛选错误复用。比较版本使用现有版本查询及差异组件,在用户打开居中 Dialog 时加载,保留加载、错误、空数据和重试状态。不得以抽屉替代。
确认框关闭后恢复原操作入口;查询结果异步移除原行且焦点落回页面主体时回退到列表搜索控件,用户已聚焦其他控件时不抢夺焦点。
## 兼容与风险
生命周期、当前范围与待审目标、推广关系沿用 `change-published-skill-visibility` 中的 REQ-VIS-05/06。退出命名空间后的所有者保持只读;前端菜单不是权限边界,服务端继续执行授权。重点回归菜单操作条件、点击冒泡、返回 URL/滚动、比较数据访问及移动端溢出。

View file

@ -45,3 +45,9 @@
#### Scenario: 窄屏与键盘操作
- **WHEN** 用户在窄屏或使用键盘操作筛选、菜单和比较弹窗
- **THEN** 控件保持可触达、内容不横向溢出,弹窗关闭后焦点恢复
#### Scenario: 操作后原焦点入口消失
- **WHEN** 菜单操作完成,查询结果移除了原技能行及其操作入口
- **THEN** 若焦点因入口消失落到页面主体,焦点返回列表搜索控件
- **AND** 原行保留时恢复原入口;用户已主动聚焦其他控件时不抢夺焦点

View file

@ -249,4 +249,37 @@ describe('My Skills interactions with real menu and dialog', () => {
}
})
it('returns focus to search when query results remove the row before confirmation closes', async () => {
const view = render(<MySkillsPage />)
await openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'mySkills.archive' }))
const dialog = await screen.findByRole('dialog', { name: 'mySkills.archiveConfirmTitle' })
showEmpty = true
view.rerender(<MySkillsPage />)
fireEvent.keyDown(dialog, { key: 'Escape' })
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('searchbox')))
})
it.each(['retained', 'removed', 'user-moved'] as const)('keeps focus deliberate after successful archive with row %s', async (result) => {
const view = render(<MySkillsPage />)
const trigger = await openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: 'mySkills.archive' }))
await screen.findByRole('dialog', { name: 'mySkills.archiveConfirmTitle' })
fireEvent.click(screen.getByRole('button', { name: 'mySkills.archive' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
await waitFor(() => expect(document.activeElement).toBe(trigger))
const otherControl = screen.getByRole('button', { name: 'nav.search' })
if (result === 'user-moved') otherControl.focus()
// Change the hook's query-result fixture after the close, then rerender the page.
// React removes the source row; no DOM node is manually deleted.
if (result !== 'retained') showEmpty = true
view.rerender(<MySkillsPage />)
await waitFor(() => expect(document.activeElement).toBe(
result === 'retained' ? trigger : result === 'user-moved' ? otherControl : screen.getByRole('searchbox')
))
expect(document.activeElement?.isConnected).toBe(true)
})
})

View file

@ -70,6 +70,8 @@ export function MySkillsPage() {
const [compareTarget, setCompareTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
const [compareVersions, setCompareVersions] = useState({ from: '', to: '' })
const compareTrigger = useRef<HTMLButtonElement | null>(null)
const searchInput = useRef<HTMLInputElement | null>(null)
const restoredTrigger = useRef<HTMLButtonElement | null>(null)
const [visibilityTarget, setVisibilityTarget] = useState<{ namespace: string; slug: string; visibility: string } | null>(null)
const scrollKey = JSON.stringify([user?.userId, keyword, namespaceFilter, filter, page])
const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null)
@ -124,9 +126,29 @@ export function MySkillsPage() {
return () => cancelAnimationFrame(frame)
}, [isLoading, scrollKey])
// Query invalidation can remove the row after its confirmation has already closed.
useEffect(() => {
const trigger = restoredTrigger.current
if (!trigger) return
const active = document.activeElement
if (!trigger.isConnected) {
if (active === document.body) searchInput.current?.focus()
restoredTrigger.current = null
} else if (active !== trigger && active !== document.body) {
restoredTrigger.current = null
}
}, [skillPage?.items])
const restoreMoreFocus = (event: Event) => {
event.preventDefault()
compareTrigger.current?.focus()
const trigger = compareTrigger.current
if (trigger?.isConnected) {
restoredTrigger.current = trigger
trigger.focus()
} else {
restoredTrigger.current = null
searchInput.current?.focus()
}
}
const handleSkillClick = (namespace: string, slug: string, version?: string) => {
@ -340,6 +362,7 @@ export function MySkillsPage() {
<div className="relative min-w-0 flex-1">
<Search aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInput}
type="search"
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}