// @flow import EventEmitter from "eventemitter3" import DropdownItem, { type DropdownItemOptions } from "./dropdown_item" import SearchResult from "./search_result" import { createCustomEvent } from "./utils" import type { CursorOffset } from "./editor" const DEFAULT_CLASS_NAME = "dropdown-menu textcomplete-dropdown" /** @typedef */ export type DropdownOptions = { className?: string, footer?: any => string | string, header?: any => string | string, maxCount?: number, placement?: string, rotate?: boolean, style?: { [string]: string }, item?: DropdownItemOptions, parent?: HTMLElement, } /** * Encapsulate a dropdown view. * * @prop {boolean} shown - Whether the #el is shown or not. * @prop {DropdownItem[]} items - The array of rendered dropdown items. */ export default class Dropdown extends EventEmitter { shown: boolean items: DropdownItem[] activeItem: DropdownItem | null footer: $PropertyType header: $PropertyType maxCount: $PropertyType rotate: $PropertyType placement: $PropertyType itemOptions: DropdownItemOptions _parent: HTMLElement _el: ?HTMLUListElement static createElement(parent?: HTMLElement): HTMLUListElement { const el = document.createElement("ul") const style = el.style style.display = "none" style.position = "absolute" style.zIndex = "10000" if (parent) { parent.appendChild(el); } else { const body = document.body if (body) { body.appendChild(el) } } return el } constructor(options: DropdownOptions) { super() this._parent = options.parent this.shown = false this.items = [] this.activeItem = null this.footer = options.footer this.header = options.header this.maxCount = options.maxCount || 10 this.el.className = options.className || DEFAULT_CLASS_NAME this.rotate = options.hasOwnProperty("rotate") ? options.rotate : true this.placement = options.placement this.itemOptions = options.item || {} const style = options.style if (style) { Object.keys(style).forEach(key => { ;(this.el.style: any)[key] = style[key] }) } } /** * @return {this} */ destroy() { const parentNode = this.el.parentNode if (parentNode) { parentNode.removeChild(this.el) } this._parent = null this.clear()._el = null return this } get el(): HTMLUListElement { if (!this._el) { this._el = Dropdown.createElement(this._parent) } return this._el } /** * Render the given data as dropdown items. * * @return {this} */ render(searchResults: SearchResult[], cursorOffset: CursorOffset) { const renderEvent = createCustomEvent("render", { cancelable: true }) this.emit("render", renderEvent) if (renderEvent.defaultPrevented) { return this } const rawResults = searchResults.map(searchResult => searchResult.data) const dropdownItems = searchResults .slice(0, this.maxCount || searchResults.length) .map(searchResult => new DropdownItem(searchResult, this.itemOptions)) this.clear() .setStrategyId(searchResults[0]) .renderEdge(rawResults, "header") .append(dropdownItems) .renderEdge(rawResults, "footer") .show() .setOffset(cursorOffset) this.emit("rendered", createCustomEvent("rendered")) return this } /** * Hide the dropdown then sweep out items. * * @return {this} */ deactivate() { return this.hide().clear() } /** * @return {this} */ select(dropdownItem: DropdownItem) { const detail = { searchResult: dropdownItem.searchResult } const selectEvent = createCustomEvent("select", { cancelable: true, detail: detail, }) this.emit("select", selectEvent) if (selectEvent.defaultPrevented) { return this } this.deactivate() this.emit("selected", createCustomEvent("selected", { detail })) return this } /** * @return {this} */ up(e: CustomEvent) { return this.shown ? this.moveActiveItem("prev", e) : this } /** * @return {this} */ down(e: CustomEvent) { return this.shown ? this.moveActiveItem("next", e) : this } /** * Retrieve the active item. */ getActiveItem(): DropdownItem | null { return this.activeItem } /** * Add items to dropdown. * * @private */ append(items: DropdownItem[]) { const fragment = document.createDocumentFragment() items.forEach(item => { this.items.push(item) item.appended(this) fragment.appendChild(item.el) }) this.el.appendChild(fragment) return this } /** @private */ setOffset(cursorOffset: CursorOffset) { const doc = document.documentElement if (doc) { const elementWidth = this.el.offsetWidth if (cursorOffset.left) { const browserWidth = doc.clientWidth if (cursorOffset.left + elementWidth > browserWidth) { cursorOffset.left = browserWidth - elementWidth } this.el.style.left = `${cursorOffset.left}px` } else if (cursorOffset.right) { if (cursorOffset.right - elementWidth < 0) { cursorOffset.right = 0 } this.el.style.right = `${cursorOffset.right}px` } let forceTop = false; if(this.isPlacementAuto()) { let dropdownHeight = this.items.length * cursorOffset.lineHeight; if(cursorOffset.clientTop + dropdownHeight > doc.clientHeight) { forceTop = true; } } if (this.isPlacementTop() || forceTop) { this.el.style.bottom = `${doc.clientHeight - cursorOffset.top + cursorOffset.lineHeight}px`; this.el.style.top = "auto"; } else { this.el.style.top = `${cursorOffset.top}px`; this.el.style.bottom = "auto"; } } return this } /** * Show the element. * * @private */ show() { if (!this.shown) { const showEvent = createCustomEvent("show", { cancelable: true }) this.emit("show", showEvent) if (showEvent.defaultPrevented) { return this } this.el.style.display = "block" this.shown = true this.emit("shown", createCustomEvent("shown")) } return this } /** * Hide the element. * * @private */ hide() { if (this.shown) { const hideEvent = createCustomEvent("hide", { cancelable: true }) this.emit("hide", hideEvent) if (hideEvent.defaultPrevented) { return this } this.el.style.display = "none" this.shown = false this.emit("hidden", createCustomEvent("hidden")) } return this } /** * Clear search results. * * @private */ clear() { this.el.innerHTML = "" this.items.forEach(item => item.destroy()) this.items = [] return this } /** @private */ moveActiveItem(direction: "next" | "prev", e: CustomEvent) { const nextActiveItem = direction === "next" ? this.activeItem ? this.activeItem.next : this.items[0] : this.activeItem ? this.activeItem.prev : this.items[this.items.length - 1] if (nextActiveItem) { nextActiveItem.activate() e.preventDefault() } return this } /** @private */ setStrategyId(searchResult: ?SearchResult) { const strategyId = searchResult && searchResult.strategy.props.id if (strategyId) { this.el.setAttribute("data-strategy", strategyId) } else { this.el.removeAttribute("data-strategy") } return this } /** * @private * @param {object[]} rawResults - What callbacked by search function. */ renderEdge(rawResults: Object[], type: "header" | "footer") { const source = (type === "header" ? this.header : this.footer) || "" const content: any = typeof source === "function" ? source(rawResults) : source const li = document.createElement("li") li.classList.add(`textcomplete-${type}`) li.innerHTML = content this.el.appendChild(li) return this } /** @private */ isPlacementTop() { return this.placement === "top" } isPlacementAuto() { return this.placement === "auto"; } }