@ui18n/selector-web
Version:
🌐 零依赖Web Components语言选择器 - 支持所有框架和浏览器的通用组件
665 lines (590 loc) • 19.7 kB
JavaScript
import {
labelForLang,
getUserSystemLanguage,
getUserSystemLanguageLabel,
normalizeLocale,
filterLanguages
} from './language-utils.js';
/**
* UI18n Language Selector - Web Component with Search
* 支持搜索的语言选择器
*/
export class UI18nLanguageSelector extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// 内部状态
this._languages = [];
this._current = '';
this._isOpen = false;
this._query = '';
this._activeIndex = 0;
this._showSearchBox = true;
this._showBrandSuffix = true;
this._brandSuffix = '- ui18n';
this._isComposing = false; // 标记是否正在使用输入法
}
// 观察的属性
static get observedAttributes() {
return [
'languages',
'current',
'theme',
'placeholder',
'show-search-box',
'show-brand-suffix',
'brand-suffix'
];
}
// 生命周期:连接到DOM
connectedCallback() {
this._setupEventListeners(); // 先设置事件监听
this.render(); // 再渲染
this.dispatchEvent(new CustomEvent('selector-ready', {
bubbles: true,
composed: true
}));
}
// 属性变化回调
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
switch (name) {
case 'languages':
this._languages = newValue ? newValue.split(',').map(s => s.trim()) : [];
break;
case 'current':
this._current = normalizeLocale(newValue || '');
break;
case 'show-search-box':
this._showSearchBox = newValue !== 'false';
break;
case 'show-brand-suffix':
this._showBrandSuffix = newValue !== 'false';
break;
case 'brand-suffix':
this._brandSuffix = newValue || '- ui18n';
break;
}
if (this.shadowRoot.innerHTML) {
this.render();
}
}
}
// 获取搜索占位符
_getSearchPlaceholder() {
const customPlaceholder = this.getAttribute('placeholder');
if (customPlaceholder) return customPlaceholder;
const systemLabel = getUserSystemLanguageLabel();
const result = this._showBrandSuffix
? `${systemLabel} ${this._brandSuffix}`
: systemLabel;
console.log('[Selector] 搜索占位符:', { systemLabel, brandSuffix: this._brandSuffix, showBrandSuffix: this._showBrandSuffix, result });
return result;
}
// 获取过滤后的语言列表
_getFilteredLanguages() {
return filterLanguages(this._languages, this._query);
}
// 获取固定显示的语言(简体中文、English,按此顺序)
_getFixedLanguages() {
const fixed = ['zh-CN', 'en']; // 顺序:简体中文在前,English在后
// 只返回在languages列表中存在的固定语言(需要规范化比较)
const result = fixed.filter(lang => {
const normalized = normalizeLocale(lang);
return this._languages.some(l => normalizeLocale(l) === normalized);
});
console.log('[Selector] 固定语言列表:', result, result.map(l => ({ code: l, label: labelForLang(l) })));
return result;
}
// 渲染组件
render() {
const theme = this.getAttribute('theme') || 'light';
const fixedLanguages = this._getFixedLanguages(); // 固定显示的语言(中文、English)
const filtered = this._getFilteredLanguages(); // 搜索结果
const currentLabel = labelForLang(this._current);
const placeholder = this._getSearchPlaceholder();
console.log('[Selector] render:', {
current: this._current,
currentLabel,
fixedCount: fixedLanguages.length,
filteredCount: filtered.length,
query: this._query
});
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
position: relative;
width: 100%;
min-width: 180px;
max-width: 360px;
}
* {
box-sizing: border-box;
}
/* 触发按钮 */
.trigger-button {
width: 100%;
padding: 8px 32px 8px 12px;
font-size: 14px;
border: 1px solid var(--ui18n-selector-border, #d1d5db);
border-radius: 6px;
background-color: var(--ui18n-selector-bg, #ffffff);
color: var(--ui18n-selector-color, #1f2937);
cursor: pointer;
outline: none;
text-align: left;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
}
.trigger-button:hover {
background-color: var(--ui18n-selector-hover, #f9fafb);
border-color: var(--ui18n-selector-border-hover, #9ca3af);
}
.trigger-button:focus {
border-color: var(--ui18n-selector-focus, #10b981);
box-shadow: 0 0 0 3px var(--ui18n-selector-focus-shadow, rgba(16, 185, 129, 0.1));
}
.trigger-button[aria-expanded="true"] {
border-color: var(--ui18n-selector-focus, #10b981);
}
.language-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.language-code {
color: #9ca3af;
font-size: 12px;
flex-shrink: 0;
white-space: nowrap;
}
.arrow {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
font-size: 10px;
color: var(--ui18n-selector-color, #1f2937);
transition: transform 0.2s ease;
}
.trigger-button[aria-expanded="true"] .arrow {
transform: translateY(-50%) rotate(180deg);
}
/* 下拉菜单 */
.dropdown {
position: absolute;
z-index: 50;
margin-top: 8px;
width: 100%;
border: 1px solid var(--ui18n-dropdown-border, #e5e7eb);
border-radius: 8px;
background-color: var(--ui18n-dropdown-bg, #ffffff);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
display: none;
}
.dropdown.open {
display: block;
}
/* 固定语言列表 */
.fixed-languages {
border-bottom: 1px solid var(--ui18n-dropdown-border, #e5e7eb);
padding: 4px 0;
margin: 0;
}
/* 搜索框 */
.search-container {
padding: 8px;
border-bottom: 1px solid var(--ui18n-dropdown-border, #e5e7eb);
}
/* 搜索结果列表 */
.search-results {
border-top: 1px solid var(--ui18n-dropdown-border, #e5e7eb);
}
.search-input {
width: 100%;
padding: 8px 12px;
font-size: 14px;
border: 1px solid var(--ui18n-input-border, #d1d5db);
border-radius: 6px;
background-color: var(--ui18n-input-bg, #ffffff);
color: var(--ui18n-input-color, #1f2937);
outline: none;
transition: all 0.2s ease;
}
.search-input:focus {
border-color: var(--ui18n-input-focus, #10b981);
box-shadow: 0 0 0 3px var(--ui18n-input-focus-shadow, rgba(16, 185, 129, 0.1));
}
.search-input::placeholder {
color: #9ca3af;
}
/* 语言列表 */
.language-list {
max-height: 256px;
overflow-y: auto;
padding: 4px 0;
margin: 0;
list-style: none;
}
.language-item {
padding: 8px 12px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.15s ease;
white-space: nowrap;
gap: 8px;
}
.language-item:hover {
background-color: var(--ui18n-item-hover, #f3f4f6);
}
.language-item.active {
background-color: var(--ui18n-item-active, #f3f4f6);
}
.language-item.selected {
background-color: var(--ui18n-item-selected, #d1fae5);
}
.language-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.language-item-code {
color: #9ca3af;
font-size: 12px;
flex-shrink: 0;
white-space: nowrap;
}
.no-results {
padding: 16px 12px;
text-align: center;
color: #6b7280;
font-size: 14px;
}
/* 暗色主题 */
:host([theme="dark"]) {
--ui18n-selector-bg: #1f2937;
--ui18n-selector-color: #f9fafb;
--ui18n-selector-border: #374151;
--ui18n-selector-hover: #374151;
--ui18n-selector-border-hover: #4b5563;
--ui18n-selector-focus: #10b981;
--ui18n-dropdown-bg: #1f2937;
--ui18n-dropdown-border: #374151;
--ui18n-input-bg: #374151;
--ui18n-input-color: #f9fafb;
--ui18n-input-border: #4b5563;
--ui18n-item-hover: #374151;
--ui18n-item-active: #4b5563;
--ui18n-item-selected: #064e3b;
}
/* 无障碍支持 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>
<div class="selector-container">
<label class="sr-only" for="ui18n-lang-trigger">选择语言</label>
<button
id="ui18n-lang-trigger"
class="trigger-button"
type="button"
aria-haspopup="listbox"
aria-expanded="${this._isOpen}"
aria-controls="ui18n-lang-dropdown"
>
<span class="language-label">${currentLabel}</span>
<span class="language-code">(${this._current})</span>
<span class="arrow">▼</span>
</button>
<div
id="ui18n-lang-dropdown"
class="dropdown ${this._isOpen ? 'open' : ''}"
role="dialog"
aria-modal="true"
aria-label="语言选择器"
>
<!-- 固定语言列表:中文和English -->
<ul
class="language-list fixed-languages"
role="listbox"
aria-label="常用语言"
>
${fixedLanguages.map((lang) => `
<li
class="language-item ${lang === this._current ? 'selected' : ''}"
role="option"
aria-selected="${lang === this._current}"
data-lang="${lang}"
data-type="fixed"
>
<span class="language-item-name">${labelForLang(lang)}</span>
<span class="language-item-code">(${lang})</span>
</li>
`).join('')}
</ul>
<!-- 搜索框 -->
${this._showSearchBox ? `
<div class="search-container">
<label class="sr-only" for="ui18n-lang-search">搜索语言</label>
<input
id="ui18n-lang-search"
class="search-input"
type="text"
placeholder="${placeholder}"
value="${this._query}"
autocomplete="off"
/>
</div>
` : ''}
<!-- 搜索结果列表(仅在有搜索内容时显示) -->
${this._query ? `
<ul
class="language-list search-results"
role="listbox"
aria-label="搜索结果"
>
${filtered.length === 0 ? `
<li class="no-results">
无法找到该语言,请重新输入
</li>
` : filtered.map((lang, idx) => `
<li
class="language-item ${lang === this._current ? 'selected' : ''} ${idx === this._activeIndex ? 'active' : ''}"
role="option"
aria-selected="${lang === this._current}"
data-lang="${lang}"
data-index="${idx}"
data-type="search"
>
<span class="language-item-name">${labelForLang(lang)}</span>
<span class="language-item-code">(${lang})</span>
</li>
`).join('')}
</ul>
` : ''}
</div>
</div>
`;
}
// 设置事件监听(只在初始化时调用一次)
_setupEventListeners() {
// 使用事件委托处理所有交互
this.shadowRoot.addEventListener('click', (e) => {
let target = e.target;
// 向上查找直到找到匹配的元素或到达 shadowRoot
while (target && target !== this.shadowRoot) {
// 处理触发按钮点击
if (target.id === 'ui18n-lang-trigger' || target.closest('#ui18n-lang-trigger')) {
this._toggleDropdown();
e.stopPropagation();
return;
}
// 处理语言项点击
if (target.classList && target.classList.contains('language-item')) {
const lang = target.getAttribute('data-lang');
console.log('[Selector] 点击语言项:', { lang, target });
if (lang) {
this._selectLanguage(lang);
e.stopPropagation();
}
return;
}
target = target.parentElement;
}
});
// 处理输入法合成事件
this.shadowRoot.addEventListener('compositionstart', (e) => {
if (e.target.id === 'ui18n-lang-search') {
this._isComposing = true;
}
});
this.shadowRoot.addEventListener('compositionend', (e) => {
if (e.target.id === 'ui18n-lang-search') {
this._isComposing = false;
// 输入法结束后,触发一次搜索
this._handleSearch(e);
}
});
// 处理搜索输入
this.shadowRoot.addEventListener('input', (e) => {
if (e.target.id === 'ui18n-lang-search') {
// 如果正在使用输入法,不立即处理
if (!this._isComposing) {
this._handleSearch(e);
}
}
});
// 处理键盘事件
this.shadowRoot.addEventListener('keydown', (e) => {
if (e.target.id === 'ui18n-lang-search') {
this._handleKeyDown(e);
}
});
// 处理鼠标悬停高亮
this.shadowRoot.addEventListener('mouseenter', (e) => {
const item = e.target.closest('.language-item');
if (item) {
const idx = parseInt(item.getAttribute('data-index'), 10);
if (!isNaN(idx)) {
this._activeIndex = idx;
this.render();
}
}
}, true); // 使用捕获阶段
// 点击外部关闭(只绑定一次)
this._handleOutsideClick = (e) => {
if (!this.contains(e.target) && this._isOpen) {
this._closeDropdown();
}
};
document.addEventListener('click', this._handleOutsideClick);
// ESC 键关闭(只绑定一次)
this._handleEscKey = (e) => {
if (e.key === 'Escape' && this._isOpen) {
this._closeDropdown();
}
};
document.addEventListener('keydown', this._handleEscKey);
}
// 生命周期:断开连接时清理
disconnectedCallback() {
// 移除全局事件监听器
if (this._handleOutsideClick) {
document.removeEventListener('click', this._handleOutsideClick);
}
if (this._handleEscKey) {
document.removeEventListener('keydown', this._handleEscKey);
}
}
// 切换下拉菜单
_toggleDropdown() {
if (this._isOpen) {
this._closeDropdown();
} else {
this._openDropdown();
}
}
// 打开下拉菜单
_openDropdown() {
this._isOpen = true;
this._query = '';
this._activeIndex = 0;
this.render();
// 聚焦搜索框
setTimeout(() => {
const searchInput = this.shadowRoot.getElementById('ui18n-lang-search');
if (searchInput) searchInput.focus();
}, 0);
}
// 关闭下拉菜单
_closeDropdown() {
this._isOpen = false;
this._query = '';
this._activeIndex = 0;
this.render();
}
// 处理搜索
_handleSearch(e) {
this._query = e.target.value;
this._activeIndex = 0;
this.render();
this._refocusSearchInput();
}
// 处理键盘导航
_handleKeyDown(e) {
const filtered = this._getFilteredLanguages();
if (e.key === 'ArrowDown') {
e.preventDefault();
this._activeIndex = Math.min(this._activeIndex + 1, filtered.length - 1);
this.render();
this._refocusSearchInput();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this._activeIndex = Math.max(this._activeIndex - 1, 0);
this.render();
this._refocusSearchInput();
} else if (e.key === 'Enter') {
e.preventDefault();
if (filtered.length > 0) {
this._selectLanguage(filtered[this._activeIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
this._closeDropdown();
}
}
// 重新聚焦搜索框
_refocusSearchInput() {
setTimeout(() => {
const searchInput = this.shadowRoot.getElementById('ui18n-lang-search');
if (searchInput) {
searchInput.focus();
searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length);
}
}, 0);
}
// 选择语言
_selectLanguage(lang) {
const normalized = normalizeLocale(lang);
this._current = normalized;
this.setAttribute('current', normalized);
// 触发 language-change 事件
this.dispatchEvent(new CustomEvent('language-change', {
detail: { language: normalized },
bubbles: true,
composed: true
}));
this._closeDropdown();
}
// 公共API:设置语言
setLanguage(language) {
if (this._languages.includes(language)) {
this._selectLanguage(language);
} else {
console.warn(`Language "${language}" is not in the available languages list`);
}
}
// 公共API:获取可用语言列表
getAvailableLanguages() {
return [...this._languages];
}
// 公共API:打开/关闭下拉菜单
open() {
this._openDropdown();
}
close() {
this._closeDropdown();
}
// 公共API:刷新组件
refresh() {
this.render();
}
}
// 注册 Custom Element
if (!customElements.get('ui18n-language-selector')) {
customElements.define('ui18n-language-selector', UI18nLanguageSelector);
}
// 导出全局变量(用于 kit-auto 检测)
if (typeof window !== 'undefined') {
window.UI18nLanguageSelector = UI18nLanguageSelector;
}