widgets/select.js

/*
 * This file is part of AUX.
 *
 * AUX is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * AUX is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General
 * Public License along with this program; if not, write to the
 * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
 * Boston, MA  02110-1301  USA
 */

/* jshint -W014 */

import { defineChildWidget } from './../child_widget.js';
import { Button } from './button.js';
import { Label } from './label.js';
import { Icon } from './icon.js';
import { setDelayedFocus, createID } from '../utils/dom.js';
import { Timer } from '../utils/timers.js';
import { SymResize } from './widget.js';

import {
  element,
  addClass,
  outerWidth,
  width,
  height,
  scrollLeft,
  scrollTop,
  setStyles,
  outerHeight,
  positionTop,
  positionLeft,
  setStyle,
  getDuration,
  empty,
  toggleClass,
} from '../utils/dom.js';
import { typecheckInteger } from '../utils/typecheck.js';
import {
  defineRender,
  defineMeasure,
  deferRender,
  deferMeasure,
} from '../renderer.js';

/**
 * The <code>useraction</code> event is emitted when a widget gets modified by user interaction.
 * The event is emitted for the options <code>selected</code> and <code>value</code>.
 *
 * @event Select#useraction
 *
 * @param {string} name - The name of the option which was changed due to the users action
 * @param {mixed} value - The new value of the option
 */

const SymEntriesChanged = Symbol('entries changes');

function setHasIcon() {
  const _has_icon = !!this.entries.find((entry) => {
    return !!entry.options.icon;
  });
  this.set('_has_icon', _has_icon);
}

/**
 * Select provides a {@link Button} with a select list to choose from
 * a list of {@link SelectEntry}.
 *
 * @class Select
 *
 * @extends Button
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {Integer|Boolean} [options.selected=false] - The index of the selected {@link SelectEntry}.
 *   Set to `-1` to unselect any already selected entries.
 * @property {mixed} [options.value] - The value of the selected entry.
 * @property {SelectEntry} [options.selected_entry] - The currently selected
 *    entry.
 * @property {Boolean} [options.auto_size=false] - If `true`, the Select is
 *   auto-sized to be as wide as the widest {@link SelectEntry}.
 * @property {Array<Object>} [options.entries=[]] - The list of {@link SelectEntry}. Each member is an
 *   object containing properties of {@link Button} and <code>value</code>, a string used
 *   as label for constructing a {@link SelectEntry} or an instance of {@link SelectEntry}.
 * @property {String|Boolean} [options.placeholder=false] - Placeholder
 *   for the button label. Set to <code>false</code> to have an empty
 *   placeholder. This placeholder is shown when no entry is selected.
 * @property {String|Boolean} [options.list_class] - A CSS class to be set on the list. This is
 *   a static option and can only be set once on initializaion.
 *
 */
export class Select extends Button {
  static get _options() {
    return {
      entries: 'array',
      selected: 'int',
      selected_entry: 'object',
      value: 'mixed',
      auto_size: 'boolean',
      show_list: 'boolean',
      sort: 'function',
      resized: 'boolean',
      placeholder: 'string|boolean',
      list_class: 'string',
      typing_delay: 'number',
      arrow: 'string',
    };
  }

  static get options() {
    return {
      entries: [], // A list of strings or objects {label: "Title", value: 1} or SelectEntry instance
      selected: -1,
      value: void 0,
      selected_entry: null,
      auto_size: false,
      show_list: false,
      arrow: 'arrowdown',
      placeholder: false,
      list_class: '',
      label: '',
      icon: '',
      role: 'select',
      typing_delay: 250,
    };
  }

