@boostdev/design-system-components 2.2.1 → 2.3.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.
@@ -55,6 +55,9 @@ type GridVariant = 'main' | 'page' | 'funnel' | 'custom';
55
55
  * auto-span-media — boolean; child `<bds-grid-item>`s without a `column-span`
56
56
  * attribute resolve their span from the intrinsic aspect
57
57
  * ratio of their first `<img>`/`<video>` child.
58
+ * masonry-preferred-column-span — number; masonry only. Child items without a
59
+ * `column-span` attribute try to span this many columns; an
60
+ * item at row-end is demoted to the remaining column count.
58
61
  *
59
62
  * Slots:
60
63
  * (default) — grid content; typically `<bds-grid-item>` children
@@ -85,12 +88,18 @@ declare class BdsGrid extends LitElement {
85
88
  attribute: string;
86
89
  reflect: boolean;
87
90
  };
91
+ masonryPreferredColumnSpan: {
92
+ type: NumberConstructor;
93
+ attribute: string;
94
+ reflect: boolean;
95
+ };
88
96
  };
89
97
  variant: GridVariant;
90
98
  columns: number | undefined;
91
99
  isCentered: boolean;
92
100
  isMasonry: boolean;
93
101
  autoSpanMedia: boolean;
102
+ masonryPreferredColumnSpan: number | undefined;
94
103
  private _ro?;
95
104
  private _mo?;
96
105
  private _frame;
@@ -133,7 +133,7 @@ function supportsNativeMasonry() {
133
133
  if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return false;
134
134
  return CSS.supports("display", "grid-lanes") || CSS.supports("grid-template-rows", "masonry");
135
135
  }
