--- /dev/null
+/**
+ * @file
+ * Sticky table headers.
+ */
+
+(function ($, Drupal, displace) {
+ /**
+ * 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];
+ }
+
+ // Select and initialize sticky table headers.
+ function tableHeaderInitHandler(e) {
+ const $tables = $(e.data.context).find('table.sticky-enabled').once('tableheader');
+ const il = $tables.length;
+ for (let i = 0; i < il; i++) {
+ TableHeader.tables.push(new TableHeader($tables[i]));
+ }
+ forTables('onScroll');
+ }
+
+ // 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);
+ }
+ }
+
+ 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 and when show/hide
+ * weight is triggered.
+ *
+ * @ignore
+ */
+ 'columnschange.TableHeader': tableHeaderResizeHandler,
+
+ /**
+ * Recalculate TableHeader.topOffset when viewport is resized.
+ *
+ * @ignore
+ */
+ 'drupalViewportOffsetChange.TableHeader': tableHeaderOffsetChangeHandler,
+ });
+
+ /**
+ * 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|bool}
+ */
+ 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();
+ }
+
+ /**
+ * 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 {bool}
+ */
+ stickyVisible: false,
+
+ /**
+ * Create the duplicate header.
+ */
+ createSticky() {
+ // 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"/>')
+ .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`;
+ }
+ return this.$stickyTable.css(css);
+ },
+
+ /**
+ * Returns true if sticky is currently visible.
+ *
+ * @return {bool}
+ * 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.parent.Drupal.displace));