  static get static_events() {
    return {
      click: function () {
        this.set('show_list', !this.options.show_list);
      },
      set_show_list: function (v) {
        this.set('arrow', v ? 'arrowup' : 'arrowdown');
        if (v) {
          const entry = this.get('selected_entry') || this.entries[0];
          if (entry) setDelayedFocus(entry.element);
        }
      },
      set_selected_entry: function (entry) {
        const entries = this.entries;
        entries.forEach((entry) => {
          entry.set('selected', false);
        });
        if (entry) {
          const icon = this.options._has_icon
            ? entry.get('icon')
              ? entry.get('icon')
              : 'blank'
            : false;
          this.update('selected', entries.indexOf(entry));
          this.update('value', entry.get('value'));
          this.update('label', entry.get('label'));
          this.update('icon', icon);
          entry.set('selected', true);
          this._list.setAttribute('aria-activedescendant', entry.get('id'));
        } else {
          this.update('selected', -1);
          this.update('value', void 0);
          this.update('label', this.get('placeholder'));
          this.update('icon', this.options._has_icon ? 'blank' : false);
          this._list.removeAttribute('aria-activedescendant');
        }
      },
      set_selected: function (index) {
        if (index === -1) {
          this.update('selected_entry', null);
          this.update('value', void 0);
        } else {
          const entries = this.entries;

          if (index >= entries.length) return;

          // this will set value
          this.update('selected_entry', entries[index]);
        }
      },
      set_value: function (value) {
        const entries = this.entries;

        for (let i = 0; i < entries.length; i++) {
          const entry = entries[i];

          if (value === entry.get('value')) {
            this.update('selected_entry', entry);
            return;
          }
        }
      },
      set_placeholder: function (label) {
        const selected_entry = this.get('selected_entry');

        if (!selected_entry) this.update('label', label);
      },
      set__has_icon: function (_has_icon) {
        if (_has_icon && !this.options.icon) {
          this.set('icon', 'blank');
        } else if (_has_icon) {
          this.update('icon', this.options.icon);
        } else if (!_has_icon) {
          this.update('icon', false);
        }
      },
    };
  }

  static get renderers() {
    return [
      defineRender(SymEntriesChanged, function () {
        const _list = this._list;

        this.entries.forEach((entry) => _list.appendChild(entry.element));
      }),
      defineMeasure(['show_list', 'list_class'], function (
        show_list,
        list_class
      ) {
        const { element, _list } = this;

        if (this.__timeout !== false) {
          window.clearTimeout(this.__timeout);
          this.__timeout = false;
        }

        if (show_list) {
          const ew = outerWidth(element, false);
          const cw = width();
          const ch = height();
          const sx = scrollLeft();
          const sy = scrollTop();

          return deferRender(() => {
            _list.className = 'aux-selectlist';
            if (list_class) addClass(_list, list_class);
            setStyles(_list, {
              opacity: '0',
              maxHeight: ch + 'px',
              maxWidth: cw + 'px',
              minWidth: ew + 'px',
            });
            this.set('_show_list', true);
            document.body.appendChild(_list);
            document.addEventListener('touchstart', this._globalTouchStart);
            document.addEventListener('mousedown', this._globalTouchStart);

            const lw = outerWidth(_list, true);
            const lh = outerHeight(_list, true);

            const top =
              Math.min(
                positionTop(element) + outerHeight(element, true),
                ch + sy - lh
              ) + 'px';
            const left = Math.min(positionLeft(element), cw + sx - lw) + 'px';

            return deferRender(() => {
              setStyles(_list, { top, left, opacity: '1' });
              element.setAttribute('aria-expanded', 'true');

              const current = this.current();

              if (!current) return;

              return deferMeasure(() => {
                const scrollTop =
                  current.element.offsetTop - _list.offsetHeight / 2;
                return deferRender(() => {
                  _list.scrollTop = scrollTop;
                });
              });
            });
          });
        } else {
          document.removeEventListener('touchstart', this._globalTouchStart);
          document.removeEventListener('mousedown', this._globalTouchStart);

          return deferRender(() => {
            element.removeAttribute('aria-expanded');
            setStyle(_list, 'opacity', '0');

            const duration = getDuration(_list);

            if (duration > 0) {
              this.__timeout = window.setTimeout(() => {
                this.set('_show_list', false);
              }, duration);
            } else {
              this.set('_show_list', false);
            }
          });
        }
      }),
      defineRender(['_show_list'], function (_show_list) {
        const { element, _list } = this;

        if (!_show_list) {
          _list.remove();
          setDelayedFocus(element);
          element.removeAttribute('aria-expanded');
        }
      }),
      defineRender([SymResize, 'auto_size', SymEntriesChanged], function (
        auto_size
      ) {
        if (auto_size) {
          const S = this.sizer.element;
          empty(S);
          const frag = document.createDocumentFragment();
          this.entries.forEach((entry) => {
            const s = element('span', { class: 'aux-sizerentry' });
            s.textContent = entry.get('label');
            frag.appendChild(s);
          });
          S.appendChild(frag);
          return deferMeasure(() => {
            const width = outerWidth(S, true, undefined, true);
            return deferRender(() => {
              if (this.label) outerWidth(this.label.element, true, width);
            });
          });
        } else {
          if (this.label) this.label.element.style.width = null;
        }
      }),
    ];
  }

