widgets/buttons.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
 */

import { addClass, removeClass, createID } from './../utils/dom.js';
import { Container } from './container.js';
import { Button } from './button.js';
import { warning } from '../utils/warning.js';
import { ChildWidgets } from '../utils/child_widgets.js';
import { defineRender } from '../renderer.js';

/**
 * The <code>useraction</code> event is emitted when a widget gets modified by user interaction.
 * The event is emitted for the option <code>select</code>.
 *
 * @event Buttons#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
 */

function compare(arr1, arr2) {
  return arr1.length === arr2.length && arr1.every((val, i) => val === arr2[i]);
}

function updateSelect(select, position, add) {
  if (Array.isArray(select)) {
    if (add === select.includes(position)) return select;

    if (!add) {
      select = select.filter((_position) => position !== _position);
    } else {
      select = select.concat([position]);
    }
  } else {
    if (add === (select === position)) return select;

    select = add ? position : -1;
  }

  return select;
}

function enforceMultiSelect(select, multi_select) {
  const is_array = Array.isArray(select);

  if (!is_array && !multi_select) return select;

  if (multi_select) {
    if (is_array) {
      if (multi_select === 1) return select;
      if (select.length <= multi_select) return select;

      return select.slice(0, multi_select);
    } else {
      return select === -1 ? [] : [select];
    }
  } else {
    return select.length ? select[0] : -1;
  }
}

function onButtonClick() {
  const parent = this.parent;
  this.userset('state', !this.get('state'));
  const position = parent.buttons.indexOf(this);
  parent.set('_focus', position);
}

function onButtonUserset(key, value) {
  if (key !== 'state') return;

  // make sure this is a boolean
  value = !!value;

  const parent = this.parent;

  const O = parent.options;
  const position = parent.buttons.indexOf(this);
  const select = enforceMultiSelect(
    updateSelect(O.select, position, value),
    O.multi_select
  );

  if (select === O.select || (O.multi_select && compare(select, O.select))) {
    warning(this.element, 500);
    return;
  }

  return parent.userset('select', select);
}

function onButtonSetState(value) {
  // make sure this is a boolean
  value = !!value;

  const parent = this.parent;

  const O = parent.options;
  const position = parent.buttons.indexOf(this);
  const select = enforceMultiSelect(
    updateSelect(O.select, position, value),
    O.multi_select
  );

  if (value) {
    this.set('aria_current', 'true');
    this.set('aria_selected', 'true');
  } else {
    this.set('aria_current', 'false');
    this.set('aria_selected', 'false');
  }

  if (select === O.select) return;

  return parent.set('select', select);
}

function onButtonAdded(button, position) {
  const buttons = this.widget;

  button.on('click', onButtonClick);
  button.on('userset', onButtonUserset);
  button.on('set_state', onButtonSetState);

  let select = buttons.get('select');
  const length = buttons.getButtons().length;

  const correctIndex = (index) => {
    if (index >= position && position < length - 1) {
      return index + 1;
    }

    return index;
  };

  if (Array.isArray(select)) {
    select = select.map(correctIndex);
    if (button.get('state') && select.indexOf(position) === -1) {
      select = [position].concat(select);
      button.set('aria_current', 'true');
      button.set('aria_selected', 'true');
    } else {
      button.set('aria_current', 'false');
      button.set('aria_selected', 'false');
    }
  } else {
    select = correctIndex(select);
    if (button.get('state')) {
      select = position;
      button.set('aria_current', 'true');
      button.set('aria_selected', 'true');
    } else {
      button.set('aria_current', 'false');
      button.set('aria_selected', 'false');
    }
  }

  buttons.set('select', select);
  buttons.emit('added', button);

  buttons.triggerResize();
}

