// const DEFAULTS = { // treshold: 2, // maximumItems: 5, // highlightTyped: true, // highlightClass: 'text-primary', // }; // // class Autocomplete { // constructor(field, options) { // this.field = field; // this.options = Object.assign({}, DEFAULTS, options); // this.dropdown = null; // // field.parentNode.classList.add('dropdown'); // field.setAttribute('data-toggle', 'dropdown'); // field.classList.add('dropdown-toggle'); // // const dropdown = ce(`
`); // if (this.options.dropdownClass) // dropdown.classList.add(this.options.dropdownClass); // // insertAfter(dropdown, field); // // this.dropdown = new bootstrap.Dropdown(field, this.options.dropdownOptions) // // field.addEventListener('click', (e) => { // if (this.createItems() === 0) { // e.stopPropagation(); // this.dropdown.hide(); // } // }); // // field.addEventListener('input', () => { // if (this.options.onInput) // this.options.onInput(this.field.value); // this.renderIfNeeded(); // }); // // field.addEventListener('keydown', (e) => { // if (e.keyCode === 27) { // this.dropdown.hide(); // return; // } // }); // } // // setData(data) { // this.options.data = data; // } // // renderIfNeeded() { // if (this.createItems() > 0) // this.dropdown.show(); // else // this.field.click(); // } // // createItem(lookup, item) { // let label; // if (this.options.highlightTyped) { // const idx = item.label.toLowerCase().indexOf(lookup.toLowerCase()); // const className = Array.isArray(this.options.highlightClass) ? this.options.highlightClass.join(' ') // : (typeof this.options.highlightClass == 'string' ? this.options.highlightClass : '') // label = item.label.substring(0, idx) // + `${item.label.substring(idx, idx + lookup.length)}` // + item.label.substring(idx + lookup.length, item.label.length); // } else // label = item.label; // return ce(``); // } // // createItems() { // const lookup = this.field.value; // if (lookup.length < this.options.treshold) { // this.dropdown.hide(); // return 0; // } // // const items = this.field.nextSibling; // items.innerHTML = ''; // // let count = 0; // for (let i = 0; i < this.options.data.length; i++) { // const {label, value} = this.options.data[i]; // const item = {label, value}; // if (item.label.toLowerCase().indexOf(lookup.toLowerCase()) >= 0) { // items.appendChild(this.createItem(lookup, item)); // if (this.options.maximumItems > 0 && ++count >= this.options.maximumItems) // break; // } // } // // this.field.nextSibling.querySelectorAll('.dropdown-item').forEach((item) => { // item.addEventListener('click', (e) => { // let dataValue = e.target.getAttribute('data-value'); // this.field.value = e.target.innerText; // if (this.options.onSelectItem) // this.options.onSelectItem({ // value: e.target.value, // label: e.target.innerText, // }); // this.dropdown.hide(); // }) // }); // // return items.childNodes.length; // } // } // // /** // * @param html // * @returns {Node} // */ // function ce(html) { // let div = document.createElement('div'); // div.innerHTML = html; // return div.firstChild; // } // // /** // * @param elem // * @param refElem // * @returns {*} // */ // function insertAfter(elem, refElem) { // return refElem.parentNode.insertBefore(elem, refElem.nextSibling) // } const DEFAULTS = { treshold: 2, maximumItems: 5, highlightTyped: true, highlightClass: 'text-primary', }; class Autocomplete { constructor(field, options) { this.field = field; this.options = Object.assign({}, DEFAULTS, options); // 兼容 threshold 正确拼写 if (typeof this.options.treshold !== 'number' && typeof this.options.threshold === 'number') { this.options.treshold = this.options.threshold; } this.menuEl = null; this.itemsEls = []; this.activeIndex = -1; this.composing = false; // 容器/触发器 field.parentNode.classList.add('dropdown'); field.setAttribute('data-toggle', 'dropdown'); field.classList.add('dropdown-toggle'); field.setAttribute('role', 'combobox'); field.setAttribute('aria-autocomplete', 'list'); field.setAttribute('aria-expanded', 'false'); // 菜单 this.menuEl = document.createElement('div'); this.menuEl.className = 'dropdown-menu'; this.menuEl.setAttribute('role', 'listbox'); if (this.options.dropdownClass) this.menuEl.classList.add(this.options.dropdownClass); field.parentNode.insertBefore(this.menuEl, field.nextSibling); // Bootstrap controller(可选) this.dropdown = (window.bootstrap && window.bootstrap.Dropdown) ? new bootstrap.Dropdown(field, this.options.dropdownOptions) : { show: () => { this.menuEl.classList.add('show'); this.field.setAttribute('aria-expanded', 'true'); }, hide: () => { this.menuEl.classList.remove('show'); this.field.setAttribute('aria-expanded', 'false'); }, }; // 事件:点击触发(无项时阻止展开) field.addEventListener('click', (e) => { if (this._buildMenu() === 0) { e.stopPropagation(); this.dropdown.hide(); } else { this._show(); } }); // 事件:输入 & 组合输入 field.addEventListener('compositionstart', () => { this.composing = true; }); field.addEventListener('compositionend', () => { this.composing = false; this._renderIfNeeded(); }); field.addEventListener('input', () => { if (this.options.onInput) this.options.onInput(this.field.value); if (!this.composing) this._renderIfNeeded(); }); // 键盘导航 field.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this._hide(); return; } if (!this.menuEl.classList.contains('show')) return; if (e.key === 'ArrowDown') { e.preventDefault(); this._moveActive(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); this._moveActive(-1); } else if (e.key === 'Enter') { // if (this.activeIndex >= 0 && this.itemsEls[this.activeIndex]) { // e.preventDefault(); // this._selectByEl(this.itemsEls[this.activeIndex]); // } } }); // 事件委派:防止 blur;点击选择 this.menuEl.addEventListener('pointerdown', (e) => { // 防止 input 失焦导致菜单提前关闭 e.preventDefault(); }); this.menuEl.addEventListener('click', (e) => { const el = e.target.closest('.dropdown-item'); if (!el) return; this._selectByEl(el); }); } // ============== Public API ============== setData(data) { this.options.data = Array.isArray(data) ? data : []; this._renderIfNeeded(); } renderIfNeeded() { // 为兼容旧调用暴露 this._renderIfNeeded(); } destroy() { // 粗暴但安全:移除菜单、清理类名/属性 try { this._hide(); } catch(_) {} this.menuEl?.remove(); this.menuEl = null; this.itemsEls = []; this.field.classList.remove('dropdown-toggle'); this.field.removeAttribute('data-toggle'); this.field.removeAttribute('role'); this.field.removeAttribute('aria-autocomplete'); this.field.removeAttribute('aria-expanded'); // 注:未全量移除所有事件监听,如需彻底销毁可在外部克隆替换节点 } // ============== Private ============== _renderIfNeeded() { if (this._buildMenu() > 0) this._show(); else this._hide(); } _show() { this.dropdown.show(); this.field.setAttribute('aria-expanded', 'true'); } _hide() { this.dropdown.hide(); this.field.setAttribute('aria-expanded', 'false'); } _buildMenu() { const lookup = String(this.field.value ?? ''); const minChars = Math.max(0, Number(this.options.treshold) || 0); if (lookup.length < minChars) { this.menuEl.innerHTML = ''; this.activeIndex = -1; return 0; } const data = Array.isArray(this.options.data) ? this.options.data : []; const filter = this.options.filter || defaultFilter; const limit = Math.max(0, Number(this.options.maximumItems) || 0); this.menuEl.innerHTML = ''; this.itemsEls = []; this.activeIndex = -1; let count = 0; for (let i = 0; i < data.length; i++) { const item = data[i]; const label = String(item.label ?? ''); const value = item.value; if (!filter(lookup, item)) continue; const btn = this._createItemEl(lookup, label, value); this.menuEl.appendChild(btn); this.itemsEls.push(btn); if (limit > 0 && ++count >= limit) break; } // 首项激活(可选) if (this.itemsEls.length > 0) this._setActive(0); return this.itemsEls.length; } _createItemEl(lookup, rawLabel, value) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'dropdown-item'; btn.setAttribute('role', 'option'); btn.dataset.value = value; btn.dataset.label = rawLabel; // 原始 label,选择时不带高亮 // 高亮 if (this.options.highlightTyped) { const container = document.createElement('span'); const idx = rawLabel.toLowerCase().indexOf(lookup.toLowerCase()); if (idx === -1) { container.textContent = rawLabel; } else { const before = document.createTextNode(rawLabel.slice(0, idx)); const mark = document.createElement('span'); const after = document.createTextNode(rawLabel.slice(idx + lookup.length)); const cls = Array.isArray(this.options.highlightClass) ? this.options.highlightClass.join(' ') : (typeof this.options.highlightClass === 'string' ? this.options.highlightClass : ''); if (cls) mark.className = cls; mark.textContent = rawLabel.slice(idx, idx + lookup.length); container.appendChild(before); container.appendChild(mark); container.appendChild(after); } btn.appendChild(container); } else { btn.textContent = rawLabel; } return btn; } _moveActive(delta) { if (this.itemsEls.length === 0) return; let next = this.activeIndex + delta; if (next < 0) next = this.itemsEls.length - 1; if (next >= this.itemsEls.length) next = 0; this._setActive(next); } _setActive(index) { this.itemsEls.forEach(el => el.classList.remove('active')); this.activeIndex = index; const el = this.itemsEls[index]; if (!el) return; el.classList.add('active'); // ARIA const id = el.id || (`ac-item-${Math.random().toString(36).slice(2)}`); el.id = id; this.field.setAttribute('aria-activedescendant', id); // 可见性 const elRect = el.getBoundingClientRect(); const menuRect = this.menuEl.getBoundingClientRect(); if (elRect.top < menuRect.top || elRect.bottom > menuRect.bottom) { el.scrollIntoView({ block: 'nearest' }); } } _selectByEl(el) { const rawLabel = el.dataset.label ?? el.textContent ?? ''; const value = el.dataset.value; this.field.value = rawLabel; if (this.options.onSelectItem) { this.options.onSelectItem({ label: rawLabel, value }); } this._hide(); } } // ===== Utils ===== // 默认过滤:label 包含(不区分大小写) function defaultFilter(lookup, item) { const label = String(item.label ?? '').toLowerCase(); return label.indexOf(String(lookup ?? '').toLowerCase()) >= 0; }