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

/**
 * The <code>useraction</code> event is emitted when a widget gets modified by user interaction.
 * The event is emitted for the option <code>show</code>.
 *
 * @event Pager#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
 */
import { defineChildWidget } from '../child_widget.js';
import { addClass, removeClass, createID } from '../utils/dom.js';
import { warn } from '../utils/log.js';
import {
  initSubscriptions,
  addSubscription,
  unsubscribeSubscriptions,
} from '../utils/subscriptions.js';
import { Pages } from './pages.js';
import { Container } from './container.js';
import { Navigation } from './navigation.js';
import { defineRender } from '../renderer.js';

/**
 * Pager, also known as Notebook in other UI toolkits, provides
 * multiple containers for displaying contents via {@link Pages}
 * which are switchable via a {@link Navigation}.
 *
 * @class Pager
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {String} [options.position="top"] - The position of the
 *   {@link Navigation}. Can either be `top`, `right`, `left` or `bottom`.
 * @property {Integer} [options.show] - The page to show. Set to -1
 *   to hide all pages.
 * @property {Array<Container|DOMNode|String>} [options.pages=[]] -
 *   An array of either an instance of {@link Container} (or derivate),
 *   a DOMNode or a string of HTML which gets wrapped in a new {@link Container}.
 * @extends Container
 *
 * @example
 * var pager = new Pager({
 *  pages: [
 *   {
 *     label: "Empty Page 1",
 *     content: document.createElement("span")
 *   },
 *   {
 *     label: { label:"Foobar", class:"foobar" },
 *     content: "<h1>Foobar</h1><p>Lorem ipsum dolor sit amet</p>"
 *   }
 *  ]
 * });
 */
export class Pager extends Container {
  static get _options() {
    return {
      pages: 'array',
      position: 'string',
      show: 'int',
    };
  }

  static get options() {
    return {
      pages: [],
      position: 'top',
      show: null,
    };
  }

  static get static_events() {
    return {
      set_position: function (value) {
        let badir;
        if (value === 'top' || value === 'bottom') {
          badir = 'horizontal';
        } else {
          badir = 'vertical';
        }
        if (this.navigation) this.navigation.set('direction', badir);
        if (this.pages) this.pages.set('animation', badir);
      },
    };
  }

  static get renderers() {
    return [
      defineRender('position', function (position) {
        const element = this.element;

        removeClass(
          element,
          'aux-top',
          'aux-right',
          'aux-bottom',
          'aux-left',
          'aux-vertical',
          'aux-horizontal'
        );
        switch (position) {
          case 'top':
            addClass(element, 'aux-top', 'aux-vertical');
            break;
          case 'bottom':
            addClass(element, 'aux-bottom', 'aux-vertical');
            break;
          case 'left':
            addClass(element, 'aux-left', 'aux-horizontal');
            break;
          case 'right':
            addClass(element, 'aux-right', 'aux-horizontal');
            break;
          default:
            warn('Unsupported position', position);
        }
      }),
    ];
  }

