mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 13:45:42 +08:00
fix(webui): stabilize mobile focus restoration
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -77,6 +77,7 @@ onUnmounted(closeMore);
|
||||
|
||||
<AbMobileMoreSheet
|
||||
:show="moreOpen"
|
||||
:return-focus-target="moreTriggerRef"
|
||||
@update:show="closeMore"
|
||||
@restore-trigger-focus="restoreMoreTriggerFocus"
|
||||
/>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user