mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-22 19:46:39 +00:00
Refactor language items into their own component
This commit is contained in:
parent
d2de8e54aa
commit
dab6bf2e3e
@ -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}
|
||||||
@ -140,77 +253,17 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
|
|||||||
<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',
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
position: 'relative',
|
|
||||||
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>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@ -221,3 +274,4 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default LanguageSelector;
|
export default LanguageSelector;
|
||||||
|
export type { LanguageSelectorProps, LanguageOption, RippleEffect };
|
Loading…
x
Reference in New Issue
Block a user