widgets/dialog.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 { Container } from './container.js';
import { translateAnchor } from '../utils/anchor.js';
import {
  element,
  addClass,
  getFocusableElements,
  observeDOM,
  setDelayedFocus,
} from '../utils/dom.js';
import { defineRender } from '../renderer.js';

function autocloseCallback(e) {
  let curr = e.target;
  while (curr) {
    // TODO: if a dialog is opened out of a dialog both should avoid
    // closing any of those on click. former version:
    //if (curr === this.element) return;
    // this closes tagger in Cabasa Dante Tagger when interacting
    // with the colorpicker.
    // workaround for the moment:
    // don't close on click on any dialog
    if (curr.classList.contains('aux-dialog')) return;
    curr = curr.parentElement;
  }
  this.close();
}

function activateAutoclose() {
  if (this._autoclose_active) return;
  setTimeout(
    function () {
      document.body.addEventListener('click', this._autoclose_cb, {
        capture: false,
      });
    }.bind(this),
    50
  );
  this._autoclose_active = true;
}

function deactivateAutoclose() {
  if (!this._autoclose_active) return;
  setTimeout(
    function () {
      document.body.removeEventListener('click', this._autoclose_cb, {
        capture: false,
      });
    }.bind(this),
    50
  );
  this._autoclose_active = false;
}

function keepInside(e) {
  if (e.key === 'Tab' || e.keyCode === 9) {
    const E = getFocusableElements(this.element);
    const first = E[0];
    const last = E[E.length - 1];
    if (e.shiftKey) {
      if (document.activeElement === first) {
        last.focus();
        e.preventDefault();
      }
    } else {
      if (document.activeElement === last) {
        first.focus();
        e.preventDefault();
      }
    }
    return false;
  }
}

function handleTabbing() {
  if (this._tabeventtargets.length) {
    for (let i = 0, m = this._tabeventtargets.length; i < m; ++i) {
      this._tabeventtargets[i].removeEventListener('keydown', this._tabbing_cb);
    }
    this._tabeventtargets = [];
  }
  if (!this.options.contain_focus) return;
  const F = getFocusableElements(this.element);
  if (F[0]) {
    F[0].addEventListener('keydown', this._tabbing_cb);
  }
  if (F[F.length - 1]) {
    F[F.length - 1].addEventListener('keydown', this._tabbing_cb);
  }
}

function off(e) {
  e.preventDefault();
  e.stopImmediatePropagation();
}

/**
 * Dialog provides a hovering area which can be closed by clicking/tapping
 * anywhere on the screen. It can be automatically pushed to the topmost
 * DOM position as a child of an AWML-ROOT or the BODY element. On close
 * it can be removed from the DOM. The {@link Anchor}-functionality
 * makes positioning the dialog window straight forward.
 *
 * @class Dialog
 *
 * @extends Container
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {String} [options.anchor="top-left"] - Origin of `x` and `y` coordinates. See {@link Anchor} for more information.
 * @property {Number} [options.x=0] - X-position of the dialog.
 * @property {Number} [options.y=0] - Y-position of the dialog.
 * @property {Boolean} [options.auto_close=false] - Set dialog to `visible=false` if clicked outside in the document.
 * @property {Boolean} [options.auto_remove=false] - Remove the dialogs DOM node after setting `visible=false`.
 * @property {Boolean} [options.toplevel=false] - Add the dialog DOM node to the topmost position in DOM on `visible=true`. Topmost means either a parenting `AWML-ROOT` or the `BODY` node.
 * @property {Boolean} [options.reset_focus=true] - Reset the focus to the element which had the focus before opening the dialog on closing the dialog.
 * @property {Boolean} [options.contain_focus=true] - Keep focus inside the dialog.
 *
 */
export class Dialog extends Container {
  static get _options() {
    return {
      anchor: 'string',
      x: 'string',
      y: 'string',
      auto_close: 'boolean',
      auto_remove: 'boolean',
      toplevel: 'boolean',
      modal: 'boolean',
      reset_focus: 'boolean',
      contain_focus: 'boolean',
    };
  }

  static get options() {
    return {
      anchor: 'top-left',
      x: undefined,
      y: undefined,
      auto_close: false,
      auto_remove: false,
      toplevel: false,
      role: 'dialog',
      modal: false,
      reset_focus: true,
      contain_focus: true,
    };
  }