function onButtonRemoved(button, position) {
  const buttons = this.widget;

  button.off('click', onButtonClick);
  button.off('userset', onButtonUserset);
  button.off('set_state', onButtonSetState);

  let select = buttons.get('select');
  const length = buttons.getButtons().length;

  const correctIndex = (index) => {
    if (index > position && position < length + 1) {
      return index - 1;
    }

    return index;
  };

  if (Array.isArray(select)) {
    select = select.filter((index) => index !== position).map(correctIndex);
  } else {
    if (select === position) {
      select = -1;
    } else {
      select = correctIndex(select);
    }
  }

  buttons.set('select', select);
  buttons.emit('removed', button);

  buttons.triggerResize();
}

function moveFocus(to) {
  const list = this.buttons.getList();
  const focus = this.get('_focus');
  if (focus === false && list.length) {
    this.set('_focus', 0);
    return;
  }
  if (!list.length) {
    this.set('_focus', false);
    return;
  }
  this.set('_focus', Math.min(list.length - 1, Math.max(0, focus + to)));
}

function clearFocus() {
  this.buttons.list.map((b) => b.set('focus', false));
}

/**
 * Buttons is a list of ({@link Button})s, arranged
 * either vertically or horizontally. Single buttons can be selected by clicking.
 * If `multi_select` is enabled, buttons can be added and removed from
 * the selection by clicking on them. Buttons uses {@link warning}
 * to highlight buttons which can't be selected due to `options.multi_select=n`.
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {Array<Object|String>} [options.buttons=[]] - A list of
 *   {@link Button} instances, button options objects or label strings
 *   which is converted to button instances on init. If `get` is called,
 *   a converted list of button instances is returned. Example:
 *  `[new Button({label:'Button#1'}), 'Button#2', {label:'Button#3'}]`
 * @property {String} [options.direction="horizontal"] - The layout
 *   of the button list, either "horizontal" or "vertical".
 * @property {Integer|Button|Array<Integer>|Array<Button>} [options.select=-1]
 *   The {@link Button} or a list of {@link Button}s, depending on
 *   `options.multi_select`, to highlight. Expects
 *   either the buttons index starting from zero or the {@link Button}
 *   instance(s) itself. Set to `-1` or `[]` to
 *   de-select any selected button.
 * @property {Object} [options.button_class=Button] - A class to
 *   be used for instantiating new buttons.
 * @property {String} [options.button_role='option'] - A role to
 *   be used for instantiating new buttons.
 * @property {Integer} [options.multi_select=0] - Set to `0` to disable
 *   multiple selection, `1` for unlimited and any other number for
 *   a defined maximum amount of selectable buttons. If an array is given
 *   for `options.select` while this option is `0`, the first entry
 *   will be used.
 * @property {Boolean} [options.deselect=false] - Define if single-selection
 *   (`options.multi_select=false`) can be de-selected.
 *
 * @class Buttons
 *
 * @extends Container
 *
 */
/**
 * A {@link Button} was added to Buttons.
 *
 * @event Buttons#added
 *
 * @param {Button} button - The {@link Button} which was added to Buttons.
 */
/**
 * A {@link Button} was removed from the Buttons.
 *
 * @event Buttons#removed
 *
 * @param {Button} button - The {@link Button} instance which was removed.
 */
export class Buttons extends Container {
  static get _options() {
    return {
      buttons: 'array',
      direction: 'string',
      select: 'int|array',
      button_class: 'Button',
      button_role: 'string',
      multi_select: 'int',
      deselect: 'boolean',
    };
  }

  static get options() {
    return {
      buttons: [],
      direction: 'horizontal',
      select: -1,
      button_class: Button,
      button_role: 'option',
      multi_select: 0,
      deselect: false,
      role: 'listbox',
      tabindex: 0,
      _focus: false,
    };
  }

