@boostdev/design-system-components 2.4.0 → 2.4.2

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.
@@ -157,6 +157,7 @@ function supportsNativeMasonry() {
157
157
  return CSS.supports("display", "grid-lanes") || CSS.supports("grid-template-rows", "masonry");
158
158
  }
159
159
  var ORIGINAL_COL_ATTR = "data-bds-masonry-original-column";
160
+ var LAST_SPAN_ATTR = "data-bds-masonry-span";
160
161
  function applyMasonryLayout(container, rawChildren, options = {}) {
161
162
  const cs = window.getComputedStyle(container);
162
163
  const targetGap = parseFloat(cs.columnGap) || 0;
@@ -168,10 +169,8 @@ function applyMasonryLayout(container, rawChildren, options = {}) {
168
169
  (el) => el.nodeType === 1 && window.getComputedStyle(el).display !== "none"
169
170
  );
170
171
  for (const child of children) {
171
- child.style.gridRow = "";
172
- child.style.gridRowStart = "";
173
- child.style.gridRowEnd = "";
174
172
  child.style.alignSelf = "start";
173
+ child.style.overflowAnchor = "none";
175
174
  }
176
175
  const preferred = options.preferredColumnSpan;
177
176
  if (preferred !== void 0) {
@@ -200,9 +199,19 @@ function applyMasonryLayout(container, rawChildren, options = {}) {
200
199
  }
201
200
  }
202
201
  const heights = children.map((child) => child.offsetHeight);
202
+ const spans = heights.map((h) => Math.max(1, Math.ceil(h + targetGap)));
203
+ let changed = false;
204
+ for (let i3 = 0; i3 < children.length; i3++) {
205
+ const prev = children[i3].getAttribute(LAST_SPAN_ATTR);
206
+ if (prev !== String(spans[i3])) {
207
+ changed = true;
208
+ break;
209
+ }
210
+ }
211
+ if (!changed) return;
203
212
  for (let i3 = 0; i3 < children.length; i3++) {
204
- const span = Math.max(1, Math.ceil(heights[i3] + targetGap));
205
- children[i3].style.gridRowEnd = `span ${span}`;
213
+ children[i3].style.gridRowEnd = `span ${spans[i3]}`;
214
+ children[i3].setAttribute(LAST_SPAN_ATTR, String(spans[i3]));
206
215
  }
207
216
  }
