mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: keep keys pressed in the external recipient confirm away from the mail shortcuts
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { render, act, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ExternalRecipientConfirmHost } from '../external-recipient-confirm';
|
||||
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
|
||||
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
|
||||
|
||||
// The confirm dialog keeps its keys: the mail view's shortcuts listen on the
|
||||
// window, and must not see Escape (close the message), j/k (move the list)
|
||||
// or Enter (open a message) while the question is open. Real focus trap.
|
||||
|
||||
const RECIPIENTS = [{ address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' as const }];
|
||||
const pageKeys = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
|
||||
window.addEventListener('keydown', pageKeys);
|
||||
});
|
||||
afterEach(() => {
|
||||
window.removeEventListener('keydown', pageKeys);
|
||||
pageKeys.mockReset();
|
||||
cleanup();
|
||||
resetSendPipeline();
|
||||
});
|
||||
|
||||
function open() {
|
||||
const view = render(<ExternalRecipientConfirmHost />);
|
||||
let answer!: Promise<boolean>;
|
||||
act(() => { answer = useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
|
||||
const back = view.container.querySelector('[data-external-recipient-back]') as HTMLButtonElement;
|
||||
const send = view.container.querySelector('[data-external-recipient-send]') as HTMLButtonElement;
|
||||
return { answer, back, send };
|
||||
}
|
||||
|
||||
describe('keys in the external recipient confirm', () => {
|
||||
it('puts the focus on Back and keeps shortcut keys from the page', () => {
|
||||
const { back } = open();
|
||||
expect(document.activeElement).toBe(back);
|
||||
for (const key of ['j', 'k', 'Enter', 'Escape']) {
|
||||
act(() => { fireEvent.keyDown(document.activeElement!, { key }); });
|
||||
// Re-open after Escape and Enter answered it.
|
||||
if (!useExternalRecipientConfirmStore.getState().pending) act(() => { void useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
|
||||
}
|
||||
expect(pageKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers Back on Escape', async () => {
|
||||
const { answer } = open();
|
||||
act(() => { fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); });
|
||||
expect(await answer).toBe(false);
|
||||
});
|
||||
|
||||
it('presses the focused button on Enter and on Space', async () => {
|
||||
const first = open();
|
||||
first.send.focus();
|
||||
act(() => { fireEvent.keyDown(first.send, { key: 'Enter' }); });
|
||||
expect(await first.answer).toBe(true);
|
||||
|
||||
let second!: Promise<boolean>;
|
||||
act(() => { second = useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
|
||||
act(() => { fireEvent.keyDown(document.activeElement!, { key: ' ' }); });
|
||||
// The focus went back to the first button, Back.
|
||||
expect(await second).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId } from 'react';
|
||||
import { useEffect, useId, type KeyboardEvent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Building } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -25,7 +25,24 @@ export function ExternalRecipientConfirmHost() {
|
||||
// A send can't be waiting on a dialog that is gone.
|
||||
useEffect(() => () => useExternalRecipientConfirmStore.getState().cancelAll(), []);
|
||||
|
||||
const dialogRef = useFocusTrap({ isActive: !!pending, onEscape: () => answer(false), restoreFocus: true });
|
||||
// Escape is handled below with the other keys, not by the trap: one key, one answer.
|
||||
const dialogRef = useFocusTrap({ isActive: !!pending, restoreFocus: true });
|
||||
|
||||
// Keys stay in the dialog: Escape must not also close the message behind
|
||||
// it, j/k must not move the list, Enter must not open a message. Enter and
|
||||
// Space press the focused button here, whatever a page listener did.
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
answer(false);
|
||||
return;
|
||||
}
|
||||
if ((e.key === 'Enter' || e.key === ' ') && e.target instanceof HTMLButtonElement) {
|
||||
e.preventDefault();
|
||||
e.target.click();
|
||||
}
|
||||
};
|
||||
|
||||
if (!pending) return null;
|
||||
const { recipients } = pending;
|
||||
@@ -37,6 +54,7 @@ export function ExternalRecipientConfirmHost() {
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) answer(false);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
|
||||
Reference in New Issue
Block a user