@boostdev/design-system-components 2.2.1 → 2.4.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,12 @@ 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 — named span or number; masonry only. Child
59
+ * items without a `column-span` attribute try to span this
60
+ * value (e.g. `"half"`, `"one-third"`). Named values adapt
61
+ * across breakpoints via the foundation's responsive column
62
+ * tokens. An item at row-end is demoted to the remaining
63
+ * column count. Numeric values are literal and don't adapt.
58
64
  *
59
65
  * Slots:
60
66
  * (default) — grid content; typically `<bds-grid-item>` children
@@ -85,12 +91,18 @@ declare class BdsGrid extends LitElement {
85
91
  attribute: string;
86
92
  reflect: boolean;
87
93
  };
94
+ masonryPreferredColumnSpan: {
95
+ type: StringConstructor;
96
+ attribute: string;
97
+ reflect: boolean;
98
+ };
88
99
  };
89
100
  variant: GridVariant;
90
101
  columns: number | undefined;
91
102
  isCentered: boolean;
92
103
  isMasonry: boolean;
93
104
  autoSpanMedia: boolean;
105
+ masonryPreferredColumnSpan: string | undefined;
94
106
  private _ro?;
95
107
  private _mo?;
96
108
  private _frame;
@@ -101,6 +113,7 @@ declare class BdsGrid extends LitElement {
101
113
  private _items;
102
114
  private _setupMasonry;
103
115
  private _scheduleMasonry;
116
+ private _resolvePreferredColumnSpan;
104
117
  private _teardownMasonry;
105
118
  render(): lit.TemplateResult<1>;
106
119
  }
