163 lines
4.6 KiB
TypeScript
163 lines
4.6 KiB
TypeScript
|
|
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||
|
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||
|
|
import {
|
||
|
|
faTriangleExclamation,
|
||
|
|
faRotateRight,
|
||
|
|
faChevronDown,
|
||
|
|
faChevronUp,
|
||
|
|
faClipboard,
|
||
|
|
} from "@fortawesome/free-solid-svg-icons";
|
||
|
|
import { useState, type FC } from "react";
|
||
|
|
|
||
|
|
type Variant = "inline" | "card" | "banner";
|
||
|
|
|
||
|
|
export type ErrorStateProps = {
|
||
|
|
/** Main heading shown to the user */
|
||
|
|
title?: string;
|
||
|
|
/** Friendly message for the user */
|
||
|
|
message?: string;
|
||
|
|
/** Raw error to help devs (object, string, whatever) */
|
||
|
|
error?: unknown;
|
||
|
|
/** Called when user clicks Retry */
|
||
|
|
onRetry?: () => Promise<void> | void;
|
||
|
|
/** Show a Retry button */
|
||
|
|
showRetry?: boolean;
|
||
|
|
/** Optional custom icon */
|
||
|
|
icon?: IconDefinition;
|
||
|
|
/** Visual style */
|
||
|
|
variant?: Variant;
|
||
|
|
/** Additional actions (e.g. “Report”) */
|
||
|
|
actions?: React.ReactNode;
|
||
|
|
/** Test id for testing */
|
||
|
|
"data-testid"?: string;
|
||
|
|
/** ClassName passthrough */
|
||
|
|
className?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
function formatError(err: unknown) {
|
||
|
|
if (!err) return "";
|
||
|
|
if (typeof err === "string") return err;
|
||
|
|
if (err instanceof Error) return err.stack || err.message;
|
||
|
|
try {
|
||
|
|
return JSON.stringify(err, null, 2);
|
||
|
|
} catch {
|
||
|
|
return String(err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const baseStyles = "w-full text-left flex items-start gap-3 rounded-md border";
|
||
|
|
|
||
|
|
const variants: Record<Variant, string> = {
|
||
|
|
inline: "p-3 border-red-200 bg-red-50 text-red-800",
|
||
|
|
card: "p-4 border-red-200 bg-red-50 text-red-800 shadow-sm",
|
||
|
|
banner: "p-3 border-red-200 bg-red-50 text-red-800 rounded-none border-x-0",
|
||
|
|
};
|
||
|
|
|
||
|
|
export const ErrorState: FC<ErrorStateProps> = ({
|
||
|
|
title = "Something went wrong",
|
||
|
|
message = "Please try again or contact support if the problem persists.",
|
||
|
|
error,
|
||
|
|
onRetry,
|
||
|
|
showRetry = !!onRetry,
|
||
|
|
icon = faTriangleExclamation,
|
||
|
|
variant = "inline",
|
||
|
|
actions,
|
||
|
|
className = "",
|
||
|
|
...rest
|
||
|
|
}) => {
|
||
|
|
const [expanded, setExpanded] = useState(false);
|
||
|
|
const [retrying, setRetrying] = useState(false);
|
||
|
|
|
||
|
|
const details = formatError(error);
|
||
|
|
|
||
|
|
async function handleRetry() {
|
||
|
|
if (!onRetry) return;
|
||
|
|
try {
|
||
|
|
setRetrying(true);
|
||
|
|
await onRetry();
|
||
|
|
} finally {
|
||
|
|
setRetrying(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function copyDetails() {
|
||
|
|
if (!details) return;
|
||
|
|
navigator.clipboard?.writeText(details).catch(() => {});
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
role="alert"
|
||
|
|
aria-live="assertive"
|
||
|
|
className={`${baseStyles} ${variants[variant]} ${className}`}
|
||
|
|
{...rest}
|
||
|
|
>
|
||
|
|
<div className="mt-0.5">
|
||
|
|
<FontAwesomeIcon icon={icon} className="h-5 w-5" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex-1 space-y-1">
|
||
|
|
<h3 className="font-medium">{title}</h3>
|
||
|
|
{message && <p className="text-sm opacity-90">{message}</p>}
|
||
|
|
|
||
|
|
{/* Controls */}
|
||
|
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||
|
|
{showRetry && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={handleRetry}
|
||
|
|
disabled={retrying}
|
||
|
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-red-600 text-white text-sm hover:bg-red-700 disabled:opacity-60"
|
||
|
|
>
|
||
|
|
<FontAwesomeIcon icon={faRotateRight} className="h-4 w-4" />
|
||
|
|
{retrying ? "Retrying…" : "Retry"}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{details && (
|
||
|
|
<>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setExpanded((v) => !v)}
|
||
|
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-white/50"
|
||
|
|
aria-expanded={expanded}
|
||
|
|
aria-controls="error-details"
|
||
|
|
>
|
||
|
|
<FontAwesomeIcon
|
||
|
|
icon={expanded ? faChevronUp : faChevronDown}
|
||
|
|
className="h-4 w-4"
|
||
|
|
/>
|
||
|
|
{expanded ? "Hide details" : "Show details"}
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={copyDetails}
|
||
|
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm hover:bg-white/50"
|
||
|
|
aria-label="Copy error details"
|
||
|
|
>
|
||
|
|
<FontAwesomeIcon icon={faClipboard} className="h-4 w-4" />
|
||
|
|
Copy details
|
||
|
|
</button>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{actions}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Dev details (collapsible) */}
|
||
|
|
{expanded && details && (
|
||
|
|
<pre
|
||
|
|
id="error-details"
|
||
|
|
className="mt-2 max-h-64 overflow-auto text-xs leading-relaxed bg-white/60 text-red-900 border rounded p-3"
|
||
|
|
>
|
||
|
|
{details}
|
||
|
|
</pre>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default ErrorState;
|