diff --git a/openspec/changes/improve-my-skills-management/design.md b/openspec/changes/improve-my-skills-management/design.md index 0109f59ae..7dbf8cd89 100644 --- a/openspec/changes/improve-my-skills-management/design.md +++ b/openspec/changes/improve-my-skills-management/design.md @@ -28,6 +28,8 @@ 技能标题进入实际详情路由。返回上下文包含已有 `returnTo` 和列表滚动位置;导航状态不得跨技能、跨筛选错误复用。比较版本使用现有版本查询及差异组件,在用户打开居中 Dialog 时加载,保留加载、错误、空数据和重试状态。不得以抽屉替代。 +确认框关闭后恢复原操作入口;查询结果异步移除原行且焦点落回页面主体时回退到列表搜索控件,用户已聚焦其他控件时不抢夺焦点。 + ## 兼容与风险 生命周期、当前范围与待审目标、推广关系沿用 `change-published-skill-visibility` 中的 REQ-VIS-05/06。退出命名空间后的所有者保持只读;前端菜单不是权限边界,服务端继续执行授权。重点回归菜单操作条件、点击冒泡、返回 URL/滚动、比较数据访问及移动端溢出。 diff --git a/openspec/changes/improve-my-skills-management/specs/my-skills-management/spec.md b/openspec/changes/improve-my-skills-management/specs/my-skills-management/spec.md index d931bf147..046854311 100644 --- a/openspec/changes/improve-my-skills-management/specs/my-skills-management/spec.md +++ b/openspec/changes/improve-my-skills-management/specs/my-skills-management/spec.md @@ -45,3 +45,9 @@ #### Scenario: 窄屏与键盘操作 - **WHEN** 用户在窄屏或使用键盘操作筛选、菜单和比较弹窗 - **THEN** 控件保持可触达、内容不横向溢出,弹窗关闭后焦点恢复 + + +#### Scenario: 操作后原焦点入口消失 +- **WHEN** 菜单操作完成,查询结果移除了原技能行及其操作入口 +- **THEN** 若焦点因入口消失落到页面主体,焦点返回列表搜索控件 +- **AND** 原行保留时恢复原入口;用户已主动聚焦其他控件时不抢夺焦点 diff --git a/web/src/pages/dashboard/my-skills-interactions.test.tsx b/web/src/pages/dashboard/my-skills-interactions.test.tsx index e26c546e8..c19004034 100644 --- a/web/src/pages/dashboard/my-skills-interactions.test.tsx +++ b/web/src/pages/dashboard/my-skills-interactions.test.tsx @@ -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() + await openMenu() + fireEvent.click(screen.getByRole('menuitem', { name: 'mySkills.archive' })) + const dialog = await screen.findByRole('dialog', { name: 'mySkills.archiveConfirmTitle' }) + showEmpty = true + view.rerender() + 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() + 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() + await waitFor(() => expect(document.activeElement).toBe( + result === 'retained' ? trigger : result === 'user-moved' ? otherControl : screen.getByRole('searchbox') + )) + expect(document.activeElement?.isConnected).toBe(true) + }) + }) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index c310af2b3..34ace90e3 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -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(null) + const searchInput = useRef(null) + const restoredTrigger = useRef(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() {