ES6: port menu

Change-Id: I8740d81b928e26215276f3ce92c87d83dbc3a7b2
diff --git a/dev/js/src-lib/menu/menu.js b/dev/js/src-lib/menu/menu.js
new file mode 100644
index 0000000..d9b2e0f
--- /dev/null
+++ b/dev/js/src-lib/menu/menu.js
@@ -0,0 +1,964 @@
+/**
+ * Scrollable drop-down menus with view filter.
+ *
+ * @author Nils Diewald
+ */
+/*
+ * TODO: Show the slider briefly on move (whenever screen is called).
+ * TODO: Ignore alt+ and strg+ key strokes.
+ * TODO: Should scroll to a chosen value after prefixing, if the chosen value is live
+ * TODO: Add a "title" to a menu that is not scrollable.
+ * TODO: Make the menu responsive by showing less items on smaller screens
+ *       or anytime items would be outside the screen.
+ * TODO: Add a .match() method to items for scrolling and probably for prefixing.
+ * TODO: Add static header (for title, sortation fields, but also for menu points like "fragments" and "history".
+ * TODO: Support space separated list of prefixes so "co no" will highlight "common noun"
+ */
+
+import defaultItemClass from './menu/item';
+import defaultPrefixClass from './menu/prefix';
+import defaultLengthFieldClass from './menu/lengthField';
+import sliderClass from './menu/slider';
+
+const menuLimit = 8;
+
+export default class KalamarMenu {
+
+  /**
+   * List of items for drop down menu (complete).
+   * Only a sublist of the menu is filtered (live).
+   * Only a sublist of the filtered menu is visible (shown).
+   */
+
+    /**
+     * Create new Menu based on the action prefix
+     * and a list of menu items.
+     *
+     *
+     * Accepts an associative array containg the elements
+     * itemClass, prefixClass, lengthFieldClass
+     *
+     * @this {Menu}
+     * @constructor
+     * @param {string} Context prefix
+     * @param {Array.<Array.<string>>} List of menu items
+     */
+  constructor (list, params) {
+      return this._init(list, params);
+    }
+
+    // Initialize list
+    _init (list, params) {
+      if (params === undefined)
+        params = {};
+
+      const t = this;
+      t._notItemElements=3;
+
+      t._itemClass = params["itemClass"] || defaultItemClass;
+
+      // Add prefix object
+      if (params["prefixClass"] !== undefined) {
+        t._prefix = new params["prefixClass"]();
+      }
+      else {
+        t._prefix = new defaultPrefixClass();
+      };
+      t._prefix._menu = t;
+
+      // Add lengthField object
+      if (params["lengthFieldClass"] !== undefined) {
+        t._lengthField = new params["lengthFieldClass"]();
+      }
+      else {
+        t._lengthField = new defaultLengthFieldClass();
+      };
+      t._lengthField._menu = t;
+
+      // Initialize slider
+      t._slider = new sliderClass(t);
+
+      // Create the element
+      var el = document.createElement("ul");
+      el.style.outline = 0;
+      el.setAttribute('tabindex', 0);
+      el.classList.add('menu', 'roll');
+      el.appendChild(t._prefix.element());
+      el.appendChild(t._lengthField.element());
+      el.appendChild(t._slider.element());
+
+      // This has to be cleaned up later on
+      el["menu"] = t;
+
+      // Arrow keys
+      el.addEventListener(
+        'keydown',
+        t._keydown.bind(t),
+        false
+      );
+
+      // Strings
+      el.addEventListener(
+        'keypress',
+        t._keypress.bind(t),
+        false
+      );
+
+      // Mousewheel
+      el.addEventListener(
+        'wheel',
+        t._mousewheel.bind(t),
+        false
+      );
+
+      // Touch events
+      ['touchstart', 'touchend', 'touchmove'].forEach(
+        e => el.addEventListener(e, t._touch.bind(t), false)
+      );
+
+      
+      t._el = el;
+
+      t._limit = menuLimit;
+      
+      t._items = new Array(); //all childNodes, i.e. ItemClass, prefixClass
+
+      t.readItems(list);
+
+      t.dontHide = false;
+        
+      return t;
+    }
+
+    // Read items to add to list
+    readItems (list) {
+      const t = this;
+
+      t._list = undefined; //filtered List containing all itemClass items
+
+      // Remove circular reference to "this" in items
+      for (let i = 0; i < t._items.length; i++) {
+        delete t._items[i]["_menu"];
+        delete t._items[i];
+      };
+
+      t._items = new Array();
+      t.removeItems();
+
+
+      // Initialize items
+      t._lengthField.reset();
+
+      // Initialize item list based on parameters
+      list.forEach(function(i){
+        const obj = new this._itemClass(i);
+
+        // This may become circular
+        obj["_menu"] = this;
+        this._lengthField.add(i);
+        this._items.push(obj);
+      }, t);
+
+      t._slider.length(t.liveLength())
+        .limit(t._limit)
+        .reInit();
+      
+      t._firstActive = false;
+      // Show the first item active always?
+      t.offset = 0;
+      t.position = 0;
+    }
+
+    // Append item to list
+    append (item) {
+      const t = this;
+      // This is cyclic!
+      item["_menu"] = t;
+      t._list = undefined;
+      t.removeItems();
+      t._items.push(item);
+      t._lengthField.add([item.content().data]);
+      t._slider.length(t.liveLength()).reInit();
+      t._firstActive = false;
+      t.offset = 0;
+      t.position = 0;
+    }
+    
+    // Initialize the item list
+    // returns true if the length of the resulting list is at least 1
+    // and there was a prefix value. Returns true if there was no prefix value set.
+    _initList () {
+      // Upon change also update alwaysmenu.js please
+      const t = this;
+
+      // Create a new list
+      if (t._list === undefined) {
+        t._list = [];
+      }
+      else if (t._list.length !== 0) {
+        t._boundary(false);
+        t._list.length = 0;
+      };
+
+      // Offset is initially zero
+      t.offset = 0;
+
+      // There is no prefix set
+      if (t.prefix().length <= 0) {
+
+        // add all items to the list and lowlight
+        let i = 0;
+        for (; i < t._items.length; i++) {
+          t._list.push(i);
+          t._items[i].lowlight();
+        };
+
+        t._slider.length(i).reInit();
+
+        return true;
+      };
+
+      /*
+       * There is a prefix set, so filter the list!
+       */
+      let pos;
+      const prefixList = t.prefix().toLowerCase().split(" ");
+
+      const items = [];
+      let maxPoints = 1; // minimum 1
+
+      // Iterate over all items and choose preferred matching items
+      // i.e. the matching happens at the word start
+      t._items.forEach(function(it, pos){
+
+        let points = 0;
+
+        prefixList.forEach(function(p) {
+
+          // Check if it matches at the beginning
+          if ((it.lcField().includes(" " + p))) {
+            points += 5;
+          }
+
+          // Check if it matches anywhere
+          else if (it.lcField().includes(p)) {
+            points += 1;
+          };
+        });
+
+        if (points > maxPoints) {
+          this._list = [pos];
+          maxPoints = points;
+        }
+        else if (points == maxPoints) {
+          this._list.push(pos);
+        }
+      }, t);
+
+      t._slider.length(t._list.length).reInit();
+
+      // Filter was successful - yeah!
+      return t._list.length > 0 ? true : false;
+    }
+
+
+    /**
+     * Destroy this menu
+     * (in case you don't trust the
+     * mark and sweep GC)!
+     */
+    destroy () {
+      // Upon change also update alwaysmenu.js please
+      const t = this;
+
+      // Remove circular reference to "this" in menu
+      if (t._el != undefined)
+        delete t._el["menu"]; 
+
+      // Remove circular reference to "this" in items
+      t._items.forEach(function(i) {
+        delete i["_menu"];
+      });
+
+      // Remove circular reference to "this" in prefix
+      delete t._prefix['_menu'];
+      delete t._lengthField['_menu'];
+      delete t._slider['_menu'];
+    }
+
+
+    /**
+     * Focus on this menu.
+     */
+    focus () {
+      this._el.focus();
+    }
+
+
+    // mouse wheel treatment
+    _mousewheel (e) {
+      const delta = e.deltaY / 120;
+      if (delta > 0)
+        this.next();
+      else if (delta < 0)
+        this.prev();
+      e.halt();
+    }
+
+
+    // touchmove treatment
+    _touch (e) {
+      const s = this.slider();
+
+      if (e.type === "touchstart") {
+        this._lastTouch = e.touches[0].clientY;
+      }
+      else if (e.type === "touchend") {
+        this._lastTouch = undefined;
+      }
+      else if (e.type === "touchmove") {
+        const to = e.touches[0];
+
+        // TODO:
+        // Instead of using 26px, choose the item height
+        // or use the menu height // shownItems
+        
+        // s.movetoRel(t.clientY - this._initTouch);
+        if ((this._lastTouch + 26) < to.clientY) {
+          this.viewDown();
+          this._lastTouch = to.clientY;
+        }
+        else if ((this._lastTouch - 26) > to.clientY) {
+          this.viewUp();
+          this._lastTouch = to.clientY;
+        }
+        e.halt();
+      };
+    }
+
+    // Arrow key and prefix treatment
+    _keydown (e) {
+      //Upon change also update alwaysmenu.js please
+      const t = this;
+
+      switch (_codeFromEvent(e)) {
+
+      case 27: // 'Esc'
+        e.halt();
+        t.hide();
+        break;
+
+      case 38: // 'Up'
+        e.halt();
+        t.prev();
+        break;
+
+      case 33: // 'Page up'
+        e.halt();
+        t.pageUp();
+        break;
+
+      case 40: // 'Down'
+        e.halt();
+        t.next();
+        break;
+
+      case 34: // 'Page down'
+        e.halt();
+        t.pageDown();
+        break;
+
+      case 39: // 'Right'
+        if (t._prefix.active())
+          break;
+
+        const item = t.liveItem(t.position);
+        
+        if (item["further"] !== undefined) {
+          item["further"].bind(item).apply();
+        };
+        
+        e.halt();
+        break;
+
+      case 13: // 'Enter'
+        // Click on prefix
+        if (t._prefix.active())
+          t._prefix.onclick(e);
+
+        // Click on item
+        else
+          t.liveItem(t.position).onclick(e);
+        e.halt();
+        break;
+
+      case 8: // 'Backspace'
+        t._prefix.chop();
+        t.show();
+        e.halt();
+        break;
+      };
+    }
+
+
+    // Add characters to prefix
+    _keypress (e) {
+      if (e.charCode !== 0) {
+        e.halt();
+        
+        // Add prefix
+        this._prefix.add(
+          String.fromCharCode(_codeFromEvent(e))
+        );
+
+        this.show();
+      };
+    }
+
+
+    /**
+     * Show a screen with a given offset
+     * in the viewport.
+     */
+    screen (nr) {
+      const t = this;
+
+      // Normalize negative values
+      if (nr < 0) {
+        nr = 0
+      }
+
+      // The shown list already shows everything
+      else if (t.liveLength() < t.limit()) {
+        return false;
+      }
+
+      // Move relatively to the next screen
+      else if (nr > (t.liveLength() - t.limit())) {
+        nr = (t.liveLength() - t.limit());
+      };
+
+      // no change
+      if (t.offset === nr)
+        return false;
+
+      t._showItems(nr);
+
+      return true;
+    }
+
+
+    /**
+     * Get the associated dom element.
+     */
+    element () {
+      return this._el;
+    }
+
+
+    /**
+     * Get the creator class for items
+     */
+    itemClass () {
+      return this._itemClass;
+    }
+
+
+    /**
+     * Get and set the numerical value
+     * for the maximum number of items visible.
+     */
+    limit (limit) {
+      if (arguments.length === 1) {
+        if (this._limit !== limit) {
+          this._limit = limit;
+          this._slider.limit(limit).reInit();
+        };
+        return this;
+      };
+      return this._limit;
+    }
+
+
+    /**
+     * Filter the list and make it visible.
+     * This is always called once the prefix changes.
+     *
+     * @param {string} Prefix for filtering the list
+     */
+    show (active) {
+      //Upon change please also update alwaysmenu.js and containermenu.js (only two lines new there)
+      const t = this;
+
+      // show menu based on initial offset
+      t._unmark();     // Unmark everything that was marked before
+      t.removeItems();
+
+      // Initialize the list
+      if (!t._initList()) {
+
+        // The prefix is not active
+        t._prefix.active(true);
+
+        // finally show the element
+        t._el.classList.add('visible');
+        
+        return true;
+      };
+
+      let offset = 0;
+
+      // Set a chosen value to active and move the viewport
+      if (arguments.length === 1) {
+
+        // Normalize active value
+        if (active < 0) {
+          active = 0;
+        }
+        else if (active >= t.liveLength()) {
+          active = t.liveLength() - 1;
+        };
+
+        // Item is outside the first viewport
+        if (active >= t._limit) {
+          offset = active;
+          const newOffset = t.liveLength() - t._limit;
+          if (offset > newOffset) {
+            offset = newOffset;
+          };
+        };
+        
+        t.position = active;
+      }
+
+      // Choose the first item
+      else if (t._firstActive) {
+        t.position = 0;
+      }
+
+      // Choose no item
+      else {
+        t.position = -1;
+      };
+
+      t.offset = offset;
+      t._showItems(offset); // Show new item list
+
+      // Make chosen value active
+      if (t.position !== -1) {
+        t.liveItem(t.position).active(true);
+      };
+
+      // The prefix is not active
+      t._prefix.active(false);
+
+      // finally show the element
+      t._el.classList.add('visible');
+
+      // Add classes for rolling menus
+      t._boundary(true);
+
+      return true;
+    }
+
+
+    /**
+     * Hide the menu and call the onHide callback.
+     */
+    hide () {
+      if (!this.dontHide) {
+        this.removeItems();
+        this._prefix.clear();
+        this.onHide();
+        this._el.classList.remove('visible');
+      }
+      // this._el.blur();
+    }
+
+
+    /**
+     * Function released when the menu hides.
+     * This method is expected to be overridden.
+     */
+    onHide () {}
+
+
+    /**
+     * Get the prefix for filtering,
+     * e.g. &quot;ve&quot; for &quot;verb&quot;
+     */
+    prefix (pref) {
+      if (arguments.length === 1) {
+        this._prefix.value(pref);
+        return this;
+      };
+      return this._prefix.value();
+    }
+
+    
+    /**
+     * Get the lengthField object.
+     */
+    lengthField () {
+      return this._lengthField;
+    }
+
+
+    /**
+     * Get the associated slider object.
+     */
+    slider () {
+      return this._slider;
+    }
+
+
+    /**
+     * Delete all visible items from the menu element
+     */
+    
+    removeItems () {
+      const liElements=this._el.getElementsByTagName("LI");
+      var ignoredCount = 0; //counts how many LI tag elements are not actually direct children
+      while (liElements.length>ignoredCount){
+        if (liElements[ignoredCount].parentNode === this._el){
+          this._el.removeChild(liElements[ignoredCount]);
+        } else {
+          ignoredCount++;
+        }
+      };
+     }
+      
+
+
+    /**
+     * Get a specific item from the complete list
+     *
+     * @param {number} index of the list item
+     */
+    item (index) {
+      return this._items[index]
+    }
+
+
+    /**
+     * Get a specific item from the filtered list
+     *
+     * @param {number} index of the list item
+     *        in the filtered list
+     */
+    liveItem (index) {
+      if (this._list === undefined)
+        if (!this._initList())
+          return;
+
+      return this._items[this._list[index]];
+    }
+
+
+    /**
+     * Get a specific item from the viewport list
+     *
+     * @param {number} index of the list item
+     *        in the visible list
+     */
+    shownItem (index) {
+      if (index >= this.limit())
+        return;
+
+      return this.liveItem(this.offset + index);
+    }
+
+
+    /**
+     * Get the length of the full item list
+     */
+    length () {
+      return this._items.length;
+    }
+
+
+    /**
+     * Length of the filtered item list.
+     */
+    liveLength () {
+      if (this._list === undefined)
+        this._initList();
+      return this._list.length;
+    }
+
+    
+    /**
+     * Make the next item in the filtered menu active
+     */
+    next () {
+      //Upon change please update alwaysmenu.js next
+      const t = this;
+
+      // No list
+      if (t.liveLength() === 0)
+        return;
+
+      // Deactivate old item
+      if (t.position !== -1 && !t._prefix.active()) {
+        t.liveItem(t.position).active(false);
+      };
+
+      // Get new active item
+      t.position++;
+      let newItem = t.liveItem(t.position);
+
+      // The next element is undefined - roll to top or to prefix
+      if (newItem === undefined) {
+
+        // Activate prefix
+        const prefix = this._prefix;
+
+        // Prefix is set and not active - choose!
+        if (prefix.isSet() && !prefix.active()) {
+          t.position--;
+          prefix.active(true);
+          return;
+        }
+
+        // Choose first item
+        else {
+          newItem = t.liveItem(0);
+          // choose first item
+          t.position = 0;
+          t._showItems(0);
+        };
+      }
+
+      // The next element is after the viewport - roll down
+      else if (t.position >= (t.limit() + t.offset)) {
+        t.screen(t.position - t.limit() + 1);
+      }
+
+      // The next element is before the viewport - roll up
+      else if (t.position <= t.offset) {
+        t.screen(t.position);
+      };
+
+      t._prefix.active(false);
+      newItem.active(true);
+    }
+
+
+    /*
+     * Make the previous item in the menu active
+     */
+    prev () {
+      //Upon Change please update alwaysmenu.js prev
+      const t = this;
+
+      // No list
+      if (t.liveLength() === 0)
+        return;
+
+      // Deactivate old item
+      if (!t._prefix.active()) {
+
+        // No active element set
+        if (t.position === -1) {
+          t.position = t.liveLength();
+        }
+
+        // No active element set
+        else {
+          t.liveItem(t.position--).active(false);
+        };
+      };
+
+      // Get new active item
+      let newItem = t.liveItem(t.position);
+
+      // The previous element is undefined - roll to bottom
+      if (newItem === undefined) {
+
+        // Activate prefix
+        const prefix = t._prefix;
+        let offset =  t.liveLength() - t.limit();
+        
+        // Normalize offset
+        offset = offset < 0 ? 0 : offset;
+
+        // Choose the last item
+        t.position = t.liveLength() - 1;
+        
+        // Prefix is set and not active - choose!
+        if (prefix.isSet() && !prefix.active()) {
+          t.position++;
+          prefix.active(true);
+          t.offset = offset;
+          return;
+        }
+
+        // Choose last item
+        else {
+          newItem = t.liveItem(t.position);
+          t._showItems(offset);
+        };
+      }
+
+      // The previous element is before the view - roll up
+      else if (t.position < t.offset) {
+        t.screen(t.position);
+      }
+
+      // The previous element is after the view - roll down
+      else if (t.position >= (t.limit() + t.offset)) {
+        t.screen(t.position - t.limit() + 2);
+      };
+
+      t._prefix.active(false);
+      newItem.active(true);
+    }
+
+
+    /**
+     * Move the page up by limit!
+     */
+    pageUp () {
+      this.screen(this.offset - this.limit());
+    }
+
+
+    /**
+     * Move the page down by limit!
+     */
+    pageDown () {
+      this.screen(this.offset + this.limit());
+    }
+
+
+    /**
+     * Move the view one item up
+     */
+    viewUp () {
+      this.screen(this.offset - 1);
+    }
+
+
+    /**
+     * Move the view one item down
+     */
+    viewDown () {
+      this.screen(this.offset + 1);
+    }
+
+    /**
+     * Reset the prefix. Currently not used in regular menu.
+     */
+    reset () {
+      this.prefix("");
+    }
+
+    // Unmark all items
+    _unmark () {
+      this._list.forEach(function(it){
+        const item = this._items[it];
+        item.lowlight();
+        item.active(false);
+      }, this);
+    }
+
+
+    // Set boundary for viewport
+    _boundary (bool) {
+      if (this._list.length === 0)
+        return;
+
+      this.item(this._list[0]).noMore(bool);
+      this.item(this._list[this._list.length - 1]).noMore(bool);
+    }
+
+
+    // Append Items that should be shown
+    _showItems (off) {
+      const t = this;
+
+      // optimization: scroll down one step
+      if (t.offset === (off - 1)) {
+        t.offset = off;
+
+        // Remove the HTML node from the first item
+        // leave lengthField/prefix/slider
+        //console.log("_showItems, at _notItemElements is: ",t._el.children[this._notItemElements]);
+        t._el.removeChild(t._el.children[this._notItemElements]);
+
+        t._append(
+          t._list[t.offset + t.limit() - 1]
+        );
+      }
+
+      // optimization: scroll up one step
+      else if (t.offset === (off + 1)) {
+        t.offset = off;
+
+        // Remove the HTML node from the last item
+        //console.log("_showItems, at lastChild is: ",t._el.lastChild);
+        t._el.removeChild(t._el.lastChild);
+
+        t._prepend(t._list[t.offset]);
+      }
+
+      else {
+        t.offset = off;
+
+        // Remove all items
+        t.removeItems();
+
+        // Use list
+        let shown = 0;
+
+        for (let i = 0; i < t._list.length; i++) {
+
+          // Don't show - it's before offset
+          shown++;
+          if (shown <= off)
+            continue;
+
+          t._append(t._list[i]);
+          
+          if (shown >= (t.limit() + off))
+            break;
+        };
+      };
+
+      // set the slider to the new offset
+      t._slider.offset(t.offset);
+    }
+
+
+    // Append item to the shown list based on index
+    _append (i) {
+      const item = this.item(i);
+
+      // Highlight based on prefix
+      if (this.prefix().length > 0) {
+        item.highlight(this.prefix().toLowerCase());
+      };
+
+      // Append element
+      this.element().appendChild(item.element());
+    }
+
+
+    // Prepend item to the shown list based on index
+    _prepend (i) {
+      const item = this.item(i);
+
+      // Highlight based on prefix
+      if (this.prefix().length > 0) {
+        item.highlight(this.prefix().toLowerCase());
+      };
+
+      const e = this.element();
+
+      // Append element after lengthField/prefix/slider
+      e.insertBefore(
+        item.element(),
+        e.children[this._notItemElements]
+      );
+    }
+};