@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.
@@ -1,4 +1,43 @@
1
1
  import { RefObject, useLayoutEffect } from 'react';
2
+ import type { GridItemSpanValue } from './GridItem';
3
+
4
+ type NamedSpan = Exclude<GridItemSpanValue, 'auto' | number>;
5
+
6
+ // Named spans resolve through the foundation's responsive column tokens so they
7
+ // match the behavior of `columnSpan` on `GridItem`. The polyfill reads the live
8
+ // value at each run, which is how a single `preferredColumnSpan: 'half'` gives
9
+ // 2 cols on a 4-col mobile grid and 6 cols on a 12-col desktop grid.
10
+ const NAMED_SPAN_VAR: Record<Exclude<NamedSpan, 'full'>, string> = {
11
+ 'three-quarters': '--bds-grid_columns-75',
12
+ 'two-thirds': '--bds-grid_columns-66',
13
+ half: '--bds-grid_columns-50',
14
+ 'one-third': '--bds-grid_columns-33',
15
+ 'one-quarter': '--bds-grid_columns-25',
16
+ };
17
+
18
+ // Mathematical fallback for environments where the foundation CSS isn't loaded
19
+ // (e.g. jsdom tests). Round to the nearest integer; clamp to [1, totalCols].
20
+ const NAMED_SPAN_FRACTION: Record<Exclude<NamedSpan, 'full'>, number> = {
21
+ 'three-quarters': 0.75,
22
+ 'two-thirds': 2 / 3,
23
+ half: 0.5,
24
+ 'one-third': 1 / 3,
25
+ 'one-quarter': 0.25,
26
+ };
27
+
28
+ function resolvePreferredColumns(
29
+ container: HTMLElement,
30
+ value: Exclude<GridItemSpanValue, 'auto'>,
31
+ totalCols: number,
32
+ ): number {
33
+ if (value === 'full') return totalCols;
34
+ if (typeof value === 'number') return Math.max(1, Math.min(totalCols, value));
35
+ const varName = NAMED_SPAN_VAR[value];
36
+ const raw = window.getComputedStyle(container).getPropertyValue(varName).trim();
37
+ const n = parseInt(raw, 10);
38
+ if (Number.isFinite(n) && n > 0) return Math.min(totalCols, n);
39
+ return Math.max(1, Math.round(totalCols * NAMED_SPAN_FRACTION[value]));
40
+ }
2
41
 
