230 lines
6.8 KiB
TypeScript
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; |