All files / src/components/downloads ConfirmDialog.tsx

100% Statements 8/8
100% Branches 17/17
100% Functions 2/2
100% Lines 6/6

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102                                                        18x               18x                       6181x 6181x 85x                         11x                                                                            
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { useId, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Dialog } from "@/components/ui/dialog";
 
interface ConfirmDialogProps {
	open: boolean;
	title: string;
	message: string;
	confirmLabel: string;
	onConfirm: () => void;
	/** Leave out for a notice. It then has one button, and clicking outside it or Escape confirms. */
	onCancel?: () => void;
	/** The label of the button that closes the dialog without acting. */
	cancelLabel?: string;
	/**
	 * A red confirm button, for actions that delete something. On by default
	 * when the dialog asks, off for a notice.
	 */
	destructive?: boolean;
	confirmDisabled?: boolean;
	/** More content below the message, such as fields or a warning. */
	children?: ReactNode;
}
 
const BUTTON =
	"px-3 py-1.5 text-xs rounded-md focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring";
 
/**
 * Asks before an action, or gives a notice with a single button. It renders
 * into the page body so a parent that clips or transforms, like a sheet,
 * can't cut it off. Focus starts on its first button, and keys pressed in it
 * don't reach what is behind it, such as the player's shortcuts.
 */
export const ConfirmDialog = ({
	open,
	title,
	message,
	confirmLabel,
	onConfirm,
	onCancel,
	cancelLabel = "Cancel",
	destructive = onCancel !== undefined,
	confirmDisabled = false,
	children,
}: ConfirmDialogProps) => {
	const titleId = useId();
	if (!open) return null;
	return createPortal(
		<Dialog
			onClose={onCancel ?? onConfirm}
			labelledBy={titleId}
			// Above the full screen player, which sits at z-9999.
			overlayClassName="z-10000"
			className="bg-card border border-border rounded-lg p-5 w-[320px] shadow-xl"
		>
			<div
				onKeyDown={(e) => {
					// React passes keys from a portal on to the components that
					// rendered it, and one of them can be the player. Escape still
					// reaches the dialog so it closes, and Tab the focus trap.
					if (e.key !== "Escape" && e.key !== "Tab") e.stopPropagation();
				}}
			>
				<h3 id={titleId} className="text-sm font-semibold mb-1.5">
					{title}
				</h3>
				<p className="text-xs text-muted-foreground mb-4 max-h-[50vh] overflow-y-auto whitespace-pre-line wrap-break-word">
					{message}
				</p>
				{children && <div className="mb-4">{children}</div>}
				<div className="flex justify-end gap-2">
					{onCancel && (
						<button
							type="button"
							className={`${BUTTON} hover:bg-accent`}
							onClick={onCancel}
						>
							{cancelLabel}
						</button>
					)}
					<button
						type="button"
						className={`${BUTTON} disabled:opacity-50 disabled:pointer-events-none ${
							destructive
								? "bg-red-600 text-white hover:bg-red-500"
								: "bg-primary text-primary-foreground hover:bg-primary/90"
						}`}
						onClick={onConfirm}
						disabled={confirmDisabled}
					>
						{confirmLabel}
					</button>
				</div>
			</div>
		</Dialog>,
		document.body
	);
};