  static get static_events() {
    return {
      hide: function () {
        deactivateAutoclose.call(this);
        if (this.options.auto_remove) {
          this.element.remove();
          this._modal.remove();
        }
        this.emit('close');
      },
      set_visible: function (val) {
        const O = this.options;
        const C = O.container;

        if (val === true) {
          if (O.auto_close) activateAutoclose.call(this);
          if (O.modal) C.appendChild(this._modal);
          this.triggerResize();
        } else {
          deactivateAutoclose.call(this);
        }

        if (val === 'showing') {
          if (C) {
            C.appendChild(this.element);
            if (O.modal) C.appendChild(this._modal);
          }
          this.reposition();
        }

        if (val) {
          if (O.toplevel && C.tagName !== 'AWML-ROOT' && C.tagName !== 'BODY') {
            let p = this.element;
            while (
              (p = p.parentElement) &&
              p.tagName !== 'AWML-ROOT' &&
              p.tagName !== 'BODY'
            );
            p.appendChild(this.element);
            if (O.modal) p.appendChild(this._modal);
          }
        } else {
          O.container = this.element.parentElement;
          this._modal.remove();
        }
      },
      set_auto_close: function (val) {
        if (val) {
          if (!this.hidden()) activateAutoclose.call(this);
        } else {
          deactivateAutoclose.call(this);
        }
      },
      set_contain_focus: handleTabbing,
    };
  }

  static get renderers() {
    return [
      defineRender('anchor', function (anchor) {
        const pos = translateAnchor(anchor, 0, 0, -100, -100);
        this.element.style.transform =
          'translate(' + pos.x + '%, ' + pos.y + '%)';
      }),
      defineRender('x', function (x) {
        this.element.style.left = typeof x === 'string' ? x : x + 'px';
      }),
      defineRender('y', function (y) {
        this.element.style.top = typeof y === 'string' ? y : y + 'px';
      }),
      defineRender('modal', function (modal) {
        const element = this.element;
        if (modal) {
          element.setAttribute('aria-modal', 'true');
        } else {
          element.removeAttribute('aria-modal');
        }
      }),
      defineRender(['modal', 'visible', 'container'], function (
        modal,
        visible,
        container
      ) {
        const _modal = this._modal;
        if (modal && visible && container) {
          if (_modal.parentNode !== container) container.appendChild(_modal);
        } else {
          _modal.remove();
        }
      }),
    ];
  }

  initialize(options) {
    super.initialize(options);
    const O = this.options;
    /* This cannot be a default option because document.body
     * is not defined there */
    if (!O.container) O.container = window.document.body;

    /**
     * @member {HTMLDiv} Dialog#_modal - The container blocking user interaction
     *   Has class <code>.aux-dialog-modal</code>.
     */
    this._modal = element('div', { class: 'aux-dialog-modal' });
    this._modal.addEventListener('click', off, { capture: true });
    this._modal.addEventListener('mousedown', off, { capture: true });
    this._modal.addEventListener('touchstart', off, { capture: true });

    this._autoclose_active = false;
    this._autoclose_cb = autocloseCallback.bind(this);
    this._tabbing_cb = keepInside.bind(this);
    this._tabeventtargets = [];
    this.set('contain_focus', O.contain_focus);

    observeDOM(this.element, handleTabbing.bind(this));
    handleTabbing.call(this);
  }

  resize() {
    if (this.options.visible) this.reposition();
  }

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

  /**
   * Open the dialog. Optionally set x and y position regarding `anchor`.
   *
   * @method Dialog#open
   *
   * @param {String} [x] - New X-position of the dialog.
   * @param {String} [y] - New Y-position of the dialog.
   * @param {HTMLElement} [focus] - Element to receive focus after opening the dialog.
   */
  open(x, y, focus) {
    this._previousFocus = document.activeElement;
    if (typeof x !== 'undefined') this.set('x', x);
    if (typeof y !== 'undefined') this.set('y', y);
    this.userset('visible', true);
    /**
     * Is fired when the dialog is opened.
     *
     * @event Dialog#open
     */
    this.emit('open');

    if (!focus) {
      const E = getFocusableElements(this.element);
      if (E[0]) {
        setDelayedFocus(E[0]);
      }
    } else {
      setDelayedFocus(focus);
    }
  }

  /**
   * Close the dialog. The node is removed from DOM if `auto_remove` is set to `true`.
   *
   * @method Dialog#close
   */
  close() {
    this.userset('visible', false);
    /**
     * Is fired when the dialog is closed.
     *
     * @event Dialog#close
     */
    this.emit('close');
    if (this._previousFocus && this.options.reset_focus) {
      setDelayedFocus(this._previousFocus);
    }
  }

  /**
   * Reposition the dialog to the current `x` and `y` position.
   *
   * @method Dialog#reposition
   */
  reposition() {
    const O = this.options;
    this.set('anchor', O.anchor);
    this.set('x', O.x);
    this.set('y', O.y);
  }
}