feat: TagAutocompleteInput for tag management in ItemRow and TripChecklist
This commit is contained in:
parent
7b909ac6a6
commit
f263225180
3 changed files with 186 additions and 156 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
116
frontend/src/components/TagAutocompleteInput.tsx
Normal file
116
frontend/src/components/TagAutocompleteInput.tsx
Normal 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;
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue