feat(popup): 将语言选择器替换为更美观的下拉菜单

重构语言选择器为现代风格的下拉菜单,改进用户体验
添加菜单动画效果和键盘导航支持
优化无障碍访问特性
这个提交包含在:
Zain 2025-12-30 14:55:53 +08:00
父节点 16dbfca817
当前提交 8583808dd3
共有 3 个文件被更改,包括 224 次插入 和 61 次删除

查看文件

@ -21,12 +21,29 @@
.header { .header {
margin-bottom: 16px; margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
} }
h1 { h1 {
font-size: 18px; font-size: 18px;
color: #333; color: #333;
margin-bottom: 8px; margin: 0;
flex: 1;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
} }
.platform-status { .platform-status {
@ -106,43 +123,105 @@
color: #999; color: #999;
} }
/* 语言选择器样式 */ /* Header actions */
.language-selector { .header-actions {
padding: 12px; flex-shrink: 0;
background: white; display: flex;
border-radius: 8px; align-items: center;
margin-bottom: 16px; gap: 8px;
} }
.language-label { .menu-container {
font-size: 12px; position: relative;
color: #666;
margin-bottom: 6px;
display: block;
} }
.language-select { .icon-button {
width: 100%; width: 32px;
padding: 8px 12px; height: 32px;
font-size: 14px; display: inline-flex;
border: 1px solid #ddd; align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 6px; border-radius: 6px;
background: white; background: transparent;
color: #333; color: #666;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.language-select:hover { .icon-button:hover {
border-color: #1890ff; background: rgba(24, 144, 255, 0.08);
border-color: rgba(24, 144, 255, 0.35);
color: #1890ff;
} }
.language-select:focus { .icon-button:focus {
outline: none; outline: none;
border-color: #1890ff; border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1); box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
} }
.icon {
width: 18px;
height: 18px;
display: block;
}
.menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 160px;
padding: 6px;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
z-index: 1000;
}
.menu-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: #333;
font-size: 13px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.menu-item::after {
content: '';
width: 16px;
text-align: right;
color: #1890ff;
font-size: 12px;
line-height: 1;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
.menu-item[data-selected='true'] {
background: rgba(24, 144, 255, 0.08);
color: #1890ff;
}
.menu-item[data-selected='true']::after {
content: '✓';
}
/* Toast提示样式 */ /* Toast提示样式 */
.toast { .toast {
position: fixed; position: fixed;
@ -283,16 +362,24 @@
<body> <body>
<div class="header"> <div class="header">
<h1 data-i18n="popup_title">CloudDrive Renamer</h1> <h1 data-i18n="popup_title">CloudDrive Renamer</h1>
</div> <div class="header-actions">
<div class="menu-container">
<!-- 语言选择器 --> <button class="icon-button" id="language-button" type="button" aria-haspopup="menu" aria-expanded="false">
<div class="language-selector"> <svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<label class="language-label" data-i18n="popup_language_label">语言 / Language</label> <circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2" />
<select class="language-select" id="language-select"> <path d="M3 12h18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<option value="zh_CN">简体中文</option> <path d="M12 3c3 3 3 15 0 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<option value="zh_TW">繁體中文</option> <path d="M12 3c-3 3-3 15 0 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<option value="en">English</option> </svg>
</select> <span class="sr-only" data-i18n="popup_language_label">语言 / Language</span>
</button>
<div class="menu" id="language-menu" role="menu" aria-labelledby="language-button" hidden>
<button class="menu-item" type="button" role="menuitemradio" aria-checked="false" data-lang="zh_CN">简体中文</button>
<button class="menu-item" type="button" role="menuitemradio" aria-checked="false" data-lang="zh_TW">繁體中文</button>
<button class="menu-item" type="button" role="menuitemradio" aria-checked="false" data-lang="en">English</button>
</div>
</div>
</div>
</div> </div>
<div class="platform-status"> <div class="platform-status">

查看文件

@ -4,7 +4,8 @@ import { PlatformName } from '../types/platform';
import { PlatformUsageStats, STORAGE_KEYS } from '../types/stats'; import { PlatformUsageStats, STORAGE_KEYS } from '../types/stats';
import { APP_VERSION_WITH_PREFIX } from '../shared/version'; import { APP_VERSION_WITH_PREFIX } from '../shared/version';
import { I18nService } from '../utils/i18n'; import { I18nService } from '../utils/i18n';
import { LanguageChangeMessage } from '../types/i18n'; import { LANGUAGE_DISPLAY_NAMES } from '../types/i18n';
import type { LanguageChangeMessage, SupportedLanguage } from '../types/i18n';
import { detectPlatformFromUrl } from '../utils/platform-detector'; import { detectPlatformFromUrl } from '../utils/platform-detector';
/** /**
@ -46,6 +47,38 @@ function localizeHTML() {
}); });
} }
function updateLanguageMenuSelection(language: SupportedLanguage) {
const languageMenu = document.getElementById('language-menu');
if (!languageMenu) return;
languageMenu.querySelectorAll<HTMLButtonElement>('[data-lang]').forEach((item) => {
const isSelected = item.dataset.lang === language;
item.setAttribute('aria-checked', isSelected ? 'true' : 'false');
item.dataset.selected = isSelected ? 'true' : 'false';
});
}
function setLanguageMenuOpen(open: boolean, options?: { focusButton?: boolean }) {
const languageButton = document.getElementById('language-button') as HTMLButtonElement | null;
const languageMenu = document.getElementById('language-menu') as HTMLElement | null;
if (!languageButton || !languageMenu) return;
languageMenu.hidden = !open;
languageButton.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) {
const selectedItem =
languageMenu.querySelector<HTMLButtonElement>('[data-selected="true"]') ||
languageMenu.querySelector<HTMLButtonElement>('[data-lang]');
selectedItem?.focus();
return;
}
if (options?.focusButton) {
languageButton.focus();
}
}
/** /**
* Update platform display name based on current tab * Update platform display name based on current tab
* Extracts duplicated logic and removes global state mutation * Extracts duplicated logic and removes global state mutation
@ -78,6 +111,16 @@ function handleLanguageChange(message: any) {
// Update all internationalized text when language changes // Update all internationalized text when language changes
localizeHTML(); localizeHTML();
// Update language menu selection
const newLanguage = (message as LanguageChangeMessage).language;
updateLanguageMenuSelection(newLanguage);
// Keep tooltip up-to-date for icon-only button
const languageButton = document.getElementById('language-button') as HTMLButtonElement | null;
if (languageButton) {
languageButton.title = I18nService.t('popup_language_label');
}
// Update platform name with new translations // Update platform name with new translations
updatePlatformDisplayName().catch(error => { updatePlatformDisplayName().catch(error => {
logger.error('Failed to update platform name after language change:', error); logger.error('Failed to update platform name after language change:', error);
@ -99,8 +142,8 @@ function setupLanguageChangeListener() {
// 初始化Popup // 初始化Popup
async function initPopup() { async function initPopup() {
// 1. First initialize language selector (loads and sets currentLanguage asynchronously) // 1. First initialize language menu (loads and sets currentLanguage asynchronously)
await initLanguageSelector(); await initLanguageMenu();
// 2. After language initialization, localize HTML with correct language // 2. After language initialization, localize HTML with correct language
localizeHTML(); localizeHTML();
@ -315,42 +358,75 @@ function showToast(message: string, duration = 2000) {
} }
/** /**
* 初始化语言选择器 * 初始化语言菜单
*/ */
async function initLanguageSelector() { async function initLanguageMenu() {
const languageSelect = document.getElementById('language-select') as HTMLSelectElement; const languageButton = document.getElementById('language-button') as HTMLButtonElement | null;
if (!languageSelect) return; const languageMenu = document.getElementById('language-menu') as HTMLElement | null;
if (!languageButton || !languageMenu) return;
const menuContainer = languageButton.closest('.menu-container');
if (!menuContainer) return;
// 获取当前语言并设置选中状态 // 获取当前语言并设置选中状态
const currentLang = await I18nService.getCurrentLanguage(); const currentLang = await I18nService.getCurrentLanguage();
languageSelect.value = currentLang; updateLanguageMenuSelection(currentLang);
// Icon-only button: add localized tooltip
languageButton.title = I18nService.t('popup_language_label');
languageButton.addEventListener('click', (e) => {
e.preventDefault();
setLanguageMenuOpen(languageMenu.hidden);
});
// 监听语言切换 // 监听语言切换
languageSelect.addEventListener('change', async (e) => { languageMenu.querySelectorAll<HTMLButtonElement>('[data-lang]').forEach((item) => {
const newLang = (e.target as HTMLSelectElement).value as 'zh_CN' | 'zh_TW' | 'en'; item.addEventListener('click', async () => {
const newLang = item.dataset.lang as SupportedLanguage;
try { // Close menu immediately for better UX
// 设置新语言 setLanguageMenuOpen(false, { focusButton: true });
await I18nService.setLanguage(newLang);
// 更新页面所有文本 try {
localizeHTML(); // 设置新语言
await I18nService.setLanguage(newLang);
// 显示Toast提示 // 更新页面所有文本
const langName = { localizeHTML();
'zh_CN': '简体中文',
'zh_TW': '繁體中文',
'en': 'English'
}[newLang];
showToast(I18nService.t('toast_language_changed', [langName])); // 更新 tooltip
languageButton.title = I18nService.t('popup_language_label');
// 更新平台名称翻译(使用提取的函数,消除重复代码) // 更新菜单选中状态
await updatePlatformDisplayName(); updateLanguageMenuSelection(newLang);
} catch (error) {
logger.error('Failed to change language:', error as Error); // 显示Toast提示
showToast(I18nService.t('toast_language_change_failed')); showToast(I18nService.t('toast_language_changed', [LANGUAGE_DISPLAY_NAMES[newLang]]));
}
// 更新平台名称翻译(使用提取的函数,消除重复代码)
await updatePlatformDisplayName();
} catch (error) {
logger.error('Failed to change language:', error as Error);
showToast(I18nService.t('toast_language_change_failed'));
}
});
});
// 点击其他区域关闭菜单
document.addEventListener('click', (e) => {
if (languageMenu.hidden) return;
const target = e.target as Node | null;
if (target && menuContainer.contains(target)) return;
setLanguageMenuOpen(false);
});
// ESC 关闭菜单
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (languageMenu.hidden) return;
e.preventDefault();
setLanguageMenuOpen(false, { focusButton: true });
}); });
} }

查看文件

@ -10,7 +10,7 @@
* 1. Edit version in package.json * 1. Edit version in package.json
* 2. Run: npm run sync:version (or automatically via prebuild/predev) * 2. Run: npm run sync:version (or automatically via prebuild/predev)
* *
* Generated at: 2025-12-30T05:36:20.170Z * Generated at: 2025-12-30T06:54:19.507Z
*/ */
/** /**