UNPKG

@ui18n/selector-web

Version:

🌐 零依赖Web Components语言选择器 - 支持所有框架和浏览器的通用组件

665 lines (590 loc) 19.7 kB
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; }