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

@@ -14,6 +14,7 @@ import Dashboard from './pages/Dashboard';
import Feature1 from './pages/Feature1';
import Feature2 from './pages/Feature2';
import Feature3 from './pages/Feature3';
import Search from './pages/Search';
import Notifications from './pages/Notifications';
import APIKeys from './pages/APIKeys';
import AdminPanel from './pages/AdminPanel';
@@ -70,6 +71,7 @@ function AppRoutes() {
<Route path="/feature1" element={<Feature1 />} />
<Route path="/feature2" element={<Feature2 />} />
<Route path="/feature3" element={<Feature3 />} />
<Route path="/search" element={<Search />} />
<Route path="/notifications" element={<Notifications />} />
<Route path="/api-keys" element={<APIKeys />} />
<Route path="/settings" element={<Settings />} />

View File

@@ -59,6 +59,12 @@
"feature3": "Feature 3 Integration",
"comingSoon": "Coming Soon"
},
"searchPage": {
"title": "Search",
"placeholder": "Search the system",
"hint": "Start typing to search",
"noResults": "No results"
},
"featuresPage": {
"title": "Features",
"subtitle": "Configure application features",

View File

@@ -59,6 +59,12 @@
"feature3": "Integrazione Funzione 3",
"comingSoon": "Prossimamente"
},
"searchPage": {
"title": "Cerca",
"placeholder": "Cerca nel sistema",
"hint": "Inizia a digitare per cercare",
"noResults": "Nessun risultato"
},
"featuresPage": {
"title": "Funzionalità",
"subtitle": "Configura le funzionalità dell'applicazione",

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

View File

@@ -0,0 +1,130 @@
.search-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-input-wrapper {
position: relative;
width: 100%;
}
.search-input-group {
display: flex;
align-items: center;
gap: var(--element-gap);
padding: var(--input-padding);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
width: 100%;
}
.search-input-group:focus-within {
border-color: rgba(var(--color-accent-rgb), 0.35);
box-shadow: var(--shadow-ring);
}
.search-input-group .material-symbols-outlined {
color: var(--color-text-secondary);
font-size: 20px;
}
.search-input-group input {
border: none;
outline: none;
background: transparent;
color: var(--color-text-primary);
width: 100%;
}
.search-popover {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 0.5rem);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 20;
}
.search-popover[data-direction='up'] {
top: auto;
bottom: calc(100% + 0.5rem);
transform: translateY(4px);
}
.search-popover.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.search-results {
display: flex;
flex-direction: column;
max-height: 260px;
overflow-y: auto;
}
.search-result {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: none;
background: transparent;
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
border-bottom: 1px solid var(--color-card-outline);
}
.search-result:last-child {
border-bottom: none;
}
.search-result:hover {
background: var(--color-bg-elevated);
}
.search-result-icon {
color: var(--color-text-secondary);
font-size: 20px;
}
.search-result-text {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.search-result-title {
font-weight: 600;
}
.search-result-subtitle {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.search-empty {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.search-empty .material-symbols-outlined {
font-size: 20px;
color: var(--color-text-muted);
}