@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.
Files changed (201) hide show
  1. package/AGENTS.md +4 -20
  2. package/README.md +3 -23
  3. package/dist/client.cjs +115 -61
  4. package/dist/client.css +537 -537
  5. package/dist/client.d.cts +11 -1
  6. package/dist/client.d.ts +11 -1
  7. package/dist/client.js +115 -61
  8. package/dist/index.cjs +115 -61
  9. package/dist/index.css +537 -537
  10. package/dist/index.d.cts +11 -1
  11. package/dist/index.d.ts +11 -1
  12. package/dist/index.js +115 -61
  13. package/dist/web-components/index.js +23 -3
  14. package/package.json +2 -10
  15. package/src/components/layout/Grid/Grid.mdx +20 -2
  16. package/src/components/layout/Grid/Grid.spec.tsx +194 -4
  17. package/src/components/layout/Grid/Grid.tsx +15 -1
  18. package/src/components/layout/Grid/masonry.ts +127 -9
  19. package/dist/native/index.cjs +0 -4337
  20. package/dist/native/index.d.cts +0 -574
  21. package/dist/native/index.d.ts +0 -574
  22. package/dist/native/index.js +0 -4421
  23. package/src/components/interaction/Button/Button.native.mdx +0 -91
  24. package/src/components/interaction/Button/Button.native.spec.tsx +0 -55
  25. package/src/components/interaction/Button/Button.native.stories.tsx +0 -141
  26. package/src/components/interaction/Button/Button.native.tsx +0 -105
  27. package/src/components/interaction/Dialog/Dialog.native.mdx +0 -61
  28. package/src/components/interaction/Dialog/Dialog.native.spec.tsx +0 -73
  29. package/src/components/interaction/Dialog/Dialog.native.stories.tsx +0 -53
  30. package/src/components/interaction/Dialog/Dialog.native.tsx +0 -128
  31. package/src/components/interaction/Drawer/Drawer.native.mdx +0 -58
  32. package/src/components/interaction/Drawer/Drawer.native.spec.tsx +0 -81
  33. package/src/components/interaction/Drawer/Drawer.native.stories.tsx +0 -33
  34. package/src/components/interaction/Drawer/Drawer.native.tsx +0 -175
  35. package/src/components/interaction/DropdownMenu/DropdownMenu.native.mdx +0 -74
  36. package/src/components/interaction/DropdownMenu/DropdownMenu.native.spec.tsx +0 -78
  37. package/src/components/interaction/DropdownMenu/DropdownMenu.native.stories.tsx +0 -51
  38. package/src/components/interaction/DropdownMenu/DropdownMenu.native.tsx +0 -254
  39. package/src/components/interaction/Popover/Popover.native.mdx +0 -61
  40. package/src/components/interaction/Popover/Popover.native.spec.tsx +0 -73
  41. package/src/components/interaction/Popover/Popover.native.stories.tsx +0 -44
  42. package/src/components/interaction/Popover/Popover.native.tsx +0 -87
  43. package/src/components/interaction/Rating/Rating.native.mdx +0 -55
  44. package/src/components/interaction/Rating/Rating.native.spec.tsx +0 -38
  45. package/src/components/interaction/Rating/Rating.native.stories.tsx +0 -37
  46. package/src/components/interaction/Rating/Rating.native.tsx +0 -50
  47. package/src/components/interaction/Toast/Toast.native.mdx +0 -81
  48. package/src/components/interaction/Toast/Toast.native.spec.tsx +0 -80
  49. package/src/components/interaction/Toast/Toast.native.stories.tsx +0 -47
  50. package/src/components/interaction/Toast/Toast.native.tsx +0 -202
  51. package/src/components/interaction/form/Checkbox/Checkbox.native.mdx +0 -74
  52. package/src/components/interaction/form/Checkbox/Checkbox.native.spec.tsx +0 -42
  53. package/src/components/interaction/form/Checkbox/Checkbox.native.stories.tsx +0 -34
  54. package/src/components/interaction/form/Checkbox/Checkbox.native.tsx +0 -80
  55. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.native.mdx +0 -78
  56. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.native.spec.tsx +0 -57
  57. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.native.stories.tsx +0 -96
  58. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.native.tsx +0 -61
  59. package/src/components/interaction/form/Combobox/Combobox.native.mdx +0 -86
  60. package/src/components/interaction/form/Combobox/Combobox.native.spec.tsx +0 -60
  61. package/src/components/interaction/form/Combobox/Combobox.native.stories.tsx +0 -99
  62. package/src/components/interaction/form/Combobox/Combobox.native.tsx +0 -211
  63. package/src/components/interaction/form/FileInput/FileInput.native.mdx +0 -86
  64. package/src/components/interaction/form/FileInput/FileInput.native.spec.tsx +0 -73
  65. package/src/components/interaction/form/FileInput/FileInput.native.stories.tsx +0 -69
  66. package/src/components/interaction/form/FileInput/FileInput.native.tsx +0 -129
  67. package/src/components/interaction/form/FormInput/FormInput.native.mdx +0 -81
  68. package/src/components/interaction/form/FormInput/FormInput.native.spec.tsx +0 -51
  69. package/src/components/interaction/form/FormInput/FormInput.native.stories.tsx +0 -54
  70. package/src/components/interaction/form/FormInput/FormInput.native.tsx +0 -122
  71. package/src/components/interaction/form/NumberInput/NumberInput.native.mdx +0 -87
  72. package/src/components/interaction/form/NumberInput/NumberInput.native.spec.tsx +0 -63
  73. package/src/components/interaction/form/NumberInput/NumberInput.native.stories.tsx +0 -63
  74. package/src/components/interaction/form/NumberInput/NumberInput.native.tsx +0 -198
  75. package/src/components/interaction/form/Radio/Radio.native.spec.tsx +0 -79
  76. package/src/components/interaction/form/Radio/Radio.native.stories.tsx +0 -44
  77. package/src/components/interaction/form/Radio/Radio.native.tsx +0 -79
  78. package/src/components/interaction/form/RadioGroup/RadioGroup.native.mdx +0 -79
  79. package/src/components/interaction/form/RadioGroup/RadioGroup.native.spec.tsx +0 -66
  80. package/src/components/interaction/form/RadioGroup/RadioGroup.native.stories.tsx +0 -96
  81. package/src/components/interaction/form/RadioGroup/RadioGroup.native.tsx +0 -61
  82. package/src/components/interaction/form/SegmentedControl/SegmentedControl.native.mdx +0 -78
  83. package/src/components/interaction/form/SegmentedControl/SegmentedControl.native.spec.tsx +0 -82
  84. package/src/components/interaction/form/SegmentedControl/SegmentedControl.native.stories.tsx +0 -99
  85. package/src/components/interaction/form/SegmentedControl/SegmentedControl.native.tsx +0 -121
  86. package/src/components/interaction/form/Select/Select.native.mdx +0 -85
  87. package/src/components/interaction/form/Select/Select.native.spec.tsx +0 -64
  88. package/src/components/interaction/form/Select/Select.native.stories.tsx +0 -75
  89. package/src/components/interaction/form/Select/Select.native.tsx +0 -252
  90. package/src/components/interaction/form/Slider/Slider.native.mdx +0 -83
  91. package/src/components/interaction/form/Slider/Slider.native.spec.tsx +0 -51
  92. package/src/components/interaction/form/Slider/Slider.native.stories.tsx +0 -58
  93. package/src/components/interaction/form/Slider/Slider.native.tsx +0 -188
  94. package/src/components/interaction/form/Switch/Switch.native.spec.tsx +0 -60
  95. package/src/components/interaction/form/Switch/Switch.native.stories.tsx +0 -33
  96. package/src/components/interaction/form/Switch/Switch.native.tsx +0 -59
  97. package/src/components/interaction/form/Textarea/Textarea.native.mdx +0 -76
  98. package/src/components/interaction/form/Textarea/Textarea.native.spec.tsx +0 -50
  99. package/src/components/interaction/form/Textarea/Textarea.native.stories.tsx +0 -50
  100. package/src/components/interaction/form/Textarea/Textarea.native.tsx +0 -108
  101. package/src/components/interaction/form/atoms/Label.native.spec.tsx +0 -39
  102. package/src/components/interaction/form/atoms/Label.native.stories.tsx +0 -18
  103. package/src/components/interaction/form/atoms/Label.native.tsx +0 -29
  104. package/src/components/interaction/form/atoms/Message.native.spec.tsx +0 -30
  105. package/src/components/interaction/form/atoms/Message.native.stories.tsx +0 -22
  106. package/src/components/interaction/form/atoms/Message.native.tsx +0 -34
  107. package/src/components/layout/ButtonGroup/ButtonGroup.native.spec.tsx +0 -83
  108. package/src/components/layout/ButtonGroup/ButtonGroup.native.stories.tsx +0 -44
  109. package/src/components/layout/ButtonGroup/ButtonGroup.native.tsx +0 -44
  110. package/src/components/layout/Card/Card.native.spec.tsx +0 -102
  111. package/src/components/layout/Card/Card.native.stories.tsx +0 -51
  112. package/src/components/layout/Card/Card.native.tsx +0 -89
  113. package/src/components/layout/IconWrapper/IconWrapper.native.spec.tsx +0 -39
  114. package/src/components/layout/IconWrapper/IconWrapper.native.stories.tsx +0 -37
  115. package/src/components/layout/IconWrapper/IconWrapper.native.tsx +0 -20
  116. package/src/components/layout/SectionHeader/SectionHeader.native.spec.tsx +0 -51
  117. package/src/components/layout/SectionHeader/SectionHeader.native.stories.tsx +0 -36
  118. package/src/components/layout/SectionHeader/SectionHeader.native.tsx +0 -79
  119. package/src/components/ui/Accordion/Accordion.native.mdx +0 -65
  120. package/src/components/ui/Accordion/Accordion.native.spec.tsx +0 -69
  121. package/src/components/ui/Accordion/Accordion.native.stories.tsx +0 -52
  122. package/src/components/ui/Accordion/Accordion.native.tsx +0 -141
  123. package/src/components/ui/Alert/Alert.native.mdx +0 -62
  124. package/src/components/ui/Alert/Alert.native.spec.tsx +0 -76
  125. package/src/components/ui/Alert/Alert.native.stories.tsx +0 -41
  126. package/src/components/ui/Alert/Alert.native.tsx +0 -94
  127. package/src/components/ui/Avatar/Avatar.native.mdx +0 -50
  128. package/src/components/ui/Avatar/Avatar.native.spec.tsx +0 -47
  129. package/src/components/ui/Avatar/Avatar.native.stories.tsx +0 -32
  130. package/src/components/ui/Avatar/Avatar.native.tsx +0 -85
  131. package/src/components/ui/Badge/Badge.native.mdx +0 -54
  132. package/src/components/ui/Badge/Badge.native.spec.tsx +0 -45
  133. package/src/components/ui/Badge/Badge.native.stories.tsx +0 -34
  134. package/src/components/ui/Badge/Badge.native.tsx +0 -50
  135. package/src/components/ui/Breadcrumb/Breadcrumb.native.mdx +0 -52
  136. package/src/components/ui/Breadcrumb/Breadcrumb.native.spec.tsx +0 -49
  137. package/src/components/ui/Breadcrumb/Breadcrumb.native.stories.tsx +0 -46
  138. package/src/components/ui/Breadcrumb/Breadcrumb.native.tsx +0 -83
  139. package/src/components/ui/Calendar/Calendar.native.mdx +0 -58
  140. package/src/components/ui/Calendar/Calendar.native.spec.tsx +0 -92
  141. package/src/components/ui/Calendar/Calendar.native.stories.tsx +0 -45
  142. package/src/components/ui/Calendar/Calendar.native.tsx +0 -294
  143. package/src/components/ui/Carousel/Carousel.native.mdx +0 -55
  144. package/src/components/ui/Carousel/Carousel.native.spec.tsx +0 -55
  145. package/src/components/ui/Carousel/Carousel.native.stories.tsx +0 -59
  146. package/src/components/ui/Carousel/Carousel.native.tsx +0 -184
  147. package/src/components/ui/Collapsible/Collapsible.native.mdx +0 -53
  148. package/src/components/ui/Collapsible/Collapsible.native.spec.tsx +0 -68
  149. package/src/components/ui/Collapsible/Collapsible.native.stories.tsx +0 -48
  150. package/src/components/ui/Collapsible/Collapsible.native.tsx +0 -139
  151. package/src/components/ui/DescriptionList/DescriptionList.native.mdx +0 -50
  152. package/src/components/ui/DescriptionList/DescriptionList.native.spec.tsx +0 -37
  153. package/src/components/ui/DescriptionList/DescriptionList.native.stories.tsx +0 -48
  154. package/src/components/ui/DescriptionList/DescriptionList.native.tsx +0 -54
  155. package/src/components/ui/Link/Link.native.mdx +0 -53
  156. package/src/components/ui/Link/Link.native.spec.tsx +0 -35
  157. package/src/components/ui/Link/Link.native.stories.tsx +0 -46
  158. package/src/components/ui/Link/Link.native.tsx +0 -48
  159. package/src/components/ui/Loading/Loading.native.spec.tsx +0 -24
  160. package/src/components/ui/Loading/Loading.native.stories.tsx +0 -31
  161. package/src/components/ui/Loading/Loading.native.tsx +0 -29
  162. package/src/components/ui/NotificationBanner/NotificationBanner.native.spec.tsx +0 -85
  163. package/src/components/ui/NotificationBanner/NotificationBanner.native.stories.tsx +0 -37
  164. package/src/components/ui/NotificationBanner/NotificationBanner.native.tsx +0 -76
  165. package/src/components/ui/Pagination/Pagination.native.mdx +0 -62
  166. package/src/components/ui/Pagination/Pagination.native.spec.tsx +0 -86
  167. package/src/components/ui/Pagination/Pagination.native.stories.tsx +0 -47
  168. package/src/components/ui/Pagination/Pagination.native.tsx +0 -157
  169. package/src/components/ui/Progress/Progress.native.spec.tsx +0 -56
  170. package/src/components/ui/Progress/Progress.native.stories.tsx +0 -34
  171. package/src/components/ui/Progress/Progress.native.tsx +0 -84
  172. package/src/components/ui/ProgressCircle/ProgressCircle.native.mdx +0 -63
  173. package/src/components/ui/ProgressCircle/ProgressCircle.native.spec.tsx +0 -39
  174. package/src/components/ui/ProgressCircle/ProgressCircle.native.stories.tsx +0 -36
  175. package/src/components/ui/ProgressCircle/ProgressCircle.native.tsx +0 -176
  176. package/src/components/ui/Separator/Separator.native.spec.tsx +0 -39
  177. package/src/components/ui/Separator/Separator.native.stories.tsx +0 -38
  178. package/src/components/ui/Separator/Separator.native.tsx +0 -32
  179. package/src/components/ui/Skeleton/Skeleton.native.spec.tsx +0 -39
  180. package/src/components/ui/Skeleton/Skeleton.native.stories.tsx +0 -31
  181. package/src/components/ui/Skeleton/Skeleton.native.tsx +0 -42
  182. package/src/components/ui/Table/Table.native.mdx +0 -72
  183. package/src/components/ui/Table/Table.native.spec.tsx +0 -83
  184. package/src/components/ui/Table/Table.native.stories.tsx +0 -55
  185. package/src/components/ui/Table/Table.native.tsx +0 -196
  186. package/src/components/ui/Tabs/Tabs.native.mdx +0 -65
  187. package/src/components/ui/Tabs/Tabs.native.spec.tsx +0 -65
  188. package/src/components/ui/Tabs/Tabs.native.stories.tsx +0 -57
  189. package/src/components/ui/Tabs/Tabs.native.tsx +0 -133
  190. package/src/components/ui/Tooltip/Tooltip.native.mdx +0 -47
  191. package/src/components/ui/Tooltip/Tooltip.native.spec.tsx +0 -37
  192. package/src/components/ui/Tooltip/Tooltip.native.stories.tsx +0 -38
  193. package/src/components/ui/Tooltip/Tooltip.native.tsx +0 -57
  194. package/src/components/ui/Typography/Typography.native.mdx +0 -56
  195. package/src/components/ui/Typography/Typography.native.spec.tsx +0 -49
  196. package/src/components/ui/Typography/Typography.native.stories.tsx +0 -36
  197. package/src/components/ui/Typography/Typography.native.tsx +0 -65
  198. package/src/native/ThemeContext.tsx +0 -28
  199. package/src/native/tokens.ts +0 -13
  200. package/src/native.ts +0 -76
  201. 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 ROW_UNIT_PX = 1;
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 spans = heights.map((h) => Math.max(1, Math.ceil(h + targetGap)));
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.4.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 1 px auto-track with `grid-auto-flow: dense`, and each item is given a `grid-row-end: span ceil(height + gap)`. Items stay in **normal document flow** (no absolute positioning), so surrounding content — footers, scroll anchors, page margins — lays out correctly around the grid.
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 means 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).
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-rows: 1px + grid-auto-flow: dense + row-gap: 0', () => {
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
- // ceil(100 + 10) = 110
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, { preferredColumnSpan: masonryPreferredColumnSpan });
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
- const ROW_UNIT_PX = 1;
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) => Math.max(1, Math.ceil(h + targetGap)));
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
- const runNow = () => {
290
- cancelAnimationFrame(frame);
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(runNow) : null;
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
  }