@boostdev/design-system-components 2.4.1 → 2.5.0

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 +128 -62
  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 +128 -62
  8. package/dist/index.cjs +128 -62
  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 +128 -62
  13. package/dist/web-components/index.js +36 -4
  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 +206 -4
  17. package/src/components/layout/Grid/Grid.tsx +15 -1
  18. package/src/components/layout/Grid/masonry.ts +148 -10
  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,16 +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
+ var LAST_SPAN_ATTR = "data-bds-masonry-span";
161
+ var UNIT_ATTR = "data-bds-masonry-unit";
160
162
  function applyMasonryLayout(container, rawChildren, options = {}) {
161
163
  const cs = window.getComputedStyle(container);
162
164
  const targetGap = parseFloat(cs.columnGap) || 0;
163
- container.style.gridAutoRows = `${ROW_UNIT_PX}px`;
164
165
  container.style.rowGap = "0px";
165
166
  container.style.gridAutoFlow = "dense";
166
167
  container.style.overflowAnchor = "none";
@@ -198,9 +199,38 @@ function applyMasonryLayout(container, rawChildren, options = {}) {
198
199
  }
199
200
  }
200
201
  const heights = children.map((child) => child.offsetHeight);
202
+ const cols = countGridColumns(container) || 1;
203
+ const totalRowSum = heights.reduce(
204
+ (s, h) => s + Math.max(1, Math.ceil(h + targetGap)),
205
+ 0
206
+ );
207
+ const projectedRows = totalRowSum / 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
+ );
222
+ let changed = false;
223
+ for (let i3 = 0; i3 < children.length; i3++) {
224
+ const prev = children[i3].getAttribute(LAST_SPAN_ATTR);
225
+ if (prev !== String(spans[i3])) {
226
+ changed = true;
227
+ break;
228
+ }
229
+ }
230
+ if (!changed) return;
201
231
  for (let i3 = 0; i3 < children.length; i3++) {
202
- const span = Math.max(1, Math.ceil(heights[i3] + targetGap));
203
- children[i3].style.gridRowEnd = `span ${span}`;
232
+ children[i3].style.gridRowEnd = `span ${spans[i3]}`;
233
+ children[i3].setAttribute(LAST_SPAN_ATTR, String(spans[i3]));
204
234
  }
205
235
  }
