- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3) - Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor - Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS - Configurare adresă platformă externalizată (build args / .env / config.js) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
239 lines
6.3 KiB
TypeScript
239 lines
6.3 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import styled from '@emotion/styled';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useTheme } from '../../contexts/ThemeContext';
|
|
import { spacing, borderRadius, animation, sizing } from '../../theme';
|
|
|
|
// SVG Icons (inline for no external dependencies)
|
|
const SunIcon: React.FC<{ size?: number }> = ({ size = 20 }) => (
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="5" />
|
|
<line x1="12" y1="1" x2="12" y2="3" />
|
|
<line x1="12" y1="21" x2="12" y2="23" />
|
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
|
<line x1="1" y1="12" x2="3" y2="12" />
|
|
<line x1="21" y1="12" x2="23" y2="12" />
|
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
</svg>
|
|
);
|
|
|
|
const MoonIcon: React.FC<{ size?: number }> = ({ size = 20 }) => (
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
</svg>
|
|
);
|
|
|
|
const SystemIcon: React.FC<{ size?: number }> = ({ size = 20 }) => (
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
|
<line x1="8" y1="21" x2="16" y2="21" />
|
|
<line x1="12" y1="17" x2="12" y2="21" />
|
|
</svg>
|
|
);
|
|
|
|
type ThemeMode = 'dark' | 'light' | 'system';
|
|
|
|
const themeOptionDefs: { value: ThemeMode; labelKey: string; icon: React.ReactNode }[] = [
|
|
{ value: 'dark', labelKey: 'theme.dark', icon: <MoonIcon size={16} /> },
|
|
{ value: 'light', labelKey: 'theme.light', icon: <SunIcon size={16} /> },
|
|
{ value: 'system', labelKey: 'theme.system', icon: <SystemIcon size={16} /> },
|
|
];
|
|
|
|
export const ThemeToggle: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
// Close on escape key
|
|
useEffect(() => {
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener('keydown', handleEscape);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', handleEscape);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
// Determine which icon to show based on theme state
|
|
const getCurrentIcon = () => {
|
|
if (theme === 'system') {
|
|
return <SystemIcon />;
|
|
}
|
|
return resolvedTheme === 'dark' ? <MoonIcon /> : <SunIcon />;
|
|
};
|
|
|
|
const handleSelect = (value: ThemeMode) => {
|
|
setTheme(value);
|
|
setIsOpen(false);
|
|
};
|
|
|
|
return (
|
|
<ToggleContainer ref={containerRef}>
|
|
<ToggleButton
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
aria-label={t('theme.toggleMenu')}
|
|
aria-expanded={isOpen}
|
|
aria-haspopup="listbox"
|
|
title={t('theme.currentTheme', { theme: theme === 'system' ? t('theme.system') : resolvedTheme })}
|
|
>
|
|
{getCurrentIcon()}
|
|
</ToggleButton>
|
|
|
|
{isOpen && (
|
|
<Dropdown role="listbox" aria-label={t('theme.selectTheme')}>
|
|
{themeOptionDefs.map((option) => (
|
|
<DropdownItem
|
|
key={option.value}
|
|
onClick={() => handleSelect(option.value)}
|
|
role="option"
|
|
aria-selected={theme === option.value}
|
|
isActive={theme === option.value}
|
|
>
|
|
<IconWrapper>{option.icon}</IconWrapper>
|
|
<span>{t(option.labelKey)}</span>
|
|
</DropdownItem>
|
|
))}
|
|
</Dropdown>
|
|
)}
|
|
</ToggleContainer>
|
|
);
|
|
};
|
|
|
|
const ToggleContainer = styled.div`
|
|
position: relative;
|
|
display: inline-block;
|
|
`;
|
|
|
|
const ToggleButton = styled.button`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: ${sizing.button.lg}px;
|
|
height: ${sizing.button.lg}px;
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border-default);
|
|
border-radius: ${borderRadius.md}px;
|
|
cursor: pointer;
|
|
color: var(--fg-primary);
|
|
transition: all ${animation.duration.normal} ${animation.easing.default};
|
|
|
|
&:hover {
|
|
background: var(--accent-subtle);
|
|
border-color: var(--accent-border);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
&:focus {
|
|
outline: none;
|
|
box-shadow: 0 0 0 2px rgba(0, 145, 152, 0.3);
|
|
}
|
|
|
|
&:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
svg {
|
|
flex-shrink: 0;
|
|
}
|
|
`;
|
|
|
|
const Dropdown = styled.div`
|
|
position: absolute;
|
|
top: calc(100% + ${spacing.sm}px);
|
|
right: 0;
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: ${borderRadius.sm}px;
|
|
padding: ${spacing.xs}px;
|
|
min-width: 140px;
|
|
z-index: 1000;
|
|
box-shadow: var(--shadow-lg);
|
|
`;
|
|
|
|
const DropdownItem = styled.button<{ isActive: boolean }>`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: ${spacing.sm}px;
|
|
width: 100%;
|
|
padding: ${spacing.sm}px ${spacing.md}px;
|
|
background: ${props => props.isActive ? 'var(--accent-subtle)' : 'transparent'};
|
|
border: none;
|
|
border-radius: ${borderRadius.sm}px;
|
|
cursor: pointer;
|
|
color: ${props => props.isActive ? 'var(--accent-text)' : 'var(--fg-primary)'};
|
|
font-size: 14px;
|
|
text-align: left;
|
|
transition: background ${animation.duration.fast} ${animation.easing.default};
|
|
|
|
&:hover {
|
|
background: ${props => props.isActive ? 'var(--accent-subtle)' : 'var(--bg-hover)'};
|
|
}
|
|
|
|
&:focus {
|
|
outline: none;
|
|
background: var(--accent-subtle);
|
|
}
|
|
`;
|
|
|
|
const IconWrapper = styled.span`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: ${sizing.icon.sm}px;
|
|
height: ${sizing.icon.sm}px;
|
|
`;
|
|
|