@@ -128,17 +128,42 @@ import {
128
128
 
129
129
  // src/components/layout/Grid/masonry.ts
130
130
  import { useLayoutEffect } from "react";
131
+ var NAMED_SPAN_VAR = {
132
+ "three-quarters": "--bds-grid_columns-75",
133
+ "two-thirds": "--bds-grid_columns-66",
134
+ half: "--bds-grid_columns-50",
135
+ "one-third": "--bds-grid_columns-33",
136
+ "one-quarter": "--bds-grid_columns-25"
137
+ };
138
+ var NAMED_SPAN_FRACTION = {
139
+ "three-quarters": 0.75,
140
+ "two-thirds": 2 / 3,
141
+ half: 0.5,
142
+ "one-third": 1 / 3,
143
+ "one-quarter": 0.25
144
+ };
145
+ function resolvePreferredColumns(container, value, totalCols) {
146
+ if (value === "full") return totalCols;
147
+ if (typeof value === "number") return Math.max(1, Math.min(totalCols, value));
148
+ const varName = NAMED_SPAN_VAR[value];
149
+ const raw = window.getComputedStyle(container).getPropertyValue(varName).trim();
150
+ const n = parseInt(raw, 10);
151
+ if (Number.isFinite(n) && n > 0) return Math.min(totalCols, n);
152
+ return Math.max(1, Math.round(totalCols * NAMED_SPAN_FRACTION[value]));
153
+ }
131
154
  var ROW_UNIT_PX = 1;
132
155
  function supportsNativeMasonry() {
133
156
  if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return false;
134
157
  return CSS.supports("display", "grid-lanes") || CSS.supports("grid-template-rows", "masonry");
135
158
  }
136
- function applyMasonryLayout(container, rawChildren) {
159
+ var ORIGINAL_COL_ATTR = "data-bds-masonry-original-column";
160
+ function applyMasonryLayout(container, rawChildren, options = {}) {
137
161
  const cs = window.getComputedStyle(container);
138
162
  const targetGap = parseFloat(cs.columnGap) || 0;
139
163
  container.style.gridAutoRows = `${ROW_UNIT_PX}px`;
140
164
  container.style.rowGap = "0px";
141
165
  container.style.gridAutoFlow = "dense";
166
+ container.style.overflowAnchor = "none";
142
167
  const children = rawChildren.filter(
143
168
  (el) => el.nodeType === 1 && window.getComputedStyle(el).display !== "none"
144
169
  );
@@ -148,24 +173,76 @@ function applyMasonryLayout(container, rawChildren) {
148
173
  child.style.gridRowEnd = "";
149
174
  child.style.alignSelf = "start";
150
175
  }
176
+ const preferred = options.preferredColumnSpan;
177
+ if (preferred !== void 0) {
178
+ const totalCols = countGridColumns(container);
179
+ const preferredCols = totalCols > 0 ? resolvePreferredColumns(container, preferred, totalCols) : 0;
180
+ if (totalCols > 0 && preferredCols > 0) {
181
+ let col = 0;
182
+ for (const child of children) {
183
+ if (child.hasAttribute("data-column-span-auto")) {
184
+ if (!child.hasAttribute(ORIGINAL_COL_ATTR)) {
185
+ child.setAttribute(ORIGINAL_COL_ATTR, child.style.gridColumn);
186
+ }
187
+ const remaining = totalCols - col;
188
+ const span = Math.max(1, Math.min(preferredCols, remaining));
189
+ child.style.gridColumn = `span ${span}`;
190
+ col = (col + span) % totalCols;
191
+ } else {
192
+ const consumed = Math.min(totalCols, readExplicitSpan(child, totalCols));
193
+ col = (col + consumed) % totalCols;
194
+ }
195
+ }
196
+ }
197
+ } else {
198
+ for (const child of children) {
199
+ restoreOriginalColumn(child);
200
+ }
201
+ }
151
202
  const heights = children.map((child) => child.offsetHeight);
152
203
  for (let i3 = 0; i3 < children.length; i3++) {
153
204
  const span = Math.max(1, Math.ceil(heights[i3] + targetGap));
154
205
  children[i3].style.gridRowEnd = `span ${span}`;
155
206
  }
156
207
  }
208
+ function countGridColumns(container) {
209
+ const value = window.getComputedStyle(container).gridTemplateColumns.trim();
210
+ if (!value || value === "none") return 0;
211
+ return value.split(/\s+/).length;
212
+ }
213
+ function readExplicitSpan(el, totalCols) {
214
+ const inline = el.style.gridColumn || el.style.gridColumnEnd;
215
+ const fromInline = parseSpan(inline, totalCols);
216
+ if (fromInline !== null) return fromInline;
217
+ const computed = window.getComputedStyle(el).gridColumn;
218
+ return parseSpan(computed, totalCols) ?? 1;
219
+ }
220
+ function parseSpan(value, totalCols) {
221
+ if (!value) return null;
222
+ const match = value.match(/^\s*span\s+(\d+)/);
223
+ if (match) return Math.max(1, Math.min(totalCols, parseInt(match[1], 10)));
224
+ if (/\/\s*-1\b/.test(value)) return totalCols;
225
+ return null;
226
+ }
157
227
  function clearMasonryLayout(container, rawChildren) {
158
228
  container.style.gridAutoRows = "";
159
229
  container.style.gridAutoFlow = "";
160
230
  container.style.rowGap = "";
231
+ container.style.overflowAnchor = "";
161
232
  for (const child of rawChildren) {
162
233
  if (child.nodeType !== 1) continue;
163
234
  child.style.gridRow = "";
164
235
  child.style.gridRowStart = "";
165
236
  child.style.gridRowEnd = "";
166
237
  child.style.alignSelf = "";
238
+ restoreOriginalColumn(child);
167
239
  }
168
240
  }
241
+ function restoreOriginalColumn(el) {
242
+ if (!el.hasAttribute(ORIGINAL_COL_ATTR)) return;
243
+ el.style.gridColumn = el.getAttribute(ORIGINAL_COL_ATTR) ?? "";
244
+ el.removeAttribute(ORIGINAL_COL_ATTR);
245
+ }
169
246
 
170
247
  // src/web-components/layout/bds-grid.ts
171
248
  var BdsGrid = class extends i2 {
@@ -222,7 +299,12 @@ var BdsGrid = class extends i2 {
222
299
  columns: { type: Number, reflect: true },
223
300
  isCentered: { type: Boolean, attribute: "is-centered", reflect: true },
224
301
  isMasonry: { type: Boolean, attribute: "is-masonry", reflect: true },
225
- autoSpanMedia: { type: Boolean, attribute: "auto-span-media", reflect: true }
302
+ autoSpanMedia: { type: Boolean, attribute: "auto-span-media", reflect: true },
303
+ masonryPreferredColumnSpan: {
304
+ type: String,
305
+ attribute: "masonry-preferred-column-span",
306
+ reflect: true
307
+ }
226
308
  };
227
309
  _ro;
228
310
  _mo;
@@ -234,6 +316,7 @@ var BdsGrid = class extends i2 {
234
316
  this.isCentered = false;
235
317
  this.isMasonry = false;
236
318
  this.autoSpanMedia = false;
319
+ this.masonryPreferredColumnSpan = void 0;
237
320
  }
238
321
  disconnectedCallback() {
239
322
  super.disconnectedCallback();
@@ -273,9 +356,20 @@ var BdsGrid = class extends i2 {
273
356
  cancelAnimationFrame(this._frame);
274
357
  this._frame = requestAnimationFrame(() => {
275
358
  const container = this._gridEl();
276
- if (container) applyMasonryLayout(container, this._items());
359
+ if (container) {
360
+ applyMasonryLayout(container, this._items(), {
361
+ preferredColumnSpan: this._resolvePreferredColumnSpan()
362
+ });
363
+ }
277
364
  });
278
365
  }
366
+ _resolvePreferredColumnSpan() {
367
+ const raw = this.masonryPreferredColumnSpan;
368
+ if (raw === void 0 || raw === null || raw === "") return void 0;
369
+ const n = Number(raw);
370
+ if (Number.isFinite(n) && String(n) === raw) return n;
371
+ return raw;
372
+ }
279
373
  _teardownMasonry() {
280
374
  cancelAnimationFrame(this._frame);
281
375
  this._ro?.disconnect();
@@ -430,6 +524,9 @@ var BdsGridItem = class extends i2 {
430
524
  const gridColumn = hasExplicitRange ? `${this.startColumn ?? "auto"} / ${this.endColumn ?? "auto"}` : resolveSpan(resolved);
431
525
  this.style.gridColumn = gridColumn;
432
526
  this.style.gridRow = this.rowSpan !== void 0 ? `span ${this.rowSpan}` : "";
527
+ const isColumnSpanAuto = (this.columnSpan === void 0 || this.columnSpan === null) && !hasExplicitRange;
528
+ if (isColumnSpanAuto) this.setAttribute("data-column-span-auto", "");
529
+ else this.removeAttribute("data-column-span-auto");
433
530
  }
434
531
  render() {
435
532
  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.4.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,42 @@ 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 half-width" feed, set `masonryPreferredColumnSpan` on the `Grid`. Items without an explicit `columnSpan` try to span that named fraction of the row. 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="half">
210
+ {cards.map((c) => (
211
+ <GridItem key={c.id}>…</GridItem>
212
+ ))}
213
+ </Grid>
214
+ ```
215
+
216
+ The accepted values match `columnSpan` on `GridItem`: `'full'`, `'three-quarters'`, `'two-thirds'`, `'half'`, `'one-third'`, `'one-quarter'`. Named values resolve through the foundation's responsive column tokens, so the same option adapts across breakpoints — `"half"` yields 2 cols on a 4-col mobile grid, 4 cols on an 8-col tablet grid, and 6 cols on a 12-col desktop grid. A `number` is accepted too, but is a literal column count and does **not** adapt — prefer named values unless you're using `variant="custom"`.
217
+
218
+ Rules:
219
+
220
+ - Requires `isMasonry`. No-op on non-masonry grids.
221
+ - 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.
222
+ - Items with `startColumn` / `endColumn` are unaffected.
223
+ - 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.
224
+
225
+ ### When NOT to use
226
+
227
+ - 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.
228
+ - 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.
229
+ - 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.
230
+
231
+ ### Performance
232
+
233
+ - 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.
234
+ - Shares the polyfill's existing `ResizeObserver` + `MutationObserver` + `requestAnimationFrame` coalescing — no new observers, no separate render.
235
+
236
+ ### SSR / hydration
237
+
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
+
204
240
  ## Migration from `@greenchoice/design-system`
205
241
 
206
242
  | Greenchoice | BoostDev equivalent |
@@ -101,6 +101,23 @@ describe('applyMasonryLayout', () => {
101
101
  expect(container.style.rowGap).toBe('0px');
102
102
  });
103
103
 
104
+ it('sets overflow-anchor: none on the container to prevent scroll-anchor jitter', () => {
105
+ // Regression: `grid-auto-flow: dense` + per-child ResizeObserver caused
106
+ // scroll anchoring to yank the viewport up when an item above the fold resized
107
+ // (scrollbar toggle, late image decode). Opting the container out makes the
108
+ // browser anchor against content outside the grid instead.
109
+ const container = makeContainer(10);
110
+ applyMasonryLayout(container, []);
111
+ expect(container.style.overflowAnchor).toBe('none');
112
+ });
113
+
114
+ it('clearMasonryLayout restores overflow-anchor', () => {
115
+ const container = makeContainer(10);
116
+ applyMasonryLayout(container, []);
117
+ clearMasonryLayout(container, []);
118
+ expect(container.style.overflowAnchor).toBe('');
119
+ });
120
+
104
121
  it('assigns grid-row-end: span ceil(height + targetGap) to each item', () => {
105
122
  const container = makeContainer(10);
106
123
  const a = makeItem(100);
@@ -170,6 +187,111 @@ describe('applyMasonryLayout', () => {
170
187
  expect(item.style.gridRow).toBe('');
171
188
  expect(item.style.alignSelf).toBe('');
172
189
  });
190
+
191
+ describe('preferredColumnSpan', () => {
192
+ function makeAutoItem(height: number) {
193
+ const el = makeItem(height);
194
+ el.setAttribute('data-column-span-auto', '');
195
+ return el;
196
+ }
197
+
198
+ it('assigns the preferred span to data-column-span-auto items that fit', () => {
199
+ // 'half' in a 4-col grid → 2 cols (via fraction fallback; jsdom has no foundation CSS).
200
+ const container = makeContainer(10);
201
+ container.style.gridTemplateColumns = '100px 100px 100px 100px';
202
+ const a = makeAutoItem(50);
203
+ const b = makeAutoItem(50);
204
+ container.append(a, b);
205
+ applyMasonryLayout(container, [a, b], { preferredColumnSpan: 'half' });
206
+ expect(a.style.gridColumn).toBe('span 2');
207
+ expect(b.style.gridColumn).toBe('span 2');
208
+ });
209
+
210
+ it('demotes the span at row end instead of wrapping with a gap', () => {
211
+ const container = makeContainer(10);
212
+ container.style.gridTemplateColumns = '100px 100px 100px';
213
+ const a = makeAutoItem(50);
214
+ const b = makeAutoItem(50);
215
+ container.append(a, b);
216
+ // 'two-thirds' in a 3-col grid → 2 cols.
217
+ applyMasonryLayout(container, [a, b], { preferredColumnSpan: 'two-thirds' });
218
+ expect(a.style.gridColumn).toBe('span 2');
219
+ // Only 1 col remains after `a` — `b` gets demoted.
220
+ expect(b.style.gridColumn).toBe('span 1');
221
+ });
222
+
223
+ it('leaves items with an explicit span untouched and consumes their columns for the demotion budget', () => {
224
+ const container = makeContainer(10);
225
+ container.style.gridTemplateColumns = '100px 100px 100px 100px';
226
+ const explicit = makeItem(50);
227
+ explicit.style.gridColumn = 'span 3';
228
+ const auto = makeAutoItem(50);
229
+ container.append(explicit, auto);
230
+ applyMasonryLayout(container, [explicit, auto], { preferredColumnSpan: 'half' });
231
+ expect(explicit.style.gridColumn).toBe('span 3');
232
+ // 3 cols consumed, 1 remains → auto demoted.
233
+ expect(auto.style.gridColumn).toBe('span 1');
234
+ });
235
+
236
+ it('is a no-op without preferredColumnSpan', () => {
237
+ const container = makeContainer(10);
238
+ container.style.gridTemplateColumns = '100px 100px';
239
+ const a = makeAutoItem(50);
240
+ container.append(a);
241
+ applyMasonryLayout(container, [a]);
242
+ expect(a.style.gridColumn).toBe('');
243
+ });
244
+
245
+ it('resolves named spans through foundation CSS vars when present', () => {
246
+ const container = makeContainer(10);
247
+ container.style.gridTemplateColumns = '100px 100px 100px 100px 100px 100px 100px 100px';
248
+ // Override the foundation var to a non-fractional value to prove we read it.
249
+ container.style.setProperty('--bds-grid_columns-50', '5');
250
+ const a = makeAutoItem(50);
251
+ container.append(a);
252
+ applyMasonryLayout(container, [a], { preferredColumnSpan: 'half' });
253
+ expect(a.style.gridColumn).toBe('span 5');
254
+ });
255
+
256
+ it('accepts numeric preferredColumnSpan as a literal column count', () => {
257
+ const container = makeContainer(10);
258
+ container.style.gridTemplateColumns = '100px 100px 100px 100px';
259
+ const a = makeAutoItem(50);
260
+ const b = makeAutoItem(50);
261
+ container.append(a, b);
262
+ applyMasonryLayout(container, [a, b], { preferredColumnSpan: 1 });
263
+ expect(a.style.gridColumn).toBe('span 1');
264
+ expect(b.style.gridColumn).toBe('span 1');
265
+ });
266
+
267
+ it('restores the original gridColumn when preferredColumnSpan is removed', () => {
268
+ // Simulates a Storybook control going preferred='half' → undefined.
269
+ // Without restore, items would stay pinned at `span 2`.
270
+ const container = makeContainer(10);
271
+ container.style.gridTemplateColumns = '100px 100px 100px 100px';
272
+ const a = makeAutoItem(50);
273
+ a.style.gridColumn = 'var(--bds-grid_span-100)';
274
+ container.append(a);
275
+ applyMasonryLayout(container, [a], { preferredColumnSpan: 'half' });
276
+ expect(a.style.gridColumn).toBe('span 2');
277
+ applyMasonryLayout(container, [a]); // preferredColumnSpan dropped
278
+ expect(a.style.gridColumn).toBe('var(--bds-grid_span-100)');
279
+ });
280
+
281
+ it('clearMasonryLayout restores gridColumn on data-column-span-auto items', () => {
282
+ const container = makeContainer(10);
283
+ container.style.gridTemplateColumns = '100px 100px 100px 100px';
284
+ const auto = makeAutoItem(50);
285
+ auto.style.gridColumn = 'var(--bds-grid_span-100)';
286
+ const explicit = makeItem(50);
287
+ explicit.style.gridColumn = 'span 2';
288
+ container.append(auto, explicit);
289
+ applyMasonryLayout(container, [auto, explicit], { preferredColumnSpan: 'half' });
290
+ clearMasonryLayout(container, [auto, explicit]);
291
+ expect(auto.style.gridColumn).toBe('var(--bds-grid_span-100)');
292
+ expect(explicit.style.gridColumn).toBe('span 2');
293
+ });
294
+ });
173
295
  });
174
296
 
175
297
  describe('GridItem', () => {
@@ -220,6 +342,21 @@ describe('GridItem', () => {
220
342
  const { container } = render(<GridItem as="article">x</GridItem>);
221
343
  expect(container.firstChild?.nodeName).toBe('ARTICLE');
222
344
  });
345
+
346
+ it('marks data-column-span-auto when columnSpan is not provided', () => {
347
+ const { container } = render(<GridItem>x</GridItem>);
348
+ expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(true);
349
+ });
350
+
351
+ it('does not mark data-column-span-auto when columnSpan is set', () => {
352
+ const { container } = render(<GridItem columnSpan="half">x</GridItem>);
353
+ expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(false);
354
+ });
355
+
356
+ it('does not mark data-column-span-auto when startColumn or endColumn is set', () => {
357
+ const { container } = render(<GridItem startColumn={2} endColumn={4}>x</GridItem>);
358
+ expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(false);
359
+ });
223
360
  });
224
361
 
225
362
  describe('aspectToColumnSpan', () => {
@@ -10,6 +10,10 @@ const meta = {
10
10
  columns: { control: 'number' },
11
11
  isCentered: { control: 'boolean' },
12
12
  isMasonry: { control: 'boolean' },
13
+ masonryPreferredColumnSpan: {
14
+ control: 'select',
15
+ options: ['full', 'three-quarters', 'two-thirds', 'half', 'one-third', 'one-quarter'],
16
+ },
13
17
  },
14
18
  } satisfies Meta<typeof Grid>;
15
19
 
@@ -140,6 +144,54 @@ export const MasonryWithSpans: Story = {
140
144
  ),
141
145
  };
142
146
 
147
+ /**
148
+ * `masonryPreferredColumnSpan="half"` makes every auto-span item try to span
149
+ * half the grid. Because `"half"` resolves through the foundation's responsive
150
+ * column tokens, the same value yields 2 cols on a 4-col mobile grid, 4 cols
151
+ * on an 8-col tablet grid, and 6 cols on a 12-col desktop grid — the layout
152
+ * adapts without you configuring per-breakpoint values.
153
+ *
154
+ * When an item would overflow the row, the polyfill **demotes** its span to
155
+ * fill just the remaining columns, so no trailing gap appears.
156
+ *
157
+ * Use the Controls to swap `masonryPreferredColumnSpan` between named values
158
+ * and watch the packing re-run. Resize the viewport to see the demotion adapt
159
+ * to the current column count.
160
+ */
161
+ export const MasonryPreferredColumnSpan: Story = {
162
+ args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
163
+ render: (args) => (
164
+ <Grid {...args}>
165
+ {Array.from({ length: 7 }).map((_, i) => (
166
+ <GridItem key={i} style={masonryCellStyle(i)}>
167
+ Card {i + 1}
168
+ </GridItem>
169
+ ))}
170
+ </Grid>
171
+ ),
172
+ };
173
+
174
+ /**
175
+ * Mixing an explicit `columnSpan="three-quarters"` hero with auto items.
176
+ * The hero keeps its span; its columns still consume the demotion budget,
177
+ * so the auto items fill the remaining row consistently.
178
+ */
179
+ export const MasonryPreferredColumnSpanWithHero: Story = {
180
+ args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
181
+ render: (args) => (
182
+ <Grid {...args}>
183
+ <GridItem columnSpan="three-quarters" style={masonryCellStyle(0)}>
184
+ Hero (three-quarters)
185
+ </GridItem>
186
+ {Array.from({ length: 8 }).map((_, i) => (
187
+ <GridItem key={i} style={masonryCellStyle(i + 1)}>
188
+ Card {i + 1}
189
+ </GridItem>
190
+ ))}
191
+ </Grid>
192
+ ),
193
+ };
194
+
143
195
  export const ComplexLayout: Story = {
144
196
  args: {},
145
197
  render: () => (
@@ -11,6 +11,7 @@ import css from './Grid.module.css';
11
11
  import { cn } from '@boostdev/design-system-foundation';
12
12
  import type { WithClassName } from '../../../types';
13
13
  import { useMasonry } from './masonry';
14
+ import type { GridItemSpanValue } from './GridItem';
14
15
 
15
16
  export type GridVariant = 'main' | 'page' | 'funnel' | 'custom';
16
17
 
@@ -45,6 +46,18 @@ type GridOwnProps = WithClassName & {
45
46
  * are unaffected.
46
47
  */
47
48
  autoSpanMedia?: boolean;
49
+ /**
50
+ * Masonry only. When set, each `GridItem` without an explicit `columnSpan`
51
+ * tries to span this value; an item at the end of a row is demoted to fill
52
+ * just the remaining columns rather than wrapping and leaving a gap. Uses
53
+ * the same named span vocabulary as `columnSpan` on `GridItem`, so a value
54
+ * like `"half"` adapts across breakpoints via the foundation's responsive
55
+ * column tokens (4/8/12). A `number` is a literal column count and does
56
+ * **not** adapt — prefer named values unless you're using `variant="custom"`.
57
+ * Requires `isMasonry`; no-op otherwise. Items with an explicit `columnSpan`
58
+ * keep their span.
59
+ */
60
+ masonryPreferredColumnSpan?: Exclude<GridItemSpanValue, 'auto'>;
48
61
  as?: ElementType;
49
62
  };
50
63
 
@@ -59,12 +72,13 @@ export function Grid<C extends ElementType = 'div'>({
59
72
  isCentered = false,
60
73
  isMasonry = false,
61
74
  autoSpanMedia = false,
75
+ masonryPreferredColumnSpan,
62
76
  as,
63
77
  style,
64
78
  ...rest
65
79
  }: GridProps<C>) {
66
80
  const ref = useRef<HTMLElement>(null);
67
- useMasonry(ref, isMasonry);
81
+ useMasonry(ref, isMasonry, { preferredColumnSpan: masonryPreferredColumnSpan });
68
82
 
69
83
  const classNames = cn(
70
84
  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
  >