diff --git a/apps/web/src/app/(main)/dashboard/oss-programs/ProgramsList.tsx b/apps/web/src/app/(main)/dashboard/oss-programs/ProgramsList.tsx index f6d569f4..7815cfdc 100644 --- a/apps/web/src/app/(main)/dashboard/oss-programs/ProgramsList.tsx +++ b/apps/web/src/app/(main)/dashboard/oss-programs/ProgramsList.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useMemo, useCallback } from "react"; +import { useState, useMemo, useCallback, useEffect } from "react"; import { Program } from "@/data/oss-programs/types"; -import { SearchInput, TagFilter, ProgramCard } from "@/components/oss-programs"; +import { SearchInput, TagFilter, ProgramCard, ProgramCardSkeleton } from "@/components/oss-programs"; interface ProgramsListProps { programs: Program[]; @@ -12,22 +12,36 @@ interface ProgramsListProps { export default function ProgramsList({ programs, tags }: ProgramsListProps) { const [searchQuery, setSearchQuery] = useState(""); const [selectedTags, setSelectedTags] = useState([]); + const [isLoading, setIsLoading] = useState(false); - // Memoize handlers to prevent child re-renders + // Memoized handlers const handleSearchChange = useCallback((value: string) => { + setIsLoading(true); setSearchQuery(value); }, []); const handleTagsChange = useCallback((newTags: string[]) => { + setIsLoading(true); setSelectedTags(newTags); }, []); + // Fake loading delay for smooth UX + useEffect(() => { + if (!isLoading) return; + + const timer = setTimeout(() => { + setIsLoading(false); + }, 200); // skeleton visible time + + return () => clearTimeout(timer); + }, [isLoading]); + const filteredPrograms = useMemo(() => { return programs.filter((program) => { const matchesSearch = program.name.toLowerCase().includes(searchQuery.toLowerCase()) || program.tags.some((tag) => - tag.toLowerCase().includes(searchQuery.toLowerCase()) + tag.toLowerCase().includes(searchQuery.toLowerCase()), ); const matchesTags = @@ -41,9 +55,9 @@ export default function ProgramsList({ programs, tags }: ProgramsListProps) { return (
- {/* Header Section */} + {/* Header */}
-

+

OSS Programs

@@ -61,19 +75,26 @@ export default function ProgramsList({ programs, tags }: ProgramsListProps) {
- {/* List Section */} -
- {filteredPrograms.length === 0 ? ( + {/* List */} +
+ {isLoading && + Array.from({ length: 3 }).map((_, i) => ( + + ))} + + {!isLoading && filteredPrograms.length === 0 && (
No programs found matching your criteria.
- ) : ( + )} + + {!isLoading && filteredPrograms.map((program) => ( - )) - )} + ))}
); } + diff --git a/apps/web/src/components/oss-programs/ProgramCardSkeleton.tsx b/apps/web/src/components/oss-programs/ProgramCardSkeleton.tsx new file mode 100644 index 00000000..ebd013bd --- /dev/null +++ b/apps/web/src/components/oss-programs/ProgramCardSkeleton.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +function ProgramCardSkeleton() { + return ( +
+ {/* Left Content */} +
+ {/* Title */} +
+ + {/* Description */} +
+
+ + {/* Region (desktop only) */} +
+
+
+
+
+
+ + {/* Chevron */} +
+
+
+
+ ); +} + +export default React.memo(ProgramCardSkeleton); diff --git a/apps/web/src/components/oss-programs/TagFilter.tsx b/apps/web/src/components/oss-programs/TagFilter.tsx index 85103b53..f08a181e 100644 --- a/apps/web/src/components/oss-programs/TagFilter.tsx +++ b/apps/web/src/components/oss-programs/TagFilter.tsx @@ -16,11 +16,13 @@ export default function TagFilter({ }: TagFilterProps) { const [filterInput, setFilterInput] = useState(""); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const dropdownRef = useRef(null); const inputRef = useRef(null); useEffect(() => { - if (!isDropdownOpen) return; // Only attach listener when open + if (!isDropdownOpen) return; function handleClickOutside(event: MouseEvent) { if ( @@ -31,11 +33,11 @@ export default function TagFilter({ } } - // Use passive listener for better scroll performance document.addEventListener("mousedown", handleClickOutside, { passive: true, }); - return () => document.removeEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); }, [isDropdownOpen]); const availableTags = useMemo(() => { @@ -46,10 +48,16 @@ export default function TagFilter({ ); }, [tags, selectedTags, filterInput]); + // Reset active suggestion on change + useEffect(() => { + setActiveIndex(0); + }, [filterInput, availableTags.length]); + const addTag = (tag: string) => { onTagsChange([...selectedTags, tag]); setFilterInput(""); setIsDropdownOpen(true); + setActiveIndex(0); inputRef.current?.focus(); }; @@ -58,6 +66,27 @@ export default function TagFilter({ }; const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isDropdownOpen || availableTags.length === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => + prev < availableTags.length - 1 ? prev + 1 : 0 + ); + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => + prev > 0 ? prev - 1 : availableTags.length - 1 + ); + } + + if (e.key === "Enter") { + e.preventDefault(); + addTag(availableTags[activeIndex]); + } + if ( e.key === "Backspace" && filterInput === "" && @@ -69,8 +98,11 @@ export default function TagFilter({ return (
+ {/* Input Container */}
{ inputRef.current?.focus(); setIsDropdownOpen(true); @@ -80,7 +112,8 @@ export default function TagFilter({ {selectedTags.map((tag) => ( {tag} ))} + setIsDropdownOpen(true)} - className="bg-transparent text-white placeholder-gray-500 focus:outline-none flex-1 min-w-0" + className="bg-transparent text-white placeholder-gray-500 + focus:outline-none flex-1 min-w-[120px]" />
- + +
- {/* Replaced Framer Motion with CSS transitions for better performance */} + {/* Dropdown */} {isDropdownOpen && ( -
+
{availableTags.length === 0 ? (
No matching tags found
) : ( - availableTags.map((tag) => ( + availableTags.map((tag, index) => ( diff --git a/apps/web/src/components/oss-programs/index.ts b/apps/web/src/components/oss-programs/index.ts index d53e19dd..480cf608 100644 --- a/apps/web/src/components/oss-programs/index.ts +++ b/apps/web/src/components/oss-programs/index.ts @@ -4,3 +4,4 @@ export { default as ProgramCard } from './ProgramCard'; export { default as ProgramHeader } from './ProgramHeader'; export { default as ProgramMetadata } from './ProgramMetadata'; export { default as ProgramSection } from './ProgramSection'; +export { default as ProgramCardSkeleton} from './ProgramCardSkeleton'