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