Chris@0: /** Chris@0: * @file Chris@0: * Sticky table headers. Chris@0: */ Chris@0: Chris@17: (function($, Drupal, displace) { Chris@0: /** Chris@0: * Constructor for the tableHeader object. Provides sticky table headers. Chris@0: * Chris@0: * TableHeader will make the current table header stick to the top of the page Chris@0: * if the table is very long. Chris@0: * Chris@0: * @constructor Drupal.TableHeader Chris@0: * Chris@0: * @param {HTMLElement} table Chris@0: * DOM object for the table to add a sticky header to. Chris@0: * Chris@0: * @listens event:columnschange Chris@0: */ Chris@0: function TableHeader(table) { Chris@0: const $table = $(table); Chris@0: Chris@0: /** Chris@0: * @name Drupal.TableHeader#$originalTable Chris@0: * Chris@0: * @type {HTMLElement} Chris@0: */ Chris@0: this.$originalTable = $table; Chris@0: Chris@0: /** Chris@0: * @type {jQuery} Chris@0: */ Chris@0: this.$originalHeader = $table.children('thead'); Chris@0: Chris@0: /** Chris@0: * @type {jQuery} Chris@0: */ Chris@0: this.$originalHeaderCells = this.$originalHeader.find('> tr > th'); Chris@0: Chris@0: /** Chris@0: * @type {null|bool} Chris@0: */ Chris@0: this.displayWeight = null; Chris@0: this.$originalTable.addClass('sticky-table'); Chris@0: this.tableHeight = $table[0].clientHeight; Chris@0: this.tableOffset = this.$originalTable.offset(); Chris@0: Chris@0: // React to columns change to avoid making checks in the scroll callback. Chris@17: this.$originalTable.on( Chris@17: 'columnschange', Chris@17: { tableHeader: this }, Chris@17: (e, display) => { Chris@17: const tableHeader = e.data.tableHeader; Chris@17: if ( Chris@17: tableHeader.displayWeight === null || Chris@17: tableHeader.displayWeight !== display Chris@17: ) { Chris@17: tableHeader.recalculateSticky(); Chris@17: } Chris@17: tableHeader.displayWeight = display; Chris@17: }, Chris@17: ); Chris@0: Chris@0: // Create and display sticky header. Chris@0: this.createSticky(); Chris@0: } Chris@0: Chris@17: // Helper method to loop through tables and execute a method. Chris@17: function forTables(method, arg) { Chris@17: const tables = TableHeader.tables; Chris@17: const il = tables.length; Chris@17: for (let i = 0; i < il; i++) { Chris@17: tables[i][method](arg); Chris@17: } Chris@17: } Chris@17: Chris@17: // Select and initialize sticky table headers. Chris@17: function tableHeaderInitHandler(e) { Chris@17: const $tables = $(e.data.context) Chris@17: .find('table.sticky-enabled') Chris@17: .once('tableheader'); Chris@17: const il = $tables.length; Chris@17: for (let i = 0; i < il; i++) { Chris@17: TableHeader.tables.push(new TableHeader($tables[i])); Chris@17: } Chris@17: forTables('onScroll'); Chris@17: } Chris@17: Chris@17: /** Chris@17: * Attaches sticky table headers. Chris@17: * Chris@17: * @type {Drupal~behavior} Chris@17: * Chris@17: * @prop {Drupal~behaviorAttach} attach Chris@17: * Attaches the sticky table header behavior. Chris@17: */ Chris@17: Drupal.behaviors.tableHeader = { Chris@17: attach(context) { Chris@17: $(window).one( Chris@17: 'scroll.TableHeaderInit', Chris@17: { context }, Chris@17: tableHeaderInitHandler, Chris@17: ); Chris@17: }, Chris@17: }; Chris@17: Chris@17: function scrollValue(position) { Chris@17: return document.documentElement[position] || document.body[position]; Chris@17: } Chris@17: Chris@17: function tableHeaderResizeHandler(e) { Chris@17: forTables('recalculateSticky'); Chris@17: } Chris@17: Chris@17: function tableHeaderOnScrollHandler(e) { Chris@17: forTables('onScroll'); Chris@17: } Chris@17: Chris@17: function tableHeaderOffsetChangeHandler(e, offsets) { Chris@17: forTables('stickyPosition', offsets.top); Chris@17: } Chris@17: Chris@17: // Bind event that need to change all tables. Chris@17: $(window).on({ Chris@17: /** Chris@17: * When resizing table width can change, recalculate everything. Chris@17: * Chris@17: * @ignore Chris@17: */ Chris@17: 'resize.TableHeader': tableHeaderResizeHandler, Chris@17: Chris@17: /** Chris@17: * Bind only one event to take care of calling all scroll callbacks. Chris@17: * Chris@17: * @ignore Chris@17: */ Chris@17: 'scroll.TableHeader': tableHeaderOnScrollHandler, Chris@17: }); Chris@17: // Bind to custom Drupal events. Chris@17: $(document).on({ Chris@17: /** Chris@17: * Recalculate columns width when window is resized and when show/hide Chris@17: * weight is triggered. Chris@17: * Chris@17: * @ignore Chris@17: */ Chris@17: 'columnschange.TableHeader': tableHeaderResizeHandler, Chris@17: Chris@17: /** Chris@17: * Recalculate TableHeader.topOffset when viewport is resized. Chris@17: * Chris@17: * @ignore Chris@17: */ Chris@17: 'drupalViewportOffsetChange.TableHeader': tableHeaderOffsetChangeHandler, Chris@17: }); Chris@17: Chris@0: /** Chris@0: * Store the state of TableHeader. Chris@0: */ Chris@17: $.extend( Chris@17: TableHeader, Chris@17: /** @lends Drupal.TableHeader */ { Chris@17: /** Chris@17: * This will store the state of all processed tables. Chris@17: * Chris@17: * @type {Array.} Chris@17: */ Chris@17: tables: [], Chris@17: }, Chris@17: ); Chris@0: Chris@0: /** Chris@0: * Extend TableHeader prototype. Chris@0: */ Chris@17: $.extend( Chris@17: TableHeader.prototype, Chris@17: /** @lends Drupal.TableHeader# */ { Chris@17: /** Chris@17: * Minimum height in pixels for the table to have a sticky header. Chris@17: * Chris@17: * @type {number} Chris@17: */ Chris@17: minHeight: 100, Chris@0: Chris@17: /** Chris@17: * Absolute position of the table on the page. Chris@17: * Chris@17: * @type {?Drupal~displaceOffset} Chris@17: */ Chris@17: tableOffset: null, Chris@0: Chris@17: /** Chris@17: * Absolute position of the table on the page. Chris@17: * Chris@17: * @type {?number} Chris@17: */ Chris@17: tableHeight: null, Chris@0: Chris@17: /** Chris@17: * Boolean storing the sticky header visibility state. Chris@17: * Chris@17: * @type {bool} Chris@17: */ Chris@17: stickyVisible: false, Chris@0: Chris@17: /** Chris@17: * Create the duplicate header. Chris@17: */ Chris@17: createSticky() { Chris@17: // Clone the table header so it inherits original jQuery properties. Chris@17: const $stickyHeader = this.$originalHeader.clone(true); Chris@17: // Hide the table to avoid a flash of the header clone upon page load. Chris@17: this.$stickyTable = $('') Chris@17: .css({ Chris@17: visibility: 'hidden', Chris@17: position: 'fixed', Chris@17: top: '0px', Chris@17: }) Chris@17: .append($stickyHeader) Chris@17: .insertBefore(this.$originalTable); Chris@0: Chris@17: this.$stickyHeaderCells = $stickyHeader.find('> tr > th'); Chris@0: Chris@17: // Initialize all computations. Chris@17: this.recalculateSticky(); Chris@17: }, Chris@0: Chris@17: /** Chris@17: * Set absolute position of sticky. Chris@17: * Chris@17: * @param {number} offsetTop Chris@17: * The top offset for the sticky header. Chris@17: * @param {number} offsetLeft Chris@17: * The left offset for the sticky header. Chris@17: * Chris@17: * @return {jQuery} Chris@17: * The sticky table as a jQuery collection. Chris@17: */ Chris@17: stickyPosition(offsetTop, offsetLeft) { Chris@17: const css = {}; Chris@17: if (typeof offsetTop === 'number') { Chris@17: css.top = `${offsetTop}px`; Chris@17: } Chris@17: if (typeof offsetLeft === 'number') { Chris@17: css.left = `${this.tableOffset.left - offsetLeft}px`; Chris@17: } Chris@17: return this.$stickyTable.css(css); Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Returns true if sticky is currently visible. Chris@17: * Chris@17: * @return {bool} Chris@17: * The visibility status. Chris@17: */ Chris@17: checkStickyVisible() { Chris@17: const scrollTop = scrollValue('scrollTop'); Chris@17: const tableTop = this.tableOffset.top - displace.offsets.top; Chris@17: const tableBottom = tableTop + this.tableHeight; Chris@17: let visible = false; Chris@17: Chris@17: if (tableTop < scrollTop && scrollTop < tableBottom - this.minHeight) { Chris@17: visible = true; Chris@17: } Chris@17: Chris@17: this.stickyVisible = visible; Chris@17: return visible; Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Check if sticky header should be displayed. Chris@17: * Chris@17: * This function is throttled to once every 250ms to avoid unnecessary Chris@17: * calls. Chris@17: * Chris@17: * @param {jQuery.Event} e Chris@17: * The scroll event. Chris@17: */ Chris@17: onScroll(e) { Chris@17: this.checkStickyVisible(); Chris@17: // Track horizontal positioning relative to the viewport. Chris@17: this.stickyPosition(null, scrollValue('scrollLeft')); Chris@17: this.$stickyTable.css( Chris@17: 'visibility', Chris@17: this.stickyVisible ? 'visible' : 'hidden', Chris@17: ); Chris@17: }, Chris@17: Chris@17: /** Chris@17: * Event handler: recalculates position of the sticky table header. Chris@17: * Chris@17: * @param {jQuery.Event} event Chris@17: * Event being triggered. Chris@17: */ Chris@17: recalculateSticky(event) { Chris@17: // Update table size. Chris@17: this.tableHeight = this.$originalTable[0].clientHeight; Chris@17: Chris@17: // Update offset top. Chris@17: displace.offsets.top = displace.calculateOffset('top'); Chris@17: this.tableOffset = this.$originalTable.offset(); Chris@17: this.stickyPosition(displace.offsets.top, scrollValue('scrollLeft')); Chris@17: Chris@17: // Update columns width. Chris@17: let $that = null; Chris@17: let $stickyCell = null; Chris@17: let display = null; Chris@17: // Resize header and its cell widths. Chris@17: // Only apply width to visible table cells. This prevents the header from Chris@17: // displaying incorrectly when the sticky header is no longer visible. Chris@17: const il = this.$originalHeaderCells.length; Chris@17: for (let i = 0; i < il; i++) { Chris@17: $that = $(this.$originalHeaderCells[i]); Chris@17: $stickyCell = this.$stickyHeaderCells.eq($that.index()); Chris@17: display = $that.css('display'); Chris@17: if (display !== 'none') { Chris@17: $stickyCell.css({ width: $that.css('width'), display }); Chris@17: } else { Chris@17: $stickyCell.css('display', 'none'); Chris@17: } Chris@17: } Chris@17: this.$stickyTable.css('width', this.$originalTable.outerWidth()); Chris@17: }, Chris@0: }, Chris@17: ); Chris@0: Chris@0: // Expose constructor in the public space. Chris@0: Drupal.TableHeader = TableHeader; Chris@17: })(jQuery, Drupal, window.Drupal.displace);