All files / src/components/ui dialog.tsx

100% Statements 25/25
100% Branches 14/14
100% Functions 6/6
100% Lines 19/19

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                                                22x               168x 168x 168x   168x             168x 113x 35x 23x 6x 6x   113x 113x     168x                   73x 73x     23x   17x   17x                                  
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { useEffect, useRef, type ReactNode } from "react";
import { isTopFocusTrap, useFocusTrap } from "@/hooks/useFocusTrap";
import { useLatestRef } from "@/hooks/useLatestRef";
import { cn } from "@/lib/utils";
 
type DialogName = { labelledBy: string; label?: never } | { label: string; labelledBy?: never };
 
type DialogProps = DialogName & {
	onClose: () => void;
	/** Added to the backdrop, a fixed overlay over the whole window by default. */
	overlayClassName?: string;
	/** The dialog box itself. */
	className?: string;
	children: ReactNode;
};
 
/**
 * A modal over a dimmed backdrop. A click on the backdrop or Escape closes it,
 * and focus moves into it and stays there until it closes. With one dialog
 * open over another, Escape closes only the top one.
 */
export const Dialog = ({
	onClose,
	overlayClassName,
	className,
	children,
	labelledBy,
	label,
}: DialogProps) => {
	const overlayRef = useRef<HTMLDivElement>(null);
	const dialogRef = useRef<HTMLDivElement>(null);
	useFocusTrap(dialogRef);
 
	const onCloseRef = useLatestRef(onClose);
 
	// Escape pressed while focus is outside the dialog, such as on the page
	// after the focused control went away. It runs before the page's own
	// handlers so the screen behind never sees the key. Keys from inside the
	// dialog are left to the handler on the backdrop, so a control inside can
	// use Escape first.
	useEffect(() => {
		const handleKey = (e: KeyboardEvent) => {
			if (e.key !== "Escape" || !isTopFocusTrap(dialogRef.current)) return;
			if (e.target instanceof Node && overlayRef.current?.contains(e.target)) return;
			e.stopPropagation();
			onCloseRef.current();
		};
		document.addEventListener("keydown", handleKey, true);
		return () => document.removeEventListener("keydown", handleKey, true);
	}, [onCloseRef]);
 
	return (
		<div
			ref={overlayRef}
			className={cn(
				"fixed inset-0 z-50 flex items-center justify-center bg-black/60",
				overlayClassName
			)}
			onClick={(e) => {
				// A dialog opened from a clickable row, like a channel card, would
				// otherwise pass its clicks on to that row.
				e.stopPropagation();
				if (e.target === e.currentTarget) onClose();
			}}
			onKeyDown={(e) => {
				if (e.key !== "Escape") return;
				// The screen behind may have its own Escape shortcut, like leaving full screen.
				e.stopPropagation();
				// A control inside, such as a field, can keep Escape for itself.
				if (!e.defaultPrevented) onClose();
			}}
		>
			<div
				ref={dialogRef}
				role="dialog"
				aria-modal="true"
				aria-labelledby={labelledBy}
				aria-label={label}
				tabIndex={-1}
				className={cn("focus:outline-hidden", className)}
			>
				{children}
			</div>
		</div>
	);
};