ADD new frontend
This commit is contained in:
230
frontend/src/components/ui/ThemeToggle/ThemeToggle.tsx
Normal file
230
frontend/src/components/ui/ThemeToggle/ThemeToggle.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React, { useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { Button } from '../Button';
|
||||
import { Sun, Moon, Computer } from 'lucide-react';
|
||||
|
||||
export interface ThemeToggleProps {
|
||||
className?: string;
|
||||
/**
|
||||
* Toggle style variant
|
||||
*/
|
||||
variant?: 'button' | 'dropdown' | 'switch';
|
||||
/**
|
||||
* Size of the toggle
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/**
|
||||
* Show labels alongside icons
|
||||
*/
|
||||
showLabels?: boolean;
|
||||
/**
|
||||
* Position of dropdown (when variant='dropdown')
|
||||
*/
|
||||
dropdownPosition?: 'left' | 'right' | 'center';
|
||||
}
|
||||
|
||||
/**
|
||||
* ThemeToggle - Reusable theme switching component
|
||||
*
|
||||
* Features:
|
||||
* - Multiple display variants (button, dropdown, switch)
|
||||
* - Support for light/dark/system themes
|
||||
* - Configurable size and labels
|
||||
* - Accessible keyboard navigation
|
||||
* - Click outside to close dropdown
|
||||
*/
|
||||
export const ThemeToggle: React.FC<ThemeToggleProps> = ({
|
||||
className,
|
||||
variant = 'button',
|
||||
size = 'md',
|
||||
showLabels = false,
|
||||
dropdownPosition = 'right',
|
||||
}) => {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const themes = [
|
||||
{ key: 'light' as const, label: 'Claro', icon: Sun },
|
||||
{ key: 'dark' as const, label: 'Oscuro', icon: Moon },
|
||||
{ key: 'auto' as const, label: 'Sistema', icon: Computer },
|
||||
];
|
||||
|
||||
const currentTheme = themes.find(t => t.key === theme) || themes[0];
|
||||
const CurrentIcon = currentTheme.icon;
|
||||
|
||||
// Size mappings
|
||||
const sizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isDropdownOpen) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
React.useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-theme-toggle]')) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
// Cycle through themes for button variant
|
||||
const handleButtonToggle = () => {
|
||||
const currentIndex = themes.findIndex(t => t.key === theme);
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
setTheme(themes[nextIndex].key);
|
||||
};
|
||||
|
||||
// Handle theme selection
|
||||
const handleThemeSelect = (themeKey: 'light' | 'dark' | 'auto') => {
|
||||
setTheme(themeKey);
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Button variant - cycles through themes
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleButtonToggle}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 p-2',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
aria-label={`Cambiar tema - Actual: ${currentTheme.label}`}
|
||||
data-theme-toggle
|
||||
>
|
||||
<CurrentIcon className={iconSizes[size]} />
|
||||
{showLabels && (
|
||||
<span className="hidden sm:inline">
|
||||
{currentTheme.label}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Switch variant - simple toggle between light/dark
|
||||
if (variant === 'switch') {
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
className={clsx(
|
||||
'relative inline-flex items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||
'bg-[var(--bg-tertiary)] hover:bg-[var(--bg-quaternary)]',
|
||||
size === 'sm' ? 'h-6 w-11' : size === 'lg' ? 'h-8 w-14' : 'h-7 w-12',
|
||||
className
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={isDark}
|
||||
aria-label="Alternar tema oscuro"
|
||||
data-theme-toggle
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-block rounded-full bg-white shadow-sm transform transition-transform duration-200 flex items-center justify-center',
|
||||
size === 'sm' ? 'h-5 w-5' : size === 'lg' ? 'h-7 w-7' : 'h-6 w-6',
|
||||
isDark ? 'translate-x-5' : 'translate-x-0.5'
|
||||
)}
|
||||
>
|
||||
{isDark ? (
|
||||
<Moon className={clsx(iconSizes[size], 'text-[var(--color-primary)]')} />
|
||||
) : (
|
||||
<Sun className={clsx(iconSizes[size], 'text-[var(--color-primary)]')} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown variant - shows all theme options
|
||||
return (
|
||||
<div className="relative" data-theme-toggle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 p-2',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
aria-label={`Seleccionar tema - Actual: ${currentTheme.label}`}
|
||||
aria-expanded={isDropdownOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<CurrentIcon className={iconSizes[size]} />
|
||||
{showLabels && (
|
||||
<span className="hidden sm:inline">
|
||||
{currentTheme.label}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute top-full mt-2 w-48 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-1 z-[var(--z-dropdown)]',
|
||||
dropdownPosition === 'left' && 'left-0',
|
||||
dropdownPosition === 'right' && 'right-0',
|
||||
dropdownPosition === 'center' && 'left-1/2 transform -translate-x-1/2'
|
||||
)}
|
||||
role="menu"
|
||||
aria-labelledby="theme-menu"
|
||||
>
|
||||
{themes.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleThemeSelect(key)}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2 text-left text-sm flex items-center gap-3',
|
||||
'hover:bg-[var(--bg-secondary)] transition-colors',
|
||||
'focus:bg-[var(--bg-secondary)] focus:outline-none',
|
||||
theme === key && 'bg-[var(--bg-secondary)] text-[var(--color-primary)]'
|
||||
)}
|
||||
role="menuitem"
|
||||
aria-label={`Cambiar a tema ${label.toLowerCase()}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
{theme === key && (
|
||||
<div className="ml-auto w-2 h-2 bg-[var(--color-primary)] rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
Reference in New Issue
Block a user