Add search page with popup UI

This commit is contained in:
2025-12-22 19:09:31 +01:00
parent 1ff1103c67
commit 8b4a639c77
5 changed files with 284 additions and 0 deletions

View 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>
);
}