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