  static get static_events() {
    return {
      userset: function (key, value) {
        if (key !== 'select' || this.options.deselect) return;

        if (value === -1 || (Array.isArray(value) && value.length === 0))
          return false;
      },
      set_select: function (value) {
        const list = this.buttons.getList();
        const current = [];
        const selected = Array.isArray(value)
          ? value
          : value === -1
          ? []
          : [value];
        for (let i = 0; i < list.length; i++) {
          // we use update, it avoids changing the state if it is already
          // correct.
          const state = selected.includes(i);
          list[i].update('state', state);
          if (state) current.push(list[i].get('id'));
          list[i].set('aria_current', state ? 'true' : 'false');
        }
        this.set('aria_current', current.join(' '));
      },
      set_multi_select: function (multi_select) {
        const O = this.options;

        const select = enforceMultiSelect(O.select, multi_select);

        this.update('select', select);

        this.set('aria_multiselectable', multi_select ? 'true' : 'false');
      },
      set_direction: function (direction) {
        this.set('aria_orientation', direction);
        const keys = ['Home', 'End', 'Space', 'Enter'];
        if (direction === 'vertical') keys.push('ArrowUp', 'ArrowDown');
        else keys.push('ArrowUp', 'ArrowDown');
        this.set('aria_keyshortcuts', keys.join(' '));
      },
      set__focus: function (focus) {
        clearFocus.call(this);
        this.set('aria_activedescendant', '');
        const button = this.buttons.list[focus];
        if (!button) return;
        button.set('focus', true);
        this.set('aria_activedescendant', button.get('id'));
      },
      keydown: function (e) {
        if (e.code === 'ArrowLeft' || e.code === 'ArrowUp') {
          this.focusPrevious();
        }
        if (e.code === 'ArrowRight' || e.code === 'ArrowDown') {
          this.focusNext();
        }
        if (e.code === 'Home') {
          this.focusFirst();
        }
        if (e.code === 'End') {
          this.focusLast();
        }
        if (e.code === 'Space' || e.code === 'Enter') {
          if (e.preventDefault) e.preventDefault();
          const focus = this.get('_focus');
          if (focus === false) return false;
          const button = this.buttons.list[focus];
          if (!button) return false;
          button.userset('state', !button.get('state'));
        }
        return false;
      },
      focus: function (e) {
        this.reFocus();
      },
      blur: function (e) {
        clearFocus.call(this);
      },
    };
  }

  static get renderers() {
    return [
      defineRender('direction', function (direction) {
        const E = this.element;
        removeClass(E, 'aux-vertical', 'aux-horizontal');
        addClass(E, 'aux-' + direction);
      }),
    ];
  }

  initialize(options) {
    super.initialize(options);
    /**
     * @member {HTMLDivElement} Buttons#element - The main DIV container.
     *   Has class <code>.aux-buttons</code>.
     */
    /**
     * @member {ChildWidgets} Buttons#buttons - An instance of {@link ChildWidgets} holding all
     *   {@link Button}s.
     */
    this.buttons = new ChildWidgets(this, {
      filter: Button,
    });
    this.buttons.on('child_added', onButtonAdded);
    this.buttons.on('child_removed', onButtonRemoved);
  }

  focusNext() {
    moveFocus.call(this, 1);
  }
  focusPrevious() {
    moveFocus.call(this, -1);
  }
  focusFirst() {
    moveFocus.call(this, -this.buttons.list.length);
  }
  focusLast() {
    moveFocus.call(this, this.buttons.list.length);
  }
  reFocus() {
    moveFocus.call(this, 0);
  }

  initialized() {
    // the set() method would otherwise try to remove initial buttons
    const O = this.options;
    const buttons = O.buttons;
    O.buttons = [];
    this.set('buttons', buttons);
    super.initialized();
  }

  draw(O, element) {
    addClass(element, 'aux-buttons');
    this.set('direction', O.direction);
    this.set('multi_select', O.multi_select);

    super.draw(O, element);
  }

  /**
   * Adds an array of buttons to the end of the list.
   *
   * @method Buttons#addButtons
   *
   * @param {Array.<string|object>} list - An Array containing
   *   Button instances, objects
   *   with options for the buttons (see {@link Button} for more
   *   information) or strings for the buttons labels.
   */
  addButtons(list) {
    return list.map((options) => this.addButton(options));
  }

