@boostdev/design-system-components 2.2.0 → 2.3.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 +111 -60
- package/dist/client.css +537 -537
- package/dist/client.d.cts +9 -1
- package/dist/client.d.ts +9 -1
- package/dist/client.js +111 -60
- package/dist/index.cjs +111 -60
- package/dist/index.css +537 -537
- package/dist/index.d.cts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +111 -60
- package/dist/web-components/index.d.ts +9 -0
- package/dist/web-components/index.js +58 -3
- package/package.json +1 -1
- package/src/components/layout/Grid/Grid.mdx +34 -0
- package/src/components/layout/Grid/Grid.spec.tsx +95 -0
- package/src/components/layout/Grid/Grid.stories.tsx +45 -0
- package/src/components/layout/Grid/Grid.tsx +20 -4
- package/src/components/layout/Grid/GridItem.tsx +8 -2
- package/src/components/layout/Grid/masonry.ts +91 -4
- package/src/web-components/layout/BdsGrid.mdx +34 -0
- package/src/web-components/layout/BdsGrid.stories.tsx +42 -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 +15 -1
|
@@ -55,6 +55,9 @@ 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
61
|
*
|
|
59
62
|
* Slots:
|
|
60
63
|
* (default) — grid content; typically `<bds-grid-item>` children
|
|
@@ -85,12 +88,18 @@ declare class BdsGrid extends LitElement {
|
|
|
85
88
|
attribute: string;
|
|
86
89
|
reflect: boolean;
|
|
87
90
|
};
|
|
91
|
+
masonryPreferredColumnSpan: {
|
|
92
|
+
type: NumberConstructor;
|
|
93
|
+
attribute: string;
|
|
94
|
+
reflect: boolean;
|
|
95
|
+
};
|
|
88
96
|
};
|
|
89
97
|
variant: GridVariant;
|
|
90
98
|
columns: number | undefined;
|
|
91
99
|
isCentered: boolean;
|
|
92
100
|
isMasonry: boolean;
|
|
93
101
|
autoSpanMedia: boolean;
|
|
102
|
+
masonryPreferredColumnSpan: number | undefined;
|
|
94
103
|
private _ro?;
|
|
95
104
|
private _mo?;
|
|
96
105
|
private _frame;
|
|
@@ -133,7 +133,7 @@ function supportsNativeMasonry() {
|
|
|
133
133
|
if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return false;
|
|
134
134
|
return CSS.supports("display", "grid-lanes") || CSS.supports("grid-template-rows", "masonry");
|
|
135
135
|
}
|
|
136
|
-
function applyMasonryLayout(container, rawChildren) {
|
|
136
|
+
function applyMasonryLayout(container, rawChildren, options = {}) {
|
|
137
137
|
const cs = window.getComputedStyle(container);
|
|
138
138
|
const targetGap = parseFloat(cs.columnGap) || 0;
|
|
139
139
|
container.style.gridAutoRows = `${ROW_UNIT_PX}px`;
|
|
@@ -146,6 +146,25 @@ function applyMasonryLayout(container, rawChildren) {
|
|
|
146
146
|
child.style.gridRow = "";
|
|
147
147
|
child.style.gridRowStart = "";
|
|
148
148
|
child.style.gridRowEnd = "";
|
|
149
|
+
child.style.alignSelf = "start";
|
|
150
|
+
}
|
|
151
|
+
const preferred = options.preferredColumnSpan ?? 0;
|
|
152
|
+
if (preferred >= 2) {
|
|
153
|
+
const totalCols = countGridColumns(container);
|
|
154
|
+
if (totalCols > 0) {
|
|
155
|
+
let col = 0;
|
|
156
|
+
for (const child of children) {
|
|
157
|
+
if (child.hasAttribute("data-column-span-auto")) {
|
|
158
|
+
const remaining = totalCols - col;
|
|
159
|
+
const span = Math.max(1, Math.min(preferred, remaining));
|
|
160
|
+
child.style.gridColumn = `span ${span}`;
|
|
161
|
+
col = (col + span) % totalCols;
|
|
162
|
+
} else {
|
|
163
|
+
const consumed = Math.min(totalCols, readExplicitSpan(child, totalCols));
|
|
164
|
+
col = (col + consumed) % totalCols;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
149
168
|
}
|
|
150
169
|
const heights = children.map((child) => child.offsetHeight);
|
|
151
170
|
for (let i3 = 0; i3 < children.length; i3++) {
|
|
@@ -153,6 +172,25 @@ function applyMasonryLayout(container, rawChildren) {
|
|
|
153
172
|
children[i3].style.gridRowEnd = `span ${span}`;
|
|
154
173
|
}
|
|
155
174
|
}
|
|
175
|
+
function countGridColumns(container) {
|
|
176
|
+
const value = window.getComputedStyle(container).gridTemplateColumns.trim();
|
|
177
|
+
if (!value || value === "none") return 0;
|
|
178
|
+
return value.split(/\s+/).length;
|
|
179
|
+
}
|
|
180
|
+
function readExplicitSpan(el, totalCols) {
|
|
181
|
+
const inline = el.style.gridColumn || el.style.gridColumnEnd;
|
|
182
|
+
const fromInline = parseSpan(inline, totalCols);
|
|
183
|
+
if (fromInline !== null) return fromInline;
|
|
184
|
+
const computed = window.getComputedStyle(el).gridColumn;
|
|
185
|
+
return parseSpan(computed, totalCols) ?? 1;
|
|
186
|
+
}
|
|
187
|
+
function parseSpan(value, totalCols) {
|
|
188
|
+
if (!value) return null;
|
|
189
|
+
const match = value.match(/^\s*span\s+(\d+)/);
|
|
190
|
+
if (match) return Math.max(1, Math.min(totalCols, parseInt(match[1], 10)));
|
|
191
|
+
if (/\/\s*-1\b/.test(value)) return totalCols;
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
156
194
|
function clearMasonryLayout(container, rawChildren) {
|
|
157
195
|
container.style.gridAutoRows = "";
|
|
158
196
|
container.style.gridAutoFlow = "";
|
|
@@ -162,6 +200,10 @@ function clearMasonryLayout(container, rawChildren) {
|
|
|
162
200
|
child.style.gridRow = "";
|
|
163
201
|
child.style.gridRowStart = "";
|
|
164
202
|
child.style.gridRowEnd = "";
|
|
203
|
+
child.style.alignSelf = "";
|
|
204
|
+
if (child.hasAttribute("data-column-span-auto")) {
|
|
205
|
+
child.style.gridColumn = "";
|
|
206
|
+
}
|
|
165
207
|
}
|
|
166
208
|
}
|
|
167
209
|
|
|
@@ -220,7 +262,12 @@ var BdsGrid = class extends i2 {
|
|
|
220
262
|
columns: { type: Number, reflect: true },
|
|
221
263
|
isCentered: { type: Boolean, attribute: "is-centered", reflect: true },
|
|
222
264
|
isMasonry: { type: Boolean, attribute: "is-masonry", reflect: true },
|
|
223
|
-
autoSpanMedia: { type: Boolean, attribute: "auto-span-media", reflect: true }
|
|
265
|
+
autoSpanMedia: { type: Boolean, attribute: "auto-span-media", reflect: true },
|
|
266
|
+
masonryPreferredColumnSpan: {
|
|
267
|
+
type: Number,
|
|
268
|
+
attribute: "masonry-preferred-column-span",
|
|
269
|
+
reflect: true
|
|
270
|
+
}
|
|
224
271
|
};
|
|
225
272
|
_ro;
|
|
226
273
|
_mo;
|
|
@@ -232,6 +279,7 @@ var BdsGrid = class extends i2 {
|
|
|
232
279
|
this.isCentered = false;
|
|
233
280
|
this.isMasonry = false;
|
|
234
281
|
this.autoSpanMedia = false;
|
|
282
|
+
this.masonryPreferredColumnSpan = void 0;
|
|
235
283
|
}
|
|
236
284
|
disconnectedCallback() {
|
|
237
285
|
super.disconnectedCallback();
|
|
@@ -271,7 +319,11 @@ var BdsGrid = class extends i2 {
|
|
|
271
319
|
cancelAnimationFrame(this._frame);
|
|
272
320
|
this._frame = requestAnimationFrame(() => {
|
|
273
321
|
const container = this._gridEl();
|
|
274
|
-
if (container)
|
|
322
|
+
if (container) {
|
|
323
|
+
applyMasonryLayout(container, this._items(), {
|
|
324
|
+
preferredColumnSpan: this.masonryPreferredColumnSpan
|
|
325
|
+
});
|
|
326
|
+
}
|
|
275
327
|
});
|
|
276
328
|
}
|
|
277
329
|
_teardownMasonry() {
|
|
@@ -428,6 +480,9 @@ var BdsGridItem = class extends i2 {
|
|
|
428
480
|
const gridColumn = hasExplicitRange ? `${this.startColumn ?? "auto"} / ${this.endColumn ?? "auto"}` : resolveSpan(resolved);
|
|
429
481
|
this.style.gridColumn = gridColumn;
|
|
430
482
|
this.style.gridRow = this.rowSpan !== void 0 ? `span ${this.rowSpan}` : "";
|
|
483
|
+
const isColumnSpanAuto = (this.columnSpan === void 0 || this.columnSpan === null) && !hasExplicitRange;
|
|
484
|
+
if (isColumnSpanAuto) this.setAttribute("data-column-span-auto", "");
|
|
485
|
+
else this.removeAttribute("data-column-span-auto");
|
|
431
486
|
}
|
|
432
487
|
render() {
|
|
433
488
|
return T`<slot></slot>`;
|
package/package.json
CHANGED
|
@@ -201,6 +201,40 @@ 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 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.
|
|
207
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
<Grid isMasonry masonryPreferredColumnSpan={2}>
|
|
210
|
+
{cards.map((c) => (
|
|
211
|
+
<GridItem key={c.id}>…</GridItem>
|
|
212
|
+
))}
|
|
213
|
+
</Grid>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Rules:
|
|
217
|
+
|
|
218
|
+
- Requires `isMasonry`. No-op on non-masonry grids.
|
|
219
|
+
- 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.
|
|
220
|
+
- Items with `startColumn` / `endColumn` are unaffected.
|
|
221
|
+
- 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.
|
|
222
|
+
|
|
223
|
+
### When NOT to use
|
|
224
|
+
|
|
225
|
+
- 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.
|
|
226
|
+
- 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.
|
|
227
|
+
- 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.
|
|
228
|
+
|
|
229
|
+
### Performance
|
|
230
|
+
|
|
231
|
+
- 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.
|
|
232
|
+
- Shares the polyfill's existing `ResizeObserver` + `MutationObserver` + `requestAnimationFrame` coalescing — no new observers, no separate render.
|
|
233
|
+
|
|
234
|
+
### SSR / hydration
|
|
235
|
+
|
|
236
|
+
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.
|
|
237
|
+
|
|
204
238
|
## Migration from `@greenchoice/design-system`
|
|
205
239
|
|
|
206
240
|
| Greenchoice | BoostDev equivalent |
|
|
@@ -143,6 +143,19 @@ describe('applyMasonryLayout', () => {
|
|
|
143
143
|
expect(hidden.style.gridRowEnd).toBe('');
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
it('sets align-self: start on children so they shrink-wrap for measurement and do not stretch into the gap', () => {
|
|
147
|
+
// Regression: without this, default `align-self: stretch` grows every child
|
|
148
|
+
// to fill the 1px grid-auto-rows track, making offsetHeight return 1 and
|
|
149
|
+
// collapsing all items to minimum size.
|
|
150
|
+
const container = makeContainer(10);
|
|
151
|
+
const a = makeItem(100);
|
|
152
|
+
const b = makeItem(50);
|
|
153
|
+
container.append(a, b);
|
|
154
|
+
applyMasonryLayout(container, [a, b]);
|
|
155
|
+
expect(a.style.alignSelf).toBe('start');
|
|
156
|
+
expect(b.style.alignSelf).toBe('start');
|
|
157
|
+
});
|
|
158
|
+
|
|
146
159
|
it('clearMasonryLayout undoes container and child styles', () => {
|
|
147
160
|
const container = makeContainer(10);
|
|
148
161
|
const item = makeItem(50);
|
|
@@ -155,6 +168,73 @@ describe('applyMasonryLayout', () => {
|
|
|
155
168
|
expect(item.style.gridRowEnd).toBe('');
|
|
156
169
|
expect(item.style.gridRowStart).toBe('');
|
|
157
170
|
expect(item.style.gridRow).toBe('');
|
|
171
|
+
expect(item.style.alignSelf).toBe('');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('preferredColumnSpan', () => {
|
|
175
|
+
function makeAutoItem(height: number) {
|
|
176
|
+
const el = makeItem(height);
|
|
177
|
+
el.setAttribute('data-column-span-auto', '');
|
|
178
|
+
return el;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
it('assigns the preferred span to data-column-span-auto items that fit', () => {
|
|
182
|
+
const container = makeContainer(10);
|
|
183
|
+
container.style.gridTemplateColumns = '100px 100px 100px 100px';
|
|
184
|
+
const a = makeAutoItem(50);
|
|
185
|
+
const b = makeAutoItem(50);
|
|
186
|
+
container.append(a, b);
|
|
187
|
+
applyMasonryLayout(container, [a, b], { preferredColumnSpan: 2 });
|
|
188
|
+
expect(a.style.gridColumn).toBe('span 2');
|
|
189
|
+
expect(b.style.gridColumn).toBe('span 2');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('demotes the span at row end instead of wrapping with a gap', () => {
|
|
193
|
+
const container = makeContainer(10);
|
|
194
|
+
container.style.gridTemplateColumns = '100px 100px 100px';
|
|
195
|
+
const a = makeAutoItem(50);
|
|
196
|
+
const b = makeAutoItem(50);
|
|
197
|
+
container.append(a, b);
|
|
198
|
+
applyMasonryLayout(container, [a, b], { preferredColumnSpan: 2 });
|
|
199
|
+
expect(a.style.gridColumn).toBe('span 2');
|
|
200
|
+
// Only 1 col remains after `a` — `b` gets demoted.
|
|
201
|
+
expect(b.style.gridColumn).toBe('span 1');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('leaves items with an explicit span untouched and consumes their columns for the demotion budget', () => {
|
|
205
|
+
const container = makeContainer(10);
|
|
206
|
+
container.style.gridTemplateColumns = '100px 100px 100px 100px';
|
|
207
|
+
const explicit = makeItem(50);
|
|
208
|
+
explicit.style.gridColumn = 'span 3';
|
|
209
|
+
const auto = makeAutoItem(50);
|
|
210
|
+
container.append(explicit, auto);
|
|
211
|
+
applyMasonryLayout(container, [explicit, auto], { preferredColumnSpan: 2 });
|
|
212
|
+
expect(explicit.style.gridColumn).toBe('span 3');
|
|
213
|
+
// 3 cols consumed, 1 remains → auto demoted.
|
|
214
|
+
expect(auto.style.gridColumn).toBe('span 1');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('is a no-op without preferredColumnSpan', () => {
|
|
218
|
+
const container = makeContainer(10);
|
|
219
|
+
container.style.gridTemplateColumns = '100px 100px';
|
|
220
|
+
const a = makeAutoItem(50);
|
|
221
|
+
container.append(a);
|
|
222
|
+
applyMasonryLayout(container, [a]);
|
|
223
|
+
expect(a.style.gridColumn).toBe('');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('clearMasonryLayout resets gridColumn on data-column-span-auto items only', () => {
|
|
227
|
+
const container = makeContainer(10);
|
|
228
|
+
container.style.gridTemplateColumns = '100px 100px 100px 100px';
|
|
229
|
+
const auto = makeAutoItem(50);
|
|
230
|
+
const explicit = makeItem(50);
|
|
231
|
+
explicit.style.gridColumn = 'span 2';
|
|
232
|
+
container.append(auto, explicit);
|
|
233
|
+
applyMasonryLayout(container, [auto, explicit], { preferredColumnSpan: 2 });
|
|
234
|
+
clearMasonryLayout(container, [auto, explicit]);
|
|
235
|
+
expect(auto.style.gridColumn).toBe('');
|
|
236
|
+
expect(explicit.style.gridColumn).toBe('span 2');
|
|
237
|
+
});
|
|
158
238
|
});
|
|
159
239
|
});
|
|
160
240
|
|
|
@@ -206,6 +286,21 @@ describe('GridItem', () => {
|
|
|
206
286
|
const { container } = render(<GridItem as="article">x</GridItem>);
|
|
207
287
|
expect(container.firstChild?.nodeName).toBe('ARTICLE');
|
|
208
288
|
});
|
|
289
|
+
|
|
290
|
+
it('marks data-column-span-auto when columnSpan is not provided', () => {
|
|
291
|
+
const { container } = render(<GridItem>x</GridItem>);
|
|
292
|
+
expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('does not mark data-column-span-auto when columnSpan is set', () => {
|
|
296
|
+
const { container } = render(<GridItem columnSpan="half">x</GridItem>);
|
|
297
|
+
expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('does not mark data-column-span-auto when startColumn or endColumn is set', () => {
|
|
301
|
+
const { container } = render(<GridItem startColumn={2} endColumn={4}>x</GridItem>);
|
|
302
|
+
expect((container.firstChild as HTMLElement).hasAttribute('data-column-span-auto')).toBe(false);
|
|
303
|
+
});
|
|
209
304
|
});
|
|
210
305
|
|
|
211
306
|
describe('aspectToColumnSpan', () => {
|
|
@@ -10,6 +10,7 @@ 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
14
|
},
|
|
14
15
|
} satisfies Meta<typeof Grid>;
|
|
15
16
|
|
|
@@ -140,6 +141,50 @@ export const MasonryWithSpans: Story = {
|
|
|
140
141
|
),
|
|
141
142
|
};
|
|
142
143
|
|
|
144
|
+
/**
|
|
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
|
+
* fill just the remaining columns, so no trailing gap appears.
|
|
149
|
+
*
|
|
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.
|
|
153
|
+
*/
|
|
154
|
+
export const MasonryPreferredColumnSpan: Story = {
|
|
155
|
+
args: { isMasonry: true, masonryPreferredColumnSpan: 2 },
|
|
156
|
+
render: (args) => (
|
|
157
|
+
<Grid {...args}>
|
|
158
|
+
{Array.from({ length: 7 }).map((_, i) => (
|
|
159
|
+
<GridItem key={i} style={masonryCellStyle(i)}>
|
|
160
|
+
Card {i + 1}
|
|
161
|
+
</GridItem>
|
|
162
|
+
))}
|
|
163
|
+
</Grid>
|
|
164
|
+
),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Mixing an explicit `columnSpan="three-quarters"` hero with auto items.
|
|
169
|
+
* The hero keeps its span; its columns still consume the demotion budget,
|
|
170
|
+
* so the auto items fill the remaining row consistently.
|
|
171
|
+
*/
|
|
172
|
+
export const MasonryPreferredColumnSpanWithHero: Story = {
|
|
173
|
+
args: { isMasonry: true, masonryPreferredColumnSpan: 2 },
|
|
174
|
+
render: (args) => (
|
|
175
|
+
<Grid {...args}>
|
|
176
|
+
<GridItem columnSpan="three-quarters" style={masonryCellStyle(0)}>
|
|
177
|
+
Hero (three-quarters)
|
|
178
|
+
</GridItem>
|
|
179
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
180
|
+
<GridItem key={i} style={masonryCellStyle(i + 1)}>
|
|
181
|
+
Card {i + 1}
|
|
182
|
+
</GridItem>
|
|
183
|
+
))}
|
|
184
|
+
</Grid>
|
|
185
|
+
),
|
|
186
|
+
};
|
|
187
|
+
|
|
143
188
|
export const ComplexLayout: Story = {
|
|
144
189
|
args: {},
|
|
145
190
|
render: () => (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
Context,
|
|
2
3
|
ElementType,
|
|
3
4
|
ComponentPropsWithoutRef,
|
|
4
5
|
CSSProperties,
|
|
@@ -23,9 +24,14 @@ export type GridContextValue = {
|
|
|
23
24
|
autoSpanMedia: boolean;
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// Lazy-initialised so the module can be evaluated in Next.js Server Components
|
|
28
|
+
// without calling `createContext` at import time (which crashes RSC — `react-server`
|
|
29
|
+
// export conditions don't expose `createContext`).
|
|
30
|
+
let _GridContext: Context<GridContextValue>;
|
|
31
|
+
export function getGridContext(): Context<GridContextValue> {
|
|
32
|
+
_GridContext ??= createContext<GridContextValue>({ autoSpanMedia: false });
|
|
33
|
+
return _GridContext;
|
|
34
|
+
}
|
|
29
35
|
|
|
30
36
|
type GridOwnProps = WithClassName & {
|
|
31
37
|
variant?: GridVariant;
|
|
@@ -39,6 +45,14 @@ type GridOwnProps = WithClassName & {
|
|
|
39
45
|
* are unaffected.
|
|
40
46
|
*/
|
|
41
47
|
autoSpanMedia?: boolean;
|
|
48
|
+
/**
|
|
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.
|
|
54
|
+
*/
|
|
55
|
+
masonryPreferredColumnSpan?: number;
|
|
42
56
|
as?: ElementType;
|
|
43
57
|
};
|
|
44
58
|
|
|
@@ -53,12 +67,13 @@ export function Grid<C extends ElementType = 'div'>({
|
|
|
53
67
|
isCentered = false,
|
|
54
68
|
isMasonry = false,
|
|
55
69
|
autoSpanMedia = false,
|
|
70
|
+
masonryPreferredColumnSpan,
|
|
56
71
|
as,
|
|
57
72
|
style,
|
|
58
73
|
...rest
|
|
59
74
|
}: GridProps<C>) {
|
|
60
75
|
const ref = useRef<HTMLElement>(null);
|
|
61
|
-
useMasonry(ref, isMasonry);
|
|
76
|
+
useMasonry(ref, isMasonry, { preferredColumnSpan: masonryPreferredColumnSpan });
|
|
62
77
|
|
|
63
78
|
const classNames = cn(
|
|
64
79
|
css.grid,
|
|
@@ -75,6 +90,7 @@ export function Grid<C extends ElementType = 'div'>({
|
|
|
75
90
|
|
|
76
91
|
const Component = as ?? 'div';
|
|
77
92
|
|
|
93
|
+
const GridContext = getGridContext();
|
|
78
94
|
const ctx = useMemo<GridContextValue>(() => ({ autoSpanMedia }), [autoSpanMedia]);
|
|
79
95
|
|
|
80
96
|
return (
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import css from './Grid.module.css';
|
|
11
11
|
import { cn } from '@boostdev/design-system-foundation';
|
|
12
12
|
import type { WithClassName } from '../../../types';
|
|
13
|
-
import {
|
|
13
|
+
import { getGridContext } from './Grid';
|
|
14
14
|
import {
|
|
15
15
|
aspectToColumnSpan,
|
|
16
16
|
findMediaChild,
|
|
@@ -75,7 +75,7 @@ export function GridItem<C extends ElementType = 'div'>({
|
|
|
75
75
|
style,
|
|
76
76
|
...rest
|
|
77
77
|
}: GridItemProps<C>) {
|
|
78
|
-
const { autoSpanMedia } = useContext(
|
|
78
|
+
const { autoSpanMedia } = useContext(getGridContext());
|
|
79
79
|
const effectiveSpan: GridItemSpanValue =
|
|
80
80
|
columnSpan ?? (autoSpanMedia ? 'auto' : 'full');
|
|
81
81
|
|
|
@@ -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
|
>
|
|
@@ -36,7 +36,23 @@ export function supportsNativeMasonry(): boolean {
|
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export
|
|
39
|
+
export type MasonryOptions = {
|
|
40
|
+
/**
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
preferredColumnSpan?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function applyMasonryLayout(
|
|
52
|
+
container: HTMLElement,
|
|
53
|
+
rawChildren: HTMLElement[],
|
|
54
|
+
options: MasonryOptions = {},
|
|
55
|
+
): void {
|
|
40
56
|
// We override row-gap to 0, so reading it back would always give 0 on re-runs.
|
|
41
57
|
// Read column-gap instead — `.grid` sets both via the `gap` shorthand and the
|
|
42
58
|
// polyfill never touches column-gap. (If a consumer sets asymmetric gaps, the
|
|
@@ -53,10 +69,37 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
|
|
|
53
69
|
);
|
|
54
70
|
|
|
55
71
|
// Pass 1 — clear prior row-span overrides so items measure at their natural height.
|
|
72
|
+
// Also force `align-self: start`: the default `stretch` would grow each item to fill
|
|
73
|
+
// the 1px track, making `offsetHeight` return 1 for every child. Keeping `start` after
|
|
74
|
+
// the span is assigned also prevents items from stretching into the gap region.
|
|
56
75
|
for (const child of children) {
|
|
57
76
|
child.style.gridRow = '';
|
|
58
77
|
child.style.gridRowStart = '';
|
|
59
78
|
child.style.gridRowEnd = '';
|
|
79
|
+
child.style.alignSelf = 'start';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Preferred-column-span pass — optional. Assigns column spans to `data-column-span-auto`
|
|
83
|
+
// 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) {
|
|
87
|
+
const totalCols = countGridColumns(container);
|
|
88
|
+
if (totalCols > 0) {
|
|
89
|
+
let col = 0;
|
|
90
|
+
for (const child of children) {
|
|
91
|
+
if (child.hasAttribute('data-column-span-auto')) {
|
|
92
|
+
const remaining = totalCols - col;
|
|
93
|
+
const span = Math.max(1, Math.min(preferred, remaining));
|
|
94
|
+
child.style.gridColumn = `span ${span}`;
|
|
95
|
+
col = (col + span) % totalCols;
|
|
96
|
+
} else {
|
|
97
|
+
// Item has an explicit span — consume its columns for the demotion budget.
|
|
98
|
+
const consumed = Math.min(totalCols, readExplicitSpan(child, totalCols));
|
|
99
|
+
col = (col + consumed) % totalCols;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
60
103
|
}
|
|
61
104
|
|
|
62
105
|
// Pass 2 — read all heights in a tight loop. The first offsetHeight read forces one
|
|
@@ -72,6 +115,36 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
|
|
|
72
115
|
}
|
|
73
116
|
}
|
|
74
117
|
|
|
118
|
+
function countGridColumns(container: HTMLElement): number {
|
|
119
|
+
// `getComputedStyle` resolves `grid-template-columns` to concrete track sizes
|
|
120
|
+
// (e.g. "256px 256px 256px 256px"), regardless of whether the author used
|
|
121
|
+
// `repeat()`, `minmax()`, or CSS custom properties. Splitting by whitespace
|
|
122
|
+
// gives the actual track count at the current viewport width.
|
|
123
|
+
const value = window.getComputedStyle(container).gridTemplateColumns.trim();
|
|
124
|
+
if (!value || value === 'none') return 0;
|
|
125
|
+
return value.split(/\s+/).length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readExplicitSpan(el: HTMLElement, totalCols: number): number {
|
|
129
|
+
// Try the inline value first to avoid resolving CSS vars that haven't been
|
|
130
|
+
// registered with `@property` (computed style returns the literal string for
|
|
131
|
+
// those). Inline values look like `span N`, `span var(...)`, or `a / b`.
|
|
132
|
+
const inline = el.style.gridColumn || el.style.gridColumnEnd;
|
|
133
|
+
const fromInline = parseSpan(inline, totalCols);
|
|
134
|
+
if (fromInline !== null) return fromInline;
|
|
135
|
+
const computed = window.getComputedStyle(el).gridColumn;
|
|
136
|
+
return parseSpan(computed, totalCols) ?? 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseSpan(value: string, totalCols: number): number | null {
|
|
140
|
+
if (!value) return null;
|
|
141
|
+
const match = value.match(/^\s*span\s+(\d+)/);
|
|
142
|
+
if (match) return Math.max(1, Math.min(totalCols, parseInt(match[1], 10)));
|
|
143
|
+
// `1 / -1` style — spans the whole row.
|
|
144
|
+
if (/\/\s*-1\b/.test(value)) return totalCols;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
75
148
|
export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElement[]): void {
|
|
76
149
|
container.style.gridAutoRows = '';
|
|
77
150
|
container.style.gridAutoFlow = '';
|
|
@@ -81,6 +154,13 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
|
|
|
81
154
|
child.style.gridRow = '';
|
|
82
155
|
child.style.gridRowStart = '';
|
|
83
156
|
child.style.gridRowEnd = '';
|
|
157
|
+
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
|
+
}
|
|
84
164
|
}
|
|
85
165
|
}
|
|
86
166
|
|
|
@@ -89,7 +169,12 @@ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
|
|
|
89
169
|
* size / mutation changes. No-op when `enabled` is false or when the browser
|
|
90
170
|
* natively supports masonry.
|
|
91
171
|
*/
|
|
92
|
-
export function useMasonry(
|
|
172
|
+
export function useMasonry(
|
|
173
|
+
ref: RefObject<HTMLElement | null>,
|
|
174
|
+
enabled: boolean,
|
|
175
|
+
options: MasonryOptions = {},
|
|
176
|
+
): void {
|
|
177
|
+
const { preferredColumnSpan } = options;
|
|
93
178
|
useLayoutEffect(() => {
|
|
94
179
|
const container = ref.current;
|
|
95
180
|
if (!container || !enabled) return;
|
|
@@ -99,7 +184,9 @@ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean)
|
|
|
99
184
|
const schedule = () => {
|
|
100
185
|
cancelAnimationFrame(frame);
|
|
101
186
|
frame = requestAnimationFrame(() => {
|
|
102
|
-
applyMasonryLayout(container, Array.from(container.children) as HTMLElement[]
|
|
187
|
+
applyMasonryLayout(container, Array.from(container.children) as HTMLElement[], {
|
|
188
|
+
preferredColumnSpan,
|
|
189
|
+
});
|
|
103
190
|
});
|
|
104
191
|
};
|
|
105
192
|
|
|
@@ -130,5 +217,5 @@ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean)
|
|
|
130
217
|
mo?.disconnect();
|
|
131
218
|
clearMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
|
|
132
219
|
};
|
|
133
|
-
}, [ref, enabled]);
|
|
220
|
+
}, [ref, enabled, preferredColumnSpan]);
|
|
134
221
|
}
|
|
@@ -49,6 +49,7 @@ Numeric `column-span="n"` is literal — it does not adapt. Use named values for
|
|
|
49
49
|
| `is-centered` | boolean | `false` | Caps max-width via `--bds-container_max-width` |
|
|
50
50
|
| `is-masonry` | boolean | `false` | Enables masonry layout (CSS Grid Level 3 "grid-lanes"). Uses native CSS when supported, otherwise runs a JS polyfill. |
|
|
51
51
|
| `auto-span-media` | boolean | `false` | Child `<bds-grid-item>`s without an explicit `column-span` resolve their span from the intrinsic aspect ratio of their first `<img>`/`<video>` descendant. See [Aspect-ratio auto-span](#aspect-ratio-auto-span). |
|
|
52
|
+
| `masonry-preferred-column-span` | 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
53
|
|
|
53
54
|
## Slots
|
|
54
55
|
|
|
@@ -161,6 +162,39 @@ Individual items can opt out with an explicit `column-span` (e.g. `column-span="
|
|
|
161
162
|
|
|
162
163
|
Named-span responsive behavior still applies — on mobile every item collapses to full-width regardless of its resolved span.
|
|
163
164
|
|
|
165
|
+
## Masonry preferred column span
|
|
166
|
+
|
|
167
|
+
For a uniform "cards mostly 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.
|
|
168
|
+
|
|
169
|
+
```html
|
|
170
|
+
<bds-grid is-masonry masonry-preferred-column-span="2">
|
|
171
|
+
<bds-grid-item>…</bds-grid-item>
|
|
172
|
+
<bds-grid-item>…</bds-grid-item>
|
|
173
|
+
</bds-grid>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Rules:
|
|
177
|
+
|
|
178
|
+
- Requires `is-masonry`. No-op on non-masonry grids.
|
|
179
|
+
- Items with an explicit `column-span` (named or numeric) keep their span. Their columns still consume the demotion budget, so a `column-span="three-quarters"` followed by an auto item in a 4-column grid correctly demotes the auto item to 1.
|
|
180
|
+
- Items with `start-column` / `end-column` are unaffected.
|
|
181
|
+
- 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.
|
|
182
|
+
|
|
183
|
+
### When NOT to use
|
|
184
|
+
|
|
185
|
+
- When span should reflect **content importance** or media shape — set `column-span` per item instead. `masonry-preferred-column-span` enforces a uniform visual rhythm and is the wrong tool for heterogeneous cards.
|
|
186
|
+
- Alongside `auto-span-media` on the same grid. Both features target items without an explicit `column-span`, and `masonry-preferred-column-span` wins — `auto-span-media` silently does nothing. Pick one strategy per grid.
|
|
187
|
+
- In a non-masonry grid. The feature depends on the polyfill's packing pass and is a no-op otherwise — use a plain `column-span` default instead.
|
|
188
|
+
|
|
189
|
+
### Performance
|
|
190
|
+
|
|
191
|
+
- 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.
|
|
192
|
+
- Shares the polyfill's existing `ResizeObserver` + `MutationObserver` + `requestAnimationFrame` coalescing — no new observers, no separate render.
|
|
193
|
+
|
|
194
|
+
### SSR / hydration
|
|
195
|
+
|
|
196
|
+
Column detection runs inside the masonry polyfill's layout pass after upgrade, so it only executes on the client. The initial server render emits each auto item at its default `full` span until the custom element upgrades and the first polyfill pass runs — matching the existing masonry SSR behavior.
|
|
197
|
+
|
|
164
198
|
### Loading behavior
|
|
165
199
|
|
|
166
200
|
Dimensions are read from `img.naturalWidth/naturalHeight` and `video.videoWidth/videoHeight` — no layout is triggered. If metadata is not yet available when the element upgrades, the item starts at `'one-quarter'` and re-resolves when the `load` (image) or `loadedmetadata` (video) event fires. Subsequent slot mutations re-run the resolver.
|