@boostdev/design-system-components 2.1.0 → 2.2.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/AGENTS.md +2 -1
- package/README.md +40 -0
- package/dist/client.cjs +303 -60
- package/dist/client.css +568 -527
- package/dist/client.d.cts +39 -1
- package/dist/client.d.ts +39 -1
- package/dist/client.js +309 -60
- package/dist/index.cjs +303 -60
- package/dist/index.css +568 -527
- package/dist/index.d.cts +39 -1
- package/dist/index.d.ts +39 -1
- package/dist/index.js +309 -60
- package/dist/web-components/index.d.ts +129 -1
- package/dist/web-components/index.js +311 -0
- package/package.json +1 -1
- package/src/components/layout/Grid/Grid.mdx +244 -0
- package/src/components/layout/Grid/Grid.module.css +59 -0
- package/src/components/layout/Grid/Grid.spec.tsx +401 -0
- package/src/components/layout/Grid/Grid.stories.tsx +160 -0
- package/src/components/layout/Grid/Grid.tsx +85 -0
- package/src/components/layout/Grid/GridItem.tsx +150 -0
- package/src/components/layout/Grid/autoSpan.ts +77 -0
- package/src/components/layout/Grid/index.ts +4 -0
- package/src/components/layout/Grid/masonry.ts +134 -0
- package/src/index.ts +2 -0
- package/src/web-components/index.ts +4 -0
- package/src/web-components/layout/BdsGrid.mdx +210 -0
- package/src/web-components/layout/BdsGrid.stories.tsx +209 -0
- package/src/web-components/layout/BdsGridItem.mdx +52 -0
- package/src/web-components/layout/BdsGridItem.stories.tsx +72 -0
- package/src/web-components/layout/bds-grid-item.spec.ts +102 -0
- package/src/web-components/layout/bds-grid-item.ts +177 -0
- package/src/web-components/layout/bds-grid.spec.ts +62 -0
- package/src/web-components/layout/bds-grid.ts +184 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { act } from 'react';
|
|
3
|
+
import { Grid } from './Grid';
|
|
4
|
+
import { GridItem, GridItemSpan } from './GridItem';
|
|
5
|
+
import { applyMasonryLayout, clearMasonryLayout } from './masonry';
|
|
6
|
+
import {
|
|
7
|
+
aspectToColumnSpan,
|
|
8
|
+
findMediaChild,
|
|
9
|
+
mediaReadyEvent,
|
|
10
|
+
readMediaAspect,
|
|
11
|
+
} from './autoSpan';
|
|
12
|
+
|
|
13
|
+
describe('Grid', () => {
|
|
14
|
+
it('renders children', () => {
|
|
15
|
+
render(<Grid>content</Grid>);
|
|
16
|
+
expect(screen.getByText('content')).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders as a div by default', () => {
|
|
20
|
+
const { container } = render(<Grid>x</Grid>);
|
|
21
|
+
expect(container.firstChild?.nodeName).toBe('DIV');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('applies the main variant class by default', () => {
|
|
25
|
+
const { container } = render(<Grid>x</Grid>);
|
|
26
|
+
expect(container.firstChild).toHaveClass('grid');
|
|
27
|
+
expect(container.firstChild).toHaveClass('--main');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('applies the page variant class', () => {
|
|
31
|
+
const { container } = render(<Grid variant="page">x</Grid>);
|
|
32
|
+
expect(container.firstChild).toHaveClass('--page');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('applies the funnel variant class', () => {
|
|
36
|
+
const { container } = render(<Grid variant="funnel">x</Grid>);
|
|
37
|
+
expect(container.firstChild).toHaveClass('--funnel');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('applies the custom variant class and --grid_columns style', () => {
|
|
41
|
+
const { container } = render(<Grid variant="custom" columns={5}>x</Grid>);
|
|
42
|
+
const el = container.firstChild as HTMLElement;
|
|
43
|
+
expect(el).toHaveClass('--custom');
|
|
44
|
+
expect(el.style.getPropertyValue('--grid_columns')).toBe('5');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('applies the centered class when isCentered is true', () => {
|
|
48
|
+
const { container } = render(<Grid isCentered>x</Grid>);
|
|
49
|
+
expect(container.firstChild).toHaveClass('--centered');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('forwards HTML attributes', () => {
|
|
53
|
+
render(<Grid data-testid="g" aria-label="layout">x</Grid>);
|
|
54
|
+
const el = screen.getByTestId('g');
|
|
55
|
+
expect(el).toHaveAttribute('aria-label', 'layout');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders as a custom element via the as prop', () => {
|
|
59
|
+
const { container } = render(<Grid as="section">x</Grid>);
|
|
60
|
+
expect(container.firstChild?.nodeName).toBe('SECTION');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('accepts a custom className', () => {
|
|
64
|
+
const { container } = render(<Grid className="custom">x</Grid>);
|
|
65
|
+
expect(container.firstChild).toHaveClass('custom');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('applies the masonry class when isMasonry is true', () => {
|
|
69
|
+
const { container } = render(<Grid isMasonry>x</Grid>);
|
|
70
|
+
expect(container.firstChild).toHaveClass('--masonry');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('applyMasonryLayout', () => {
|
|
75
|
+
// offsetHeight isn't computed in jsdom; stub it per-element to simulate measured heights.
|
|
76
|
+
function stubHeight(el: HTMLElement, height: number) {
|
|
77
|
+
Object.defineProperty(el, 'offsetHeight', { configurable: true, value: height });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeContainer(gap: number) {
|
|
81
|
+
const container = document.createElement('div');
|
|
82
|
+
container.style.display = 'grid';
|
|
83
|
+
// Polyfill reads column-gap as the target gap (see masonry.ts).
|
|
84
|
+
container.style.columnGap = `${gap}px`;
|
|
85
|
+
container.style.rowGap = `${gap}px`;
|
|
86
|
+
document.body.append(container);
|
|
87
|
+
return container;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function makeItem(height: number) {
|
|
91
|
+
const el = document.createElement('div');
|
|
92
|
+
stubHeight(el, height);
|
|
93
|
+
return el;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it('overrides container to grid-auto-rows: 1px + grid-auto-flow: dense + row-gap: 0', () => {
|
|
97
|
+
const container = makeContainer(10);
|
|
98
|
+
applyMasonryLayout(container, []);
|
|
99
|
+
expect(container.style.gridAutoRows).toBe('1px');
|
|
100
|
+
expect(container.style.gridAutoFlow).toBe('dense');
|
|
101
|
+
expect(container.style.rowGap).toBe('0px');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('assigns grid-row-end: span ceil(height + targetGap) to each item', () => {
|
|
105
|
+
const container = makeContainer(10);
|
|
106
|
+
const a = makeItem(100);
|
|
107
|
+
const b = makeItem(201);
|
|
108
|
+
container.append(a, b);
|
|
109
|
+
applyMasonryLayout(container, [a, b]);
|
|
110
|
+
|
|
111
|
+
// ceil(100 + 10) = 110
|
|
112
|
+
expect(a.style.gridRowEnd).toBe('span 110');
|
|
113
|
+
// ceil(201 + 10) = 211
|
|
114
|
+
expect(b.style.gridRowEnd).toBe('span 211');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('spans at least 1 row for zero-height items', () => {
|
|
118
|
+
const container = makeContainer(0);
|
|
119
|
+
const item = makeItem(0);
|
|
120
|
+
container.append(item);
|
|
121
|
+
applyMasonryLayout(container, [item]);
|
|
122
|
+
expect(item.style.gridRowEnd).toBe('span 1');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('clears prior row-span before measuring', () => {
|
|
126
|
+
const container = makeContainer(10);
|
|
127
|
+
const item = makeItem(50);
|
|
128
|
+
item.style.gridRow = 'span 99';
|
|
129
|
+
container.append(item);
|
|
130
|
+
applyMasonryLayout(container, [item]);
|
|
131
|
+
// ceil(50 + 10) = 60
|
|
132
|
+
expect(item.style.gridRowEnd).toBe('span 60');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('ignores children with display:none', () => {
|
|
136
|
+
const container = makeContainer(10);
|
|
137
|
+
const visible = makeItem(50);
|
|
138
|
+
const hidden = makeItem(50);
|
|
139
|
+
hidden.style.display = 'none';
|
|
140
|
+
container.append(visible, hidden);
|
|
141
|
+
applyMasonryLayout(container, [visible, hidden]);
|
|
142
|
+
expect(visible.style.gridRowEnd).toBe('span 60');
|
|
143
|
+
expect(hidden.style.gridRowEnd).toBe('');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('clearMasonryLayout undoes container and child styles', () => {
|
|
147
|
+
const container = makeContainer(10);
|
|
148
|
+
const item = makeItem(50);
|
|
149
|
+
container.append(item);
|
|
150
|
+
applyMasonryLayout(container, [item]);
|
|
151
|
+
clearMasonryLayout(container, [item]);
|
|
152
|
+
expect(container.style.gridAutoRows).toBe('');
|
|
153
|
+
expect(container.style.gridAutoFlow).toBe('');
|
|
154
|
+
expect(container.style.rowGap).toBe('');
|
|
155
|
+
expect(item.style.gridRowEnd).toBe('');
|
|
156
|
+
expect(item.style.gridRowStart).toBe('');
|
|
157
|
+
expect(item.style.gridRow).toBe('');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('GridItem', () => {
|
|
162
|
+
it('defaults to full span', () => {
|
|
163
|
+
const { container } = render(<GridItem>x</GridItem>);
|
|
164
|
+
const el = container.firstChild as HTMLElement;
|
|
165
|
+
expect(el.style.gridColumn).toBe('var(--bds-grid_span-100)');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('maps named spans to span tokens', () => {
|
|
169
|
+
const cases: Array<[string, string]> = [
|
|
170
|
+
['three-quarters', 'span var(--bds-grid_columns-75)'],
|
|
171
|
+
['two-thirds', 'span var(--bds-grid_columns-66)'],
|
|
172
|
+
['half', 'span var(--bds-grid_columns-50)'],
|
|
173
|
+
['one-third', 'span var(--bds-grid_columns-33)'],
|
|
174
|
+
['one-quarter', 'span var(--bds-grid_columns-25)'],
|
|
175
|
+
];
|
|
176
|
+
for (const [input, expected] of cases) {
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
178
|
+
const { container } = render(<GridItem columnSpan={input as any}>x</GridItem>);
|
|
179
|
+
expect((container.firstChild as HTMLElement).style.gridColumn).toBe(expected);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('accepts numeric column span', () => {
|
|
184
|
+
const { container } = render(<GridItem columnSpan={3}>x</GridItem>);
|
|
185
|
+
expect((container.firstChild as HTMLElement).style.gridColumn).toBe('span 3');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('applies rowSpan', () => {
|
|
189
|
+
const { container } = render(<GridItem rowSpan={2}>x</GridItem>);
|
|
190
|
+
expect((container.firstChild as HTMLElement).style.gridRow).toBe('span 2');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('startColumn and endColumn override columnSpan', () => {
|
|
194
|
+
const { container } = render(
|
|
195
|
+
<GridItem columnSpan="half" startColumn={2} endColumn={5}>x</GridItem>,
|
|
196
|
+
);
|
|
197
|
+
expect((container.firstChild as HTMLElement).style.gridColumn).toBe('2 / 5');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('GridItemSpan constant exposes kebab-case values', () => {
|
|
201
|
+
expect(GridItemSpan.threeQuarters).toBe('three-quarters');
|
|
202
|
+
expect(GridItemSpan.oneQuarter).toBe('one-quarter');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('renders as a custom element via the as prop', () => {
|
|
206
|
+
const { container } = render(<GridItem as="article">x</GridItem>);
|
|
207
|
+
expect(container.firstChild?.nodeName).toBe('ARTICLE');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('aspectToColumnSpan', () => {
|
|
212
|
+
it('returns one-quarter for portrait, square, or slightly landscape (r ≤ 1.5)', () => {
|
|
213
|
+
expect(aspectToColumnSpan(0.5)).toBe('one-quarter');
|
|
214
|
+
expect(aspectToColumnSpan(1)).toBe('one-quarter');
|
|
215
|
+
expect(aspectToColumnSpan(1.5)).toBe('one-quarter');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('returns half for 3:2 / 16:9 landscape (1.5 < r ≤ 2.2)', () => {
|
|
219
|
+
expect(aspectToColumnSpan(3 / 2)).toBe('one-quarter');
|
|
220
|
+
expect(aspectToColumnSpan(16 / 9)).toBe('half');
|
|
221
|
+
expect(aspectToColumnSpan(2.2)).toBe('half');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('returns three-quarters for 21:9 / cinematic (2.2 < r ≤ 3.5)', () => {
|
|
225
|
+
expect(aspectToColumnSpan(21 / 9)).toBe('three-quarters');
|
|
226
|
+
expect(aspectToColumnSpan(2.35)).toBe('three-quarters');
|
|
227
|
+
expect(aspectToColumnSpan(3.5)).toBe('three-quarters');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('returns full for hard panoramic (r > 3.5)', () => {
|
|
231
|
+
expect(aspectToColumnSpan(32 / 9)).toBe('full');
|
|
232
|
+
expect(aspectToColumnSpan(5)).toBe('full');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('returns one-quarter for invalid/non-positive ratios', () => {
|
|
236
|
+
expect(aspectToColumnSpan(0)).toBe('one-quarter');
|
|
237
|
+
expect(aspectToColumnSpan(-1)).toBe('one-quarter');
|
|
238
|
+
expect(aspectToColumnSpan(NaN)).toBe('one-quarter');
|
|
239
|
+
expect(aspectToColumnSpan(Infinity)).toBe('one-quarter');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('findMediaChild', () => {
|
|
244
|
+
it('returns the first <img> descendant', () => {
|
|
245
|
+
const root = document.createElement('div');
|
|
246
|
+
root.innerHTML = '<span><img src="a.jpg" /></span>';
|
|
247
|
+
expect(findMediaChild(root)?.tagName).toBe('IMG');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('returns the first <video> descendant', () => {
|
|
251
|
+
const root = document.createElement('div');
|
|
252
|
+
root.innerHTML = '<video></video>';
|
|
253
|
+
expect(findMediaChild(root)?.tagName).toBe('VIDEO');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('returns null when there is no media child', () => {
|
|
257
|
+
const root = document.createElement('div');
|
|
258
|
+
root.innerHTML = '<p>text only</p>';
|
|
259
|
+
expect(findMediaChild(root)).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('readMediaAspect', () => {
|
|
264
|
+
it('reads an image aspect from naturalWidth/naturalHeight', () => {
|
|
265
|
+
const img = document.createElement('img');
|
|
266
|
+
Object.defineProperty(img, 'naturalWidth', { value: 1600, configurable: true });
|
|
267
|
+
Object.defineProperty(img, 'naturalHeight', { value: 900, configurable: true });
|
|
268
|
+
expect(readMediaAspect(img)).toBeCloseTo(16 / 9);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('returns null when image dimensions are not yet resolved', () => {
|
|
272
|
+
const img = document.createElement('img');
|
|
273
|
+
Object.defineProperty(img, 'naturalWidth', { value: 0, configurable: true });
|
|
274
|
+
Object.defineProperty(img, 'naturalHeight', { value: 0, configurable: true });
|
|
275
|
+
expect(readMediaAspect(img)).toBeNull();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('reads a video aspect from videoWidth/videoHeight', () => {
|
|
279
|
+
const video = document.createElement('video');
|
|
280
|
+
Object.defineProperty(video, 'videoWidth', { value: 1920, configurable: true });
|
|
281
|
+
Object.defineProperty(video, 'videoHeight', { value: 1080, configurable: true });
|
|
282
|
+
expect(readMediaAspect(video)).toBeCloseTo(16 / 9);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('mediaReadyEvent', () => {
|
|
287
|
+
it('returns null for an image already complete', () => {
|
|
288
|
+
const img = document.createElement('img');
|
|
289
|
+
Object.defineProperty(img, 'complete', { value: true, configurable: true });
|
|
290
|
+
expect(mediaReadyEvent(img)).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('returns "load" for an image that is not complete', () => {
|
|
294
|
+
const img = document.createElement('img');
|
|
295
|
+
Object.defineProperty(img, 'complete', { value: false, configurable: true });
|
|
296
|
+
expect(mediaReadyEvent(img)).toBe('load');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('returns "loadedmetadata" for a video with readyState < 1', () => {
|
|
300
|
+
const video = document.createElement('video');
|
|
301
|
+
Object.defineProperty(video, 'readyState', { value: 0, configurable: true });
|
|
302
|
+
expect(mediaReadyEvent(video)).toBe('loadedmetadata');
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('GridItem columnSpan="auto"', () => {
|
|
307
|
+
function stubImgAspect(img: HTMLImageElement, w: number, h: number, complete = true) {
|
|
308
|
+
Object.defineProperty(img, 'naturalWidth', { value: w, configurable: true });
|
|
309
|
+
Object.defineProperty(img, 'naturalHeight', { value: h, configurable: true });
|
|
310
|
+
Object.defineProperty(img, 'complete', { value: complete, configurable: true });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
it('derives half span from a 16:9 image', () => {
|
|
314
|
+
const Media = () => {
|
|
315
|
+
const img = <img ref={(el) => el && stubImgAspect(el, 1600, 900)} alt="" /> ;
|
|
316
|
+
return img;
|
|
317
|
+
};
|
|
318
|
+
const { container } = render(
|
|
319
|
+
<GridItem columnSpan="auto"><Media /></GridItem>,
|
|
320
|
+
);
|
|
321
|
+
const el = container.firstChild as HTMLElement;
|
|
322
|
+
expect(el.style.gridColumn).toBe('span var(--bds-grid_columns-50)');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('derives full span from a 32:9 panoramic image', () => {
|
|
326
|
+
const Media = () => <img ref={(el) => el && stubImgAspect(el, 3200, 900)} alt="" />;
|
|
327
|
+
const { container } = render(
|
|
328
|
+
<GridItem columnSpan="auto"><Media /></GridItem>,
|
|
329
|
+
);
|
|
330
|
+
const el = container.firstChild as HTMLElement;
|
|
331
|
+
expect(el.style.gridColumn).toBe('var(--bds-grid_span-100)');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('falls back to one-quarter when no media child is present', () => {
|
|
335
|
+
const { container } = render(<GridItem columnSpan="auto"><span>text</span></GridItem>);
|
|
336
|
+
const el = container.firstChild as HTMLElement;
|
|
337
|
+
expect(el.style.gridColumn).toBe('span var(--bds-grid_columns-25)');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('resolves after the load event when metadata is not yet available', () => {
|
|
341
|
+
const img = document.createElement('img');
|
|
342
|
+
stubImgAspect(img, 0, 0, false);
|
|
343
|
+
const { container } = render(
|
|
344
|
+
<GridItem columnSpan="auto">
|
|
345
|
+
<span ref={(el) => el && el.appendChild(img)} />
|
|
346
|
+
</GridItem>,
|
|
347
|
+
);
|
|
348
|
+
const el = container.firstChild as HTMLElement;
|
|
349
|
+
expect(el.style.gridColumn).toBe('span var(--bds-grid_columns-25)');
|
|
350
|
+
|
|
351
|
+
stubImgAspect(img, 1600, 900, true);
|
|
352
|
+
act(() => {
|
|
353
|
+
img.dispatchEvent(new Event('load'));
|
|
354
|
+
});
|
|
355
|
+
expect(el.style.gridColumn).toBe('span var(--bds-grid_columns-50)');
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe('Grid autoSpanMedia', () => {
|
|
360
|
+
function stubImgAspect(img: HTMLImageElement, w: number, h: number) {
|
|
361
|
+
Object.defineProperty(img, 'naturalWidth', { value: w, configurable: true });
|
|
362
|
+
Object.defineProperty(img, 'naturalHeight', { value: h, configurable: true });
|
|
363
|
+
Object.defineProperty(img, 'complete', { value: true, configurable: true });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
it('applies auto span to items without an explicit columnSpan', () => {
|
|
367
|
+
const { container } = render(
|
|
368
|
+
<Grid autoSpanMedia>
|
|
369
|
+
<GridItem data-testid="a">
|
|
370
|
+
<img ref={(el) => el && stubImgAspect(el, 1600, 900)} alt="" />
|
|
371
|
+
</GridItem>
|
|
372
|
+
</Grid>,
|
|
373
|
+
);
|
|
374
|
+
const a = container.querySelector('[data-testid="a"]') as HTMLElement;
|
|
375
|
+
expect(a.style.gridColumn).toBe('span var(--bds-grid_columns-50)');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('respects an explicit columnSpan when autoSpanMedia is set', () => {
|
|
379
|
+
const { container } = render(
|
|
380
|
+
<Grid autoSpanMedia>
|
|
381
|
+
<GridItem data-testid="a" columnSpan="one-third">
|
|
382
|
+
<img ref={(el) => el && stubImgAspect(el, 1600, 900)} alt="" />
|
|
383
|
+
</GridItem>
|
|
384
|
+
</Grid>,
|
|
385
|
+
);
|
|
386
|
+
const a = container.querySelector('[data-testid="a"]') as HTMLElement;
|
|
387
|
+
expect(a.style.gridColumn).toBe('span var(--bds-grid_columns-33)');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('defaults to full span when autoSpanMedia is off', () => {
|
|
391
|
+
const { container } = render(
|
|
392
|
+
<Grid>
|
|
393
|
+
<GridItem data-testid="a">
|
|
394
|
+
<img ref={(el) => el && stubImgAspect(el, 1600, 900)} alt="" />
|
|
395
|
+
</GridItem>
|
|
396
|
+
</Grid>,
|
|
397
|
+
);
|
|
398
|
+
const a = container.querySelector('[data-testid="a"]') as HTMLElement;
|
|
399
|
+
expect(a.style.gridColumn).toBe('var(--bds-grid_span-100)');
|
|
400
|
+
});
|
|
401
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { Grid } from './Grid';
|
|
3
|
+
import { GridItem } from './GridItem';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'React/Layout/Grid',
|
|
7
|
+
component: Grid,
|
|
8
|
+
argTypes: {
|
|
9
|
+
variant: { control: 'select', options: ['main', 'page', 'funnel', 'custom'] },
|
|
10
|
+
columns: { control: 'number' },
|
|
11
|
+
isCentered: { control: 'boolean' },
|
|
12
|
+
isMasonry: { control: 'boolean' },
|
|
13
|
+
},
|
|
14
|
+
} satisfies Meta<typeof Grid>;
|
|
15
|
+
|
|
16
|
+
export default meta;
|
|
17
|
+
type Story = StoryObj<typeof meta>;
|
|
18
|
+
|
|
19
|
+
const demoStyle = {
|
|
20
|
+
background: 'var(--bds-color_bg-subtle, #eef)',
|
|
21
|
+
padding: 'var(--bds-space_m, 16px)',
|
|
22
|
+
borderRadius: 'var(--bds-border_radius--s, 8px)',
|
|
23
|
+
textAlign: 'center' as const,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const Main: Story = {
|
|
27
|
+
args: { variant: 'main' },
|
|
28
|
+
render: (args) => (
|
|
29
|
+
<Grid {...args}>
|
|
30
|
+
<GridItem columnSpan="half" style={demoStyle}>half</GridItem>
|
|
31
|
+
<GridItem columnSpan="one-quarter" style={demoStyle}>¼</GridItem>
|
|
32
|
+
<GridItem columnSpan="one-quarter" style={demoStyle}>¼</GridItem>
|
|
33
|
+
</Grid>
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const Page: Story = {
|
|
38
|
+
args: { variant: 'page' },
|
|
39
|
+
render: (args) => (
|
|
40
|
+
<Grid {...args}>
|
|
41
|
+
<GridItem style={demoStyle}>First</GridItem>
|
|
42
|
+
<GridItem style={demoStyle}>Second</GridItem>
|
|
43
|
+
</Grid>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const Funnel: Story = {
|
|
48
|
+
args: { variant: 'funnel' },
|
|
49
|
+
render: (args) => (
|
|
50
|
+
<Grid {...args}>
|
|
51
|
+
<GridItem style={demoStyle}>Funnel content</GridItem>
|
|
52
|
+
<GridItem columnSpan="half" style={demoStyle}>Half</GridItem>
|
|
53
|
+
<GridItem columnSpan="half" style={demoStyle}>Half</GridItem>
|
|
54
|
+
</Grid>
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Custom: Story = {
|
|
59
|
+
args: { variant: 'custom', columns: 5 },
|
|
60
|
+
render: (args) => (
|
|
61
|
+
<Grid {...args}>
|
|
62
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
63
|
+
<GridItem key={i} columnSpan={1} style={demoStyle}>Col {i + 1}</GridItem>
|
|
64
|
+
))}
|
|
65
|
+
</Grid>
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const Centered: Story = {
|
|
70
|
+
args: { isCentered: true },
|
|
71
|
+
render: (args) => (
|
|
72
|
+
<Grid {...args}>
|
|
73
|
+
<GridItem style={demoStyle}>Centered grid</GridItem>
|
|
74
|
+
<GridItem columnSpan="half" style={demoStyle}>Half</GridItem>
|
|
75
|
+
<GridItem columnSpan="half" style={demoStyle}>Half</GridItem>
|
|
76
|
+
</Grid>
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const Spans: Story = {
|
|
81
|
+
args: {},
|
|
82
|
+
render: () => (
|
|
83
|
+
<Grid>
|
|
84
|
+
<GridItem columnSpan="one-quarter" style={demoStyle}>one-quarter (25%)</GridItem>
|
|
85
|
+
<GridItem columnSpan="three-quarters" style={demoStyle}>three-quarters (75%)</GridItem>
|
|
86
|
+
<GridItem columnSpan="one-third" style={demoStyle}>one-third (33%)</GridItem>
|
|
87
|
+
<GridItem columnSpan="two-thirds" style={demoStyle}>two-thirds (66%)</GridItem>
|
|
88
|
+
<GridItem columnSpan="half" style={demoStyle}>half (50%)</GridItem>
|
|
89
|
+
<GridItem columnSpan="half" style={demoStyle}>half (50%)</GridItem>
|
|
90
|
+
<GridItem columnSpan="full" style={demoStyle}>full (100%)</GridItem>
|
|
91
|
+
</Grid>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const StartEnd: Story = {
|
|
96
|
+
args: { variant: 'custom', columns: 6 },
|
|
97
|
+
render: (args) => (
|
|
98
|
+
<Grid {...args}>
|
|
99
|
+
<GridItem startColumn={2} endColumn={6} style={demoStyle}>
|
|
100
|
+
startColumn=2, endColumn=6 (escape hatch — use with care)
|
|
101
|
+
</GridItem>
|
|
102
|
+
</Grid>
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Pseudo-random but stable per-index heights so masonry demos don't shift every render.
|
|
107
|
+
const masonryHeights = [120, 180, 90, 220, 150, 200, 110, 170, 240, 130, 160, 190];
|
|
108
|
+
const masonryCellStyle = (index: number) => ({
|
|
109
|
+
...demoStyle,
|
|
110
|
+
blockSize: `${masonryHeights[index % masonryHeights.length]}px`,
|
|
111
|
+
display: 'flex',
|
|
112
|
+
alignItems: 'center',
|
|
113
|
+
justifyContent: 'center',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const Masonry: Story = {
|
|
117
|
+
args: { isMasonry: true },
|
|
118
|
+
render: (args) => (
|
|
119
|
+
<Grid {...args}>
|
|
120
|
+
{Array.from({ length: 12 }).map((_, i) => (
|
|
121
|
+
<GridItem key={i} columnSpan="one-quarter" style={masonryCellStyle(i)}>
|
|
122
|
+
Item {i + 1}
|
|
123
|
+
</GridItem>
|
|
124
|
+
))}
|
|
125
|
+
</Grid>
|
|
126
|
+
),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const MasonryWithSpans: Story = {
|
|
130
|
+
args: { isMasonry: true },
|
|
131
|
+
render: (args) => (
|
|
132
|
+
<Grid {...args}>
|
|
133
|
+
<GridItem columnSpan="half" style={masonryCellStyle(0)}>Spans half</GridItem>
|
|
134
|
+
{Array.from({ length: 10 }).map((_, i) => (
|
|
135
|
+
<GridItem key={i} columnSpan="one-quarter" style={masonryCellStyle(i + 1)}>
|
|
136
|
+
Item {i + 1}
|
|
137
|
+
</GridItem>
|
|
138
|
+
))}
|
|
139
|
+
</Grid>
|
|
140
|
+
),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const ComplexLayout: Story = {
|
|
144
|
+
args: {},
|
|
145
|
+
render: () => (
|
|
146
|
+
<Grid>
|
|
147
|
+
<GridItem columnSpan="one-quarter" style={demoStyle}>¼</GridItem>
|
|
148
|
+
<GridItem columnSpan="three-quarters" style={demoStyle}>¾</GridItem>
|
|
149
|
+
<GridItem columnSpan="one-third" style={demoStyle}>⅓</GridItem>
|
|
150
|
+
<GridItem columnSpan="two-thirds" style={demoStyle}>⅔</GridItem>
|
|
151
|
+
<GridItem columnSpan="half" style={demoStyle}>½</GridItem>
|
|
152
|
+
<GridItem columnSpan="half" style={demoStyle}>½</GridItem>
|
|
153
|
+
<GridItem columnSpan="full" style={demoStyle}>full row</GridItem>
|
|
154
|
+
<GridItem columnSpan="one-quarter" style={demoStyle}>¼</GridItem>
|
|
155
|
+
<GridItem columnSpan="one-quarter" style={demoStyle}>¼</GridItem>
|
|
156
|
+
<GridItem columnSpan="one-quarter" style={demoStyle}>¼</GridItem>
|
|
157
|
+
<GridItem columnSpan="one-quarter" style={demoStyle}>¼</GridItem>
|
|
158
|
+
</Grid>
|
|
159
|
+
),
|
|
160
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ElementType,
|
|
3
|
+
ComponentPropsWithoutRef,
|
|
4
|
+
CSSProperties,
|
|
5
|
+
createContext,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import css from './Grid.module.css';
|
|
10
|
+
import { cn } from '@boostdev/design-system-foundation';
|
|
11
|
+
import type { WithClassName } from '../../../types';
|
|
12
|
+
import { useMasonry } from './masonry';
|
|
13
|
+
|
|
14
|
+
export type GridVariant = 'main' | 'page' | 'funnel' | 'custom';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Context used by `GridItem` to pick up Grid-level defaults such as
|
|
18
|
+
* `autoSpanMedia`. Exposed as a context (not a prop) so consumers can nest
|
|
19
|
+
* arbitrary wrappers between `<Grid>` and its `<GridItem>` children without
|
|
20
|
+
* losing the defaulting behavior.
|
|
21
|
+
*/
|
|
22
|
+
export type GridContextValue = {
|
|
23
|
+
autoSpanMedia: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const GridContext = createContext<GridContextValue>({
|
|
27
|
+
autoSpanMedia: false,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
type GridOwnProps = WithClassName & {
|
|
31
|
+
variant?: GridVariant;
|
|
32
|
+
columns?: number;
|
|
33
|
+
isCentered?: boolean;
|
|
34
|
+
isMasonry?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* When true, any child `GridItem` without an explicit `columnSpan` resolves
|
|
37
|
+
* to `"auto"` — column span is derived from the intrinsic aspect ratio of
|
|
38
|
+
* the first `<img>`/`<video>` child. Items that set `columnSpan` explicitly
|
|
39
|
+
* are unaffected.
|
|
40
|
+
*/
|
|
41
|
+
autoSpanMedia?: boolean;
|
|
42
|
+
as?: ElementType;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type GridProps<C extends ElementType = 'div'> = GridOwnProps &
|
|
46
|
+
Omit<ComponentPropsWithoutRef<C>, keyof GridOwnProps>;
|
|
47
|
+
|
|
48
|
+
export function Grid<C extends ElementType = 'div'>({
|
|
49
|
+
children,
|
|
50
|
+
className,
|
|
51
|
+
variant = 'main',
|
|
52
|
+
columns,
|
|
53
|
+
isCentered = false,
|
|
54
|
+
isMasonry = false,
|
|
55
|
+
autoSpanMedia = false,
|
|
56
|
+
as,
|
|
57
|
+
style,
|
|
58
|
+
...rest
|
|
59
|
+
}: GridProps<C>) {
|
|
60
|
+
const ref = useRef<HTMLElement>(null);
|
|
61
|
+
useMasonry(ref, isMasonry);
|
|
62
|
+
|
|
63
|
+
const classNames = cn(
|
|
64
|
+
css.grid,
|
|
65
|
+
css[`--${variant}`],
|
|
66
|
+
isCentered && css['--centered'],
|
|
67
|
+
isMasonry && css['--masonry'],
|
|
68
|
+
className,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const composedStyle: CSSProperties =
|
|
72
|
+
variant === 'custom' && columns !== undefined
|
|
73
|
+
? { ...(style as CSSProperties), ['--grid_columns' as string]: columns }
|
|
74
|
+
: (style as CSSProperties);
|
|
75
|
+
|
|
76
|
+
const Component = as ?? 'div';
|
|
77
|
+
|
|
78
|
+
const ctx = useMemo<GridContextValue>(() => ({ autoSpanMedia }), [autoSpanMedia]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Component ref={ref} {...rest} className={classNames} style={composedStyle}>
|
|
82
|
+
<GridContext.Provider value={ctx}>{children}</GridContext.Provider>
|
|
83
|
+
</Component>
|
|
84
|
+
);
|
|
85
|
+
}
|