@boostdev/design-system-components 2.4.2 → 2.5.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/AGENTS.md +4 -20
- package/README.md +3 -23
- package/dist/client.cjs +115 -61
- package/dist/client.css +537 -537
- package/dist/client.d.cts +11 -1
- package/dist/client.d.ts +11 -1
- package/dist/client.js +115 -61
- package/dist/index.cjs +115 -61
- package/dist/index.css +537 -537
- package/dist/index.d.cts +11 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +115 -61
- package/dist/web-components/index.js +23 -3
- package/package.json +2 -10
- package/src/components/layout/Grid/Grid.mdx +20 -2
- package/src/components/layout/Grid/Grid.spec.tsx +194 -4
- package/src/components/layout/Grid/Grid.tsx +15 -1
- package/src/components/layout/Grid/masonry.ts +127 -9
- package/dist/native/index.cjs +0 -4337
- package/dist/native/index.d.cts +0 -574
- package/dist/native/index.d.ts +0 -574
- package/dist/native/index.js +0 -4421
- package/src/components/interaction/Button/Button.native.mdx +0 -91
- package/src/components/interaction/Button/Button.native.spec.tsx +0 -55
- package/src/components/interaction/Button/Button.native.stories.tsx +0 -141
- package/src/components/interaction/Button/Button.native.tsx +0 -105
- package/src/components/interaction/Dialog/Dialog.native.mdx +0 -61
- package/src/components/interaction/Dialog/Dialog.native.spec.tsx +0 -73
- package/src/components/interaction/Dialog/Dialog.native.stories.tsx +0 -53
- package/src/components/interaction/Dialog/Dialog.native.tsx +0 -128
- package/src/components/interaction/Drawer/Drawer.native.mdx +0 -58
- package/src/components/interaction/Drawer/Drawer.native.spec.tsx +0 -81
- package/src/components/interaction/Drawer/Drawer.native.stories.tsx +0 -33
- package/src/components/interaction/Drawer/Drawer.native.tsx +0 -175
- package/src/components/interaction/DropdownMenu/DropdownMenu.native.mdx +0 -74
- package/src/components/interaction/DropdownMenu/DropdownMenu.native.spec.tsx +0 -78
- package/src/components/interaction/DropdownMenu/DropdownMenu.native.stories.tsx +0 -51
- package/src/components/interaction/DropdownMenu/DropdownMenu.native.tsx +0 -254
- package/src/components/interaction/Popover/Popover.native.mdx +0 -61
- package/src/components/interaction/Popover/Popover.native.spec.tsx +0 -73
- package/src/components/interaction/Popover/Popover.native.stories.tsx +0 -44
- package/src/components/interaction/Popover/Popover.native.tsx +0 -87
- package/src/components/interaction/Rating/Rating.native.mdx +0 -55
- package/src/components/interaction/Rating/Rating.native.spec.tsx +0 -38
- package/src/components/interaction/Rating/Rating.native.stories.tsx +0 -37
- package/src/components/interaction/Rating/Rating.native.tsx +0 -50
- package/src/components/interaction/Toast/Toast.native.mdx +0 -81
- package/src/components/interaction/Toast/Toast.native.spec.tsx +0 -80
- package/src/components/interaction/Toast/Toast.native.stories.tsx +0 -47
- package/src/components/interaction/Toast/Toast.native.tsx +0 -202
- package/src/components/interaction/form/Checkbox/Checkbox.native.mdx +0 -74
- package/src/components/interaction/form/Checkbox/Checkbox.native.spec.tsx +0 -42
- package/src/components/interaction/form/Checkbox/Checkbox.native.stories.tsx +0 -34
- package/src/components/interaction/form/Checkbox/Checkbox.native.tsx +0 -80
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.native.mdx +0 -78
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.native.spec.tsx +0 -57
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.native.stories.tsx +0 -96
- package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.native.tsx +0 -61
- package/src/components/interaction/form/Combobox/Combobox.native.mdx +0 -86
- package/src/components/interaction/form/Combobox/Combobox.native.spec.tsx +0 -60
- package/src/components/interaction/form/Combobox/Combobox.native.stories.tsx +0 -99
- package/src/components/interaction/form/Combobox/Combobox.native.tsx +0 -211
- package/src/components/interaction/form/FileInput/FileInput.native.mdx +0 -86
- package/src/components/interaction/form/FileInput/FileInput.native.spec.tsx +0 -73
- package/src/components/interaction/form/FileInput/FileInput.native.stories.tsx +0 -69
- package/src/components/interaction/form/FileInput/FileInput.native.tsx +0 -129
- package/src/components/interaction/form/FormInput/FormInput.native.mdx +0 -81
- package/src/components/interaction/form/FormInput/FormInput.native.spec.tsx +0 -51
- package/src/components/interaction/form/FormInput/FormInput.native.stories.tsx +0 -54
- package/src/components/interaction/form/FormInput/FormInput.native.tsx +0 -122
- package/src/components/interaction/form/NumberInput/NumberInput.native.mdx +0 -87
- package/src/components/interaction/form/NumberInput/NumberInput.native.spec.tsx +0 -63
- package/src/components/interaction/form/NumberInput/NumberInput.native.stories.tsx +0 -63
- package/src/components/interaction/form/NumberInput/NumberInput.native.tsx +0 -198
- package/src/components/interaction/form/Radio/Radio.native.spec.tsx +0 -79
- package/src/components/interaction/form/Radio/Radio.native.stories.tsx +0 -44
- package/src/components/interaction/form/Radio/Radio.native.tsx +0 -79
- package/src/components/interaction/form/RadioGroup/RadioGroup.native.mdx +0 -79
- package/src/components/interaction/form/RadioGroup/RadioGroup.native.spec.tsx +0 -66
- package/src/components/interaction/form/RadioGroup/RadioGroup.native.stories.tsx +0 -96
- package/src/components/interaction/form/RadioGroup/RadioGroup.native.tsx +0 -61
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.native.mdx +0 -78
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.native.spec.tsx +0 -82
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.native.stories.tsx +0 -99
- package/src/components/interaction/form/SegmentedControl/SegmentedControl.native.tsx +0 -121
- package/src/components/interaction/form/Select/Select.native.mdx +0 -85
- package/src/components/interaction/form/Select/Select.native.spec.tsx +0 -64
- package/src/components/interaction/form/Select/Select.native.stories.tsx +0 -75
- package/src/components/interaction/form/Select/Select.native.tsx +0 -252
- package/src/components/interaction/form/Slider/Slider.native.mdx +0 -83
- package/src/components/interaction/form/Slider/Slider.native.spec.tsx +0 -51
- package/src/components/interaction/form/Slider/Slider.native.stories.tsx +0 -58
- package/src/components/interaction/form/Slider/Slider.native.tsx +0 -188
- package/src/components/interaction/form/Switch/Switch.native.spec.tsx +0 -60
- package/src/components/interaction/form/Switch/Switch.native.stories.tsx +0 -33
- package/src/components/interaction/form/Switch/Switch.native.tsx +0 -59
- package/src/components/interaction/form/Textarea/Textarea.native.mdx +0 -76
- package/src/components/interaction/form/Textarea/Textarea.native.spec.tsx +0 -50
- package/src/components/interaction/form/Textarea/Textarea.native.stories.tsx +0 -50
- package/src/components/interaction/form/Textarea/Textarea.native.tsx +0 -108
- package/src/components/interaction/form/atoms/Label.native.spec.tsx +0 -39
- package/src/components/interaction/form/atoms/Label.native.stories.tsx +0 -18
- package/src/components/interaction/form/atoms/Label.native.tsx +0 -29
- package/src/components/interaction/form/atoms/Message.native.spec.tsx +0 -30
- package/src/components/interaction/form/atoms/Message.native.stories.tsx +0 -22
- package/src/components/interaction/form/atoms/Message.native.tsx +0 -34
- package/src/components/layout/ButtonGroup/ButtonGroup.native.spec.tsx +0 -83
- package/src/components/layout/ButtonGroup/ButtonGroup.native.stories.tsx +0 -44
- package/src/components/layout/ButtonGroup/ButtonGroup.native.tsx +0 -44
- package/src/components/layout/Card/Card.native.spec.tsx +0 -102
- package/src/components/layout/Card/Card.native.stories.tsx +0 -51
- package/src/components/layout/Card/Card.native.tsx +0 -89
- package/src/components/layout/IconWrapper/IconWrapper.native.spec.tsx +0 -39
- package/src/components/layout/IconWrapper/IconWrapper.native.stories.tsx +0 -37
- package/src/components/layout/IconWrapper/IconWrapper.native.tsx +0 -20
- package/src/components/layout/SectionHeader/SectionHeader.native.spec.tsx +0 -51
- package/src/components/layout/SectionHeader/SectionHeader.native.stories.tsx +0 -36
- package/src/components/layout/SectionHeader/SectionHeader.native.tsx +0 -79
- package/src/components/ui/Accordion/Accordion.native.mdx +0 -65
- package/src/components/ui/Accordion/Accordion.native.spec.tsx +0 -69
- package/src/components/ui/Accordion/Accordion.native.stories.tsx +0 -52
- package/src/components/ui/Accordion/Accordion.native.tsx +0 -141
- package/src/components/ui/Alert/Alert.native.mdx +0 -62
- package/src/components/ui/Alert/Alert.native.spec.tsx +0 -76
- package/src/components/ui/Alert/Alert.native.stories.tsx +0 -41
- package/src/components/ui/Alert/Alert.native.tsx +0 -94
- package/src/components/ui/Avatar/Avatar.native.mdx +0 -50
- package/src/components/ui/Avatar/Avatar.native.spec.tsx +0 -47
- package/src/components/ui/Avatar/Avatar.native.stories.tsx +0 -32
- package/src/components/ui/Avatar/Avatar.native.tsx +0 -85
- package/src/components/ui/Badge/Badge.native.mdx +0 -54
- package/src/components/ui/Badge/Badge.native.spec.tsx +0 -45
- package/src/components/ui/Badge/Badge.native.stories.tsx +0 -34
- package/src/components/ui/Badge/Badge.native.tsx +0 -50
- package/src/components/ui/Breadcrumb/Breadcrumb.native.mdx +0 -52
- package/src/components/ui/Breadcrumb/Breadcrumb.native.spec.tsx +0 -49
- package/src/components/ui/Breadcrumb/Breadcrumb.native.stories.tsx +0 -46
- package/src/components/ui/Breadcrumb/Breadcrumb.native.tsx +0 -83
- package/src/components/ui/Calendar/Calendar.native.mdx +0 -58
- package/src/components/ui/Calendar/Calendar.native.spec.tsx +0 -92
- package/src/components/ui/Calendar/Calendar.native.stories.tsx +0 -45
- package/src/components/ui/Calendar/Calendar.native.tsx +0 -294
- package/src/components/ui/Carousel/Carousel.native.mdx +0 -55
- package/src/components/ui/Carousel/Carousel.native.spec.tsx +0 -55
- package/src/components/ui/Carousel/Carousel.native.stories.tsx +0 -59
- package/src/components/ui/Carousel/Carousel.native.tsx +0 -184
- package/src/components/ui/Collapsible/Collapsible.native.mdx +0 -53
- package/src/components/ui/Collapsible/Collapsible.native.spec.tsx +0 -68
- package/src/components/ui/Collapsible/Collapsible.native.stories.tsx +0 -48
- package/src/components/ui/Collapsible/Collapsible.native.tsx +0 -139
- package/src/components/ui/DescriptionList/DescriptionList.native.mdx +0 -50
- package/src/components/ui/DescriptionList/DescriptionList.native.spec.tsx +0 -37
- package/src/components/ui/DescriptionList/DescriptionList.native.stories.tsx +0 -48
- package/src/components/ui/DescriptionList/DescriptionList.native.tsx +0 -54
- package/src/components/ui/Link/Link.native.mdx +0 -53
- package/src/components/ui/Link/Link.native.spec.tsx +0 -35
- package/src/components/ui/Link/Link.native.stories.tsx +0 -46
- package/src/components/ui/Link/Link.native.tsx +0 -48
- package/src/components/ui/Loading/Loading.native.spec.tsx +0 -24
- package/src/components/ui/Loading/Loading.native.stories.tsx +0 -31
- package/src/components/ui/Loading/Loading.native.tsx +0 -29
- package/src/components/ui/NotificationBanner/NotificationBanner.native.spec.tsx +0 -85
- package/src/components/ui/NotificationBanner/NotificationBanner.native.stories.tsx +0 -37
- package/src/components/ui/NotificationBanner/NotificationBanner.native.tsx +0 -76
- package/src/components/ui/Pagination/Pagination.native.mdx +0 -62
- package/src/components/ui/Pagination/Pagination.native.spec.tsx +0 -86
- package/src/components/ui/Pagination/Pagination.native.stories.tsx +0 -47
- package/src/components/ui/Pagination/Pagination.native.tsx +0 -157
- package/src/components/ui/Progress/Progress.native.spec.tsx +0 -56
- package/src/components/ui/Progress/Progress.native.stories.tsx +0 -34
- package/src/components/ui/Progress/Progress.native.tsx +0 -84
- package/src/components/ui/ProgressCircle/ProgressCircle.native.mdx +0 -63
- package/src/components/ui/ProgressCircle/ProgressCircle.native.spec.tsx +0 -39
- package/src/components/ui/ProgressCircle/ProgressCircle.native.stories.tsx +0 -36
- package/src/components/ui/ProgressCircle/ProgressCircle.native.tsx +0 -176
- package/src/components/ui/Separator/Separator.native.spec.tsx +0 -39
- package/src/components/ui/Separator/Separator.native.stories.tsx +0 -38
- package/src/components/ui/Separator/Separator.native.tsx +0 -32
- package/src/components/ui/Skeleton/Skeleton.native.spec.tsx +0 -39
- package/src/components/ui/Skeleton/Skeleton.native.stories.tsx +0 -31
- package/src/components/ui/Skeleton/Skeleton.native.tsx +0 -42
- package/src/components/ui/Table/Table.native.mdx +0 -72
- package/src/components/ui/Table/Table.native.spec.tsx +0 -83
- package/src/components/ui/Table/Table.native.stories.tsx +0 -55
- package/src/components/ui/Table/Table.native.tsx +0 -196
- package/src/components/ui/Tabs/Tabs.native.mdx +0 -65
- package/src/components/ui/Tabs/Tabs.native.spec.tsx +0 -65
- package/src/components/ui/Tabs/Tabs.native.stories.tsx +0 -57
- package/src/components/ui/Tabs/Tabs.native.tsx +0 -133
- package/src/components/ui/Tooltip/Tooltip.native.mdx +0 -47
- package/src/components/ui/Tooltip/Tooltip.native.spec.tsx +0 -37
- package/src/components/ui/Tooltip/Tooltip.native.stories.tsx +0 -38
- package/src/components/ui/Tooltip/Tooltip.native.tsx +0 -57
- package/src/components/ui/Typography/Typography.native.mdx +0 -56
- package/src/components/ui/Typography/Typography.native.spec.tsx +0 -49
- package/src/components/ui/Typography/Typography.native.stories.tsx +0 -36
- package/src/components/ui/Typography/Typography.native.tsx +0 -65
- package/src/native/ThemeContext.tsx +0 -28
- package/src/native/tokens.ts +0 -13
- package/src/native.ts +0 -76
- package/src/stories/ReactNative.mdx +0 -156
|
@@ -151,17 +151,17 @@ function resolvePreferredColumns(container, value, totalCols) {
|
|
|
151
151
|
if (Number.isFinite(n) && n > 0) return Math.min(totalCols, n);
|
|
152
152
|
return Math.max(1, Math.round(totalCols * NAMED_SPAN_FRACTION[value]));
|
|
153
153
|
}
|
|
154
|
-
var
|
|
154
|
+
var TARGET_MAX_IMPLICIT_ROWS = 5e3;
|
|
155
155
|
function supportsNativeMasonry() {
|
|
156
156
|
if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return false;
|
|
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
160
|
var LAST_SPAN_ATTR = "data-bds-masonry-span";
|
|
161
|
+
var UNIT_ATTR = "data-bds-masonry-unit";
|
|
161
162
|
function applyMasonryLayout(container, rawChildren, options = {}) {
|
|
162
163
|
const cs = window.getComputedStyle(container);
|
|
163
164
|
const targetGap = parseFloat(cs.columnGap) || 0;
|
|
164
|
-
container.style.gridAutoRows = `${ROW_UNIT_PX}px`;
|
|
165
165
|
container.style.rowGap = "0px";
|
|
166
166
|
container.style.gridAutoFlow = "dense";
|
|
167
167
|
container.style.overflowAnchor = "none";
|
|
@@ -199,7 +199,26 @@ function applyMasonryLayout(container, rawChildren, options = {}) {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
const heights = children.map((child) => child.offsetHeight);
|
|
202
|
-
const
|
|
202
|
+
const cols = countGridColumns(container) || 1;
|
|
203
|
+
const projectedRows = children.reduce((sum, child, i3) => {
|
|
204
|
+
const rowSpan = Math.max(1, Math.ceil(heights[i3] + targetGap));
|
|
205
|
+
const colSpan = readExplicitSpan(child, cols);
|
|
206
|
+
return sum + rowSpan * colSpan;
|
|
207
|
+
}, 0) / cols;
|
|
208
|
+
const autoUnit = Math.max(
|
|
209
|
+
1,
|
|
210
|
+
Math.ceil(projectedRows / TARGET_MAX_IMPLICIT_ROWS)
|
|
211
|
+
);
|
|
212
|
+
const rowUnit = options.rowUnitPx !== void 0 && options.rowUnitPx > 0 ? Math.floor(options.rowUnitPx) : autoUnit;
|
|
213
|
+
container.style.gridAutoRows = `${rowUnit}px`;
|
|
214
|
+
const prevUnit = container.getAttribute(UNIT_ATTR);
|
|
215
|
+
if (prevUnit !== String(rowUnit)) {
|
|
216
|
+
for (const child of children) child.removeAttribute(LAST_SPAN_ATTR);
|
|
217
|
+
container.setAttribute(UNIT_ATTR, String(rowUnit));
|
|
218
|
+
}
|
|
219
|
+
const spans = heights.map(
|
|
220
|
+
(h) => Math.max(1, Math.ceil((h + targetGap) / rowUnit))
|
|
221
|
+
);
|
|
203
222
|
let changed = false;
|
|
204
223
|
for (let i3 = 0; i3 < children.length; i3++) {
|
|
205
224
|
const prev = children[i3].getAttribute(LAST_SPAN_ATTR);
|
|
@@ -238,6 +257,7 @@ function clearMasonryLayout(container, rawChildren) {
|
|
|
238
257
|
container.style.gridAutoFlow = "";
|
|
239
258
|
container.style.rowGap = "";
|
|
240
259
|
container.style.overflowAnchor = "";
|
|
260
|
+
container.removeAttribute(UNIT_ATTR);
|
|
241
261
|
for (const child of rawChildren) {
|
|
242
262
|
if (child.nodeType !== 1) continue;
|
|
243
263
|
child.style.gridRow = "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boostdev/design-system-components",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "BoostDev React component library: accessible, token-driven components built on @boostdev/design-system-foundation",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"React",
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
"type": "module",
|
|
27
27
|
"exports": {
|
|
28
28
|
".": {
|
|
29
|
-
"react-native": "./dist/native/index.js",
|
|
30
29
|
"types": "./dist/index.d.ts",
|
|
31
30
|
"import": "./dist/index.js",
|
|
32
31
|
"require": "./dist/index.cjs"
|
|
@@ -262,7 +261,6 @@
|
|
|
262
261
|
"./css/section-header": "./src/components/layout/SectionHeader/SectionHeader.module.css"
|
|
263
262
|
},
|
|
264
263
|
"main": "./dist/index.js",
|
|
265
|
-
"react-native": "./dist/native/index.js",
|
|
266
264
|
"types": "./dist/index.d.ts",
|
|
267
265
|
"files": [
|
|
268
266
|
"dist",
|
|
@@ -297,14 +295,12 @@
|
|
|
297
295
|
"@types/node": "^25.5.0",
|
|
298
296
|
"@types/react": "^19",
|
|
299
297
|
"@types/react-dom": "^19",
|
|
300
|
-
"@types/react-native": "^0.73.0",
|
|
301
298
|
"@vitejs/plugin-react": "^5.1.4",
|
|
302
299
|
"eslint": "^9",
|
|
303
300
|
"git-cliff": "^2.12.0",
|
|
304
301
|
"identity-obj-proxy": "^3.0.0",
|
|
305
302
|
"jsdom": "^28.1.0",
|
|
306
303
|
"lit": "^3.3.2",
|
|
307
|
-
"react-native-web": "^0.21.2",
|
|
308
304
|
"remark-gfm": "^4.0.1",
|
|
309
305
|
"storybook": "^8.6.18",
|
|
310
306
|
"storybook-addon-tag-badges": "^1.4.0",
|
|
@@ -321,15 +317,11 @@
|
|
|
321
317
|
"peerDependencies": {
|
|
322
318
|
"@boostdev/design-system-foundation": ">=2.0.0",
|
|
323
319
|
"react": ">=18",
|
|
324
|
-
"react-dom": ">=18"
|
|
325
|
-
"react-native": ">=0.70"
|
|
320
|
+
"react-dom": ">=18"
|
|
326
321
|
},
|
|
327
322
|
"peerDependenciesMeta": {
|
|
328
323
|
"react-dom": {
|
|
329
324
|
"optional": true
|
|
330
|
-
},
|
|
331
|
-
"react-native": {
|
|
332
|
-
"optional": true
|
|
333
325
|
}
|
|
334
326
|
},
|
|
335
327
|
"publishConfig": {
|
|
@@ -131,7 +131,7 @@ Set `isMasonry` on any `Grid` to switch to a masonry (a.k.a. Pinterest-style) la
|
|
|
131
131
|
Masonry targets [CSS Grid Layout Module Level 3](https://www.w3.org/TR/css-grid-3/) ("grid lanes"):
|
|
132
132
|
|
|
133
133
|
- **Native path** — when the browser supports `display: grid-lanes` or `grid-template-rows: masonry`, CSS handles everything and the JS polyfill short-circuits. Currently Safari Technology Preview only; the polyfill runs in all other browsers.
|
|
134
|
-
- **Polyfill path** — row-span packing. The container's row tracks are collapsed to a
|
|
134
|
+
- **Polyfill path** — row-span packing. The container's row tracks are collapsed to a small auto-track with `grid-auto-flow: dense`, and each item is given a `grid-row-end: span ceil((height + gap) / rowUnit)`. Items stay in **normal document flow** (no absolute positioning), so surrounding content — footers, scroll anchors, page margins — lays out correctly around the grid. The row-unit defaults to 1 px; for very long galleries the polyfill auto-grows it — see [Row-unit auto-tuner](#row-unit-auto-tuner).
|
|
135
135
|
|
|
136
136
|
The polyfill re-runs on child size changes (`ResizeObserver` on each child) and child addition/removal (`MutationObserver` on the container). The container itself is not observed — setting row-spans changes the container block-size, which would loop back through the observer.
|
|
137
137
|
|
|
@@ -145,7 +145,7 @@ The polyfill re-runs on child size changes (`ResizeObserver` on each child) and
|
|
|
145
145
|
|
|
146
146
|
- **Initial layout** — three passes (clear, batch-read all heights, batch-write all spans). Height reads and writes are each grouped to avoid O(N) forced reflows; the measured cost is one layout pass regardless of item count.
|
|
147
147
|
- **Re-layout** — on child resize or mutation the polyfill is scheduled via `requestAnimationFrame`, so bursts of observer callbacks coalesce into a single re-run per frame.
|
|
148
|
-
- **Ceil overshoot** — the 1 px row track
|
|
148
|
+
- **Ceil overshoot** — at the default 1 px row track each item's span can overshoot by up to 1 px. Not visible in practice (row-gap is 0; the gap is baked into each item's span). The auto-tuner may grow the row-unit for very long galleries — overshoot is then bounded by the chosen unit (still imperceptible at the unit sizes the auto-tuner picks).
|
|
149
149
|
- **Long lists** — for galleries with hundreds of items, virtualisation is still the correct answer; masonry is orthogonal.
|
|
150
150
|
|
|
151
151
|
### SSR / hydration
|
|
@@ -237,6 +237,24 @@ Rules:
|
|
|
237
237
|
|
|
238
238
|
Column detection runs inside the masonry polyfill's `useLayoutEffect`, so it only executes on the client after mount. The initial server render emits each auto item at its default `full` span until hydration resolves the first polyfill pass — which matches the existing masonry SSR behavior.
|
|
239
239
|
|
|
240
|
+
## Row-unit auto-tuner
|
|
241
|
+
|
|
242
|
+
Browsers cap the implicit grid track count for resource protection — historically Chromium at 1000 and Firefox at ~10000 tracks. Past that cap the engine clamps or wraps items, which the user sees as overlapping thumbnails toward the bottom of a long masonry stack. The polyfill expresses each item's height as a row-span (`ceil((height + gap) / rowUnit)`), so a long enough gallery at the default 1 px row-unit will hit the cap.
|
|
243
|
+
|
|
244
|
+
To stay safely below it, the polyfill auto-tunes the row-unit on every layout pass:
|
|
245
|
+
|
|
246
|
+
1. Project the implicit row count for the current children using the live column count (`sum(spans) / columns`).
|
|
247
|
+
2. If the projection stays under the safe threshold (~5000 rows — 2× headroom under the strictest reasonable cap), keep the row-unit at 1 px and get pixel-accurate row gaps.
|
|
248
|
+
3. If it would exceed the threshold, grow the row-unit just enough to bring the projection back under (`ceil(projectedRows / threshold)`). Each item's span is recomputed against the new unit; the per-child span cache is invalidated automatically when the unit changes between runs.
|
|
249
|
+
|
|
250
|
+
This is automatic — most galleries never trigger it. For the rare case where you've measured the auto-tuner picking the wrong unit, escape with `masonryRowUnitPx`:
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
<Grid isMasonry masonryRowUnitPx={4}>…</Grid>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Trade-off: smaller unit = more pixel-accurate row gaps; larger unit = more headroom under browser caps. Set this **only** when you've confirmed the auto-tuner is wrong for your case — the auto-pick is the right answer for almost every gallery.
|
|
257
|
+
|
|
240
258
|
## Migration from `@greenchoice/design-system`
|
|
241
259
|
|
|
242
260
|
| Greenchoice | BoostDev equivalent |
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react';
|
|
2
2
|
import { act } from 'react';
|
|
3
|
+
import { afterEach, beforeEach, vi } from 'vitest';
|
|
3
4
|
import { Grid } from './Grid';
|
|
4
5
|
import { GridItem, GridItemSpan } from './GridItem';
|
|
5
6
|
import { applyMasonryLayout, clearMasonryLayout } from './masonry';
|
|
@@ -93,9 +94,12 @@ describe('applyMasonryLayout', () => {
|
|
|
93
94
|
return el;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
it('overrides container to grid-auto-
|
|
97
|
+
it('overrides container to grid-auto-flow: dense + row-gap: 0 and picks a row-track unit', () => {
|
|
97
98
|
const container = makeContainer(10);
|
|
98
99
|
applyMasonryLayout(container, []);
|
|
100
|
+
// Auto-tuner picks 1px when the projected row count is well under
|
|
101
|
+
// the implicit-grid cap; long galleries get a larger unit (covered
|
|
102
|
+
// in dedicated tests below).
|
|
99
103
|
expect(container.style.gridAutoRows).toBe('1px');
|
|
100
104
|
expect(container.style.gridAutoFlow).toBe('dense');
|
|
101
105
|
expect(container.style.rowGap).toBe('0px');
|
|
@@ -141,16 +145,15 @@ describe('applyMasonryLayout', () => {
|
|
|
141
145
|
expect(a.style.overflowAnchor).toBe('');
|
|
142
146
|
});
|
|
143
147
|
|
|
144
|
-
it('assigns grid-row-end: span ceil(height + targetGap) to each item', () => {
|
|
148
|
+
it('assigns grid-row-end: span ceil((height + targetGap) / rowUnit) to each item', () => {
|
|
145
149
|
const container = makeContainer(10);
|
|
146
150
|
const a = makeItem(100);
|
|
147
151
|
const b = makeItem(201);
|
|
148
152
|
container.append(a, b);
|
|
149
153
|
applyMasonryLayout(container, [a, b]);
|
|
150
154
|
|
|
151
|
-
//
|
|
155
|
+
// Auto-tuner stays at unit=1 here — projected rows is tiny.
|
|
152
156
|
expect(a.style.gridRowEnd).toBe('span 110');
|
|
153
|
-
// ceil(201 + 10) = 211
|
|
154
157
|
expect(b.style.gridRowEnd).toBe('span 211');
|
|
155
158
|
});
|
|
156
159
|
|
|
@@ -242,6 +245,73 @@ describe('applyMasonryLayout', () => {
|
|
|
242
245
|
expect(item.style.alignSelf).toBe('');
|
|
243
246
|
});
|
|
244
247
|
|
|
248
|
+
describe('row-unit auto-tuner', () => {
|
|
249
|
+
it('grows the row-unit when projected implicit row count would exceed the safe threshold', () => {
|
|
250
|
+
// Build a worst-case scenario: many tall items in a single column so the
|
|
251
|
+
// projection equals sum(spans). 50 items × 600px each = 30,000 row-units
|
|
252
|
+
// at unit=1, well over the 5,000 threshold → auto picks unit=ceil(30000/5000)=6.
|
|
253
|
+
const container = makeContainer(0);
|
|
254
|
+
container.style.gridTemplateColumns = '100px';
|
|
255
|
+
const items = Array.from({ length: 50 }, () => makeItem(600));
|
|
256
|
+
container.append(...items);
|
|
257
|
+
applyMasonryLayout(container, items);
|
|
258
|
+
expect(container.style.gridAutoRows).toBe('6px');
|
|
259
|
+
// span = ceil(600 / 6) = 100
|
|
260
|
+
expect(items[0].style.gridRowEnd).toBe('span 100');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('accounts for column span when projecting implicit row count', () => {
|
|
264
|
+
// Regression: the projection has to weight each item by its column span,
|
|
265
|
+
// otherwise a gallery of half-span items reports half the true row
|
|
266
|
+
// count and the unit stays at 1. 30 items × 600px tall × 6-col span on
|
|
267
|
+
// a 12-col grid = 30 * 600 * 6 / 12 = 9,000 implicit rows; the naive
|
|
268
|
+
// sum(rowSpan)/cols formula would have said 1,500 → unit 1 → overlap in
|
|
269
|
+
// Firefox. Correct: ceil(9000/5000) = 2.
|
|
270
|
+
const container = makeContainer(0);
|
|
271
|
+
container.style.gridTemplateColumns = '100px '.repeat(12).trim();
|
|
272
|
+
const items = Array.from({ length: 30 }, () => {
|
|
273
|
+
const el = makeItem(600);
|
|
274
|
+
el.style.gridColumn = 'span 6';
|
|
275
|
+
return el;
|
|
276
|
+
});
|
|
277
|
+
container.append(...items);
|
|
278
|
+
applyMasonryLayout(container, items);
|
|
279
|
+
expect(container.style.gridAutoRows).toBe('2px');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('honors an explicit rowUnitPx override', () => {
|
|
283
|
+
const container = makeContainer(10);
|
|
284
|
+
const item = makeItem(100);
|
|
285
|
+
container.append(item);
|
|
286
|
+
applyMasonryLayout(container, [item], { rowUnitPx: 8 });
|
|
287
|
+
expect(container.style.gridAutoRows).toBe('8px');
|
|
288
|
+
// ceil((100 + 10) / 8) = 14
|
|
289
|
+
expect(item.style.gridRowEnd).toBe('span 14');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('invalidates the per-child span cache when the row-unit changes between runs', () => {
|
|
293
|
+
const container = makeContainer(10);
|
|
294
|
+
const item = makeItem(100);
|
|
295
|
+
container.append(item);
|
|
296
|
+
applyMasonryLayout(container, [item], { rowUnitPx: 1 });
|
|
297
|
+
expect(item.style.gridRowEnd).toBe('span 110');
|
|
298
|
+
// Switching units must rewrite — without cache invalidation the diff
|
|
299
|
+
// check would short-circuit because the cached "110" still matches the
|
|
300
|
+
// newly-computed value (which would be 14 under unit=8).
|
|
301
|
+
applyMasonryLayout(container, [item], { rowUnitPx: 8 });
|
|
302
|
+
expect(item.style.gridRowEnd).toBe('span 14');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('clearMasonryLayout removes the unit attribute so the next mount re-tunes', () => {
|
|
306
|
+
const container = makeContainer(10);
|
|
307
|
+
const item = makeItem(100);
|
|
308
|
+
container.append(item);
|
|
309
|
+
applyMasonryLayout(container, [item], { rowUnitPx: 8 });
|
|
310
|
+
clearMasonryLayout(container, [item]);
|
|
311
|
+
expect(container.hasAttribute('data-bds-masonry-unit')).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
245
315
|
describe('preferredColumnSpan', () => {
|
|
246
316
|
function makeAutoItem(height: number) {
|
|
247
317
|
const el = makeItem(height);
|
|
@@ -348,6 +418,126 @@ describe('applyMasonryLayout', () => {
|
|
|
348
418
|
});
|
|
349
419
|
});
|
|
350
420
|
|
|
421
|
+
describe('useMasonry scroll debounce', () => {
|
|
422
|
+
// jsdom has no ResizeObserver — install a mock that captures the callback so
|
|
423
|
+
// tests can simulate an image decode firing during/outside of a scroll.
|
|
424
|
+
let roCallback: ResizeObserverCallback | null = null;
|
|
425
|
+
const originalRO = globalThis.ResizeObserver;
|
|
426
|
+
|
|
427
|
+
beforeEach(() => {
|
|
428
|
+
roCallback = null;
|
|
429
|
+
class MockResizeObserver {
|
|
430
|
+
constructor(cb: ResizeObserverCallback) {
|
|
431
|
+
roCallback = cb;
|
|
432
|
+
}
|
|
433
|
+
observe() {}
|
|
434
|
+
unobserve() {}
|
|
435
|
+
disconnect() {}
|
|
436
|
+
}
|
|
437
|
+
(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver =
|
|
438
|
+
MockResizeObserver as unknown as typeof ResizeObserver;
|
|
439
|
+
vi.useFakeTimers();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
afterEach(() => {
|
|
443
|
+
vi.useRealTimers();
|
|
444
|
+
if (originalRO) {
|
|
445
|
+
(globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = originalRO;
|
|
446
|
+
} else {
|
|
447
|
+
delete (globalThis as unknown as Record<string, unknown>).ResizeObserver;
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
function stubHeight(el: Element, height: number) {
|
|
452
|
+
Object.defineProperty(el, 'offsetHeight', { configurable: true, value: height });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
it('defers re-runs while the user is actively scrolling and applies them once scroll idles', () => {
|
|
456
|
+
// Regression: during fast scroll in Firefox, an image decode grows its GridItem,
|
|
457
|
+
// ResizeObserver fires, and the synchronous `grid-auto-flow: dense` re-pack
|
|
458
|
+
// races the compositor — items appear to overlap because cached tiles from the
|
|
459
|
+
// pre-shuffle layout are painted over the new layout for a frame or two.
|
|
460
|
+
const { container } = render(
|
|
461
|
+
<Grid isMasonry>
|
|
462
|
+
<GridItem data-testid="a">a</GridItem>
|
|
463
|
+
</Grid>,
|
|
464
|
+
);
|
|
465
|
+
const item = container.querySelector('[data-testid="a"]') as HTMLElement;
|
|
466
|
+
stubHeight(item, 100);
|
|
467
|
+
|
|
468
|
+
// Flush the initial rAF-scheduled layout pass.
|
|
469
|
+
act(() => {
|
|
470
|
+
vi.advanceTimersByTime(0);
|
|
471
|
+
// rAF in jsdom is backed by setTimeout(..., 0)
|
|
472
|
+
});
|
|
473
|
+
act(() => {
|
|
474
|
+
vi.runAllTimers();
|
|
475
|
+
});
|
|
476
|
+
const initialSpan = item.style.gridRowEnd;
|
|
477
|
+
expect(initialSpan).not.toBe('');
|
|
478
|
+
|
|
479
|
+
// User starts scrolling.
|
|
480
|
+
window.dispatchEvent(new Event('scroll'));
|
|
481
|
+
|
|
482
|
+
// Image decode grows the item; RO fires.
|
|
483
|
+
stubHeight(item, 300);
|
|
484
|
+
act(() => {
|
|
485
|
+
roCallback?.([] as unknown as ResizeObserverEntry[], {} as ResizeObserver);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// Mid-scroll: the new span is NOT applied — deferred until scroll idle.
|
|
489
|
+
expect(item.style.gridRowEnd).toBe(initialSpan);
|
|
490
|
+
|
|
491
|
+
// Scroll stops; after the idle window, the deferred layout pass runs.
|
|
492
|
+
act(() => {
|
|
493
|
+
vi.advanceTimersByTime(200);
|
|
494
|
+
});
|
|
495
|
+
expect(item.style.gridRowEnd).not.toBe(initialSpan);
|
|
496
|
+
expect(item.style.gridRowEnd).toBe('span 300');
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('still lays out newly-mounted children during active scroll', async () => {
|
|
500
|
+
// Regression: deferring the whole polyfill during scroll left skeletons
|
|
501
|
+
// invisible — a child mounted mid-scroll would keep `grid-auto-rows: 1px`
|
|
502
|
+
// and never receive a row-span write, collapsing to 1px tall until scroll
|
|
503
|
+
// stopped. Only RO-triggered resizes of already-laid-out items should
|
|
504
|
+
// defer; initial layout and MutationObserver-driven runs must not.
|
|
505
|
+
const stub = (h: number) => (el: HTMLElement | null) => {
|
|
506
|
+
if (el) stubHeight(el, h);
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
function Harness({ extra = false }: { extra?: boolean }) {
|
|
510
|
+
return (
|
|
511
|
+
<Grid isMasonry>
|
|
512
|
+
<GridItem data-testid="a" ref={stub(100)}>a</GridItem>
|
|
513
|
+
{extra ? <GridItem data-testid="b" ref={stub(80)}>b</GridItem> : null}
|
|
514
|
+
</Grid>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const { container, rerender } = render(<Harness />);
|
|
519
|
+
await act(async () => {
|
|
520
|
+
await vi.runAllTimersAsync();
|
|
521
|
+
});
|
|
522
|
+
const a = container.querySelector('[data-testid="a"]') as HTMLElement;
|
|
523
|
+
expect(a.style.gridRowEnd).not.toBe('');
|
|
524
|
+
|
|
525
|
+
// User is actively scrolling when the new child mounts.
|
|
526
|
+
window.dispatchEvent(new Event('scroll'));
|
|
527
|
+
|
|
528
|
+
await act(async () => {
|
|
529
|
+
rerender(<Harness extra />);
|
|
530
|
+
});
|
|
531
|
+
await act(async () => {
|
|
532
|
+
await vi.runAllTimersAsync();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const b = container.querySelector('[data-testid="b"]') as HTMLElement;
|
|
536
|
+
// The new child must have received a span even though we're mid-scroll.
|
|
537
|
+
expect(b.style.gridRowEnd).toBe('span 80');
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
351
541
|
describe('GridItem', () => {
|
|
352
542
|
it('defaults to full span', () => {
|
|
353
543
|
const { container } = render(<GridItem>x</GridItem>);
|
|
@@ -58,6 +58,16 @@ type GridOwnProps = WithClassName & {
|
|
|
58
58
|
* keep their span.
|
|
59
59
|
*/
|
|
60
60
|
masonryPreferredColumnSpan?: Exclude<GridItemSpanValue, 'auto'>;
|
|
61
|
+
/**
|
|
62
|
+
* Masonry only. Manual override for the row-track unit (in pixels) the
|
|
63
|
+
* polyfill uses for `grid-auto-rows` and as the ceil() granularity of each
|
|
64
|
+
* item's row span. Omit to let the polyfill auto-pick the smallest unit
|
|
65
|
+
* that keeps the implicit row count under any browser cap — that's the
|
|
66
|
+
* right answer for almost every gallery and yields pixel-perfect row gaps
|
|
67
|
+
* when the gallery is short enough to not need rounding. Set this only if
|
|
68
|
+
* you've confirmed the auto-tuner is wrong for your case.
|
|
69
|
+
*/
|
|
70
|
+
masonryRowUnitPx?: number;
|
|
61
71
|
as?: ElementType;
|
|
62
72
|
};
|
|
63
73
|
|
|
@@ -73,12 +83,16 @@ export function Grid<C extends ElementType = 'div'>({
|
|
|
73
83
|
isMasonry = false,
|
|
74
84
|
autoSpanMedia = false,
|
|
75
85
|
masonryPreferredColumnSpan,
|
|
86
|
+
masonryRowUnitPx,
|
|
76
87
|
as,
|
|
77
88
|
style,
|
|
78
89
|
...rest
|
|
79
90
|
}: GridProps<C>) {
|
|
80
91
|
const ref = useRef<HTMLElement>(null);
|
|
81
|
-
useMasonry(ref, isMasonry, {
|
|
92
|
+
useMasonry(ref, isMasonry, {
|
|
93
|
+
preferredColumnSpan: masonryPreferredColumnSpan,
|
|
94
|
+
rowUnitPx: masonryRowUnitPx,
|
|
95
|
+
});
|
|
82
96
|
|
|
83
97
|
const classNames = cn(
|
|
84
98
|
css.grid,
|
|
@@ -65,7 +65,15 @@ function resolvePreferredColumns(
|
|
|
65
65
|
* `grid-template-rows: masonry` value, the polyfill yields to CSS.
|
|
66
66
|
*/
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
// Target ceiling on the implicit grid row count produced by the polyfill.
|
|
69
|
+
// Browsers cap implicit grid tracks for resource-protection — historically
|
|
70
|
+
// Chromium at 1000 and Firefox at ~10000; past the cap, the layout engine
|
|
71
|
+
// clamps/wraps items and they overlap visually (e.g. bottom thumbnails of a
|
|
72
|
+
// long masonry stack on top of each other in Firefox). The auto-tuner picks
|
|
73
|
+
// a `rowUnitPx` so the projected row count stays under this number. Lower
|
|
74
|
+
// is safer; higher gives finer (more pixel-accurate) row gaps. 5000 leaves
|
|
75
|
+
// 2× headroom under the strictest reasonable cap.
|
|
76
|
+
const TARGET_MAX_IMPLICIT_ROWS = 5000;
|
|
69
77
|
|
|
70
78
|
export function supportsNativeMasonry(): boolean {
|
|
71
79
|
if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return false;
|
|
@@ -87,6 +95,17 @@ export type MasonryOptions = {
|
|
|
87
95
|
* budget for the demotion calculation.
|
|
88
96
|
*/
|
|
89
97
|
preferredColumnSpan?: Exclude<GridItemSpanValue, 'auto'>;
|
|
98
|
+
/**
|
|
99
|
+
* Manual override for the row-track unit (in pixels) the polyfill uses for
|
|
100
|
+
* `grid-auto-rows` and as the ceil() granularity of each item's row span.
|
|
101
|
+
* When omitted (recommended), the polyfill auto-picks the smallest unit that
|
|
102
|
+
* keeps projected implicit row count under `TARGET_MAX_IMPLICIT_ROWS`.
|
|
103
|
+
*
|
|
104
|
+
* Trade-off: smaller = more pixel-accurate row gaps; larger = more headroom
|
|
105
|
+
* under browser implicit-grid-track caps for very long galleries. Set this
|
|
106
|
+
* only if you've confirmed the auto-tuner is wrong for your case.
|
|
107
|
+
*/
|
|
108
|
+
rowUnitPx?: number;
|
|
90
109
|
};
|
|
91
110
|
|
|
92
111
|
// Data attribute used to remember an auto item's inline `grid-column` before the
|
|
@@ -105,6 +124,14 @@ const ORIGINAL_COL_ATTR = 'data-bds-masonry-original-column';
|
|
|
105
124
|
// of the newly laid out items for a frame or two.
|
|
106
125
|
const LAST_SPAN_ATTR = 'data-bds-masonry-span';
|
|
107
126
|
|
|
127
|
+
// Container attribute caching the last applied row-unit (px). When the
|
|
128
|
+
// auto-tuner picks a different unit on a re-run (e.g. children added pushed
|
|
129
|
+
// the projected row count past the threshold), every per-child cached span
|
|
130
|
+
// becomes stale because they were computed against the old unit — we clear
|
|
131
|
+
// them so the next diff check forces a full rewrite. Cleared in
|
|
132
|
+
// clearMasonryLayout.
|
|
133
|
+
const UNIT_ATTR = 'data-bds-masonry-unit';
|
|
134
|
+
|
|
108
135
|
export function applyMasonryLayout(
|
|
109
136
|
container: HTMLElement,
|
|
110
137
|
rawChildren: HTMLElement[],
|
|
@@ -117,7 +144,6 @@ export function applyMasonryLayout(
|
|
|
117
144
|
const cs = window.getComputedStyle(container);
|
|
118
145
|
const targetGap = parseFloat(cs.columnGap) || 0;
|
|
119
146
|
|
|
120
|
-
container.style.gridAutoRows = `${ROW_UNIT_PX}px`;
|
|
121
147
|
container.style.rowGap = '0px';
|
|
122
148
|
container.style.gridAutoFlow = 'dense';
|
|
123
149
|
// `dense` + per-child `ResizeObserver` can fight the browser's scroll anchoring:
|
|
@@ -190,12 +216,58 @@ export function applyMasonryLayout(
|
|
|
190
216
|
// O(N) forced-reflow cost of a read-then-write loop.
|
|
191
217
|
const heights = children.map((child) => child.offsetHeight);
|
|
192
218
|
|
|
219
|
+
// Pick the row-track unit. Manual override wins; otherwise auto-tune.
|
|
220
|
+
//
|
|
221
|
+
// Auto-tune uses the projected implicit row count (assuming ideal `dense`
|
|
222
|
+
// packing across the current grid columns) and picks the smallest unit such
|
|
223
|
+
// that the projection stays under TARGET_MAX_IMPLICIT_ROWS. Galleries that
|
|
224
|
+
// don't risk hitting any browser cap stay at unit=1 and get pixel-perfect
|
|
225
|
+
// gaps; only galleries large enough to threaten the cap pay the rounding
|
|
226
|
+
// cost. The unit always grows monotonically with content, so once a gallery
|
|
227
|
+
// is above the threshold its layout is stable across runs.
|
|
228
|
+
//
|
|
229
|
+
// The projection treats each item as occupying `rowSpan × colSpan` cells
|
|
230
|
+
// — a `columnSpan="full"` item is a whole row to itself, a one-quarter
|
|
231
|
+
// item shares its row with three siblings. Summing the area and dividing
|
|
232
|
+
// by total columns gives a lower bound on implicit row count. (A naive
|
|
233
|
+
// `sum(rowSpan) / cols` would silently underestimate by avg-col-span and
|
|
234
|
+
// leave galleries with multi-column items overflowing browser caps.)
|
|
235
|
+
const cols = countGridColumns(container) || 1;
|
|
236
|
+
const projectedRows = children.reduce((sum, child, i) => {
|
|
237
|
+
const rowSpan = Math.max(1, Math.ceil(heights[i] + targetGap));
|
|
238
|
+
const colSpan = readExplicitSpan(child, cols);
|
|
239
|
+
return sum + rowSpan * colSpan;
|
|
240
|
+
}, 0) / cols;
|
|
241
|
+
const autoUnit = Math.max(
|
|
242
|
+
1,
|
|
243
|
+
Math.ceil(projectedRows / TARGET_MAX_IMPLICIT_ROWS),
|
|
244
|
+
);
|
|
245
|
+
const rowUnit =
|
|
246
|
+
options.rowUnitPx !== undefined && options.rowUnitPx > 0
|
|
247
|
+
? Math.floor(options.rowUnitPx)
|
|
248
|
+
: autoUnit;
|
|
249
|
+
|
|
250
|
+
// Apply the chosen unit. Setting `gridAutoRows` here (not before measurement)
|
|
251
|
+
// is safe because `align-self: start` makes offsetHeight return the content
|
|
252
|
+
// height regardless of the cell size — heights are independent of the unit.
|
|
253
|
+
container.style.gridAutoRows = `${rowUnit}px`;
|
|
254
|
+
|
|
255
|
+
// If the unit changed since the last run, every cached span is stale.
|
|
256
|
+
// Force a re-write by clearing the cache attributes before the diff check.
|
|
257
|
+
const prevUnit = container.getAttribute(UNIT_ATTR);
|
|
258
|
+
if (prevUnit !== String(rowUnit)) {
|
|
259
|
+
for (const child of children) child.removeAttribute(LAST_SPAN_ATTR);
|
|
260
|
+
container.setAttribute(UNIT_ATTR, String(rowUnit));
|
|
261
|
+
}
|
|
262
|
+
|
|
193
263
|
// Compute spans; skip the entire write pass if every span matches the
|
|
194
264
|
// previously-applied value. ResizeObserver fires freely in Firefox during
|
|
195
265
|
// image decode and scroll — without this guard, each fire triggers a dense
|
|
196
266
|
// re-pack that's visible as overlap on fast scroll (the compositor hasn't
|
|
197
267
|
// caught up with the newly shuffled positions).
|
|
198
|
-
const spans = heights.map((h) =>
|
|
268
|
+
const spans = heights.map((h) =>
|
|
269
|
+
Math.max(1, Math.ceil((h + targetGap) / rowUnit)),
|
|
270
|
+
);
|
|
199
271
|
let changed = false;
|
|
200
272
|
for (let i = 0; i < children.length; i++) {
|
|
201
273
|
const prev = children[i].getAttribute(LAST_SPAN_ATTR);
|
|
@@ -249,6 +321,7 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
|
|
|
249
321
|
container.style.gridAutoFlow = '';
|
|
250
322
|
container.style.rowGap = '';
|
|
251
323
|
container.style.overflowAnchor = '';
|
|
324
|
+
container.removeAttribute(UNIT_ATTR);
|
|
252
325
|
for (const child of rawChildren) {
|
|
253
326
|
if (child.nodeType !== 1) continue;
|
|
254
327
|
child.style.gridRow = '';
|
|
@@ -279,24 +352,66 @@ export function useMasonry(
|
|
|
279
352
|
enabled: boolean,
|
|
280
353
|
options: MasonryOptions = {},
|
|
281
354
|
): void {
|
|
282
|
-
const { preferredColumnSpan } = options;
|
|
355
|
+
const { preferredColumnSpan, rowUnitPx } = options;
|
|
283
356
|
useLayoutEffect(() => {
|
|
284
357
|
const container = ref.current;
|
|
285
358
|
if (!container || !enabled) return;
|
|
286
359
|
if (supportsNativeMasonry()) return;
|
|
287
360
|
|
|
288
361
|
let frame = 0;
|
|
289
|
-
|
|
290
|
-
|
|
362
|
+
let scrollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
363
|
+
let scrolling = false;
|
|
364
|
+
let pending = false;
|
|
365
|
+
|
|
366
|
+
const apply = () => {
|
|
291
367
|
applyMasonryLayout(container, Array.from(container.children) as HTMLElement[], {
|
|
292
368
|
preferredColumnSpan,
|
|
369
|
+
rowUnitPx,
|
|
293
370
|
});
|
|
294
371
|
};
|
|
372
|
+
const runNow = () => {
|
|
373
|
+
cancelAnimationFrame(frame);
|
|
374
|
+
apply();
|
|
375
|
+
};
|
|
295
376
|
const schedule = () => {
|
|
296
377
|
cancelAnimationFrame(frame);
|
|
297
378
|
frame = requestAnimationFrame(runNow);
|
|
298
379
|
};
|
|
299
380
|
|
|
381
|
+
// RO-triggered re-runs (resizes of existing items — image decode, font swap) are
|
|
382
|
+
// the ones that cause the Firefox compositor race: `grid-auto-flow: dense` re-packs
|
|
383
|
+
// already-laid-out items, and cached tiles from the pre-shuffle layout paint over
|
|
384
|
+
// the new positions for a frame or two, looking like overlap. Deferring *those*
|
|
385
|
+
// writes until scroll stops hides the race. Initial mount and MutationObserver-
|
|
386
|
+
// triggered runs (new children added) must NOT defer — a skeleton mounting during
|
|
387
|
+
// active scroll would otherwise stay at `grid-auto-rows: 1px` height, invisibly
|
|
388
|
+
// collapsed, until scroll idles.
|
|
389
|
+
const onResize = () => {
|
|
390
|
+
if (scrolling) {
|
|
391
|
+
pending = true;
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
apply();
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const SCROLL_IDLE_MS = 150;
|
|
398
|
+
const onScroll = () => {
|
|
399
|
+
scrolling = true;
|
|
400
|
+
if (scrollTimer) clearTimeout(scrollTimer);
|
|
401
|
+
scrollTimer = setTimeout(() => {
|
|
402
|
+
scrolling = false;
|
|
403
|
+
scrollTimer = null;
|
|
404
|
+
if (pending) {
|
|
405
|
+
pending = false;
|
|
406
|
+
apply();
|
|
407
|
+
}
|
|
408
|
+
}, SCROLL_IDLE_MS);
|
|
409
|
+
};
|
|
410
|
+
// `capture: true` catches scroll events from any nested scroll container, since
|
|
411
|
+
// scroll doesn't bubble. `passive: true` tells the browser we won't preventDefault —
|
|
412
|
+
// critical for not blocking scroll performance.
|
|
413
|
+
window.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
|
414
|
+
|
|
300
415
|
schedule();
|
|
301
416
|
|
|
302
417
|
// ResizeObserver fires during the "update the rendering" step, AFTER layout and
|
|
@@ -305,9 +420,10 @@ export function useMasonry(
|
|
|
305
420
|
// defer to rAF instead, there's a visible frame where a grown item (e.g. a
|
|
306
421
|
// lazy-loaded image whose dimensions just resolved) overflows its stale cell
|
|
307
422
|
// and visually overlaps the next item in its column — easy to see on fast
|
|
308
|
-
// scroll through a long gallery.
|
|
423
|
+
// scroll through a long gallery. (onResize itself still defers writes when the
|
|
424
|
+
// user is actively scrolling — see its `scrolling` guard above.)
|
|
309
425
|
const ro =
|
|
310
|
-
typeof ResizeObserver !== 'undefined' ? new ResizeObserver(
|
|
426
|
+
typeof ResizeObserver !== 'undefined' ? new ResizeObserver(onResize) : null;
|
|
311
427
|
const observeDescendants = (child: Element) => {
|
|
312
428
|
if (!ro) return;
|
|
313
429
|
ro.observe(child);
|
|
@@ -342,9 +458,11 @@ export function useMasonry(
|
|
|
342
458
|
|
|
343
459
|
return () => {
|
|
344
460
|
cancelAnimationFrame(frame);
|
|
461
|
+
if (scrollTimer) clearTimeout(scrollTimer);
|
|
462
|
+
window.removeEventListener('scroll', onScroll, { capture: true } as EventListenerOptions);
|
|
345
463
|
ro?.disconnect();
|
|
346
464
|
mo?.disconnect();
|
|
347
465
|
clearMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
|
|
348
466
|
};
|
|
349
|
-
}, [ref, enabled, preferredColumnSpan]);
|
|
467
|
+
}, [ref, enabled, preferredColumnSpan, rowUnitPx]);
|
|
350
468
|
}
|