Add search page with popup UI
This commit is contained in:
140
frontend/src/pages/Search.tsx
Normal file
140
frontend/src/pages/Search.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../contexts/LanguageContext';
|
||||
import { useSidebar } from '../contexts/SidebarContext';
|
||||
import '../styles/Search.css';
|
||||
|
||||
type SearchResult = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
const PANEL_ESTIMATED_HEIGHT = 260;
|
||||
|
||||
export default function Search() {
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [direction, setDirection] = useState<'up' | 'down'>('down');
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const results = useMemo<SearchResult[]>(() => {
|
||||
const term = query.trim().toLowerCase();
|
||||
if (!term) return [];
|
||||
return [];
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const updateDirection = () => {
|
||||
const rect = wrapperRef.current?.getBoundingClientRect();
|
||||
if (!rect || typeof window === 'undefined') return;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const shouldOpenUp = spaceBelow < PANEL_ESTIMATED_HEIGHT && spaceAbove > spaceBelow;
|
||||
setDirection(shouldOpenUp ? 'up' : 'down');
|
||||
};
|
||||
updateDirection();
|
||||
window.addEventListener('resize', updateDirection);
|
||||
return () => window.removeEventListener('resize', updateDirection);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const nextTarget = event.relatedTarget as Node | null;
|
||||
if (nextTarget && wrapperRef.current?.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value);
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const showResults = query.trim().length > 0;
|
||||
const showPopover = isOpen;
|
||||
const hintText = showResults
|
||||
? t.searchPage.noResults
|
||||
: t.searchPage.hint;
|
||||
|
||||
return (
|
||||
<main className="main-content search-root">
|
||||
<div className="page-tabs-container">
|
||||
<div className="page-tabs-slider">
|
||||
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
<div className="page-title-section">
|
||||
<span className="page-title-text">{t.searchPage.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-content">
|
||||
<div className="content-narrow search-panel">
|
||||
<div className="search-input-wrapper" ref={wrapperRef}>
|
||||
<div className="search-input-group">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={t.searchPage.placeholder}
|
||||
value={query}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`search-popover ${showPopover ? 'open' : ''}`}
|
||||
data-direction={direction}
|
||||
aria-hidden={!showPopover}
|
||||
>
|
||||
{results.length > 0 ? (
|
||||
<div className="search-results">
|
||||
{results.map((result) => (
|
||||
<button key={result.id} className="search-result" type="button">
|
||||
<span className="search-result-icon material-symbols-outlined">
|
||||
{result.icon || 'search'}
|
||||
</span>
|
||||
<span className="search-result-text">
|
||||
<span className="search-result-title">{result.title}</span>
|
||||
{result.subtitle && (
|
||||
<span className="search-result-subtitle">{result.subtitle}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="search-empty">
|
||||
<span className="material-symbols-outlined">manage_search</span>
|
||||
<span>{hintText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user