mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
UI improvements
This commit is contained in:
@@ -1,45 +1,118 @@
|
||||
import { UnstyledButton, useMantineColorScheme, useComputedColorScheme, rem } from '@mantine/core';
|
||||
import { IconSun, IconMoon } from '@tabler/icons-react';
|
||||
import {
|
||||
Menu,
|
||||
UnstyledButton,
|
||||
Text,
|
||||
Group,
|
||||
rem,
|
||||
useMantineColorScheme,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconDeviceDesktop,
|
||||
IconCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ColorSchemeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true });
|
||||
const isDark = computed === 'dark';
|
||||
|
||||
const themes = [
|
||||
{
|
||||
value: 'light' as const,
|
||||
label: t('profile.appearance.light', 'Light'),
|
||||
icon: IconSun,
|
||||
color: '#e67e22',
|
||||
},
|
||||
{
|
||||
value: 'dark' as const,
|
||||
label: t('profile.appearance.dark', 'Dark'),
|
||||
icon: IconMoon,
|
||||
color: '#9b59b6',
|
||||
},
|
||||
{
|
||||
value: 'auto' as const,
|
||||
label: t('profile.appearance.system', 'System'),
|
||||
icon: IconDeviceDesktop,
|
||||
color: '#3498db',
|
||||
},
|
||||
];
|
||||
|
||||
const CurrentIcon = colorScheme === 'auto' ? IconDeviceDesktop : isDark ? IconMoon : IconSun;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
aria-label={t('common.toggleTheme')}
|
||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(38),
|
||||
height: rem(38),
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-primary-color-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-primary-color-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
}}
|
||||
>
|
||||
{isDark ? <IconSun size={19} /> : <IconMoon size={19} />}
|
||||
</UnstyledButton>
|
||||
<Menu shadow="lg" width={170} position="bottom-end" withinPortal transitionProps={{ transition: 'pop-top-right', duration: 150 }}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
aria-label={t('common.toggleTheme', 'Toggle light / dark mode')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(38),
|
||||
height: rem(38),
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: isDark ? 'var(--mantine-color-yellow-4)' : 'var(--mantine-color-blue-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)',
|
||||
transition: 'all 200ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px var(--mantine-primary-color-3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)';
|
||||
}}
|
||||
>
|
||||
<CurrentIcon size={19} stroke={1.8} />
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown
|
||||
style={{
|
||||
borderRadius: rem(14),
|
||||
padding: rem(6),
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
<Menu.Label style={{ fontWeight: 600, fontSize: rem(11), letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
||||
{t('profile.appearance.title', 'Appearance')}
|
||||
</Menu.Label>
|
||||
{themes.map((theme) => {
|
||||
const Icon = theme.icon;
|
||||
const isSelected = colorScheme === theme.value;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={theme.value}
|
||||
onClick={() => setColorScheme(theme.value)}
|
||||
leftSection={<Icon size={16} stroke={1.8} style={{ color: isSelected ? 'var(--mantine-primary-color-filled)' : 'currentColor' }} />}
|
||||
rightSection={
|
||||
isSelected ? <IconCheck size={16} stroke={2.4} style={{ color: 'var(--mantine-primary-color-filled)' }} /> : undefined
|
||||
}
|
||||
style={{
|
||||
borderRadius: rem(8),
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
backgroundColor: isSelected ? 'var(--mantine-primary-color-light)' : undefined,
|
||||
color: isSelected ? 'var(--mantine-primary-color-filled)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Text size="sm">{theme.label}</Text>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +1,179 @@
|
||||
import { Menu, UnstyledButton, Text, rem } from '@mantine/core';
|
||||
import { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
import { Menu, UnstyledButton, Text, Group, rem } from '@mantine/core';
|
||||
import { IconCheck, IconChevronDown, IconWorld } from '@tabler/icons-react';
|
||||
import { GB, ET } from 'country-flag-icons/react/3x2';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
supportedLanguages: readonly string[];
|
||||
/** `icon` renders a compact globe button; `button` shows the language label. */
|
||||
/** `icon` renders a compact flag button; `button` shows the language label. */
|
||||
variant?: 'icon' | 'button';
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({ supportedLanguages, variant = 'icon' }: LanguageSwitcherProps) {
|
||||
type FlagComponentType = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
|
||||
const LANGUAGE_CONFIG: Record<
|
||||
string,
|
||||
{ Flag: FlagComponentType; nativeName: string; shortCode: string }
|
||||
> = {
|
||||
en: { Flag: GB, nativeName: 'English', shortCode: 'EN' },
|
||||
am: { Flag: ET, nativeName: 'አማርኛ', shortCode: 'AM' },
|
||||
};
|
||||
|
||||
export function LanguageSwitcher({
|
||||
supportedLanguages,
|
||||
variant = 'icon',
|
||||
}: LanguageSwitcherProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const current = i18n.language;
|
||||
const current = i18n.language || 'en';
|
||||
|
||||
const change = (lng: string) => {
|
||||
if (lng !== current) i18n.changeLanguage(lng);
|
||||
};
|
||||
|
||||
const currentConfig = LANGUAGE_CONFIG[current] || {
|
||||
Flag: null,
|
||||
nativeName: current.toUpperCase(),
|
||||
shortCode: current.toUpperCase(),
|
||||
};
|
||||
|
||||
const CurrentFlag = currentConfig.Flag;
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
|
||||
<Menu
|
||||
shadow="lg"
|
||||
width={190}
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
transitionProps={{ transition: 'pop-top-right', duration: 150 }}
|
||||
>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
aria-label={t('language.label')}
|
||||
aria-label={t('language.label', 'Language')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: rem(4),
|
||||
gap: rem(6),
|
||||
width: variant === 'icon' ? rem(38) : 'auto',
|
||||
height: rem(38),
|
||||
padding: variant === 'icon' ? 0 : '0 12px',
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transition: 'all 150ms ease',
|
||||
color: 'var(--mantine-color-gray-7)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)',
|
||||
transition: 'all 200ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-primary-color-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-primary-color-6)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-primary-color-2)';
|
||||
'0 4px 12px rgba(0,0,0,0.1), 0 0 0 1px var(--mantine-primary-color-3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
'0 1px 3px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.08)';
|
||||
}}
|
||||
>
|
||||
<IconWorld size={19} />
|
||||
{CurrentFlag ? (
|
||||
<CurrentFlag
|
||||
style={{
|
||||
width: rem(20),
|
||||
height: rem(14),
|
||||
borderRadius: rem(2),
|
||||
display: 'block',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<IconWorld size={19} />
|
||||
)}
|
||||
|
||||
{variant !== 'icon' && (
|
||||
<>
|
||||
<Text size="sm" fw={500}>
|
||||
{t(`language.${current}`)}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{t(`language.${current}`, currentConfig.nativeName)}
|
||||
</Text>
|
||||
<IconChevronDown size={14} />
|
||||
</>
|
||||
<IconChevronDown size={14} stroke={2} style={{ opacity: 0.7 }} />
|
||||
</Group>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown
|
||||
style={{ borderRadius: rem(12), padding: rem(6) }}
|
||||
style={{
|
||||
borderRadius: rem(14),
|
||||
padding: rem(6),
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
<Menu.Label>{t('language.label')}</Menu.Label>
|
||||
{supportedLanguages.map((lng) => (
|
||||
<Menu.Item
|
||||
key={lng}
|
||||
onClick={() => change(lng)}
|
||||
rightSection={
|
||||
current === lng ? <IconCheck size={16} /> : undefined
|
||||
}
|
||||
style={{ borderRadius: rem(8) }}
|
||||
>
|
||||
{t(`language.${lng}`)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
<Menu.Label
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: rem(11),
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{t('language.label', 'Language')}
|
||||
</Menu.Label>
|
||||
{supportedLanguages.map((lng) => {
|
||||
const config = LANGUAGE_CONFIG[lng] || {
|
||||
Flag: null,
|
||||
nativeName: lng.toUpperCase(),
|
||||
shortCode: lng.toUpperCase(),
|
||||
};
|
||||
const Flag = config.Flag;
|
||||
const isSelected = current === lng;
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
key={lng}
|
||||
onClick={() => change(lng)}
|
||||
leftSection={
|
||||
Flag ? (
|
||||
<Flag
|
||||
style={{
|
||||
width: rem(20),
|
||||
height: rem(14),
|
||||
borderRadius: rem(2),
|
||||
display: 'block',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<IconWorld size={16} />
|
||||
)
|
||||
}
|
||||
rightSection={
|
||||
isSelected ? (
|
||||
<IconCheck
|
||||
size={16}
|
||||
stroke={2.4}
|
||||
style={{ color: 'var(--mantine-primary-color-filled)' }}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
style={{
|
||||
borderRadius: rem(8),
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-primary-color-light)'
|
||||
: undefined,
|
||||
color: isSelected ? 'var(--mantine-primary-color-filled)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" style={{ width: '100%' }}>
|
||||
<Text size="sm">{t(`language.${lng}`, config.nativeName)}</Text>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user