  initialize(options) {
    this.__timeout = false;

    /**
     * @member {Array} Select#entries - An array containing all entry objects with members <code>label</code> and <code>value</code>.
     */
    this.entries = [];
    super.initialize(options);
    /**
     * @member {HTMLDivElement} Select#element - The main DIV container.
     *   Has class <code>.aux-select</code>.
     */

    /**
     * @member {HTMLListElement} Select#_list - A HTML list for displaying the entry labels.
     *   Has class <code>.aux-selectlist</code>.
     */
    this._list = element('div', 'aux-selectlist');
    this._list.setAttribute('role', 'listbox');

    this._globalTouchStart = (e) => {
      if (this._list.contains(e.target) || this.element.contains(e.target))
        return;
      this.showList(false);
    };

    const sel = this.options.selected;
    const val = this.options.value;
    this.set('entries', this.options.entries);

    if (sel !== -1) {
      this.set('selected', sel);
    } else if (val !== void 0) {
      this.set('value', val);
    }

    this.__typing = '';
    this._timer = new Timer(() => {
      this.__typing = '';
    });
  }

  destroy() {
    if (this.__timeout !== false) {
      window.clearTimeout(this.__timeout);
      this.__timeout = false;
    }
    this.clear();
    this._list.remove();
    document.removeEventListener('touchstart', this._globalTouchStart);
    document.removeEventListener('mousedown', this._globalTouchStart);
    super.destroy();
  }

  /**
   * Show or hide the select list
   *
   * @method Select#showList
   *
   * @param {boolean} show - `true` to show and `false` to hide the list
   *   of {@link SelectEntry}.
   */
  showList(s) {
    this.set('show_list', !!s);
  }

  /**
   * Select a {@link SelectEntry} by its index.
   *
   * @method Select#select
   *
   * @param {Integer} index - The index of the {@link SelectEntry} to select.
   */
  select(id) {
    if (!Number.isInteger(id) && !id) id = -1;
    this.set('selected', id);
  }

  /**
   * Select a {@link SelectEntry} by its value.
   *
   * @method Select#selectValue
   *
   * @param {mixed} value - The value of the {@link SelectEntry} to select.
   */
  selectValue(value) {
    const id = this.indexByValue(value);
    this.set('selected', id);
  }

  /**
   * Select a {@link SelectEntry} by its label.
   *
   * @method Select#selectLabel
   *
   * @param {mixed} label - The label of the {@link SelectEntry} to select.
   */
  selectLabel(label) {
    const id = this.indexByLabel(label);
    this.set('selected', id);
  }

  /**
   * Replaces the list of {@link SelectEntry} to select from with an entirely new one.
   *
   * @method Select#setEntries
   *
   * @param {Array} entries - An array of {@link SelectEntry} to set as the new list to select from.
   *   Please refer to {@link Select#addEntry} for more details.
   */
  setEntries(entries) {
    const value = this.get('value');

    this.clear();
    this.addEntries(entries);

    if (value !== void 0) {
      const index = this.indexByValue(value);

      if (index !== -1) this.select(index);
    }
  }

