import getFloatedTargetPos from '../utils/getFloatedTargetPos' import supportDom from '../decorators/supportDom' import getKey from '../utils/getKey' import { debounce, noop, toPixel, throttle } from '../utils'
const renderMenu = row => {
return `<div class="search-dropdown-menu-item" data-item>${JSON.stringify(row)}</div>`
}
const itemClick = row => JSON.stringify(row)
@supportDom export default class SearchDropdown {
constructor(dom, options = {}) { this.dom = dom this.options = options this.options.getData = options.getData || noop this.options.renderMenu = options.renderMenu || renderMenu this.options.itemClick = options.itemClick || itemClick this.options.change = options.change || noop this.options.wait = options.wait || 50 this.place = options.place || 'bottom' this.align = options.align || 'left' this.offset = options.offset || 14 this.offsetTop = options.offsetTop || 0 this.offsetLeft = options.offsetLeft || 0 this.noDataMsg = options.noDataMsg || '沒有資料' this.getFloatedTargetPos = options.getFloatedTargetPos || getFloatedTargetPos this.isMenuVisible = false this.lastKeyword = null this.selectedIndex = 0 this.items = [] this.compositionStarted = false this.compositionJustEnded = false this.loading = true this.init() } init() { this.initTextNode() this.appendMenu() this.addEvents() } initTextNode() { this.textNode = Array.from(this.dom.childNodes) .find(node => node.nodeType === Node.TEXT_NODE) if (this.textNode) { this.headSpaces = this.textNode.textContent.match(/^\s+/) || '' this.tailSpaces = this.textNode.textContent.match(/\s+$/) || '' } } setText(value) { if (this.textNode) { this.textNode.textContent = `${this.headSpaces}${value}${this.tailSpaces}` } } appendMenu() { const menu = document.createElement('div') const { dataset } = menu dataset.place = this.place dataset.align = this.align dataset.offset = this.offset dataset.offsetTop = this.offsetTop dataset.offsetLeft = this.offsetLeft menu.className = 'search-dropdown dropdown-menu' const inputWrap = document.createElement('div') inputWrap.className = 'search-dropdown-input-wrap' const input = document.createElement('input') input.type = 'text' input.className = 'input search-dropdown-input' inputWrap.appendChild(input) const loader = document.createElement('div') loader.className = 'search-dropdown-loader' loader.innerHTML = ` <div class="fb-loader"> <div></div> <div></div> <div></div> </div> ` inputWrap.appendChild(loader) if (this.options.placeholder) { input.setAttribute('placeholder', this.options.placeholder) } const menuContent = document.createElement('div') menuContent.className = 'search-dropdown-menu' menu.appendChild(inputWrap) menu.appendChild(menuContent) this.menu = menu this.input = input this.menuContent = menuContent this.loader = loader } setLoading(loading) { this.loading = loading if (loading) { this.input.classList.add('loading') this.loader.style.display = 'block' } else { this.input.classList.remove('loading') this.loader.style.display = 'none' } } setMenuContentActive(active) { if (active) { return this.menuContent.classList.add('active') } this.menuContent.classList.remove('active') } hideMenu() { const { menu } = this menu.style.transform = 'scale(.8)' menu.style.opacity = 0 setTimeout(() => menu.remove(), 300) // recover menu.dataset.place = this.place menu.dataset.align = this.align this.isMenuVisible = false this.lastKeyword = null } showMenu() { const { input, menu } = this this.getData(input.value) menu.style.display = 'block' menu.style.opacity = 0 menu.style.transform = 'scale(.8)' document.body.appendChild(menu) setTimeout(() => { this.adjustMenuPos() menu.style.transform = 'scale(1)' menu.style.opacity = 1 this.isMenuVisible = true this.input.focus() }, 0) } toggleMenu() { return this.isMenuVisible ? this.hideMenu() : this.showMenu() } adjustMenuPos() { const { menu, dom, offset, offsetLeft, offsetTop } = this const { pos, place, align } = this.getFloatedTargetPos({ src: dom, target: menu, place: this.place, align: this.align, offset, offsetLeft, offsetTop }) menu.dataset.place = place menu.dataset.align = align menu.style.left = toPixel(pos.left) menu.style.top = toPixel(pos.top) } renderMenu() { const { menuContent, items, selectedIndex } = this const { renderItem } = this.options let menuItems = items.map((item, i) => { return renderItem(item, i, (selectedIndex === i), items) }) if (menuItems.length === 0) { menuItems = [`<div class="search-dropdown-menu-item">${this.noDataMsg}</div>`] } menuContent.innerHTML = menuItems.join('') this.setMenuContentActive(items.length > 0) const menuItemEls = this.getMenuItemEls() const selectedEl = menuItemEls[selectedIndex] if (selectedEl) { const scrollTop = menuContent.scrollTop const contentTop = menuContent.offsetTop const contentBottom = contentTop + menuContent.offsetHeight const elHeight = selectedEl.offsetHeight const elTop = selectedEl.offsetTop - scrollTop const elBottom = elTop + elHeight if (elTop < contentTop) { menuContent.scrollTop -= elHeight } else if (elBottom > contentBottom) { menuContent.scrollTop += elHeight } } } setItems(items) { this.items = items this.renderMenu() } async getData(keyword) { if (this.lastKeyword === keyword) { return } this.resetSelectedIndex() this.lastKeyword = keyword this.setItems([]) this.setLoading(true) const items = await this.options.getData(keyword) this.setLoading(false) if (this.lastKeyword === this.input.value) { this.setItems(items) } } getMenuItemEls() { return Array.from(this.menuContent.querySelectorAll('[data-item]')) } findClickedItem(target) { const index = this.getMenuItemEls() .findIndex(item => (target === item) || (item.contains(target))) return this.items[index] } isInputFocused() { return document.activeElement === this.input } resetSelectedIndex() { this.selectedIndex = 0 } selectPrevItem() { if (this.items.length === 0) { return } if (this.selectedIndex > 0) { this.selectedIndex -= 1 this.renderMenu() } } selectNextItem() { const { length } = this.items if (length === 0) { return } if ((this.selectedIndex + 1) < (length - 1)) { this.selectedIndex += 1 this.renderMenu() } } setItem(item) { this.setText(this.options.itemClick(item)) this.hideMenu() this.options.change(item) } setCurrentItem() { const item = this.items[this.selectedIndex] if (item) { this.setItem(item) } } handleEscKey() { if (this.isInputFocused()) { this.input.blur() } else { this.hideMenu() } } addEvents() { this.addEvent(this.menuContent, 'click', event => { const item = this.findClickedItem(event.target) if (item) { this.setItem(item) } }) this.addEvent(this.input, 'focus', () => { this.renderMenu() }) this.addEvent(this.input, 'keyup', debounce(event => { if (this.compositionStarted) { return } this.getData(event.target.value) }, this.options.wait)) this.addEvent(this.input, 'compositionstart', () => { this.compositionStarted = true }) this.addEvent(this.input, 'compositionend', () => { this.compositionJustEnded = true this.compositionStarted = false }) this.addEvent(this.dom, 'click', () => this.toggleMenu()) this.addEvent(document, 'click', event => { if (! this.isMenuVisible) { return } const isBackdrop = (event.target !== this.dom) && (! this.dom.contains(event.target)) && (! this.menu.contains(event.target)) if (isBackdrop) { this.hideMenu() } }) this.addEvent(document, 'keydown', event => { const key = getKey(event) if (this.isMenuVisible && ['up', 'down'].includes(key)) { event.preventDefault() } }) this.addEvent(document, 'keyup', event => { if (this.compositionStarted) { return } if (! this.isMenuVisible) { return } const key = getKey(event) if (key === 'esc') { return this.handleEscKey() } if (key === 'up') { return this.selectPrevItem() } if (key === 'down') { return this.selectNextItem() } // workaround to block composition ended with enter key if ((key === 'enter') && this.compositionJustEnded) { this.compositionJustEnded = false return } if (key === 'enter') { return this.setCurrentItem() } }) this.addEvent(window, 'resize', throttle(() => { if (! this.isMenuVisible) { return } this.adjustMenuPos() }, 300)) } destroy() { this.menu.remove() this.menu = null this.input = null this.menuContent = null this.loader = null }
}