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 { Item, Tag } from "../api";
import { getTagColor } from "../utils/tagColors"; import { getTagColor } from "../utils/tagColors";
import TagAutocompleteInput from "./TagAutocompleteInput";
interface ItemRowProps { interface ItemRowProps {
item: Item; item: Item;
@ -24,14 +25,7 @@ export default function ItemRow({
const [editName, setEditName] = useState(item.name); const [editName, setEditName] = useState(item.name);
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const [addingTag, setAddingTag] = useState(false); const [addingTag, setAddingTag] = useState(false);
const [newTagInput, setNewTagInput] = useState(""); const inputRef = useRef<HTMLInputElement>(null);
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
const filteredSuggestions = allTags.filter(
(t) =>
t.name.toLowerCase().includes(newTagInput.toLowerCase()) &&
!item.tags.some((it) => it.id === t.id)
);
function handleSave() { function handleSave() {
if (editName.trim() && editName !== item.name) { if (editName.trim() && editName !== item.name) {
@ -64,8 +58,13 @@ export default function ItemRow({
onMouseEnter={() => setHover(true)} onMouseEnter={() => setHover(true)}
onMouseLeave={() => { onMouseLeave={() => {
setHover(false); setHover(false);
setAddingTag(false); // Only close add tag input if not focused
setNewTagInput(""); if (
!addingTag ||
(inputRef.current && document.activeElement !== inputRef.current)
) {
setAddingTag(false);
}
}} }}
> >
{isEditing ? ( {isEditing ? (
@ -121,116 +120,33 @@ export default function ItemRow({
{hover && !addingTag && ( {hover && !addingTag && (
<button <button
className="text-xs text-blue-500 hover:underline" className="text-xs text-blue-500 hover:underline"
onClick={() => setAddingTag(true)} onClick={() => {
setAddingTag(true);
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}}
> >
+ Tag + Tag
</button> </button>
)} )}
{addingTag && ( {addingTag && (
<div className="relative"> <TagAutocompleteInput
<input ref={inputRef}
className="border rounded px-1 py-0.5" allTags={allTags}
placeholder="Tag..." selectedTags={item.tags}
value={newTagInput} onAddTag={(tagId, viaTab) => {
onChange={e => { onAddTag(item.id, tagId);
setNewTagInput(e.target.value); if (viaTab) {
setAutocompleteIndex(0); setTimeout(() => setAddingTag(true), 0);
}} } else {
autoFocus setAddingTag(false);
onKeyDown={e => { }
// Autocomplete navigation }}
const match = newTagInput.match(/^(\w*)$/); onEscape={() => setAddingTag(false)}
const suggestions = match placeholder="Tag suchen..."
? 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>
)} )}
{/* Löschen-Button am Ende der Zeile */} {/* 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 // 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 { useParams } from "react-router-dom";
import { getTripItems, toggleTripItem, updateTrip, getTags } from "../api"; import { getTripItems, toggleTripItem, updateTrip, getTags } from "../api";
import TagAutocompleteInput from "../components/TagAutocompleteInput";
export default function TripChecklist({ trips }: { trips: any[] }) { export default function TripChecklist({ trips }: { trips: any[] }) {
const { id } = useParams(); const { id } = useParams();
@ -9,6 +10,8 @@ export default function TripChecklist({ trips }: { trips: any[] }) {
const [allTags, setAllTags] = useState<any[]>([]); const [allTags, setAllTags] = useState<any[]>([]);
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [hoveredTag, setHoveredTag] = useState<string | null>(null); const [hoveredTag, setHoveredTag] = useState<string | null>(null);
const [addingTag, setAddingTag] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
@ -280,45 +283,40 @@ export default function TripChecklist({ trips }: { trips: any[] }) {
); );
}) })
)} )}
{/* Tag hinzufügen */} {/* "+Tag" link and dropdown */}
<div className="relative"> {!addingTag ? (
<input <button
type="text" className="text-blue-500 underline text-sm ml-2"
placeholder="+ Tag" onClick={() => {
value={tagInput} setAddingTag(true);
onChange={e => setTagInput(e.target.value)} setTimeout(() => {
className="border rounded px-2 py-0.5 text-sm ml-2" inputRef.current?.focus();
list="tag-suggestions" }, 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> </div>
{/* Progressbar integriert am unteren Rand */} {/* Progressbar integriert am unteren Rand */}
<div className="mt-6"> <div className="mt-6">
@ -344,7 +342,7 @@ export default function TripChecklist({ trips }: { trips: any[] }) {
transition: "width 0.3s", transition: "width 0.3s",
}} }}
/> />
{/* Optional: gray overlay for unfilled part */} {/* gray overlay for unfilled part */}
<div <div
className="h-full w-full" className="h-full w-full"
style={{ 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 className="text-right text-xs text-gray-500 mt-1">{progress}%</div>
</div> </div>
</div> </div>
{/* ...Rest der Checklist... */} {/* ...existing code... */}
<ul <ul
className="grid grid-cols-1 gap-x-8 gap-y-2" className="grid grid-cols-1 gap-x-8 gap-y-2"
> >