3
42
  /**
4
43
  * Masonry layout polyfill based on CSS Grid Layout Module Level 3 (a.k.a. "grid-lanes").
@@ -36,7 +75,31 @@ export function supportsNativeMasonry(): boolean {
36
75
  );
37
76
  }
38
77
 
39
- export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElement[]): void {
78
+ export type MasonryOptions = {
79
+ /**
80
+ * When set, the polyfill assigns column spans to items marked
81
+ * `data-column-span-auto`. Named values (`'half'`, `'one-third'`, etc.) are
82
+ * resolved through the foundation's responsive column tokens so the same
83
+ * option yields the right span at every breakpoint. A `number` is a literal
84
+ * column count. If a preferred-span item would overflow the row, it is
85
+ * demoted to fill only the remaining columns (never less than 1). Items with
86
+ * an explicit `columnSpan` keep their span; their span still consumes column
87
+ * budget for the demotion calculation.
88
+ */
89
+ preferredColumnSpan?: Exclude<GridItemSpanValue, 'auto'>;
90
+ };
91
+
92
+ // Data attribute used to remember an auto item's inline `grid-column` before the
93
+ // polyfill overwrites it — restored on cleanup or when the preferred option is
94
+ // unset, so a Storybook control going from preferred=2 → undefined doesn't leave
95
+ // items pinned to their polyfill-assigned span.
96
+ const ORIGINAL_COL_ATTR = 'data-bds-masonry-original-column';
97
+
98
+ export function applyMasonryLayout(
99
+ container: HTMLElement,
100
+ rawChildren: HTMLElement[],
101
+ options: MasonryOptions = {},
102
+ ): void {
40
103
  // We override row-gap to 0, so reading it back would always give 0 on re-runs.
41
104
  // Read column-gap instead — `.grid` sets both via the `gap` shorthand and the
42
105
  // polyfill never touches column-gap. (If a consumer sets asymmetric gaps, the
@@ -47,6 +110,13 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
47
110
  container.style.gridAutoRows = `${ROW_UNIT_PX}px`;
48
111
  container.style.rowGap = '0px';
49
112
  container.style.gridAutoFlow = 'dense';
113
+ // `dense` + per-child `ResizeObserver` can fight the browser's scroll anchoring:
114
+ // a subpixel child resize (scrollbar toggling, late image decode, font swap) re-runs
115
+ // the pack, items above the viewport shift, and the browser yanks the viewport up
116
+ // to "follow" them. Opting this container out of scroll anchoring means the browser
117
+ // picks something outside the grid as the anchor, so internal reshuffles are invisible
118
+ // to scroll position. Cleared in clearMasonryLayout.
119
+ container.style.overflowAnchor = 'none';
50
120
 
51
121
  const children = rawChildren.filter(
52
122
  (el) => el.nodeType === 1 && window.getComputedStyle(el).display !== 'none',
@@ -63,6 +133,41 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
63
133
  child.style.alignSelf = 'start';
64
134
  }
65
135
 
136
+ // Preferred-column-span pass — optional. Assigns column spans to `data-column-span-auto`
137
+ // items, demoting to fit remaining row space. Runs before height measurement so items
138
+ // are measured at the width they will actually render. When the option is not set,
139
+ // any previously-written spans are restored to their original inline value so
140
+ // a Storybook control change from preferred='half' → undefined doesn't leave auto
141
+ // items pinned.
142
+ const preferred = options.preferredColumnSpan;
143
+ if (preferred !== undefined) {
144
+ const totalCols = countGridColumns(container);
145
+ const preferredCols = totalCols > 0 ? resolvePreferredColumns(container, preferred, totalCols) : 0;
146
+ if (totalCols > 0 && preferredCols > 0) {
147
+ let col = 0;
148
+ for (const child of children) {
149
+ if (child.hasAttribute('data-column-span-auto')) {
150
+ if (!child.hasAttribute(ORIGINAL_COL_ATTR)) {
151
+ child.setAttribute(ORIGINAL_COL_ATTR, child.style.gridColumn);
152
+ }
153
+ const remaining = totalCols - col;
154
+ const span = Math.max(1, Math.min(preferredCols, remaining));
155
+ child.style.gridColumn = `span ${span}`;
156
+ col = (col + span) % totalCols;
157
+ } else {
158
+ // Item has an explicit span — consume its columns for the demotion budget.
159
+ const consumed = Math.min(totalCols, readExplicitSpan(child, totalCols));
160
+ col = (col + consumed) % totalCols;
161
+ }
162
+ }
163
+ }
164
+ } else {
165
+ // No preferred span — restore any items the polyfill previously wrote.
166
+ for (const child of children) {
167
+ restoreOriginalColumn(child);
168
+ }
169
+ }
170
+
66
171
  // Pass 2 — read all heights in a tight loop. The first offsetHeight read forces one
67
172
  // reflow; subsequent reads are cached until the next style write. This avoids the
68
173
  // O(N) forced-reflow cost of a read-then-write loop.
@@ -76,25 +181,70 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
76
181
  }
77
182
  }
78
183
 
184
+ function countGridColumns(container: HTMLElement): number {
185
+ // `getComputedStyle` resolves `grid-template-columns` to concrete track sizes
186
+ // (e.g. "256px 256px 256px 256px"), regardless of whether the author used
187
+ // `repeat()`, `minmax()`, or CSS custom properties. Splitting by whitespace
188
+ // gives the actual track count at the current viewport width.
189
+ const value = window.getComputedStyle(container).gridTemplateColumns.trim();
190
+ if (!value || value === 'none') return 0;
191
+ return value.split(/\s+/).length;
192
+ }
193
+
194
+ function readExplicitSpan(el: HTMLElement, totalCols: number): number {
195
+ // Try the inline value first to avoid resolving CSS vars that haven't been
196
+ // registered with `@property` (computed style returns the literal string for
197
+ // those). Inline values look like `span N`, `span var(...)`, or `a / b`.
198
+ const inline = el.style.gridColumn || el.style.gridColumnEnd;
199
+ const fromInline = parseSpan(inline, totalCols);
200
+ if (fromInline !== null) return fromInline;
201
+ const computed = window.getComputedStyle(el).gridColumn;
202
+ return parseSpan(computed, totalCols) ?? 1;
203
+ }
204
+
205
+ function parseSpan(value: string, totalCols: number): number | null {
206
+ if (!value) return null;
207
+ const match = value.match(/^\s*span\s+(\d+)/);
208
+ if (match) return Math.max(1, Math.min(totalCols, parseInt(match[1], 10)));
209
+ // `1 / -1` style — spans the whole row.
210
+ if (/\/\s*-1\b/.test(value)) return totalCols;
211
+ return null;
212
+ }
213
+
79
214
  export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElement[]): void {
80
215
  container.style.gridAutoRows = '';
81
216
  container.style.gridAutoFlow = '';
82
217
  container.style.rowGap = '';
218
+ container.style.overflowAnchor = '';
83
219
  for (const child of rawChildren) {
84
220
  if (child.nodeType !== 1) continue;
85
221
  child.style.gridRow = '';
86
222
  child.style.gridRowStart = '';
87
223
  child.style.gridRowEnd = '';
88
224
  child.style.alignSelf = '';
225
+ // Restore any `gridColumn` value the polyfill overwrote. Items the
226
+ // polyfill never touched have no ORIGINAL_COL_ATTR and are left alone.
227
+ restoreOriginalColumn(child);
89
228
  }
90
229
  }
