fix(webui): stabilize mobile focus restoration

This commit is contained in:
Estrella Pan
2026-07-15 15:20:58 +02:00
parent 9e4ccd88db
commit 36560ae78f
5 changed files with 129 additions and 17 deletions

View File

@@ -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);

View File

@@ -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<typeof useAppInfo>);
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();

View File

@@ -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();
});

View File

@@ -77,6 +77,7 @@ onUnmounted(closeMore);
<AbMobileMoreSheet
:show="moreOpen"
:return-focus-target="moreTriggerRef"
@update:show="closeMore"
@restore-trigger-focus="restoreMoreTriggerFocus"
/>

View File

@@ -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<boolean> {
function confirm(
options: ConfirmOptions,
focusOptions: ConfirmFocusOptions = {}
): Promise<boolean> {
return new Promise<boolean>((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 };