feat: TagAutocompleteInput for tag management in ItemRow and TripChecklist

This commit is contained in:
Felix Zett 2025-09-20 17:05:51 +02:00
parent 7b909ac6a6
commit f263225180
3 changed files with 186 additions and 156 deletions

View file

@ -1,6 +1,7 @@
import React, { useState } from "react";
import React, { useState, useRef } from "react";
import { Item, Tag } from "../api";
import { getTagColor } from "../utils/tagColors";
import TagAutocompleteInput from "./TagAutocompleteInput";
interface ItemRowProps {
item: Item;
@ -24,14 +25,7 @@ export default function ItemRow({
const [editName, setEditName] = useState(item.name);
const [hover, setHover] = useState(false);
const [addingTag, setAddingTag] = useState(false);
const [newTagInput, setNewTagInput] = useState("");
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
const filteredSuggestions = allTags.filter(
(t) =>
t.name.toLowerCase().includes(newTagInput.toLowerCase()) &&
!item.tags.some((it) => it.id === t.id)
);
const inputRef = useRef<HTMLInputElement>(null);
function handleSave() {
if (editName.trim() && editName !== item.name) {
@ -64,8 +58,13 @@ export default function ItemRow({
onMouseEnter={() => setHover(true)}
onMouseLeave={() => {
setHover(false);
setAddingTag(false);
setNewTagInput("");
// Only close add tag input if not focused
if (
!addingTag ||
(inputRef.current && document.activeElement !== inputRef.current)
) {
setAddingTag(false);
}
}}
>
{isEditing ? (
@ -121,116 +120,33 @@ export default function ItemRow({
{hover && !addingTag && (
<button
className="text-xs text-blue-500 hover:underline"
onClick={() => setAddingTag(true)}
onClick={() => {
setAddingTag(true);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}}
>
+ Tag
</button>
)}
{addingTag && (
<div className="relative">
<input
className="border rounded px-1 py-0.5"
placeholder="Tag..."
value={newTagInput}
onChange={e => {
setNewTagInput(e.target.value);
setAutocompleteIndex(0);
}}
autoFocus
onKeyDown={e => {
// Autocomplete navigation
const match = newTagInput.match(/^(\w*)$/);
const suggestions = match
? allTags.filter(
tag =>
tag.name.toLowerCase().startsWith(match[1].toLowerCase()) &&
!item.tags.some((it) => it.id === tag.id)
)
: [];
const showDropdown = suggestions.length > 0 && match && match[1].length >= 0;
if (showDropdown) {
if (e.key === "ArrowDown") {
e.preventDefault();
setAutocompleteIndex(i => (i + 1) % suggestions.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setAutocompleteIndex(i => (i - 1 + suggestions.length) % suggestions.length);
return;
}
if (e.key === "Enter") {
e.preventDefault();
const tag = suggestions[autocompleteIndex];
if (tag) {
onAddTag(item.id, tag.id);
setAddingTag(false);
setNewTagInput("");
}
return;
}
if (e.key === "Tab") {
e.preventDefault();
const tag = suggestions[autocompleteIndex];
if (tag) {
onAddTag(item.id, tag.id);
setNewTagInput("");
setAutocompleteIndex(0);
// keep addingTag true so you can add another tag
setTimeout(() => {
(e.target as HTMLInputElement).focus();
}, 0);
}
return;
}
}
if (e.key === "Escape") {
setAddingTag(false);
setNewTagInput("");
}
}}
/>
{/* Autocomplete-Dropdown */}
{(() => {
const match = newTagInput.match(/^(\w*)$/);
const suggestions = match
? allTags.filter(
tag =>
tag.name.toLowerCase().startsWith(match[1].toLowerCase()) &&
!item.tags.some((it) => it.id === tag.id)
)
: [];
const showDropdown = suggestions.length > 0 && match && match[1].length >= 0;
if (showDropdown && autocompleteIndex >= suggestions.length) setAutocompleteIndex(0);
return (
showDropdown && (
<ul className="absolute bg-white border rounded mt-1 shadow-md z-10 w-full max-h-40 overflow-auto">
{suggestions.map((tag, idx) => (
<li
key={tag.id}
className={
"px-2 py-1 cursor-pointer flex items-center " +
(idx === autocompleteIndex ? "bg-blue-100 font-bold" : "")
}
onMouseDown={() => {
onAddTag(item.id, tag.id);
setAddingTag(false);
setNewTagInput("");
}}
onMouseEnter={() => setAutocompleteIndex(idx)}
>
#{tag.name}
{tag.mandatory && <span className="text-red-500 font-bold ml-1">!</span>}
</li>
))}
</ul>
)
);
})()}
</div>
<TagAutocompleteInput
ref={inputRef}
allTags={allTags}
selectedTags={item.tags}
onAddTag={(tagId, viaTab) => {
onAddTag(item.id, tagId);
if (viaTab) {
setTimeout(() => setAddingTag(true), 0);
} else {
setAddingTag(false);
}
}}
onEscape={() => setAddingTag(false)}
placeholder="Tag suchen..."
/>
)}
{/* Löschen-Button am Ende der Zeile */}

View file

@ -0,0 +1,116 @@
import React, { useState, useImperativeHandle, forwardRef, useRef } from "react";
import { Tag } from "../api";
interface TagAutocompleteInputProps {
allTags: Tag[];
selectedTags: Tag[];
onAddTag: (tagId: string, viaTab?: boolean) => void;
onEscape?: () => void;
placeholder?: string;
}
const TagAutocompleteInput = forwardRef<HTMLInputElement, TagAutocompleteInputProps>(function TagAutocompleteInput(
{ allTags, selectedTags, onAddTag, onEscape, placeholder = "+ Tag" },
ref
) {
const [input, setInput] = useState("");
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
const filteredSuggestions = allTags.filter(
(t) =>
!selectedTags.some((st) => st.id === t.id) &&
(input === "" || t.name.toLowerCase().startsWith(input.toLowerCase()))
);
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (filteredSuggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setAutocompleteIndex((i) => (i + 1) % filteredSuggestions.length);
}
if (e.key === "ArrowUp") {
e.preventDefault();
setAutocompleteIndex(
(i) => (i - 1 + filteredSuggestions.length) % filteredSuggestions.length
);
}
if (e.key === "Enter") {
e.preventDefault();
const tag = filteredSuggestions[autocompleteIndex];
if (tag) {
onAddTag(tag.id, false);
setInput("");
setAutocompleteIndex(0);
}
}
if (e.key === "Tab") {
e.preventDefault();
const tag = filteredSuggestions[autocompleteIndex];
if (tag) {
onAddTag(tag.id, true);
setInput("");
setAutocompleteIndex(0);
setTimeout(() => {
(e.target as HTMLInputElement).focus();
}, 0);
}
}
}
if (e.key === "Escape") {
setInput("");
if (onEscape) onEscape();
}
}
return (
<div className="relative">
<input
ref={inputRef}
type="text"
placeholder={placeholder}
value={input}
onChange={e => {
setInput(e.target.value);
setAutocompleteIndex(0);
}}
className="border rounded px-2 py-0.5 text-sm ml-2"
onKeyDown={handleKeyDown}
autoComplete="off"
/>
{input !== "" && filteredSuggestions.length === 0 && (
<div className="absolute left-0 top-full mt-1 text-xs text-gray-400 px-2">
Kein passender Tag
</div>
)}
{filteredSuggestions.length > 0 && (
<ul className="absolute left-0 top-full mt-1 bg-white border rounded shadow z-10 w-full max-h-40 overflow-auto">
{filteredSuggestions.map((tag, idx) => (
<li
key={tag.id}
className={
"px-3 py-1 cursor-pointer flex items-center " +
(idx === autocompleteIndex ? "bg-blue-100 font-bold" : "")
}
onMouseDown={() => {
onAddTag(tag.id);
setInput("");
setAutocompleteIndex(0);
}}
onMouseEnter={() => setAutocompleteIndex(idx)}
>
#{tag.name}
{tag.mandatory && (
<span className="text-red-500 font-bold ml-1">!</span>
)}
</li>
))}
</ul>
)}
</div>
);
});
export default TagAutocompleteInput;

View file

@ -1,7 +1,8 @@
// filepath: frontend/src/pages/TripChecklist.tsx
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { getTripItems, toggleTripItem, updateTrip, getTags } from "../api";
import TagAutocompleteInput from "../components/TagAutocompleteInput";
export default function TripChecklist({ trips }: { trips: any[] }) {
const { id } = useParams();
@ -9,6 +10,8 @@ export default function TripChecklist({ trips }: { trips: any[] }) {
const [allTags, setAllTags] = useState<any[]>([]);
const [tagInput, setTagInput] = useState("");
const [hoveredTag, setHoveredTag] = useState<string | null>(null);
const [addingTag, setAddingTag] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (id) {
@ -280,45 +283,40 @@ export default function TripChecklist({ trips }: { trips: any[] }) {
);
})
)}
{/* Tag hinzufügen */}
<div className="relative">
<input
type="text"
placeholder="+ Tag"
value={tagInput}
onChange={e => setTagInput(e.target.value)}
className="border rounded px-2 py-0.5 text-sm ml-2"
list="tag-suggestions"
{/* "+Tag" link and dropdown */}
{!addingTag ? (
<button
className="text-blue-500 underline text-sm ml-2"
onClick={() => {
setAddingTag(true);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}}
type="button"
>
+ Tag
</button>
) : (
<TagAutocompleteInput
ref={inputRef}
allTags={allTags}
selectedTags={selectedTags}
onAddTag={(tagId, viaTab) => {
handleAddTag(tagId);
if (viaTab) {
setTimeout(() => {
setAddingTag(true);
inputRef.current?.focus();
}, 0);
} else {
setAddingTag(false);
}
}}
onEscape={() => setAddingTag(false)}
placeholder="Tag suchen..."
/>
<datalist id="tag-suggestions">
{allTags
.filter(
(t) =>
t.name.toLowerCase().includes(tagInput.toLowerCase()) &&
!selectedTags.some((st) => st.id === t.id)
)
.map((t) => (
<option key={t.id} value={t.name} />
))}
</datalist>
{tagInput &&
allTags
.filter(
(t) =>
t.name.toLowerCase() === tagInput.toLowerCase() &&
!selectedTags.some((st) => st.id === t.id)
)
.map((t) => (
<button
key={t.id}
className="ml-2 text-xs text-blue-500 underline"
onClick={() => handleAddTag(t.id)}
type="button"
>
Hinzufügen
</button>
))}
</div>
)}
</div>
{/* Progressbar integriert am unteren Rand */}
<div className="mt-6">
@ -344,7 +342,7 @@ export default function TripChecklist({ trips }: { trips: any[] }) {
transition: "width 0.3s",
}}
/>
{/* Optional: gray overlay for unfilled part */}
{/* gray overlay for unfilled part */}
<div
className="h-full w-full"
style={{
@ -363,7 +361,7 @@ export default function TripChecklist({ trips }: { trips: any[] }) {
<div className="text-right text-xs text-gray-500 mt-1">{progress}%</div>
</div>
</div>
{/* ...Rest der Checklist... */}
{/* ...existing code... */}
<ul
className="grid grid-cols-1 gap-x-8 gap-y-2"
>