@boostdev/design-system-components 2.3.0 → 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,9 +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 — 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
+ * 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.
61
64
  *
62
65
  * Slots:
63
66
  * (default) — grid content; typically `<bds-grid-item>` children
@@ -89,7 +92,7 @@ declare class BdsGrid extends LitElement {
89
92
  reflect: boolean;
90
93
  };
91
94
  masonryPreferredColumnSpan: {
92
- type: NumberConstructor;
95
+ type: StringConstructor;
93
96
  attribute: string;
94
97
  reflect: boolean;
95
98
  };
@@ -99,7 +102,7 @@ declare class BdsGrid extends LitElement {
99
102
  isCentered: boolean;
100
103
  isMasonry: boolean;
101
104
  autoSpanMedia: boolean;
102
- masonryPreferredColumnSpan: number | undefined;
105
+ masonryPreferredColumnSpan: string | undefined;
103
106
  private _ro?;
104
107
  private _mo?;
105
108
  private _frame;
@@ -110,6 +113,7 @@ declare class BdsGrid extends LitElement {
110
113
  private _items;
111
114
  private _setupMasonry;
112
115
  private _scheduleMasonry;
116
+ private _resolvePreferredColumnSpan;
113
117
  private _teardownMasonry;
114
118
  render(): lit.TemplateResult<1>;
115
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
  }
159
+ var ORIGINAL_COL_ATTR = "data-bds-masonry-original-column";
136
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,15 +173,19 @@ function applyMasonryLayout(container, rawChildren, options = {}) {
148
173
  child.style.gridRowEnd = "";
149
174
  child.style.alignSelf = "start";
150
175
  }
151
- const preferred = options.preferredColumnSpan ?? 0;
152
- if (preferred >= 2) {
176
+ const preferred = options.preferredColumnSpan;
177
+ if (preferred !== void 0) {
153
178
  const totalCols = countGridColumns(container);
154
- if (totalCols > 0) {
179
+ const preferredCols = totalCols > 0 ? resolvePreferredColumns(container, preferred, totalCols) : 0;
180
+ if (totalCols > 0 && preferredCols > 0) {
155
181
  let col = 0;
156
182
  for (const child of children) {
157
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
+ }
158
187
  const remaining = totalCols - col;
159
- const span = Math.max(1, Math.min(preferred, remaining));
188
+ const span = Math.max(1, Math.min(preferredCols, remaining));
160
189
  child.style.gridColumn = `span ${span}`;
161
190
  col = (col + span) % totalCols;
162
191
  } else {
@@ -165,6 +194,10 @@ function applyMasonryLayout(container, rawChildren, options = {}) {
165
194
  }
166
195
  }
167
196
  }
197
+ } else {
198
+ for (const child of children) {
199
+ restoreOriginalColumn(child);
200
+ }
168
201
  }
169
202
  const heights = children.map((child) => child.offsetHeight);
170
203
  for (let i3 = 0; i3 < children.length; i3++) {
@@ -195,17 +228,21 @@ function clearMasonryLayout(container, rawChildren) {
195
228
  container.style.gridAutoRows = "";
196
229
  container.style.gridAutoFlow = "";
197
230
  container.style.rowGap = "";
231
+ container.style.overflowAnchor = "";
198
232
  for (const child of rawChildren) {
199
233
  if (child.nodeType !== 1) continue;
200
234
  child.style.gridRow = "";
201
235
  child.style.gridRowStart = "";
202
236
  child.style.gridRowEnd = "";
203
237
  child.style.alignSelf = "";
204
- if (child.hasAttribute("data-column-span-auto")) {
205
- child.style.gridColumn = "";
206
- }
238
+ restoreOriginalColumn(child);
207
239
  }
208
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
+ }
209
246
 
210
247
  // src/web-components/layout/bds-grid.ts
211
248
  var BdsGrid = class extends i2 {
@@ -264,7 +301,7 @@ var BdsGrid = class extends i2 {
264
301
  isMasonry: { type: Boolean, attribute: "is-masonry", reflect: true },
265
302
  autoSpanMedia: { type: Boolean, attribute: "auto-span-media", reflect: true },
266
303
  masonryPreferredColumnSpan: {
267
- type: Number,
304
+ type: String,
268
305
  attribute: "masonry-preferred-column-span",
269
306
  reflect: true
270
307
  }
@@ -321,11 +358,18 @@ var BdsGrid = class extends i2 {
321
358
  const container = this._gridEl();
322
359
  if (container) {
323
360
  applyMasonryLayout(container, this._items(), {
324
- preferredColumnSpan: this.masonryPreferredColumnSpan
361
+ preferredColumnSpan: this._resolvePreferredColumnSpan()
325
362
  });
326
363
  }
327
364
  });
328
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
+ }
329
373
  _teardownMasonry() {
330
374
  cancelAnimationFrame(this._frame);
331
375
  this._ro?.disconnect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-components",
3
- "version": "2.3.0",
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",
@@ -203,16 +203,18 @@ Intrinsic dimensions are read from `img.naturalWidth/naturalHeight` and `video.v
203
203
 
204
204
  ## Masonry preferred column span
205
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.
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
207
 
208
208
  ```tsx
209
- <Grid isMasonry masonryPreferredColumnSpan={2}>
209
+ <Grid isMasonry masonryPreferredColumnSpan="half">
210
210
  {cards.map((c) => (
211
211
  <GridItem key={c.id}>…</GridItem>
212
212
  ))}
213
213
  </Grid>
214
214
  ```
215
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
+
216
218
  Rules:
217
219
 
218
220
  - Requires `isMasonry`. No-op on non-masonry grids.
@@ -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);
@@ -179,12 +196,13 @@ describe('applyMasonryLayout', () => {
179
196
  }
180
197
 
181
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).
182
200
  const container = makeContainer(10);
183
201
  container.style.gridTemplateColumns = '100px 100px 100px 100px';
184
202
  const a = makeAutoItem(50);
185
203
  const b = makeAutoItem(50);
186
204
  container.append(a, b);
187
- applyMasonryLayout(container, [a, b], { preferredColumnSpan: 2 });
205
+ applyMasonryLayout(container, [a, b], { preferredColumnSpan: 'half' });
188
206
  expect(a.style.gridColumn).toBe('span 2');
189
207
  expect(b.style.gridColumn).toBe('span 2');
190
208
  });
@@ -195,7 +213,8 @@ describe('applyMasonryLayout', () => {
195
213
  const a = makeAutoItem(50);
196
214
  const b = makeAutoItem(50);
197
215
  container.append(a, b);
198
- applyMasonryLayout(container, [a, b], { preferredColumnSpan: 2 });
216
+ // 'two-thirds' in a 3-col grid 2 cols.
217
+ applyMasonryLayout(container, [a, b], { preferredColumnSpan: 'two-thirds' });
199
218
  expect(a.style.gridColumn).toBe('span 2');
200
219
  // Only 1 col remains after `a` — `b` gets demoted.
201
220
  expect(b.style.gridColumn).toBe('span 1');
@@ -208,7 +227,7 @@ describe('applyMasonryLayout', () => {
208
227
  explicit.style.gridColumn = 'span 3';
209
228
  const auto = makeAutoItem(50);
210
229
  container.append(explicit, auto);
211
- applyMasonryLayout(container, [explicit, auto], { preferredColumnSpan: 2 });
230
+ applyMasonryLayout(container, [explicit, auto], { preferredColumnSpan: 'half' });
212
231
  expect(explicit.style.gridColumn).toBe('span 3');
213
232
  // 3 cols consumed, 1 remains → auto demoted.
214
233
  expect(auto.style.gridColumn).toBe('span 1');
@@ -223,16 +242,53 @@ describe('applyMasonryLayout', () => {
223
242
  expect(a.style.gridColumn).toBe('');
224
243
  });
225
244
 
226
- it('clearMasonryLayout resets gridColumn on data-column-span-auto items only', () => {
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', () => {
227
282
  const container = makeContainer(10);
228
283
  container.style.gridTemplateColumns = '100px 100px 100px 100px';
229
284
  const auto = makeAutoItem(50);
285
+ auto.style.gridColumn = 'var(--bds-grid_span-100)';
230
286
  const explicit = makeItem(50);
231
287
  explicit.style.gridColumn = 'span 2';
232
288
  container.append(auto, explicit);
233
- applyMasonryLayout(container, [auto, explicit], { preferredColumnSpan: 2 });
289
+ applyMasonryLayout(container, [auto, explicit], { preferredColumnSpan: 'half' });
234
290
  clearMasonryLayout(container, [auto, explicit]);
235
- expect(auto.style.gridColumn).toBe('');
291
+ expect(auto.style.gridColumn).toBe('var(--bds-grid_span-100)');
236
292
  expect(explicit.style.gridColumn).toBe('span 2');
237
293
  });
238
294
  });
@@ -10,7 +10,10 @@ 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
+ masonryPreferredColumnSpan: {
14
+ control: 'select',
15
+ options: ['full', 'three-quarters', 'two-thirds', 'half', 'one-third', 'one-quarter'],
16
+ },
14
17
  },
15
18
  } satisfies Meta<typeof Grid>;
16
19
 
@@ -142,17 +145,21 @@ export const MasonryWithSpans: Story = {
142
145
  };
143
146
 
144
147
  /**
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
+ * `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
148
155
  * fill just the remaining columns, so no trailing gap appears.
149
156
  *
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.
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.
153
160
  */
154
161
  export const MasonryPreferredColumnSpan: Story = {
155
- args: { isMasonry: true, masonryPreferredColumnSpan: 2 },
162
+ args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
156
163
  render: (args) => (
157
164
  <Grid {...args}>
158
165
  {Array.from({ length: 7 }).map((_, i) => (
@@ -170,7 +177,7 @@ export const MasonryPreferredColumnSpan: Story = {
170
177
  * so the auto items fill the remaining row consistently.
171
178
  */
172
179
  export const MasonryPreferredColumnSpanWithHero: Story = {
173
- args: { isMasonry: true, masonryPreferredColumnSpan: 2 },
180
+ args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
174
181
  render: (args) => (
175
182
  <Grid {...args}>
176
183
  <GridItem columnSpan="three-quarters" style={masonryCellStyle(0)}>
@@ -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
 
@@ -46,13 +47,17 @@ type GridOwnProps = WithClassName & {
46
47
  */
47
48
  autoSpanMedia?: boolean;
48
49
  /**
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.
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.
54
59
  */
55
- masonryPreferredColumnSpan?: number;
60
+ masonryPreferredColumnSpan?: Exclude<GridItemSpanValue, 'auto'>;
56
61
  as?: ElementType;
57
62
  };
58
63
 
@@ -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").
@@ -38,16 +77,24 @@ export function supportsNativeMasonry(): boolean {
38
77
 
39
78
  export type MasonryOptions = {
40
79
  /**
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.
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.
47
88
  */
48
- preferredColumnSpan?: number;
89
+ preferredColumnSpan?: Exclude<GridItemSpanValue, 'auto'>;
49
90
  };
50
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
+
51
98
  export function applyMasonryLayout(
52
99
  container: HTMLElement,
53
100
  rawChildren: HTMLElement[],
@@ -63,6 +110,13 @@ export function applyMasonryLayout(
63
110
  container.style.gridAutoRows = `${ROW_UNIT_PX}px`;
64
111
  container.style.rowGap = '0px';
65
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';
66
120
 
67
121
  const children = rawChildren.filter(
68
122
  (el) => el.nodeType === 1 && window.getComputedStyle(el).display !== 'none',
@@ -81,16 +135,23 @@ export function applyMasonryLayout(
81
135
 
82
136
  // Preferred-column-span pass — optional. Assigns column spans to `data-column-span-auto`
83
137
  // 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) {
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) {
87
144
  const totalCols = countGridColumns(container);
88
- if (totalCols > 0) {
145
+ const preferredCols = totalCols > 0 ? resolvePreferredColumns(container, preferred, totalCols) : 0;
146
+ if (totalCols > 0 && preferredCols > 0) {
89
147
  let col = 0;
90
148
  for (const child of children) {
91
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
+ }
92
153
  const remaining = totalCols - col;
93
- const span = Math.max(1, Math.min(preferred, remaining));
154
+ const span = Math.max(1, Math.min(preferredCols, remaining));
94
155
  child.style.gridColumn = `span ${span}`;
95
156
  col = (col + span) % totalCols;
96
157
  } else {
@@ -100,6 +161,11 @@ export function applyMasonryLayout(
100
161
  }
101
162
  }
102
163
  }
164
+ } else {
165
+ // No preferred span — restore any items the polyfill previously wrote.
166
+ for (const child of children) {
167
+ restoreOriginalColumn(child);
168
+ }
103
169
  }
104
170
 
105
171
  // Pass 2 — read all heights in a tight loop. The first offsetHeight read forces one
@@ -149,21 +215,25 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
149
215
  container.style.gridAutoRows = '';
150
216
  container.style.gridAutoFlow = '';
151
217
  container.style.rowGap = '';
218
+ container.style.overflowAnchor = '';
152
219
  for (const child of rawChildren) {
153
220
  if (child.nodeType !== 1) continue;
154
221
  child.style.gridRow = '';
155
222
  child.style.gridRowStart = '';
156
223
  child.style.gridRowEnd = '';
157
224
  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
- }
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);
164
228
  }
165
229
  }
166
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
+
167
237
  /**
168
238
  * React hook that runs the polyfill against `ref.current` and re-runs on
169
239
  * size / mutation changes. No-op when `enabled` is false or when the browser
@@ -49,7 +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
+ | `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). |
53
53
 
54
54
  ## Slots
55
55
 
@@ -164,15 +164,17 @@ Named-span responsive behavior still applies — on mobile every item collapses
164
164
 
165
165
  ## Masonry preferred column span
166
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.
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
168
 
169
169
  ```html
170
- <bds-grid is-masonry masonry-preferred-column-span="2">
170
+ <bds-grid is-masonry masonry-preferred-column-span="half">
171
171
  <bds-grid-item>…</bds-grid-item>
172
172
  <bds-grid-item>…</bds-grid-item>
173
173
  </bds-grid>
174
174
  ```
175
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
+
176
178
  Rules:
177
179
 
178
180
  - Requires `is-masonry`. No-op on non-masonry grids.
@@ -16,7 +16,7 @@ function BdsGrid({
16
16
  columns?: number;
17
17
  isCentered?: boolean;
18
18
  isMasonry?: boolean;
19
- masonryPreferredColumnSpan?: number;
19
+ masonryPreferredColumnSpan?: string;
20
20
  children?: React.ReactNode;
21
21
  }) {
22
22
  return React.createElement(
@@ -74,7 +74,10 @@ const meta = {
74
74
  columns: { control: 'number' },
75
75
  isCentered: { control: 'boolean' },
76
76
  isMasonry: { control: 'boolean' },
77
- masonryPreferredColumnSpan: { control: { type: 'number', min: 1, max: 4 } },
77
+ masonryPreferredColumnSpan: {
78
+ control: 'select',
79
+ options: ['full', 'three-quarters', 'two-thirds', 'half', 'one-third', 'one-quarter'],
80
+ },
78
81
  },
79
82
  } satisfies Meta<typeof BdsGrid>;
80
83
 
@@ -194,16 +197,19 @@ export const MasonryWithSpans: Story = {
194
197
  };
195
198
 
196
199
  /**
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
+ * `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.
200
207
  *
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.
208
+ * Swap `masonryPreferredColumnSpan` between named values in the Controls and
209
+ * resize the viewport to watch the demotion adapt to the current column count.
204
210
  */
205
211
  export const MasonryPreferredColumnSpan: Story = {
206
- args: { isMasonry: true, masonryPreferredColumnSpan: 2 },
212
+ args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
207
213
  render: (args) => (
208
214
  <BdsGrid {...args}>
209
215
  {Array.from({ length: 7 }).map((_, i) => (
@@ -219,7 +225,7 @@ export const MasonryPreferredColumnSpan: Story = {
219
225
  * auto items fill the remaining row consistently.
220
226
  */
221
227
  export const MasonryPreferredColumnSpanWithHero: Story = {
222
- args: { isMasonry: true, masonryPreferredColumnSpan: 2 },
228
+ args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
223
229
  render: (args) => (
224
230
  <BdsGrid {...args}>
225
231
  <Item columnSpan="three-quarters"><div style={masonryCellStyle(0)}>Hero (three-quarters)</div></Item>