  /**
   * Adds new {@link SelectEntry} to the end of the list to select from.
   *
   * @method Select#addEntries
   *
   * @param {Array} entries - An array of {@link SelectEntry} to add to the end of the list
   *   of {@link SelectEntry} to select from. Please refer to {@link Select#addEntry}
   *   for more details.
   */
  addEntries(entries) {
    for (let i = 0; i < entries.length; i++) this.addEntry(entries[i]);
  }

  /**
   * Adds a single {@link SelectEntry} to the end of the list.
   *
   * @method Select#addEntry
   *
   * @param {mixed} entry - A string to be displayed and used as the value,
   *   an object with members <code>label</code> and <code>value</code>
   *   or an instance of {@link SelectEntry}.
   * @param {integer} [position] - The position in the list to add the new
   *   entry at. If omitted, the entry is added at the end.
   *
   * @emits Select.entryadded
   */
  addEntry(ent, position) {
    let entry;

    if (position !== void 0) {
      typecheckInteger(position);

      if (position < 0 || position > this.entries.length)
        throw new TypeError('Index out of bounds.');
    }

    if (typeof ent === 'object' && ent instanceof SelectEntry) {
      entry = ent;
    } else if (typeof ent === 'string') {
      entry = new SelectEntry({
        value: ent,
        label: ent,
      });
    } else if (
      typeof ent === 'object' &&
      'value' in ent &&
      ('label' in ent || 'icon' in ent)
    ) {
      ent.element = null;
      entry = new SelectEntry(ent);
    } else {
      throw new TypeError('Unsupported type of entry.');
    }

    if (position !== void 0) {
      this.entries.splice(position, 0, entry);
    }

    this.addChild(entry);

    return entry;
  }

  addChild(child) {
    super.addChild(child);

    if (!(child instanceof SelectEntry)) return;

    const O = this.options;
    const entries = this.entries;
    const entry = child;

    if (!entries.includes(entry)) entries.push(entry);

    if (O.sort) entries.sort(O.sort);

    const index = entries.indexOf(entry);

    // invalidate entries.
    this.invalidate(SymEntriesChanged);

    setHasIcon.call(this);

    const selected = this.options.selected;

    // adjust selected
    if (selected !== -1 && selected >= index && selected + 1 < entries.length) {
      this.set('selected', selected + 1);
    } else if (selected === index) {
      this.set('selected', selected);
    }
    /**
     * Is fired when a new {@link SelectEntry} is added to the list.
     *
     * @event Select#entryadded
     *
     * @param {SelectEntry} entry - A new {@link SelectEntry}.
     */
    this.emit('entryadded', entry);
  }

  /**
   * Remove a {@link SelectEntry} from the list by its index.
   *
   * @method Select#removeIndex
   *
   * @param {Integer} index - The index of the {@link SelectEntry} to be removed from the list.
   *
   * @emits Select#entryremoved
   */
  removeIndex(index) {
    const entry = this.entries[index];

    if (!entry) {
      throw new Error('Index does not exist.');
    }

    this.removeChild(entry);
  }

  /**
   * Remove a {@link SelectEntry} from the list by its value.
   *
   * @method Select#removeValue
   *
   * @param {mixed} value - The value of the {@link SelectEntry} to be removed from the list.
   *
   * @emits Select#entryremoved
   */
  removeValue(val) {
    this.removeIndex(this.indexByValue(val));
  }

  /**
   * Remove an entry from the list by its label.
   *
   * @method Select#removeLabel
   *
   * @param {string} label - The label of the entry to be removed from the list.
   *
   * @emits Select#entryremoved
   */
  removeLabel(label) {
    this.removeIndex(this.indexByLabel(label));
  }

  /**
   * Remove an entry from the list.
   *
   * @method Select#removeEntry
   *
   * @param {SelectEntry} entry - The {@link SelectEntry} to be removed from the list.
   *
   * @emits Select#entryremoved
   */
  removeEntry(entry) {
    this.removeChild(entry);
  }

