vanilla-match-height
Version:
a vanilla responsive equal heights
431 lines (382 loc) • 17 kB
text/typescript
/**
* vanilla-match-height v2.1.0 by @mitera
* Simone Miterangelis <simone@mite.it>
* License: MIT
*/
interface HTMLElement {
matchHeight(settings?: Settings): MatchHeight;
}
interface MatchHeight {
_remains: Item[];
wrapEl: HTMLElement;
settings: Settings;
_bind(): void;
_init(): void;
_unbind(): void;
_throttle(fn: Function, threshold: number): () => void;
_applyAll(): void;
_validateProperty(value?: string | null): RegExpMatchArray | null;
_applyDataApi(property: string): void;
_remove(): void;
_apply(): void;
_resetStyle($that: HTMLElement, property: string): void;
_update(elements: HTMLElement[]): void;
_process(): void;
}
interface Settings {
elements?: string | null;
byRow?: boolean | null;
target?: HTMLElement | null;
attributeName?: string | null;
attributeValue?: string | null;
property: string;
remove?: HTMLElement | null;
events?: boolean | null;
throttle?: number | null;
beforeUpdate?: any | null;
afterUpdate?: any | null;
}
type Item = {
el: HTMLElement;
top: number;
height: number;
}
(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;
this._remains = [];
// 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);
}
/**
* 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,})$/
);
}
/**
* 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} attributeName
*/
MatchHeight.prototype._applyDataApi = function(attributeName: string) {
let elements: HTMLElement[] = Array.from(this.wrapEl.querySelectorAll('[' + attributeName + ']'));
elements.forEach( ( item ) => {
this._resetStyle(item, this.settings.property);
} );
const groups: Map<string, HTMLElement[]> = new Map();
elements.forEach((el) => {
const groupId = el.getAttribute(attributeName);
if (groupId) {
if (!groups.has(groupId)) {
groups.set(groupId, []);
}
groups.get(groupId)!.push(el);
}
});
// Apply once per unique group instead of once per element
groups.forEach((elements) => {
this._update(elements);
});
}
/**
* 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 + '"]'));
}
}
this._update(elements);
}
/**
* Updates the height of elements in the MatchHeight instance.
* This function recalculates and applies the matching height
* logic to the target elements based on their current visibility,
* size, and any applied options. It ensures that the elements
* are properly aligned and maintain consistent heights.
*
* This method is usually called internally when the heights need
* to be refreshed, for example, after a window resize or content
* change.
*
* The height update takes into account any groupings, overrides,
* or custom configurations provided to the MatchHeight instance.
*/
MatchHeight.prototype._update = function(elements: HTMLElement[]) {
if ( elements.length === 0 ) return;
this._remains = Array.prototype.map.call( elements, ( el: HTMLElement ): Item => {
return {
el,
top: 0,
height: 0
};
} ) as Item[];
// remove all height before
this._remains.forEach( ( item: Item ) => {
this._resetStyle(item.el, this.settings.property);
} );
this._process();
}
/**
* Processes the elements provided to the MatchHeight instance and determines
* the height adjustment required to make all the elements have the same height.
* This method calculates the maximum height among a group of elements and
* applies it uniformly to maintain consistency in their appearance.
*
* This method is typically used when dynamic height adjustments
* are required to ensure a uniform layout for elements with varying heights.
* Internal logic includes checking constraints, grouping elements, and
* calculating corresponding height adjustments while avoiding unnecessary DOM changes.
*
* @private
*/
MatchHeight.prototype._process = function() {
this._remains.forEach( ( item:Item ) => {
const bb = item.el.getBoundingClientRect();
item.top = this.settings.byRow ? bb.top : 0;
item.height = bb.height;
} );
this._remains.sort( ( a:Item, b:Item ) => a.top - b.top);
const errorThreshold = 1;
const processingTop = this._remains[0].top;
const processingTargets = this._remains.filter((item: { top: number; }) => Math.abs(item.top - processingTop) <= errorThreshold);
let maxHeightInRow = 0;
if (this.settings.target) maxHeightInRow = this.settings.target.getBoundingClientRect().height;
else maxHeightInRow = Math.max(...processingTargets.map((item: Item) => item.height));
processingTargets.forEach((item: Item) => {
const styles = window.getComputedStyle(item.el);
const isBorderBox = styles.boxSizing === 'border-box';
if (isBorderBox) {
if (this.settings.property) item.el.style.setProperty(this.settings.property, `${maxHeightInRow}px`);
} else {
const paddingAndBorder =
(parseFloat(styles.paddingTop) || 0) +
(parseFloat(styles.paddingBottom) || 0) +
(parseFloat(styles.borderTopWidth) || 0) +
(parseFloat(styles.borderBottomWidth) || 0);
if (this.settings.property) item.el.style.setProperty(this.settings.property, `${maxHeightInRow - paddingAndBorder}px`);
}
if (this.settings.remove) {
if (this.settings.remove instanceof NodeList) {
Array.from(this.settings.remove).forEach((el) => {
if (item.el === el && this.settings.property && el instanceof HTMLElement) {
this._resetStyle(el, this.settings.property);
}
});
} else if (item.el === this.settings.remove && this.settings.property) {
this._resetStyle(item.el, this.settings.property);
}
}
});
this._remains.splice(0, processingTargets.length);
if (0 < this._remains.length) {
this._process()
}
}
/**
* 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');
}
}
})();