feature: Add shadcn Tooltips to AppHeader for better UX

- Migrated serial number tooltip from native title to shadcn Tooltip
  - Shows formatted machine details (serial, MAC, total stitches, service count)
  - Better multi-line formatting with proper spacing

- Added tooltip to machine status badge
  - Shows state description explaining current status

- Added tooltip to auto-refresh spinner
  - Shows "Auto-refreshing machine status"

- Removed redundant title attributes
  - Disconnect button already has clear label
  - Error button has Popover for details

Benefits:
- Consistent tooltip styling across the app
- Better accessibility with ARIA attributes
- Improved readability with proper formatting
- Better positioning and animations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-12-21 00:16:52 +01:00
parent e1a64e9459
commit 8b6eb593d9

View file

@ -15,6 +15,12 @@ import {
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 { Popover, PopoverTrigger } from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function AppHeader() { export function AppHeader() {
@ -61,145 +67,171 @@ export function AppHeader() {
const StatusIcon = stateIcons[stateVisual.iconName]; const StatusIcon = stateIcons[stateVisual.iconName];
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"> <TooltipProvider>
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center"> <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">
{/* Machine Connection Status - Responsive width column */} <div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
<div className="flex items-center gap-3 w-full lg:w-[280px]"> {/* Machine Connection Status - Responsive width column */}
<div <div className="flex items-center gap-3 w-full lg:w-[280px]">
className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50" <div
style={{ visibility: isConnected ? "visible" : "hidden" }} className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50"
></div> style={{ visibility: isConnected ? "visible" : "hidden" }}
<div ></div>
className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" <div
style={{ visibility: !isConnected ? "visible" : "hidden" }} className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5"
></div> style={{ visibility: !isConnected ? "visible" : "hidden" }}
<div className="flex-1 min-w-0"> ></div>
<div className="flex items-center gap-2"> <div className="flex-1 min-w-0">
<h1 className="text-lg lg:text-xl font-bold text-white leading-tight"> <div className="flex items-center gap-2">
Respira <h1 className="text-lg lg:text-xl font-bold text-white leading-tight">
</h1> Respira
{isConnected && machineInfo?.serialNumber && ( </h1>
<span {isConnected && machineInfo?.serialNumber && (
className="text-xs text-primary-200 cursor-help" <Tooltip>
title={`Serial: ${machineInfo.serialNumber}${ <TooltipTrigger asChild>
machineInfo.macAddress <span className="text-xs text-primary-200 cursor-help">
? `\nMAC: ${machineInfo.macAddress}` {machineInfo.serialNumber}
: "" </span>
}${ </TooltipTrigger>
machineInfo.totalCount !== undefined <TooltipContent className="max-w-xs">
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}` <div className="text-sm space-y-1">
: "" <p className="font-semibold">
}${ Serial: {machineInfo.serialNumber}
machineInfo.serviceCount !== undefined </p>
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}` {machineInfo.macAddress && (
: "" <p className="text-xs">
}`} MAC: {machineInfo.macAddress}
> </p>
{machineInfo.serialNumber} )}
</span> {machineInfo.totalCount !== undefined && (
)} <p className="text-xs">
{isPolling && ( Total stitches:{" "}
<ArrowPathIcon {machineInfo.totalCount.toLocaleString()}
className="w-3.5 h-3.5 text-primary-200 animate-spin" </p>
title="Auto-refreshing status" )}
/> {machineInfo.serviceCount !== undefined && (
)} <p className="text-xs">
</div> Stitches since service:{" "}
<div className="flex items-center gap-2 mt-1 min-h-[32px]"> {machineInfo.serviceCount.toLocaleString()}
{isConnected ? ( </p>
<> )}
<Button </div>
onClick={disconnect} </TooltipContent>
size="sm" </Tooltip>
variant="outline"
className="gap-1.5 bg-white/10 hover:bg-danger-600 text-primary-100 hover:text-white border-white/20 hover:border-danger-600 flex-shrink-0"
title="Disconnect from machine"
aria-label="Disconnect from machine"
>
<XMarkIcon className="w-3 h-3" />
Disconnect
</Button>
<Badge
variant="outline"
className="gap-1.5 px-2.5 py-1.5 sm:py-1 text-sm font-semibold bg-white/20 text-white border-white/30 flex-shrink-0"
>
<StatusIcon className="w-3 h-3" />
{machineStatusName}
</Badge>
</>
) : (
<p className="text-xs text-primary-200">Not Connected</p>
)}
{/* Error indicator - always render to prevent layout shift */}
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="destructive"
className={cn(
"gap-1.5 flex-shrink-0",
machineErrorMessage || pyodideError
? "animate-pulse hover:animate-none"
: "invisible pointer-events-none",
)}
title="Click to view error details"
aria-label="View error details"
disabled={!(machineErrorMessage || pyodideError)}
>
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{(() => {
if (pyodideError) return "Python Error";
if (isPairingError) return "Pairing Required";
const errorMsg = machineErrorMessage || "";
// Categorize by error message content
if (
errorMsg.toLowerCase().includes("bluetooth") ||
errorMsg.toLowerCase().includes("connection")
) {
return "Connection Error";
}
if (errorMsg.toLowerCase().includes("upload")) {
return "Upload Error";
}
if (errorMsg.toLowerCase().includes("pattern")) {
return "Pattern Error";
}
if (machineError !== undefined) {
return `Machine Error`;
}
// Default fallback
return "Error";
})()}
</span>
</Button>
</PopoverTrigger>
{/* Error popover content */}
{(machineErrorMessage || pyodideError) && (
<ErrorPopoverContent
machineError={
machineError != 0xdd ? machineError : undefined
}
isPairingError={isPairingError}
errorMessage={machineErrorMessage}
pyodideError={pyodideError}
/>
)} )}
</Popover> {isPolling && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ArrowPathIcon className="w-3.5 h-3.5 text-primary-200 animate-spin" />
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Auto-refreshing machine status</p>
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
{isConnected ? (
<>
<Button
onClick={disconnect}
size="sm"
variant="outline"
className="gap-1.5 bg-white/10 hover:bg-danger-600 text-primary-100 hover:text-white border-white/20 hover:border-danger-600 flex-shrink-0"
aria-label="Disconnect from machine"
>
<XMarkIcon className="w-3 h-3" />
Disconnect
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="gap-1.5 px-2.5 py-1.5 sm:py-1 text-sm font-semibold bg-white/20 text-white border-white/30 flex-shrink-0 cursor-help"
>
<StatusIcon className="w-3 h-3" />
{machineStatusName}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{stateVisual.description}</p>
</TooltipContent>
</Tooltip>
</>
) : (
<p className="text-xs text-primary-200">Not Connected</p>
)}
{/* Error indicator - always render to prevent layout shift */}
<Popover>
<PopoverTrigger asChild>
<Button
size="sm"
variant="destructive"
className={cn(
"gap-1.5 flex-shrink-0",
machineErrorMessage || pyodideError
? "animate-pulse hover:animate-none"
: "invisible pointer-events-none",
)}
aria-label="View error details"
disabled={!(machineErrorMessage || pyodideError)}
>
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{(() => {
if (pyodideError) return "Python Error";
if (isPairingError) return "Pairing Required";
const errorMsg = machineErrorMessage || "";
// Categorize by error message content
if (
errorMsg.toLowerCase().includes("bluetooth") ||
errorMsg.toLowerCase().includes("connection")
) {
return "Connection Error";
}
if (errorMsg.toLowerCase().includes("upload")) {
return "Upload Error";
}
if (errorMsg.toLowerCase().includes("pattern")) {
return "Pattern Error";
}
if (machineError !== undefined) {
return `Machine Error`;
}
// Default fallback
return "Error";
})()}
</span>
</Button>
</PopoverTrigger>
{/* Error popover content */}
{(machineErrorMessage || pyodideError) && (
<ErrorPopoverContent
machineError={
machineError != 0xdd ? machineError : undefined
}
isPairingError={isPairingError}
errorMessage={machineErrorMessage}
pyodideError={pyodideError}
/>
)}
</Popover>
</div>
</div> </div>
</div> </div>
</div>
{/* Workflow Stepper - Flexible width column */} {/* Workflow Stepper - Flexible width column */}
<div> <div>
<WorkflowStepper /> <WorkflowStepper />
</div>
</div> </div>
</div> </header>
</header> </TooltipProvider>
); );
} }