widgets/expand.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 { defineChildWidget } from '../child_widget.js';
import { Container } from './container.js';
import { Toggle } from './toggle.js';
import { Timer } from './../utils/timers.js';
import { addClass } from '../utils/dom.js';

function set_class(cls) {
  const classes = [
    'aux-expanding',
    'aux-expanded',
    'aux-collapsing',
    'aux-collapsed',
  ];
  for (let i = 0, m = classes.length; i < m; ++i) {
    if (classes[i] != cls) this.removeClass(classes[i]);
  }
  this.addClass(cls);
  this.triggerResize();
}

function expand_cb() {
  set_class.call(this, 'aux-expanded');
  this.emit('expanded');
}

function collapse_cb() {
  set_class.call(this, 'aux-collapsed');
  this.emit('collapsed');
}

function expand() {
  const O = this.options;
  if (O.expanded) {
    if (this.timer_collapse.active) this.timer_collapse.stop();
    if (O.showing_duration && !this.timer_expand.active) {
      set_class.call(this, 'aux-expanding');
      this.timer_expand.start(O.showing_duration);
    } else if (O.showing_duration && this.timer_expand.active) {
      return;
    } else {
      set_class.call(this, 'aux-expanded');
      /**
       * Is fired after the expand was expanded
       *
       * @event Expand#expanded
       */
      this.emit('expanded');
    }
  } else {
    if (this.timer_expand.active) this.timer_expand.stop();
    if (O.hiding_duration && !this.timer_collapse.active) {
      set_class.call(this, 'aux-collapsing');
      this.timer_collapse.start(O.hiding_duration);
    } else if (O.showing_duration) {
      return;
    } else {
      set_class.call(this, 'aux-collapsed');
      /**
       * Is fired after the expand was collapsed
       *
       * @event Expand#collapsed
       */
      this.emit('collapsed');
    }
  }
}

function toggle(e) {
  const self = this.parent;
  e.preventDefault();
  e.stopPropagation();
  return collapse.call(self, !self.options.expanded);
}
function collapse(state) {
  this.userset('expanded', state);
  return false;
}
function visibleWhenExpanded(widget) {
  const v = widget.options._expanded;
  return v !== false;
}
function visibleWhenCollapsed(widget) {
  const v = widget.options._collapsed;
  return v === true;
}
function isVisible(widget) {
  const value = this.options.always_expanded || this.options.expanded;

  if (value) {
    return visibleWhenExpanded(widget);
  } else {
    return visibleWhenCollapsed(widget);
  }
}
function changedExpanded(value) {
  const group = this.options.group;
  let other_expand;
  let grp;

  if (group) {
    grp = expand_groups[group];
    if (value) {
      other_expand = grp.active;
      grp.active = this;
      if (other_expand && other_expand !== this)
        other_expand.userset('expanded', false);
    } else if (grp.active === this) {
      grp.active = false;
      if (grp.default) grp.default.userset('expanded', true);
    }
  }
  updateVisibility.call(this);
}
function addToGroup(group) {
  let grp;
  const O = this.options;
  if (!(grp = expand_groups[group]))
    expand_groups[group] = grp = { active: false, default: false };

  if (O.group_default) {
    grp.default = this;
    if (!grp.active) {
      this.set('expanded', true);
      return;
    }
  }

  if (O.expanded) changedExpanded.call(this, O.expanded);
}

