Move the serach box to teh sidebar

This commit is contained in:
Urtzi Alfaro
2025-09-24 19:40:51 +02:00
parent be1fec17c4
commit e978d04800
3 changed files with 174 additions and 104 deletions

View File

@@ -212,10 +212,10 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
{/* Header */} {/* Header */}
{shouldShowHeader && ( {shouldShowHeader && (
<Header <Header
onMenuClick={toggleSidebar} onMenuClick={toggleSidebar}
sidebarCollapsed={isSidebarCollapsed} sidebarCollapsed={isSidebarCollapsed}
className="z-[var(--z-fixed)]" className="z-[var(--z-fixed)]"
/> />
)} )}
<div className="flex flex-1 relative"> <div className="flex flex-1 relative">

View File

@@ -13,7 +13,6 @@ import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel'
import { CompactLanguageSelector } from '../../ui/LanguageSelector'; import { CompactLanguageSelector } from '../../ui/LanguageSelector';
import { import {
Menu, Menu,
Search,
Bell, Bell,
X X
} from 'lucide-react'; } from 'lucide-react';
@@ -59,7 +58,7 @@ export interface HeaderProps {
} }
export interface HeaderRef { export interface HeaderRef {
focusSearch: () => void; // No search-related methods anymore
} }
/** /**
@@ -100,63 +99,28 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
clearAll clearAll
} = useNotifications(); } = useNotifications();
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...'); const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...');
// Focus search input
const focusSearch = useCallback(() => {
searchInputRef.current?.focus();
}, []);
// Expose ref methods // Expose ref methods
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
focusSearch, // No search functions available anymore
}), [focusSearch]); }), []);
// Handle search
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
}, []);
const handleSearchSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (searchValue.trim()) {
// TODO: Implement search functionality
console.log('Search:', searchValue);
}
}, [searchValue]);
const clearSearch = useCallback(() => {
setSearchValue('');
searchInputRef.current?.focus();
}, []);
// Keyboard shortcuts // Keyboard shortcuts
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Cmd/Ctrl + K for search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
focusSearch();
}
// Escape to close menus // Escape to close menus
if (e.key === 'Escape') { if (e.key === 'Escape') {
setIsNotificationPanelOpen(false); setIsNotificationPanelOpen(false);
if (isSearchFocused) {
searchInputRef.current?.blur();
}
} }
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
}, [focusSearch, isSearchFocused]); }, []);
// Close menus when clicking outside // Close menus when clicking outside
React.useEffect(() => { React.useEffect(() => {
@@ -238,71 +202,18 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
</div> </div>
)} )}
{/* Search */} {/* Space for potential future content */ }
{showSearch && isAuthenticated && ( {isAuthenticated && (
<form <div className="hidden md:flex items-center flex-1 max-w-md mx-4">
onSubmit={handleSearchSubmit} &nbsp; {/* Empty space to maintain layout consistency */}
className="hidden md:flex items-center flex-1 max-w-md mx-4" </div>
>
<div className="relative w-full">
<div className="absolute left-3 top-0 bottom-0 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-[var(--text-tertiary)]" />
</div>
<input
ref={searchInputRef}
type="text"
value={searchValue}
onChange={handleSearchChange}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder={defaultSearchPlaceholder}
className={clsx(
'w-full pl-10 pr-12 py-2.5 text-sm',
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
'rounded-lg transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
'focus:border-[var(--color-primary)]',
'placeholder:text-[var(--text-tertiary)]',
'h-9'
)}
aria-label={t('common:accessibility.search', 'Search in the application')}
/>
{searchValue ? (
<button
type="button"
onClick={clearSearch}
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
aria-label={t('common:actions.clear', 'Clear search')}
>
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
</button>
) : (
<div className="absolute right-3 top-0 bottom-0 flex items-center pointer-events-none">
<kbd className="hidden lg:inline-flex items-center justify-center h-5 px-1.5 text-xs text-[var(--text-tertiary)] font-mono bg-[var(--bg-tertiary)] rounded border border-[var(--border-primary)]">
K
</kbd>
</div>
)}
</div>
</form>
)} )}
</div> </div>
{/* Right section */} {/* Right section */}
{isAuthenticated && ( {isAuthenticated && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Mobile search */} {/* Placeholder for potential future items */ }
{showSearch && (
<Button
variant="ghost"
size="sm"
onClick={focusSearch}
className="md:hidden w-10 h-10 p-0 flex items-center justify-center"
aria-label={t('common:actions.search', 'Search')}
>
<Search className="h-5 w-5" />
</Button>
)}
{/* Language selector */} {/* Language selector */}
<CompactLanguageSelector className="w-auto min-w-[60px]" /> <CompactLanguageSelector className="w-auto min-w-[60px]" />

View File

@@ -33,7 +33,8 @@ import {
Menu, Menu,
LogOut, LogOut,
MoreHorizontal, MoreHorizontal,
X X,
Search
} from 'lucide-react'; } from 'lucide-react';
export interface SidebarProps { export interface SidebarProps {
@@ -143,6 +144,9 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set()); const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [hoveredItem, setHoveredItem] = useState<string | null>(null); const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isSearchFocused, setIsSearchFocused] = useState(false);
const [searchValue, setSearchValue] = useState('');
const searchInputRef = React.useRef<HTMLInputElement>(null);
const sidebarRef = React.useRef<HTMLDivElement>(null); const sidebarRef = React.useRef<HTMLDivElement>(null);
// Get subscription-aware navigation routes // Get subscription-aware navigation routes
@@ -260,6 +264,29 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
} }
}, [navigate, onClose]); }, [navigate, onClose]);
// Handle search
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
}, []);
const handleSearchSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (searchValue.trim()) {
// TODO: Implement search functionality
console.log('Search:', searchValue);
}
}, [searchValue]);
const clearSearch = useCallback(() => {
setSearchValue('');
searchInputRef.current?.focus();
}, []);
// Focus search input
const focusSearch = useCallback(() => {
searchInputRef.current?.focus();
}, []);
// Handle logout // Handle logout
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
await logout(); await logout();
@@ -389,6 +416,42 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
} }
}, [isProfileMenuOpen]); }, [isProfileMenuOpen]);
// Keyboard shortcuts for search and other functionality
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd/Ctrl + K for search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
focusSearch();
}
// Escape to close menus
if (e.key === 'Escape') {
setIsProfileMenuOpen(false);
if (onClose) onClose(); // Close mobile sidebar
if (isSearchFocused) {
searchInputRef.current?.blur();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [focusSearch, isSearchFocused, setIsProfileMenuOpen, onClose]);
// Close search and menus when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest('[data-profile-menu]') && !target.closest('[data-search]')) {
setIsProfileMenuOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
// Render submenu overlay for collapsed sidebar // Render submenu overlay for collapsed sidebar
const renderSubmenuOverlay = (item: NavigationItem) => { const renderSubmenuOverlay = (item: NavigationItem) => {
if (!item.children || item.children.length === 0) return null; if (!item.children || item.children.length === 0) return null;
@@ -597,6 +660,55 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
)} )}
aria-label={t('common:accessibility.menu', 'Main navigation')} aria-label={t('common:accessibility.menu', 'Main navigation')}
> >
{/* Search */}
{!isCollapsed && (
<div className="px-4 pt-4" data-search>
<form
onSubmit={handleSearchSubmit}
className="relative"
>
<div className="absolute left-3 top-0 bottom-0 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-[var(--text-tertiary)]" />
</div>
<input
ref={searchInputRef}
type="text"
value={searchValue}
onChange={handleSearchChange}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder={t('common:forms.search_placeholder', 'Search...')}
className={clsx(
'w-full pl-10 pr-12 py-2.5 text-sm',
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
'rounded-lg transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
'focus:border-[var(--color-primary)]',
'placeholder:text-[var(--text-tertiary)]',
'h-9'
)}
aria-label={t('common:accessibility.search', 'Search in the application')}
/>
{searchValue ? (
<button
type="button"
onClick={clearSearch}
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
aria-label={t('common:actions.clear', 'Clear search')}
>
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
</button>
) : (
<div className="absolute right-3 top-0 bottom-0 flex items-center pointer-events-none">
<kbd className="hidden lg:inline-flex items-center justify-center h-5 px-1.5 text-xs text-[var(--text-tertiary)] font-mono bg-[var(--bg-tertiary)] rounded border border-[var(--border-primary)]">
K
</kbd>
</div>
)}
</form>
</div>
)}
{/* Navigation */} {/* Navigation */}
<nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-2 py-4' : 'p-4')}> <nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-2 py-4' : 'p-4')}>
<ul className={clsx(isCollapsed ? 'space-y-2' : 'space-y-2')}> <ul className={clsx(isCollapsed ? 'space-y-2' : 'space-y-2')}>
@@ -750,6 +862,53 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
</Button> </Button>
</div> </div>
{/* Mobile search - always visible in mobile view */}
<div className="p-4 border-b border-[var(--border-primary)]">
<form
onSubmit={handleSearchSubmit}
className="relative"
>
<div className="absolute left-3 top-0 bottom-0 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-[var(--text-tertiary)]" />
</div>
<input
ref={searchInputRef}
type="text"
value={searchValue}
onChange={handleSearchChange}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
placeholder={t('common:forms.search_placeholder', 'Search...')}
className={clsx(
'w-full pl-10 pr-12 py-2.5 text-sm',
'bg-[var(--bg-secondary)] border border-[var(--border-primary)]',
'rounded-lg transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
'focus:border-[var(--color-primary)]',
'placeholder:text-[var(--text-tertiary)]',
'h-9'
)}
aria-label={t('common:accessibility.search', 'Search in the application')}
/>
{searchValue ? (
<button
type="button"
onClick={clearSearch}
className="absolute right-3 top-0 bottom-0 flex items-center p-1 hover:bg-[var(--bg-tertiary)] rounded-full transition-colors"
aria-label={t('common:actions.clear', 'Clear search')}
>
<X className="h-3 w-3 text-[var(--text-tertiary)]" />
</button>
) : (
<div className="absolute right-3 top-0 bottom-0 flex items-center pointer-events-none">
<kbd className="hidden lg:inline-flex items-center justify-center h-5 px-1.5 text-xs text-[var(--text-tertiary)] font-mono bg-[var(--bg-tertiary)] rounded border border-[var(--border-primary)]">
K
</kbd>
</div>
)}
</form>
</div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 p-4 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"> <nav className="flex-1 p-4 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
<ul className="space-y-2 pb-4"> <ul className="space-y-2 pb-4">