  createButton(options) {
    const O = this.options;
    if (options instanceof Button) {
      if (!options.get('id')) {
        options.set('id', createID('aux-button-'));
      }
      options.set('role', O.button_role);
      options.set('tabindex', false);
      return options;
    } else {
      if (typeof options === 'string') {
        options = { label: options, role: O.button_role };
      } else if (options === void 0) {
        options = { role: O.button_role };
      } else if (typeof options !== 'object') {
        throw new TypeError('Expected object of options.');
      }
      if (!options.id) options.id = createID('aux-button-');
      options.role = O.button_role;
      options.tabindex = false;
      return new this.options.button_class(options);
    }
  }

  /**
   * Adds a {@link Button} to Buttons.
   *
   * @method Buttons#addButton
   *
   * @param {Button|Object|String} options - An alread instantiated {@link Button},
   *   an object containing options for a new {@link Button} to add
   *   or a string for the label of the newly created {@link Button}.
   * @param {integer} [position] - The position to add the {@link Button}
   *   to. If `undefined`, the {@link Button} is added to the end of the list.
   *
   * @returns {Button} The {@link Button} instance.
   */
  addButton(options, position) {
    const button = this.createButton(options);
    const buttons = this.getButtons();
    const element = this.element;

    if (!(position >= 0 && position < buttons.length)) {
      element.appendChild(button.element);
    } else {
      element.insertBefore(button.element, buttons[position].element);
    }

    if (button.parent !== this) {
      // if this button is a web component, the above appendChild would have
      // already triggered a call to addChild
      this.addChild(button);
    }
    return button;
  }

  /**
   * Removes a {@link Button} from Buttons.
   *
   * @method Buttons#removeButton
   *
   * @param {integer|Button} button - button index or the {@link Button}
   *   instance to be removed.
   * @param {Boolean} destroy - destroy the {@link Button} after removal.
   */
  removeButton(button, destroy) {
    const buttons = this.getButtons();
    let position = -1;

    if (button instanceof Button) {
      position = buttons.indexOf(button);
    } else if (typeof button === 'number') {
      position = button;
      button = buttons.at(position);
    }

    if (!button || position === -1) throw new Error('Unknown button.');

    button.element.remove();

    if (buttons.at(position) === button) {
      // NOTE: if we remove a child which is a web component,
      // it will itself call removeChild
      this.removeChild(button);
    }

    if (destroy) {
      button.destroyAndRemove();
    }
  }

  /**
   * @returns {Button[]} The list of {@link Button}s.
   * @method Buttons#getButtons
   */
  getButtons() {
    return this.buttons.getList();
  }

  /**
   * Removes all buttons.
   *
   * @method Buttons#empty
   */
  empty() {
    this.buttons.forEach((button) => this.removeButton(button, true));
  }

  destroy() {
    this.empty();
    this.buttons.destroy();
    this.set('buttons', []);
    super.destroy();
  }

  /**
   * Checks if an index or {@link Button} is selected.
   *
   * @method Buttons#isSelected
   *
   * @param {Integer|Button} button - button index or {@link Button} instance.
   *
   * @returns {Boolean}
   */
  isSelected(probe) {
    const button = typeof probe === 'number' ? this.buttons.at(probe) : probe;

    if (!button) throw new Error('Unknown button.');

    return button.get('state');
  }

  set(key, value) {
    if (key === 'buttons') {
      // remove all buttons which were added using this option
      this.buttons.list.forEach((b) => this.removeButton(b, true));
      value = this.addButtons(value || []);
    } else if (key === 'select') {
      value = enforceMultiSelect(value, this.options.multi_select);
    }

    return super.set(key, value);
  }

  get(key) {
    if (key === 'buttons') return this.getButtons();
    else return super.get(key);
  }
}