@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.
- package/dist/client.cjs +95 -58
- package/dist/client.css +537 -537
- package/dist/client.d.cts +30 -26
- package/dist/client.d.ts +30 -26
- package/dist/client.js +95 -58
- package/dist/index.cjs +95 -58
- package/dist/index.css +537 -537
- package/dist/index.d.cts +30 -26
- package/dist/index.d.ts +30 -26
- package/dist/index.js +95 -58
- package/dist/web-components/index.d.ts +9 -5
- package/dist/web-components/index.js +53 -9
- package/package.json +1 -1
- package/src/components/layout/Grid/Grid.mdx +4 -2
- package/src/components/layout/Grid/Grid.spec.tsx +62 -6
- package/src/components/layout/Grid/Grid.stories.tsx +16 -9
- package/src/components/layout/Grid/Grid.tsx +11 -6
- package/src/components/layout/Grid/masonry.ts +88 -18
- package/src/web-components/layout/BdsGrid.mdx +5 -3
- package/src/web-components/layout/BdsGrid.stories.tsx +16 -10
- package/src/web-components/layout/bds-grid.spec.ts +3 -3
- package/src/web-components/layout/bds-grid.ts +19 -6
|
@@ -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
|
|
59
|
-
* `column-span` attribute try to span this
|
|
60
|
-
*
|
|
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:
|
|
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:
|
|
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
|
|
152
|
-
if (preferred
|
|
176
|
+
const preferred = options.preferredColumnSpan;
|
|
177
|
+
if (preferred !== void 0) {
|
|
153
178
|
const totalCols = countGridColumns(container);
|
|
154
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
@@ -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
|
|
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=
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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('
|
|
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:
|
|
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: {
|
|
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=
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
+
* `masonryPreferredColumnSpan="half"` makes every auto-span item try to span
|
|
149
|
+
* half the grid. Because `"half"` resolves through the foundation's responsive
|
|
150
|
+
* column tokens, the same value yields 2 cols on a 4-col mobile grid, 4 cols
|
|
151
|
+
* on an 8-col tablet grid, and 6 cols on a 12-col desktop grid — the layout
|
|
152
|
+
* adapts without you configuring per-breakpoint values.
|
|
153
|
+
*
|
|
154
|
+
* When an item would overflow the row, the polyfill **demotes** its span to
|
|
148
155
|
* fill just the remaining columns, so no trailing gap appears.
|
|
149
156
|
*
|
|
150
|
-
* Use the Controls to
|
|
151
|
-
* the packing re-run. Resize the viewport
|
|
152
|
-
*
|
|
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:
|
|
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:
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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?:
|
|
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
|
|
42
|
-
* `data-column-span-auto
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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?:
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
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
|
|
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
|
|
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="
|
|
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?:
|
|
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: {
|
|
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="
|
|
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.
|
|
200
207
|
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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:
|
|
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:
|
|
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>
|