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}
|
||||||
@ -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 };
|
Loading…
x
Reference in New Issue
Block a user