Home Reference Source Test

spatial-navigation-cursor/index.js

// @flow

/*::
type cursorManager$Rect = {
  top: number;
  left: number;
  width: number;
  height: number;
};

export type spatialNavigationCursor$EventListener = {
  type: string;
  listener: EventHandler;
};

export class spatialNavigationCursor$FocusUpdatedEvent extends CustomEvent {
  detail: {
    target: HTMLElement;
  };
}

export type spatialNavigationCursor$Events = {
  FOCUS_UPDATED: 'focusUpdated';
  CURSOR_TRANSITIONSTART: 'cursorTransitionstart';
  CURSOR_TRANSITIONEND: 'cursorTransitionend';
};

type Dimension = {
  width: number;
  height: number;
};
*/

export default class CursorManager {
  /*::
  static Events: spatialNavigationCursor$Events;
  root_: HTMLElement;
  cursor_: HTMLElement;
  focused_: ?HTMLElement;
  freezed_: boolean;
  focusClassName_: string;
  viewHeight_: number;
  observer_: ?MutationObserver;
  eventListeners_: spatialNavigationCursor$EventListener[];
  onTransitionStart_: EventHandler;
  onTransitionEnd_: EventHandler;
  */

  /**
   * @param {Object} props
   * @param {HTMLElement} props.root an HTML element to use as its root
   * @param {string} props.focusClassName a css class name to identify the focused element
   */
  constructor({ root, focusClassName }/*: {
    root: HTMLElement,
    focusClassName: string,
  }*/) {
    /**
     * @type {HTMLElement}
     * @access private
     */
    this.root_ = root;

    /**
     * @type {string}
     * @access private
     */
    this.focusClassName_ = focusClassName;

    /**
     * @type {HTMLElement}
     * @access private
     */
    this.cursor_ = document.createElement('div');

    /**
     * @type {HTMLElement}
     * @access private
     */
    this.focused_ = null;

    /**
     * @type {boolean}
     * @access private
     */
    this.freezed_ = false;

    /**
     * @type {string}
     * @access private
     */
    this.viewHeight_ = window.innerHeight;

    /**
     * @type {MutationObserver}
     * @access private
     */
    this.observer_ = null;

    /**
     * @type {Object[]}
     * @access private
     */
    this.eventListeners_ = [];

    this.onTransitionStart_ = this.onTransitionStart_.bind(this);
    this.onTransitionEnd_ = this.onTransitionEnd_.bind(this);
  }

  /**
   * Start the manager
   */
  start()/*: void*/ {
    this.styleCursor_();
    this.appendToRoot_();
    this.observeFocus_();
    this.listenToCursorEvents_();
    this.focus();
  }

  /**
   * Resume the manager
   */
  resume()/*: void*/ {
    this.freezed_ = false;
    this.focus();
  }

  /**
   * Stop the manager
   */
  stop()/*: void*/ {
    this.removeFromRoot_();
    this.disconnectFocus_();
  }

  /**
   * Freeze the cursor
   */
  freeze() {
    this.freezed_ = true;
  }

  /**
   * Focus on the element with the cursor
   */
  focus()/*: void*/ {
    if (!this.focused_ || this.cursor_.style.display === 'none') {
      this.place();
    } else {
      this.move();
    }
  }

  /**
   * Place the cursor
   */
  place()/*: void*/ {
    const focused = isInDocumentBody(this.focused_) ? this.focused_ : document.querySelector(`.${ this.focusClassName_ }`);
    if (!focused) {
      this.hideCursor_();
      return;
    }

    const origTransitionDuration = this.cursor_.style.transitionDuration;
    this.cursor_.style.transitionDuration = '0';
    const r = getAbsoluteElementRect(focused);
    if (focused.dataset.sncTracked !== 'false') this.scrollIntoView_(r);
    this.resizeCursorTo_(r);
    this.moveCursorTo_(r);
    if (!focused.classList.contains(this.focusClassName_)) {
      this.hideCursor_();
    } else {
      this.updateCursorModifier_();
      this.showCursor_();
    }
    this.cursor_.style.transitionDuration = origTransitionDuration;
  }

  /**
   * Move the cursor
   */
  move()/*: void*/ {
    const focused = isInDocumentBody(this.focused_) ? this.focused_ : document.querySelector(`.${ this.focusClassName_ }`);
    if (!focused || !focused.classList.contains(this.focusClassName_)) {
      this.hideCursor_();
      return;
    } else {
      this.showCursor_();
    }

    const r = getAbsoluteElementRect(focused);
    if (focused.dataset.sncTracked !== 'false') this.scrollIntoView_(r);
    this.resizeCursorTo_(r);
    this.moveCursorTo_(r);
    this.updateCursorModifier_();
  }

