mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
fix(web): restore list focus after managed rows disappear
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
d841fca194
commit
2fad28f7cf
4 changed files with 65 additions and 1 deletions
|
|
@ -28,6 +28,8 @@
|
|||
|
||||
技能标题进入实际详情路由。返回上下文包含已有 `returnTo` 和列表滚动位置;导航状态不得跨技能、跨筛选错误复用。比较版本使用现有版本查询及差异组件,在用户打开居中 Dialog 时加载,保留加载、错误、空数据和重试状态。不得以抽屉替代。
|
||||
|
||||
确认框关闭后恢复原操作入口;查询结果异步移除原行且焦点落回页面主体时回退到列表搜索控件,用户已聚焦其他控件时不抢夺焦点。
|
||||
|
||||
## 兼容与风险
|
||||
|
||||
生命周期、当前范围与待审目标、推广关系沿用 `change-published-skill-visibility` 中的 REQ-VIS-05/06。退出命名空间后的所有者保持只读;前端菜单不是权限边界,服务端继续执行授权。重点回归菜单操作条件、点击冒泡、返回 URL/滚动、比较数据访问及移动端溢出。
|
||||
|
|
|
|||
|
|
@ -45,3 +45,9 @@
|
|||
#### Scenario: 窄屏与键盘操作
|
||||
- **WHEN** 用户在窄屏或使用键盘操作筛选、菜单和比较弹窗
|
||||
- **THEN** 控件保持可触达、内容不横向溢出,弹窗关闭后焦点恢复
|
||||
|
||||
|
||||
#### Scenario: 操作后原焦点入口消失
|
||||
- **WHEN** 菜单操作完成,查询结果移除了原技能行及其操作入口
|
||||
- **THEN** 若焦点因入口消失落到页面主体,焦点返回列表搜索控件
|
||||
- **AND** 原行保留时恢复原入口;用户已主动聚焦其他控件时不抢夺焦点
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue