@boostdev/design-system-components 2.3.0 → 2.4.1

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,35 +128,62 @@ 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
  );
145
170
  for (const child of children) {
146
- child.style.gridRow = "";
147
- child.style.gridRowStart = "";
148
- child.style.gridRowEnd = "";
149
171
  child.style.alignSelf = "start";
172
+ child.style.overflowAnchor = "none";
150
173
  }
151
- const preferred = options.preferredColumnSpan ?? 0;
152
- if (preferred >= 2) {
174
+ const preferred = options.preferredColumnSpan;
175
+ if (preferred !== void 0) {
153
176
  const totalCols = countGridColumns(container);
154
- if (totalCols > 0) {
177
+ const preferredCols = totalCols > 0 ? resolvePreferredColumns(container, preferred, totalCols) : 0;
178
+ if (totalCols > 0 && preferredCols > 0) {
155
179
  let col = 0;
156
180
  for (const child of children) {
157
181
  if (child.hasAttribute("data-column-span-auto")) {
182
+ if (!child.hasAttribute(ORIGINAL_COL_ATTR)) {
183
+ child.setAttribute(ORIGINAL_COL_ATTR, child.style.gridColumn);
184
+ }
158
185
  const remaining = totalCols - col;
159
- const span = Math.max(1, Math.min(preferred, remaining));
186
+ const span = Math.max(1, Math.min(preferredCols, remaining));
160
187
  child.style.gridColumn = `span ${span}`;
161
188
  col = (col + span) % totalCols;
162
189
  } else {
@@ -165,6 +192,10 @@ function applyMasonryLayout(container, rawChildren, options = {}) {
165
192
  }
166
193
  }
167
194
  }
195
+ } else {
196
+ for (const child of children) {
197
+ restoreOriginalColumn(child);
198
+ }
168
199
  }
169
200
  const heights = children.map((child) => child.offsetHeight);
170
201
  for (let i3 = 0; i3 < children.length; i3++) {
@@ -195,17 +226,22 @@ function clearMasonryLayout(container, rawChildren) {
195
226
  container.style.gridAutoRows = "";
196
227
  container.style.gridAutoFlow = "";
197
228
  container.style.rowGap = "";
229
+ container.style.overflowAnchor = "";
198
230
  for (const child of rawChildren) {
199
231
  if (child.nodeType !== 1) continue;
200
232
  child.style.gridRow = "";
201
233
  child.style.gridRowStart = "";
202
234
  child.style.gridRowEnd = "";
203
235
  child.style.alignSelf = "";
204
- if (child.hasAttribute("data-column-span-auto")) {
205
- child.style.gridColumn = "";
206
- }
236
+ child.style.overflowAnchor = "";
237
+ restoreOriginalColumn(child);
207
238
  }
208
239
  }
240
+ function restoreOriginalColumn(el) {
241
+ if (!el.hasAttribute(ORIGINAL_COL_ATTR)) return;
242
+ el.style.gridColumn = el.getAttribute(ORIGINAL_COL_ATTR) ?? "";
243
+ el.removeAttribute(ORIGINAL_COL_ATTR);
244
+ }
209
245
 
210
246
  // src/web-components/layout/bds-grid.ts
211
247
  var BdsGrid = class extends i2 {
@@ -264,7 +300,7 @@ var BdsGrid = class extends i2 {
264
300
  isMasonry: { type: Boolean, attribute: "is-masonry", reflect: true },
265
301
  autoSpanMedia: { type: Boolean, attribute: "auto-span-media", reflect: true },
266
302
  masonryPreferredColumnSpan: {
267
- type: Number,
303
+ type: String,
268
304
  attribute: "masonry-preferred-column-span",
269
305
  reflect: true
270
306
  }
@@ -321,11 +357,18 @@ var BdsGrid = class extends i2 {
321
357
  const container = this._gridEl();
322
358
  if (container) {
323
359
  applyMasonryLayout(container, this._items(), {
324
- preferredColumnSpan: this.masonryPreferredColumnSpan
360
+ preferredColumnSpan: this._resolvePreferredColumnSpan()
325
361
  });
326
362
  }
327
363
  });
328
364
  }
365
+ _resolvePreferredColumnSpan() {
366
+ const raw = this.masonryPreferredColumnSpan;
367
+ if (raw === void 0 || raw === null || raw === "") return void 0;
368
+ const n = Number(raw);
369
+ if (Number.isFinite(n) && String(n) === raw) return n;
370
+ return raw;
371
+ }
329
372
  _teardownMasonry() {
330
373
  cancelAnimationFrame(this._frame);
331
374
  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.1",
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,46 @@ 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
+
121
+ it('sets overflow-anchor: none on each child so the browser picks an anchor outside the grid', () => {
122
+ // `overflow-anchor` doesn't inherit — setting it only on the container leaves
123
+ // children anchor-eligible, so a child above the viewport being rewritten still
124
+ // drags the scroll position. Excluding each child forces the browser to anchor
125
+ // on content outside the grid.
126
+ const container = makeContainer(10);
127
+ const a = makeItem(100);
128
+ const b = makeItem(200);
129
+ container.append(a, b);
130
+ applyMasonryLayout(container, [a, b]);
131
+ expect(a.style.overflowAnchor).toBe('none');
132
+ expect(b.style.overflowAnchor).toBe('none');
133
+ });
134
+
135
+ it('clearMasonryLayout restores overflow-anchor on children', () => {
136
+ const container = makeContainer(10);
137
+ const a = makeItem(100);
138
+ container.append(a);
139
+ applyMasonryLayout(container, [a]);
140
+ clearMasonryLayout(container, [a]);
141
+ expect(a.style.overflowAnchor).toBe('');
142
+ });
143
+
104
144
  it('assigns grid-row-end: span ceil(height + targetGap) to each item', () => {
105
145
  const container = makeContainer(10);
106
146
  const a = makeItem(100);
@@ -179,12 +219,13 @@ describe('applyMasonryLayout', () => {
179
219
  }
180
220
 
181
221
  it('assigns the preferred span to data-column-span-auto items that fit', () => {
222
+ // 'half' in a 4-col grid → 2 cols (via fraction fallback; jsdom has no foundation CSS).
182
223
  const container = makeContainer(10);
183
224
  container.style.gridTemplateColumns = '100px 100px 100px 100px';
184
225
  const a = makeAutoItem(50);
185
226
  const b = makeAutoItem(50);
186
227
  container.append(a, b);
187
- applyMasonryLayout(container, [a, b], { preferredColumnSpan: 2 });
228
+ applyMasonryLayout(container, [a, b], { preferredColumnSpan: 'half' });
188
229
  expect(a.style.gridColumn).toBe('span 2');
189
230
  expect(b.style.gridColumn).toBe('span 2');
190
231
  });
@@ -195,7 +236,8 @@ describe('applyMasonryLayout', () => {
195
236
  const a = makeAutoItem(50);
196
237
  const b = makeAutoItem(50);
197
238
  container.append(a, b);
198
- applyMasonryLayout(container, [a, b], { preferredColumnSpan: 2 });
239
+ // 'two-thirds' in a 3-col grid 2 cols.
240
+ applyMasonryLayout(container, [a, b], { preferredColumnSpan: 'two-thirds' });
199
241
  expect(a.style.gridColumn).toBe('span 2');
200
242
  // Only 1 col remains after `a` — `b` gets demoted.
201
243
  expect(b.style.gridColumn).toBe('span 1');
@@ -208,7 +250,7 @@ describe('applyMasonryLayout', () => {
208
250
  explicit.style.gridColumn = 'span 3';
209
251
  const auto = makeAutoItem(50);
210
252
  container.append(explicit, auto);
211
- applyMasonryLayout(container, [explicit, auto], { preferredColumnSpan: 2 });
253
+ applyMasonryLayout(container, [explicit, auto], { preferredColumnSpan: 'half' });
212
254
  expect(explicit.style.gridColumn).toBe('span 3');
213
255
  // 3 cols consumed, 1 remains → auto demoted.
214
256
  expect(auto.style.gridColumn).toBe('span 1');
@@ -223,16 +265,53 @@ describe('applyMasonryLayout', () => {
223
265
  expect(a.style.gridColumn).toBe('');
224
266
  });
225
267
 
226
- it('clearMasonryLayout resets gridColumn on data-column-span-auto items only', () => {
268
+ it('resolves named spans through foundation CSS vars when present', () => {
269
+ const container = makeContainer(10);
270
+ container.style.gridTemplateColumns = '100px 100px 100px 100px 100px 100px 100px 100px';
271
+ // Override the foundation var to a non-fractional value to prove we read it.
272
+ container.style.setProperty('--bds-grid_columns-50', '5');
273
+ const a = makeAutoItem(50);
274
+ container.append(a);
275
+ applyMasonryLayout(container, [a], { preferredColumnSpan: 'half' });
276
+ expect(a.style.gridColumn).toBe('span 5');
277
+ });
278
+
279
+ it('accepts numeric preferredColumnSpan as a literal column count', () => {
280
+ const container = makeContainer(10);
281
+ container.style.gridTemplateColumns = '100px 100px 100px 100px';
282
+ const a = makeAutoItem(50);
283
+ const b = makeAutoItem(50);
284
+ container.append(a, b);
285
+ applyMasonryLayout(container, [a, b], { preferredColumnSpan: 1 });
286
+ expect(a.style.gridColumn).toBe('span 1');
287
+ expect(b.style.gridColumn).toBe('span 1');
288
+ });
289
+
290
+ it('restores the original gridColumn when preferredColumnSpan is removed', () => {
291
+ // Simulates a Storybook control going preferred='half' → undefined.
292
+ // Without restore, items would stay pinned at `span 2`.
293
+ const container = makeContainer(10);
294
+ container.style.gridTemplateColumns = '100px 100px 100px 100px';
295
+ const a = makeAutoItem(50);
296
+ a.style.gridColumn = 'var(--bds-grid_span-100)';
297
+ container.append(a);
298
+ applyMasonryLayout(container, [a], { preferredColumnSpan: 'half' });
299
+ expect(a.style.gridColumn).toBe('span 2');
300
+ applyMasonryLayout(container, [a]); // preferredColumnSpan dropped
301
+ expect(a.style.gridColumn).toBe('var(--bds-grid_span-100)');
302
+ });
303
+
304
+ it('clearMasonryLayout restores gridColumn on data-column-span-auto items', () => {
227
305
  const container = makeContainer(10);
228
306
  container.style.gridTemplateColumns = '100px 100px 100px 100px';
229
307
  const auto = makeAutoItem(50);
308
+ auto.style.gridColumn = 'var(--bds-grid_span-100)';
230
309
  const explicit = makeItem(50);
231
310
  explicit.style.gridColumn = 'span 2';
232
311
  container.append(auto, explicit);
233
- applyMasonryLayout(container, [auto, explicit], { preferredColumnSpan: 2 });
312
+ applyMasonryLayout(container, [auto, explicit], { preferredColumnSpan: 'half' });
234
313
  clearMasonryLayout(container, [auto, explicit]);
235
- expect(auto.style.gridColumn).toBe('');
314
+ expect(auto.style.gridColumn).toBe('var(--bds-grid_span-100)');
236
315
  expect(explicit.style.gridColumn).toBe('span 2');
237
316
  });
238
317
  });
@@ -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