91
230
 
231
+ function restoreOriginalColumn(el: HTMLElement): void {
232
+ if (!el.hasAttribute(ORIGINAL_COL_ATTR)) return;
233
+ el.style.gridColumn = el.getAttribute(ORIGINAL_COL_ATTR) ?? '';
234
+ el.removeAttribute(ORIGINAL_COL_ATTR);
235
+ }
236
+
92
237
  /**
93
238
  * React hook that runs the polyfill against `ref.current` and re-runs on
94
239
  * size / mutation changes. No-op when `enabled` is false or when the browser
95
240
  * natively supports masonry.
96
241
  */
97
- export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean): void {
242
+ export function useMasonry(
243
+ ref: RefObject<HTMLElement | null>,
244
+ enabled: boolean,
245
+ options: MasonryOptions = {},
246
+ ): void {
247
+ const { preferredColumnSpan } = options;
98
248
  useLayoutEffect(() => {
99
249
  const container = ref.current;
100
250
  if (!container || !enabled) return;
@@ -104,7 +254,9 @@ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean)
104
254
  const schedule = () => {
105
255
  cancelAnimationFrame(frame);
106
256
  frame = requestAnimationFrame(() => {
107
- applyMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
257
+ applyMasonryLayout(container, Array.from(container.children) as HTMLElement[], {
258
+ preferredColumnSpan,
259
+ });
108
260
  });
109
261
  };
110
262
 
@@ -135,5 +287,5 @@ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean)
135
287
  mo?.disconnect();
136
288
  clearMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
137
289
  };
138
- }, [ref, enabled]);
290
+ }, [ref, enabled, preferredColumnSpan]);
139
291
  }
@@ -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` | named span \| number | — | In masonry mode, items without an explicit `column-span` try to span this value, demoting to fit at row ends. Accepts the same named values as `column-span` (`"half"`, `"one-third"`, etc.). See [Masonry preferred column span](#masonry-preferred-column-span). |
52
53
 
53
54
  ## Slots
54
55
 
@@ -161,6 +162,41 @@ 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 half-width" feed, set `masonry-preferred-column-span` on the `<bds-grid>`. Items without an explicit `column-span` 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.
168
+
169
+ ```html
170
+ <bds-grid is-masonry masonry-preferred-column-span="half">
171
+ <bds-grid-item>…</bds-grid-item>
172
+ <bds-grid-item>…</bds-grid-item>
173
+ </bds-grid>
174
+ ```
175
+
176
+ Accepted values match `column-span` on `<bds-grid-item>`: `"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 numeric string (e.g. `"2"`) is accepted too, but is a literal column count and does **not** adapt — prefer named values unless you're using `variant="custom"`.
177
+
178
+ Rules:
179
+
180
+ - Requires `is-masonry`. No-op on non-masonry grids.
181
+ - 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.
182
+ - Items with `start-column` / `end-column` are unaffected.
183
+ - 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.
184
+
185
+ ### When NOT to use
186
+
187
+ - 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.
188
+ - 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.
189
+ - 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.
190
+
191
+ ### Performance
192
+
193
+ - 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.
194
+ - Shares the polyfill's existing `ResizeObserver` + `MutationObserver` + `requestAnimationFrame` coalescing — no new observers, no separate render.
195
+
196
+ ### SSR / hydration
197
+
198
+ 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.
199
+
164
200
  ### Loading behavior
165
201
 
