100 lines
3.0 KiB
React
100 lines
3.0 KiB
React
|
|
/**
|
||
|
|
* InputDialog — replaces window.prompt().
|
||
|
|
* Usage:
|
||
|
|
* <InputDialog
|
||
|
|
* open={open} onOpenChange={setOpen}
|
||
|
|
* title="Rename Category"
|
||
|
|
* description="Enter a new name."
|
||
|
|
* label="Name" defaultValue={current} placeholder="e.g. Utilities"
|
||
|
|
* onConfirm={(value) => handleSave(value)}
|
||
|
|
* />
|
||
|
|
*/
|
||
|
|
import { useState, useEffect, useRef } from 'react';
|
||
|
|
import {
|
||
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||
|
|
} from '@/components/ui/dialog';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { Label } from '@/components/ui/label';
|
||
|
|
|
||
|
|
export function InputDialog({
|
||
|
|
open,
|
||
|
|
onOpenChange,
|
||
|
|
title,
|
||
|
|
description,
|
||
|
|
label,
|
||
|
|
defaultValue = '',
|
||
|
|
placeholder = '',
|
||
|
|
confirmLabel = 'Save',
|
||
|
|
cancelLabel = 'Cancel',
|
||
|
|
validate, // optional (value: string) => string | null (null = valid)
|
||
|
|
onConfirm,
|
||
|
|
loading = false,
|
||
|
|
}) {
|
||
|
|
const [value, setValue] = useState(defaultValue);
|
||
|
|
const [error, setError] = useState('');
|
||
|
|
const inputRef = useRef(null);
|
||
|
|
|
||
|
|
// Reset value when dialog opens
|
||
|
|
useEffect(() => {
|
||
|
|
if (open) {
|
||
|
|
setValue(defaultValue);
|
||
|
|
setError('');
|
||
|
|
// autofocus + select all after animation frame
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
inputRef.current?.focus();
|
||
|
|
inputRef.current?.select();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}, [open, defaultValue]);
|
||
|
|
|
||
|
|
const handleConfirm = () => {
|
||
|
|
const trimmed = value.trim();
|
||
|
|
if (!trimmed) { setError('This field is required.'); return; }
|
||
|
|
if (validate) {
|
||
|
|
const msg = validate(trimmed);
|
||
|
|
if (msg) { setError(msg); return; }
|
||
|
|
}
|
||
|
|
onConfirm?.(trimmed);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleKeyDown = (e) => {
|
||
|
|
if (e.key === 'Enter') { e.preventDefault(); handleConfirm(); }
|
||
|
|
if (e.key === 'Escape') onOpenChange?.(false);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
|
|
<DialogContent className="max-w-sm">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>{title}</DialogTitle>
|
||
|
|
{description && <DialogDescription>{description}</DialogDescription>}
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
{label && <Label htmlFor="input-dialog-field" className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Label>}
|
||
|
|
<Input
|
||
|
|
id="input-dialog-field"
|
||
|
|
ref={inputRef}
|
||
|
|
value={value}
|
||
|
|
onChange={(e) => { setValue(e.target.value); setError(''); }}
|
||
|
|
onKeyDown={handleKeyDown}
|
||
|
|
placeholder={placeholder}
|
||
|
|
className={error ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||
|
|
/>
|
||
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="ghost" onClick={() => onOpenChange?.(false)} disabled={loading}>
|
||
|
|
{cancelLabel}
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleConfirm} disabled={loading || !value.trim()}>
|
||
|
|
{loading ? 'Saving…' : confirmLabel}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|