  initializePages() {
    this.pages_subscriptions = unsubscribeSubscriptions(
      this.pages_subscriptions
    );

    const pages = this.pages;
    const navigation = this.navigation;

    if (!pages || !navigation) return;

    // create one button for each page and keep label and icon synchronized
    // with the page label/icon
    let subs = pages.pages.forEachAsync((page, position) => {
      let _subs = initSubscriptions();
      const id = page.element.id || false;
      const button = navigation.addButton(
        {
          label: page.get('label') || false,
          icon: page.get('icon') || false,
          id: id ? id + '-button' : false,
        },
        position
      );

      this.page_to_button.set(page, button);

      _subs = addSubscription(
        _subs,
        page.subscribe('set_label', (label) => {
          button.set('label', label);
        })
      );

      _subs = addSubscription(
        _subs,
        page.subscribe('set_icon', (icon) => {
          button.set('icon', icon);
        })
      );

      _subs = addSubscription(_subs, () => {
        navigation.removeButton(button);
        this.page_to_button.delete(page);
      });

      this.emit('added', page);

      _subs = addSubscription(_subs, () => {
        this.emit('removed', page);
      });

      return _subs;
    });

    // delegate the userset action from pages to pager
    subs = addSubscription(
      subs,
      pages.subscribe('userset', (key, value) => {
        if (key !== 'show') return;
        return this.userset('show', value);
      })
    );

    // delegate the set_show action from pages to pager
    subs = addSubscription(
      subs,
      pages.subscribe('set_show', (value) => {
        return this.update('show', value);
      })
    );

    // delegate the userset action from navigation to pager
    subs = addSubscription(
      subs,
      navigation.subscribe('userset', (key, value) => {
        if (key !== 'select') return;
        return this.userset('show', value);
      })
    );

    // delegate the set_select action from navigation to pager
    subs = addSubscription(
      subs,
      navigation.subscribe('set_select', (value) => {
        this.update('show', value);
      })
    );

    // delegate the set_show action from pager to both pages and navigation
    subs = addSubscription(
      subs,
      this.subscribe('set_show', (value) => {
        pages.update('show', value);
        navigation.update('select', value);
      })
    );

    // delegate the added and removed events from pages
    subs = addSubscription(
      subs,
      this.subscribe('set_show', (value) => {
        pages.update('show', value);
        navigation.update('select', value);
      })
    );

    this.pages.set('show', this.get('show'));
    this.navigation.set('select', this.get('show'));
    this.set('position', this.get('position'));

    this.pages_subscriptions = subs;
  }

  getButtonForPage(page) {
    return this.page_to_button.get(page);
  }

  initialize(options) {
    super.initialize(options);
    /**
     * The main DIV element. Has the class <code>.aux-pager</code>.
     *
     * @member Pager#element
     */
    this.pages_subscriptions = initSubscriptions();
    this.page_to_button = new Map();
  }

  initialized() {
    super.initialized();
    this.addPages(this.options.pages);
    this.set('position', this.options.position);
    this.set('show', this.options.show);
  }

  draw(O, element) {
    addClass(element, 'aux-pager');

    super.draw(O, element);
  }

  removeChild(child) {
    if (this.isDestructed()) return;
    if (child instanceof Pages) {
      if (this.pages === child) {
        this.pages.element.remove();
        this.pages = null;
        this.initializePages();
      }
    } else if (child instanceof Navigation) {
      if (this.navigation === child) {
        this.navigation.element.remove();
        this.navigation = null;
        this.initializePages();
      }
    }

    super.removeChild(child);
  }

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

