mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
feat: add one dialog shell and build the confirm and name prompts on it
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user