Files
bakery-ia/frontend/src/components/ui/ThemeToggle/ThemeToggle.tsx
2025-08-28 10:41:04 +02:00

230 lines
6.8 KiB
TypeScript

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;