tableheader.js

/**
 * @file
 * Sticky table headers.
 */

(function ($, Drupal, displace) {
  /**
   * Constructor for the tableHeader object. Provides sticky table headers.
   *
   * TableHeader will make the current table header stick to the top of the page
   * if the table is very long.
   *
   * @constructor Drupal.TableHeader
   *
   * @param {HTMLElement} table
   *   DOM object for the table to add a sticky header to.
   *
   * @listens event:columnschange
   */
  function TableHeader(table) {
    const $table = $(table);

    /**
     * @name Drupal.TableHeader#$originalTable
     *
     * @type {HTMLElement}
     */
    this.$originalTable = $table;

    /**
     * @type {jQuery}
     */
    this.$originalHeader = $table.children('thead');

    /**
     * @type {jQuery}
     */
    this.$originalHeaderCells = this.$originalHeader.find('> tr > th');

    /**
     * @type {null|boolean}
     */
    this.displayWeight = null;
    this.$originalTable.addClass('sticky-table');
    this.tableHeight = $table[0].clientHeight;
    this.tableOffset = this.$originalTable.offset();

    // React to columns change to avoid making checks in the scroll callback.
    this.$originalTable.on(
      'columnschange',
      { tableHeader: this },
      (e, display) => {
        const tableHeader = e.data.tableHeader;
        if (
          tableHeader.displayWeight === null ||
          tableHeader.displayWeight !== display
        ) {
          tableHeader.recalculateSticky();
        }
        tableHeader.displayWeight = display;
      },
    );

    // Create and display sticky header.
    this.createSticky();
  }

  // Helper method to loop through tables and execute a method.
  function forTables(method, arg) {
    const tables = TableHeader.tables;
    const il = tables.length;
    for (let i = 0; i < il; i++) {
      tables[i][method](arg);
    }
  }

  // Select and initialize sticky table headers.
  function tableHeaderInitHandler(e) {
    once('tableheader', $(e.data.context).find('table.sticky-enabled')).forEach(
      (table) => {
        TableHeader.tables.push(new TableHeader(table));
      },
    );
    forTables('onScroll');
  }

  /**
   * Attaches sticky table headers.
   *
   * @type {Drupal~behavior}
   *
   * @prop {Drupal~behaviorAttach} attach
   *   Attaches the sticky table header behavior.
   */
  Drupal.behaviors.tableHeader = {
    attach(context) {
      $(window).one(
        'scroll.TableHeaderInit',
        { context },
        tableHeaderInitHandler,
      );
    },
  };

  function scrollValue(position) {
    return document.documentElement[position] || document.body[position];
  }

  function tableHeaderResizeHandler(e) {
    forTables('recalculateSticky');
  }

  function tableHeaderOnScrollHandler(e) {
    forTables('onScroll');
  }

  function tableHeaderOffsetChangeHandler(e, offsets) {
    forTables('stickyPosition', offsets.top);
  }

  // Bind event that need to change all tables.
  $(window).on({
    /**
     * When resizing table width can change, recalculate everything.
     *
     * @ignore
     */
    'resize.TableHeader': tableHeaderResizeHandler,

    /**
     * Bind only one event to take care of calling all scroll callbacks.
     *
     * @ignore
     */
    'scroll.TableHeader': tableHeaderOnScrollHandler,
  });
  // Bind to custom Drupal events.
  $(document).on({
    /**
     * Recalculate columns width when window is resized, when show/hide weight
     * is triggered, or when toolbar tray is toggled.
     *
     * @ignore
     */
    'columnschange.TableHeader drupalToolbarTrayChange':
      tableHeaderResizeHandler,

    /**
     * Recalculate TableHeader.topOffset when viewport is resized.
     *
     * @ignore
     */
    'drupalViewportOffsetChange.TableHeader': tableHeaderOffsetChangeHandler,
  });

  /**
   * Store the state of TableHeader.
   */
  $.extend(
    TableHeader,
    /** @lends Drupal.TableHeader */ {
      /**
       * This will store the state of all processed tables.
       *
       * @type {Array.<Drupal.TableHeader>}
       */
      tables: [],
    },
  );

  /**
   * Extend TableHeader prototype.
   */
  $.extend(
    TableHeader.prototype,
    /** @lends Drupal.TableHeader# */ {
      /**
       * Minimum height in pixels for the table to have a sticky header.
       *
       * @type {number}
       */
      minHeight: 100,

      /**
       * Absolute position of the table on the page.
       *
       * @type {?Drupal~displaceOffset}
       */
      tableOffset: null,

      /**
       * Absolute position of the table on the page.
       *
       * @type {?number}
       */
      tableHeight: null,

      /**
       * Boolean storing the sticky header visibility state.
       *
       * @type {boolean}
       */
      stickyVisible: false,

      /**
       * Create the duplicate header.
       */
      createSticky() {
        // For caching purposes.
        this.$html = $('html');
        // Clone the table header so it inherits original jQuery properties.
        const $stickyHeader = this.$originalHeader.clone(true);
        // Hide the table to avoid a flash of the header clone upon page load.
        this.$stickyTable = $('<table class="sticky-header"></table>')
          .css({
            visibility: 'hidden',
            position: 'fixed',
            top: '0px',
          })
          .append($stickyHeader)
          .insertBefore(this.$originalTable);

        this.$stickyHeaderCells = $stickyHeader.find('> tr > th');

        // Initialize all computations.
        this.recalculateSticky();
      },

      /**
       * Set absolute position of sticky.
       *
       * @param {number} offsetTop
       *   The top offset for the sticky header.
       * @param {number} offsetLeft
       *   The left offset for the sticky header.
       *
       * @return {jQuery}
       *   The sticky table as a jQuery collection.
       */
      stickyPosition(offsetTop, offsetLeft) {
        const css = {};
        if (typeof offsetTop === 'number') {
          css.top = `${offsetTop}px`;
        }
        if (typeof offsetLeft === 'number') {
          css.left = `${this.tableOffset.left - offsetLeft}px`;
        }
        this.$html.css(
          'scroll-padding-top',
          displace.offsets.top +
            (this.stickyVisible ? this.$stickyTable.height() : 0),
        );
        return this.$stickyTable.css(css);
      },

      /**
       * Returns true if sticky is currently visible.
       *
       * @return {boolean}
       *   The visibility status.
       */
      checkStickyVisible() {
        const scrollTop = scrollValue('scrollTop');
        const tableTop = this.tableOffset.top - displace.offsets.top;
        const tableBottom = tableTop + this.tableHeight;
        let visible = false;

        if (tableTop < scrollTop && scrollTop < tableBottom - this.minHeight) {
          visible = true;
        }

        this.stickyVisible = visible;
        return visible;
      },

      /**
       * Check if sticky header should be displayed.
       *
       * This function is throttled to once every 250ms to avoid unnecessary
       * calls.
       *
       * @param {jQuery.Event} e
       *   The scroll event.
       */
      onScroll(e) {
        this.checkStickyVisible();
        // Track horizontal positioning relative to the viewport.
        this.stickyPosition(null, scrollValue('scrollLeft'));
        this.$stickyTable.css(
          'visibility',
          this.stickyVisible ? 'visible' : 'hidden',
        );
      },

      /**
       * Event handler: recalculates position of the sticky table header.
       *
       * @param {jQuery.Event} event
       *   Event being triggered.
       */
      recalculateSticky(event) {
        // Update table size.
        this.tableHeight = this.$originalTable[0].clientHeight;

        // Update offset top.
        displace.offsets.top = displace.calculateOffset('top');
        this.tableOffset = this.$originalTable.offset();
        this.stickyPosition(displace.offsets.top, scrollValue('scrollLeft'));

        // Update columns width.
        let $that = null;
        let $stickyCell = null;
        let display = null;
        // Resize header and its cell widths.
        // Only apply width to visible table cells. This prevents the header from
        // displaying incorrectly when the sticky header is no longer visible.
        const il = this.$originalHeaderCells.length;
        for (let i = 0; i < il; i++) {
          $that = $(this.$originalHeaderCells[i]);
          $stickyCell = this.$stickyHeaderCells.eq($that.index());
          display = $that.css('display');
          if (display !== 'none') {
            $stickyCell.css({ width: $that.css('width'), display });
          } else {
            $stickyCell.css('display', 'none');
          }
        }
        this.$stickyTable.css('width', this.$originalTable.outerWidth());
      },
    },
  );

  // Expose constructor in the public space.
  Drupal.TableHeader = TableHeader;
})(jQuery, Drupal, window.Drupal.displace);