diff --git a/frontend/src/components/ItemRow.tsx b/frontend/src/components/ItemRow.tsx index 51eac63..01b30d3 100644 --- a/frontend/src/components/ItemRow.tsx +++ b/frontend/src/components/ItemRow.tsx @@ -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(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 && ( )} {addingTag && ( -
- { - 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 && ( - - ) - ); - })()} -
+ { + 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 */} diff --git a/frontend/src/components/TagAutocompleteInput.tsx b/frontend/src/components/TagAutocompleteInput.tsx new file mode 100644 index 0000000..ae83705 --- /dev/null +++ b/frontend/src/components/TagAutocompleteInput.tsx @@ -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(function TagAutocompleteInput( + { allTags, selectedTags, onAddTag, onEscape, placeholder = "+ Tag" }, + ref +) { + const [input, setInput] = useState(""); + const [autocompleteIndex, setAutocompleteIndex] = useState(0); + const inputRef = useRef(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) { + 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 ( +
+ { + 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 && ( +
+ Kein passender Tag +
+ )} + {filteredSuggestions.length > 0 && ( +
    + {filteredSuggestions.map((tag, idx) => ( +
  • { + onAddTag(tag.id); + setInput(""); + setAutocompleteIndex(0); + }} + onMouseEnter={() => setAutocompleteIndex(idx)} + > + #{tag.name} + {tag.mandatory && ( + ! + )} +
  • + ))} +
+ )} +
+ ); +}); + +export default TagAutocompleteInput; \ No newline at end of file diff --git a/frontend/src/pages/TripChecklist.tsx b/frontend/src/pages/TripChecklist.tsx index 564f390..5bfd96d 100644 --- a/frontend/src/pages/TripChecklist.tsx +++ b/frontend/src/pages/TripChecklist.tsx @@ -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([]); const [tagInput, setTagInput] = useState(""); const [hoveredTag, setHoveredTag] = useState(null); + const [addingTag, setAddingTag] = useState(false); + const inputRef = useRef(null); useEffect(() => { if (id) { @@ -280,45 +283,40 @@ export default function TripChecklist({ trips }: { trips: any[] }) { ); }) )} - {/* Tag hinzufügen */} -
- setTagInput(e.target.value)} - className="border rounded px-2 py-0.5 text-sm ml-2" - list="tag-suggestions" + {/* "+Tag" link and dropdown */} + {!addingTag ? ( + + ) : ( + { + handleAddTag(tagId); + if (viaTab) { + setTimeout(() => { + setAddingTag(true); + inputRef.current?.focus(); + }, 0); + } else { + setAddingTag(false); + } + }} + onEscape={() => setAddingTag(false)} + placeholder="Tag suchen..." /> - - {allTags - .filter( - (t) => - t.name.toLowerCase().includes(tagInput.toLowerCase()) && - !selectedTags.some((st) => st.id === t.id) - ) - .map((t) => ( - - {tagInput && - allTags - .filter( - (t) => - t.name.toLowerCase() === tagInput.toLowerCase() && - !selectedTags.some((st) => st.id === t.id) - ) - .map((t) => ( - - ))} -
+ )} {/* Progressbar integriert am unteren Rand */}
@@ -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 */}
{progress}%
- {/* ...Rest der Checklist... */} + {/* ...existing code... */}