@boostdev/design-system-components 2.4.0 → 2.4.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.
@@ -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);
@@ -113,24 +113,31 @@ export function applyMasonryLayout(
113
113
  // `dense` + per-child `ResizeObserver` can fight the browser's scroll anchoring:
114
114
  // a subpixel child resize (scrollbar toggling, late image decode, font swap) re-runs
115
115
  // 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.
116
+ // to "follow" them. `overflow-anchor: none` doesn't inherit, so setting it on the
117
+ // container alone doesn't exclude grid children from anchor selection the browser
118
+ // will still pick one of the `<GridItem>`s as the anchor and drag the viewport when
119
+ // that item's row-span is rewritten. We set it on the container AND every child so
120
+ // the browser picks an anchor outside the grid entirely. Cleared in clearMasonryLayout.
119
121
  container.style.overflowAnchor = 'none';
120
122
 
121
123
  const children = rawChildren.filter(
122
124
  (el) => el.nodeType === 1 && window.getComputedStyle(el).display !== 'none',
123
125
  );
124
126
 
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.
127
+ // Pass 1 — force `align-self: start`: the default `stretch` would grow each item
128
+ // to fill the 1px track, making `offsetHeight` return 1 for every child. Also
129
+ // propagate `overflow-anchor: none` to each child (see container comment above).
130
+ //
131
+ // We intentionally do NOT clear `gridRow*` before measuring — with `align-self: start`,
132
+ // an item's `offsetHeight` is its natural content height regardless of the assigned
133
+ // span, so the stale span doesn't bias the measurement. Keeping the old span during
134
+ // the measurement phase means the container's block-size doesn't momentarily collapse
135
+ // (which is the other shape the scroll jump can take: grid height drops → document
136
+ // scrollHeight drops → browser clamps `scrollTop` → visible jump even with
137
+ // `overflow-anchor: none`).
129
138
  for (const child of children) {
130
- child.style.gridRow = '';
131
- child.style.gridRowStart = '';
132
- child.style.gridRowEnd = '';
133
139
  child.style.alignSelf = 'start';
140
+ child.style.overflowAnchor = 'none';
134
141
  }
135
142
 
136
143
  // Preferred-column-span pass — optional. Assigns column spans to `data-column-span-auto`
@@ -222,6 +229,7 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
222
229
  child.style.gridRowStart = '';
223
230
  child.style.gridRowEnd = '';
224
231
  child.style.alignSelf = '';
232
+ child.style.overflowAnchor = '';
225
233
  // Restore any `gridColumn` value the polyfill overwrote. Items the
226
234
  // polyfill never touched have no ORIGINAL_COL_ATTR and are left alone.
227
235
  restoreOriginalColumn(child);
@@ -251,31 +259,55 @@ export function useMasonry(
251
259
  if (supportsNativeMasonry()) return;
252
260
 
253
261
  let frame = 0;
254
- const schedule = () => {
262
+ const runNow = () => {
255
263
  cancelAnimationFrame(frame);
256
- frame = requestAnimationFrame(() => {
257
- applyMasonryLayout(container, Array.from(container.children) as HTMLElement[], {
258
- preferredColumnSpan,
259
- });
264
+ applyMasonryLayout(container, Array.from(container.children) as HTMLElement[], {
265
+ preferredColumnSpan,
260
266
  });
261
267
  };
268
+ const schedule = () => {
269
+ cancelAnimationFrame(frame);
270
+ frame = requestAnimationFrame(runNow);
271
+ };
262
272
 
263
273
  schedule();
264
274
 
275
+ // ResizeObserver fires during the "update the rendering" step, AFTER layout and
276
+ // BEFORE paint. Running the polyfill synchronously here means the paint that
277
+ // follows uses the freshly-computed row-spans — not the next frame's. If we
278
+ // defer to rAF instead, there's a visible frame where a grown item (e.g. a
279
+ // lazy-loaded image whose dimensions just resolved) overflows its stale cell
280
+ // and visually overlaps the next item in its column — easy to see on fast
281
+ // scroll through a long gallery.
265
282
  const ro =
266
- typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null;
283
+ typeof ResizeObserver !== 'undefined' ? new ResizeObserver(runNow) : null;
284
+ const observeDescendants = (child: Element) => {
285
+ if (!ro) return;
286
+ ro.observe(child);
287
+ // Also observe media descendants directly. `loading="lazy"` images decode
288
+ // asynchronously; if the consumer hasn't reserved aspect ratio, the parent
289
+ // GridItem's size only changes once the image pushes it larger — by which
290
+ // point the next frame has painted with the stale cell. Observing the image
291
+ // itself means the polyfill re-runs in the same frame as the decode.
292
+ for (const media of Array.from(child.querySelectorAll('img, video'))) {
293
+ ro.observe(media);
294
+ }
295
+ };
267
296
  if (ro) {
268
297
  // Observe each child's size, not the container — re-running when the container
269
298
  // resizes causes a feedback loop (we set grid-auto-rows, which changes container
270
299
  // block-size, which fires the observer). Child-size changes are what we actually
271
300
  // need to respond to.
272
- for (const child of Array.from(container.children)) ro.observe(child);
301
+ for (const child of Array.from(container.children)) observeDescendants(child);
273
302
  }
274
303
 
304
+ // MutationObserver fires as a microtask after DOM mutations — layout hasn't
305
+ // happened yet, so defer to rAF to let the browser lay out the new children
306
+ // before we measure them.
275
307
  const mo =
276
308
  typeof MutationObserver !== 'undefined'
277
309
  ? new MutationObserver(() => {
278
- if (ro) for (const child of Array.from(container.children)) ro.observe(child);
310
+ for (const child of Array.from(container.children)) observeDescendants(child);
279
311
  schedule();
280
312
  })
281
313
  : null;