height-harmony 1.0.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,220 +1,270 @@
1
1
  # Height Harmony
2
2
 
3
- A lightweight, zero-dependency JavaScript utility for equalizing element heights.
3
+ **The fastest, smartest equal-height JavaScript utility on the web.**
4
4
 
5
- ## Live Demo
5
+ Zero dependencies. ResizeObserver-powered. Automatically responsive. Drop it in and forget it.
6
6
 
7
- **[View the interactive demo →](https://byronjohnson.github.io/height-harmony/demo/)**
7
+ [![npm version](https://img.shields.io/badge/npm-v2.0.0-45c4b0?style=flat-square)](https://www.npmjs.com/package/height-harmony)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-dafdba?style=flat-square)](LICENSE)
9
+ [![gzip size](https://img.shields.io/badge/gzip-%3C2KB-9aeba3?style=flat-square)](#)
10
+
11
+ **[View the interactive demo →](https://heightharmony.byronj.me/)**
12
+
13
+ ---
14
+
15
+ ## What's New in v2.0
16
+
17
+ | Feature | v1 | v2 |
18
+ |---|---|---|
19
+ | ResizeObserver (auto-reactive) | ❌ | ✅ |
20
+ | MutationObserver (dynamic content) | ❌ | ✅ |
21
+ | Options object | ❌ | ✅ |
22
+ | `destroy()` / `refresh()` methods | ❌ | ✅ |
23
+ | Breakpoint support | ❌ | ✅ |
24
+ | `data-hh-group` HTML attribute | ❌ | ✅ |
25
+ | ESM + UMD build | ❌ | ✅ |
26
+ | Zero layout thrash | partial | ✅ |
27
+
28
+ ---
8
29
 
9
30
  ## Installation
10
31
 
11
- ### NPM
32
+ ### npm / yarn / pnpm
33
+
12
34
  ```bash
13
35
  npm install height-harmony
14
36
  ```
15
37
 
16
- ```javascript
17
- // ES6 Modules (recommended)
18
- import heightHarmony from 'height-harmony';
38
+ ### CDN (UMD — no bundler needed)
19
39
 
20
- // CommonJS
21
- const heightHarmony = require('height-harmony');
40
+ ```html
41
+ <script src="https://cdn.jsdelivr.net/npm/height-harmony@2/dist/height-harmony-min.js"></script>
42
+ <script>
43
+ heightHarmony('.card');
44
+ </script>
22
45
  ```
23
46
 
24
- ### CDN
47
+ ### CDN (ES Module)
25
48
 
26
- #### ES Modules (Modern Browsers)
27
49
  ```html
28
50
  <script type="module">
29
- import heightHarmony from 'https://unpkg.com/height-harmony@latest/height-harmony.js';
30
-
31
- // Use immediately
32
- heightHarmony('.my-elements');
51
+ import heightHarmony from 'https://cdn.jsdelivr.net/npm/height-harmony@2/dist/height-harmony.es.js';
52
+ heightHarmony('.card');
33
53
  </script>
34
54
  ```
35
55
 
36
- #### Traditional Script Tag
37
- ```html
38
- <!-- Latest version -->
39
- <script src="https://unpkg.com/height-harmony@latest/height-harmony-min.js"></script>
56
+ ---
57
+
58
+ ## Quick Start
59
+
60
+ ```javascript
61
+ import heightHarmony from 'height-harmony';
62
+
63
+ // Basic — equalize all matching elements
64
+ heightHarmony('.card');
40
65
 
41
- <!-- Specific version -->
42
- <script src="https://unpkg.com/height-harmony@1.0.0/height-harmony-min.js"></script>
66
+ // With options
67
+ heightHarmony('.card', { debounce: 100, breakpoint: 768 });
68
+
69
+ // Store the instance
70
+ const hh = heightHarmony('.card');
71
+ hh.refresh(); // manual re-trigger
72
+ hh.destroy(); // clean up all observers and remove inline styles
43
73
  ```
44
74
 
45
- ### Direct Download
46
- ```html
47
- <!-- ES Modules -->
48
- <script type="module" src="height-harmony.js"></script>
75
+ ---
76
+
77
+ ## API Reference
78
+
79
+ ### `heightHarmony(target, options?)`
80
+
81
+ **Parameters**
82
+
83
+ | Parameter | Type | Description |
84
+ |---|---|---|
85
+ | `target` | `string \| NodeList \| HTMLElement[]` | CSS selector string or a collection of elements |
86
+ | `options` | `HeightHarmonyOptions` | *(optional)* Configuration object |
87
+
88
+ **Returns** `HeightHarmonyInstance`
89
+
90
+ ---
49
91
 
50
- <!-- Traditional script tag -->
51
- <script src="height-harmony-min.js"></script>
92
+ ### Options
93
+
94
+ ```typescript
95
+ interface HeightHarmonyOptions {
96
+ /**
97
+ * Milliseconds to debounce ResizeObserver / MutationObserver callbacks.
98
+ * 0 = no debounce, only requestAnimationFrame (default).
99
+ * @default 0
100
+ */
101
+ debounce?: number;
102
+
103
+ /**
104
+ * Use `min-height` instead of `height`.
105
+ * Allows elements to grow taller than the maximum if new content is added.
106
+ * @default false
107
+ */
108
+ minHeight?: boolean;
109
+
110
+ /**
111
+ * Viewport width (px) below which harmonizing is disabled.
112
+ * Set to 768 to let mobile layouts stack naturally.
113
+ * @default 0 (always on)
114
+ */
115
+ breakpoint?: number;
116
+
117
+ /**
118
+ * Whether to auto-watch via ResizeObserver and MutationObserver.
119
+ * Set to false for fire-and-forget manual mode.
120
+ * @default true
121
+ */
122
+ watch?: boolean;
123
+
124
+ /**
125
+ * Apply a CSS `transition` on height changes for smooth animation.
126
+ * @default true
127
+ */
128
+ transitions?: boolean;
129
+ }
52
130
  ```
53
131
 
54
- ## Basic Usage
132
+ ---
133
+
134
+ ### Instance Methods
135
+
136
+ #### `.refresh()` → `this`
137
+
138
+ Manually triggers a height re-calculation. Useful after CSS transitions finish or after content changes you explicitly control.
55
139
 
56
140
  ```javascript
57
- // Apply equal heights to elements
58
- heightHarmony('.card');
59
- heightHarmony('.feature-box');
141
+ const hh = heightHarmony('.card');
142
+ // ... some time later, after a font loads or animation finishes
143
+ hh.refresh();
60
144
  ```
61
145
 
62
- ## Version Information
146
+ #### `.destroy()` → `this`
147
+
148
+ Disconnects all ResizeObserver and MutationObserver instances, removes all inline `height` / `min-height` styles set by this instance, and marks it as destroyed.
63
149
 
64
150
  ```javascript
65
- // Check the current version
66
- console.log(heightHarmony.version); // "1.0.0"
151
+ const hh = heightHarmony('.card');
152
+ // Clean up when a component unmounts (React, Vue, etc.)
153
+ hh.destroy();
67
154
  ```
68
155
 
69
- ## ES Module Usage
156
+ ---
157
+
158
+ ### `heightHarmony.autoInit(options?)`
159
+
160
+ Scans the entire document for elements with `data-hh-group` attributes and harmonizes each group automatically.
70
161
 
71
- ### Browser ES Modules
72
162
  ```html
73
- <!DOCTYPE html>
74
- <html>
75
- <head>
76
- <script type="module">
77
- import heightHarmony from './height-harmony.js';
78
-
79
- // Initialize when DOM is ready
80
- document.addEventListener('DOMContentLoaded', function() {
81
- heightHarmony('.card');
82
- });
83
-
84
- // Handle responsive behavior
85
- window.addEventListener('resize', function() {
86
- heightHarmony('.card');
87
- });
88
- </script>
89
- </head>
90
- <body>
91
- <div class="card">Content 1</div>
92
- <div class="card">Content 2</div>
93
- <div class="card">Content 3</div>
94
- </body>
95
- </html>
163
+ <!-- HTML -->
164
+ <div data-hh-group="cards">Card 1 — short content</div>
165
+ <div data-hh-group="cards">Card 2 — a lot more content here...</div>
166
+ <div data-hh-group="sidebar">Widget A</div>
167
+ <div data-hh-group="sidebar">Widget B</div>
96
168
  ```
97
169
 
98
- ### Node.js / Build Tools
99
170
  ```javascript
100
- // app.js
101
171
  import heightHarmony from 'height-harmony';
102
172
 
103
- // In your component
104
- function initCards() {
105
- heightHarmony('.product-card');
106
- }
107
-
108
- // Export for use in other modules
109
- export { initCards };
173
+ // One call handles all groups
174
+ const instances = heightHarmony.autoInit({ debounce: 100 });
175
+ // Returns an array of HeightHarmonyInstance, one per group
110
176
  ```
111
177
 
112
- ### Module Bundlers (Webpack, Rollup, etc.)
178
+ ---
179
+
180
+ ### `heightHarmony.version`
181
+
113
182
  ```javascript
114
- // Works seamlessly with modern bundlers
183
+ console.log(heightHarmony.version); // "2.0.0"
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Framework Integration
189
+
190
+ ### React
191
+
192
+ ```jsx
193
+ import { useEffect, useRef } from 'react';
115
194
  import heightHarmony from 'height-harmony';
116
195
 
117
- // Tree-shaking friendly
118
- export function createCardGrid() {
119
- heightHarmony('.grid-item');
196
+ function CardGrid({ cards }) {
197
+ useEffect(() => {
198
+ const hh = heightHarmony('.card', { debounce: 50 });
199
+ return () => hh.destroy();
200
+ }, [cards]); // re-run when cards array changes
201
+
202
+ return (
203
+ <div className="grid">
204
+ {cards.map(card => <div className="card" key={card.id}>{card.content}</div>)}
205
+ </div>
206
+ );
120
207
  }
121
208
  ```
122
209
 
123
- ## Responsive Support
210
+ ### Vue 3
124
211
 
125
- ### ES Modules
126
212
  ```javascript
213
+ import { onMounted, onUnmounted, watch } from 'vue';
127
214
  import heightHarmony from 'height-harmony';
128
215
 
129
- document.addEventListener('DOMContentLoaded', function() {
130
- // Initial harmonization
131
- heightHarmony('.card');
132
-
133
- // Reapply on window resize
134
- let resizeTimeout;
135
- window.addEventListener('resize', function() {
136
- clearTimeout(resizeTimeout);
137
- resizeTimeout = setTimeout(function() {
138
- heightHarmony('.card');
139
- }, 150);
140
- });
141
-
142
- // Handle orientation changes on mobile
143
- window.addEventListener('orientationchange', function() {
144
- setTimeout(function() {
145
- heightHarmony('.card');
146
- }, 300);
147
- });
148
- });
216
+ export function useHeightHarmony(selector, options = {}) {
217
+ let instance = null;
218
+ onMounted(() => { instance = heightHarmony(selector, options); });
219
+ onUnmounted(() => { instance?.destroy(); });
220
+ return { refresh: () => instance?.refresh() };
221
+ }
149
222
  ```
150
223
 
151
- ### Traditional Script Tag
224
+ ### Vanilla JS — DOMContentLoaded
225
+
152
226
  ```javascript
153
- // Initialize on page load
154
- document.addEventListener('DOMContentLoaded', function() {
155
- heightHarmony('.card');
156
- });
227
+ import heightHarmony from 'height-harmony';
157
228
 
158
- // Reapply on window resize
159
- let resizeTimeout;
160
- window.addEventListener('resize', function() {
161
- clearTimeout(resizeTimeout);
162
- resizeTimeout = setTimeout(function() {
163
- heightHarmony('.card');
164
- }, 150);
229
+ document.addEventListener('DOMContentLoaded', () => {
230
+ heightHarmony('.card'); // ResizeObserver handles everything else
165
231
  });
166
232
  ```
167
233
 
168
- ## Dynamic Content
234
+ ---
169
235
 
170
- ### ES Modules
171
- ```javascript
172
- import heightHarmony from 'height-harmony';
236
+ ## How It Works
173
237
 
174
- // After adding new content via AJAX/fetch
175
- function addNewCards(data) {
176
- // Add new elements to DOM
177
- const container = document.getElementById('card-container');
178
- data.forEach(item => {
179
- const card = document.createElement('div');
180
- card.className = 'card';
181
- card.textContent = item.content;
182
- container.appendChild(card);
183
- });
184
-
185
- // Harmonize heights after DOM update
186
- setTimeout(function() {
187
- heightHarmony('.card');
188
- }, 10);
189
- }
190
- ```
238
+ 1. **Reset** Clears inline `height` / `min-height` on all matched elements in a single write pass.
239
+ 2. **Measure** — Reads `offsetHeight` for every element in one synchronous batch (no interleaved read/write thrashing).
240
+ 3. **Apply** Sets all elements to the maximum measured height.
241
+ 4. **Watch** `ResizeObserver` re-syncs automatically whenever any element's size changes. `MutationObserver` re-syncs when new elements are added to parent containers.
191
242
 
192
- ### Traditional Approach
193
- ```javascript
194
- // After adding new content
195
- setTimeout(function() {
196
- heightHarmony('.product-card');
197
- }, 10);
198
- ```
243
+ ---
199
244
 
200
- ## Browser Compatibility
245
+ ## Performance
201
246
 
202
- ### ES Modules
203
- - Chrome 61+
204
- - Firefox 60+
205
- - Safari 10.1+
206
- - Edge 16+
247
+ Height Harmony v2 is carefully engineered to avoid common causes of layout thrashing:
207
248
 
208
- ### Traditional Script Tag
209
- - All modern browsers
210
- - IE 9+
249
+ - All height reads happen **before** any writes (batch read → batch write)
250
+ - `ResizeObserver` is far more efficient than `window.resize` — it only fires for elements that actually changed
251
+ - `requestAnimationFrame` ensures writes happen at the right point in the browser rendering pipeline
252
+ - A built-in debounce option prevents excessive recalculations during rapid mutations
211
253
 
212
- ## How It Works
254
+ ---
255
+
256
+ ## Browser Compatibility
257
+
258
+ | Feature | Chrome | Firefox | Safari | Edge |
259
+ |---|---|---|---|---|
260
+ | ResizeObserver | 64+ | 69+ | 13.1+ | 79+ |
261
+ | MutationObserver | 26+ | 14+ | 7+ | 12+ |
262
+ | ES Modules | 61+ | 60+ | 10.1+ | 16+ |
263
+
264
+ For very old browsers, Height Harmony automatically falls back to a debounced `window.resize` listener.
213
265
 
214
- 1. Resets all element heights to measure natural dimensions
215
- 2. Finds the tallest element in the group
216
- 3. Sets all elements to match the tallest height
266
+ ---
217
267
 
218
268
  ## License
219
269
 
220
- MIT
270
+ [MIT](LICENSE) © Byron Johnson
@@ -1,22 +1,12 @@
1
- function heightHarmony(selector) {
2
- const elements = document.querySelectorAll(selector);
3
- if (elements.length === 0) return;
4
- elements.forEach((element) => {
5
- element.style.height = "0px";
6
- });
7
- requestAnimationFrame(() => {
8
- let maxHeight = 0;
9
- elements.forEach((element) => {
10
- element.style.height = "";
11
- const elementHeight = element.offsetHeight;
12
- maxHeight = Math.max(maxHeight, elementHeight);
13
- });
14
- elements.forEach((element) => {
15
- element.style.height = maxHeight + "px";
16
- });
17
- });
18
- }
19
- heightHarmony.version = "1.0.0";
20
- export {
21
- heightHarmony as default
22
- };
1
+ !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).heightHarmony={})}(this,function(e){"use strict";
2
+ /**
3
+ * Height Harmony v2.0.0
4
+ * The fastest, smartest equal-height JavaScript utility on the web.
5
+ *
6
+ * Automatically synchronizes element heights using ResizeObserver and
7
+ * MutationObserver — no manual resize listeners needed.
8
+ *
9
+ * @author Byron Johnson
10
+ * @license MIT
11
+ * @see https://heightharmony.byronj.me
12
+ */function t(e,t){let s=null;return function(...i){null!==s&&clearTimeout(s),t>0?s=setTimeout(()=>{s=null,requestAnimationFrame(()=>e.apply(this,i))},t):requestAnimationFrame(()=>e.apply(this,i))}}class s{constructor(e,s={}){this._target=e,this._opts=Object.assign({debounce:0,minHeight:!1,breakpoint:0,watch:!0,transitions:!0},s),this._destroyed=!1,this._resizeObserver=null,this._mutationObserver=null,this._cleanupFallback=null,this._debouncedSync=t(this._sync.bind(this),this._opts.debounce),this._sync(),this._opts.watch&&this._setupObservers()}refresh(){return this._destroyed||this._sync(),this}destroy(){if(this._destroyed)return this;this._destroyed=!0,this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._mutationObserver&&(this._mutationObserver.disconnect(),this._mutationObserver=null),this._cleanupFallback&&(this._cleanupFallback(),this._cleanupFallback=null);const e=this._opts.minHeight?"min-height":"height";return this._getElements().forEach(t=>{t.style.removeProperty(e),t.style.removeProperty("box-sizing"),this._opts.transitions&&t.style.removeProperty("transition")}),this}_getElements(){return"string"==typeof this._target?Array.from(document.querySelectorAll(this._target)):this._target instanceof NodeList||Array.isArray(this._target)?Array.from(this._target):this._target instanceof HTMLElement?[this._target]:[]}_sync(){if(this._destroyed)return;const e=this._getElements();if(0===e.length)return;if(this._opts.breakpoint>0&&window.innerWidth<this._opts.breakpoint){const t=this._opts.minHeight?"min-height":"height";return void e.forEach(e=>e.style.removeProperty(t))}const t=this._opts.minHeight?"min-height":"height";e.forEach(e=>{e.style.setProperty(t,"","important"),e.style.setProperty("box-sizing","border-box","important")});let s=0;e.forEach(e=>{const t=e.offsetHeight;t>s&&(s=t)}),0!==s&&e.forEach(e=>{this._opts.transitions&&e.style.setProperty("transition",`${t} 0.2s ease`,""),e.style.setProperty(t,`${s}px`,"important")})}_setupObservers(){if("undefined"!=typeof ResizeObserver)this._resizeObserver=new ResizeObserver(e=>{e.length>0&&this._debouncedSync()}),(()=>{this._getElements().forEach(e=>this._resizeObserver.observe(e))})();else{const e=t(this._sync.bind(this),Math.max(this._opts.debounce,150)),s=()=>setTimeout(()=>this._sync(),300);window.addEventListener("resize",e,{passive:!0}),window.addEventListener("orientationchange",s,{passive:!0}),this._cleanupFallback=()=>{window.removeEventListener("resize",e),window.removeEventListener("orientationchange",s)}}if("undefined"!=typeof MutationObserver){const e=this._getElements(),t=new Set(e.map(e=>e.parentElement).filter(Boolean));t.size>0&&(this._mutationObserver=new MutationObserver(e=>{e.some(e=>e.addedNodes.length>0||e.removedNodes.length>0)&&(this._debouncedSync(),this._resizeObserver&&this._getElements().forEach(e=>{try{this._resizeObserver.observe(e)}catch(t){}}))}),t.forEach(e=>{this._mutationObserver.observe(e,{childList:!0,subtree:!1})}))}}}function i(e,t){return new s(e,t)}i.version="2.0.0",i.autoInit=function(e={}){const t=document.querySelectorAll("[data-hh-group]");if(0===t.length)return[];const i=new Map;t.forEach(e=>{const t=e.getAttribute("data-hh-group");i.has(t)||i.set(t,[]),i.get(t).push(e)});const r=[];return i.forEach(t=>{r.push(new s(t,e))}),r},e.HeightHarmonyInstance=s,e.default=i,Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Height Harmony v2.0.0
3
+ * The fastest, smartest equal-height JavaScript utility on the web.
4
+ *
5
+ * Automatically synchronizes element heights using ResizeObserver and
6
+ * MutationObserver — no manual resize listeners needed.
7
+ *
8
+ * @author Byron Johnson
9
+ * @license MIT
10
+ * @see https://heightharmony.byronj.me
11
+ */
12
+ const VERSION = "2.0.0";
13
+ function debounce(fn, wait) {
14
+ let timer = null;
15
+ return function debounced(...args) {
16
+ if (timer !== null) clearTimeout(timer);
17
+ if (wait > 0) {
18
+ timer = setTimeout(() => {
19
+ timer = null;
20
+ requestAnimationFrame(() => fn.apply(this, args));
21
+ }, wait);
22
+ } else {
23
+ requestAnimationFrame(() => fn.apply(this, args));
24
+ }
25
+ };
26
+ }
27
+ class HeightHarmonyInstance {
28
+ /**
29
+ * @param {string|NodeList|HTMLElement[]} target CSS selector or element list
30
+ * @param {HeightHarmonyOptions} options
31
+ */
32
+ constructor(target, options = {}) {
33
+ this._target = target;
34
+ this._opts = Object.assign(
35
+ { debounce: 0, minHeight: false, breakpoint: 0, watch: true, transitions: true },
36
+ options
37
+ );
38
+ this._destroyed = false;
39
+ this._resizeObserver = null;
40
+ this._mutationObserver = null;
41
+ this._cleanupFallback = null;
42
+ this._debouncedSync = debounce(this._sync.bind(this), this._opts.debounce);
43
+ this._sync();
44
+ if (this._opts.watch) {
45
+ this._setupObservers();
46
+ }
47
+ }
48
+ // ── Public API ──────────────────────────────────────────────────────────────
49
+ /**
50
+ * Manually triggers a height re-calculation.
51
+ * Useful after CSS transitions finish or after content changes you control.
52
+ * @returns {this}
53
+ */
54
+ refresh() {
55
+ if (this._destroyed) return this;
56
+ this._sync();
57
+ return this;
58
+ }
59
+ /**
60
+ * Tears down all observers, removes inline height/transition styles set by
61
+ * this instance, and marks the instance as destroyed.
62
+ * @returns {this}
63
+ */
64
+ destroy() {
65
+ if (this._destroyed) return this;
66
+ this._destroyed = true;
67
+ if (this._resizeObserver) {
68
+ this._resizeObserver.disconnect();
69
+ this._resizeObserver = null;
70
+ }
71
+ if (this._mutationObserver) {
72
+ this._mutationObserver.disconnect();
73
+ this._mutationObserver = null;
74
+ }
75
+ if (this._cleanupFallback) {
76
+ this._cleanupFallback();
77
+ this._cleanupFallback = null;
78
+ }
79
+ const prop = this._opts.minHeight ? "min-height" : "height";
80
+ this._getElements().forEach((el) => {
81
+ el.style.removeProperty(prop);
82
+ el.style.removeProperty("box-sizing");
83
+ if (this._opts.transitions) {
84
+ el.style.removeProperty("transition");
85
+ }
86
+ });
87
+ return this;
88
+ }
89
+ // ── Private ─────────────────────────────────────────────────────────────────
90
+ /**
91
+ * Resolves the target into an array of HTMLElements.
92
+ * @returns {HTMLElement[]}
93
+ */
94
+ _getElements() {
95
+ if (typeof this._target === "string") {
96
+ return Array.from(document.querySelectorAll(this._target));
97
+ }
98
+ if (this._target instanceof NodeList || Array.isArray(this._target)) {
99
+ return Array.from(this._target);
100
+ }
101
+ if (this._target instanceof HTMLElement) {
102
+ return [this._target];
103
+ }
104
+ return [];
105
+ }
106
+ /**
107
+ * Core synchronization routine.
108
+ * Measures natural heights, finds the max, applies it to all elements.
109
+ */
110
+ _sync() {
111
+ if (this._destroyed) return;
112
+ const elements = this._getElements();
113
+ if (elements.length === 0) return;
114
+ if (this._opts.breakpoint > 0 && window.innerWidth < this._opts.breakpoint) {
115
+ const prop2 = this._opts.minHeight ? "min-height" : "height";
116
+ elements.forEach((el) => el.style.removeProperty(prop2));
117
+ return;
118
+ }
119
+ const prop = this._opts.minHeight ? "min-height" : "height";
120
+ elements.forEach((el) => {
121
+ el.style.setProperty(prop, "", "important");
122
+ el.style.setProperty("box-sizing", "border-box", "important");
123
+ });
124
+ let maxH = 0;
125
+ elements.forEach((el) => {
126
+ const h = el.offsetHeight;
127
+ if (h > maxH) maxH = h;
128
+ });
129
+ if (maxH === 0) return;
130
+ elements.forEach((el) => {
131
+ if (this._opts.transitions) {
132
+ el.style.setProperty("transition", `${prop} 0.2s ease`, "");
133
+ }
134
+ el.style.setProperty(prop, `${maxH}px`, "important");
135
+ });
136
+ }
137
+ /**
138
+ * Sets up ResizeObserver to watch each element and MutationObserver to
139
+ * watch the parent containers for new elements being added.
140
+ */
141
+ _setupObservers() {
142
+ if (typeof ResizeObserver !== "undefined") {
143
+ this._resizeObserver = new ResizeObserver((entries) => {
144
+ if (entries.length > 0) {
145
+ this._debouncedSync();
146
+ }
147
+ });
148
+ const observe = () => {
149
+ this._getElements().forEach((el) => this._resizeObserver.observe(el));
150
+ };
151
+ observe();
152
+ } else {
153
+ const handler = debounce(this._sync.bind(this), Math.max(this._opts.debounce, 150));
154
+ const orientationHandler = () => setTimeout(() => this._sync(), 300);
155
+ window.addEventListener("resize", handler, { passive: true });
156
+ window.addEventListener("orientationchange", orientationHandler, { passive: true });
157
+ this._cleanupFallback = () => {
158
+ window.removeEventListener("resize", handler);
159
+ window.removeEventListener("orientationchange", orientationHandler);
160
+ };
161
+ }
162
+ if (typeof MutationObserver !== "undefined") {
163
+ const elements = this._getElements();
164
+ const parents = new Set(elements.map((el) => el.parentElement).filter(Boolean));
165
+ if (parents.size > 0) {
166
+ this._mutationObserver = new MutationObserver((mutations) => {
167
+ const hasNewNodes = mutations.some((m) => m.addedNodes.length > 0 || m.removedNodes.length > 0);
168
+ if (hasNewNodes) {
169
+ this._debouncedSync();
170
+ if (this._resizeObserver) {
171
+ this._getElements().forEach((el) => {
172
+ try {
173
+ this._resizeObserver.observe(el);
174
+ } catch (_) {
175
+ }
176
+ });
177
+ }
178
+ }
179
+ });
180
+ parents.forEach((parent) => {
181
+ this._mutationObserver.observe(parent, { childList: true, subtree: false });
182
+ });
183
+ }
184
+ }
185
+ }
186
+ }
187
+ function heightHarmony(target, opts) {
188
+ return new HeightHarmonyInstance(target, opts);
189
+ }
190
+ heightHarmony.version = VERSION;
191
+ heightHarmony.autoInit = function autoInit(opts = {}) {
192
+ const all = document.querySelectorAll("[data-hh-group]");
193
+ if (all.length === 0) return [];
194
+ const groups = /* @__PURE__ */ new Map();
195
+ all.forEach((el) => {
196
+ const key = el.getAttribute("data-hh-group");
197
+ if (!groups.has(key)) groups.set(key, []);
198
+ groups.get(key).push(el);
199
+ });
200
+ const instances = [];
201
+ groups.forEach((elements) => {
202
+ instances.push(new HeightHarmonyInstance(elements, opts));
203
+ });
204
+ return instances;
205
+ };
206
+ export {
207
+ HeightHarmonyInstance,
208
+ heightHarmony as default
209
+ };
package/height-harmony.js CHANGED
@@ -1,46 +1,316 @@
1
1
  /**
2
- * Sets all matching elements to the same height (the height of the tallest element)
3
- * @param {string} selector - CSS selector for the elements to harmonize
4
- * @version 1.0.1
2
+ * Height Harmony v2.0.0
3
+ * The fastest, smartest equal-height JavaScript utility on the web.
4
+ *
5
+ * Automatically synchronizes element heights using ResizeObserver and
6
+ * MutationObserver — no manual resize listeners needed.
7
+ *
8
+ * @author Byron Johnson
9
+ * @license MIT
10
+ * @see https://heightharmony.byronj.me
5
11
  */
6
- function heightHarmony(selector) {
7
- // Get all matching elements
8
- const elements = document.querySelectorAll(selector);
9
12
 
10
- // Exit if no elements found
13
+ /**
14
+ * @typedef {Object} HeightHarmonyOptions
15
+ * @property {number} [debounce=0] - Milliseconds to debounce resize/mutation callbacks (0 = rAF only)
16
+ * @property {boolean} [minHeight=false] - Use min-height instead of height, allowing elements to grow taller
17
+ * @property {number} [breakpoint=0] - Disable harmonizing below this viewport width (px); 0 = always on
18
+ * @property {boolean} [watch=true] - Auto-watch via ResizeObserver and MutationObserver
19
+ * @property {boolean} [transitions=true] - Apply CSS transition on height changes for smooth animation
20
+ */
21
+
22
+ const VERSION = '2.0.0';
23
+
24
+ // ─── Internal helpers ────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Creates a debounced version of fn that fires after `wait` ms of inactivity.
28
+ * If wait === 0 we skip the timeout and only use requestAnimationFrame.
29
+ * @param {Function} fn
30
+ * @param {number} wait
31
+ * @returns {Function}
32
+ */
33
+ function debounce(fn, wait) {
34
+ let timer = null;
35
+ return function debounced(...args) {
36
+ if (timer !== null) clearTimeout(timer);
37
+ if (wait > 0) {
38
+ timer = setTimeout(() => {
39
+ timer = null;
40
+ requestAnimationFrame(() => fn.apply(this, args));
41
+ }, wait);
42
+ } else {
43
+ requestAnimationFrame(() => fn.apply(this, args));
44
+ }
45
+ };
46
+ }
47
+
48
+ // ─── HeightHarmonyInstance ───────────────────────────────────────────────────
49
+
50
+ class HeightHarmonyInstance {
51
+ /**
52
+ * @param {string|NodeList|HTMLElement[]} target CSS selector or element list
53
+ * @param {HeightHarmonyOptions} options
54
+ */
55
+ constructor(target, options = {}) {
56
+ this._target = target;
57
+ this._opts = Object.assign(
58
+ { debounce: 0, minHeight: false, breakpoint: 0, watch: true, transitions: true },
59
+ options
60
+ );
61
+
62
+ this._destroyed = false;
63
+ this._resizeObserver = null;
64
+ this._mutationObserver = null;
65
+ this._cleanupFallback = null;
66
+ this._debouncedSync = debounce(this._sync.bind(this), this._opts.debounce);
67
+
68
+ // Run immediately
69
+ this._sync();
70
+
71
+ // Set up observers if watching is enabled
72
+ if (this._opts.watch) {
73
+ this._setupObservers();
74
+ }
75
+ }
76
+
77
+ // ── Public API ──────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Manually triggers a height re-calculation.
81
+ * Useful after CSS transitions finish or after content changes you control.
82
+ * @returns {this}
83
+ */
84
+ refresh() {
85
+ if (this._destroyed) return this;
86
+ this._sync();
87
+ return this;
88
+ }
89
+
90
+ /**
91
+ * Tears down all observers, removes inline height/transition styles set by
92
+ * this instance, and marks the instance as destroyed.
93
+ * @returns {this}
94
+ */
95
+ destroy() {
96
+ if (this._destroyed) return this;
97
+ this._destroyed = true;
98
+
99
+ if (this._resizeObserver) {
100
+ this._resizeObserver.disconnect();
101
+ this._resizeObserver = null;
102
+ }
103
+ if (this._mutationObserver) {
104
+ this._mutationObserver.disconnect();
105
+ this._mutationObserver = null;
106
+ }
107
+
108
+ // Clean up window event listeners added by the ResizeObserver fallback
109
+ if (this._cleanupFallback) {
110
+ this._cleanupFallback();
111
+ this._cleanupFallback = null;
112
+ }
113
+
114
+ // Remove all inline styles we set
115
+ const prop = this._opts.minHeight ? 'min-height' : 'height';
116
+ this._getElements().forEach(el => {
117
+ el.style.removeProperty(prop);
118
+ el.style.removeProperty('box-sizing');
119
+ if (this._opts.transitions) {
120
+ el.style.removeProperty('transition');
121
+ }
122
+ });
123
+
124
+ return this;
125
+ }
126
+
127
+ // ── Private ─────────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Resolves the target into an array of HTMLElements.
131
+ * @returns {HTMLElement[]}
132
+ */
133
+ _getElements() {
134
+ if (typeof this._target === 'string') {
135
+ return Array.from(document.querySelectorAll(this._target));
136
+ }
137
+ if (this._target instanceof NodeList || Array.isArray(this._target)) {
138
+ return Array.from(this._target);
139
+ }
140
+ if (this._target instanceof HTMLElement) {
141
+ return [this._target];
142
+ }
143
+ return [];
144
+ }
145
+
146
+ /**
147
+ * Core synchronization routine.
148
+ * Measures natural heights, finds the max, applies it to all elements.
149
+ */
150
+ _sync() {
151
+ if (this._destroyed) return;
152
+
153
+ const elements = this._getElements();
11
154
  if (elements.length === 0) return;
12
155
 
13
- // First pass: Reset all heights to 0px to clear any previous styling completely
14
- elements.forEach(element => {
15
- element.style.height = '0px';
156
+ // Check breakpoint disable below threshold
157
+ if (this._opts.breakpoint > 0 && window.innerWidth < this._opts.breakpoint) {
158
+ const prop = this._opts.minHeight ? 'min-height' : 'height';
159
+ elements.forEach(el => el.style.removeProperty(prop));
160
+ return;
161
+ }
162
+
163
+ const prop = this._opts.minHeight ? 'min-height' : 'height';
164
+
165
+ // Step 1: Strip current inline heights so we read natural layout heights
166
+ elements.forEach(el => {
167
+ el.style.setProperty(prop, '', 'important');
168
+ // Ensure border-box so our offsetHeight read is reliable
169
+ el.style.setProperty('box-sizing', 'border-box', 'important');
170
+ });
171
+
172
+ // Step 2: Force a synchronous layout read (single batch)
173
+ // We use offsetHeight (includes padding/border, respects box model)
174
+ let maxH = 0;
175
+ elements.forEach(el => {
176
+ const h = el.offsetHeight;
177
+ if (h > maxH) maxH = h;
178
+ });
179
+
180
+ if (maxH === 0) return;
181
+
182
+ // Step 3: Apply the max height to all elements
183
+ elements.forEach(el => {
184
+ if (this._opts.transitions) {
185
+ el.style.setProperty('transition', `${prop} 0.2s ease`, '');
186
+ }
187
+ el.style.setProperty(prop, `${maxH}px`, 'important');
16
188
  });
189
+ }
190
+
191
+ /**
192
+ * Sets up ResizeObserver to watch each element and MutationObserver to
193
+ * watch the parent containers for new elements being added.
194
+ */
195
+ _setupObservers() {
196
+ // ResizeObserver: re-sync whenever any observed element changes size
197
+ if (typeof ResizeObserver !== 'undefined') {
198
+ this._resizeObserver = new ResizeObserver(entries => {
199
+ // Only fire if at least one entry has a real size change
200
+ if (entries.length > 0) {
201
+ this._debouncedSync();
202
+ }
203
+ });
17
204
 
18
- // Use requestAnimationFrame for better browser synchronization
19
- requestAnimationFrame(() => {
20
- // Reset heights to auto to get natural heights
21
- let maxHeight = 0;
205
+ const observe = () => {
206
+ this._getElements().forEach(el => this._resizeObserver.observe(el));
207
+ };
22
208
 
23
- // Find the maximum natural height
24
- elements.forEach(element => {
25
- // Reset to empty string to get natural height
26
- element.style.height = '';
209
+ observe();
210
+ } else {
211
+ // Fallback for browsers without ResizeObserver (very old Safari, etc.)
212
+ const handler = debounce(this._sync.bind(this), Math.max(this._opts.debounce, 150));
213
+ const orientationHandler = () => setTimeout(() => this._sync(), 300);
27
214
 
28
- // Get the element's height
29
- const elementHeight = element.offsetHeight;
215
+ window.addEventListener('resize', handler, { passive: true });
216
+ window.addEventListener('orientationchange', orientationHandler, { passive: true });
30
217
 
31
- // Update maxHeight if this element is taller
32
- maxHeight = Math.max(maxHeight, elementHeight);
218
+ // Store cleanup so destroy() can remove both listeners
219
+ this._cleanupFallback = () => {
220
+ window.removeEventListener('resize', handler);
221
+ window.removeEventListener('orientationchange', orientationHandler);
222
+ };
223
+ }
224
+
225
+ // MutationObserver: re-sync when new children are added to parent containers
226
+ if (typeof MutationObserver !== 'undefined') {
227
+ const elements = this._getElements();
228
+ const parents = new Set(elements.map(el => el.parentElement).filter(Boolean));
229
+
230
+ if (parents.size > 0) {
231
+ this._mutationObserver = new MutationObserver(mutations => {
232
+ const hasNewNodes = mutations.some(m => m.addedNodes.length > 0 || m.removedNodes.length > 0);
233
+ if (hasNewNodes) {
234
+ this._debouncedSync();
235
+ // Re-observe any new elements (ResizeObserver)
236
+ if (this._resizeObserver) {
237
+ this._getElements().forEach(el => {
238
+ try { this._resizeObserver.observe(el); } catch (_) {}
239
+ });
240
+ }
241
+ }
33
242
  });
34
243
 
35
- // Set all elements to the maximum height
36
- elements.forEach(element => {
37
- element.style.height = maxHeight + 'px';
244
+ parents.forEach(parent => {
245
+ this._mutationObserver.observe(parent, { childList: true, subtree: false });
38
246
  });
39
- });
247
+ }
248
+ }
249
+ }
40
250
  }
41
251
 
42
- // Add version information
43
- heightHarmony.version = '1.0.0';
252
+ // ─── Public factory function ──────────────────────────────────────────────────
253
+
254
+ /**
255
+ * heightHarmony — equalizes the heights of all elements matching `target`.
256
+ *
257
+ * @param {string|NodeList|HTMLElement[]} target - CSS selector or element collection
258
+ * @param {HeightHarmonyOptions} [opts] - Configuration options
259
+ * @returns {HeightHarmonyInstance} - Instance with refresh() and destroy() methods
260
+ *
261
+ * @example
262
+ * // Basic usage (same as v1)
263
+ * heightHarmony('.card');
264
+ *
265
+ * @example
266
+ * // With options
267
+ * heightHarmony('.card', { debounce: 100, breakpoint: 768 });
268
+ *
269
+ * @example
270
+ * // Store instance for later control
271
+ * const hh = heightHarmony('.card');
272
+ * hh.refresh(); // manual re-trigger
273
+ * hh.destroy(); // clean up observers & styles
274
+ */
275
+ function heightHarmony(target, opts) {
276
+ return new HeightHarmonyInstance(target, opts);
277
+ }
278
+
279
+ // ─── Static metadata ──────────────────────────────────────────────────────────
280
+
281
+ heightHarmony.version = VERSION;
282
+
283
+ /**
284
+ * Auto-initializes all elements with `data-hh-group` attributes.
285
+ * Groups elements sharing the same data-hh-group value and harmonizes each group.
286
+ *
287
+ * Usage in HTML:
288
+ * <div data-hh-group="cards">...</div>
289
+ * <div data-hh-group="cards">...</div>
290
+ *
291
+ * @param {HeightHarmonyOptions} [opts] - Options applied to all groups
292
+ * @returns {HeightHarmonyInstance[]} - Array of instances, one per group
293
+ */
294
+ heightHarmony.autoInit = function autoInit(opts = {}) {
295
+ const all = document.querySelectorAll('[data-hh-group]');
296
+ if (all.length === 0) return [];
297
+
298
+ /** @type {Map<string, HTMLElement[]>} */
299
+ const groups = new Map();
300
+ all.forEach(el => {
301
+ const key = el.getAttribute('data-hh-group');
302
+ if (!groups.has(key)) groups.set(key, []);
303
+ groups.get(key).push(el);
304
+ });
305
+
306
+ const instances = [];
307
+ groups.forEach((elements) => {
308
+ instances.push(new HeightHarmonyInstance(elements, opts));
309
+ });
310
+ return instances;
311
+ };
312
+
313
+ // ─── Exports ──────────────────────────────────────────────────────────────────
44
314
 
45
- // Export the function as the default export
315
+ export { HeightHarmonyInstance };
46
316
  export default heightHarmony;
package/package.json CHANGED
@@ -1,14 +1,18 @@
1
1
  {
2
2
  "name": "height-harmony",
3
- "version": "1.0.1",
3
+ "version": "2.0.1",
4
4
  "description": "A lightweight, zero-dependency JavaScript utility for equalizing element heights",
5
+ "publishConfig": {
6
+ "registry": "https://registry.npmjs.org/",
7
+ "access": "public"
8
+ },
5
9
  "main": "dist/height-harmony-min.js",
6
- "module": "height-harmony.js",
10
+ "module": "dist/height-harmony.es.js",
7
11
  "exports": {
8
12
  ".": {
9
- "import": "./height-harmony.js",
10
- "require": "./dist/height-harmony-min.js",
11
- "browser": "./dist/height-harmony-min.js"
13
+ "browser": "./dist/height-harmony-min.js",
14
+ "import": "./dist/height-harmony.es.js",
15
+ "require": "./dist/height-harmony-min.js"
12
16
  }
13
17
  },
14
18
  "browser": "dist/height-harmony-min.js",
@@ -17,6 +21,7 @@
17
21
  "files": [
18
22
  "height-harmony.js",
19
23
  "dist/height-harmony-min.js",
24
+ "dist/height-harmony.es.js",
20
25
  "README.md",
21
26
  "LICENSE"
22
27
  ],
@@ -30,20 +35,26 @@
30
35
  "type": "git",
31
36
  "url": "git+https://github.com/byronjohnson/height-harmony.git"
32
37
  },
33
- "homepage": "https://byronjohnson.github.io/height-harmony/demo",
38
+ "homepage": "https://heightharmony.byronj.me",
34
39
  "bugs": {
35
40
  "url": "https://github.com/byronjohnson/height-harmony/issues"
36
41
  },
37
42
  "keywords": [
38
43
  "javascript",
39
44
  "height",
45
+ "equal-height",
40
46
  "equalize",
41
47
  "responsive",
42
48
  "css",
43
49
  "utility",
44
50
  "frontend",
45
51
  "dom",
46
- "elements"
52
+ "elements",
53
+ "resize-observer",
54
+ "mutation-observer",
55
+ "auto",
56
+ "layout",
57
+ "zero-dependency"
47
58
  ],
48
59
  "author": "Byron Johnson",
49
60
  "license": "MIT",
@@ -52,6 +63,6 @@
52
63
  "vite": "^6.3.6"
53
64
  },
54
65
  "engines": {
55
- "node": ">=0.10.0"
66
+ "node": ">=14.0.0"
56
67
  }
57
- }
68
+ }