  /**
   * Resize the cursor to the dimension of the focused element
   */
  resize() {
    const focused = this.focused_;
    if (!focused) return;
    const { width, height } = getElementDimension(focused);
    this.resizeCursorTo_({ width, height, top: 0, left: 0 });
  }

  /**
   * Get the cursor element
   * @return {HTMLElement} the cursor element
   */
  getCursor()/*: HTMLElement*/ {
    return this.cursor_;
  }

  /**
   * Add an event listener
   * @return {HTMLElement} the focused element
   */
  getFocusedElement()/*: ?HTMLElement*/ {
    return this.focused_;
  }

  /**
   * Add an event listener
   * @param {string} type an event type
   * @param {Function} listener an evnet handler 
   */
  addEventListener(type/*: string*/, listener/*: EventHandler*/) {
    this.eventListeners_.push({ type, listener });
  }

  /**
   * Remove an event listener
   * @param {string} type an event type
   * @param {Function} listener an evnet handler 
   */
  removeEventListener(type/*: string*/, listener/*: EventHandler*/) {
    const idx = this.eventListeners_.findIndex(l => l.type === type && l.listener === listener);
    if (~idx) {
      this.eventListeners_.splice(idx, 1);
    }
  }

  /**
   * Scroll into view of destination rect
   * @access private
   * @param {cursorManager$Rect} rect the destination rect
   * @param {number} rect.top
   * @param {number} rect.left
   * @param {number} rect.width
   * @param {number} rect.height
   */
  scrollIntoView_(rect/*: cursorManager$Rect*/)/*: void*/ {
    const left = window.pageXOffset;
    window.scroll({
      left,
      top: rect.top - (window.innerHeight * 0.5) + (rect.height * 0.5),
      behavior: 'smooth',
    });
  }

  /**
   * Move the cursor to the destination rect
   * @access private
   * @param {cursorManager$Rect} rect the destination rect to scroll to
   * @param {number} rect.top
   * @param {number} rect.left
   * @param {number} rect.width
   * @param {number} rect.height
   */
  moveCursorTo_(rect/*: cursorManager$Rect*/)/*: void*/ {
    const style = getComputedStyle(this.cursor_, '');
    const borserTop = parseInt(style.borderTopWidth, 10);
    const borserLeft = parseInt(style.borderLeftWidth, 10);
    this.cursor_.style.transform = `translate3d(${ rect.left - borserLeft }px, ${ rect.top - borserTop }px, 0)`;
  }

  /**
   * Move the cursor to the destination rect
   * @access private
   * @param {cursorManager$Rect} rect the destination rect to scroll to
   * @param {number} rect.top
   * @param {number} rect.left
   * @param {number} rect.width
   * @param {number} rect.height
   */
  resizeCursorTo_(rect/*: cursorManager$Rect*/)/*: void*/ {
    this.cursor_.style.width = `${ rect.width }px`;
    this.cursor_.style.height = `${ rect.height }px`;
  }

  /**
   * Style the cursor
   * @access private
   */
  styleCursor_()/*: void*/ {
    this.cursor_.classList.add('__spatial-navigation-cursor__');
    this.cursor_.style.position = 'absolute';
    this.cursor_.style.top = '0';
    this.cursor_.style.left = '0';
    this.hideCursor_();
  }

  /**
   * Listen to the cursor's event
   * @access private
   */
  listenToCursorEvents_() {
    this.cursor_.addEventListener('transitionend', this.onTransitionStart_);
  }

  /**
   * Unlisten to the cursor's event
   * @access private
   */
  unlistenToCursorEvents_() {
    this.cursor_.removeEventListener('transitionend', this.onTransitionEnd_);
  }

  /**
   * Update the cursor's modifier
   * @access private
   */
  updateCursorModifier_() {
    if (!this.focused_) return;
    const old = this.cursor_.dataset.sncModifier || '';
    const now = this.focused_.dataset.sncModifier || '';
    if (now === old) return;
    this.cursor_.dataset.sncModifier = now;
  }

  /**
   * Hide the cursor
   * @access private
   */
  hideCursor_()/*: void*/ {
    this.cursor_.style.display = 'none';
  }

  /**
   * Show the cursor
   * @access private
   */
  showCursor_()/*: void*/ {
    this.cursor_.style.display = '';
  }

