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

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

查看文件

@ -21,12 +21,29 @@
.header {
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h1 {
font-size: 18px;
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 {
@ -106,43 +123,105 @@
color: #999;
}
/* 语言选择器样式 */
.language-selector {
padding: 12px;
background: white;
border-radius: 8px;
margin-bottom: 16px;
/* Header actions */
.header-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
}
.language-label {
font-size: 12px;
color: #666;
margin-bottom: 6px;
display: block;
.menu-container {
position: relative;
}
.language-select {
width: 100%;
padding: 8px 12px;
font-size: 14px;
border: 1px solid #ddd;
.icon-button {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 6px;
background: white;
color: #333;
background: transparent;
color: #666;
cursor: pointer;
transition: all 0.2s;
}
.language-select:hover {
border-color: #1890ff;
.icon-button:hover {
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;
border-color: #1890ff;
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 {
position: fixed;
@ -283,16 +362,24 @@
<body>
<div class="header">
<h1 data-i18n="popup_title">CloudDrive Renamer</h1>
</div>
<!-- 语言选择器 -->
<div class="language-selector">
<label class="language-label" data-i18n="popup_language_label">语言 / Language</label>
<select class="language-select" id="language-select">
<option value="zh_CN">简体中文</option>
<option value="zh_TW">繁體中文</option>
<option value="en">English</option>
</select>
<div class="header-actions">
<div class="menu-container">
<button class="icon-button" id="language-button" type="button" aria-haspopup="menu" aria-expanded="false">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M3 12h18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M12 3c3 3 3 15 0 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M12 3c-3 3-3 15 0 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<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 class="platform-status">

查看文件

@ -4,7 +4,8 @@ import { PlatformName } from '../types/platform';
import { PlatformUsageStats, STORAGE_KEYS } from '../types/stats';
import { APP_VERSION_WITH_PREFIX } from '../shared/version';
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';
/**
@ -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
* Extracts duplicated logic and removes global state mutation
@ -78,6 +111,16 @@ function handleLanguageChange(message: any) {
// Update all internationalized text when language changes
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
updatePlatformDisplayName().catch(error => {
logger.error('Failed to update platform name after language change:', error);
@ -99,8 +142,8 @@ function setupLanguageChangeListener() {
// 初始化Popup
async function initPopup() {
// 1. First initialize language selector (loads and sets currentLanguage asynchronously)
await initLanguageSelector();
// 1. First initialize language menu (loads and sets currentLanguage asynchronously)
await initLanguageMenu();
// 2. After language initialization, localize HTML with correct language
localizeHTML();
@ -315,42 +358,75 @@ function showToast(message: string, duration = 2000) {
}
/**
* 初始化语言选择器
* 初始化语言菜单
*/
async function initLanguageSelector() {
const languageSelect = document.getElementById('language-select') as HTMLSelectElement;
if (!languageSelect) return;
async function initLanguageMenu() {
const languageButton = document.getElementById('language-button') as HTMLButtonElement | null;
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();
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) => {
const newLang = (e.target as HTMLSelectElement).value as 'zh_CN' | 'zh_TW' | 'en';
languageMenu.querySelectorAll<HTMLButtonElement>('[data-lang]').forEach((item) => {
item.addEventListener('click', async () => {
const newLang = item.dataset.lang as SupportedLanguage;
try {
// 设置新语言
await I18nService.setLanguage(newLang);
// Close menu immediately for better UX
setLanguageMenuOpen(false, { focusButton: true });
// 更新页面所有文本
localizeHTML();
try {
// 设置新语言
await I18nService.setLanguage(newLang);
// 显示Toast提示
const langName = {
'zh_CN': '简体中文',
'zh_TW': '繁體中文',
'en': 'English'
}[newLang];
// 更新页面所有文本
localizeHTML();
showToast(I18nService.t('toast_language_changed', [langName]));
// 更新 tooltip
languageButton.title = I18nService.t('popup_language_label');
// 更新平台名称翻译(使用提取的函数,消除重复代码)
await updatePlatformDisplayName();
} catch (error) {
logger.error('Failed to change language:', error as Error);
showToast(I18nService.t('toast_language_change_failed'));
}
// 更新菜单选中状态
updateLanguageMenuSelection(newLang);
// 显示Toast提示
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
* 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
*/
/**