From 005a1c08faacd942265828a7a70820d5c0bead8e Mon Sep 17 00:00:00 2001 From: Felix Zett Date: Wed, 17 Sep 2025 21:05:50 +0200 Subject: [PATCH] feat: tag autocomplete feature in search input --- frontend/src/pages/ItemsPage.tsx | 160 ++++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 25 deletions(-) diff --git a/frontend/src/pages/ItemsPage.tsx b/frontend/src/pages/ItemsPage.tsx index 9e75258..e9abc37 100644 --- a/frontend/src/pages/ItemsPage.tsx +++ b/frontend/src/pages/ItemsPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { getItems, getTags, @@ -41,6 +41,11 @@ export default function ItemsPage() { const [newItemTripId, setNewItemTripId] = useState(""); const [adding, setAdding] = useState(false); + const inputRef = useRef(null); + const [tagAutocomplete, setTagAutocomplete] = useState<{name: string, id: string}[]>([]); + const [autocompleteActive, setAutocompleteActive] = useState(false); + const [autocompleteIndex, setAutocompleteIndex] = useState(0); + async function loadData() { setLoading(true); try { @@ -102,10 +107,14 @@ export default function ItemsPage() { } // Filtern + const tagMatches = filterText.match(/#(\w+)/g) || []; + const tagNames = tagMatches.map(t => t.slice(1).toLowerCase()); + const textOnly = filterText.replace(/#\w+/g, "").trim(); + const filteredItems = items.filter((item) => { const matchesText = - filterText === "" || - item.name.toLowerCase().includes(filterText.toLowerCase()); + textOnly === "" || + item.name.toLowerCase().includes(textOnly.toLowerCase()); const matchesTags = selectedTags.length === 0 || @@ -126,38 +135,133 @@ export default function ItemsPage() { ); } + // Autocomplete-Logik + useEffect(() => { + const match = filterText.match(/#(\w*)$/); + if (match) { + const prefix = match[1].toLowerCase(); + const suggestions = tags + .filter(tag => tag.name.toLowerCase().startsWith(prefix) && !selectedTags.includes(tag.id)); + setTagAutocomplete(suggestions); + setAutocompleteActive(suggestions.length > 0 && prefix.length > 0); + setAutocompleteIndex(0); + } else { + setTagAutocomplete([]); + setAutocompleteActive(false); + setAutocompleteIndex(0); + } + }, [filterText, tags, selectedTags]); + + function handleSearchInput(value: string) { + setFilterText(value); + + // Tags aus dem Suchfeld extrahieren: #tag1 #tag2 + const tagMatches = value.match(/#(\w+)/g) || []; + const tagNames = tagMatches.map(t => t.slice(1).toLowerCase()); + const matchedTagIds = tags + .filter(tag => tagNames.includes(tag.name.toLowerCase())) + .map(tag => tag.id); + + setSelectedTags(matchedTagIds); + } + + function handleAutocompleteSelect(idx: number) { + const match = filterText.match(/#(\w*)$/); + if (!match) return; + const before = filterText.slice(0, match.index); + const tag = tagAutocomplete[idx]; + // Tag einfügen und mit Leerzeichen abschließen + const completed = `${before}#${tag.name} `; + setFilterText(completed); + setAutocompleteActive(false); + setTagAutocomplete([]); + setAutocompleteIndex(0); + // Tags neu setzen + handleSearchInput(completed); + // Fokus zurück aufs Input + inputRef.current?.focus(); + } + + function handleInputKeyDown(e: React.KeyboardEvent) { + if (autocompleteActive && tagAutocomplete.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setAutocompleteIndex(i => (i + 1) % tagAutocomplete.length); + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setAutocompleteIndex(i => (i - 1 + tagAutocomplete.length) % tagAutocomplete.length); + } + if (e.key === "Tab" || e.key === "Enter") { + e.preventDefault(); + handleAutocompleteSelect(autocompleteIndex); + } + } + } + return (

Items

- {/* Suche */} -
+ {/* Suche + Tag-Filter im Suchfeld */} +
setFilterText(e.target.value)} + onChange={e => handleSearchInput(e.target.value)} + onKeyDown={handleInputKeyDown} className="border rounded px-3 py-2 w-full shadow focus:outline-none focus:ring-2 focus:ring-blue-300" + autoComplete="off" /> + {/* Autocomplete-Dropdown: schon bei "#" anzeigen */} + {(() => { + const match = filterText.match(/#(\w*)$/); + if (match && (autocompleteActive || match[1] === "")) { + return ( +
    + {tagAutocomplete.length === 0 && match[1] === "" ? ( + tags.map((tag, idx) => ( +
  • handleAutocompleteSelect(idx)} + onMouseEnter={() => setAutocompleteIndex(idx)} + > + #{tag.name} + {tag.mandatory && !} +
  • + )) + ) : ( + tagAutocomplete.map((tag, idx) => ( +
  • handleAutocompleteSelect(idx)} + onMouseEnter={() => setAutocompleteIndex(idx)} + > + #{tag.name} + {tag.mandatory && !} +
  • + )) + )} +
+ ); + } + return null; + })()}
- {/* Tag-Filter */} -
- -
- - {/* Neue ItemRow als erste Zeile */} -
    -
  • - setNewItemName(e.target.value)} - placeholder="Neues Item..." +
    + setNewItemName(e.target.value)} + placeholder="Neues Item..." onKeyDown={e => { if (e.key === "Enter") handleAddItem(); }} @@ -181,7 +285,7 @@ export default function ItemsPage() { {/* Tags-Auswahl, nur wenn kein Trip gewählt */} {!newItemTripId && ( -
    +
    {tags.map((tag) => ( Hinzufügen +
    + + {/* Neue ItemRow als erste Zeile */} +
      +
    • +