@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
|
@@ -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").
|
|
@@ -36,7 +75,31 @@ export function supportsNativeMasonry(): boolean {
|
|
|
36
75
|
);
|
|
37
76
|
}
|
|
38
77
|
|
|
39
|
-
export
|
|
78
|
+
export type MasonryOptions = {
|
|
79
|
+
/**
|
|
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.
|
|
88
|
+
*/
|
|
89
|
+
preferredColumnSpan?: Exclude<GridItemSpanValue, 'auto'>;
|
|
90
|
+
};
|
|
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
|
+
|
|
98
|
+
export function applyMasonryLayout(
|
|
99
|
+
container: HTMLElement,
|
|
100
|
+
rawChildren: HTMLElement[],
|
|
101
|
+
options: MasonryOptions = {},
|
|
102
|
+
): void {
|
|
40
103
|
// We override row-gap to 0, so reading it back would always give 0 on re-runs.
|
|
41
104
|
// Read column-gap instead — `.grid` sets both via the `gap` shorthand and the
|
|
42
105
|
// polyfill never touches column-gap. (If a consumer sets asymmetric gaps, the
|
|
@@ -47,6 +110,13 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
|
|
|
47
110
|
container.style.gridAutoRows = `${ROW_UNIT_PX}px`;
|
|
48
111
|
container.style.rowGap = '0px';
|
|
49
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';
|
|
50
120
|
|
|
51
121
|
const children = rawChildren.filter(
|
|
52
122
|
(el) => el.nodeType === 1 && window.getComputedStyle(el).display !== 'none',
|
|
@@ -63,6 +133,41 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
|
|
|
63
133
|
child.style.alignSelf = 'start';
|
|
64
134
|
}
|
|
65
135
|
|
|
136
|
+
// Preferred-column-span pass — optional. Assigns column spans to `data-column-span-auto`
|
|
137
|
+
// items, demoting to fit remaining row space. Runs before height measurement so items
|
|
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) {
|
|
144
|
+
const totalCols = countGridColumns(container);
|
|
145
|
+
const preferredCols = totalCols > 0 ? resolvePreferredColumns(container, preferred, totalCols) : 0;
|
|
146
|
+
if (totalCols > 0 && preferredCols > 0) {
|
|
147
|
+
let col = 0;
|
|
148
|
+
for (const child of children) {
|
|
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
|
+
}
|
|
153
|
+
const remaining = totalCols - col;
|
|
154
|
+
const span = Math.max(1, Math.min(preferredCols, remaining));
|
|
155
|
+
child.style.gridColumn = `span ${span}`;
|
|
156
|
+
col = (col + span) % totalCols;
|
|
157
|
+
} else {
|
|
158
|
+
// Item has an explicit span — consume its columns for the demotion budget.
|
|
159
|
+
const consumed = Math.min(totalCols, readExplicitSpan(child, totalCols));
|
|
160
|
+
col = (col + consumed) % totalCols;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
// No preferred span — restore any items the polyfill previously wrote.
|
|
166
|
+
for (const child of children) {
|
|
167
|
+
restoreOriginalColumn(child);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
66
171
|
// Pass 2 — read all heights in a tight loop. The first offsetHeight read forces one
|
|
67
172
|
// reflow; subsequent reads are cached until the next style write. This avoids the
|
|
68
173
|
// O(N) forced-reflow cost of a read-then-write loop.
|
|
@@ -76,25 +181,70 @@ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElem
|
|
|
76
181
|
}
|
|
77
182
|
}
|
|
78
183
|
|
|
184
|
+
function countGridColumns(container: HTMLElement): number {
|
|
185
|
+
// `getComputedStyle` resolves `grid-template-columns` to concrete track sizes
|
|
186
|
+
// (e.g. "256px 256px 256px 256px"), regardless of whether the author used
|
|
187
|
+
// `repeat()`, `minmax()`, or CSS custom properties. Splitting by whitespace
|
|
188
|
+
// gives the actual track count at the current viewport width.
|
|
189
|
+
const value = window.getComputedStyle(container).gridTemplateColumns.trim();
|
|
190
|
+
if (!value || value === 'none') return 0;
|
|
191
|
+
return value.split(/\s+/).length;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function readExplicitSpan(el: HTMLElement, totalCols: number): number {
|
|
195
|
+
// Try the inline value first to avoid resolving CSS vars that haven't been
|
|
196
|
+
// registered with `@property` (computed style returns the literal string for
|
|
197
|
+
// those). Inline values look like `span N`, `span var(...)`, or `a / b`.
|
|
198
|
+
const inline = el.style.gridColumn || el.style.gridColumnEnd;
|
|
199
|
+
const fromInline = parseSpan(inline, totalCols);
|
|
200
|
+
if (fromInline !== null) return fromInline;
|
|
201
|
+
const computed = window.getComputedStyle(el).gridColumn;
|
|
202
|
+
return parseSpan(computed, totalCols) ?? 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseSpan(value: string, totalCols: number): number | null {
|
|
206
|
+
if (!value) return null;
|
|
207
|
+
const match = value.match(/^\s*span\s+(\d+)/);
|
|
208
|
+
if (match) return Math.max(1, Math.min(totalCols, parseInt(match[1], 10)));
|
|
209
|
+
// `1 / -1` style — spans the whole row.
|
|
210
|
+
if (/\/\s*-1\b/.test(value)) return totalCols;
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
79
214
|
export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElement[]): void {
|
|
80
215
|
container.style.gridAutoRows = '';
|
|
81
216
|
container.style.gridAutoFlow = '';
|
|
82
217
|
container.style.rowGap = '';
|
|
218
|
+
container.style.overflowAnchor = '';
|
|
83
219
|
for (const child of rawChildren) {
|
|
84
220
|
if (child.nodeType !== 1) continue;
|
|
85
221
|
child.style.gridRow = '';
|
|
86
222
|
child.style.gridRowStart = '';
|
|
87
223
|
child.style.gridRowEnd = '';
|
|
88
224
|
child.style.alignSelf = '';
|
|
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);
|
|
89
228
|
}
|
|
90
229
|
}
|
|
91
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
|
+
|
|
92
237
|
/**
|
|
93
238
|
* React hook that runs the polyfill against `ref.current` and re-runs on
|
|
94
239
|
* size / mutation changes. No-op when `enabled` is false or when the browser
|
|
95
240
|
* natively supports masonry.
|
|
96
241
|
*/
|
|
97
|
-
export function useMasonry(
|
|
242
|
+
export function useMasonry(
|
|
243
|
+
ref: RefObject<HTMLElement | null>,
|
|
244
|
+
enabled: boolean,
|
|
245
|
+
options: MasonryOptions = {},
|
|
246
|
+
): void {
|
|
247
|
+
const { preferredColumnSpan } = options;
|
|
98
248
|
useLayoutEffect(() => {
|
|
99
249
|
const container = ref.current;
|
|
100
250
|
if (!container || !enabled) return;
|
|
@@ -104,7 +254,9 @@ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean)
|
|
|
104
254
|
const schedule = () => {
|
|
105
255
|
cancelAnimationFrame(frame);
|
|
106
256
|
frame = requestAnimationFrame(() => {
|
|
107
|
-
applyMasonryLayout(container, Array.from(container.children) as HTMLElement[]
|
|
257
|
+
applyMasonryLayout(container, Array.from(container.children) as HTMLElement[], {
|
|
258
|
+
preferredColumnSpan,
|
|
259
|
+
});
|
|
108
260
|
});
|
|
109
261
|
};
|
|
110
262
|
|
|
@@ -135,5 +287,5 @@ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean)
|
|
|
135
287
|
mo?.disconnect();
|
|
136
288
|
clearMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
|
|
137
289
|
};
|
|
138
|
-
}, [ref, enabled]);
|
|
290
|
+
}, [ref, enabled, preferredColumnSpan]);
|
|
139
291
|
}
|
|
@@ -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` | 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). |
|
|
52
53
|
|
|
53
54
|
## Slots
|
|
54
55
|
|
|
@@ -161,6 +162,41 @@ 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 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
|
+
|
|
169
|
+
```html
|
|
170
|
+
<bds-grid is-masonry masonry-preferred-column-span="half">
|
|
171
|
+
<bds-grid-item>…</bds-grid-item>
|
|
172
|
+
<bds-grid-item>…</bds-grid-item>
|
|
173
|
+
</bds-grid>
|
|
174
|
+
```
|
|
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
|
+
|
|
178
|
+
Rules:
|
|
179
|
+
|
|
180
|
+
- Requires `is-masonry`. No-op on non-masonry grids.
|
|
181
|
+
- 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.
|
|
182
|
+
- Items with `start-column` / `end-column` are unaffected.
|
|
183
|
+
- 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.
|
|
184
|
+
|
|
185
|
+
### When NOT to use
|
|
186
|
+
|
|
187
|
+
- 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.
|
|
188
|
+
- 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.
|
|
189
|
+
- 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.
|
|
190
|
+
|
|
191
|
+
### Performance
|
|
192
|
+
|
|
193
|
+
- 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.
|
|
194
|
+
- Shares the polyfill's existing `ResizeObserver` + `MutationObserver` + `requestAnimationFrame` coalescing — no new observers, no separate render.
|
|
195
|
+
|
|
196
|
+
### SSR / hydration
|
|
197
|
+
|
|
198
|
+
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.
|
|
199
|
+
|
|
164
200
|
### Loading behavior
|
|
165
201
|
|
|
166
202
|
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.
|
|
@@ -9,12 +9,14 @@ function BdsGrid({
|
|
|
9
9
|
columns,
|
|
10
10
|
isCentered,
|
|
11
11
|
isMasonry,
|
|
12
|
+
masonryPreferredColumnSpan,
|
|
12
13
|
children,
|
|
13
14
|
}: {
|
|
14
15
|
variant?: GridVariant;
|
|
15
16
|
columns?: number;
|
|
16
17
|
isCentered?: boolean;
|
|
17
18
|
isMasonry?: boolean;
|
|
19
|
+
masonryPreferredColumnSpan?: string;
|
|
18
20
|
children?: React.ReactNode;
|
|
19
21
|
}) {
|
|
20
22
|
return React.createElement(
|
|
@@ -24,6 +26,7 @@ function BdsGrid({
|
|
|
24
26
|
columns,
|
|
25
27
|
'is-centered': isCentered || undefined,
|
|
26
28
|
'is-masonry': isMasonry || undefined,
|
|
29
|
+
'masonry-preferred-column-span': masonryPreferredColumnSpan,
|
|
27
30
|
},
|
|
28
31
|
children,
|
|
29
32
|
);
|
|
@@ -45,7 +48,7 @@ function Item({
|
|
|
45
48
|
return React.createElement(
|
|
46
49
|
'bds-grid-item',
|
|
47
50
|
{
|
|
48
|
-
'column-span': columnSpan
|
|
51
|
+
'column-span': columnSpan,
|
|
49
52
|
'start-column': startColumn,
|
|
50
53
|
'end-column': endColumn,
|
|
51
54
|
'row-span': rowSpan,
|
|
@@ -71,6 +74,10 @@ const meta = {
|
|
|
71
74
|
columns: { control: 'number' },
|
|
72
75
|
isCentered: { control: 'boolean' },
|
|
73
76
|
isMasonry: { control: 'boolean' },
|
|
77
|
+
masonryPreferredColumnSpan: {
|
|
78
|
+
control: 'select',
|
|
79
|
+
options: ['full', 'three-quarters', 'two-thirds', 'half', 'one-third', 'one-quarter'],
|
|
80
|
+
},
|
|
74
81
|
},
|
|
75
82
|
} satisfies Meta<typeof BdsGrid>;
|
|
76
83
|
|
|
@@ -189,6 +196,46 @@ export const MasonryWithSpans: Story = {
|
|
|
189
196
|
),
|
|
190
197
|
};
|
|
191
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.
|
|
207
|
+
*
|
|
208
|
+
* Swap `masonryPreferredColumnSpan` between named values in the Controls and
|
|
209
|
+
* resize the viewport to watch the demotion adapt to the current column count.
|
|
210
|
+
*/
|
|
211
|
+
export const MasonryPreferredColumnSpan: Story = {
|
|
212
|
+
args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
|
|
213
|
+
render: (args) => (
|
|
214
|
+
<BdsGrid {...args}>
|
|
215
|
+
{Array.from({ length: 7 }).map((_, i) => (
|
|
216
|
+
<Item key={i}><div style={masonryCellStyle(i)}>Card {i + 1}</div></Item>
|
|
217
|
+
))}
|
|
218
|
+
</BdsGrid>
|
|
219
|
+
),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* An explicit `column-span="three-quarters"` hero alongside auto items.
|
|
224
|
+
* The hero keeps its span; its columns consume the demotion budget, so the
|
|
225
|
+
* auto items fill the remaining row consistently.
|
|
226
|
+
*/
|
|
227
|
+
export const MasonryPreferredColumnSpanWithHero: Story = {
|
|
228
|
+
args: { isMasonry: true, masonryPreferredColumnSpan: 'half' },
|
|
229
|
+
render: (args) => (
|
|
230
|
+
<BdsGrid {...args}>
|
|
231
|
+
<Item columnSpan="three-quarters"><div style={masonryCellStyle(0)}>Hero (three-quarters)</div></Item>
|
|
232
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
233
|
+
<Item key={i}><div style={masonryCellStyle(i + 1)}>Card {i + 1}</div></Item>
|
|
234
|
+
))}
|
|
235
|
+
</BdsGrid>
|
|
236
|
+
),
|
|
237
|
+
};
|
|
238
|
+
|
|
192
239
|
export const ComplexLayout: Story = {
|
|
193
240
|
args: {},
|
|
194
241
|
render: () => (
|
|
@@ -99,4 +99,18 @@ describe('bds-grid-item', () => {
|
|
|
99
99
|
expect(item.style.gridColumn).toBe('span var(--bds-grid_columns-33)');
|
|
100
100
|
cleanup(grid);
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
it('marks data-column-span-auto when column-span is not set', async () => {
|
|
104
|
+
const el = (await fixture('<bds-grid-item></bds-grid-item>')) as BdsGridItem;
|
|
105
|
+
expect(el.hasAttribute('data-column-span-auto')).toBe(true);
|
|
106
|
+
cleanup(el);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('clears data-column-span-auto when column-span is set', async () => {
|
|
110
|
+
const el = (await fixture(
|
|
111
|
+
'<bds-grid-item column-span="half"></bds-grid-item>',
|
|
112
|
+
)) as BdsGridItem;
|
|
113
|
+
expect(el.hasAttribute('data-column-span-auto')).toBe(false);
|
|
114
|
+
cleanup(el);
|
|
115
|
+
});
|
|
102
116
|
});
|
|
@@ -161,6 +161,13 @@ export class BdsGridItem extends LitElement {
|
|
|
161
161
|
|
|
162
162
|
this.style.gridColumn = gridColumn;
|
|
163
163
|
this.style.gridRow = this.rowSpan !== undefined ? `span ${this.rowSpan}` : '';
|
|
164
|
+
|
|
165
|
+
// Marker the masonry polyfill reads to know this item's span wasn't set by
|
|
166
|
+
// the consumer — it may override with the parent `masonry-preferred-column-span`.
|
|
167
|
+
const isColumnSpanAuto =
|
|
168
|
+
(this.columnSpan === undefined || this.columnSpan === null) && !hasExplicitRange;
|
|
169
|
+
if (isColumnSpanAuto) this.setAttribute('data-column-span-auto', '');
|
|
170
|
+
else this.removeAttribute('data-column-span-auto');
|
|
164
171
|
}
|
|
165
172
|
|
|
166
173
|
render() {
|
|
@@ -59,4 +59,12 @@ describe('bds-grid', () => {
|
|
|
59
59
|
expect(el.shadowRoot!.querySelector('.grid')!.classList.contains('--masonry')).toBe(true);
|
|
60
60
|
cleanup(el);
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
it('reflects masonry-preferred-column-span as a string property', async () => {
|
|
64
|
+
const el = await fixture(
|
|
65
|
+
'<bds-grid is-masonry masonry-preferred-column-span="half"></bds-grid>',
|
|
66
|
+
) as BdsGrid;
|
|
67
|
+
expect(el.masonryPreferredColumnSpan).toBe('half');
|
|
68
|
+
cleanup(el);
|
|
69
|
+
});
|
|
62
70
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LitElement, css, html } from 'lit';
|
|
2
2
|
import { applyMasonryLayout, clearMasonryLayout, supportsNativeMasonry } from '../../components/layout/Grid/masonry';
|
|
3
|
+
import type { GridItemSpanValue } from '../../components/layout/Grid/GridItem';
|
|
3
4
|
|
|
4
5
|
export type GridVariant = 'main' | 'page' | 'funnel' | 'custom';
|
|
5
6
|
|
|
@@ -16,6 +17,12 @@ export type GridVariant = 'main' | 'page' | 'funnel' | 'custom';
|
|
|
16
17
|
* auto-span-media — boolean; child `<bds-grid-item>`s without a `column-span`
|
|
17
18
|
* attribute resolve their span from the intrinsic aspect
|
|
18
19
|
* ratio of their first `<img>`/`<video>` child.
|
|
20
|
+
* masonry-preferred-column-span — named span or number; masonry only. Child
|
|
21
|
+
* items without a `column-span` attribute try to span this
|
|
22
|
+
* value (e.g. `"half"`, `"one-third"`). Named values adapt
|
|
23
|
+
* across breakpoints via the foundation's responsive column
|
|
24
|
+
* tokens. An item at row-end is demoted to the remaining
|
|
25
|
+
* column count. Numeric values are literal and don't adapt.
|
|
19
26
|
*
|
|
20
27
|
* Slots:
|
|
21
28
|
* (default) — grid content; typically `<bds-grid-item>` children
|
|
@@ -76,6 +83,11 @@ export class BdsGrid extends LitElement {
|
|
|
76
83
|
isCentered: { type: Boolean, attribute: 'is-centered', reflect: true },
|
|
77
84
|
isMasonry: { type: Boolean, attribute: 'is-masonry', reflect: true },
|
|
78
85
|
autoSpanMedia: { type: Boolean, attribute: 'auto-span-media', reflect: true },
|
|
86
|
+
masonryPreferredColumnSpan: {
|
|
87
|
+
type: String,
|
|
88
|
+
attribute: 'masonry-preferred-column-span',
|
|
89
|
+
reflect: true,
|
|
90
|
+
},
|
|
79
91
|
};
|
|
80
92
|
|
|
81
93
|
declare variant: GridVariant;
|
|
@@ -83,6 +95,7 @@ export class BdsGrid extends LitElement {
|
|
|
83
95
|
declare isCentered: boolean;
|
|
84
96
|
declare isMasonry: boolean;
|
|
85
97
|
declare autoSpanMedia: boolean;
|
|
98
|
+
declare masonryPreferredColumnSpan: string | undefined;
|
|
86
99
|
|
|
87
100
|
private _ro?: ResizeObserver;
|
|
88
101
|
private _mo?: MutationObserver;
|
|
@@ -95,6 +108,7 @@ export class BdsGrid extends LitElement {
|
|
|
95
108
|
this.isCentered = false;
|
|
96
109
|
this.isMasonry = false;
|
|
97
110
|
this.autoSpanMedia = false;
|
|
111
|
+
this.masonryPreferredColumnSpan = undefined;
|
|
98
112
|
}
|
|
99
113
|
|
|
100
114
|
disconnectedCallback() {
|
|
@@ -144,10 +158,23 @@ export class BdsGrid extends LitElement {
|
|
|
144
158
|
cancelAnimationFrame(this._frame);
|
|
145
159
|
this._frame = requestAnimationFrame(() => {
|
|
146
160
|
const container = this._gridEl();
|
|
147
|
-
if (container)
|
|
161
|
+
if (container) {
|
|
162
|
+
applyMasonryLayout(container, this._items(), {
|
|
163
|
+
preferredColumnSpan: this._resolvePreferredColumnSpan(),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
148
166
|
});
|
|
149
167
|
}
|
|
150
168
|
|
|
169
|
+
private _resolvePreferredColumnSpan(): Exclude<GridItemSpanValue, 'auto'> | undefined {
|
|
170
|
+
const raw = this.masonryPreferredColumnSpan;
|
|
171
|
+
if (raw === undefined || raw === null || raw === '') return undefined;
|
|
172
|
+
// Numeric attribute → literal column count; otherwise treat as a named span.
|
|
173
|
+
const n = Number(raw);
|
|
174
|
+
if (Number.isFinite(n) && String(n) === raw) return n;
|
|
175
|
+
return raw as Exclude<GridItemSpanValue, 'auto'>;
|
|
176
|
+
}
|
|
177
|
+
|
|
151
178
|
private _teardownMasonry() {
|
|
152
179
|
cancelAnimationFrame(this._frame);
|
|
153
180
|
this._ro?.disconnect();
|