function removeFromGroup(group) {
  const grp = expand_groups[group];

  if (grp.default === this) grp.default = false;
  if (grp.active === this) {
    grp.active = false;
    if (grp.default) grp.default.set('expanded', true);
  }
}
function removeGroupDefault(group) {
  if (!group) return;
  const grp = expand_groups[group];
  grp.default = false;
}
function updateVisibility() {
  const C = this.children;
  const value = this.options.always_expanded || this.options.expanded;

  if (C) {
    const test = value ? visibleWhenExpanded : visibleWhenCollapsed;
    for (let i = 0; i < C.length; i++) {
      if (test(C[i])) this.showChild(i);
      else this.hideChild(i);
    }
  }

  if (value) {
    this.emit('expand');
    /**
     * Is fired when the expand expands.
     *
     * @event Expand#expand
     */
  } else {
    /**
     * Is fired when the expand collapses.
     *
     * @event Expand#collapse
     */
    this.emit('collapse');
  }
}
const expand_groups = {};
/**
 * Expand is a container which can be toggled between two different states,
 * expanded and collapsed. It can be used to implement overlay popups, but it is
 * not limited to that application.
 * In expanded mode the container has the class <code>.aux-expanded</code>.
 * Child widgets are shown or hidden depending on the state of the two pseudo
 * options <code>_expanded</code> and <code>_collapsed</code>. If a child widget
 * of the expand has <code>_expanded</code> set to true it will be shown in
 * expanded state. If a child widget has <code>_collapsed</code> set to false, it
 * will be shown in collapsed state. This feature can be used to make interfaces
 * more reactive.
 *
 * @class Expand
 *
 * @extends Container
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {Boolean} [options.expanded=false] - The state of the widget.
 * @property {Boolean} [options.always_expanded=false] - This essentially overwrites
 *   the <code>expanded</code> option. This can be used to switch this widget to be
 *   always expanded, e.g. when the screen size is big enough.
 * @property {String} [options.group=""] - If set, this expand is grouped together with
 *   all other expands of the same group name. At most one expand of the same group
 *   can be open at one time.
 * @property {Boolean} [options.group_default=false] - If set, this expand is expanded
 *   if all other group members are collapsed.
 * @property {String} [options.icon=""] - Icon of the {@link Button} which toggles expanded state.
 * @property {String} [options.icon_active=""] - Icon of the active {@link Button} which toggles expanded state.
 * @property {String} [options.label=""] - Label of the {@link Button} which toggles expanded state.
 * @property {Boolean} [options.show_button=true] - Set to `false` to hide the {@link Button} toggling expanded state.
 */
export class Expand extends Container {
  static get _options() {
    return Object.assign({}, Container.getOptionTypes(), {
      expanded: 'boolean',
      always_expanded: 'boolean',
      group: 'string',
      group_default: 'boolean',
      label: 'string',
      icon: 'string',
      icon_active: 'string',
    });
  }

  static get options() {
    return {
      expanded: false,
      always_expanded: false,
      group_default: false,
      label: '',
      icon: '',
      icon_active: '',
      role: 'region',
    };
  }

  static get static_events() {
    return {
      set_expanded: changedExpanded,
      set_always_expanded: updateVisibility,
      set_group: function (value) {
        if (value) addToGroup.call(this, value);
      },
    };
  }

  /**
   * Toggles the collapsed state of the widget.
   *
   * @method Expand#toggle
   */
  toggle() {
    toggle.call(this);
  }

  redraw() {
    const I = this.invalid;
    const O = this.options;

    super.redraw();

    if (I.always_expanded) {
      this[O.always_expanded ? 'addClass' : 'removeClass'](
        'aux-always-expanded'
      );
    }

    if (I.expanded || I.always_expanded) {
      I.always_expanded = I.expanded = false;
      expand.call(this);
    }
  }

  initialize(options) {
    super.initialize(options);

    this.timer_expand = new Timer(expand_cb.bind(this));
    this.timer_collapse = new Timer(collapse_cb.bind(this));

    this._update_visibility = updateVisibility.bind(this);

    if (this.options.group) addToGroup.call(this, this.options.group);

    this.set('expanded', this.options.expanded);
    this.set('always_expanded', this.options.always_expanded);
  }

  draw(O, element) {
    /**
     * @member {HTMLDivElement} Expand#element - The main DIV container.
     *   Has class <code>.aux-expand</code>.
     */
    addClass(element, 'aux-expand');

    super.draw(O, element);
  }

  addChild(child) {
    super.addChild(child);
    if (!isVisible.call(this, child)) this.hideChild(child);
    child.on('set__expanded', this._update_visibility);
    child.on('set__collapsed', this._update_visibility);
  }

  removeChild(child) {
    super.removeChild(child);
    child.off('set__expanded', this._update_visibility);
    child.off('set__collapsed', this._update_visibility);
  }

  set(key, value) {
    let group;
    if (key === 'group') {
      group = this.options.group;
      // this is reached from init, where this element was never added
      // to the group.
      if (group && value !== group) removeFromGroup.call(this, group);
    } else if (key === 'group_default') {
      if (!value && this.options.group_default)
        removeGroupDefault.call(this, this.options.group);
    }
    return super.set(key, value);
  }
}
/**
 * @member {Toggle} Expand#button - The button for toggling the state of the expand.
 */
defineChildWidget(Expand, 'button', {
  create: Toggle,
  show: true,
  map_options: {
    expanded: 'state',
    label: 'label',
    icon: 'icon',
    icon_active: 'icon_active',
  },
  default_options: {
    _expanded: true,
    _collapsed: true,
    class: 'aux-toggleexpand',
  },
  static_events: {
    click: toggle,
  },
});