From 838577876ebc3febfe0523dcc5a7dfa150c612f6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:17:46 +0100 Subject: [PATCH] feat: add one dialog shell and build the confirm and name prompts on it --- components/ui/__tests__/dialog.test.tsx | 162 +++++++++++++++++++++ components/ui/confirm-dialog.tsx | 82 +++-------- components/ui/dialog.tsx | 184 ++++++++++++++++++++++++ components/ui/prompt-dialog.tsx | 128 ++++++++--------- 4 files changed, 429 insertions(+), 127 deletions(-) create mode 100644 components/ui/__tests__/dialog.test.tsx create mode 100644 components/ui/dialog.tsx diff --git a/components/ui/__tests__/dialog.test.tsx b/components/ui/__tests__/dialog.test.tsx new file mode 100644 index 000000000..34046f93d --- /dev/null +++ b/components/ui/__tests__/dialog.test.tsx @@ -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( +
+ {}} title="Rename" description="Pick a name" /> +
, + ); + 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(); + 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(null); + return ( + <> + + setOpen(false)} title="T"> + + + + {}} title="unused" initialFocus={target} /> + + ); + } + render(); + 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(null); + return ( + {}} title="T" initialFocus={target}> + + + + ); + } + render(); + 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(); + 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(); + 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(

text

); + (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( + {}} 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(); + 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( + {}} + 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( {}} 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]); + }); +}); diff --git a/components/ui/confirm-dialog.tsx b/components/ui/confirm-dialog.tsx index 99f684dba..8f6652e2a 100644 --- a/components/ui/confirm-dialog.tsx +++ b/components/ui/confirm-dialog.tsx @@ -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 ( -
-
-
-
- {variant === "destructive" && ( -
- -
- )} -
-

- {title} -

-

- {message} -

- {details} -
+ +
-
- -
+ ) : undefined + } + footer={ + <> @@ -112,8 +70,10 @@ export function ConfirmDialog({ > {resolvedConfirmText} -
-
-
+ + } + > + {details} +
); } diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 000000000..bb589d7d6 --- /dev/null +++ b/components/ui/dialog.tsx @@ -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; + /** Wraps body and footer in a form, so Enter in a field submits. */ + onSubmit?: (e: FormEvent) => 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 = { + 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 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 + * }> + * + * + * ``` + */ +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(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 = ( + <> +
+
+ {icon} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} + {children} +
+
+
+ {footer && ( +
{footer}
+ )} + + ); + + return createPortal( +
{ + pressedOnBackdrop.current = e.target === e.currentTarget; + }} + onClick={(e) => { + const outside = pressedOnBackdrop.current && e.target === e.currentTarget; + pressedOnBackdrop.current = false; + if (outside && !blockOutside) onClose(); + }} + > +
+ {onSubmit ? ( +
+ {body} +
+ ) : ( + body + )} + {showClose && ( + // Last in the tab order, so the focus starts in the content. + + + + )} +
+
, + document.body + ); +} diff --git a/components/ui/prompt-dialog.tsx b/components/ui/prompt-dialog.tsx index 48effdba0..235710e4b 100644 --- a/components/ui/prompt-dialog.tsx +++ b/components/ui/prompt-dialog.tsx @@ -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(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 ( -
-
-
-
-

- {title} -

- {message && ( -

{message}

- )} - setValue(e.target.value)} - placeholder={placeholder} - className="mt-4" - /> -
- -
- - -
-
-
-
+ + + + + } + > + 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 && ( + + )} + ); }