@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.
- package/dist/client.cjs +139 -56
- package/dist/client.css +537 -537
- package/dist/client.d.cts +30 -18
- package/dist/client.d.ts +30 -18
- package/dist/client.js +139 -56
- package/dist/index.cjs +139 -56
- package/dist/index.css +537 -537
- package/dist/index.d.cts +30 -18
- package/dist/index.d.ts +30 -18
- package/dist/index.js +139 -56
- package/dist/web-components/index.d.ts +13 -0
- package/dist/web-components/index.js +100 -3
- package/package.json +1 -1
- package/src/components/layout/Grid/Grid.mdx +36 -0
- package/src/components/layout/Grid/Grid.spec.tsx +137 -0
- package/src/components/layout/Grid/Grid.stories.tsx +52 -0
- package/src/components/layout/Grid/Grid.tsx +15 -1
- package/src/components/layout/Grid/GridItem.tsx +6 -0
- package/src/components/layout/Grid/masonry.ts +156 -4
- package/src/web-components/layout/BdsGrid.mdx +36 -0
- package/src/web-components/layout/BdsGrid.stories.tsx +48 -1
- package/src/web-components/layout/bds-grid-item.spec.ts +14 -0
- package/src/web-components/layout/bds-grid-item.ts +7 -0
- package/src/web-components/layout/bds-grid.spec.ts +8 -0
- package/src/web-components/layout/bds-grid.ts +28 -1
|
@@ -55,6 +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 — 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.
|
|
58
64
|
*
|
|
59
65
|
* Slots:
|
|
60
66
|
* (default) — grid content; typically `<bds-grid-item>` children
|
|
@@ -85,12 +91,18 @@ declare class BdsGrid extends LitElement {
|
|
|
85
91
|
attribute: string;
|
|
86
92
|
reflect: boolean;
|
|
87
93
|
};
|
|
94
|
+
masonryPreferredColumnSpan: {
|
|
95
|
+
type: StringConstructor;
|
|
96
|
+
attribute: string;
|
|
97
|
+
reflect: boolean;
|
|
98
|
+
};
|
|
88
99
|
};
|
|
89
100
|
variant: GridVariant;
|
|
90
101
|
columns: number | undefined;
|
|
91
102
|
isCentered: boolean;
|
|
92
103
|
isMasonry: boolean;
|
|
93
104
|
autoSpanMedia: boolean;
|
|
105
|
+
masonryPreferredColumnSpan: string | undefined;
|
|
94
106
|
private _ro?;
|
|
95
107
|
private _mo?;
|
|
96
108
|
private _frame;
|
|
@@ -101,6 +113,7 @@ declare class BdsGrid extends LitElement {
|
|
|
101
113
|
private _items;
|
|
102
114
|
private _setupMasonry;
|
|
103
115
|
private _scheduleMasonry;
|
|
116
|
+
private _resolvePreferredColumnSpan;
|
|
104
117
|
private _teardownMasonry;
|
|
105
118
|
render(): lit.TemplateResult<1>;
|
|
106
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
|
}
|
|
136
|
-
|
|
159
|
+
var ORIGINAL_COL_ATTR = "data-bds-masonry-original-column";
|
|
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,24 +173,76 @@ function applyMasonryLayout(container, rawChildren) {
|
|
|
148
173
|
child.style.gridRowEnd = "";
|
|
149
174
|
child.style.alignSelf = "start";
|
|
150
175
|
}
|
|
176
|
+
const preferred = options.preferredColumnSpan;
|
|
177
|
+
if (preferred !== void 0) {
|
|
178
|
+
const totalCols = countGridColumns(container);
|
|
179
|
+
const preferredCols = totalCols > 0 ? resolvePreferredColumns(container, preferred, totalCols) : 0;
|
|
180
|
+
if (totalCols > 0 && preferredCols > 0) {
|
|
181
|
+
let col = 0;
|
|
182
|
+
for (const child of children) {
|
|
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
|
+
}
|
|
187
|
+
const remaining = totalCols - col;
|
|
188
|
+
const span = Math.max(1, Math.min(preferredCols, remaining));
|
|
189
|
+
child.style.gridColumn = `span ${span}`;
|
|
190
|
+
col = (col + span) % totalCols;
|
|
191
|
+
} else {
|
|
192
|
+
const consumed = Math.min(totalCols, readExplicitSpan(child, totalCols));
|
|
193
|
+
col = (col + consumed) % totalCols;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
for (const child of children) {
|
|
199
|
+
restoreOriginalColumn(child);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
151
202
|
const heights = children.map((child) => child.offsetHeight);
|
|
152
203
|
for (let i3 = 0; i3 < children.length; i3++) {
|
|
153
204
|
const span = Math.max(1, Math.ceil(heights[i3] + targetGap));
|
|
154
205
|
children[i3].style.gridRowEnd = `span ${span}`;
|
|
155
206
|
}
|
|
156
207
|
}
|
|
208
|
+
function countGridColumns(container) {
|
|
209
|
+
const value = window.getComputedStyle(container).gridTemplateColumns.trim();
|
|
210
|
+
if (!value || value === "none") return 0;
|
|
211
|
+
return value.split(/\s+/).length;
|
|
212
|
+
}
|
|
213
|
+
function readExplicitSpan(el, totalCols) {
|
|
214
|
+
const inline = el.style.gridColumn || el.style.gridColumnEnd;
|
|
215
|
+
const fromInline = parseSpan(inline, totalCols);
|
|
216
|
+
if (fromInline !== null) return fromInline;
|
|
217
|
+
const computed = window.getComputedStyle(el).gridColumn;
|
|
218
|
+
return parseSpan(computed, totalCols) ?? 1;
|
|
219
|
+
}
|
|
220
|
+
function parseSpan(value, totalCols) {
|
|
221
|
+
if (!value) return null;
|
|
222
|
+
const match = value.match(/^\s*span\s+(\d+)/);
|
|
223
|
+
if (match) return Math.max(1, Math.min(totalCols, parseInt(match[1], 10)));
|
|
224
|
+
if (/\/\s*-1\b/.test(value)) return totalCols;
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
157
227
|
function clearMasonryLayout(container, rawChildren) {
|
|
158
228
|
container.style.gridAutoRows = "";
|
|
159
229
|
container.style.gridAutoFlow = "";
|
|
160
230
|
container.style.rowGap = "";
|
|
231
|
+
container.style.overflowAnchor = "";
|
|
161
232
|
for (const child of rawChildren) {
|
|
162
233
|
if (child.nodeType !== 1) continue;
|
|
163
234
|
child.style.gridRow = "";
|
|
164
235
|
child.style.gridRowStart = "";
|
|
165
236
|
child.style.gridRowEnd = "";
|
|
166
237
|
child.style.alignSelf = "";
|
|
238
|
+
restoreOriginalColumn(child);
|
|
167
239
|
}
|
|
168
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
|
+
}
|
|
169
246
|
|
|
170
247
|
// src/web-components/layout/bds-grid.ts
|
|
171
248
|
var BdsGrid = class extends i2 {
|
|
@@ -222,7 +299,12 @@ var BdsGrid = class extends i2 {
|
|
|
222
299
|
columns: { type: Number, reflect: true },
|
|
223
300
|
isCentered: { type: Boolean, attribute: "is-centered", reflect: true },
|
|
224
301
|
isMasonry: { type: Boolean, attribute: "is-masonry", reflect: true },
|
|
225
|
-
autoSpanMedia: { type: Boolean, attribute: "auto-span-media", reflect: true }
|
|
302
|
+
autoSpanMedia: { type: Boolean, attribute: "auto-span-media", reflect: true },
|
|
303
|
+
masonryPreferredColumnSpan: {
|
|
304
|
+
type: String,
|
|
305
|
+
attribute: "masonry-preferred-column-span",
|
|
306
|
+
reflect: true
|
|
307
|
+
}
|
|
226
308
|
};
|
|
227
309
|
_ro;
|
|
228
310
|
_mo;
|
|
@@ -234,6 +316,7 @@ var BdsGrid = class extends i2 {
|
|
|
234
316
|
this.isCentered = false;
|
|
235
317
|
this.isMasonry = false;
|
|
236
318
|
this.autoSpanMedia = false;
|
|
319
|
+
this.masonryPreferredColumnSpan = void 0;
|
|
237
320
|
}
|
|
238
321
|
disconnectedCallback() {
|
|
239
322
|
super.disconnectedCallback();
|
|
@@ -273,9 +356,20 @@ var BdsGrid = class extends i2 {
|
|
|
273
356
|
cancelAnimationFrame(this._frame);
|
|
274
357
|
this._frame = requestAnimationFrame(() => {
|
|
275
358
|
const container = this._gridEl();
|
|
276
|
-
if (container)
|
|
359
|
+
if (container) {
|
|
360
|
+
applyMasonryLayout(container, this._items(), {
|
|
361
|
+
preferredColumnSpan: this._resolvePreferredColumnSpan()
|
|
362
|
+
});
|
|
363
|
+
}
|
|
277
364
|
});
|
|
278
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
|
+
}
|
|
279
373
|
_teardownMasonry() {
|
|
280
374
|
cancelAnimationFrame(this._frame);
|
|
281
375
|
this._ro?.disconnect();
|
|
@@ -430,6 +524,9 @@ var BdsGridItem = class extends i2 {
|
|
|
430
524
|
const gridColumn = hasExplicitRange ? `${this.startColumn ?? "auto"} / ${this.endColumn ?? "auto"}` : resolveSpan(resolved);
|
|
431
525
|
this.style.gridColumn = gridColumn;
|
|
432
526
|
this.style.gridRow = this.rowSpan !== void 0 ? `span ${this.rowSpan}` : "";
|
|
527
|
+
const isColumnSpanAuto = (this.columnSpan === void 0 || this.columnSpan === null) && !hasExplicitRange;
|
|
528
|
+
if (isColumnSpanAuto) this.setAttribute("data-column-span-auto", "");
|
|
529
|
+
else this.removeAttribute("data-column-span-auto");
|
|
433
530
|
}
|
|
434
531
|
render() {
|
|
435
532
|
return T`<slot></slot>`;
|
package/package.json
CHANGED
|
@@ -201,6 +201,42 @@ Intrinsic dimensions are read from `img.naturalWidth/naturalHeight` and `video.v
|
|
|
201
201
|
- Event subscriptions are `{ once: true }` so there's no cleanup ceremony per item.
|
|
202
202
|
- Re-resolution cost is bounded by the number of items whose media changes, not the total item count.
|
|
203
203
|
|
|
204
|
+
## Masonry preferred column span
|
|
205
|
+
|
|
206
|
+
For a uniform "cards mostly 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
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
<Grid isMasonry masonryPreferredColumnSpan="half">
|
|
210
|
+
{cards.map((c) => (
|
|
211
|
+
<GridItem key={c.id}>…</GridItem>
|
|
212
|
+
))}
|
|
213
|
+
</Grid>
|
|
214
|
+
```
|
|
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
|
+
|
|
218
|
+
Rules:
|
|
219
|
+
|
|
220
|
+
- Requires `isMasonry`. No-op on non-masonry grids.
|
|
221
|
+
- Items with an explicit `columnSpan` (named or numeric) keep their span. Their columns still consume the demotion budget, so a `columnSpan="three-quarters"` followed by an auto item in a 4-column grid correctly demotes the auto item to 1.
|
|
222
|
+
- Items with `startColumn` / `endColumn` are unaffected.
|
|
223
|
+
- 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.
|
|
224
|
+
|
|
225
|
+
### When NOT to use
|
|
226
|
+
|
|
227
|
+
- When span should reflect **content importance** or media shape — set `columnSpan` per item instead. `masonryPreferredColumnSpan` enforces a uniform visual rhythm and is the wrong tool for heterogeneous cards.
|
|
228
|
+
- Alongside `autoSpanMedia` on the same grid. Both features target items without an explicit `columnSpan`, and `masonryPreferredColumnSpan` wins — `autoSpanMedia` silently does nothing. Pick one strategy per grid.
|
|
229
|
+
- In a non-masonry grid. The feature depends on the polyfill's packing pass and is a no-op otherwise — use a plain `columnSpan` default instead.
|
|
230
|
+
|
|
231
|
+
### Performance
|
|
232
|
+
|
|
233
|
+
- 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.
|
|
234
|
+
- Shares the polyfill's existing `ResizeObserver` + `MutationObserver` + `requestAnimationFrame` coalescing — no new observers, no separate render.
|
|
235
|
+
|
|
236
|
+
### SSR / hydration
|
|
237
|
+
|
|
238
|
+
Column detection runs inside the masonry polyfill's `useLayoutEffect`, so it only executes on the client after mount. The initial server render emits each auto item at its default `full` span until hydration resolves the first polyfill pass — which matches the existing masonry SSR behavior.
|
|
239
|
+
|
|
204
240
|
## Migration from `@greenchoice/design-system`
|
|
205
241
|
|
|
206
242
|
| Greenchoice | BoostDev equivalent |
|
|
@@ -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);
|
|
@@ -170,6 +187,111 @@ describe('applyMasonryLayout', () => {
|
|
|
170
187
|
expect(item.style.gridRow).toBe('');
|
|
171
188
|
expect(item.style.alignSelf).toBe('');
|
|
172
189
|
});
|
|
190
|
+
|
|
191
|
+
describe('preferredColumnSpan', () => {
|
|
192
|
+
function makeAutoItem(height: number) {
|
|
193
|
+
const el = makeItem(height);
|
|
194
|
+
el.setAttribute('data-column-span-auto', '');
|
|
195
|
+
return el;
|
|
196
|
+
}
|
|
197
|
+
|
|
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).
|
|
200
|
+
const container = makeContainer(10);
|
|
201
|
+
container.style.gridTemplateColumns = '100px 100px 100px 100px';
|
|
202
|
+
const a = makeAutoItem(50);
|
|
203
|
+
const b = makeAutoItem(50);
|
|
204
|
+
container.append(a, b);
|
|
205
|
+
applyMasonryLayout(container, [a, b], { preferredColumnSpan: 'half' });
|
|
206
|
+
expect(a.style.gridColumn).toBe('span 2');
|
|
207
|
+
expect(b.style.gridColumn).toBe('span 2');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('demotes the span at row end instead of wrapping with a gap', () => {
|
|
211
|
+
const container = makeContainer(10);
|
|
212
|
+
container.style.gridTemplateColumns = '100px 100px 100px';
|
|
213
|
+
const a = makeAutoItem(50);
|
|
214
|
+
const b = makeAutoItem(50);
|
|
215
|
+
container.append(a, b);
|
|
216
|
+
// 'two-thirds' in a 3-col grid → 2 cols.
|
|
217
|
+
applyMasonryLayout(container, [a, b], { preferredColumnSpan: 'two-thirds' });
|
|
218
|
+
expect(a.style.gridColumn).toBe('span 2');
|
|
219
|
+
// Only 1 col remains after `a` — `b` gets demoted.
|
|
220
|
+
expect(b.style.gridColumn).toBe('span 1');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('leaves items with an explicit span untouched and consumes their columns for the demotion budget', () => {
|
|
224
|
+
const container = makeContainer(10);
|
|
225
|
+
container.style.gridTemplateColumns = '100px 100px 100px 100px';
|
|
226
|
+
const explicit = makeItem(50);
|
|
227
|
+
explicit.style.gridColumn = 'span 3';
|
|
228
|
+
const auto = makeAutoItem(50);
|
|
229
|
+
container.append(explicit, auto);
|
|
230
|
+
applyMasonryLayout(container, [explicit, auto], { preferredColumnSpan: 'half' });
|
|
231
|
+
expect(explicit.style.gridColumn).toBe('span 3');
|
|
232
|
+
// 3 cols consumed, 1 remains → auto demoted.
|
|
233
|
+
expect(auto.style.gridColumn).toBe('span 1');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('is a no-op without preferredColumnSpan', () => {
|
|
237
|
+
const container = makeContainer(10);
|
|
238
|
+
container.style.gridTemplateColumns = '100px 100px';
|
|
239
|
+
const a = makeAutoItem(50);
|
|
240
|
+
container.append(a);
|
|
241
|
+
applyMasonryLayout(container, [a]);
|
|
242
|
+
expect(a.style.gridColumn).toBe('');
|
|
243
|
+
});
|
|
244
|
+
|
|
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', () => {
|
|
282
|
+
const container = makeContainer(10);
|
|
283
|
+
container.style.gridTemplateColumns = '100px 100px 100px 100px';
|
|
284
|
+
const auto = makeAutoItem(50);
|
|
285
|
+
auto.style.gridColumn = 'var(--bds-grid_span-100)';
|
|
286
|
+
const explicit = makeItem(50);
|
|
287
|
+
explicit.style.gridColumn = 'span 2';
|
|
288
|
+
container.append(auto, explicit);
|
|
289
|
+
applyMasonryLayout(container, [auto, explicit], { preferredColumnSpan: 'half' });
|
|
290
|
+
clearMasonryLayout(container, [auto, explicit]);
|
|
291
|
+
expect(auto.style.gridColumn).toBe('var(--bds-grid_span-100)');
|
|
292
|
+
expect(explicit.style.gridColumn).toBe('span 2');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
173
295
|
});
|
|
174
296
|
|
|
175
297
|
describe('GridItem', () => {
|
|
@@ -220,6 +342,21 @@ describe('GridItem', () => {
|
|
|
220
342
|
const { container } = render(<GridItem as="article">x</GridItem>);
|
|
221
343
|
expect(container.firstChild?.nodeName).toBe('ARTICLE');
|
|
222
344
|
});
|
|
345
|
+
|
|
346
|
+
it('marks data-column-span-auto when columnSpan is not provided', () => {
|
|
347
|
+
const { container } = render(<GridItem>x</GridItem>);
|
|
348
|
+
expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('does not mark data-column-span-auto when columnSpan is set', () => {
|
|
352
|
+
const { container } = render(<GridItem columnSpan="half">x</GridItem>);
|
|
353
|
+
expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('does not mark data-column-span-auto when startColumn or endColumn is set', () => {
|
|
357
|
+
const { container } = render(<GridItem startColumn={2} endColumn={4}>x</GridItem>);
|
|
358
|
+
expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(false);
|
|
359
|
+
});
|
|
223
360
|
});
|
|
224
361
|
|
|
225
362
|
describe('aspectToColumnSpan', () => {
|
|
@@ -10,6 +10,10 @@ const meta = {
|
|
|
10
10
|
columns: { control: 'number' },
|
|
11
11
|
isCentered: { control: 'boolean' },
|
|
12
12
|
isMasonry: { control: 'boolean' },
|
|
13
|
+
masonryPreferredColumnSpan: {
|
|
14
|
+
control: 'select',
|
|
15
|
+
options: ['full', 'three-quarters', 'two-thirds', 'half', 'one-third', 'one-quarter'],
|
|
16
|
+
},
|
|
13
17
|
},
|
|
14
18
|
} satisfies Meta<typeof Grid>;
|
|
15
19
|
|
|
@@ -140,6 +144,54 @@ export const MasonryWithSpans: Story = {
|
|
|
140
144
|
),
|
|
141
145
|
};
|
|
142
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
|
|
155
|
+
* fill just the remaining columns, so no trailing gap appears.
|
|
156
|
+
*
|
|
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.
|
|
160
|
+
*/
|
|
161
|
+
export const MasonryPreferredColumnSpan: Story = {
|
|
162
|
+
args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
|
|
163
|
+
render: (args) => (
|
|
164
|
+
<Grid {...args}>
|
|
165
|
+
{Array.from({ length: 7 }).map((_, i) => (
|
|
166
|
+
<GridItem key={i} style={masonryCellStyle(i)}>
|
|
167
|
+
Card {i + 1}
|
|
168
|
+
</GridItem>
|
|
169
|
+
))}
|
|
170
|
+
</Grid>
|
|
171
|
+
),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Mixing an explicit `columnSpan="three-quarters"` hero with auto items.
|
|
176
|
+
* The hero keeps its span; its columns still consume the demotion budget,
|
|
177
|
+
* so the auto items fill the remaining row consistently.
|
|
178
|
+
*/
|
|
179
|
+
export const MasonryPreferredColumnSpanWithHero: Story = {
|
|
180
|
+
args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
|
|
181
|
+
render: (args) => (
|
|
182
|
+
<Grid {...args}>
|
|
183
|
+
<GridItem columnSpan="three-quarters" style={masonryCellStyle(0)}>
|
|
184
|
+
Hero (three-quarters)
|
|
185
|
+
</GridItem>
|
|
186
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
187
|
+
<GridItem key={i} style={masonryCellStyle(i + 1)}>
|
|
188
|
+
Card {i + 1}
|
|
189
|
+
</GridItem>
|
|
190
|
+
))}
|
|
191
|
+
</Grid>
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
|
|
143
195
|
export const ComplexLayout: Story = {
|
|
144
196
|
args: {},
|
|
145
197
|
render: () => (
|
|
@@ -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
|
|
|
@@ -45,6 +46,18 @@ type GridOwnProps = WithClassName & {
|
|
|
45
46
|
* are unaffected.
|
|
46
47
|
*/
|
|
47
48
|
autoSpanMedia?: boolean;
|
|
49
|
+
/**
|
|
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.
|
|
59
|
+
*/
|
|
60
|
+
masonryPreferredColumnSpan?: Exclude<GridItemSpanValue, 'auto'>;
|
|
48
61
|
as?: ElementType;
|
|
49
62
|
};
|
|
50
63
|
|
|
@@ -59,12 +72,13 @@ export function Grid<C extends ElementType = 'div'>({
|
|
|
59
72
|
isCentered = false,
|
|
60
73
|
isMasonry = false,
|
|
61
74
|
autoSpanMedia = false,
|
|
75
|
+
masonryPreferredColumnSpan,
|
|
62
76
|
as,
|
|
63
77
|
style,
|
|
64
78
|
...rest
|
|
65
79
|
}: GridProps<C>) {
|
|
66
80
|
const ref = useRef<HTMLElement>(null);
|
|
67
|
-
useMasonry(ref, isMasonry);
|
|
81
|
+
useMasonry(ref, isMasonry, { preferredColumnSpan: masonryPreferredColumnSpan });
|
|
68
82
|
|
|
69
83
|
const classNames = cn(
|
|
70
84
|
css.grid,
|
|
@@ -137,10 +137,16 @@ export function GridItem<C extends ElementType = 'div'>({
|
|
|
137
137
|
|
|
138
138
|
const Component = as ?? 'div';
|
|
139
139
|
|
|
140
|
+
// Marker the masonry polyfill reads to know this item's span wasn't set by the
|
|
141
|
+
// consumer — it may override the span with `masonryPreferredColumnSpan` on Grid.
|
|
142
|
+
// Items with an explicit range or explicit `columnSpan` are never eligible.
|
|
143
|
+
const isColumnSpanAuto = columnSpan === undefined && !hasExplicitRange;
|
|
144
|
+
|
|
140
145
|
return (
|
|
141
146
|
<Component
|
|
142
147
|
ref={ref as never}
|
|
143
148
|
{...rest}
|
|
149
|
+
data-column-span-auto={isColumnSpanAuto ? '' : undefined}
|
|
144
150
|
className={cn(css.item, className)}
|
|
145
151
|
style={composedStyle}
|
|
146
152
|
>
|