diff --git a/webui/src/components/basic/__tests__/ab-confirm.test.ts b/webui/src/components/basic/__tests__/ab-confirm.test.ts index 29cb12e5..f84f4f22 100644 --- a/webui/src/components/basic/__tests__/ab-confirm.test.ts +++ b/webui/src/components/basic/__tests__/ab-confirm.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { defineComponent } from 'vue'; import { mount } from '@vue/test-utils'; import AbConfirmHost from '../ab-confirm-host.vue'; -import { useConfirm } from '@/hooks/useConfirm'; +import { useConfirm, useConfirmState } from '@/hooks/useConfirm'; async function tick() { await new Promise((resolve) => setTimeout(resolve)); @@ -66,6 +66,68 @@ describe('useConfirm + ab-confirm-host', () => { trigger.remove(); }); + it('should restore an explicit trigger after transition teardown finishes', async () => { + const trigger = document.createElement('button'); + const teardownTarget = document.createElement('button'); + document.body.append(trigger, teardownTarget); + teardownTarget.focus(); + const wrapper = mount(AbConfirmHost, { + attachTo: document.body, + global: { stubs: { AbModal: ModalStub } }, + }); + const { confirm } = useConfirm(); + const result = confirm({ title: 'Log out?' }, { returnFocus: trigger }); + await tick(); + + (document.querySelector('.ab-confirm-cancel') as HTMLButtonElement).click(); + await result; + wrapper.getComponent(ModalStub).vm.$emit('after-leave'); + teardownTarget.focus(); + await tick(); + + expect(document.activeElement === trigger).toBe(true); + wrapper.unmount(); + trigger.remove(); + teardownTarget.remove(); + }); + + it('should ignore a queued restoration from a stale focus cycle', async () => { + const firstTrigger = document.createElement('button'); + const secondTrigger = document.createElement('button'); + const teardownTarget = document.createElement('button'); + document.body.append(firstTrigger, secondTrigger, teardownTarget); + firstTrigger.focus(); + const firstTriggerFocus = vi.fn(); + firstTrigger.addEventListener('focus', firstTriggerFocus); + const { confirm } = useConfirm(); + const { settle, restoreFocus } = useConfirmState(); + + const first = confirm( + { title: 'First confirmation' }, + { returnFocus: firstTrigger } + ); + settle(false); + restoreFocus(); + secondTrigger.focus(); + const second = confirm( + { title: 'Second confirmation' }, + { returnFocus: secondTrigger } + ); + settle(false); + restoreFocus(); + teardownTarget.focus(); + await Promise.all([first, second]); + await tick(); + + expect({ + staleFocusCount: firstTriggerFocus.mock.calls.length, + activeElement: document.activeElement, + }).toEqual({ staleFocusCount: 0, activeElement: secondTrigger }); + firstTrigger.remove(); + secondTrigger.remove(); + teardownTarget.remove(); + }); + it('should preserve the original trigger when an open confirmation is replaced', async () => { const trigger = document.createElement('button'); document.body.append(trigger); diff --git a/webui/src/components/layout/__tests__/ab-mobile-more-sheet.test.ts b/webui/src/components/layout/__tests__/ab-mobile-more-sheet.test.ts index 40940b9d..6814ecea 100644 --- a/webui/src/components/layout/__tests__/ab-mobile-more-sheet.test.ts +++ b/webui/src/components/layout/__tests__/ab-mobile-more-sheet.test.ts @@ -54,14 +54,18 @@ const actions = { toggleDark: vi.fn(), }; -function mountSheet(running = false, statusKnown = true) { +function mountSheet( + running = false, + statusKnown = true, + returnFocusTarget?: HTMLElement +) { vi.mocked(useAppInfo).mockReturnValue({ running: ref(running), statusKnown: ref(statusKnown), } as ReturnType); return mount(AbMobileMoreSheet, { - props: { show: true }, + props: { show: true, returnFocusTarget }, global: { stubs: { RouterLink: RouterLinkStub, @@ -224,6 +228,19 @@ describe('ab-mobile-more-sheet', () => { expect(wrapper.emitted('restore-trigger-focus')).toEqual([[]]); }); + it('should pass the persistent trigger to a destructive confirmation', async () => { + const trigger = document.createElement('button'); + const wrapper = mountSheet(false, true, trigger); + + await wrapper.find('[data-action="logout"]').trigger('click'); + await finishSheetLeave(wrapper); + + expect(actions.confirm).toHaveBeenCalledWith( + expect.objectContaining({ title: 'mobile.confirm_logout' }), + { returnFocus: trigger } + ); + }); + it('should not open Add RSS before More has fully left', async () => { const wrapper = mountSheet(); diff --git a/webui/src/components/layout/ab-mobile-more-sheet.vue b/webui/src/components/layout/ab-mobile-more-sheet.vue index 2e62a587..9227d014 100644 --- a/webui/src/components/layout/ab-mobile-more-sheet.vue +++ b/webui/src/components/layout/ab-mobile-more-sheet.vue @@ -30,8 +30,9 @@ import { useDarkMode } from '@/hooks/useDarkMode'; import { useMyI18n } from '@/hooks/useMyI18n'; import { useProgramStore } from '@/store/program'; -defineProps<{ +const props = defineProps<{ show: boolean; + returnFocusTarget?: HTMLElement | null; }>(); const emit = defineEmits<{ @@ -73,8 +74,8 @@ function handleAfterLeave() { const action = pendingAction.value; pendingAction.value = null; if (action) { - // The next dialog captures the currently focused element as its return - // target. Restore the persistent trigger synchronously before it mounts. + // Restore the persistent trigger for queued dialogs that inspect the + // active element. Destructive confirms also receive it explicitly below. emit('restore-trigger-focus'); action(); } @@ -107,11 +108,14 @@ function confirmAction( danger = false ) { runAfterLeave(async () => { - const accepted = await confirm({ - title: t(titleKey), - body: t(bodyKey), - danger, - }); + const accepted = await confirm( + { + title: t(titleKey), + body: t(bodyKey), + danger, + }, + { returnFocus: props.returnFocusTarget } + ); if (!accepted) return; await action(); }); diff --git a/webui/src/components/layout/ab-mobile-nav.vue b/webui/src/components/layout/ab-mobile-nav.vue index 5fb3b23d..8e8825cd 100644 --- a/webui/src/components/layout/ab-mobile-nav.vue +++ b/webui/src/components/layout/ab-mobile-nav.vue @@ -77,6 +77,7 @@ onUnmounted(closeMore); diff --git a/webui/src/hooks/useConfirm.ts b/webui/src/hooks/useConfirm.ts index 06bf7f0d..8f3d8fd5 100644 --- a/webui/src/hooks/useConfirm.ts +++ b/webui/src/hooks/useConfirm.ts @@ -9,6 +9,11 @@ export interface ConfirmOptions { danger?: boolean; } +export interface ConfirmFocusOptions { + /** 对话框取消后恢复焦点的持久元素。 */ + returnFocus?: HTMLElement | null; +} + interface ConfirmRequest extends ConfirmOptions { resolve: (value: boolean) => void; } @@ -18,13 +23,16 @@ const state = reactive<{ current: ConfirmRequest | null }>({ current: null }); let returnFocusCycleActive = false; let cycleReturnFocus: HTMLElement | null = null; let restoreAfterLeave = false; +let focusCycleGeneration = 0; -function captureReturnFocus(): HTMLElement | null { +function captureReturnFocus( + explicitTarget?: HTMLElement | null +): HTMLElement | null { if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') { return null; } - const target = document.activeElement; + const target = explicitTarget ?? document.activeElement; return target instanceof HTMLElement && target !== document.body && target !== document.documentElement && @@ -38,13 +46,17 @@ function captureReturnFocus(): HTMLElement | null { * `if (await confirm({ title, danger: true })) { … }` */ export function useConfirm() { - function confirm(options: ConfirmOptions): Promise { + function confirm( + options: ConfirmOptions, + focusOptions: ConfirmFocusOptions = {} + ): Promise { return new Promise((resolve) => { // Replacements and dialogs reopened during a leave transition belong to // one focus cycle. Keep the first durable trigger for that whole cycle. if (!returnFocusCycleActive) { - cycleReturnFocus = captureReturnFocus(); + cycleReturnFocus = captureReturnFocus(focusOptions.returnFocus); returnFocusCycleActive = true; + focusCycleGeneration += 1; } // 已有未决确认时,先取消旧的,避免悬挂的 Promise state.current?.resolve(false); @@ -73,10 +85,26 @@ export function useConfirmState() { const target = cycleReturnFocus; const shouldRestore = restoreAfterLeave; + const generation = focusCycleGeneration; cycleReturnFocus = null; restoreAfterLeave = false; returnFocusCycleActive = false; - if (shouldRestore && target?.isConnected) target.focus(); + if (!shouldRestore || !target?.isConnected) return; + + // Headless UI may remove its focus trap after TransitionRoot emits + // after-leave. Restore in the next microtask so that teardown cannot + // override the persistent trigger, while rejecting stale focus cycles. + queueMicrotask(() => { + if ( + focusCycleGeneration !== generation || + returnFocusCycleActive || + state.current || + !target.isConnected + ) { + return; + } + target.focus(); + }); } return { state, settle, restoreFocus };