@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.
- package/dist/client.cjs +84 -64
- package/dist/client.css +537 -537
- package/dist/client.js +84 -64
- package/dist/index.cjs +84 -64
- package/dist/index.css +537 -537
- package/dist/index.js +84 -64
- package/dist/web-components/index.js +16 -5
- package/package.json +1 -1
- package/src/components/layout/Grid/Grid.spec.tsx +54 -0
- package/src/components/layout/Grid/masonry.ts +79 -20
|
@@ -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
|
-
|
|
205
|
-
children[i3].
|
|
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
|
@@ -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.
|
|
117
|
-
//
|
|
118
|
-
//
|
|
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 —
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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
|
-
|
|
180
|
-
children[i].
|
|
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
|
|
289
|
+
const runNow = () => {
|
|
255
290
|
cancelAnimationFrame(frame);
|
|
256
|
-
|
|
257
|
-
|
|
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(
|
|
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))
|
|
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
|
-
|
|
337
|
+
for (const child of Array.from(container.children)) observeDescendants(child);
|
|
279
338
|
schedule();
|
|
280
339
|
})
|
|
281
340
|
: null;
|