  removeEntries(a) {
    a.forEach((entry) => {
      this.removeEntry(entry);
    });
  }

  _removeEntry(entry) {
    const entries = this.entries;
    const index = entries.indexOf(entry);

    if (index === -1) throw new Error('Unknown entry.');

    const selected = this.get('selected');

    this.entries = entries.filter((_entry) => _entry !== entry);

    if (selected === index) {
      // unselect current entry
      this.set('selected_entry', null);
    } else if (selected > index) {
      this.set('selected', selected - 1);
    }

    const li = entry.element;

    // remove from DOM
    if (li.parentElement === this._list) li.remove();
    this.invalidate(SymEntriesChanged);

    setHasIcon.call(this);

    /**
     * Is fired when an entry was removed from the list.
     *
     * @event Select.entryremoved
     *
     * @param {SelectEntry} entry - The removed select entry.
     */
    this.emit('entryremoved', entry);
  }

  removeChild(child) {
    super.removeChild(child);
    if (child instanceof SelectEntry) {
      this._removeEntry(child);
    }
  }

  /**
   * Get the index of a {@link SelectEntry} by its value.
   *
   * @method Select#indexByValue
   *
   * @param {Mixed} value - The value of the {@link SelectEntry}.
   *
   * @returns {Integer|Boolean} The index of the entry or `-1`.
   */
  indexByValue(val) {
    const entries = this.entries;
    for (let i = 0; i < entries.length; i++) {
      if (entries[i].options.value === val) return i;
    }
    return -1;
  }

  /**
   * Get the index of a {@link SelectEntry} by its label/label.
   *
   * @method Select#indexByLabel
   *
   * @param {String} label - The label/label of the {@link SelectEntry}.
   *
   * @returns {Integer} The index of the entry or `-1`.
   */
  indexByLabel(label) {
    const entries = this.entries;
    for (let i = 0; i < entries.length; i++) {
      if (entries[i].options.label === label) return i;
    }
    return -1;
  }

  /**
   * Get the index of a {@link SelectEntry} by the {@link SelectEntry} itself.
   *
   * @method Select#indexByEntry
   *
   * @param {SelectEntry} entry - The {@link SelectEntry}.
   *
   * @returns {Integer|Boolean} The index of the entry or `-1`.
   */
  indexByEntry(entry) {
    return this.entries.indexOf(entry);
  }

  /**
   * Get the index of a {@link SelectEntry} by its HTMLElement.
   *
   * @method Select#indexByElement
   *
   * @param {HTMLElement} element - The element of the {@link SelectEntry}.
   *
   * @returns {Integer} The index of the entry or `-1`.
   */
  indexByElement(element) {
    const entries = this.entries;
    for (let i = 0; i < entries.length; i++) {
      if (entries[i].element === element) return i;
    }
    return -1;
  }

  /**
   * Get a {@link SelectEntry} by its value.
   *
   * @method Select#entryByValue
   *
   * @param {Mixed} value - The value of the {@link SelectEntry}.
   *
   * @returns {SelectEntry|False} The {@link SelectEntry} or `null`.
   */
  entryByValue(val) {
    const entries = this.entries;
    for (let i = 0; i < entries.length; i++) {
      if (entries[i].options.value === val) return entries[i];
    }
    return null;
  }

  /**
   * Get a {@link SelectEntry} by its label/label.
   *
   * @method Select#entryByLabel
   *
   * @param {String} label - The label of the {@link SelectEntry}.
   *
   * @returns {SelectEntry|Boolean} The {@link SelectEntry} or `null`.
   */
  entryByLabel(label) {
    const entries = this.entries;
    for (let i = 0; i < entries.length; i++) {
      if (entries[i].options.label === label) return entries[i];
    }
    return null;
  }

