@boostdev/design-system-components 2.2.0 → 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`;
@@ -146,6 +146,25 @@ function applyMasonryLayout(container, rawChildren) {
146
146
  child.style.gridRow = "";
147
147
  child.style.gridRowStart = "";
148
148
  child.style.gridRowEnd = "";
149
+ child.style.alignSelf = "start";
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
+ }
149
168
  }
150
169
  const heights = children.map((child) => child.offsetHeight);
151
170
  for (let i3 = 0; i3 < children.length; i3++) {
@@ -153,6 +172,25 @@ function applyMasonryLayout(container, rawChildren) {
153
172
  children[i3].style.gridRowEnd = `span ${span}`;
154
173
  }
155
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
+ }
156
194
  function clearMasonryLayout(container, rawChildren) {
157
195
  container.style.gridAutoRows = "";
158
196
  container.style.gridAutoFlow = "";
@@ -162,6 +200,10 @@ function clearMasonryLayout(container, rawChildren) {
162
200
  child.style.gridRow = "";
163
201
  child.style.gridRowStart = "";
164
202
  child.style.gridRowEnd = "";
203
+ child.style.alignSelf = "";
204
+ if (child.hasAttribute("data-column-span-auto")) {
205
+ child.style.gridColumn = "";
206
+ }
165
207
  }
166
208
  }
167
209
 
@@ -220,7 +262,12 @@ var BdsGrid = class extends i2 {
220
262
  columns: { type: Number, reflect: true },
221
263
  isCentered: { type: Boolean, attribute: "is-centered", reflect: true },
222
264
  isMasonry: { type: Boolean, attribute: "is-masonry", reflect: true },
223
- 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
+ }
224
271
  };
225
272
  _ro;
226
273
  _mo;
@@ -232,6 +279,7 @@ var BdsGrid = class extends i2 {
232
279
  this.isCentered = false;
233
280
  this.isMasonry = false;
234
281
  this.autoSpanMedia = false;
282
+ this.masonryPreferredColumnSpan = void 0;
235
283
  }
236
284
  disconnectedCallback() {
237
285
  super.disconnectedCallback();
@@ -271,7 +319,11 @@ var BdsGrid = class extends i2 {
271
319
  cancelAnimationFrame(this._frame);
272
320
  this._frame = requestAnimationFrame(() => {
273
321
  const container = this._gridEl();
274
- if (container) applyMasonryLayout(container, this._items());
322
+ if (container) {
323
+ applyMasonryLayout(container, this._items(), {
324
+ preferredColumnSpan: this.masonryPreferredColumnSpan
325
+ });
326
+ }
275
327
  });
276
328
  }
277
329
  _teardownMasonry() {
@@ -428,6 +480,9 @@ var BdsGridItem = class extends i2 {
428
480
  const gridColumn = hasExplicitRange ? `${this.startColumn ?? "auto"} / ${this.endColumn ?? "auto"}` : resolveSpan(resolved);
429
481
  this.style.gridColumn = gridColumn;
430
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");
431
486
  }
432
487
  render() {
433
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.0",
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 |
@@ -143,6 +143,19 @@ describe('applyMasonryLayout', () => {
143
143
  expect(hidden.style.gridRowEnd).toBe('');
144
144
  });
145
145
 
146
+ it('sets align-self: start on children so they shrink-wrap for measurement and do not stretch into the gap', () => {
147
+ // Regression: without this, default `align-self: stretch` grows every child
148
+ // to fill the 1px grid-auto-rows track, making offsetHeight return 1 and
149
+ // collapsing all items to minimum size.
150
+ const container = makeContainer(10);
151
+ const a = makeItem(100);
152
+ const b = makeItem(50);
153
+ container.append(a, b);
154
+ applyMasonryLayout(container, [a, b]);
155
+ expect(a.style.alignSelf).toBe('start');
156
+ expect(b.style.alignSelf).toBe('start');
157
+ });
158
+
146
159
  it('clearMasonryLayout undoes container and child styles', () => {
147
160
  const container = makeContainer(10);
148
161
  const item = makeItem(50);
@@ -155,6 +168,73 @@ describe('applyMasonryLayout', () => {
155
168
  expect(item.style.gridRowEnd).toBe('');
156
169
  expect(item.style.gridRowStart).toBe('');
157
170
  expect(item.style.gridRow).toBe('');
171
+ expect(item.style.alignSelf).toBe('');
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
+ });
158
238
  });
159
239
  });
160
240
 
@@ -206,6 +286,21 @@ describe('GridItem', () => {
206
286
  const { container } = render(<GridItem as="article">x</GridItem>);
207
287
  expect(container.firstChild?.nodeName).toBe('ARTICLE');
208
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
+ });
209
304
  });
210
305
 
211
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: () => (
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Context,
2
3
  ElementType,
3
4
  ComponentPropsWithoutRef,
4
5
  CSSProperties,
@@ -23,9 +24,14 @@ export type GridContextValue = {
23
24
  autoSpanMedia: boolean;
24
25
  };
25
26
 
26
- export const GridContext = createContext<GridContextValue>({
27
- autoSpanMedia: false,
28
- });
27
+ // Lazy-initialised so the module can be evaluated in Next.js Server Components
28
+ // without calling `createContext` at import time (which crashes RSC — `react-server`
29
+ // export conditions don't expose `createContext`).
30
+ let _GridContext: Context<GridContextValue>;
31
+ export function getGridContext(): Context<GridContextValue> {
32
+ _GridContext ??= createContext<GridContextValue>({ autoSpanMedia: false });
33
+ return _GridContext;
34
+ }
29
35
 
30
36
  type GridOwnProps = WithClassName & {
31
37
  variant?: GridVariant;
@@ -39,6 +45,14 @@ type GridOwnProps = WithClassName & {
39
45
  * are unaffected.
40
46
  */
41
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;
42
56
  as?: ElementType;
43
57
  };
44
58
 
@@ -53,12 +67,13 @@ export function Grid<C extends ElementType = 'div'>({
53
67
  isCentered = false,
54
68
  isMasonry = false,
55
69
  autoSpanMedia = false,
70
+ masonryPreferredColumnSpan,
56
71
  as,
57
72
  style,
58
73
  ...rest
59
74
  }: GridProps<C>) {
60
75
  const ref = useRef<HTMLElement>(null);
61
- useMasonry(ref, isMasonry);
76
+ useMasonry(ref, isMasonry, { preferredColumnSpan: masonryPreferredColumnSpan });
62
77
 
63
78
  const classNames = cn(
64
79
  css.grid,
@@ -75,6 +90,7 @@ export function Grid<C extends ElementType = 'div'>({
75
90
 
76
91
  const Component = as ?? 'div';
77
92
 
93
+ const GridContext = getGridContext();
78
94
  const ctx = useMemo<GridContextValue>(() => ({ autoSpanMedia }), [autoSpanMedia]);
79
95
 
80
96
  return (
@@ -10,7 +10,7 @@ import {
10
10
  import css from './Grid.module.css';
11
11
  import { cn } from '@boostdev/design-system-foundation';
12
12
  import type { WithClassName } from '../../../types';
13
- import { GridContext } from './Grid';
13
+ import { getGridContext } from './Grid';
14
14
  import {
15
15
  aspectToColumnSpan,
16
16
  findMediaChild,
@@ -75,7 +75,7 @@ export function GridItem<C extends ElementType = 'div'>({
75
75
  style,
76
76
  ...rest
77
77
  }: GridItemProps<C>) {
78
- const { autoSpanMedia } = useContext(GridContext);
78
+ const { autoSpanMedia } = useContext(getGridContext());
79
79
  const effectiveSpan: GridItemSpanValue =
80
80
  columnSpan ?? (autoSpanMedia ? 'auto' : 'full');
81
81
 
@@ -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
@@ -53,10 +69,37 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
53
69
  );
54
70
 
55
71
  // Pass 1 — clear prior row-span overrides so items measure at their natural height.
72
+ // Also force `align-self: start`: the default `stretch` would grow each item to fill
73
+ // the 1px track, making `offsetHeight` return 1 for every child. Keeping `start` after
74
+ // the span is assigned also prevents items from stretching into the gap region.
56
75
  for (const child of children) {
57
76
  child.style.gridRow = '';
58
77
  child.style.gridRowStart = '';
59
78
  child.style.gridRowEnd = '';
79
+ child.style.alignSelf = 'start';
80
+ }
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
+ }
60
103
  }
61
104
 
62
105
  // Pass 2 — read all heights in a tight loop. The first offsetHeight read forces one
@@ -72,6 +115,36 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
72
115
  }
73
116
  }
74
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
+
75
148
  export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElement[]): void {
76
149
  container.style.gridAutoRows = '';
77
150
  container.style.gridAutoFlow = '';
@@ -81,6 +154,13 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
81
154
  child.style.gridRow = '';
82
155
  child.style.gridRowStart = '';
83
156
  child.style.gridRowEnd = '';
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
+ }
84
164
  }
85
165
  }
86
166
 
@@ -89,7 +169,12 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
89
169
  * size / mutation changes. No-op when `enabled` is false or when the browser
90
170
  * natively supports masonry.
91
171
  */
92
- 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;
93
178
  useLayoutEffect(() => {
94
179
  const container = ref.current;
95
180
  if (!container || !enabled) return;
@@ -99,7 +184,9 @@ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean)
99
184
  const schedule = () => {
100
185
  cancelAnimationFrame(frame);
101
186
  frame = requestAnimationFrame(() => {
102
- applyMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
187
+ applyMasonryLayout(container, Array.from(container.children) as HTMLElement[], {
188
+ preferredColumnSpan,
189
+ });
103
190
  });
104
191
  };
105
192
 
@@ -130,5 +217,5 @@ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean)
130
217
  mo?.disconnect();
131
218
  clearMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
132
219
  };
133
- }, [ref, enabled]);
220
+ }, [ref, enabled, preferredColumnSpan]);
134
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.