    if (child instanceof Pages) {
      if (this.pages && this.pages !== child) {
        // this.pages is being replaced by a new instance (set by the user)
        this.removeChild(this.pages);
      }

      this.pages = child;
      this.initializePages();
    }
  }

  /**
   * Adds an array of pages.
   *
   * @method Pager#addPages
   *
   * @param {Array<Object>} options - An Array of objects with members
   *   `content` and all options available in {@link Button}.
   *   `content` is either a {@link Container} (or derivate) widget,
   *   a DOMNode or a string of HTML which
   *   gets wrapped in a new {@link Container} with options from
   *   argument `options`.
   *
   * @example
   * var p = new Pager();
   * p.addPages([
   *   {
   *     label: "Page 1",
   *     icon: "gear",
   *     content: "<h1>Page1</h1>",
   *   }
   * ]);
   *
   */
  addPages(pages) {
    if (!Array.isArray(pages))
      throw new TypeError('Expected array of objects.');

    for (let i = 0; i < pages.length; i++) {
      if (typeof pages[i] !== 'object') {
        throw new TypeError('Expected array of objects.');
      }

      const options = Object.assign({}, pages[i]);
      const content = options.content;
      delete options.content;

      this.addPage(options, content);
    }
  }

  /**
   * Adds a {@link Container} to the pager and a corresponding {@link Button}
   *   to the pagers {@link Navigation}.
   *
   * @method Pager#addPage
   *
   * @param {string|Object} buttonOptions - A string with the {@link Button}s label or
   *   an object containing options for the {@link Button} instance.
   * @param {Container|DOMNode|string} content - The content of the page.
   *   Either a {@link Container} (or derivate) widget,
   *   a DOMNode or a string of HTML which gets wrapped in a new
   *   {@link Container} using options from argument `options`.
   * @param {Object} [options={ }] - An object containing options for
   *   the {@link Container} to be added as page if `content` is
   *   either a string or a DOMNode.
   * @param {integer|undefined} [position] - The position to add the new
   *   page to. If undefined, the page is added at the end.
   *
   * @emits Pager#added
   */
  addPage(buttonOptions, content, options, position) {
    const p = this.pages.addPage(content, position, options);

    let pid;
    if (!options || !options.id) {
      pid = createID('aux-page-');
      p.set('id', pid);
    } else {
      pid = options.id;
    }

    const button = this.getButtonForPage(p);

    let bid;

    if (typeof buttonOptions === 'string') {
      p.set('label', buttonOptions);
      bid = createID('aux-button-');
      button.set('id', bid);
    } else if (typeof buttonOptions === 'object') {
      const label = buttonOptions.label;

      if (label) p.set('label', label);

      for (const key in buttonOptions) {
        if (Object.prototype.hasOwnProperty.call(buttonOptions, key))
          button.set(key, buttonOptions[key]);
      }

      if (!buttonOptions.id) {
        bid = createID('aux-button-');
      } else {
        bid = buttonOptions.id;
      }
      button.set('id', bid);
    } else {
      throw new TypeError('Unsupported API.');
    }

    button.set('aria_controls', pid);
    p.set('aria_labelledby', bid);

    return p;
  }

  /**
   * Removes a page from the Pager.
   *
   * @method Pager#removePage
   *
   * @param {integer|Container} page - The container to remove. Either an
   *   index or the {@link Container} widget generated by <code>addPage</code>.
   *
   * @emits Pager#removed
   */
  removePage(page) {
    this.pages.removePage(page);
  }

  /**
   * Returns the currently displayed page or null.
   *
   * @method Pager#current
   */
  current() {
    return this.pager.current();
  }

  /**
   * Opens the first page of the pager. Returns <code>true</code> if a
   * first page exists, <code>false</code> otherwise.
   *
   * @method Pager#first
   */
  first() {
    if (this.pages.getPages().length) {
      this.set('show', 0);
      return true;
    }
    return false;
  }

  /**
   * Opens the last page of the pager. Returns <code>true</code> if a
   * last page exists, <code>false</code> otherwise.
   *
   * @method Pager#last
   */
  last() {
    if (this.pages.getPages().length) {
      this.set('show', this.pages.getPages().length - 1);
      return true;
    }
    return false;
  }

  /**
   * Opens the next page of the pager. Returns <code>true</code> if a
   * next page exists, <code>false</code> otherwise.
   *
   * @method Pager#next
   */
  next() {
    const c = this.options.show;
    return this.set('show', c + 1) !== c;
  }

  /**
   * Opens the previous page of the pager. Returns <code>true</code> if a
   * previous page exists, <code>false</code> otherwise.
   *
   * @method Pager#prev
   */
  prev() {
    const c = this.options.show;
    return this.set('show', c - 1) !== c;
  }

  getPages() {
    return this.pages.getPages();
  }
}

/**
 * The {@link Navigation} instance acting as the menu.
 *
 * @member Pager#navigation
 */
defineChildWidget(Pager, 'navigation', {
  create: Navigation,
  show: true,
  map_options: {
    show: 'select',
  },
  default_options: {
    'buttons.role': 'tablist',
    'buttons.button_role': 'tab',
  },
  static_events: {
    userset: function (key, value) {
      if (key === 'select') {
        this.parent.userset('show', value);
      } else {
        this.parent.userset(key, value);
      }
      return false;
    },
  },
});

/**
 * The {@link Pages} instance.
 *
 * @member Pager#pages
 */

/**
 * A page was removed from the Pager
 *
 * @event Pager#removed
 *
 * @param {Container} page - The {@link Container} which was removed.
 */
/**
 * A page was added to the Pager.
 *
 * @event Pager#added
 *
 * @param {Container} page - The {@link Container} which was added as a page.
 */

defineChildWidget(Pager, 'pages', {
  create: Pages,
  show: true,
  inherit_options: true,
  //static_events: {
  //"added" : function (p) { this.emit("added", p); },
  //"removed" : function (p) { this.emit("removed", p); },
  //},
  blacklist_options: ['pages'],
});