feat(11-03): add TagMultiSelect component with hashed tag colors
- TAG_COLORS 7-color pastel palette + getTagColorIndex() deterministic name->color hash (D-07, no stored color column) - renders tag badges with inline remove (X), calls removeTagFromService - "+" dropdown to add a new tag via Enter, calls addTagToService - closes dropdown on outside click and Escape; inline error display
This commit is contained in:
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useTransition } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X, Plus } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { addTagToService, removeTagFromService } from "@/app/admin/catalog/actions";
|
||||||
|
|
||||||
|
// D-07: deterministic name -> color index, no stored `color` column.
|
||||||
|
// 7-color pastel palette, bg-*-100/text-*-900 pairs for AA contrast on white.
|
||||||
|
const TAG_COLORS = [
|
||||||
|
"bg-blue-100 text-blue-900",
|
||||||
|
"bg-green-100 text-green-900",
|
||||||
|
"bg-purple-100 text-purple-900",
|
||||||
|
"bg-pink-100 text-pink-900",
|
||||||
|
"bg-amber-100 text-amber-900",
|
||||||
|
"bg-teal-100 text-teal-900",
|
||||||
|
"bg-orange-100 text-orange-900",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTagColorIndex(name: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + name.charCodeAt(i);
|
||||||
|
hash |= 0; // 32-bit int
|
||||||
|
}
|
||||||
|
return Math.abs(hash) % TAG_COLORS.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagMultiSelectProps {
|
||||||
|
tags: string[];
|
||||||
|
serviceId: string;
|
||||||
|
onTagsChanged?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagMultiSelect({ tags, serviceId, onTagsChanged }: TagMultiSelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [newTag, setNewTag] = useState("");
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
function handleAddTag() {
|
||||||
|
const trimmed = newTag.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await addTagToService(serviceId, trimmed);
|
||||||
|
setNewTag("");
|
||||||
|
onTagsChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveTag(tagName: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await removeTagFromService(serviceId, tagName);
|
||||||
|
onTagsChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Errore nella rimozione");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full min-w-[140px]">
|
||||||
|
<div
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
className="flex flex-wrap gap-1 items-center px-2 py-1 rounded cursor-pointer hover:bg-[#f0f0f0] transition-colors duration-150 min-h-[28px]"
|
||||||
|
>
|
||||||
|
{tags.length === 0 ? (
|
||||||
|
<span className="text-xs text-[#71717a]">—</span>
|
||||||
|
) : (
|
||||||
|
tags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
TAG_COLORS[getTagColorIndex(tag)],
|
||||||
|
"border-transparent text-xs px-2 py-0.5 gap-1 font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveTag(tag);
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
className="hover:opacity-70 disabled:opacity-30"
|
||||||
|
aria-label={`Rimuovi tag ${tag}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<Plus className="h-3.5 w-3.5 text-[#71717a]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-2 z-10 min-w-[200px]">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Nome tag..."
|
||||||
|
value={newTag}
|
||||||
|
onChange={(e) => setNewTag(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTag();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm h-8 ring-1 ring-primary"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddTag}
|
||||||
|
disabled={isPending || !newTag.trim()}
|
||||||
|
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90 h-8 px-2"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user