166
202
  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?: string;
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,10 @@ const meta = {
71
74
  columns: { control: 'number' },
72
75
  isCentered: { control: 'boolean' },
73
76
  isMasonry: { control: 'boolean' },
77
+ masonryPreferredColumnSpan: {
78
+ control: 'select',
79
+ options: ['full', 'three-quarters', 'two-thirds', 'half', 'one-third', 'one-quarter'],
80
+ },
74
81
  },
75
82
  } satisfies Meta<typeof BdsGrid>;
76
83
 
@@ -189,6 +196,46 @@ export const MasonryWithSpans: Story = {
189
196
  ),
190
197
  };
191
198
 
199
+ /**
200
+ * `masonry-preferred-column-span="half"` makes every auto-span item try to
201
+ * span half the grid. Named values resolve through the foundation's responsive
202
+ * column tokens, so the same value yields 2 cols on a 4-col mobile grid, 4 on
203
+ * an 8-col tablet grid, and 6 on a 12-col desktop grid.
204
+ *
205
+ * When an item would overflow the row, the polyfill **demotes** its span to
206
+ * fill just the remaining columns, so no trailing gap appears.
207
+ *
208
+ * Swap `masonryPreferredColumnSpan` between named values in the Controls and
209
+ * resize the viewport to watch the demotion adapt to the current column count.
210
+ */
211
+ export const MasonryPreferredColumnSpan: Story = {
212
+ args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
213
+ render: (args) => (
214
+ <BdsGrid {...args}>
215
+ {Array.from({ length: 7 }).map((_, i) => (
216
+ <Item key={i}><div style={masonryCellStyle(i)}>Card {i + 1}</div></Item>
217
+ ))}
218
+ </BdsGrid>
219
+ ),
220
+ };
221
+
222
+ /**
223
+ * An explicit `column-span="three-quarters"` hero alongside auto items.
224
+ * The hero keeps its span; its columns consume the demotion budget, so the
225
+ * auto items fill the remaining row consistently.
226
+ */
227
+ export const MasonryPreferredColumnSpanWithHero: Story = {
228
+ args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
229
+ render: (args) => (
230
+ <BdsGrid {...args}>
231
+ <Item columnSpan="three-quarters"><div style={masonryCellStyle(0)}>Hero (three-quarters)</div></Item>
232
+ {Array.from({ length: 8 }).map((_, i) => (
233
+ <Item key={i}><div style={masonryCellStyle(i + 1)}>Card {i + 1}</div></Item>
234
+ ))}
235
+ </BdsGrid>
236
+ ),
237
+ };
238
+
192
239
  export const ComplexLayout: Story = {
193
240
  args: {},
194
241
  render: () => (
@@ -99,4 +99,18 @@ describe('bds-grid-item', () => {
99
99
  expect(item.style.gridColumn).toBe('span var(--bds-grid_columns-33)');
100
100
  cleanup(grid);
101
101
  });
102
+
103
+ it('marks data-column-span-auto when column-span is not set', async () => {
104
+ const el = (await fixture('<bds-grid-item></bds-grid-item>')) as BdsGridItem;
105
+ expect(el.hasAttribute('data-column-span-auto')).toBe(true);
106
+ cleanup(el);
107
+ });
108
+
109
+ it('clears data-column-span-auto when column-span is set', async () => {
110
+ const el = (await fixture(
111
+ '<bds-grid-item column-span="half"></bds-grid-item>',
112
+ )) as BdsGridItem;
113
+ expect(el.hasAttribute('data-column-span-auto')).toBe(false);
114
+ cleanup(el);
115
+ });
102
116
  });
@@ -161,6 +161,13 @@ export class BdsGridItem extends LitElement {
161
161
 
162
162
  this.style.gridColumn = gridColumn;
163
163
  this.style.gridRow = this.rowSpan !== undefined ? `span ${this.rowSpan}` : '';
164
+
165
+ // Marker the masonry polyfill reads to know this item's span wasn't set by
166
+ // the consumer — it may override with the parent `masonry-preferred-column-span`.
167
+ const isColumnSpanAuto =
168
+ (this.columnSpan === undefined || this.columnSpan === null) && !hasExplicitRange;
169
+ if (isColumnSpanAuto) this.setAttribute('data-column-span-auto', '');
170
+ else this.removeAttribute('data-column-span-auto');
164
171
  }
165
172
 