208
217
  function countGridColumns(container) {
@@ -235,6 +244,8 @@ function clearMasonryLayout(container, rawChildren) {
235
244
  child.style.gridRowStart = "";
236
245
  child.style.gridRowEnd = "";
237
246
  child.style.alignSelf = "";
247
+ child.style.overflowAnchor = "";
248
+ child.removeAttribute(LAST_SPAN_ATTR);
238
249
  restoreOriginalColumn(child);
239
250
  }
240
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-components",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "BoostDev React component library: accessible, token-driven components built on @boostdev/design-system-foundation",
5
5
  "keywords": [
6
6
  "React",
@@ -118,6 +118,29 @@ describe('applyMasonryLayout', () => {
118
118
  expect(container.style.overflowAnchor).toBe('');
119
119
  });
120
120
 
121
+ it('sets overflow-anchor: none on each child so the browser picks an anchor outside the grid', () => {
122
+ // `overflow-anchor` doesn't inherit — setting it only on the container leaves
123
+ // children anchor-eligible, so a child above the viewport being rewritten still
124
+ // drags the scroll position. Excluding each child forces the browser to anchor
125
+ // on content outside the grid.
126
+ const container = makeContainer(10);
127
+ const a = makeItem(100);
128
+ const b = makeItem(200);
129
+ container.append(a, b);
130
+ applyMasonryLayout(container, [a, b]);
131
+ expect(a.style.overflowAnchor).toBe('none');
132
+ expect(b.style.overflowAnchor).toBe('none');
133
+ });
134
+
135
+ it('clearMasonryLayout restores overflow-anchor on children', () => {
136
+ const container = makeContainer(10);
137
+ const a = makeItem(100);
138
+ container.append(a);
139
+ applyMasonryLayout(container, [a]);
140
+ clearMasonryLayout(container, [a]);
141
+ expect(a.style.overflowAnchor).toBe('');
142
+ });
143
+
121
144
  it('assigns grid-row-end: span ceil(height + targetGap) to each item', () => {
122
145
  const container = makeContainer(10);
123
146
  const a = makeItem(100);
@@ -173,6 +196,37 @@ describe('applyMasonryLayout', () => {
173
196
  expect(b.style.alignSelf).toBe('start');
174
197
  });
175
198
 
199
+ it('skips re-writing row-spans when measured heights match the cached value', () => {
200
+ // ResizeObserver fires freely on `<img>` decode in Firefox even when the
201
+ // parent's reserved aspect-ratio box hasn't actually changed. Without this
202
+ // short-circuit, every fire triggers a `grid-auto-flow: dense` re-pack —
203
+ // visible as image overlap during fast scroll because Firefox's compositor
204
+ // hasn't caught up with the newly shuffled item positions.
205
+ const container = makeContainer(10);
206
+ const item = makeItem(50);
207
+ container.append(item);
208
+ applyMasonryLayout(container, [item]);
209
+ expect(item.style.gridRowEnd).toBe('span 60');
210
+
211
+ // Simulate something else writing to gridRowEnd — if the polyfill re-runs
212
+ // unnecessarily, this would be overwritten.
213
+ item.style.gridRowEnd = 'span 999';
214
+ applyMasonryLayout(container, [item]);
215
+ expect(item.style.gridRowEnd).toBe('span 999');
216
+ });
217
+
218
+ it('re-writes row-spans when measured heights change', () => {
219
+ const container = makeContainer(10);
220
+ const item = makeItem(50);
221
+ container.append(item);
222
+ applyMasonryLayout(container, [item]);
223
+ expect(item.style.gridRowEnd).toBe('span 60');
224
+
225
+ Object.defineProperty(item, 'offsetHeight', { configurable: true, value: 80 });
226
+ applyMasonryLayout(container, [item]);
227
+ expect(item.style.gridRowEnd).toBe('span 90');
228
+ });
229
+
176
230
  it('clearMasonryLayout undoes container and child styles', () => {
177
231
  const container = makeContainer(10);
178
232
  const item = makeItem(50);
@@ -95,6 +95,16 @@ export type MasonryOptions = {
95
95
  // items pinned to their polyfill-assigned span.
96
96
  const ORIGINAL_COL_ATTR = 'data-bds-masonry-original-column';
97
97
 
98
+ // Data attribute caching the last applied row-span. Used to short-circuit no-op
99
+ // re-runs: when every child's newly-measured span matches the cached value, the
100
+ // polyfill skips all writes. This prevents `grid-auto-flow: dense` from
101
+ // re-packing on spurious ResizeObserver fires (subpixel fluctuations, `<img>`
102
+ // decode in Firefox where offsetHeight briefly wobbles). Dense re-packing
103
+ // during fast scroll in Firefox is visible as images overlapping, because
104
+ // Firefox's compositor paints cached tiles from the pre-shuffle layout on top
105
+ // of the newly laid out items for a frame or two.
106
+ const LAST_SPAN_ATTR = 'data-bds-masonry-span';
107
+
98
108
  export function applyMasonryLayout(
99
109
  container: HTMLElement,
100
110
  rawChildren: HTMLElement[],
@@ -113,24 +123,31 @@ export function applyMasonryLayout(
113
123
  // `dense` + per-child `ResizeObserver` can fight the browser's scroll anchoring:
114
124
  // a subpixel child resize (scrollbar toggling, late image decode, font swap) re-runs
115
125
  // the pack, items above the viewport shift, and the browser yanks the viewport up
116
- // to "follow" them. Opting this container out of scroll anchoring means the browser
117
- // picks something outside the grid as the anchor, so internal reshuffles are invisible
118
- // to scroll position. Cleared in clearMasonryLayout.
126
+ // to "follow" them. `overflow-anchor: none` doesn't inherit, so setting it on the
127
+ // container alone doesn't exclude grid children from anchor selection the browser
128
+ // will still pick one of the `<GridItem>`s as the anchor and drag the viewport when
129
+ // that item's row-span is rewritten. We set it on the container AND every child so
130
+ // the browser picks an anchor outside the grid entirely. Cleared in clearMasonryLayout.
119
131
  container.style.overflowAnchor = 'none';
120
132
 
121
133
  const children = rawChildren.filter(
122
134
  (el) => el.nodeType === 1 && window.getComputedStyle(el).display !== 'none',
123
135
  );
124
136
 
125
- // Pass 1 — clear prior row-span overrides so items measure at their natural height.
126
- // Also force `align-self: start`: the default `stretch` would grow each item to fill
127
- // the 1px track, making `offsetHeight` return 1 for every child. Keeping `start` after
128
- // the span is assigned also prevents items from stretching into the gap region.
137
+ // Pass 1 — force `align-self: start`: the default `stretch` would grow each item
138
+ // to fill the 1px track, making `offsetHeight` return 1 for every child. Also
139
+ // propagate `overflow-anchor: none` to each child (see container comment above).
140
+ //
141
+ // We intentionally do NOT clear `gridRow*` before measuring — with `align-self: start`,
142
+ // an item's `offsetHeight` is its natural content height regardless of the assigned
143
+ // span, so the stale span doesn't bias the measurement. Keeping the old span during
144
+ // the measurement phase means the container's block-size doesn't momentarily collapse
145
+ // (which is the other shape the scroll jump can take: grid height drops → document
146
+ // scrollHeight drops → browser clamps `scrollTop` → visible jump even with
147
+ // `overflow-anchor: none`).
129
148
  for (const child of children) {
130
- child.style.gridRow = '';
131
- child.style.gridRowStart = '';
132
- child.style.gridRowEnd = '';
133
149
  child.style.alignSelf = 'start';
150
+ child.style.overflowAnchor = 'none';
134
151
  }
135
152
 
136
153
  // Preferred-column-span pass — optional. Assigns column spans to `data-column-span-auto`
@@ -173,11 +190,27 @@ export function applyMasonryLayout(
173
190
  // O(N) forced-reflow cost of a read-then-write loop.
174
191
  const heights = children.map((child) => child.offsetHeight);
175
192
 
193
+ // Compute spans; skip the entire write pass if every span matches the
194
+ // previously-applied value. ResizeObserver fires freely in Firefox during
195
+ // image decode and scroll — without this guard, each fire triggers a dense
196
+ // re-pack that's visible as overlap on fast scroll (the compositor hasn't
197
+ // caught up with the newly shuffled positions).
198
+ const spans = heights.map((h) => Math.max(1, Math.ceil(h + targetGap)));
199
+ let changed = false;
200
+ for (let i = 0; i < children.length; i++) {
201
+ const prev = children[i].getAttribute(LAST_SPAN_ATTR);
202
+ if (prev !== String(spans[i])) {
203
+ changed = true;
204
+ break;
205
+ }
206
+ }
207
+ if (!changed) return;
208
+
176
209
  // Pass 3 — batch all row-span writes. No reads in between, so the browser can
177
210
  // coalesce style invalidation and do layout once for the whole batch.
178
211
  for (let i = 0; i < children.length; i++) {
179
- const span = Math.max(1, Math.ceil(heights[i] + targetGap));
180
- children[i].style.gridRowEnd = `span ${span}`;
212
+ children[i].style.gridRowEnd = `span ${spans[i]}`;
213
+ children[i].setAttribute(LAST_SPAN_ATTR, String(spans[i]));
181
214
  }
182
215
  }
183
216
 
@@ -222,6 +255,8 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
222
255
  child.style.gridRowStart = '';
223
256
  child.style.gridRowEnd = '';
224
257
  child.style.alignSelf = '';
258
+ child.style.overflowAnchor = '';
259
+ child.removeAttribute(LAST_SPAN_ATTR);
225
260
  // Restore any `gridColumn` value the polyfill overwrote. Items the
226
261
  // polyfill never touched have no ORIGINAL_COL_ATTR and are left alone.
227
262
  restoreOriginalColumn(child);
@@ -251,31 +286,55 @@ export function useMasonry(
251
286
  if (supportsNativeMasonry()) return;
252
287
 
253
288
  let frame = 0;
254
- const schedule = () => {
289
+ const runNow = () => {
255
290
  cancelAnimationFrame(frame);
256
- frame = requestAnimationFrame(() => {
257
- applyMasonryLayout(container, Array.from(container.children) as HTMLElement[], {
258
- preferredColumnSpan,
259
- });
291
+ applyMasonryLayout(container, Array.from(container.children) as HTMLElement[], {
292
+ preferredColumnSpan,
260
293
  });
261
294
  };
295
+ const schedule = () => {
296
+ cancelAnimationFrame(frame);
297
+ frame = requestAnimationFrame(runNow);
298
+ };
262
299
 
263
300
  schedule();
264
301
 
302
+ // ResizeObserver fires during the "update the rendering" step, AFTER layout and
303
+ // BEFORE paint. Running the polyfill synchronously here means the paint that
304
+ // follows uses the freshly-computed row-spans — not the next frame's. If we
305
+ // defer to rAF instead, there's a visible frame where a grown item (e.g. a
306
+ // lazy-loaded image whose dimensions just resolved) overflows its stale cell
307
+ // and visually overlaps the next item in its column — easy to see on fast
308
+ // scroll through a long gallery.
265
309
  const ro =
266
- typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null;
310
+ typeof ResizeObserver !== 'undefined' ? new ResizeObserver(runNow) : null;
311
+ const observeDescendants = (child: Element) => {
312
+ if (!ro) return;
313
+ ro.observe(child);
314
+ // Also observe media descendants directly. `loading="lazy"` images decode
315
+ // asynchronously; if the consumer hasn't reserved aspect ratio, the parent
316
+ // GridItem's size only changes once the image pushes it larger — by which
317
+ // point the next frame has painted with the stale cell. Observing the image
318
+ // itself means the polyfill re-runs in the same frame as the decode.
319
+ for (const media of Array.from(child.querySelectorAll('img, video'))) {
320
+ ro.observe(media);
321
+ }
322
+ };
267
323
  if (ro) {
268
324
  // Observe each child's size, not the container — re-running when the container
269
325
  // resizes causes a feedback loop (we set grid-auto-rows, which changes container
270
326
  // block-size, which fires the observer). Child-size changes are what we actually
271
327
  // need to respond to.
272
- for (const child of Array.from(container.children)) ro.observe(child);
328
+ for (const child of Array.from(container.children)) observeDescendants(child);
273
329
  }
274
330
 
331
+ // MutationObserver fires as a microtask after DOM mutations — layout hasn't
332
+ // happened yet, so defer to rAF to let the browser lay out the new children
333
+ // before we measure them.
275
334
  const mo =
276
335
  typeof MutationObserver !== 'undefined'
277
336
  ? new MutationObserver(() => {
278
- if (ro) for (const child of Array.from(container.children)) ro.observe(child);
337
+ for (const child of Array.from(container.children)) observeDescendants(child);
279
338
  schedule();
280
339
  })
281
340
  : null;