Refactor language items into their own component

This commit is contained in:
James Brunton 2025-09-18 13:42:25 +01:00
parent d2de8e54aa
commit dab6bf2e3e

View File

@ -5,20 +5,141 @@ import { supportedLanguages } from '../../i18n';
import LocalIcon from './LocalIcon'; import LocalIcon from './LocalIcon';
import styles from './LanguageSelector.module.css'; import styles from './LanguageSelector.module.css';
// Types
interface LanguageSelectorProps { interface LanguageSelectorProps {
position?: React.ComponentProps<typeof Menu>['position']; position?: React.ComponentProps<typeof Menu>['position'];
offset?: number; offset?: number;
compact?: boolean; // icon-only trigger compact?: boolean; // icon-only trigger
} }
const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = false }: LanguageSelectorProps) => { interface LanguageOption {
value: string;
label: string;
}
interface RippleEffect {
x: number;
y: number;
key: number;
}
// Sub-components
interface LanguageItemProps {
option: LanguageOption;
index: number;
animationTriggered: boolean;
isSelected: boolean;
onClick: (event: React.MouseEvent) => void;
rippleEffect?: RippleEffect | null;
pendingLanguage: string | null;
compact: boolean;
}
const LanguageItem: React.FC<LanguageItemProps> = ({
option,
index,
animationTriggered,
isSelected,
onClick,
rippleEffect,
pendingLanguage,
compact
}) => {
return (
<div
className={styles.languageItem}
style={{
opacity: animationTriggered ? 1 : 0,
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
}}
>
<Button
variant="subtle"
size="sm"
fullWidth
onClick={onClick}
data-selected={isSelected}
styles={{
root: {
borderRadius: '4px',
minHeight: '32px',
padding: '4px 8px',
justifyContent: 'flex-start',
position: 'relative',
overflow: 'hidden',
backgroundColor: isSelected
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
: 'transparent',
color: isSelected
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
backgroundColor: isSelected
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}
},
label: {
fontSize: '13px',
fontWeight: isSelected ? 600 : 400,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
position: 'relative',
zIndex: 2,
}
}}
>
{option.label}
{!compact && rippleEffect && pendingLanguage === option.value && (
<div
key={rippleEffect.key}
style={{
position: 'absolute',
left: rippleEffect.x,
top: rippleEffect.y,
width: 0,
height: 0,
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-blue-4)',
opacity: 0.6,
transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
zIndex: 1,
}}
/>
)}
</Button>
</div>
);
};
const RippleStyles: React.FC = () => (
<style>
{`
@keyframes ripple-expand {
0% { width: 0; height: 0; opacity: 0.6; }
50% { opacity: 0.3; }
100% { width: 100px; height: 100px; opacity: 0; }
}
`}
</style>
);
// Main component
const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-start', offset = 8, compact = false }) => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false); const [animationTriggered, setAnimationTriggered] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null); const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null); const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(null);
const languageOptions = Object.entries(supportedLanguages) const languageOptions: LanguageOption[] = Object.entries(supportedLanguages)
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB)) .sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({ .map(([code, name]) => ({
value: code, value: code,
@ -65,15 +186,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
return ( return (
<> <>
<style> <RippleStyles />
{`
@keyframes ripple-expand {
0% { width: 0; height: 0; opacity: 0.6; }
50% { opacity: 0.3; }
100% { width: 100px; height: 100px; opacity: 0; }
}
`}
</style>
<Menu <Menu
opened={opened} opened={opened}
onChange={setOpened} onChange={setOpened}
@ -86,138 +199,79 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}} }}
> >
<Menu.Target> <Menu.Target>
{compact ? ( {compact ? (
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
radius="md" radius="md"
title={currentLanguage} title={currentLanguage}
className="right-rail-icon" className="right-rail-icon"
styles={{ styles={{
root: { root: {
color: 'var(--right-rail-icon)', color: 'var(--right-rail-icon)',
'&:hover': { '&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
}
} }
} }}
}} >
> <LocalIcon icon="language" width="1.5rem" height="1.5rem" />
<LocalIcon icon="language" width="1.5rem" height="1.5rem" /> </ActionIcon>
</ActionIcon> ) : (
) : ( <Button
<Button variant="subtle"
variant="subtle" size="sm"
size="sm" leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />}
leftSection={<LocalIcon icon="language" width="1.5rem" height="1.5rem" />} styles={{
styles={{ root: {
root: { border: 'none',
border: 'none', color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))', transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)', '&:hover': {
'&:hover': { backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', }
} },
}, label: { fontSize: '12px', fontWeight: 500 }
label: { fontSize: '12px', fontWeight: 500 } }}
}} >
> <span className={styles.languageText}>
<span className={styles.languageText}> {currentLanguage}
{currentLanguage} </span>
</span> </Button>
</Button> )}
)} </Menu.Target>
</Menu.Target>
<Menu.Dropdown <Menu.Dropdown
style={{ style={{
padding: '12px', padding: '12px',
borderRadius: '8px', borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))', backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))', border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
}} }}
> >
<ScrollArea h={190} type="scroll"> <ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}> <div className={styles.languageGrid}>
{languageOptions.map((option, index) => ( {languageOptions.map((option, index) => (
<div <LanguageItem
key={option.value} key={option.value}
className={styles.languageItem} option={option}
style={{ index={index}
opacity: animationTriggered ? 1 : 0, animationTriggered={animationTriggered}
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)', isSelected={option.value === i18n.language}
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
}}
>
<Button
variant="subtle"
size="sm"
fullWidth
onClick={(event) => handleLanguageChange(option.value, event)} onClick={(event) => handleLanguageChange(option.value, event)}
data-selected={option.value === i18n.language} rippleEffect={rippleEffect}
styles={{ pendingLanguage={pendingLanguage}
root: { compact={compact}
borderRadius: '4px', />
minHeight: '32px', ))}
padding: '4px 8px', </div>
justifyContent: 'flex-start', </ScrollArea>
position: 'relative', </Menu.Dropdown>
overflow: 'hidden',
backgroundColor: option.value === i18n.language
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
: 'transparent',
color: option.value === i18n.language
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
backgroundColor: option.value === i18n.language
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}
},
label: {
fontSize: '13px',
fontWeight: option.value === i18n.language ? 600 : 400,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
position: 'relative',
zIndex: 2,
}
}}
>
{option.label}
{!compact && rippleEffect && pendingLanguage === option.value && (
<div
key={rippleEffect.key}
style={{
position: 'absolute',
left: rippleEffect.x,
top: rippleEffect.y,
width: 0,
height: 0,
borderRadius: '50%',
backgroundColor: 'var(--mantine-color-blue-4)',
opacity: 0.6,
transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
zIndex: 1,
}}
/>
)}
</Button>
</div>
))}
</div>
</ScrollArea>
</Menu.Dropdown>
</Menu> </Menu>
</> </>
); );
}; };
export default LanguageSelector; export default LanguageSelector;
export type { LanguageSelectorProps, LanguageOption, RippleEffect };