166
173
  render() {
@@ -59,4 +59,12 @@ describe('bds-grid', () => {
59
59
  expect(el.shadowRoot!.querySelector('.grid')!.classList.contains('--masonry')).toBe(true);
60
60
  cleanup(el);
61
61
  });
62
+
63
+ it('reflects masonry-preferred-column-span as a string property', async () => {
64
+ const el = await fixture(
65
+ '<bds-grid is-masonry masonry-preferred-column-span="half"></bds-grid>',
66
+ ) as BdsGrid;
67
+ expect(el.masonryPreferredColumnSpan).toBe('half');
68
+ cleanup(el);
69
+ });
62
70
  });
@@ -1,5 +1,6 @@
1
1
  import { LitElement, css, html } from 'lit';
2
2
  import { applyMasonryLayout, clearMasonryLayout, supportsNativeMasonry } from '../../components/layout/Grid/masonry';
3
+ import type { GridItemSpanValue } from '../../components/layout/Grid/GridItem';
3
4
 
4
5
  export type GridVariant = 'main' | 'page' | 'funnel' | 'custom';
5
6
 
@@ -16,6 +17,12 @@ export type GridVariant = 'main' | 'page' | 'funnel' | 'custom';
16
17
  * auto-span-media — boolean; child `<bds-grid-item>`s without a `column-span`
17
18
  * attribute resolve their span from the intrinsic aspect
18
19
  * ratio of their first `<img>`/`<video>` child.
20
+ * masonry-preferred-column-span — named span or number; masonry only. Child
21
+ * items without a `column-span` attribute try to span this
22
+ * value (e.g. `"half"`, `"one-third"`). Named values adapt
23
+ * across breakpoints via the foundation's responsive column
24
+ * tokens. An item at row-end is demoted to the remaining
25
+ * column count. Numeric values are literal and don't adapt.
19
26
  *
20
27
  * Slots:
21
28
  * (default) — grid content; typically `<bds-grid-item>` children
@@ -76,6 +83,11 @@ export class BdsGrid extends LitElement {
76
83
  isCentered: { type: Boolean, attribute: 'is-centered', reflect: true },
77
84
  isMasonry: { type: Boolean, attribute: 'is-masonry', reflect: true },
78
85
  autoSpanMedia: { type: Boolean, attribute: 'auto-span-media', reflect: true },
86
+ masonryPreferredColumnSpan: {
87
+ type: String,
88
+ attribute: 'masonry-preferred-column-span',
89
+ reflect: true,
90
+ },
79
91
  };
80
92
 
81
93
  declare variant: GridVariant;
@@ -83,6 +95,7 @@ export class BdsGrid extends LitElement {
83
95
  declare isCentered: boolean;
84
96
  declare isMasonry: boolean;
85
97
  declare autoSpanMedia: boolean;
98
+ declare masonryPreferredColumnSpan: string | undefined;
86
99
 
87
100
  private _ro?: ResizeObserver;
88
101
  private _mo?: MutationObserver;
@@ -95,6 +108,7 @@ export class BdsGrid extends LitElement {
95
108
  this.isCentered = false;
96
109
  this.isMasonry = false;
97
110
  this.autoSpanMedia = false;
111
+ this.masonryPreferredColumnSpan = undefined;
98
112
  }
99
113
 
100
114
  disconnectedCallback() {
@@ -144,10 +158,23 @@ export class BdsGrid extends LitElement {
144
158
  cancelAnimationFrame(this._frame);
145
159
  this._frame = requestAnimationFrame(() => {
146
160
  const container = this._gridEl();
147
- if (container) applyMasonryLayout(container, this._items());
161
+ if (container) {
162
+ applyMasonryLayout(container, this._items(), {
163
+ preferredColumnSpan: this._resolvePreferredColumnSpan(),
164
+ });
165
+ }
148
166
  });
149
167
  }
150
168
 
169
+ private _resolvePreferredColumnSpan(): Exclude<GridItemSpanValue, 'auto'> | undefined {
170
+ const raw = this.masonryPreferredColumnSpan;
171
+ if (raw === undefined || raw === null || raw === '') return undefined;
172
+ // Numeric attribute → literal column count; otherwise treat as a named span.
173
+ const n = Number(raw);
174
+ if (Number.isFinite(n) && String(n) === raw) return n;
175
+ return raw as Exclude<GridItemSpanValue, 'auto'>;
176
+ }
177
+
151
178
  private _teardownMasonry() {
152
179
  cancelAnimationFrame(this._frame);
153
180
  this._ro?.disconnect();