  /**
   * Get the next {@link SelectEntry} whose label starts with the given string.
   *
   * @method Select#nextEntryByPartialLabel
   *
   * @param {String} label - The label of the {@link SelectEntry}.
   * @param {Number} current - the current index to start searching.
   *
   * @returns {SelectEntry|Boolean} The {@link SelectEntry} or `null`.
   */
  nextEntryByPartialLabel(label, current) {
    const entries = this.entries;
    current = current || -1;
    current = Math.max(0, Math.min(this.entries.length - 1, current + 1));
    for (let i = current; i < entries.length; i++) {
      if (entries[i].options.label.toLowerCase().startsWith(label))
        return entries[i];
    }
    return null;
  }

  /**
   * Get a {@link SelectEntry} by its index.
   *
   * @method Select#entryByIndex
   *
   * @param {Integer} index - The index of the {@link SelectEntry}.
   *
   * @returns {SelectEntry|Boolean} The {@link SelectEntry} or `null`.
   */
  entryByIndex(index) {
    const entries = this.entries;
    if (index >= 0 && index < entries.length && entries[index])
      return entries[index];
    return null;
  }

  /**
   * Get a value by its {@link SelectEntry} index.
   *
   * @method Select#valueByIndex
   *
   * @param {Integer} index - The index of the {@link SelectEntry}.
   *
   * @returns {Mixed|Boolean} The value of the {@link SelectEntry} or `undefined`.
   */
  valueByIndex(index) {
    const entries = this.entries;
    if (index >= 0 && index < entries.length && entries[index]) {
      return entries[index].options.value;
    }
    return void 0;
  }

  /**
   * Get the value of a {@link SelectEntry}.
   *
   * @method Select#valueByEntry
   *
   * @param {SelectEntry} entry - The {@link SelectEntry}.
   *
   * @returns {mixed} The value of the {@link SelectEntry}.
   */
  valueByEntry(entry) {
    return entry.options.value;
  }

  /**
   * Get the value of a {@link SelectEntry} by its label/label.
   *
   * @method Select#valueByLabel
   *
   * @param {String} label - The label of the {@link SelectEntry}.
   *
   * @returns {Mixed|Boolean} The value of the {@link SelectEntry} or `undefined`.
   */
  valueByLabel(label) {
    const entries = this.entries;
    for (let i = 0; i < entries.length; i++) {
      if (entries[i].options.label === label) return entries[i].options.value;
    }
    return void 0;
  }

  /**
   * Remove all {@link SelectEntry} from the list.
   *
   * @method Select#clear
   *
   * @emits Select#cleared
   */
  clear() {
    empty(this._list);
    this.entries.forEach((entry) => {
      this.removeChild(entry);
    });
    /**
     * Is fired when the list is cleared.
     *
     * @event Select.cleared
     */
    this.emit('cleared');
  }

  draw(O, element) {
    addClass(element, 'aux-select');
    this.element.setAttribute('aria-haspopup', 'listbox');

    super.draw(O, element);
  }

  /**
   * Get the currently selected {@link SelectEntry}.
   *
   * @method Select#current
   *
   * @returns {SelectEntry|Boolean} The currently selected {@link SelectEntry} or `null`.
   */
  current() {
    return this.get('selected_entry');
  }

  /**
   * Get the currently selected {@link SelectEntry}'s index. Just for the sake of completeness, this
   *   function abstracts `options.selected`.
   *
   * @method Select#currentIndex
   *
   * @returns {Integer|Boolean} The index of the currently selected {@link SelectEntry} or `-1`.
   */
  currentIndex() {
    return this.get('selected');
  }

  /**
   * Get the currently selected {@link SelectEntry}'s value.
   *
   * @method Select#currentValue
   *
   * @returns {Mixed|Boolean} The value of the currently selected {@link SelectEntry} or `undefined`.
   */
  currentValue() {
    const entry = this.current();
    return entry ? entry.get('value') : void 0;
  }

  focusWhileTyping(key) {
    this._timer.restart(this.options.typing_delay);
    this.__typing += key;
    let entry;
    if (this.__typing.length > 1)
      entry = this.nextEntryByPartialLabel(this.__typing.toLowerCase());
    else
      entry = this.nextEntryByPartialLabel(
        this.__typing.toLowerCase(),
        this.indexByElement(document.activeElement)
      );
    if (entry) entry.element.focus();
  }

