feat(timesheet): add searchable tag selectors

This commit is contained in:
2026-04-25 19:09:10 +03:30
parent 8b16344aef
commit cf7dd06046
2 changed files with 77 additions and 25 deletions

View File

@@ -56,6 +56,7 @@ function FilterTagMultiSelect({
title: string; title: string;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({}); const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({});
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
@@ -105,7 +106,17 @@ function FilterTagMultiSelect({
}; };
}, [isOpen]); }, [isOpen]);
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
}
}, [isOpen]);
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id)); 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; const label = selectedTags.length > 0 ? selectedTags.map((tag) => tag.name).join(" | ") : title;
return ( return (
@@ -127,8 +138,20 @@ function FilterTagMultiSelect({
style={dropdownStyle} style={dropdownStyle}
className="rounded-xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900" className="rounded-xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900"
> >
<div className="max-h-64 space-y-1 overflow-y-auto"> <div className="border-b border-slate-200 p-2 dark:border-slate-700">
{tags.map((tag) => { <div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(event) => 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"
/>
</div>
</div>
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
{filteredTags.map((tag) => {
const selected = selectedTagIds.includes(tag.id); const selected = selectedTagIds.includes(tag.id);
return ( return (
<button <button
@@ -156,6 +179,11 @@ function FilterTagMultiSelect({
</button> </button>
); );
})} })}
{filteredTags.length === 0 && (
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
No tags found.
</div>
)}
</div> </div>
</div>, </div>,
document.body, document.body,

View File

@@ -533,6 +533,7 @@ function TagMultiSelect({
portalOwnerId?: string; portalOwnerId?: string;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -587,8 +588,18 @@ function TagMultiSelect({
}; };
}, [isOpen]); }, [isOpen]);
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
}
}, [isOpen]);
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name); const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
const joinedSelectedLabels = selectedLabels.join(" | "); const joinedSelectedLabels = selectedLabels.join(" | ");
const normalizedSearch = searchQuery.trim().toLowerCase();
const filteredTags = normalizedSearch
? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch))
: tags;
const buttonLabel = compact const buttonLabel = compact
? selectedTags.length > 0 ? selectedTags.length > 0
? joinedSelectedLabels ? joinedSelectedLabels
@@ -638,8 +649,21 @@ function TagMultiSelect({
{tags.length === 0 ? ( {tags.length === 0 ? (
<p className="px-2 py-2 text-sm text-slate-500 dark:text-slate-400">{emptyHint}</p> <p className="px-2 py-2 text-sm text-slate-500 dark:text-slate-400">{emptyHint}</p>
) : ( ) : (
<div className="max-h-56 space-y-1 overflow-y-auto"> <>
{tags.map((tag) => { <div className="border-b border-slate-200 p-2 dark:border-slate-700">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(event) => 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"
/>
</div>
</div>
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
{filteredTags.map((tag) => {
const selected = selectedTags.includes(tag.id); const selected = selectedTags.includes(tag.id);
return ( return (
<button <button
@@ -2306,8 +2330,8 @@ export default function Timesheet() {
client: t.projects?.clientLabel || "Client", client: t.projects?.clientLabel || "Client",
tags: t.tags?.title || "Tags", tags: t.tags?.title || "Tags",
clear: extendedTimesheet.clearFilters || "Clear filters", clear: extendedTimesheet.clearFilters || "Clear filters",
customFrom: extendedTimesheet.customFromLabel || "From", customFrom: extendedTimesheet.customFromLabel || "From date",
customTo: extendedTimesheet.customToLabel || "To", customTo: extendedTimesheet.customToLabel || "To date",
allClients: extendedTimesheet.allClientsLabel || "All clients", allClients: extendedTimesheet.allClientsLabel || "All clients",
allProjects: extendedTimesheet.allProjectsLabel || "All projects", allProjects: extendedTimesheet.allProjectsLabel || "All projects",
allTags: extendedTimesheet.allTagsLabel || "All tags", allTags: extendedTimesheet.allTagsLabel || "All tags",