feature: Migrate ConfirmDialog and BluetoothDevicePicker to shadcn dialogs

- Migrated ConfirmDialog to use shadcn AlertDialog component
  - Replaced custom modal structure with AlertDialogContent, AlertDialogHeader, AlertDialogFooter
  - Used AlertDialogAction and AlertDialogCancel for buttons
  - Maintained danger/warning variant support with border-top styling
  - Simplified code by removing manual escape key handling (built into AlertDialog)

- Migrated BluetoothDevicePicker to use shadcn Dialog component
  - Replaced custom modal structure with DialogContent, DialogHeader, DialogFooter
  - Used shadcn Button components with outline variant for device selection
  - Maintained scanning state UI with loading spinner in DialogDescription
  - Simplified code by removing manual escape key handling (built into Dialog)
  - Disabled close button for better UX during device selection

Both migrations maintain the same external API and functionality while significantly reducing code complexity.

🤖 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-20 23:13:09 +01:00
parent 3ca5edf4dc
commit 1820bcde77
2 changed files with 104 additions and 168 deletions

View file

@ -1,5 +1,14 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import type { BluetoothDevice } from "../types/electron"; import type { BluetoothDevice } from "../types/electron";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export function BluetoothDevicePicker() { export function BluetoothDevicePicker() {
const [devices, setDevices] = useState<BluetoothDevice[]>([]); const [devices, setDevices] = useState<BluetoothDevice[]>([]);
@ -40,111 +49,71 @@ export function BluetoothDevicePicker() {
setIsScanning(false); setIsScanning(false);
}, []); }, []);
// Handle escape key
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
handleCancel();
}
},
[handleCancel],
);
useEffect(() => {
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen, handleEscape]);
if (!isOpen) return null;
return ( return (
<div <Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" <DialogContent
onClick={handleCancel} className="border-t-4 border-primary-600 dark:border-primary-500"
> showCloseButton={false}
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 border-t-4 border-primary-600 dark:border-primary-500"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="bluetooth-picker-title"
aria-describedby="bluetooth-picker-message"
> >
<div className="p-6 border-b border-gray-300 dark:border-gray-600"> <DialogHeader>
<h3 <DialogTitle>Select Bluetooth Device</DialogTitle>
id="bluetooth-picker-title" <DialogDescription>
className="m-0 text-base lg:text-lg font-semibold dark:text-white" {isScanning && devices.length === 0 ? (
> <div className="flex items-center gap-3 py-2">
Select Bluetooth Device <svg
</h3> className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400"
</div> xmlns="http://www.w3.org/2000/svg"
<div className="p-6"> fill="none"
{isScanning && devices.length === 0 ? ( viewBox="0 0 24 24"
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300"> >
<svg <circle
className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400" className="opacity-25"
xmlns="http://www.w3.org/2000/svg" cx="12"
fill="none" cy="12"
viewBox="0 0 24 24" r="10"
> stroke="currentColor"
<circle strokeWidth="4"
className="opacity-25" />
cx="12" <path
cy="12" className="opacity-75"
r="10" fill="currentColor"
stroke="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
strokeWidth="4" />
></circle> </svg>
<path <span>Scanning for Bluetooth devices...</span>
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span id="bluetooth-picker-message">
Scanning for Bluetooth devices...
</span>
</div>
) : (
<>
<p
id="bluetooth-picker-message"
className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100"
>
{devices.length} device{devices.length !== 1 ? "s" : ""} found.
Select a device to connect:
</p>
<div className="space-y-2">
{devices.map((device) => (
<button
key={device.deviceId}
onClick={() => handleSelectDevice(device.deviceId)}
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-900 dark:hover:text-primary-100 active:bg-primary-200 dark:active:bg-primary-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={`Connect to ${device.deviceName}`}
>
<div className="font-semibold text-gray-900 dark:text-white">
{device.deviceName}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{device.deviceId}
</div>
</button>
))}
</div> </div>
</> ) : (
)} `${devices.length} device${devices.length !== 1 ? "s" : ""} found. Select a device to connect:`
</div> )}
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600"> </DialogDescription>
<button </DialogHeader>
onClick={handleCancel}
className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" {!isScanning && devices.length > 0 && (
aria-label="Cancel device selection" <div className="space-y-2">
> {devices.map((device) => (
<Button
key={device.deviceId}
onClick={() => handleSelectDevice(device.deviceId)}
variant="outline"
className="w-full h-auto px-4 py-3 justify-start"
>
<div className="text-left">
<div className="font-semibold">{device.deviceName}</div>
<div className="text-xs text-muted-foreground mt-1">
{device.deviceId}
</div>
</div>
</Button>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel Cancel
</button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</div> </Dialog>
); );
} }

View file

@ -1,4 +1,14 @@
import { useEffect, useCallback } from "react"; import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
@ -21,75 +31,32 @@ export function ConfirmDialog({
onCancel, onCancel,
variant = "warning", variant = "warning",
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
// Handle escape key
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onCancel();
}
},
[onCancel],
);
useEffect(() => {
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen, handleEscape]);
if (!isOpen) return null;
return ( return (
<div <AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" <AlertDialogContent
onClick={onCancel} className={cn(
> variant === "danger"
<div ? "border-t-4 border-danger-600 dark:border-danger-500"
className={`bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === "danger" ? "border-t-4 border-danger-600 dark:border-danger-500" : "border-t-4 border-warning-500 dark:border-warning-600"}`} : "border-t-4 border-warning-500 dark:border-warning-600",
onClick={(e) => e.stopPropagation()} )}
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-message"
> >
<div className="p-6 border-b border-gray-300 dark:border-gray-600"> <AlertDialogHeader>
<h3 <AlertDialogTitle>{title}</AlertDialogTitle>
id="dialog-title" <AlertDialogDescription>{message}</AlertDialogDescription>
className="m-0 text-base lg:text-lg font-semibold dark:text-white" </AlertDialogHeader>
> <AlertDialogFooter>
{title} <AlertDialogCancel onClick={onCancel}>{cancelText}</AlertDialogCancel>
</h3> <AlertDialogAction
</div>
<div className="p-6">
<p
id="dialog-message"
className="m-0 leading-relaxed text-gray-900 dark:text-gray-100"
>
{message}
</p>
</div>
<div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
<button
onClick={onCancel}
className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
autoFocus
aria-label="Cancel action"
>
{cancelText}
</button>
<button
onClick={onConfirm} onClick={onConfirm}
className={ className={cn(
variant === "danger" variant === "danger" &&
? "px-6 py-2.5 bg-danger-600 dark:bg-danger-700 text-white rounded-lg font-semibold text-sm hover:bg-danger-700 dark:hover:bg-danger-600 active:bg-danger-800 dark:active:bg-danger-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-danger-300 dark:focus:ring-danger-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" "bg-danger-600 hover:bg-danger-700 dark:bg-danger-700 dark:hover:bg-danger-600",
: "px-6 py-2.5 bg-primary-600 dark:bg-primary-700 text-white rounded-lg font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" )}
}
aria-label={`Confirm: ${confirmText}`}
> >
{confirmText} {confirmText}
</button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
); );
} }