yuku-t/textcomplete

View on GitHub
src/dropdown.js

Summary

Maintainability
B
4 hrs
Test Coverage
// @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<DropdownOptions, "footer">
  header: $PropertyType<DropdownOptions, "header">
  maxCount: $PropertyType<DropdownOptions, "maxCount">
  rotate: $PropertyType<DropdownOptions, "rotate">
  placement: $PropertyType<DropdownOptions, "placement">
  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";
  }
}