136
- function applyMasonryLayout(container, rawChildren) {
136
+ function applyMasonryLayout(container, rawChildren, options = {}) {
137
137
  const cs = window.getComputedStyle(container);
138
138
  const targetGap = parseFloat(cs.columnGap) || 0;
139
139
  container.style.gridAutoRows = `${ROW_UNIT_PX}px`;
@@ -148,12 +148,49 @@ function applyMasonryLayout(container, rawChildren) {
148
148
  child.style.gridRowEnd = "";
149
149
  child.style.alignSelf = "start";
150
150
  }
151
+ const preferred = options.preferredColumnSpan ?? 0;
152
+ if (preferred >= 2) {
153
+ const totalCols = countGridColumns(container);
154
+ if (totalCols > 0) {
155
+ let col = 0;
156
+ for (const child of children) {
157
+ if (child.hasAttribute("data-column-span-auto")) {
158
+ const remaining = totalCols - col;
159
+ const span = Math.max(1, Math.min(preferred, remaining));
160
+ child.style.gridColumn = `span ${span}`;
161
+ col = (col + span) % totalCols;
162
+ } else {
163
+ const consumed = Math.min(totalCols, readExplicitSpan(child, totalCols));
164
+ col = (col + consumed) % totalCols;
165
+ }
166
+ }
167
+ }
168
+ }
151
169
  const heights = children.map((child) => child.offsetHeight);
152
170
  for (let i3 = 0; i3 < children.length; i3++) {
153
171
  const span = Math.max(1, Math.ceil(heights[i3] + targetGap));
154
172
  children[i3].style.gridRowEnd = `span ${span}`;
155
173
  }
156
174
  }
175
+ function countGridColumns(container) {
176
+ const value = window.getComputedStyle(container).gridTemplateColumns.trim();
177
+ if (!value || value === "none") return 0;
178
+ return value.split(/\s+/).length;
179
+ }
180
+ function readExplicitSpan(el, totalCols) {
181
+ const inline = el.style.gridColumn || el.style.gridColumnEnd;
182
+ const fromInline = parseSpan(inline, totalCols);
183
+ if (fromInline !== null) return fromInline;
184
+ const computed = window.getComputedStyle(el).gridColumn;
185
+ return parseSpan(computed, totalCols) ?? 1;
186
+ }
187
+ function parseSpan(value, totalCols) {
188
+ if (!value) return null;
189
+ const match = value.match(/^\s*span\s+(\d+)/);
190
+ if (match) return Math.max(1, Math.min(totalCols, parseInt(match[1], 10)));
191
+ if (/\/\s*-1\b/.test(value)) return totalCols;
192
+ return null;
193
+ }
157
194
  function clearMasonryLayout(container, rawChildren) {
158
195
  container.style.gridAutoRows = "";
159
196
  container.style.gridAutoFlow = "";
@@ -164,6 +201,9 @@ function clearMasonryLayout(container, rawChildren) {
164
201
  child.style.gridRowStart = "";
165
202
  child.style.gridRowEnd = "";
166
203
  child.style.alignSelf = "";
204
+ if (child.hasAttribute("data-column-span-auto")) {
205
+ child.style.gridColumn = "";
206
+ }
167
207
  }
168
208
  }
169
209
 
@@ -222,7 +262,12 @@ var BdsGrid = class extends i2 {
222
262
  columns: { type: Number, reflect: true },
223
263
  isCentered: { type: Boolean, attribute: "is-centered", reflect: true },
224
264
  isMasonry: { type: Boolean, attribute: "is-masonry", reflect: true },
225
- autoSpanMedia: { type: Boolean, attribute: "auto-span-media", reflect: true }
265
+ autoSpanMedia: { type: Boolean, attribute: "auto-span-media", reflect: true },
266
+ masonryPreferredColumnSpan: {
267
+ type: Number,
268
+ attribute: "masonry-preferred-column-span",
269
+ reflect: true
270
+ }
226
271
  };
227
272
  _ro;
228
273
  _mo;
@@ -234,6 +279,7 @@ var BdsGrid = class extends i2 {
234
279
  this.isCentered = false;
235
280
  this.isMasonry = false;
236
281
  this.autoSpanMedia = false;
282
+ this.masonryPreferredColumnSpan = void 0;
237
283
  }
238
284
  disconnectedCallback() {
239
285
  super.disconnectedCallback();
@@ -273,7 +319,11 @@ var BdsGrid = class extends i2 {
273
319
  cancelAnimationFrame(this._frame);
274
320
  this._frame = requestAnimationFrame(() => {
275
321
  const container = this._gridEl();
276
- if (container) applyMasonryLayout(container, this._items());
322
+ if (container) {
323
+ applyMasonryLayout(container, this._items(), {
324
+ preferredColumnSpan: this.masonryPreferredColumnSpan
325
+ });
326
+ }
277
327
  });
278
328
  }
279
329
  _teardownMasonry() {
@@ -430,6 +480,9 @@ var BdsGridItem = class extends i2 {
430
480
  const gridColumn = hasExplicitRange ? `${this.startColumn ?? "auto"} / ${this.endColumn ?? "auto"}` : resolveSpan(resolved);
431
481
  this.style.gridColumn = gridColumn;
432
482
  this.style.gridRow = this.rowSpan !== void 0 ? `span ${this.rowSpan}` : "";
483
+ const isColumnSpanAuto = (this.columnSpan === void 0 || this.columnSpan === null) && !hasExplicitRange;
484
+ if (isColumnSpanAuto) this.setAttribute("data-column-span-auto", "");
485
+ else this.removeAttribute("data-column-span-auto");
433
486
  }
434
487
  render() {
435
488
  return T`<slot></slot>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-components",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "BoostDev React component library: accessible, token-driven components built on @boostdev/design-system-foundation",
5
5
  "keywords": [
6
6
  "React",
@@ -201,6 +201,40 @@ Intrinsic dimensions are read from `img.naturalWidth/naturalHeight` and `video.v
201
201
  - Event subscriptions are `{ once: true }` so there's no cleanup ceremony per item.
202
202
  - Re-resolution cost is bounded by the number of items whose media changes, not the total item count.
203
203
 
204
+ ## Masonry preferred column span
205
+
206
+ For a uniform "cards mostly two columns wide" feed, set `masonryPreferredColumnSpan` on the `Grid`. Items without an explicit `columnSpan` try to span that many columns. When an item would sit at the end of a row with fewer columns remaining than the preferred span, the polyfill **demotes** its span to just fill the remaining columns — the item never wraps to a new row leaving a visible gap behind it.
207
+
208
+ ```tsx
209
+ <Grid isMasonry masonryPreferredColumnSpan={2}>
210
+ {cards.map((c) => (
211
+ <GridItem key={c.id}>…</GridItem>
212
+ ))}
213
+ </Grid>
214
+ ```
215
+
216
+ Rules:
217
+
218
+ - Requires `isMasonry`. No-op on non-masonry grids.
219
+ - Items with an explicit `columnSpan` (named or numeric) keep their span. Their columns still consume the demotion budget, so a `columnSpan="three-quarters"` followed by an auto item in a 4-column grid correctly demotes the auto item to 1.
220
+ - Items with `startColumn` / `endColumn` are unaffected.
221
+ - Responsive: the polyfill reads the live column count at the current viewport width, so the same grid can be 2+2 on wide screens and 1+1+1+1 on mobile without any media-query wiring.
222
+
223
+ ### When NOT to use
224
+
225
+ - When span should reflect **content importance** or media shape — set `columnSpan` per item instead. `masonryPreferredColumnSpan` enforces a uniform visual rhythm and is the wrong tool for heterogeneous cards.
226
+ - Alongside `autoSpanMedia` on the same grid. Both features target items without an explicit `columnSpan`, and `masonryPreferredColumnSpan` wins — `autoSpanMedia` silently does nothing. Pick one strategy per grid.
227
+ - In a non-masonry grid. The feature depends on the polyfill's packing pass and is a no-op otherwise — use a plain `columnSpan` default instead.
228
+
229
+ ### Performance
230
+
231
+ - One extra `getComputedStyle(container).gridTemplateColumns` read per masonry layout pass to count columns; the demotion pass itself is a single O(n) walk over auto items.
232
+ - Shares the polyfill's existing `ResizeObserver` + `MutationObserver` + `requestAnimationFrame` coalescing — no new observers, no separate render.
233
+
234
+ ### SSR / hydration
235
+
236
+ 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.
237
+
204
238
  ## Migration from `@greenchoice/design-system`
205
239
 
206
240
  | Greenchoice | BoostDev equivalent |
@@ -170,6 +170,72 @@ describe('applyMasonryLayout', () => {
170
170
  expect(item.style.gridRow).toBe('');
171
171
  expect(item.style.alignSelf).toBe('');
172
172
  });
173
+
174
+ describe('preferredColumnSpan', () => {
175
+ function makeAutoItem(height: number) {
176
+ const el = makeItem(height);
177
+ el.setAttribute('data-column-span-auto', '');
178
+ return el;
179
+ }
180
+
181
+ it('assigns the preferred span to data-column-span-auto items that fit', () => {
182
+ const container = makeContainer(10);
183
+ container.style.gridTemplateColumns = '100px 100px 100px 100px';
184
+ const a = makeAutoItem(50);
185
+ const b = makeAutoItem(50);
186
+ container.append(a, b);
187
+ applyMasonryLayout(container, [a, b], { preferredColumnSpan: 2 });
188
+ expect(a.style.gridColumn).toBe('span 2');
189
+ expect(b.style.gridColumn).toBe('span 2');
190
+ });
191
+
192
+ it('demotes the span at row end instead of wrapping with a gap', () => {
193
+ const container = makeContainer(10);
194
+ container.style.gridTemplateColumns = '100px 100px 100px';
195
+ const a = makeAutoItem(50);
196
+ const b = makeAutoItem(50);
197
+ container.append(a, b);
198
+ applyMasonryLayout(container, [a, b], { preferredColumnSpan: 2 });
199
+ expect(a.style.gridColumn).toBe('span 2');
200
+ // Only 1 col remains after `a` — `b` gets demoted.
201
+ expect(b.style.gridColumn).toBe('span 1');
202
+ });
203
+
204
+ it('leaves items with an explicit span untouched and consumes their columns for the demotion budget', () => {
205
+ const container = makeContainer(10);
206
+ container.style.gridTemplateColumns = '100px 100px 100px 100px';
207
+ const explicit = makeItem(50);
208
+ explicit.style.gridColumn = 'span 3';
209
+ const auto = makeAutoItem(50);
210
+ container.append(explicit, auto);
211
+ applyMasonryLayout(container, [explicit, auto], { preferredColumnSpan: 2 });
212
+ expect(explicit.style.gridColumn).toBe('span 3');
213
+ // 3 cols consumed, 1 remains → auto demoted.
214
+ expect(auto.style.gridColumn).toBe('span 1');
215
+ });
216
+
217
+ it('is a no-op without preferredColumnSpan', () => {
218
+ const container = makeContainer(10);
219
+ container.style.gridTemplateColumns = '100px 100px';
220
+ const a = makeAutoItem(50);
221
+ container.append(a);
222
+ applyMasonryLayout(container, [a]);
223
+ expect(a.style.gridColumn).toBe('');
224
+ });
225
+
226
+ it('clearMasonryLayout resets gridColumn on data-column-span-auto items only', () => {
227
+ const container = makeContainer(10);
228
+ container.style.gridTemplateColumns = '100px 100px 100px 100px';
229
+ const auto = makeAutoItem(50);
230
+ const explicit = makeItem(50);
231
+ explicit.style.gridColumn = 'span 2';
232
+ container.append(auto, explicit);
233
+ applyMasonryLayout(container, [auto, explicit], { preferredColumnSpan: 2 });
234
+ clearMasonryLayout(container, [auto, explicit]);
235
+ expect(auto.style.gridColumn).toBe('');
236
+ expect(explicit.style.gridColumn).toBe('span 2');
237
+ });
238
+ });
173
239
  });
174
240
 
175
241
  describe('GridItem', () => {
@@ -220,6 +286,21 @@ describe('GridItem', () => {
220
286
  const { container } = render(<GridItem as="article">x</GridItem>);
221
287
  expect(container.firstChild?.nodeName).toBe('ARTICLE');
222
288
  });
289
+
290
+ it('marks data-column-span-auto when columnSpan is not provided', () => {
291
+ const { container } = render(<GridItem>x</GridItem>);
292
+ expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(true);
293
+ });
294
+
295
+ it('does not mark data-column-span-auto when columnSpan is set', () => {
296
+ const { container } = render(<GridItem columnSpan="half">x</GridItem>);
297
+ expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(false);
298
+ });
299
+
300
+ it('does not mark data-column-span-auto when startColumn or endColumn is set', () => {
301
+ const { container } = render(<GridItem startColumn={2} endColumn={4}>x</GridItem>);
302
+ expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(false);
303
+ });
223
304
  });
224
305
 
225
306
  describe('aspectToColumnSpan', () => {
@@ -10,6 +10,7 @@ const meta = {
10
10
  columns: { control: 'number' },
11
11
  isCentered: { control: 'boolean' },
12
12
  isMasonry: { control: 'boolean' },
13
+ masonryPreferredColumnSpan: { control: { type: 'number', min: 1, max: 4 } },
13
14
  },
14
15
  } satisfies Meta<typeof Grid>;
15
16
 
@@ -140,6 +141,50 @@ export const MasonryWithSpans: Story = {
140
141
  ),
141
142
  };
142
143
 
144
+ /**
145
+ * `masonryPreferredColumnSpan={2}` makes every auto-span item span 2 columns.
146
+ * With 7 items in a 12-column desktop grid (6 per row × 2 cols), the 7th item
147
+ * would normally leave a 10-col gap — the polyfill **demotes** its span to
148
+ * fill just the remaining columns, so no trailing gap appears.
149
+ *
150
+ * Use the Controls to flip `masonryPreferredColumnSpan` between 1–4 and watch
151
+ * the packing re-run. Resize the viewport (or the Storybook canvas) to see
152
+ * the demotion adapt to the current column count.
153
+ */
154
+ export const MasonryPreferredColumnSpan: Story = {
155
+ args: { isMasonry: true, masonryPreferredColumnSpan: 2 },
156
+ render: (args) => (
157
+ <Grid {...args}>
158
+ {Array.from({ length: 7 }).map((_, i) => (
159
+ <GridItem key={i} style={masonryCellStyle(i)}>
160
+ Card {i + 1}
161
+ </GridItem>
162
+ ))}
163
+ </Grid>
164
+ ),
165
+ };
166
+
167
+ /**
168
+ * Mixing an explicit `columnSpan="three-quarters"` hero with auto items.
169
+ * The hero keeps its span; its columns still consume the demotion budget,
170
+ * so the auto items fill the remaining row consistently.
171
+ */
172
+ export const MasonryPreferredColumnSpanWithHero: Story = {
173
+ args: { isMasonry: true, masonryPreferredColumnSpan: 2 },
174
+ render: (args) => (
175
+ <Grid {...args}>
176
+ <GridItem columnSpan="three-quarters" style={masonryCellStyle(0)}>
177
+ Hero (three-quarters)
178
+ </GridItem>
179
+ {Array.from({ length: 8 }).map((_, i) => (
180
+ <GridItem key={i} style={masonryCellStyle(i + 1)}>
181
+ Card {i + 1}
182
+ </GridItem>
183
+ ))}
184
+ </Grid>
185
+ ),
186
+ };
187
+
143
188
  export const ComplexLayout: Story = {
144
189
  args: {},
145
190
  render: () => (
@@ -45,6 +45,14 @@ type GridOwnProps = WithClassName & {
45
45
  * are unaffected.
46
46
  */
47
47
  autoSpanMedia?: boolean;
48
+ /**
49
+ * Masonry only. When set (>= 2), each `GridItem` without an explicit
50
+ * `columnSpan` tries to span this many columns; an item at the end of a row
51
+ * is demoted to fill just the remaining columns rather than wrapping and
52
+ * leaving a gap. Requires `isMasonry`; no-op otherwise. Items with an
53
+ * explicit `columnSpan` keep their span.
54
+ */
55
+ masonryPreferredColumnSpan?: number;
48
56
  as?: ElementType;
49
57
  };
50
58
 
@@ -59,12 +67,13 @@ export function Grid<C extends ElementType = 'div'>({
59
67
  isCentered = false,
60
68
  isMasonry = false,
61
69
  autoSpanMedia = false,
70
+ masonryPreferredColumnSpan,
62
71
  as,
63
72
  style,
64
73
  ...rest
65
74
  }: GridProps<C>) {
66
75
  const ref = useRef<HTMLElement>(null);
67
- useMasonry(ref, isMasonry);
76
+ useMasonry(ref, isMasonry, { preferredColumnSpan: masonryPreferredColumnSpan });
68
77
 
69
78
  const classNames = cn(
70
79
  css.grid,
@@ -137,10 +137,16 @@ export function GridItem<C extends ElementType = 'div'>({
137
137
 
138
138
  const Component = as ?? 'div';
139
139
 
140
+ // Marker the masonry polyfill reads to know this item's span wasn't set by the
141
+ // consumer — it may override the span with `masonryPreferredColumnSpan` on Grid.
142
+ // Items with an explicit range or explicit `columnSpan` are never eligible.
143
+ const isColumnSpanAuto = columnSpan === undefined && !hasExplicitRange;
144
+
140
145
  return (
141
146
  <Component
142
147
  ref={ref as never}
143
148
  {...rest}
149
+ data-column-span-auto={isColumnSpanAuto ? '' : undefined}
144
150
  className={cn(css.item, className)}
145
151
  style={composedStyle}
146
152
  >
@@ -36,7 +36,23 @@ export function supportsNativeMasonry(): boolean {
36
36
  );
37
37
  }
38
38
 
39
- export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElement[]): void {
39
+ export type MasonryOptions = {
40
+ /**
41
+ * When set (>= 2), the polyfill assigns column spans to items marked
42
+ * `data-column-span-auto` so they try to occupy this many columns each. If a
43
+ * preferred-span item would overflow the row, it is demoted to fill only the
44
+ * remaining columns (never less than 1). Items with an explicit `columnSpan`
45
+ * keep their span; their span still consumes column budget for the demotion
46
+ * calculation.
47
+ */
48
+ preferredColumnSpan?: number;
49
+ };
50
+
51
+ export function applyMasonryLayout(
52
+ container: HTMLElement,
53
+ rawChildren: HTMLElement[],
54
+ options: MasonryOptions = {},
55
+ ): void {
40
56
  // We override row-gap to 0, so reading it back would always give 0 on re-runs.
41
57
  // Read column-gap instead — `.grid` sets both via the `gap` shorthand and the
42
58
  // polyfill never touches column-gap. (If a consumer sets asymmetric gaps, the
@@ -63,6 +79,29 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
63
79
  child.style.alignSelf = 'start';
64
80
  }
65
81
 
82
+ // Preferred-column-span pass — optional. Assigns column spans to `data-column-span-auto`
83
+ // items, demoting to fit remaining row space. Runs before height measurement so items
84
+ // are measured at the width they will actually render.
85
+ const preferred = options.preferredColumnSpan ?? 0;
86
+ if (preferred >= 2) {
87
+ const totalCols = countGridColumns(container);
88
+ if (totalCols > 0) {
89
+ let col = 0;
90
+ for (const child of children) {
91
+ if (child.hasAttribute('data-column-span-auto')) {
92
+ const remaining = totalCols - col;
93
+ const span = Math.max(1, Math.min(preferred, remaining));
94
+ child.style.gridColumn = `span ${span}`;
95
+ col = (col + span) % totalCols;
96
+ } else {
97
+ // Item has an explicit span — consume its columns for the demotion budget.
98
+ const consumed = Math.min(totalCols, readExplicitSpan(child, totalCols));
99
+ col = (col + consumed) % totalCols;
100
+ }
101
+ }
102
+ }
103
+ }
104
+
66
105
  // Pass 2 — read all heights in a tight loop. The first offsetHeight read forces one
67
106
  // reflow; subsequent reads are cached until the next style write. This avoids the
68
107
  // O(N) forced-reflow cost of a read-then-write loop.
@@ -76,6 +115,36 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
76
115
  }
77
116
  }
78
117
 
118
+ function countGridColumns(container: HTMLElement): number {
119
+ // `getComputedStyle` resolves `grid-template-columns` to concrete track sizes
120
+ // (e.g. "256px 256px 256px 256px"), regardless of whether the author used
121
+ // `repeat()`, `minmax()`, or CSS custom properties. Splitting by whitespace
122
+ // gives the actual track count at the current viewport width.
123
+ const value = window.getComputedStyle(container).gridTemplateColumns.trim();
124
+ if (!value || value === 'none') return 0;
125
+ return value.split(/\s+/).length;
126
+ }
127
+
128
+ function readExplicitSpan(el: HTMLElement, totalCols: number): number {
129
+ // Try the inline value first to avoid resolving CSS vars that haven't been
130
+ // registered with `@property` (computed style returns the literal string for
131
+ // those). Inline values look like `span N`, `span var(...)`, or `a / b`.
132
+ const inline = el.style.gridColumn || el.style.gridColumnEnd;
133
+ const fromInline = parseSpan(inline, totalCols);
134
+ if (fromInline !== null) return fromInline;
135
+ const computed = window.getComputedStyle(el).gridColumn;
136
+ return parseSpan(computed, totalCols) ?? 1;
137
+ }
138
+
139
+ function parseSpan(value: string, totalCols: number): number | null {
140
+ if (!value) return null;
141
+ const match = value.match(/^\s*span\s+(\d+)/);
142
+ if (match) return Math.max(1, Math.min(totalCols, parseInt(match[1], 10)));
143
+ // `1 / -1` style — spans the whole row.
144
+ if (/\/\s*-1\b/.test(value)) return totalCols;
145
+ return null;
146
+ }
147
+
79
148
  export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElement[]): void {
80
149
  container.style.gridAutoRows = '';
81
150
  container.style.gridAutoFlow = '';
@@ -86,6 +155,12 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
86
155
  child.style.gridRowStart = '';
87
156
  child.style.gridRowEnd = '';
88
157
  child.style.alignSelf = '';
158
+ // Only clear `gridColumn` on items the polyfill owned (marked as auto) —
159
+ // items with an author-specified span had their column set from the React
160
+ // render pass and should keep it.
161
+ if (child.hasAttribute('data-column-span-auto')) {
162
+ child.style.gridColumn = '';
163
+ }
89
164
  }
90
165
  }
91
166
 
@@ -94,7 +169,12 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
94
169
  * size / mutation changes. No-op when `enabled` is false or when the browser
95
170
  * natively supports masonry.
96
171
  */
97
- export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean): void {
172
+ export function useMasonry(
173
+ ref: RefObject<HTMLElement | null>,
174
+ enabled: boolean,
175
+ options: MasonryOptions = {},
176
+ ): void {
177
+ const { preferredColumnSpan } = options;
98
178
  useLayoutEffect(() => {
99
179
  const container = ref.current;
100
180
  if (!container || !enabled) return;
@@ -104,7 +184,9 @@ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean)
104
184
  const schedule = () => {
105
185
  cancelAnimationFrame(frame);
106
186
  frame = requestAnimationFrame(() => {
107
- applyMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
187
+ applyMasonryLayout(container, Array.from(container.children) as HTMLElement[], {
188
+ preferredColumnSpan,
189
+ });
108
190
  });
109
191
  };
110
192
 
@@ -135,5 +217,5 @@ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean)
135
217
  mo?.disconnect();
136
218
  clearMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
137
219
  };
138
- }, [ref, enabled]);
220
+ }, [ref, enabled, preferredColumnSpan]);
139
221
  }
