Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions frontend/src/components/AutocompleteDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';

interface Props {
suggestions: string[];
Expand All @@ -16,6 +16,17 @@ const AutocompleteDropdown: React.FC<Props> = ({
highlightPrefix,
}) => {
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [openAbove, setOpenAbove] = useState(false);
const listRef = useRef<HTMLUListElement>(null);

// Determine if dropdown should open above or below
useEffect(() => {
if (!visible || !listRef.current) return;
const rect = listRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.top;
// If less than 200px below (keyboard likely open), flip above
setOpenAbove(spaceBelow < 200);
}, [visible, suggestions]);

if (!visible) return null;
if (suggestions.length === 0) return null;
Expand Down Expand Up @@ -47,17 +58,17 @@ const AutocompleteDropdown: React.FC<Props> = ({

const renderHighlightedText = (text: string, prefix: string) => {
if (!prefix) return <>{text}</>;

const lowerText = text.toLowerCase();
const lowerPrefix = prefix.toLowerCase();
const index = lowerText.indexOf(lowerPrefix);

if (index === -1) return <>{text}</>;

const before = text.substring(0, index);
const match = text.substring(index, index + prefix.length);
const after = text.substring(index + prefix.length);

return (
<>
<span className="sr-only">{text}</span>
Expand All @@ -72,11 +83,14 @@ const AutocompleteDropdown: React.FC<Props> = ({

return (
<ul
ref={listRef}
data-testid="autocomplete-dropdown"
role="listbox"
tabIndex={0}
onKeyDown={handleKeyDown}
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100 outline-none"
className={`absolute z-10 w-full bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100 outline-none ${
openAbove ? 'bottom-full mb-1' : 'top-full mt-1'
}`}
>
{displaySuggestions.map((suggestion, index) => (
<li
Expand Down
136 changes: 136 additions & 0 deletions frontend/src/components/BurgerMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import ThemeToggle from './ThemeToggle';
import LanguageSwitcher from './LanguageSwitcher';
import { useI18n } from '../context/I18nContext';

const BurgerMenu: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const sheetRef = useRef<HTMLDivElement>(null);
const { t } = useI18n();

// Close on outside click
useEffect(() => {
if (!isOpen) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as Node;
if (!menuRef.current?.contains(target) && !sheetRef.current?.contains(target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [isOpen]);

// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false);
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [isOpen]);

// Lock body scroll on mobile when open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [isOpen]);

const settingsContent = (
<>
{/* Theme section */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2">
{t('settings.theme', {}, 'Appearance')}
</label>
<ThemeToggle />
</div>

{/* Divider */}
<div className="border-t border-gray-100 dark:border-gray-700" />

{/* Language section */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2">
{t('settings.language', {}, 'Language')}
</label>
<LanguageSwitcher />
</div>
</>
);

return (
<div ref={menuRef} className="relative" data-testid="burger-menu">
<button
onClick={() => setIsOpen((prev) => !prev)}
aria-expanded={isOpen}
aria-label="Settings menu"
className="p-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
data-testid="burger-menu-button"
>
{isOpen ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>

{/* Desktop dropdown */}
{isOpen && (
<div
className="hidden md:block absolute right-0 mt-2 w-auto min-w-[240px] bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3 z-30"
data-testid="burger-menu-panel"
>
{settingsContent}
</div>
)}

{/* Mobile bottom sheet — portaled to escape stacking context */}
{isOpen && createPortal(
<>
<div
className="md:hidden fixed inset-0 bg-black/40 z-40"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<div
ref={sheetRef}
className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-gray-800 rounded-t-2xl shadow-2xl animate-slide-up"
data-testid="burger-menu-panel"
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
</div>

{/* Title */}
<div className="px-5 pt-2 pb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{t('settings.title', {}, 'Settings')}
</h2>
</div>

{/* Settings */}
<div className="px-5 pb-8 space-y-5">
{settingsContent}
</div>
</div>
</>,
document.body
)}
</div>
);
};

export default BurgerMenu;
8 changes: 3 additions & 5 deletions frontend/src/components/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { useList } from '../context/ListContext';
import { useI18n } from '../context/I18nContext';
import LanguageSwitcher from './LanguageSwitcher';
import ThemeToggle from './ThemeToggle';
import BurgerMenu from './BurgerMenu';
import RecentListsSection from './RecentListsSection';

const HomePage = () => {
Expand All @@ -23,9 +22,8 @@ const HomePage = () => {

return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="absolute top-4 right-4 z-20 flex items-center gap-2">
<ThemeToggle />
<LanguageSwitcher />
<div className="absolute top-4 right-4 z-20">
<BurgerMenu />
</div>
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-md p-8 mt-16 sm:mt-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6 text-center">
Expand Down
85 changes: 33 additions & 52 deletions frontend/src/components/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,43 @@
import React, { useState } from 'react';
import React from 'react';
import { useI18n } from '../context/I18nContext';

const GlobeIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
);

const LanguageSwitcher: React.FC = () => {
const { language, setLanguage, availableLanguages } = useI18n();
const [isOpen, setIsOpen] = useState(false);

const currentLanguage = availableLanguages.find(lang => lang.code === language);

return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 sm:gap-2 px-2 py-1.5 sm:px-3 sm:py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors bg-white dark:bg-gray-800/80 backdrop-blur-sm shadow-sm"
title="Change language"
>
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
<span className="hidden sm:inline">{currentLanguage?.name}</span>
<span className="sm:hidden">{language.toUpperCase()}</span>
<svg className={`w-3.5 h-3.5 sm:w-4 sm:h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>

{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>

{/* Dropdown */}
<div className="absolute right-0 mt-2 w-32 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-20 mr-0 sm:mr-0">
<div className="py-1">
{availableLanguages.map((lang) => (
<button
key={lang.code}
onClick={() => {
setLanguage(lang.code);
setIsOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${
language === lang.code
? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}
>
{lang.name}
</button>
))}
</div>
</div>
</>
)}
<div
role="radiogroup"
aria-label="Language"
className="flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-700 rounded-lg"
>
{availableLanguages.map((lang) => {
const isActive = language === lang.code;
return (
<button
key={lang.code}
role="radio"
aria-checked={isActive}
onClick={() => setLanguage(lang.code)}
className={`relative flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isActive
? 'bg-white dark:bg-gray-500 text-blue-700 dark:text-blue-100 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title={lang.name}
>
{isActive && <GlobeIcon />}
{lang.name}
</button>
);
})}
</div>
);
};

export default LanguageSwitcher;
export default LanguageSwitcher;
Loading
Loading