mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
feature: Migrate ErrorPopover to shadcn Popover component
- Migrated ErrorPopover to use shadcn PopoverContent - Removed forwardRef wrapper (handled by shadcn internally) - Replaced custom absolute positioning with PopoverContent component - Used cn() utility for cleaner className management - Maintained all styling with info/danger color variants - Updated AppHeader to use Popover pattern - Replaced manual state management (showErrorPopover/setErrorPopover) - Removed refs and click-outside detection useEffect - Wrapped error button in Popover component with PopoverTrigger - Simplified code by removing 30+ lines of manual popover handling Benefits: - Better keyboard navigation and accessibility (built into Radix UI) - Automatic focus management and ARIA attributes - Cleaner, more maintainable code - Consistent with other shadcn components in the app 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
621de91bc8
commit
5849a1e854
2 changed files with 123 additions and 150 deletions
|
|
@ -1,9 +1,8 @@
|
||||||
import { useRef, useEffect } from "react";
|
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useMachineStore } from "../stores/useMachineStore";
|
import { useMachineStore } from "../stores/useMachineStore";
|
||||||
import { useUIStore } from "../stores/useUIStore";
|
import { useUIStore } from "../stores/useUIStore";
|
||||||
import { WorkflowStepper } from "./WorkflowStepper";
|
import { WorkflowStepper } from "./WorkflowStepper";
|
||||||
import { ErrorPopover } from "./ErrorPopover";
|
import { ErrorPopoverContent } from "./ErrorPopover";
|
||||||
import { getStateVisualInfo } from "../utils/machineStateHelpers";
|
import { getStateVisualInfo } from "../utils/machineStateHelpers";
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
|
|
@ -15,6 +14,7 @@ import {
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
|
|
@ -42,17 +42,12 @@ export function AppHeader() {
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore(
|
const { pyodideError } = useUIStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
pyodideError: state.pyodideError,
|
pyodideError: state.pyodideError,
|
||||||
showErrorPopover: state.showErrorPopover,
|
|
||||||
setErrorPopover: state.setErrorPopover,
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const errorPopoverRef = useRef<HTMLDivElement>(null);
|
|
||||||
const errorButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
// Get state visual info for header status badge
|
// Get state visual info for header status badge
|
||||||
const stateVisual = getStateVisualInfo(machineStatus);
|
const stateVisual = getStateVisualInfo(machineStatus);
|
||||||
const stateIcons = {
|
const stateIcons = {
|
||||||
|
|
@ -65,26 +60,6 @@ export function AppHeader() {
|
||||||
};
|
};
|
||||||
const StatusIcon = stateIcons[stateVisual.iconName];
|
const StatusIcon = stateIcons[stateVisual.iconName];
|
||||||
|
|
||||||
// Close error popover when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
errorPopoverRef.current &&
|
|
||||||
!errorPopoverRef.current.contains(event.target as Node) &&
|
|
||||||
errorButtonRef.current &&
|
|
||||||
!errorButtonRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setErrorPopover(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (showErrorPopover) {
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () =>
|
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}
|
|
||||||
}, [showErrorPopover, setErrorPopover]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
|
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
|
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
|
||||||
|
|
@ -157,64 +132,63 @@ export function AppHeader() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error indicator - always render to prevent layout shift */}
|
{/* Error indicator - always render to prevent layout shift */}
|
||||||
<div className="relative">
|
<Popover>
|
||||||
<Button
|
<PopoverTrigger asChild>
|
||||||
ref={errorButtonRef}
|
<Button
|
||||||
onClick={() => setErrorPopover(!showErrorPopover)}
|
size="sm"
|
||||||
size="sm"
|
variant="destructive"
|
||||||
variant="destructive"
|
className={cn(
|
||||||
className={cn(
|
"gap-1.5 flex-shrink-0",
|
||||||
"gap-1.5 flex-shrink-0",
|
machineErrorMessage || pyodideError
|
||||||
machineErrorMessage || pyodideError
|
? "animate-pulse hover:animate-none"
|
||||||
? "animate-pulse hover:animate-none"
|
: "invisible pointer-events-none",
|
||||||
: "invisible pointer-events-none",
|
)}
|
||||||
)}
|
title="Click to view error details"
|
||||||
title="Click to view error details"
|
aria-label="View error details"
|
||||||
aria-label="View error details"
|
disabled={!(machineErrorMessage || pyodideError)}
|
||||||
disabled={!(machineErrorMessage || pyodideError)}
|
>
|
||||||
>
|
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
|
<span>
|
||||||
<span>
|
{(() => {
|
||||||
{(() => {
|
if (pyodideError) return "Python Error";
|
||||||
if (pyodideError) return "Python Error";
|
if (isPairingError) return "Pairing Required";
|
||||||
if (isPairingError) return "Pairing Required";
|
|
||||||
|
|
||||||
const errorMsg = machineErrorMessage || "";
|
const errorMsg = machineErrorMessage || "";
|
||||||
|
|
||||||
// Categorize by error message content
|
// Categorize by error message content
|
||||||
if (
|
if (
|
||||||
errorMsg.toLowerCase().includes("bluetooth") ||
|
errorMsg.toLowerCase().includes("bluetooth") ||
|
||||||
errorMsg.toLowerCase().includes("connection")
|
errorMsg.toLowerCase().includes("connection")
|
||||||
) {
|
) {
|
||||||
return "Connection Error";
|
return "Connection Error";
|
||||||
}
|
}
|
||||||
if (errorMsg.toLowerCase().includes("upload")) {
|
if (errorMsg.toLowerCase().includes("upload")) {
|
||||||
return "Upload Error";
|
return "Upload Error";
|
||||||
}
|
}
|
||||||
if (errorMsg.toLowerCase().includes("pattern")) {
|
if (errorMsg.toLowerCase().includes("pattern")) {
|
||||||
return "Pattern Error";
|
return "Pattern Error";
|
||||||
}
|
}
|
||||||
if (machineError !== undefined) {
|
if (machineError !== undefined) {
|
||||||
return `Machine Error`;
|
return `Machine Error`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fallback
|
// Default fallback
|
||||||
return "Error";
|
return "Error";
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
{/* Error popover */}
|
{/* Error popover content */}
|
||||||
{showErrorPopover && (machineErrorMessage || pyodideError) && (
|
{(machineErrorMessage || pyodideError) && (
|
||||||
<ErrorPopover
|
<ErrorPopoverContent
|
||||||
ref={errorPopoverRef}
|
|
||||||
machineError={machineError}
|
machineError={machineError}
|
||||||
isPairingError={isPairingError}
|
isPairingError={isPairingError}
|
||||||
errorMessage={machineErrorMessage}
|
errorMessage={machineErrorMessage}
|
||||||
pyodideError={pyodideError}
|
pyodideError={pyodideError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,94 @@
|
||||||
import { forwardRef } from "react";
|
|
||||||
import {
|
import {
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
InformationCircleIcon,
|
InformationCircleIcon,
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
import { getErrorDetails } from "../utils/errorCodeHelpers";
|
import { getErrorDetails } from "../utils/errorCodeHelpers";
|
||||||
|
import { PopoverContent } from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ErrorPopoverProps {
|
interface ErrorPopoverContentProps {
|
||||||
machineError?: number;
|
machineError?: number;
|
||||||
isPairingError: boolean;
|
isPairingError: boolean;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
pyodideError?: string | null;
|
pyodideError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
|
export function ErrorPopoverContent({
|
||||||
({ machineError, isPairingError, errorMessage, pyodideError }, ref) => {
|
machineError,
|
||||||
const errorDetails = getErrorDetails(machineError);
|
isPairingError,
|
||||||
const isPairingErr = isPairingError;
|
errorMessage,
|
||||||
const errorMsg = pyodideError || errorMessage || "";
|
pyodideError,
|
||||||
const isInfo = isPairingErr || errorDetails?.isInformational;
|
}: ErrorPopoverContentProps) {
|
||||||
|
const errorDetails = getErrorDetails(machineError);
|
||||||
|
const isPairingErr = isPairingError;
|
||||||
|
const errorMsg = pyodideError || errorMessage || "";
|
||||||
|
const isInfo = isPairingErr || errorDetails?.isInformational;
|
||||||
|
|
||||||
const bgColor = isInfo
|
const bgColor = isInfo
|
||||||
? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
|
? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
|
||||||
: "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
|
: "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
|
||||||
|
|
||||||
const iconColor = isInfo
|
const iconColor = isInfo
|
||||||
? "text-info-600 dark:text-info-400"
|
? "text-info-600 dark:text-info-400"
|
||||||
: "text-danger-600 dark:text-danger-400";
|
: "text-danger-600 dark:text-danger-400";
|
||||||
|
|
||||||
const textColor = isInfo
|
const textColor = isInfo
|
||||||
? "text-info-900 dark:text-info-200"
|
? "text-info-900 dark:text-info-200"
|
||||||
: "text-danger-900 dark:text-danger-200";
|
: "text-danger-900 dark:text-danger-200";
|
||||||
|
|
||||||
const descColor = isInfo
|
const descColor = isInfo
|
||||||
? "text-info-800 dark:text-info-300"
|
? "text-info-800 dark:text-info-300"
|
||||||
: "text-danger-800 dark:text-danger-300";
|
: "text-danger-800 dark:text-danger-300";
|
||||||
|
|
||||||
const listColor = isInfo
|
const listColor = isInfo
|
||||||
? "text-info-700 dark:text-info-300"
|
? "text-info-700 dark:text-info-300"
|
||||||
: "text-danger-700 dark:text-danger-300";
|
: "text-danger-700 dark:text-danger-300";
|
||||||
|
|
||||||
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
|
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
|
||||||
const title =
|
const title =
|
||||||
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
|
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<PopoverContent
|
||||||
ref={ref}
|
className={cn("w-[600px] border-l-4 p-4 backdrop-blur-sm", bgColor)}
|
||||||
className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn"
|
align="start"
|
||||||
role="dialog"
|
>
|
||||||
aria-label="Error details"
|
<div className="flex items-start gap-3">
|
||||||
>
|
<Icon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)} />
|
||||||
<div
|
<div className="flex-1">
|
||||||
className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
|
<h3 className={cn("text-base font-semibold mb-2", textColor)}>
|
||||||
>
|
{title}
|
||||||
<div className="flex items-start gap-3">
|
</h3>
|
||||||
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
|
<p className={cn("text-sm mb-3", descColor)}>
|
||||||
<div className="flex-1">
|
{errorDetails?.description || errorMsg}
|
||||||
<h3 className={`text-base font-semibold ${textColor} mb-2`}>
|
</p>
|
||||||
{title}
|
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
|
||||||
</h3>
|
<>
|
||||||
<p className={`text-sm ${descColor} mb-3`}>
|
<h4 className={cn("text-sm font-semibold mb-2", textColor)}>
|
||||||
{errorDetails?.description || errorMsg}
|
{isInfo ? "Steps:" : "How to Fix:"}
|
||||||
</p>
|
</h4>
|
||||||
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
|
<ol
|
||||||
<>
|
className={cn(
|
||||||
<h4 className={`text-sm font-semibold ${textColor} mb-2`}>
|
"list-decimal list-inside text-sm space-y-1.5",
|
||||||
{isInfo ? "Steps:" : "How to Fix:"}
|
listColor,
|
||||||
</h4>
|
)}
|
||||||
<ol
|
>
|
||||||
className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}
|
{errorDetails.solutions.map((solution, index) => (
|
||||||
>
|
<li key={index} className="pl-2">
|
||||||
{errorDetails.solutions.map((solution, index) => (
|
{solution}
|
||||||
<li key={index} className="pl-2">
|
</li>
|
||||||
{solution}
|
))}
|
||||||
</li>
|
</ol>
|
||||||
))}
|
</>
|
||||||
</ol>
|
)}
|
||||||
</>
|
{machineError !== undefined && !errorDetails?.isInformational && (
|
||||||
)}
|
<p className={cn("text-xs mt-3 font-mono", descColor)}>
|
||||||
{machineError !== undefined && !errorDetails?.isInformational && (
|
Error Code: 0x
|
||||||
<p className={`text-xs ${descColor} mt-3 font-mono`}>
|
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
||||||
Error Code: 0x
|
</p>
|
||||||
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
)}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</PopoverContent>
|
||||||
},
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
ErrorPopover.displayName = "ErrorPopover";
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue