UNPKG

vj-ui-components

Version:

A collection of beautiful, customizable React UI components including versatile navigation with dual layout support (sidebar/top), stylish input fields with icon support, advanced search with recommendations and autocomplete, elegant modals with animation

554 lines (503 loc) 16.5 kB
import React, { useState, useEffect, useRef, forwardRef } from "react"; import { IconSearch, IconX, IconClock, IconTrendingUp, IconArrowRight } from "@tabler/icons-react"; const Search = forwardRef(({ placeholder = "Search...", value = "", onChange, onSelect, onSearch, // Data sources suggestions = [], recentSearches = [], trendingSearches = [], // Theming props primaryColor = "#2563eb", secondaryColor = "#1e40af", backgroundColor = "rgba(255, 255, 255, 0.1)", textColor = "#1f2937", placeholderColor = "#6b7280", borderColor = "rgba(255, 255, 255, 0.2)", focusBorderColor = "#3b82f6", // Styling props size = "md", // "sm", "md", "lg" variant = "default", // "default", "filled", "outlined", "glassmorphism" borderRadius = "12px", className = "", // Behavior props showRecentSearches = true, showTrendingSearches = true, showSuggestions = true, maxResults = 8, enableAutocomplete = true, debounceMs = 300, clearOnSelect = false, // Custom content leftIcon = <IconSearch size={18} />, emptyStateMessage = "No results found", recentSearchesTitle = "Recent Searches", trendingSearchesTitle = "Trending", suggestionsTitle = "Suggestions", ...rest }, ref) => { const [inputValue, setInputValue] = useState(value); const [isOpen, setIsOpen] = useState(false); const [filteredSuggestions, setFilteredSuggestions] = useState([]); const [highlightedIndex, setHighlightedIndex] = useState(-1); const [searchHistory, setSearchHistory] = useState(recentSearches); const searchRef = useRef(null); const dropdownRef = useRef(null); const debounceRef = useRef(null); // Handle input changes with debouncing useEffect(() => { if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { if (inputValue && enableAutocomplete) { const filtered = suggestions .filter(item => typeof item === 'string' ? item.toLowerCase().includes(inputValue.toLowerCase()) : item.title?.toLowerCase().includes(inputValue.toLowerCase()) || item.description?.toLowerCase().includes(inputValue.toLowerCase()) ) .slice(0, maxResults); setFilteredSuggestions(filtered); } else { setFilteredSuggestions([]); } if (onChange) { onChange(inputValue); } }, debounceMs); return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, [inputValue, suggestions, enableAutocomplete, maxResults, debounceMs, onChange]); // Handle outside clicks useEffect(() => { const handleClickOutside = (event) => { if ( searchRef.current && !searchRef.current.contains(event.target) && dropdownRef.current && !dropdownRef.current.contains(event.target) ) { setIsOpen(false); setHighlightedIndex(-1); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Get size styles const getSizeStyles = () => { switch (size) { case "sm": return { padding: "8px 12px", fontSize: "0.875rem", minHeight: "36px", }; case "lg": return { padding: "16px 20px", fontSize: "1.125rem", minHeight: "56px", }; default: // md return { padding: "12px 16px", fontSize: "1rem", minHeight: "44px", }; } }; // Get variant styles const getVariantStyles = () => { const baseStyles = { border: `1px solid ${isOpen ? focusBorderColor : borderColor}`, borderRadius: borderRadius, transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)", outline: "none", width: "100%", fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", }; switch (variant) { case "filled": return { ...baseStyles, background: backgroundColor, backdropFilter: "none", boxShadow: isOpen ? `0 0 0 3px ${focusBorderColor}20` : "none", }; case "outlined": return { ...baseStyles, background: "transparent", backdropFilter: "none", boxShadow: isOpen ? `0 0 0 3px ${focusBorderColor}20` : "none", }; case "glassmorphism": return { ...baseStyles, background: `linear-gradient(135deg, ${backgroundColor}, rgba(255, 255, 255, 0.05))`, backdropFilter: "blur(10px)", boxShadow: isOpen ? `0 8px 32px rgba(0, 0, 0, 0.12), 0 0 0 1px ${focusBorderColor}40` : "0 4px 16px rgba(0, 0, 0, 0.08)", border: `1px solid ${isOpen ? focusBorderColor : 'rgba(255, 255, 255, 0.2)'}`, }; default: return { ...baseStyles, background: "#ffffff", boxShadow: isOpen ? `0 0 0 3px ${focusBorderColor}20, 0 2px 8px rgba(0, 0, 0, 0.1)` : "0 1px 3px rgba(0, 0, 0, 0.1)", }; } }; // Handle input changes const handleInputChange = (e) => { const newValue = e.target.value; setInputValue(newValue); setIsOpen(true); setHighlightedIndex(-1); }; // Handle input focus const handleFocus = () => { setIsOpen(true); }; // Handle search execution const handleSearch = (searchTerm = inputValue) => { if (searchTerm.trim()) { // Add to search history const newHistory = [searchTerm, ...searchHistory.filter(item => item !== searchTerm)].slice(0, 5); setSearchHistory(newHistory); // Call search callback if (onSearch) { onSearch(searchTerm); } // Clear input if specified if (clearOnSelect) { setInputValue(""); } setIsOpen(false); } }; // Handle item selection const handleItemSelect = (item) => { const searchTerm = typeof item === 'string' ? item : item.title || item.value || ''; setInputValue(searchTerm); if (onSelect) { onSelect(item); } handleSearch(searchTerm); }; // Handle keyboard navigation const handleKeyDown = (e) => { const totalItems = getTotalDropdownItems(); switch (e.key) { case 'ArrowDown': e.preventDefault(); setHighlightedIndex(prev => prev < totalItems - 1 ? prev + 1 : 0 ); break; case 'ArrowUp': e.preventDefault(); setHighlightedIndex(prev => prev > 0 ? prev - 1 : totalItems - 1 ); break; case 'Enter': e.preventDefault(); if (highlightedIndex >= 0) { const selectedItem = getItemAtIndex(highlightedIndex); handleItemSelect(selectedItem); } else { handleSearch(); } break; case 'Escape': setIsOpen(false); setHighlightedIndex(-1); if (searchRef.current) { searchRef.current.blur(); } break; } }; // Get total dropdown items count const getTotalDropdownItems = () => { let count = 0; if (inputValue && filteredSuggestions.length > 0) count += filteredSuggestions.length; if (!inputValue && showRecentSearches && searchHistory.length > 0) count += searchHistory.length; if (!inputValue && showTrendingSearches && trendingSearches.length > 0) count += trendingSearches.length; return count; }; // Get item at specific index const getItemAtIndex = (index) => { let currentIndex = 0; if (inputValue && filteredSuggestions.length > 0) { if (index < filteredSuggestions.length) { return filteredSuggestions[index]; } currentIndex += filteredSuggestions.length; } if (!inputValue && showRecentSearches && searchHistory.length > 0) { if (index < currentIndex + searchHistory.length) { return searchHistory[index - currentIndex]; } currentIndex += searchHistory.length; } if (!inputValue && showTrendingSearches && trendingSearches.length > 0) { return trendingSearches[index - currentIndex]; } return null; }; // Clear input const handleClear = () => { setInputValue(""); setIsOpen(false); setHighlightedIndex(-1); if (onChange) { onChange(""); } if (searchRef.current) { searchRef.current.focus(); } }; // Render dropdown item const renderItem = (item, index, type = "suggestion") => { const isHighlighted = index === highlightedIndex; const itemText = typeof item === 'string' ? item : item.title || item.value || ''; const itemDescription = typeof item === 'object' ? item.description : null; return ( <div key={`${type}-${index}`} className="search-dropdown-item" style={{ padding: "12px 16px", cursor: "pointer", borderRadius: "8px", margin: "2px 0", background: isHighlighted ? `${primaryColor}10` : "transparent", border: isHighlighted ? `1px solid ${primaryColor}30` : "1px solid transparent", transition: "all 0.2s ease", display: "flex", alignItems: "center", gap: "12px", }} onMouseDown={(e) => e.preventDefault()} onClick={() => handleItemSelect(item)} onMouseEnter={() => setHighlightedIndex(index)} > {type === "recent" && <IconClock size={16} style={{ color: placeholderColor }} />} {type === "trending" && <IconTrendingUp size={16} style={{ color: primaryColor }} />} {type === "suggestion" && leftIcon} <div style={{ flex: 1 }}> <div style={{ color: textColor, fontSize: "0.9rem", fontWeight: isHighlighted ? "500" : "400" }}> {itemText} </div> {itemDescription && ( <div style={{ color: placeholderColor, fontSize: "0.8rem", marginTop: "2px" }}> {itemDescription} </div> )} </div> {isHighlighted && <IconArrowRight size={14} style={{ color: primaryColor }} />} </div> ); }; const sizeStyles = getSizeStyles(); const variantStyles = getVariantStyles(); const containerStyles = { ...variantStyles, display: "flex", alignItems: "center", position: "relative", cursor: "text", padding: sizeStyles.padding, minHeight: sizeStyles.minHeight, }; return ( <div style={{ position: "relative", width: "100%" }} className={className}> {/* Search Input Container */} <div ref={searchRef} style={containerStyles} onClick={() => { if (ref?.current) { ref.current.focus(); } }} > {/* Left Icon */} <div style={{ display: "flex", alignItems: "center", color: placeholderColor, marginRight: "8px", flexShrink: 0 }}> {leftIcon} </div> {/* Input Element */} <input ref={ref} type="text" value={inputValue} onChange={handleInputChange} onFocus={handleFocus} onKeyDown={handleKeyDown} placeholder={placeholder} style={{ background: "transparent", border: "none", outline: "none", flex: 1, color: textColor, fontSize: sizeStyles.fontSize, fontFamily: "inherit", padding: 0, margin: 0, }} {...rest} /> {/* Clear Button */} {inputValue && ( <button type="button" onClick={handleClear} style={{ background: "none", border: "none", cursor: "pointer", padding: "4px", display: "flex", alignItems: "center", color: placeholderColor, marginLeft: "8px", borderRadius: "4px", transition: "all 0.2s ease", }} onMouseEnter={(e) => { e.target.style.background = "rgba(0, 0, 0, 0.1)"; }} onMouseLeave={(e) => { e.target.style.background = "none"; }} > <IconX size={16} /> </button> )} </div> {/* Dropdown */} {isOpen && ( <div ref={dropdownRef} style={{ position: "absolute", top: "100%", left: 0, right: 0, zIndex: 1000, marginTop: "4px", background: variant === "glassmorphism" ? `linear-gradient(135deg, ${backgroundColor}, rgba(255, 255, 255, 0.05))` : "#ffffff", backdropFilter: variant === "glassmorphism" ? "blur(10px)" : "none", border: `1px solid ${borderColor}`, borderRadius: borderRadius, boxShadow: "0 8px 32px rgba(0, 0, 0, 0.12)", maxHeight: "320px", overflowY: "auto", padding: "8px", }} > {/* Filtered Suggestions */} {inputValue && filteredSuggestions.length > 0 && ( <div> {showSuggestions && ( <div style={{ padding: "8px 16px 4px", fontSize: "0.75rem", fontWeight: "600", color: placeholderColor, textTransform: "uppercase", letterSpacing: "0.5px" }}> {suggestionsTitle} </div> )} {filteredSuggestions.map((item, index) => renderItem(item, index, "suggestion") )} </div> )} {/* Recent Searches */} {!inputValue && showRecentSearches && searchHistory.length > 0 && ( <div> <div style={{ padding: "8px 16px 4px", fontSize: "0.75rem", fontWeight: "600", color: placeholderColor, textTransform: "uppercase", letterSpacing: "0.5px" }}> {recentSearchesTitle} </div> {searchHistory.map((item, index) => renderItem(item, index, "recent") )} </div> )} {/* Trending Searches */} {!inputValue && showTrendingSearches && trendingSearches.length > 0 && ( <div> <div style={{ padding: "8px 16px 4px", fontSize: "0.75rem", fontWeight: "600", color: placeholderColor, textTransform: "uppercase", letterSpacing: "0.5px" }}> {trendingSearchesTitle} </div> {trendingSearches.map((item, index) => renderItem(item, index + (searchHistory.length || 0), "trending") )} </div> )} {/* Empty State */} {inputValue && filteredSuggestions.length === 0 && ( <div style={{ padding: "24px 16px", textAlign: "center", color: placeholderColor, fontSize: "0.9rem" }}> {emptyStateMessage} </div> )} </div> )} </div> ); }); Search.displayName = "Search"; export default Search;