feat(11-03): add EditableCell click-to-edit component
- text/number/textarea/toggle types with click-to-edit display mode - Enter (non-textarea) saves, Escape cancels, blur saves - required validation with inline error, disabled state styling - ring-1 ring-primary focus per DESIGN-SYSTEM.md inline-edit pattern
This commit is contained in:
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface EditableCellProps {
|
||||||
|
value: string | number | boolean;
|
||||||
|
type?: "text" | "number" | "textarea" | "toggle";
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
formatDisplay?: (value: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditableCell({
|
||||||
|
value,
|
||||||
|
type = "text",
|
||||||
|
onSave,
|
||||||
|
required,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
formatDisplay,
|
||||||
|
}: EditableCellProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [tempValue, setTempValue] = useState(String(value));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTempValue(String(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
if ("select" in inputRef.current) {
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
if (disabled) return;
|
||||||
|
setError(null);
|
||||||
|
setTempValue(String(value));
|
||||||
|
setIsEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
if (required && tempValue.trim().length === 0) {
|
||||||
|
setError("Campo richiesto");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
onSave(tempValue);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setTempValue(String(value));
|
||||||
|
setError(null);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||||
|
if (e.key === "Enter" && type !== "textarea") {
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
let display: string;
|
||||||
|
if (type === "toggle") {
|
||||||
|
display = tempValue === "true" ? "✓ Attivo" : "✗ Disattivato";
|
||||||
|
} else {
|
||||||
|
const raw = String(value);
|
||||||
|
display = formatDisplay ? formatDisplay(raw) : raw || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={startEdit}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 rounded transition-colors duration-150 text-sm",
|
||||||
|
disabled
|
||||||
|
? "cursor-not-allowed opacity-50"
|
||||||
|
: "cursor-pointer hover:bg-[#f0f0f0]",
|
||||||
|
type === "textarea" && "line-clamp-2 max-w-md"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
ref={inputRef as React.Ref<HTMLTextAreaElement>}
|
||||||
|
value={tempValue}
|
||||||
|
onChange={(e) => setTempValue(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn("ring-1 ring-primary resize-none text-sm", error && "ring-2 ring-red-500")}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
) : type === "toggle" ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef as React.Ref<HTMLInputElement>}
|
||||||
|
type="checkbox"
|
||||||
|
checked={tempValue === "true"}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked ? "true" : "false";
|
||||||
|
setTempValue(next);
|
||||||
|
onSave(next);
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="h-4 w-4 cursor-pointer accent-[#1A463C]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
ref={inputRef as React.Ref<HTMLInputElement>}
|
||||||
|
type={type}
|
||||||
|
value={tempValue}
|
||||||
|
onChange={(e) => setTempValue(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
step={type === "number" ? "0.01" : undefined}
|
||||||
|
min={type === "number" ? "0" : undefined}
|
||||||
|
className={cn("ring-1 ring-primary h-8 text-sm", error && "ring-2 ring-red-500")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user