206
236
  function countGridColumns(container) {
@@ -227,6 +257,7 @@ function clearMasonryLayout(container, rawChildren) {
227
257
  container.style.gridAutoFlow = "";
228
258
  container.style.rowGap = "";
229
259
  container.style.overflowAnchor = "";
260
+ container.removeAttribute(UNIT_ATTR);
230
261
  for (const child of rawChildren) {
231
262
  if (child.nodeType !== 1) continue;
232
263
  child.style.gridRow = "";
@@ -234,6 +265,7 @@ function clearMasonryLayout(container, rawChildren) {
234
265
  child.style.gridRowEnd = "";
235
266
  child.style.alignSelf = "";
236
267
  child.style.overflowAnchor = "";
268
+ child.removeAttribute(LAST_SPAN_ATTR);
237
269
  restoreOriginalColumn(child);
238
270
  }
239
271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-components",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
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
 
@@ -196,6 +199,37 @@ describe('applyMasonryLayout', () => {
196
199
  expect(b.style.alignSelf).toBe('start');
197
200
  });
198
201
 
202
+ it('skips re-writing row-spans when measured heights match the cached value', () => {
203
+ // ResizeObserver fires freely on `<img>` decode in Firefox even when the
204
+ // parent's reserved aspect-ratio box hasn't actually changed. Without this
205
+ // short-circuit, every fire triggers a `grid-auto-flow: dense` re-pack —
206
+ // visible as image overlap during fast scroll because Firefox's compositor
207
+ // hasn't caught up with the newly shuffled item positions.
208
+ const container = makeContainer(10);
209
+ const item = makeItem(50);
210
+ container.append(item);
211
+ applyMasonryLayout(container, [item]);
212
+ expect(item.style.gridRowEnd).toBe('span 60');
213
+
214
+ // Simulate something else writing to gridRowEnd — if the polyfill re-runs
215
+ // unnecessarily, this would be overwritten.
216
+ item.style.gridRowEnd = 'span 999';
217
+ applyMasonryLayout(container, [item]);
218
+ expect(item.style.gridRowEnd).toBe('span 999');
219
+ });
220
+
221
+ it('re-writes row-spans when measured heights change', () => {
222
+ const container = makeContainer(10);
223
+ const item = makeItem(50);
224
+ container.append(item);
225
+ applyMasonryLayout(container, [item]);
226
+ expect(item.style.gridRowEnd).toBe('span 60');
227
+
228
+ Object.defineProperty(item, 'offsetHeight', { configurable: true, value: 80 });
229
+ applyMasonryLayout(container, [item]);
230
+ expect(item.style.gridRowEnd).toBe('span 90');
231
+ });
232
+
199
233
  it('clearMasonryLayout undoes container and child styles', () => {
200
234
  const container = makeContainer(10);
201
235
  const item = makeItem(50);
@@ -211,6 +245,54 @@ describe('applyMasonryLayout', () => {
211
245
  expect(item.style.alignSelf).toBe('');
212
246
  });
213
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('honors an explicit rowUnitPx override', () => {
264
+ const container = makeContainer(10);
265
+ const item = makeItem(100);
266
+ container.append(item);
267
+ applyMasonryLayout(container, [item], { rowUnitPx: 8 });
268
+ expect(container.style.gridAutoRows).toBe('8px');
269
+ // ceil((100 + 10) / 8) = 14
270
+ expect(item.style.gridRowEnd).toBe('span 14');
271
+ });
272
+
273
+ it('invalidates the per-child span cache when the row-unit changes between runs', () => {
274
+ const container = makeContainer(10);
275
+ const item = makeItem(100);
276
+ container.append(item);
277
+ applyMasonryLayout(container, [item], { rowUnitPx: 1 });
278
+ expect(item.style.gridRowEnd).toBe('span 110');
279
+ // Switching units must rewrite — without cache invalidation the diff
280
+ // check would short-circuit because the cached "110" still matches the
281
+ // newly-computed value (which would be 14 under unit=8).
282
+ applyMasonryLayout(container, [item], { rowUnitPx: 8 });
283
+ expect(item.style.gridRowEnd).toBe('span 14');
284
+ });
285
+
286
+ it('clearMasonryLayout removes the unit attribute so the next mount re-tunes', () => {
287
+ const container = makeContainer(10);
288
+ const item = makeItem(100);
289
+ container.append(item);
290
+ applyMasonryLayout(container, [item], { rowUnitPx: 8 });
291
+ clearMasonryLayout(container, [item]);
292
+ expect(container.hasAttribute('data-bds-masonry-unit')).toBe(false);
293
+ });
294
+ });
295
+
214
296
  describe('preferredColumnSpan', () => {
215
297
  function makeAutoItem(height: number) {
216
298
  const el = makeItem(height);
@@ -317,6 +399,126 @@ describe('applyMasonryLayout', () => {
317
399
  });
318
400
  });
319
401
 
402
+ describe('useMasonry scroll debounce', () => {
403
+ // jsdom has no ResizeObserver — install a mock that captures the callback so
404
+ // tests can simulate an image decode firing during/outside of a scroll.
405
+ let roCallback: ResizeObserverCallback | null = null;
406
+ const originalRO = globalThis.ResizeObserver;
407
+
408
+ beforeEach(() => {
409
+ roCallback = null;
410
+ class MockResizeObserver {
411
+ constructor(cb: ResizeObserverCallback) {
412
+ roCallback = cb;
413
+ }
414
+ observe() {}
415
+ unobserve() {}
416
+ disconnect() {}
417
+ }
418
+ (globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver =
419
+ MockResizeObserver as unknown as typeof ResizeObserver;
420
+ vi.useFakeTimers();
421
+ });
422
+
423
+ afterEach(() => {
424
+ vi.useRealTimers();
425
+ if (originalRO) {
426
+ (globalThis as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = originalRO;
427
+ } else {
428
+ delete (globalThis as unknown as Record<string, unknown>).ResizeObserver;
429
+ }
430
+ });
431
+
432
+ function stubHeight(el: Element, height: number) {
433
+ Object.defineProperty(el, 'offsetHeight', { configurable: true, value: height });
434
+ }
435
+
436
+ it('defers re-runs while the user is actively scrolling and applies them once scroll idles', () => {
437
+ // Regression: during fast scroll in Firefox, an image decode grows its GridItem,
438
+ // ResizeObserver fires, and the synchronous `grid-auto-flow: dense` re-pack
439
+ // races the compositor — items appear to overlap because cached tiles from the
440
+ // pre-shuffle layout are painted over the new layout for a frame or two.
441
+ const { container } = render(
442
+ <Grid isMasonry>
443
+ <GridItem data-testid="a">a</GridItem>
444
+ </Grid>,
445
+ );
446
+ const item = container.querySelector('[data-testid="a"]') as HTMLElement;
447
+ stubHeight(item, 100);
448
+
449
+ // Flush the initial rAF-scheduled layout pass.
450
+ act(() => {
451
+ vi.advanceTimersByTime(0);
452
+ // rAF in jsdom is backed by setTimeout(..., 0)
453
+ });
454
+ act(() => {
455
+ vi.runAllTimers();
456
+ });
457
+ const initialSpan = item.style.gridRowEnd;
458
+ expect(initialSpan).not.toBe('');
459
+
460
+ // User starts scrolling.
461
+ window.dispatchEvent(new Event('scroll'));
462
+
463
+ // Image decode grows the item; RO fires.
464
+ stubHeight(item, 300);
465
+ act(() => {
466
+ roCallback?.([] as unknown as ResizeObserverEntry[], {} as ResizeObserver);
467
+ });
468
+
469
+ // Mid-scroll: the new span is NOT applied — deferred until scroll idle.
470
+ expect(item.style.gridRowEnd).toBe(initialSpan);
471
+
472
+ // Scroll stops; after the idle window, the deferred layout pass runs.
473
+ act(() => {
474
+ vi.advanceTimersByTime(200);
475
+ });
476
+ expect(item.style.gridRowEnd).not.toBe(initialSpan);
477
+ expect(item.style.gridRowEnd).toBe('span 300');
478
+ });
479
+
480
+ it('still lays out newly-mounted children during active scroll', async () => {
481
+ // Regression: deferring the whole polyfill during scroll left skeletons
482
+ // invisible — a child mounted mid-scroll would keep `grid-auto-rows: 1px`
483
+ // and never receive a row-span write, collapsing to 1px tall until scroll
484
+ // stopped. Only RO-triggered resizes of already-laid-out items should
485
+ // defer; initial layout and MutationObserver-driven runs must not.
486
+ const stub = (h: number) => (el: HTMLElement | null) => {
487
+ if (el) stubHeight(el, h);
488
+ };
489
+
490
+ function Harness({ extra = false }: { extra?: boolean }) {
491
+ return (
492
+ <Grid isMasonry>
493
+ <GridItem data-testid="a" ref={stub(100)}>a</GridItem>
494
+ {extra ? <GridItem data-testid="b" ref={stub(80)}>b</GridItem> : null}
495
+ </Grid>
496
+ );
497
+ }
498
+
499
+ const { container, rerender } = render(<Harness />);
500
+ await act(async () => {
501
+ await vi.runAllTimersAsync();
502
+ });
503
+ const a = container.querySelector('[data-testid="a"]') as HTMLElement;
504
+ expect(a.style.gridRowEnd).not.toBe('');
505
+
506
+ // User is actively scrolling when the new child mounts.
507
+ window.dispatchEvent(new Event('scroll'));
508
+
509
+ await act(async () => {
510
+ rerender(<Harness extra />);
511
+ });
512
+ await act(async () => {
513
+ await vi.runAllTimersAsync();
514
+ });
515
+
516
+ const b = container.querySelector('[data-testid="b"]') as HTMLElement;
517
+ // The new child must have received a span even though we're mid-scroll.
518
+ expect(b.style.gridRowEnd).toBe('span 80');
519
+ });
520
+ });
521
+
320
522
  describe('GridItem', () => {
321
523
  it('defaults to full span', () => {
322
524
  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,