  /**
   * Append the cursor to the root
   * @access private
   */
  appendToRoot_()/*: void*/ {
    this.root_.appendChild(this.cursor_);
  }

  /**
   * Append the cursor to the root
   * @access private
   */
  removeFromRoot_()/*: void*/ {
    this.root_.removeChild(this.cursor_);
  }

  /**
   * Observe the change of focused elements
   * @access private
   */
  observeFocus_() {
    const observer = this.observer_ = new MutationObserver((mutationRecords) => {
      var target;
      for (let mutation of mutationRecords) {
        if ( (mutation.target/*: any*/).classList.contains(this.focusClassName_) ) {
          target = mutation.target;
          break;
        }
      }
      if (target) {
        this.focused_ = ((target/*: any*/)/*: HTMLElement*/);
        if (!this.freezed_) this.focus();
        this.trigger_(CursorManager.Events.FOCUS_UPDATED, new CustomEvent(CursorManager.Events.FOCUS_UPDATED, {
          detail: { target },
        }));
      } else if ( !this.checkFocusedElementExistence_() ) {
        this.hideCursor_();
      }
    });
    observer.observe(this.root_, {
      attributeFilter: [ "class" ],
      attributes: true,
      subtree: true,
    });
  }

  /**
   * Stop Observering the change of focused elements
   * @access private
   */
  disconnectFocus_()/*: void*/ {
    if (!this.observer_) return;
    this.observer_.disconnect();
  }

  /**
   * Trigger an event
   * @access private
   * @param {string} type an event type
   * @param {...*} args an event type
   */
  trigger_(type/*: string*/, ...args/*: any[]*/)/*: void*/ {
    this.eventListeners_
      .filter(l => l.type === type)
      .forEach(l => l.listener(...args));
  }

  /**
   * Check if the current focused element exists
   * @access private
   * @return {boolean} whether the focused element exists
   */
  checkFocusedElementExistence_()/*: boolean*/ {
    return (
      !!this.focused_ &&
      this.focused_.classList.contains(this.focusClassName_) &&
      isInDocumentBody(this.focused_)
    );
  }

  /**
   * Event handler fro transition start
   * @access private
   * @param {Event} e an event
   * @return {*} any value
   */
  onTransitionStart_(e/*: Event*/)/*: mixed*/ {
    this.trigger_(CursorManager.Events.CURSOR_TRANSITIONSTART, e);
  }

  /**
   * Event handler fro transition end
   * @access private
   * @param {Event} e an event
   * @return {*} any value
   */
  onTransitionEnd_(e/*: Event*/)/*: mixed*/ {
    this.trigger_(CursorManager.Events.CURSOR_TRANSITIONEND, e);
  }
}

CursorManager.Events = {
  FOCUS_UPDATED: 'focusUpdated',
  CURSOR_TRANSITIONSTART: 'cursorTransitionstart',
  CURSOR_TRANSITIONEND: 'cursorTransitionend',
};

/**
 * Get an element rect with its absolute location
 * @param {HTMLElement} elem an HTML element
 * @return {cursorManager$Rect} the element rect with its absolute location
 */
function getAbsoluteElementRect(elem/*: HTMLElement*/)/* cursorManager$Rect*/ {
  const r = elem.getBoundingClientRect();
  return { left: r.left + window.pageXOffset, top: r.top + window.pageYOffset, width: r.width, height: r.height };
}

/**
 * Check if an element is in the document body
 * @param {HTMLElement} element an HTML element
 * @return {boolean} whether the element is the document body
 */
function isInDocumentBody(element/*: ?HTMLElement*/)/*: boolean*/ {
  if (!element) return false;
  if (element === document.body) return true;
  if (getComputedStyle(element).position === 'fixed') {
    return isInDocumentBody((element.parentElement/*: any*/));
  } else if (!element.offsetParent) return false;
  return isInDocumentBody((element.offsetParent/*: any*/));
}

/**
 * Get an element's dimension
 * @param {HTMLElement} element an HTML element
 * @return {Object} the dimension
 */
function getElementDimension(element/*: HTMLElement*/)/*: Dimension*/ {
  const style = getComputedStyle(element);
  const w = parseInt(style.width, 10);
  const h = parseInt(style.height, 10);
  const l = parseInt(style.paddingLeft, 10);
  const r = parseInt(style.paddingRight, 10);
  const t = parseInt(style.paddingTop, 10);
  const b = parseInt(style.paddingBottom, 10);
  const width = w + l + r;
  const height = h + t + b;
  return { width, height };
}