/**
* @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|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();
}
// 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) {
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');
}
/**
* 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 {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"></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`;
}
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.Drupal.displace);