@@ -49,6 +49,7 @@ Numeric `column-span="n"` is literal — it does not adapt. Use named values for
49
49
  | `is-centered` | boolean | `false` | Caps max-width via `--bds-container_max-width` |
50
50
  | `is-masonry` | boolean | `false` | Enables masonry layout (CSS Grid Level 3 "grid-lanes"). Uses native CSS when supported, otherwise runs a JS polyfill. |
51
51
  | `auto-span-media` | boolean | `false` | Child `<bds-grid-item>`s without an explicit `column-span` resolve their span from the intrinsic aspect ratio of their first `<img>`/`<video>` descendant. See [Aspect-ratio auto-span](#aspect-ratio-auto-span). |
52
+ | `masonry-preferred-column-span` | number | — | In masonry mode, items without an explicit `column-span` try to span this many columns, demoting to fit at row ends. See [Masonry preferred column span](#masonry-preferred-column-span). |
52
53
 
53
54
  ## Slots
54
55
 
@@ -161,6 +162,39 @@ Individual items can opt out with an explicit `column-span` (e.g. `column-span="
161
162
 
162
163
  Named-span responsive behavior still applies — on mobile every item collapses to full-width regardless of its resolved span.
163
164
 
165
+ ## Masonry preferred column span
166
+
167
+ For a uniform "cards mostly two columns wide" feed, set `masonry-preferred-column-span` on the `<bds-grid>`. Items without an explicit `column-span` try to span that many columns. When an item would sit at the end of a row with fewer columns remaining than the preferred span, the polyfill **demotes** its span to just fill the remaining columns — the item never wraps to a new row leaving a visible gap behind it.
168
+
169
+ ```html
170
+ <bds-grid is-masonry masonry-preferred-column-span="2">
171
+ <bds-grid-item>…</bds-grid-item>
172
+ <bds-grid-item>…</bds-grid-item>
173
+ </bds-grid>
174
+ ```
175
+
176
+ Rules:
177
+
178
+ - Requires `is-masonry`. No-op on non-masonry grids.
179
+ - Items with an explicit `column-span` (named or numeric) keep their span. Their columns still consume the demotion budget, so a `column-span="three-quarters"` followed by an auto item in a 4-column grid correctly demotes the auto item to 1.
180
+ - Items with `start-column` / `end-column` are unaffected.
181
+ - Responsive: the polyfill reads the live column count at the current viewport width, so the same grid can be 2+2 on wide screens and 1+1+1+1 on mobile without any media-query wiring.
182
+
183
+ ### When NOT to use
184
+
185
+ - When span should reflect **content importance** or media shape — set `column-span` per item instead. `masonry-preferred-column-span` enforces a uniform visual rhythm and is the wrong tool for heterogeneous cards.
186
+ - Alongside `auto-span-media` on the same grid. Both features target items without an explicit `column-span`, and `masonry-preferred-column-span` wins — `auto-span-media` silently does nothing. Pick one strategy per grid.
187
+ - In a non-masonry grid. The feature depends on the polyfill's packing pass and is a no-op otherwise — use a plain `column-span` default instead.
188
+
189
+ ### Performance
190
+
191
+ - One extra `getComputedStyle(container).gridTemplateColumns` read per masonry layout pass to count columns; the demotion pass itself is a single O(n) walk over auto items.
192
+ - Shares the polyfill's existing `ResizeObserver` + `MutationObserver` + `requestAnimationFrame` coalescing — no new observers, no separate render.
193
+
194
+ ### SSR / hydration
195
+
196
+ Column detection runs inside the masonry polyfill's layout pass after upgrade, so it only executes on the client. The initial server render emits each auto item at its default `full` span until the custom element upgrades and the first polyfill pass runs — matching the existing masonry SSR behavior.
197
+
164
198
  ### Loading behavior
165
199
 
166
200
  Dimensions are read from `img.naturalWidth/naturalHeight` and `video.videoWidth/videoHeight` — no layout is triggered. If metadata is not yet available when the element upgrades, the item starts at `'one-quarter'` and re-resolves when the `load` (image) or `loadedmetadata` (video) event fires. Subsequent slot mutations re-run the resolver.
@@ -9,12 +9,14 @@ function BdsGrid({
9
9
  columns,
10
10
  isCentered,
11
11
  isMasonry,
12
+ masonryPreferredColumnSpan,
12
13
  children,
13
14
  }: {
14
15
  variant?: GridVariant;
15
16
  columns?: number;
16
17
  isCentered?: boolean;
17
18
  isMasonry?: boolean;
19
+ masonryPreferredColumnSpan?: number;
18
20
  children?: React.ReactNode;
19
21
  }) {
20
22
  return React.createElement(
@@ -24,6 +26,7 @@ function BdsGrid({
24
26
  columns,
25
27
  'is-centered': isCentered || undefined,
26
28
  'is-masonry': isMasonry || undefined,
29
+ 'masonry-preferred-column-span': masonryPreferredColumnSpan,
27
30
  },
28
31
  children,
29
32
  );
@@ -45,7 +48,7 @@ function Item({
45
48
  return React.createElement(
46
49
  'bds-grid-item',
47
50
  {
48
- 'column-span': columnSpan ?? 'full',
51
+ 'column-span': columnSpan,
49
52
  'start-column': startColumn,
50
53
  'end-column': endColumn,
51
54
  'row-span': rowSpan,
@@ -71,6 +74,7 @@ const meta = {
71
74
  columns: { control: 'number' },
72
75
  isCentered: { control: 'boolean' },
73
76
  isMasonry: { control: 'boolean' },
77
+ masonryPreferredColumnSpan: { control: { type: 'number', min: 1, max: 4 } },
74
78
  },
75
79
  } satisfies Meta<typeof BdsGrid>;
76
80
 
@@ -189,6 +193,43 @@ export const MasonryWithSpans: Story = {
189
193
  ),
190
194
  };
191
195
 
196
+ /**
197
+ * `masonry-preferred-column-span="2"` makes every auto-span item span 2 columns.
198
+ * With 7 items in a 12-column desktop grid, the 7th would normally leave a trailing
199
+ * gap — the polyfill **demotes** its span to fill just the remaining columns.
200
+ *
201
+ * Flip `masonryPreferredColumnSpan` between 1–4 in the Controls to see the
202
+ * packing re-run. Resize the viewport to watch the demotion adapt to the
203
+ * current column count.
204
+ */
205
+ export const MasonryPreferredColumnSpan: Story = {
206
+ args: { isMasonry: true, masonryPreferredColumnSpan: 2 },
207
+ render: (args) => (
208
+ <BdsGrid {...args}>
209
+ {Array.from({ length: 7 }).map((_, i) => (
210
+ <Item key={i}><div style={masonryCellStyle(i)}>Card {i + 1}</div></Item>
211
+ ))}
212
+ </BdsGrid>
213
+ ),
214
+ };
215
+
216
+ /**
217
+ * An explicit `column-span="three-quarters"` hero alongside auto items.
218
+ * The hero keeps its span; its columns consume the demotion budget, so the
219
+ * auto items fill the remaining row consistently.
220
+ */
221
+ export const MasonryPreferredColumnSpanWithHero: Story = {
222
+ args: { isMasonry: true, masonryPreferredColumnSpan: 2 },
223
+ render: (args) => (
224
+ <BdsGrid {...args}>
225
+ <Item columnSpan="three-quarters"><div style={masonryCellStyle(0)}>Hero (three-quarters)</div></Item>
226
+ {Array.from({ length: 8 }).map((_, i) => (
227
+ <Item key={i}><div style={masonryCellStyle(i + 1)}>Card {i + 1}</div></Item>
228
+ ))}
229
+ </BdsGrid>
230
+ ),
231
+ };
232
+
192
233
  export const ComplexLayout: Story = {
193
234
  args: {},
194
235
  render: () => (