fix(web): preserve list navigation and menu dialog focus

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-11-04 09:50:15 +08:00
parent efaa70e67c
commit d841fca194
4 changed files with 87 additions and 23 deletions

View file

@ -5,9 +5,9 @@
## 2. 实现
- [x] 统一筛选面板及真实结果数量
- [ ] 独立卡片、主要操作与更多菜单
- [ ] 详情返回上下文与居中版本比较
- [ ] 中英文、窄屏与键盘行为
- [x] 独立卡片、主要操作与更多菜单
- [x] 详情返回上下文与居中版本比较
- [x] 中英文、窄屏与键盘行为
## 3. 验证与交付
- [x] 筛选首片针对性测试与静态检查

View file

@ -1,7 +1,7 @@
/** @vitest-environment jsdom */
import { StrictMode } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const navigate = vi.fn()
const copy = vi.fn()
@ -9,7 +9,9 @@ const retryVersions = vi.fn()
const retryCompare = vi.fn()
const useVersions = vi.fn()
const useCompare = vi.fn()
let skill = makeSkill()
vi.hoisted(() => { vi.stubEnv('BASE_URL', '/skillhub/') })
let skill: ReturnType<typeof makeSkill> & { ownerPreviewVersion?: { id: number; version: string; status: string } } = makeSkill()
let showEmpty = false
let superAdmin = false
let search: { filter?: string; q?: string; namespace?: string; page?: number } = {}
@ -24,7 +26,7 @@ function makeSkill() {
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => navigate,
useLocation: () => ({ pathname: '/dashboard/skills', searchStr: '?page=2' }),
useLocation: () => ({ pathname: '/dashboard/skills', searchStr: `?${new URLSearchParams(Object.entries(search).map(([key, value]) => [key, String(value)]))}` }),
useSearch: () => search,
}))
vi.mock('react-i18next', async () => ({
@ -33,7 +35,7 @@ vi.mock('react-i18next', async () => ({
}))
vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ user: { userId: 'reader' }, hasRole: () => superAdmin }) }))
vi.mock('@/shared/hooks/use-user-queries', () => ({
useMySkills: () => ({ data: { items: [skill], total: 1, size: 10 }, isLoading: false }),
useMySkills: () => ({ data: { items: showEmpty ? [] : [skill], total: showEmpty ? 0 : 1, size: 10 }, isLoading: false }),
useSubmitPromotion: () => ({ mutateAsync: vi.fn() }),
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useMyNamespaces: () => ({ data: [] }) }))
@ -62,6 +64,7 @@ async function openMenu() {
beforeEach(() => {
skill = makeSkill()
showEmpty = false
search = {}
superAdmin = false
navigate.mockReset()
@ -80,6 +83,7 @@ beforeEach(() => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
})
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
afterAll(() => vi.unstubAllEnvs())
describe('My Skills interactions with real menu and dialog', () => {
it('loads comparison on demand and returns focus after Escape without navigating', async () => {
@ -140,7 +144,7 @@ describe('My Skills interactions with real menu and dialog', () => {
search = { filter: 'PUBLISHED', page: 2 }
const first = render(<MySkillsPage />)
fireEvent.click(screen.getByRole('link', { name: 'Demo' }))
expect(navigate.mock.calls[0][0].search.returnTo).toBe('/dashboard/skills?page=2')
expect(navigate.mock.calls[0][0].search.returnTo).toBe('/dashboard/skills?filter=PUBLISHED&page=2')
first.unmount()
render(<StrictMode><MySkillsPage /></StrictMode>)
await waitFor(() => expect(window.scrollTo).toHaveBeenCalledWith({ top: 450, behavior: 'instant' }))
@ -189,4 +193,60 @@ describe('My Skills interactions with real menu and dialog', () => {
await screen.findByText('skillCompare.loading')
})
it.each(['ordinary', 'ctrlKey', 'metaKey'] as const)('retains deployment prefix and return context when opening with %s', (modifier) => {
search = { filter: 'PUBLISHED', q: 'a skill', namespace: 'team', page: 2 }
skill.ownerPreviewVersion = { id: 12, version: '1.2.0', status: 'PENDING_REVIEW' }
render(<MySkillsPage />)
const link = screen.getByRole('link', { name: 'Demo' })
const href = new URL(link.getAttribute('href') ?? '', 'https://example.test')
expect(href.pathname).toBe('/skillhub/space/team/demo')
expect(href.searchParams.get('returnTo')).toBe('/dashboard/skills?filter=PUBLISHED&q=a+skill&namespace=team&page=2')
expect(href.searchParams.get('version')).toBe('1.2.0')
// Prevent jsdom's unsupported browser navigation after checking the native href.
link.addEventListener('click', event => event.preventDefault(), { once: true })
fireEvent.click(link, modifier === 'ordinary' ? {} : { [modifier]: true })
if (modifier === 'ordinary') {
expect(navigate).toHaveBeenCalledWith({
to: '/space/team/demo',
search: { returnTo: href.searchParams.get('returnTo'), version: '1.2.0' },
})
} else expect(navigate).not.toHaveBeenCalled()
})
it('clears all four URL conditions from the empty-state action', () => {
search = { filter: 'PUBLISHED', q: 'missing', namespace: 'team', page: 2 }
showEmpty = true
render(<MySkillsPage />)
const emptyStateClear = screen.getAllByRole('button', { name: 'mySkills.clearSearch' }).at(-1)
if (!emptyStateClear) throw new Error('Missing empty-state clear action')
fireEvent.click(emptyStateClear)
expect(navigate).toHaveBeenCalledOnce()
expect(navigate.mock.calls[0][0].search(search)).toEqual({ filter: undefined, q: undefined, namespace: undefined, page: 0 })
})
it.each([
['archive', 'archiveConfirmTitle'],
['unarchive', 'unarchiveConfirmTitle'],
['withdrawReview', 'withdrawConfirmTitle'],
['promoteToGlobal', 'promotionConfirmTitle'],
['restoreHidden', 'restoreHiddenConfirmTitle'],
])('restores the more trigger after Escape and Cancel on %s', async (action, title) => {
if (action === 'unarchive') skill.status = 'ARCHIVED'
if (action === 'withdrawReview') skill.ownerPreviewVersion = { id: 12, version: '1.2.0', status: 'PENDING_REVIEW' }
if (action === 'promoteToGlobal') skill.canSubmitPromotion = true
if (action === 'restoreHidden') { search = { filter: 'HIDDEN' }; superAdmin = true }
render(<MySkillsPage />)
for (const closeViaEscape of [true, false]) {
const trigger = await openMenu()
fireEvent.click(screen.getByRole('menuitem', { name: `mySkills.${action}` }))
const dialog = await screen.findByRole('dialog', { name: `mySkills.${title}` })
if (closeViaEscape) fireEvent.keyDown(dialog, { key: 'Escape' })
else fireEvent.click(screen.getByRole('button', { name: 'dialog.cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
await waitFor(() => expect(document.activeElement).toBe(trigger))
expect(navigate).not.toHaveBeenCalled()
}
})
})

View file

@ -23,6 +23,7 @@ import { useRestoreHiddenSkill } from '@/features/admin/use-admin-skills'
import { getHeadlineVersion, getPublishedVersion, getOwnerPreviewVersion, hasPendingOwnerPreview } from '@/shared/lib/skill-lifecycle'
import { formatCompactCount } from '@/shared/lib/number-format'
import { toast } from '@/shared/lib/toast'
import { withBasePath } from '@/shared/lib/base-path'
import { buildReturnTo } from '@/shared/lib/auth-route'
import { SkillVisibilityDialog } from '@/features/skill/skill-visibility-dialog'
import { SkillAvatar } from '@/features/skill/skill-avatar'
@ -123,6 +124,11 @@ export function MySkillsPage() {
return () => cancelAnimationFrame(frame)
}, [isLoading, scrollKey])
const restoreMoreFocus = (event: Event) => {
event.preventDefault()
compareTrigger.current?.focus()
}
const handleSkillClick = (namespace: string, slug: string, version?: string) => {
rememberMySkillsScroll(scrollKey, window.scrollY)
navigate({
@ -131,11 +137,6 @@ export function MySkillsPage() {
})
}
const handleClearSearch = () => {
setKeywordInput('')
updateSearch({ q: undefined, namespace: undefined, page: 0 })
}
const handleClearFilters = () => {
setKeywordInput('')
updateSearch({ q: undefined, namespace: undefined, filter: undefined, page: 0 })
@ -435,7 +436,7 @@ export function MySkillsPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<h3 className="min-w-0 break-words text-lg font-semibold [overflow-wrap:anywhere]">
{hidden ? displayName : <a href={`/space/${skill.namespace}/${encodeURIComponent(skill.slug)}?${detailSearch}`}
{hidden ? displayName : <a href={withBasePath(`/space/${skill.namespace}/${encodeURIComponent(skill.slug)}?${detailSearch}`)}
className="rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={event => {
event.stopPropagation()
@ -466,7 +467,7 @@ export function MySkillsPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 p-1.5" onClick={event => event.stopPropagation()}
onCloseAutoFocus={event => { if (compareTarget || visibilityTarget) event.preventDefault() }}>
onCloseAutoFocus={event => { if (compareTarget || visibilityTarget || archiveTarget || unarchiveTarget || withdrawTarget || promotionTarget || restoreHiddenTarget) event.preventDefault() }}>
{!hidden && <DropdownMenuItem onSelect={openDetail}><Eye aria-hidden="true" className="mr-2 h-4 w-4" />{t('mySkills.viewDetail')}</DropdownMenuItem>}
<DropdownMenuItem onSelect={async () => {
try { await copyToClipboard(coordinate); toast.success(t('mySkills.coordinateCopied')) }
@ -531,7 +532,7 @@ export function MySkillsPage() {
description={hasActiveSearch ? t('mySkills.emptySearchDescription') : t(emptyStateKey.description)}
action={
hasActiveSearch ? (
<Button data-analytics-id="skill.owned.clear" size="lg" variant="outline" onClick={handleClearSearch}>
<Button data-analytics-id="skill.owned.clear" size="lg" variant="outline" onClick={handleClearFilters}>
{t('mySkills.clearSearch')}
</Button>
) : undefined
@ -540,10 +541,10 @@ export function MySkillsPage() {
)}
{visibilityTarget && <SkillVisibilityDialog {...visibilityTarget} open hideTrigger onOpenChange={open => { if (!open) setVisibilityTarget(null) }}
onCloseAutoFocus={event => { event.preventDefault(); compareTrigger.current?.focus() }} />}
onCloseAutoFocus={restoreMoreFocus} />}
<Dialog open={!!compareTarget} onOpenChange={open => { if (!open) setCompareTarget(null) }}>
<DialogContent className="w-[calc(100vw-2rem)] max-w-6xl max-h-[85vh] gap-3 p-4 sm:p-6"
onCloseAutoFocus={event => { event.preventDefault(); compareTrigger.current?.focus() }}>
onCloseAutoFocus={restoreMoreFocus}>
<DialogHeader className="pr-8">
<DialogTitle>{t('mySkills.compareVersions')}</DialogTitle>
<DialogDescription className="break-all">{compareTarget?.name}</DialogDescription>
@ -553,7 +554,7 @@ export function MySkillsPage() {
</DialogContent>
</Dialog>
<ConfirmDialog confirmAnalyticsId="skill.restore.confirm" cancelAnalyticsId="skill.restore.cancel"
<ConfirmDialog onCloseAutoFocus={restoreMoreFocus} confirmAnalyticsId="skill.restore.confirm" cancelAnalyticsId="skill.restore.cancel"
open={!!restoreHiddenTarget}
onOpenChange={(open) => {
if (!open) {
@ -567,6 +568,7 @@ export function MySkillsPage() {
/>
<ConfirmDialog
onCloseAutoFocus={restoreMoreFocus}
confirmAnalyticsId={skillPage?.items.find((skill) => skill.id === promotionTarget?.skillId)?.managementState?.promotionRole === 'TEAM_SOURCE' ? 'skill.promotion.update.confirm' : 'skill.promotion.create.confirm'}
cancelAnalyticsId={skillPage?.items.find((skill) => skill.id === promotionTarget?.skillId)?.managementState?.promotionRole === 'TEAM_SOURCE' ? 'skill.promotion.update.cancel' : 'skill.promotion.create.cancel'}
open={!!promotionTarget}
@ -581,7 +583,7 @@ export function MySkillsPage() {
onConfirm={handleSubmitPromotion}
/>
<ConfirmDialog confirmAnalyticsId="skill.archive.confirm" cancelAnalyticsId="skill.archive.cancel"
<ConfirmDialog onCloseAutoFocus={restoreMoreFocus} confirmAnalyticsId="skill.archive.confirm" cancelAnalyticsId="skill.archive.cancel"
open={!!archiveTarget}
onOpenChange={(open) => {
if (!open) {
@ -594,7 +596,7 @@ export function MySkillsPage() {
onConfirm={handleArchiveSkill}
/>
<ConfirmDialog confirmAnalyticsId="skill.unarchive.confirm" cancelAnalyticsId="skill.unarchive.cancel"
<ConfirmDialog onCloseAutoFocus={restoreMoreFocus} confirmAnalyticsId="skill.unarchive.confirm" cancelAnalyticsId="skill.unarchive.cancel"
open={!!unarchiveTarget}
onOpenChange={(open) => {
if (!open) {
@ -607,7 +609,7 @@ export function MySkillsPage() {
onConfirm={handleUnarchiveSkill}
/>
<ConfirmDialog confirmAnalyticsId="skill.review.withdraw.confirm" cancelAnalyticsId="skill.review.withdraw.cancel"
<ConfirmDialog onCloseAutoFocus={restoreMoreFocus} confirmAnalyticsId="skill.review.withdraw.confirm" cancelAnalyticsId="skill.review.withdraw.cancel"
open={!!withdrawTarget}
onOpenChange={(open) => {
if (!open) {

View file

@ -24,6 +24,7 @@ interface ConfirmDialogProps {
contentTestId?: string
confirmButtonTestId?: string
closeOnConfirm?: boolean
onCloseAutoFocus?: (event: Event) => void
}
export function ConfirmDialog({
@ -40,6 +41,7 @@ export function ConfirmDialog({
confirmAnalyticsId,
cancelAnalyticsId,
closeOnConfirm = true,
onCloseAutoFocus,
}: ConfirmDialogProps) {
const { t } = useTranslation()
const openRef = useRef(open)
@ -65,7 +67,7 @@ export function ConfirmDialog({
return (
<Dialog open={open} onOpenChange={(next) => { if (!pendingRef.current) onOpenChange(next) }}>
<DialogContent closeAnalyticsId={cancelAnalyticsId} data-testid={contentTestId} aria-label={title} hideClose={pending}>
<DialogContent onCloseAutoFocus={onCloseAutoFocus} closeAnalyticsId={cancelAnalyticsId} data-testid={contentTestId} aria-label={title} hideClose={pending}>
<DialogHeader className="min-w-0 text-center sm:text-center">
<DialogTitle className="text-center">{title}</DialogTitle>
{description && <DialogDescription className="text-center break-all">{description}</DialogDescription>}