UNPKG

vanilla-match-height

Version:
508 lines (451 loc) 21 kB
/** * vanilla-match-height v2.0.0 by @mitera * Simone Miterangelis <simone@mite.it> * License: MIT */ interface HTMLElement { matchHeight(settings?: Settings): MatchHeight; } interface MatchHeight { wrapEl: HTMLElement; settings: Settings; _bind(): void; _merge(o1: Settings, o2: Settings): Settings; _init(): void; _unbind(): void; _throttle(fn: Function, threshold: number): () => void; _applyAll(): void; _validateProperty(value?: string | null): RegExpMatchArray | null; _parse(value: string): number; _rows(elements: HTMLElement[]): HTMLElement[][]; _applyDataApi(property: string): void; _remove(): void; _apply(): void; _resetStyle($that: HTMLElement, property: string): void; } interface Settings { elements?: string | null; byRow?: boolean | null; target?: HTMLElement | null; attributeName?: string | null; attributeValue?: string | null; property?: string | null; remove?: HTMLElement | null; events?: boolean | null; throttle?: number | null; beforeUpdate?: any | null; afterUpdate?: any | null; } (function(){ /** * Extends the HTMLElement prototype with a method named `matchHeight`. * * This method adjusts the height of the element to match the height of the tallest element in a collection of associated elements. * * The elements to "match height" with are typically determined by the context in which the method is invoked, * such as sibling elements or elements in the same container. * * It ensures consistent heights for elements in flexible layouts, improving visual alignment and design structure. * * Note: This method does not remove or reset inline height styles. Use caution to avoid conflicts with pre-existing styles. */ HTMLElement.prototype.matchHeight = function(settings?: Settings) { // @ts-ignore return new MatchHeight(this, settings); } /** * Constructs a new MatchHeight instance. * * @param {HTMLElement} wrapEl - The parent element within which child elements will have their heights matched. * @param {Settings} settings - An object containing configuration options for the MatchHeight instance, such as elements to select, row-type matching, and event handling. * @return {void} This function does not return a value; it initializes a new instance of the MatchHeight class. */ function MatchHeight(this: MatchHeight, wrapEl: HTMLElement, settings: Settings) { this.wrapEl = wrapEl; // Default settings let default_settings: Settings = { elements: null, byRow: true, target: null, attributeName: null, attributeValue: null, property: 'height', remove: null, events: true, throttle: 80, beforeUpdate: null, afterUpdate: null } this.settings = {...default_settings, ...settings} as Settings; if (!this._validateProperty(this.settings.property)) { this.settings.property = 'height'; } if (this.settings.events) { const update = this._applyAll.bind(this); if (document.readyState !== 'loading') { this._applyAll(); } else { document.addEventListener( 'DOMContentLoaded', update, { once: true } ); } if (this.settings.throttle && this.settings.throttle > 0) { this._bind = this._throttle(update, this.settings.throttle); } this._init(); } } /** * Initializes the MatchHeight instance by setting up necessary * configurations or bindings to ensure the height matching functionality. * This method is typically used internally during the setup process. * * @private */ MatchHeight.prototype._init = function() { window.addEventListener("resize", this._bind); window.addEventListener("orientationchange", this._bind); } /** * Unbinds all MatchHeight event listeners from the target elements. * * This method removes all associated events such as resize listeners or scroll listeners * that were previously bound by the MatchHeight functionality. It ensures that the * target elements are no longer affected by the MatchHeight behavior, preventing * further updates or recalculations. * * Use this method when you no longer need MatchHeight to handle the height equalization * of the elements, or before completely removing the associated DOM elements from the page. */ MatchHeight.prototype._unbind = function() { window.removeEventListener("resize", this._bind); window.removeEventListener("orientationchange", this._bind); } /** * Merges the specified elements' heights into a single maximum height * value. This method adjusts the heights of the elements it processes * to ensure uniformity across the group. * * @private * @param {HTMLElement[]} elements - An array of DOM elements whose heights are to be merged. * @param {number[]} heights - An array of heights corresponding to the elements. * @return {void} */ MatchHeight.prototype._merge = function(o1: any, o2: any) { if (o1 != null) { for (let i in o1) { o2[i] = o1[i]; } } return o2; } /** * A utility function designed to limit the rate at which a given function is executed. * This method ensures that the specified function is triggered at most once within * a defined time period determined by the provided delay duration. * * @param {Function} callback - The function to be throttled. It will only execute * once per delay interval regardless of how often it is invoked. * @param {number} delay - The time interval, specified in milliseconds, during * which repeated function calls will be ignored after the initial execution. * @returns {Function} - A new function that wraps the original callback and enforces * the throttle behavior, allowing controlled execution within the given delay. */ MatchHeight.prototype._throttle = function(fn: Function, threshold: number) { let last: number, deferTimer: any; return function () { const now = Date.now(); if (last && now < last + threshold) { clearTimeout(deferTimer); deferTimer = setTimeout(function () { last = now; fn(); }, threshold); } else { last = now; fn(); } }; } /** * Applies the height matching functionality to all elements managed by the MatchHeight instance. * * This method calculates and sets the maximum height for each group of elements * that share the same context, ensuring a uniform height across the group. * * The function iterates through each group of elements, computes the tallest height * among them, and applies that height to all elements within the group. * * It is typically invoked to synchronize the heights of elements * after initialization or when elements are updated dynamically. */ MatchHeight.prototype._applyAll = function() { if (this.settings && this.settings.beforeUpdate) { this.settings.beforeUpdate(); } this._apply(); if (this.settings.attributeName && this._validateProperty(this.settings.attributeName)) { this._applyDataApi(this.settings.attributeName); } this._applyDataApi('data-match-height'); this._applyDataApi('data-mh'); if (this.settings && this.settings.afterUpdate) { this.settings.afterUpdate(); } } /** * Validates the given property to ensure it exists and is valid for use * in the MatchHeight functionality. This method checks if the property is * allowed and conforms to expected types or values for processing height * matching. * * @private * @param {string} property - The property to validate. * @returns {boolean} True if the property is valid, otherwise false. */ MatchHeight.prototype._validateProperty = function(value: string) { return String(value) .toLowerCase() .match( /^([a-z-]{2,})$/ ); } /** * Parses the elements that need to be matched for height. * This method iterates through a collection of elements, * converts their relevant height-related data into an array of objects, * and prepares the data for further processing. * * @private * @param {NodeList | HTMLElement[]} elements - A collection of DOM elements whose heights need to be matched. * @returns {Object[]} - An array of objects where each object contains the DOM element * and its calculated height details. */ MatchHeight.prototype._parse = function(value: string) { // parse value and convert NaN to 0 return parseFloat(value) || 0; } /** * An internal property used to store an array of rows within the MatchHeight instance. * Each row is represented by a group of DOM elements that should have their heights matched. * This property is dynamically populated and updated based on the current layout and grouping logic. * It plays a critical role in determining the height adjustments applied to the elements. * * @type {Array<Array<Element>>} * @private */ MatchHeight.prototype._rows = function(elements: HTMLElement[]) { let tolerance: number = 1, lastTop: number = -1, listRows: HTMLElement[][] = [], rows: HTMLElement[] = []; // group elements by their top position elements.forEach(($that) => { let top = $that.getBoundingClientRect().top - this._parse(window.getComputedStyle($that).getPropertyValue('margin-top')); // if the row top is the same, add to the row group if (lastTop != -1 && Math.floor(Math.abs(lastTop - top)) >= tolerance) { listRows.push(rows); rows = []; lastTop = -1; } rows.push($that); // keep track of the last row top lastTop = top; }); listRows.push(rows); return listRows; } /** * Applies the match height functionality to all elements * found in the DOM that use the `data-match-height` attribute. * The method selects such elements and applies the match * height logic to ensure consistent height across groups. * * This function is intended to be triggered automatically * and processes elements with matching data attributes, * grouping them by their attribute values and applying * equal height adjustments. * * @param {String} property */ MatchHeight.prototype._applyDataApi = function(property: string) { let $row: HTMLElement[] = Array.from(this.wrapEl.querySelectorAll('[' + property + ']')); // generate groups by their groupId set by elements using data-match-height $row.forEach(($el) => { let groupId = $el.getAttribute(property); this.settings = this._merge({attributeName: property, attributeValue: groupId}, this.settings); this._apply(); }); } /** * Removes the match height functionality from the elements. * * This function iterates through the selected elements and resets * their inline styles that were applied to set equal heights. * It ensures that the DOM elements revert to their original height styles. * * @private */ MatchHeight.prototype._remove = function() { let $elements: HTMLElement[] = [] let opts = this.settings; if (opts.elements) { $elements = Array.from(this.wrapEl.querySelectorAll(opts.elements)); } else { if (opts.attributeName && opts.attributeValue) { $elements = Array.from(this.wrapEl.querySelectorAll('[' + opts.attributeName + '="' + opts.attributeValue + '"]')); } } $elements.forEach((item) => { item.style.setProperty(opts.property, ''); if (item.getAttribute('style') === '') item.removeAttribute('style'); }); } /** * Applies the match height functionality to the set of elements. * This method calculates the maximum height among the group of elements * and adjusts each element's height to match the maximum. It ensures that all * selected elements within the context of the MatchHeight instance appear with * consistent height. * * This method takes into account any existing styles, box model properties, * such as padding and borders, to ensure accurate height calculations if needed. * * It is typically used internally to enforce height equality in the associated * elements. */ MatchHeight.prototype._apply = function() { let opts = this.settings; let $elements: HTMLElement[] = [] if (opts.elements && opts.elements.trim() != '') { $elements = Array.from(this.wrapEl.querySelectorAll(opts.elements)); } else { if (opts.attributeName && this._validateProperty(opts.attributeName) && opts.attributeValue && opts.attributeValue.trim() != '') { $elements = Array.from(this.wrapEl.querySelectorAll('[' + opts.attributeName + '="' + opts.attributeValue + '"]')); } } let rows: HTMLElement[][] = [$elements]; // get rows if using byRow, otherwise assume one row if (opts.byRow && !opts.target) { // must first force an arbitrary equal height so floating elements break evenly $elements.forEach(($that) => { let display = window.getComputedStyle($that).getPropertyValue('display'); // temporarily force a usable display value if (display && (display !== 'inline-block' && display !== 'flex' && display !== 'inline-flex')) { display = 'display: block; '; } // cache the original inline style $that.setAttribute('style-cache', $that.getAttribute('style') || ''); // reset style $that.setAttribute('style', display + 'padding-top: 0; padding-bottom: 0; margin-top: 0; margin-bottom: 0; border-top-width: 0; border-bottom-width: 0; height: 100px; overflow: hidden;'); }); // get the array of rows (based on element top position) rows = this._rows($elements); // revert original inline styles $elements.forEach(($that) => { $that.setAttribute('style', $that.getAttribute('style-cache') || ''); $that.removeAttribute('style-cache'); if ($that.getAttribute('style') === '') $that.removeAttribute('style'); }); } rows.forEach(($row) => { let targetHeight = 0; if (!opts.target) { // skip apply to rows with only one item if (opts.byRow && $row.length <= 1) { $row.forEach(($that) => { this._resetStyle($that, opts.property); }) return; } // iterate the row and find the max height $row.forEach(($that) => { let style = $that.getAttribute('style') || '', display = window.getComputedStyle($that).getPropertyValue('display'); // temporarily force a usable display value if (display && (display !== 'inline-block' && display !== 'flex' && display !== 'inline-flex')) { display = 'block'; } // ensure we get the correct actual height (and not a previously set height value) $that.setAttribute('style', 'display: ' + display + ';'); // find the max height (including padding, but not margin) let isTarget = true; if (opts.remove) { if (opts.remove instanceof NodeList) { opts.remove.forEach(($el: HTMLElement) => { if ($that === $el) { isTarget = false; } }); } else { if ($that === opts.remove) { isTarget = false; } } } if (isTarget) { if ($that.getBoundingClientRect().height > targetHeight) { targetHeight = $that.getBoundingClientRect().height; } } // revert styles if (style) { $that.setAttribute('style', style); } else { $that.style.setProperty('display', ''); } if ($that.getAttribute('style') === '') $that.removeAttribute('style'); }); } else { // if target set, use the height of the target element targetHeight = opts.target.getBoundingClientRect().height; } // iterate the row and apply the height to all elements $row.forEach(($that) => { let verticalPadding = 0; // don't apply to a target if (opts.target && $that === opts.target) { return; } // handle padding and border correctly (required when not using border-box) verticalPadding = this._parse(window.getComputedStyle($that).getPropertyValue('padding-top')) + this._parse(window.getComputedStyle($that).getPropertyValue('padding-bottom')) + this._parse(window.getComputedStyle($that).getPropertyValue('border-top-width')) + this._parse(window.getComputedStyle($that).getPropertyValue('border-bottom-width')); // set the height (accounting for padding and border) $that.style.setProperty(opts.property, (targetHeight - verticalPadding) + 'px'); if ($that.getBoundingClientRect().height < targetHeight) { $that.style.setProperty(opts.property, targetHeight + 'px'); } if (opts.remove) { if (opts.remove instanceof NodeList) { let removedItems: HTMLElement[] = Array.from(opts.remove); removedItems.forEach(($el: HTMLElement) => { if ($that === $el) { this._resetStyle($el, opts.property); } }); } else { if ($that === opts.remove) { this._resetStyle($that, opts.property); } } } }); }); } /** * Resets the inline styles applied to the elements previously adjusted * for matching height. This method ensures that the height-related * adjustments such as min-height, height, or padding are removed, * reverting the elements to their original styles. * * @param {HTMLElement} $that * @param {String} property */ MatchHeight.prototype._resetStyle = function($that: HTMLElement, property: string) { if (this._validateProperty(property)) { $that.style.setProperty(property, ''); if ($that.getAttribute('style') === '') $that.removeAttribute('style'); } } })();