  set(key, value) {
    if (key === 'selected') {
      typecheckInteger(value);
      if (value < -1) throw new TypeError('expected Integer >= -1.');
    }

    value = super.set(key, value);

    switch (key) {
      case 'entries':
        this.setEntries(value);
        break;
    }
    return value;
  }
}

function onSelect(e) {
  const w = this.parent;
  const id = w.indexByEntry(this);
  const entry = this;
  e.stopPropagation();
  e.preventDefault();

  if (w.userset('selected', id) === false) return false;
  w.userset('value', this.options.value);
  /**
   * Is fired when a selection was made by the user. The arguments
   * are the value of the currently selected {@link SelectEntry}, its index, its label and the {@link SelectEntry} instance.
   *
   * @event Select#select
   *
   * @param {mixed} value - The value of the selected entry.
   * @param {number} value - The ID of the selected entry.
   * @param {string} value - The label of the selected entry.
   * @param {SelectEntry} value - The selected entry.
   */
  w.emit('select', entry.options.value, id, entry.options.label, entry);
  w.showList(false);

  return false;
}

/**
 * @member {Select} Select#arrow - The arrow icon.
 */
defineChildWidget(Select, 'arrow', {
  create: Icon,
  show: true,
  default_options: {
    icon: 'arrowdown',
    class: 'aux-arrow',
  },
  map_options: {
    arrow: 'icon',
  },
});

function onFocusMove(O) {
  const { direction, speed } = O;
  const parent = this.parent;
  const last = parent.entries.length - 1;
  let i;
  if (speed === 'full') {
    if (direction === 'up' || direction === 'right') i = last;
    else i = 0;
  } else {
    i = parent.indexByEntry(this);
    if (direction === 'up' || direction === 'right') i -= 1;
    else i += 1;
    i = Math.max(0, Math.min(i, last));
  }
  setDelayedFocus(parent.entries[i].element);
}

function onKeyDown(e) {
  if (e.code === 'Tab') {
    this.parent.set('show_list', false);
    return false;
  }
  if (e.code === 'Escape') {
    this.parent.set('show_list', false);
    return false;
  }
  if (e.key.length === 1) {
    this.parent.focusWhileTyping(e.key);
    return false;
  }
  if (e.code === 'Enter' || e.code === 'Space') {
    this.element.click();
    return false;
  }
}

/**
 * SelectEntry provides a {@link Button} as an entry for {@link Select}.
 *
 * @class SelectEntry
 *
 * @extends Button
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {String} [options.label=""] - The label of the entry. Kept for backward compatibility, deprecated, use label instead.
 * @property {mixed} [options.value] - The value of the selected entry.
 *
 */
export class SelectEntry extends Button {
  static get _options() {
    return {
      value: 'mixed',
      selected: 'boolean',
    };
  }

  static get options() {
    return {
      label: '',
      value: null,
      role: 'option',
      selected: false,
    };
  }

  static get renderers() {
    return [
      defineRender('selected', function (selected) {
        const element = this.element;
        toggleClass(element, 'aux-active', selected);
        if (selected) {
          element.removeAttribute('aria-selected');
        } else {
          element.setAttribute('aria-selected', 'true');
        }
      }),
    ];
  }

  initialize(options) {
    super.initialize(options);
    this.set('id', createID('aux-select-entry-'));
  }

  draw(O, element) {
    addClass(this.element, 'aux-selectentry');
    super.draw(O, element);
  }

  static get static_events() {
    return {
      click: onSelect,
      focus_move: onFocusMove,
      keydown: onKeyDown,
    };
  }
}

/**
 * @member {Select} Select#sizer - A blind element for `auto_size`.
 */
defineChildWidget(Select, 'sizer', {
  create: Label,
  option: 'auto_size',
  toggle_class: true,
  default_options: {
    class: 'aux-sizer',
  },
});