feat: add one dialog shell and build the confirm and name prompts on it

This commit is contained in:
Linus Rath
2026-10-28 01:17:46 +01:00
parent 4c8d597f82
commit 838577876e
4 changed files with 429 additions and 127 deletions
+162
View File
@@ -0,0 +1,162 @@
import { useRef, useState } from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Dialog } from '../dialog';
import { ConfirmDialog } from '../confirm-dialog';
import { PromptDialog, nameSelectionRange } from '../prompt-dialog';
afterEach(() => vi.useRealTimers());
function backdropOf(panel: HTMLElement) {
return panel.parentElement as HTMLElement;
}
describe('Dialog', () => {
it('renders in a portal on body, labelled by its title and described by its description', () => {
const { container } = render(
<div data-testid="host">
<Dialog isOpen onClose={() => {}} title="Rename" description="Pick a name" />
</div>,
);
const dialog = screen.getByRole('dialog', { name: 'Rename' });
expect(container.contains(dialog)).toBe(false);
expect(dialog.closest('body > div.fixed')).not.toBeNull();
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAccessibleDescription('Pick a name');
expect(screen.getByRole('heading', { level: 2, name: 'Rename' })).toHaveClass('text-lg', 'font-semibold');
expect(backdropOf(dialog)).toHaveClass('z-[60]', 'bg-black/50');
});
it('has a named close button at the top end that closes it', () => {
const onClose = vi.fn();
render(<Dialog isOpen onClose={onClose} title="T" />);
fireEvent.click(screen.getByRole('button', { name: 'close' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('focuses the first field, or the initialFocus target, and restores the focus on close', () => {
function Host() {
const [open, setOpen] = useState(false);
const target = useRef<HTMLButtonElement>(null);
return (
<>
<button type="button" onClick={() => setOpen(true)}>opener</button>
<Dialog isOpen={open} onClose={() => setOpen(false)} title="T">
<button type="button">first button</button>
<input aria-label="field" />
</Dialog>
<Dialog isOpen={false} onClose={() => {}} title="unused" initialFocus={target} />
</>
);
}
render(<Host />);
const opener = screen.getByText('opener');
opener.focus();
fireEvent.click(opener);
expect(document.activeElement).toBe(screen.getByLabelText('field'));
fireEvent.keyDown(screen.getByLabelText('field'), { key: 'Escape' });
expect(screen.queryByRole('dialog')).toBeNull();
expect(document.activeElement).toBe(opener);
});
it('honours initialFocus', () => {
function Host() {
const target = useRef<HTMLButtonElement>(null);
return (
<Dialog isOpen onClose={() => {}} title="T" initialFocus={target}>
<input aria-label="field" />
<button ref={target} type="button">target</button>
</Dialog>
);
}
render(<Host />);
expect(document.activeElement).toBe(screen.getByText('target'));
});
it('closes on an outside click, but not while dirty or on a press that started inside', () => {
const onClose = vi.fn();
const { rerender } = render(<Dialog isOpen onClose={onClose} title="T"><input aria-label="f" /></Dialog>);
const backdrop = backdropOf(screen.getByRole('dialog'));
// A selection dragged out of the field, released on the backdrop.
fireEvent.mouseDown(screen.getByLabelText('f'));
fireEvent.click(backdrop);
expect(onClose).not.toHaveBeenCalled();
fireEvent.mouseDown(backdrop);
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledTimes(1);
rerender(<Dialog isOpen onClose={onClose} title="T" dirty><input aria-label="f" /></Dialog>);
fireEvent.mouseDown(backdrop);
fireEvent.click(backdrop);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('closes on Escape after the focus left for the page', () => {
const onClose = vi.fn();
render(<Dialog isOpen onClose={onClose} title="T"><p>text</p></Dialog>);
(document.activeElement as HTMLElement | null)?.blur();
fireEvent.keyDown(document.body, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('ConfirmDialog on the Dialog shell', () => {
it('keeps its alertdialog markup and has no close X', () => {
render(
<ConfirmDialog isOpen onClose={() => {}} onConfirm={() => {}} title="Delete file?" message="It is gone for good." variant="destructive" confirmText="Delete" />,
);
const dialog = screen.getByRole('alertdialog', { name: 'Delete file?' });
expect(dialog).toHaveAccessibleDescription('It is gone for good.');
expect(dialog).toHaveClass('max-w-md', 'rounded-lg', 'shadow-xl');
const buttons = screen.getAllByRole('button');
expect(buttons.map((b) => b.textContent)).toEqual(['cancel', 'Delete']);
});
it('confirms and closes', () => {
const onConfirm = vi.fn();
const onClose = vi.fn();
render(<ConfirmDialog isOpen onClose={onClose} onConfirm={onConfirm} title="T" message="M" />);
fireEvent.click(screen.getByText('confirm'));
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('PromptDialog', () => {
it('shows the validation message and blocks Enter while it fails', () => {
const onSubmit = vi.fn();
render(
<PromptDialog
isOpen
onClose={() => {}}
onSubmit={onSubmit}
title="New folder"
validate={(v) => (v.includes('/') ? 'No slashes' : null)}
/>,
);
const input = screen.getByRole('textbox');
fireEvent.change(input, { target: { value: 'a/b' } });
expect(screen.getByRole('alert')).toHaveTextContent('No slashes');
expect(input).toHaveAttribute('aria-invalid', 'true');
fireEvent.submit(input.closest('form')!);
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: 'ab' } });
expect(screen.queryByRole('alert')).toBeNull();
fireEvent.submit(input.closest('form')!);
expect(onSubmit).toHaveBeenCalledWith('ab');
});
it('selects the base name of a file', () => {
vi.useFakeTimers();
render(<PromptDialog isOpen onClose={() => {}} onSubmit={() => {}} title="Rename" defaultValue="report.final.pdf" selectBaseName />);
act(() => { vi.advanceTimersByTime(60); });
const input = screen.getByRole('textbox') as HTMLInputElement;
expect([input.selectionStart, input.selectionEnd]).toEqual([0, 'report.final'.length]);
});
it('works out the base name range', () => {
expect(nameSelectionRange('a.txt', true)).toEqual([0, 1]);
expect(nameSelectionRange('.bashrc', true)).toEqual([0, 7]);
expect(nameSelectionRange('notes', true)).toEqual([0, 5]);
expect(nameSelectionRange('a.txt', false)).toEqual([0, 5]);
});
});
+21 -61
View File
@@ -1,9 +1,8 @@
"use client";
import { useEffect, useId } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { AlertTriangle } from "@/components/icons";
import { cn } from "@/lib/utils";
@@ -32,68 +31,27 @@ export function ConfirmDialog({
variant = "default",
}: ConfirmDialogProps) {
const t = useTranslations("confirm_dialog");
const id = useId();
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
useEffect(() => {
if (!isOpen) return;
const handleBackdropClick = (e: MouseEvent) => {
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleBackdropClick);
return () => document.removeEventListener("mousedown", handleBackdropClick);
}, [isOpen, onClose, dialogRef]);
if (!isOpen) return null;
const resolvedConfirmText = confirmText || t("confirm");
const resolvedCancelText = cancelText || t("cancel");
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-message`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<div className="flex items-start gap-4">
{variant === "destructive" && (
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
)}
<div className="flex-1 min-w-0">
<h2
id={`${id}-title`}
className="text-lg font-semibold text-foreground"
>
{title}
</h2>
<p
id={`${id}-message`}
className="mt-2 text-sm text-muted-foreground"
>
{message}
</p>
{details}
</div>
<Dialog
isOpen={isOpen}
onClose={onClose}
role="alertdialog"
title={title}
description={message}
showClose={false}
icon={
variant === "destructive" ? (
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
) : undefined
}
footer={
<>
<Button variant="outline" onClick={onClose}>
{resolvedCancelText}
</Button>
@@ -112,8 +70,10 @@ export function ConfirmDialog({
>
{resolvedConfirmText}
</Button>
</div>
</div>
</div>
</>
}
>
{details}
</Dialog>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { useEffect, useId, useRef, type ReactNode, type RefObject, type FormEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { IconButton } from "@/components/ui/icon-button";
import { X } from "@/components/icons";
import { cn } from "@/lib/utils";
export type DialogSize = "sm" | "md" | "lg" | "full-on-phone";
export interface DialogProps {
isOpen: boolean;
/** Called by Escape, the close X and an outside click. Ask about unsaved input here (R5). */
onClose: () => void;
title: ReactNode;
/** One line under the title; becomes the dialog's description. */
description?: ReactNode;
/** The body, under the title and description. */
children?: ReactNode;
/** The buttons: Cancel (outline) first, then the primary. Laid out at the end. */
footer?: ReactNode;
/** `alertdialog` for confirmations. */
role?: "dialog" | "alertdialog";
/** sm 384px, md 448px (default), lg 672px; full-on-phone fills a phone screen and is lg above. */
size?: DialogSize;
/** Drawn before the title, e.g. the warning icon of a destructive confirm. */
icon?: ReactNode;
/** The close X at the top end. Default true. */
showClose?: boolean;
/** Ignore clicks outside, e.g. while the dialog holds unsaved input. */
preventOutsideClose?: boolean;
/** Same as preventOutsideClose: the dialog holds unsaved input. */
dirty?: boolean;
/** Where the focus goes on open. Default: the first field, else the first control. */
initialFocus?: RefObject<HTMLElement | null>;
/** Wraps body and footer in a form, so Enter in a field submits. */
onSubmit?: (e: FormEvent<HTMLFormElement>) => void;
/** Extra classes for the panel. */
className?: string;
/** Extra classes for the body. */
bodyClassName?: string;
/** Test hook on the panel. */
testId?: string;
}
const SIZES: Record<DialogSize, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-2xl",
"full-on-phone":
"max-w-2xl max-sm:max-w-none max-sm:h-full max-sm:max-h-none max-sm:rounded-none max-sm:border-0",
};
const FIELD_SELECTOR =
'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]):not(:disabled), textarea:not(:disabled), select:not(:disabled), [contenteditable="true"]';
/**
* The one dialog shell (R4): a portal on <body> at z-[60] over a dimmed
* backdrop, labelled by its title, focus moved in and given back, Escape
* from anywhere inside, outside click closes unless `dirty`.
*
* ```tsx
* <Dialog isOpen={open} onClose={close} title={t("rename")}
* footer={<><Button variant="outline" onClick={close}>{t("cancel")}</Button><Button onClick={save}>{t("save")}</Button></>}>
* <Input ... />
* </Dialog>
* ```
*/
export function Dialog({
isOpen,
onClose,
title,
description,
children,
footer,
role = "dialog",
size = "md",
icon,
showClose = true,
preventOutsideClose = false,
dirty = false,
initialFocus,
onSubmit,
className,
bodyClassName,
testId,
}: DialogProps) {
const tCommon = useTranslations("common");
const id = useId();
const titleId = `${id}-title`;
const descriptionId = `${id}-description`;
const panelRef = useFocusTrap({ isActive: isOpen, onEscape: onClose, restoreFocus: true });
// A press that started on the backdrop, so a text selection dragged out of
// a field and released outside doesn't count as an outside click.
const pressedOnBackdrop = useRef(false);
// The focus trap has put the focus on the first control; a field or an
// explicit target is the better start.
useEffect(() => {
if (!isOpen) return;
const panel = panelRef.current;
if (!panel) return;
const target = initialFocus?.current ?? panel.querySelector<HTMLElement>(FIELD_SELECTOR);
target?.focus();
// Only on open: a later render must not pull the focus back.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
if (!isOpen || typeof document === "undefined") return null;
const blockOutside = preventOutsideClose || dirty;
const body = (
<>
<div className={cn("p-6 overflow-y-auto min-h-0", size === "full-on-phone" && "max-sm:flex-1", bodyClassName)}>
<div className={cn(icon && "flex items-start gap-4")}>
{icon}
<div className={cn(icon && "flex-1 min-w-0")}>
<h2 id={titleId} className={cn("text-lg font-semibold text-foreground", showClose && "pe-8")}>
{title}
</h2>
{description && (
<p id={descriptionId} className="mt-2 text-sm text-muted-foreground">
{description}
</p>
)}
{children}
</div>
</div>
</div>
{footer && (
<div className="flex items-center justify-end gap-3 px-6 pb-6">{footer}</div>
)}
</>
);
return createPortal(
<div
className={cn(
"fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150",
size === "full-on-phone" && "max-sm:p-0"
)}
onMouseDown={(e) => {
pressedOnBackdrop.current = e.target === e.currentTarget;
}}
onClick={(e) => {
const outside = pressedOnBackdrop.current && e.target === e.currentTarget;
pressedOnBackdrop.current = false;
if (outside && !blockOutside) onClose();
}}
>
<div
ref={panelRef}
role={role}
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
data-testid={testId}
className={cn(
"relative flex flex-col max-h-full bg-background border border-border rounded-lg shadow-xl w-full outline-none animate-in zoom-in-95 duration-200",
SIZES[size],
className
)}
>
{onSubmit ? (
<form onSubmit={onSubmit} className="flex flex-col min-h-0 flex-1">
{body}
</form>
) : (
body
)}
{showClose && (
// Last in the tab order, so the focus starts in the content.
<IconButton label={tCommon("close")} onClick={onClose} className="absolute top-3 end-3">
<X className="w-4 h-4" />
</IconButton>
)}
</div>
</div>,
document.body
);
}
+62 -66
View File
@@ -1,9 +1,9 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
interface PromptDialogProps {
@@ -16,6 +16,21 @@ interface PromptDialogProps {
defaultValue?: string;
confirmText?: string;
cancelText?: string;
/**
* Checks the trimmed value as it is typed. A message is shown under the
* field and blocks submitting; null means the value is fine.
*/
validate?: (value: string) => string | null;
/** Select only the part before the last dot ("report" of "report.pdf"), for file names. */
selectBaseName?: boolean;
}
/** The selection range for a name: all of it, or the part before the extension. */
export function nameSelectionRange(value: string, baseName: boolean): [number, number] {
if (!baseName) return [0, value.length];
const dot = value.lastIndexOf(".");
// ".bashrc" has no extension to keep; "a." keeps nothing after its dot.
return dot > 0 ? [0, dot] : [0, value.length];
}
export function PromptDialog({
@@ -28,48 +43,34 @@ export function PromptDialog({
defaultValue = "",
confirmText,
cancelText,
validate,
selectBaseName = false,
}: PromptDialogProps) {
const t = useTranslations("confirm_dialog");
const id = useId();
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
useEffect(() => {
if (isOpen) {
setValue(defaultValue);
const t = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
const input = inputRef.current;
if (!input) return;
input.focus();
const [start, end] = nameSelectionRange(input.value, selectBaseName);
input.setSelectionRange(start, end);
}, 50);
return () => clearTimeout(t);
}
}, [isOpen, defaultValue]);
useEffect(() => {
if (!isOpen) return;
const handleBackdropClick = (e: MouseEvent) => {
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleBackdropClick);
return () => document.removeEventListener("mousedown", handleBackdropClick);
}, [isOpen, onClose, dialogRef]);
if (!isOpen) return null;
}, [isOpen, defaultValue, selectBaseName]);
const resolvedConfirmText = confirmText || t("confirm");
const resolvedCancelText = cancelText || t("cancel");
const trimmed = value.trim();
const canSubmit = trimmed.length > 0;
const error = trimmed.length > 0 && validate ? validate(trimmed) : null;
const canSubmit = trimmed.length > 0 && !error;
const errorId = `${id}-error`;
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
@@ -82,45 +83,40 @@ export function PromptDialog({
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<form onSubmit={handleSubmit}>
<div className="p-6">
<h2
id={`${id}-title`}
className="text-lg font-semibold text-foreground"
>
{title}
</h2>
{message && (
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
)}
<Input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
className="mt-4"
/>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button type="button" variant="outline" onClick={onClose}>
{resolvedCancelText}
</Button>
<Button type="submit" variant="default" disabled={!canSubmit}>
{resolvedConfirmText}
</Button>
</div>
</form>
</div>
</div>
<Dialog
isOpen={isOpen}
onClose={onClose}
title={title}
description={message}
showClose={false}
initialFocus={inputRef}
onSubmit={handleSubmit}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>
{resolvedCancelText}
</Button>
<Button type="submit" variant="default" disabled={!canSubmit}>
{resolvedConfirmText}
</Button>
</>
}
>
<Input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={error ? "mt-4 border-destructive focus-visible:border-destructive" : "mt-4"}
/>
{error && (
<p id={errorId} role="alert" className="mt-1.5 text-xs text-destructive">
{error}
</p>
)}
</Dialog>
);
}