mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: keep the keyboard focus inside plugin dialogs and return it when they close
This commit is contained in:
@@ -78,6 +78,25 @@ describe('connect dialog', () => {
|
||||
await expect(result).resolves.toEqual({ connected: false });
|
||||
});
|
||||
|
||||
// A modal: the first field takes the focus, Tab stays inside, and the
|
||||
// focus goes back to what opened it.
|
||||
it('holds the focus while open and gives it back after', async () => {
|
||||
const opener = document.createElement('button');
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
render(<PluginConnectDialog />);
|
||||
const result = open();
|
||||
const server = screen.getByText('server').parentElement!.querySelector('input')!;
|
||||
expect(server).toHaveFocus();
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.keyDown(server, { key: 'Tab', shiftKey: true });
|
||||
expect(dialog).toContainElement(document.activeElement as HTMLElement);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'cancel' }));
|
||||
await expect(result).resolves.toEqual({ connected: false });
|
||||
await waitFor(() => expect(opener).toHaveFocus());
|
||||
opener.remove();
|
||||
});
|
||||
|
||||
it('shows the server error and stays open', async () => {
|
||||
apiFetch.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'The server address must start with https://' }), { status: 400 }));
|
||||
render(<PluginConnectDialog />);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// A plugin's dialog is a real modal for the keyboard: the focus moves in to
|
||||
// the safe choice, Tab can't leave it, and the focus comes back to where it
|
||||
// was once the dialog is answered.
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('../plugin-iframe-slot', () => ({ PluginIframeSlot: () => <div data-testid="custom-frame" /> }));
|
||||
|
||||
import { PluginDialogHost } from '../plugin-dialog-host';
|
||||
import { awaitDialog, awaitPrompt, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
|
||||
|
||||
let opener: HTMLButtonElement;
|
||||
beforeEach(() => {
|
||||
while (head()) resolveHead(null);
|
||||
resetDialogLimits();
|
||||
opener = document.createElement('button');
|
||||
opener.textContent = 'opener';
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
});
|
||||
afterEach(() => opener.remove());
|
||||
|
||||
describe('plugin dialog focus', () => {
|
||||
it('starts on Cancel for a destructive confirm, keeps Tab inside, and returns the focus', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let answer: Promise<boolean>;
|
||||
act(() => { answer = awaitDialog({ pluginId: 'p', kind: 'confirm', title: 'Delete it?', message: '', danger: true }); });
|
||||
|
||||
const cancel = screen.getByRole('button', { name: 'cancel' });
|
||||
expect(cancel).toHaveFocus();
|
||||
const dialog = screen.getByRole('dialog', { name: 'Delete it?' });
|
||||
fireEvent.keyDown(cancel, { key: 'Tab', shiftKey: true });
|
||||
expect(dialog).toContainElement(document.activeElement as HTMLElement);
|
||||
|
||||
await act(async () => { fireEvent.click(cancel); });
|
||||
await expect(answer!).resolves.toBe(false);
|
||||
await waitFor(() => expect(opener).toHaveFocus());
|
||||
});
|
||||
|
||||
it('starts in the first field of a prompt and marks required fields', async () => {
|
||||
render(<PluginDialogHost />);
|
||||
let answer: Promise<Record<string, string> | null>;
|
||||
act(() => {
|
||||
answer = awaitPrompt({ pluginId: 'p', kind: 'prompt', title: 'Name', message: '', fields: [{ name: 'n', label: 'Name', required: true }] });
|
||||
});
|
||||
const input = screen.getByRole('textbox', { name: 'Name' });
|
||||
expect(input).toHaveFocus();
|
||||
expect(input).toHaveAttribute('aria-required', 'true');
|
||||
act(() => { fireEvent.keyDown(document, { key: 'Escape' }); });
|
||||
await expect(answer!).resolves.toBeNull();
|
||||
await waitFor(() => expect(opener).toHaveFocus());
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { KeyRound } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { productName } from '@/lib/product-name';
|
||||
import {
|
||||
currentConnect,
|
||||
@@ -46,6 +47,15 @@ export function PluginConnectDialog() {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [request]);
|
||||
|
||||
// Tab stays in the dialog, the first field takes the focus, and the focus
|
||||
// goes back to where it was when the dialog closes. (Escape is the
|
||||
// listener above.)
|
||||
const panelRef = useFocusTrap({
|
||||
isActive: !!request,
|
||||
restoreFocus: true,
|
||||
initialFocus: () => panelRef.current?.querySelector<HTMLElement>('input:not(:disabled)'),
|
||||
});
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const { spec, mode, pluginName } = request;
|
||||
@@ -96,10 +106,10 @@ export function PluginConnectDialog() {
|
||||
if (e.target === e.currentTarget) request.resolve({ connected: false });
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full max-w-[440px] flex-col rounded-lg border border-border bg-popover text-popover-foreground shadow-xl">
|
||||
<div ref={panelRef} className="flex w-full max-w-[440px] flex-col rounded-lg border border-border bg-popover text-popover-foreground shadow-xl">
|
||||
<form className="p-5" onSubmit={(e) => { e.preventDefault(); void submit(); }}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<KeyRound className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||
<KeyRound className="h-5 w-5 flex-shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<h2 id={titleId} className="text-lg font-semibold text-foreground">{t('title', { label: spec.label })}</h2>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-muted-foreground [overflow-wrap:anywhere]">{intro}</p>
|
||||
@@ -116,7 +126,6 @@ export function PluginConnectDialog() {
|
||||
spellCheck={false}
|
||||
value={server}
|
||||
placeholder={t('server_hint')}
|
||||
autoFocus
|
||||
onChange={(e) => setServer(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
@@ -129,7 +138,6 @@ export function PluginConnectDialog() {
|
||||
type="text"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
autoFocus={!!fixedOrigin}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
@@ -142,7 +150,6 @@ export function PluginConnectDialog() {
|
||||
type="password"
|
||||
value={secret}
|
||||
autoComplete="off"
|
||||
autoFocus={!!fixedOrigin && !needsUsername}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { cancelForPlugin, head, overlay, resolveDialog, subscribe, type DialogRequest } from '@/lib/plugin-sandbox/host-dialog';
|
||||
import type { DialogButtonSpec } from '@/lib/plugin-sandbox/ui-kit/dialog-buttons';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -165,6 +166,23 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [current, canSubmit, values]);
|
||||
|
||||
// Tab stays in the dialog and the focus goes back to what opened it. The
|
||||
// first field of a prompt takes the focus, a confirm's safe button (Cancel
|
||||
// when it is destructive), else the first control. A dialog under another
|
||||
// one lets go. Escape is the listener above.
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const confirmButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useFocusTrap({
|
||||
isActive: !!current && layer !== 'below',
|
||||
restoreFocus: true,
|
||||
initialFocus: () => {
|
||||
if (current?.kind === 'prompt') return panelRef.current?.querySelector<HTMLElement>('input');
|
||||
if (current?.kind === 'confirm' && current.danger) return cancelButtonRef.current;
|
||||
if (current?.kind === 'confirm' || current?.kind === 'alert') return confirmButtonRef.current;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const name = pluginName ?? current.pluginId;
|
||||
@@ -200,6 +218,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="flex w-full flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in zoom-in-95 duration-200"
|
||||
style={{ maxWidth: custom ? (current.width ?? 560) : 448, maxHeight: '90vh' }}
|
||||
data-plugin-dialog={current.pluginId}
|
||||
@@ -218,7 +237,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
aria-label={t('close')}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
<X className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
@@ -262,16 +281,16 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
{isPrompt ? (
|
||||
<form onSubmit={(e) => { e.preventDefault(); submitPrompt(); }}>
|
||||
<div className="mb-6 flex flex-col gap-4">
|
||||
{fields.map((f, i) => (
|
||||
{fields.map((f) => (
|
||||
<label key={f.name} className="block">
|
||||
<span className="text-sm font-medium mb-1 block text-foreground">
|
||||
{f.label}{f.required && <span className="text-destructive"> *</span>}
|
||||
{f.label}{f.required && <span className="text-destructive" aria-hidden="true"> *</span>}
|
||||
</span>
|
||||
<Input
|
||||
type={f.type === 'password' ? 'password' : 'text'}
|
||||
value={values[f.name] ?? ''}
|
||||
placeholder={f.placeholder}
|
||||
autoFocus={i === 0}
|
||||
aria-required={f.required || undefined}
|
||||
autoComplete={f.type === 'password' ? 'off' : undefined}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
|
||||
/>
|
||||
@@ -289,12 +308,12 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
|
||||
) : (
|
||||
<div className={cn('flex items-center justify-end gap-3', !current.message && 'mt-3')}>
|
||||
{current.kind === 'confirm' && (
|
||||
<Button type="button" variant="outline" autoFocus={!!current.danger} onClick={cancel}>{cancelLabel}</Button>
|
||||
<Button ref={cancelButtonRef} type="button" variant="outline" onClick={cancel}>{cancelLabel}</Button>
|
||||
)}
|
||||
<Button
|
||||
ref={confirmButtonRef}
|
||||
type="button"
|
||||
variant={current.danger ? 'destructive' : 'default'}
|
||||
autoFocus={current.kind === 'alert' || !current.danger}
|
||||
onClick={() => resolveThis(true)}
|
||||
>
|
||||
{confirmLabel}
|
||||
|
||||
Reference in New Issue
Block a user