From cf7dd060462c5f505377f6abfa0e30af61d068a5 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sat, 25 Apr 2026 19:09:10 +0330 Subject: [PATCH] feat(timesheet): add searchable tag selectors --- .../timesheet/TimesheetFilterBar.tsx | 32 ++++++++- src/pages/Timesheet.tsx | 70 +++++++++++++------ 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/components/timesheet/TimesheetFilterBar.tsx b/src/components/timesheet/TimesheetFilterBar.tsx index 4914ae9..d30b574 100644 --- a/src/components/timesheet/TimesheetFilterBar.tsx +++ b/src/components/timesheet/TimesheetFilterBar.tsx @@ -56,6 +56,7 @@ function FilterTagMultiSelect({ title: string; }) { const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const [dropdownStyle, setDropdownStyle] = useState({}); const wrapperRef = useRef(null); const buttonRef = useRef(null); @@ -105,7 +106,17 @@ function FilterTagMultiSelect({ }; }, [isOpen]); + useEffect(() => { + if (!isOpen) { + setSearchQuery(""); + } + }, [isOpen]); + const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id)); + const normalizedSearch = searchQuery.trim().toLowerCase(); + const filteredTags = normalizedSearch + ? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch)) + : tags; const label = selectedTags.length > 0 ? selectedTags.map((tag) => tag.name).join(" | ") : title; return ( @@ -127,8 +138,20 @@ function FilterTagMultiSelect({ style={dropdownStyle} className="rounded-xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900" > -
- {tags.map((tag) => { +
+
+ + setSearchQuery(event.target.value)} + placeholder="Search tags..." + className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800" + /> +
+
+
+ {filteredTags.map((tag) => { const selected = selectedTagIds.includes(tag.id); return (
, document.body, diff --git a/src/pages/Timesheet.tsx b/src/pages/Timesheet.tsx index 3e5ad27..53d4d3d 100644 --- a/src/pages/Timesheet.tsx +++ b/src/pages/Timesheet.tsx @@ -531,9 +531,10 @@ function TagMultiSelect({ title: string; compact?: boolean; portalOwnerId?: string; -}) { - const [isOpen, setIsOpen] = useState(false); - const wrapperRef = useRef(null); +}) { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const wrapperRef = useRef(null); const buttonRef = useRef(null); const dropdownRef = useRef(null); const [dropdownStyle, setDropdownStyle] = useState({}); @@ -585,11 +586,21 @@ function TagMultiSelect({ window.removeEventListener("resize", closeOnViewportChange); window.removeEventListener("scroll", closeOnViewportChange, true); }; - }, [isOpen]); - - const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name); - const joinedSelectedLabels = selectedLabels.join(" | "); - const buttonLabel = compact + }, [isOpen]); + + useEffect(() => { + if (!isOpen) { + setSearchQuery(""); + } + }, [isOpen]); + + const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name); + const joinedSelectedLabels = selectedLabels.join(" | "); + const normalizedSearch = searchQuery.trim().toLowerCase(); + const filteredTags = normalizedSearch + ? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch)) + : tags; + const buttonLabel = compact ? selectedTags.length > 0 ? joinedSelectedLabels : "" @@ -629,19 +640,32 @@ function TagMultiSelect({ {isOpen && ( createPortal( -
- {tags.length === 0 ? ( -

{emptyHint}

- ) : ( -
- {tags.map((tag) => { - const selected = selectedTags.includes(tag.id); - return ( +
+ {tags.length === 0 ? ( +

{emptyHint}

+ ) : ( + <> +
+
+ + setSearchQuery(event.target.value)} + placeholder="Search tags..." + className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800" + /> +
+
+
+ {filteredTags.map((tag) => { + const selected = selectedTags.includes(tag.id); + return (