@boostdev/design-system-components 2.1.0 → 2.2.1

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.
Files changed (34) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +40 -0
  3. package/dist/client.cjs +308 -60
  4. package/dist/client.css +568 -527
  5. package/dist/client.d.cts +39 -1
  6. package/dist/client.d.ts +39 -1
  7. package/dist/client.js +314 -60
  8. package/dist/index.cjs +308 -60
  9. package/dist/index.css +568 -527
  10. package/dist/index.d.cts +39 -1
  11. package/dist/index.d.ts +39 -1
  12. package/dist/index.js +314 -60
  13. package/dist/web-components/index.d.ts +129 -1
  14. package/dist/web-components/index.js +313 -0
  15. package/package.json +1 -1
  16. package/src/components/layout/Grid/Grid.mdx +244 -0
  17. package/src/components/layout/Grid/Grid.module.css +59 -0
  18. package/src/components/layout/Grid/Grid.spec.tsx +415 -0
  19. package/src/components/layout/Grid/Grid.stories.tsx +160 -0
  20. package/src/components/layout/Grid/Grid.tsx +92 -0
  21. package/src/components/layout/Grid/GridItem.tsx +150 -0
  22. package/src/components/layout/Grid/autoSpan.ts +77 -0
  23. package/src/components/layout/Grid/index.ts +4 -0
  24. package/src/components/layout/Grid/masonry.ts +139 -0
  25. package/src/index.ts +2 -0
  26. package/src/web-components/index.ts +4 -0
  27. package/src/web-components/layout/BdsGrid.mdx +210 -0
  28. package/src/web-components/layout/BdsGrid.stories.tsx +209 -0
  29. package/src/web-components/layout/BdsGridItem.mdx +52 -0
  30. package/src/web-components/layout/BdsGridItem.stories.tsx +72 -0
  31. package/src/web-components/layout/bds-grid-item.spec.ts +102 -0
  32. package/src/web-components/layout/bds-grid-item.ts +177 -0
  33. package/src/web-components/layout/bds-grid.spec.ts +62 -0
  34. package/src/web-components/layout/bds-grid.ts +184 -0
@@ -0,0 +1,209 @@
1
+ import React from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import type { GridVariant } from './bds-grid';
4
+ import type { GridItemSpanValue } from './bds-grid-item';
5
+ import '../index'; // auto-registers all custom elements
6
+
7
+ function BdsGrid({
8
+ variant = 'main',
9
+ columns,
10
+ isCentered,
11
+ isMasonry,
12
+ children,
13
+ }: {
14
+ variant?: GridVariant;
15
+ columns?: number;
16
+ isCentered?: boolean;
17
+ isMasonry?: boolean;
18
+ children?: React.ReactNode;
19
+ }) {
20
+ return React.createElement(
21
+ 'bds-grid',
22
+ {
23
+ variant,
24
+ columns,
25
+ 'is-centered': isCentered || undefined,
26
+ 'is-masonry': isMasonry || undefined,
27
+ },
28
+ children,
29
+ );
30
+ }
31
+
32
+ function Item({
33
+ columnSpan,
34
+ startColumn,
35
+ endColumn,
36
+ rowSpan,
37
+ children,
38
+ }: {
39
+ columnSpan?: GridItemSpanValue;
40
+ startColumn?: number;
41
+ endColumn?: number;
42
+ rowSpan?: number;
43
+ children?: React.ReactNode;
44
+ }) {
45
+ return React.createElement(
46
+ 'bds-grid-item',
47
+ {
48
+ 'column-span': columnSpan ?? 'full',
49
+ 'start-column': startColumn,
50
+ 'end-column': endColumn,
51
+ 'row-span': rowSpan,
52
+ },
53
+ children,
54
+ );
55
+ }
56
+
57
+ const cellStyle = {
58
+ background: 'var(--bds-color_bg-subtle, #eef)',
59
+ padding: 'var(--bds-space_m, 16px)',
60
+ borderRadius: 'var(--bds-border_radius--s, 8px)',
61
+ textAlign: 'center' as const,
62
+ };
63
+
64
+ const meta = {
65
+ title: 'Web Components/Layout/Grid',
66
+ component: BdsGrid,
67
+ tags: ['!stable', 'experimental'],
68
+ parameters: { layout: 'padded' },
69
+ argTypes: {
70
+ variant: { control: 'select', options: ['main', 'page', 'funnel', 'custom'] },
71
+ columns: { control: 'number' },
72
+ isCentered: { control: 'boolean' },
73
+ isMasonry: { control: 'boolean' },
74
+ },
75
+ } satisfies Meta<typeof BdsGrid>;
76
+
77
+ export default meta;
78
+ type Story = StoryObj<typeof meta>;
79
+
80
+ export const Main: Story = {
81
+ args: { variant: 'main' },
82
+ render: (args) => (
83
+ <BdsGrid {...args}>
84
+ <Item columnSpan="half"><div style={cellStyle}>half</div></Item>
85
+ <Item columnSpan="one-quarter"><div style={cellStyle}>¼</div></Item>
86
+ <Item columnSpan="one-quarter"><div style={cellStyle}>¼</div></Item>
87
+ </BdsGrid>
88
+ ),
89
+ };
90
+
91
+ export const Page: Story = {
92
+ args: { variant: 'page' },
93
+ render: (args) => (
94
+ <BdsGrid {...args}>
95
+ <Item><div style={cellStyle}>First</div></Item>
96
+ <Item><div style={cellStyle}>Second</div></Item>
97
+ </BdsGrid>
98
+ ),
99
+ };
100
+
101
+ export const Spans: Story = {
102
+ args: {},
103
+ render: () => (
104
+ <BdsGrid>
105
+ <Item columnSpan="one-quarter"><div style={cellStyle}>one-quarter</div></Item>
106
+ <Item columnSpan="three-quarters"><div style={cellStyle}>three-quarters</div></Item>
107
+ <Item columnSpan="one-third"><div style={cellStyle}>one-third</div></Item>
108
+ <Item columnSpan="two-thirds"><div style={cellStyle}>two-thirds</div></Item>
109
+ <Item columnSpan="half"><div style={cellStyle}>half</div></Item>
110
+ <Item columnSpan="half"><div style={cellStyle}>half</div></Item>
111
+ <Item columnSpan="full"><div style={cellStyle}>full</div></Item>
112
+ </BdsGrid>
113
+ ),
114
+ };
115
+
116
+ export const Funnel: Story = {
117
+ args: { variant: 'funnel' },
118
+ render: (args) => (
119
+ <BdsGrid {...args}>
120
+ <Item><div style={cellStyle}>Funnel content</div></Item>
121
+ <Item columnSpan="half"><div style={cellStyle}>Half</div></Item>
122
+ <Item columnSpan="half"><div style={cellStyle}>Half</div></Item>
123
+ </BdsGrid>
124
+ ),
125
+ };
126
+
127
+ export const Custom: Story = {
128
+ args: { variant: 'custom', columns: 5 },
129
+ render: (args) => (
130
+ <BdsGrid {...args}>
131
+ {Array.from({ length: 5 }).map((_, i) => (
132
+ <Item key={i} columnSpan={1}><div style={cellStyle}>Col {i + 1}</div></Item>
133
+ ))}
134
+ </BdsGrid>
135
+ ),
136
+ };
137
+
138
+ export const Centered: Story = {
139
+ args: { isCentered: true },
140
+ render: (args) => (
141
+ <BdsGrid {...args}>
142
+ <Item><div style={cellStyle}>Centered</div></Item>
143
+ <Item columnSpan="half"><div style={cellStyle}>Half</div></Item>
144
+ <Item columnSpan="half"><div style={cellStyle}>Half</div></Item>
145
+ </BdsGrid>
146
+ ),
147
+ };
148
+
149
+ export const StartEnd: Story = {
150
+ args: { variant: 'custom', columns: 6 },
151
+ render: (args) => (
152
+ <BdsGrid {...args}>
153
+ <Item startColumn={2} endColumn={6}>
154
+ <div style={cellStyle}>startColumn=2, endColumn=6 (escape hatch — use with care)</div>
155
+ </Item>
156
+ </BdsGrid>
157
+ ),
158
+ };
159
+
160
+ const masonryHeights = [120, 180, 90, 220, 150, 200, 110, 170, 240, 130, 160, 190];
161
+ const masonryCellStyle = (i: number) => ({
162
+ ...cellStyle,
163
+ blockSize: `${masonryHeights[i % masonryHeights.length]}px`,
164
+ display: 'flex',
165
+ alignItems: 'center',
166
+ justifyContent: 'center',
167
+ });
168
+
169
+ export const Masonry: Story = {
170
+ args: { isMasonry: true },
171
+ render: (args) => (
172
+ <BdsGrid {...args}>
173
+ {Array.from({ length: 12 }).map((_, i) => (
174
+ <Item key={i} columnSpan="one-quarter"><div style={masonryCellStyle(i)}>Item {i + 1}</div></Item>
175
+ ))}
176
+ </BdsGrid>
177
+ ),
178
+ };
179
+
180
+ export const MasonryWithSpans: Story = {
181
+ args: { isMasonry: true },
182
+ render: (args) => (
183
+ <BdsGrid {...args}>
184
+ <Item columnSpan="half"><div style={masonryCellStyle(0)}>Spans half</div></Item>
185
+ {Array.from({ length: 10 }).map((_, i) => (
186
+ <Item key={i} columnSpan="one-quarter"><div style={masonryCellStyle(i + 1)}>Item {i + 1}</div></Item>
187
+ ))}
188
+ </BdsGrid>
189
+ ),
190
+ };
191
+
192
+ export const ComplexLayout: Story = {
193
+ args: {},
194
+ render: () => (
195
+ <BdsGrid>
196
+ <Item columnSpan="one-quarter"><div style={cellStyle}>¼</div></Item>
197
+ <Item columnSpan="three-quarters"><div style={cellStyle}>¾</div></Item>
198
+ <Item columnSpan="one-third"><div style={cellStyle}>⅓</div></Item>
199
+ <Item columnSpan="two-thirds"><div style={cellStyle}>⅔</div></Item>
200
+ <Item columnSpan="half"><div style={cellStyle}>½</div></Item>
201
+ <Item columnSpan="half"><div style={cellStyle}>½</div></Item>
202
+ <Item columnSpan="full"><div style={cellStyle}>full row</div></Item>
203
+ <Item columnSpan="one-quarter"><div style={cellStyle}>¼</div></Item>
204
+ <Item columnSpan="one-quarter"><div style={cellStyle}>¼</div></Item>
205
+ <Item columnSpan="one-quarter"><div style={cellStyle}>¼</div></Item>
206
+ <Item columnSpan="one-quarter"><div style={cellStyle}>¼</div></Item>
207
+ </BdsGrid>
208
+ ),
209
+ };
@@ -0,0 +1,52 @@
1
+ import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
2
+ import * as Stories from './BdsGridItem.stories';
3
+
4
+ <Meta of={Stories} />
5
+
6
+ # &lt;bds-grid-item&gt;
7
+
8
+ Grid cell for `<bds-grid>`. Sets `grid-column` / `grid-row` on its host element using the foundation span tokens.
9
+
10
+ > **Status: experimental** — API may change before stable release.
11
+
12
+ ## Examples
13
+
14
+ ### Inside a grid
15
+ <Canvas of={Stories.InsideGrid} />
16
+
17
+ ### Start / end (escape hatch)
18
+ <Canvas of={Stories.StartEnd} />
19
+
20
+ ## Props
21
+
22
+ <ArgTypes of={Stories} />
23
+
24
+ ## Attributes
25
+
26
+ | Attribute | Type | Default | Description |
27
+ | --------------- | ------------------------------------------------------------------------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
28
+ | `column-span` | `"full"` <br /> \| `"three-quarters"` <br /> \| `"two-thirds"` <br /> \| `"half"` <br /> \| `"one-third"` <br /> \| `"one-quarter"` <br /> \| `"auto"` <br /> \| `number` | `"full"` | How many grid columns to span. `"auto"` derives the span from the intrinsic aspect ratio of the first `<img>`/`<video>` descendant — see [Aspect-ratio auto-span](?path=/docs/web-components-layout-grid--docs#aspect-ratio-auto-span). |
29
+ | `row-span` | number | — | How many rows to span. Ignored in masonry layouts. |
30
+ | `start-column` | number | — | **Escape hatch** — explicit start column (overrides `column-span`) |
31
+ | `end-column` | number | — | **Escape hatch** — explicit end column (overrides `column-span`) |
32
+
33
+ When the parent `<bds-grid>` sets `auto-span-media`, items without an explicit `column-span` behave as if `column-span="auto"`.
34
+
35
+ ### Warning: start-column / end-column
36
+
37
+ These override the responsive span tokens and force fixed positioning. Only reach for them when you fully understand the layout implications. Prefer named `column-span` values.
38
+
39
+ ## Slots
40
+
41
+ | Slot | Description |
42
+ | ----------- | ----------- |
43
+ | `(default)` | Cell content |
44
+
45
+ ## Usage in plain HTML
46
+
47
+ ```html
48
+ <bds-grid variant="main">
49
+ <bds-grid-item column-span="three-quarters">Wide</bds-grid-item>
50
+ <bds-grid-item column-span="one-quarter">Narrow</bds-grid-item>
51
+ </bds-grid>
52
+ ```
@@ -0,0 +1,72 @@
1
+ import React from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import type { GridItemSpanValue } from './bds-grid-item';
4
+ import '../index';
5
+
6
+ function BdsGridItem({
7
+ columnSpan = 'full',
8
+ rowSpan,
9
+ startColumn,
10
+ endColumn,
11
+ children,
12
+ }: {
13
+ columnSpan?: GridItemSpanValue;
14
+ rowSpan?: number;
15
+ startColumn?: number;
16
+ endColumn?: number;
17
+ children?: React.ReactNode;
18
+ }) {
19
+ return React.createElement(
20
+ 'bds-grid-item',
21
+ {
22
+ 'column-span': columnSpan,
23
+ 'row-span': rowSpan,
24
+ 'start-column': startColumn,
25
+ 'end-column': endColumn,
26
+ },
27
+ children,
28
+ );
29
+ }
30
+
31
+ const cellStyle = {
32
+ background: 'var(--bds-color_bg-subtle, #eef)',
33
+ padding: 'var(--bds-space_m, 16px)',
34
+ borderRadius: 'var(--bds-border_radius--s, 8px)',
35
+ textAlign: 'center' as const,
36
+ };
37
+
38
+ const meta = {
39
+ title: 'Web Components/Layout/GridItem',
40
+ component: BdsGridItem,
41
+ tags: ['!stable', 'experimental'],
42
+ parameters: { layout: 'padded' },
43
+ argTypes: {
44
+ columnSpan: {
45
+ control: 'select',
46
+ options: ['full', 'three-quarters', 'two-thirds', 'half', 'one-third', 'one-quarter'],
47
+ },
48
+ rowSpan: { control: 'number' },
49
+ startColumn: { control: 'number' },
50
+ endColumn: { control: 'number' },
51
+ },
52
+ } satisfies Meta<typeof BdsGridItem>;
53
+
54
+ export default meta;
55
+ type Story = StoryObj<typeof meta>;
56
+
57
+ export const InsideGrid: Story = {
58
+ args: { columnSpan: 'half' },
59
+ render: (args) =>
60
+ React.createElement('bds-grid', { variant: 'main' },
61
+ React.createElement(BdsGridItem, args, <div style={cellStyle}>half</div>),
62
+ React.createElement(BdsGridItem, { columnSpan: 'half' }, <div style={cellStyle}>half</div>),
63
+ ),
64
+ };
65
+
66
+ export const StartEnd: Story = {
67
+ args: { startColumn: 2, endColumn: 6 },
68
+ render: (args) =>
69
+ React.createElement('bds-grid', { variant: 'custom', columns: 6 },
70
+ React.createElement(BdsGridItem, args, <div style={cellStyle}>escape hatch</div>),
71
+ ),
72
+ };
@@ -0,0 +1,102 @@
1
+ import { fixture, cleanup } from '../test/helpers';
2
+ import { BdsGridItem } from './bds-grid-item';
3
+ import './bds-grid';
4
+
5
+ describe('bds-grid-item', () => {
6
+ it('is registered as a custom element', () => {
7
+ expect(customElements.get('bds-grid-item')).toBe(BdsGridItem);
8
+ });
9
+
10
+ it('defaults to full span', async () => {
11
+ const el = await fixture('<bds-grid-item></bds-grid-item>') as BdsGridItem;
12
+ expect(el.style.gridColumn).toBe('var(--bds-grid_span-100)');
13
+ cleanup(el);
14
+ });
15
+
16
+ it('maps named spans to span tokens', async () => {
17
+ const cases: Array<[string, string]> = [
18
+ ['three-quarters', 'span var(--bds-grid_columns-75)'],
19
+ ['two-thirds', 'span var(--bds-grid_columns-66)'],
20
+ ['half', 'span var(--bds-grid_columns-50)'],
21
+ ['one-third', 'span var(--bds-grid_columns-33)'],
22
+ ['one-quarter', 'span var(--bds-grid_columns-25)'],
23
+ ];
24
+ for (const [input, expected] of cases) {
25
+ const el = await fixture(`<bds-grid-item column-span="${input}"></bds-grid-item>`);
26
+ expect((el as HTMLElement).style.gridColumn).toBe(expected);
27
+ cleanup(el);
28
+ }
29
+ });
30
+
31
+ it('accepts numeric column-span attribute', async () => {
32
+ const el = await fixture('<bds-grid-item column-span="3"></bds-grid-item>');
33
+ expect((el as HTMLElement).style.gridColumn).toBe('span 3');
34
+ cleanup(el);
35
+ });
36
+
37
+ it('applies row-span', async () => {
38
+ const el = await fixture('<bds-grid-item row-span="2"></bds-grid-item>');
39
+ expect((el as HTMLElement).style.gridRow).toBe('span 2');
40
+ cleanup(el);
41
+ });
42
+
43
+ it('start-column and end-column override column-span', async () => {
44
+ const el = await fixture(
45
+ '<bds-grid-item column-span="half" start-column="2" end-column="5"></bds-grid-item>',
46
+ );
47
+ expect((el as HTMLElement).style.gridColumn).toBe('2 / 5');
48
+ cleanup(el);
49
+ });
50
+
51
+ it('renders slotted children', async () => {
52
+ const el = await fixture('<bds-grid-item>cell</bds-grid-item>');
53
+ expect(el.textContent?.trim()).toBe('cell');
54
+ cleanup(el);
55
+ });
56
+
57
+ it('resolves column-span="auto" from a 16:9 image', async () => {
58
+ const el = (await fixture(
59
+ '<bds-grid-item column-span="auto"><img alt=""></bds-grid-item>',
60
+ )) as BdsGridItem;
61
+ const img = el.querySelector('img') as HTMLImageElement;
62
+ Object.defineProperty(img, 'naturalWidth', { value: 1600, configurable: true });
63
+ Object.defineProperty(img, 'naturalHeight', { value: 900, configurable: true });
64
+ Object.defineProperty(img, 'complete', { value: true, configurable: true });
65
+ el.requestUpdate();
66
+ await el.updateComplete;
67
+ expect((el as HTMLElement).style.gridColumn).toBe('span var(--bds-grid_columns-50)');
68
+ cleanup(el);
69
+ });
70
+
71
+ it('falls back to one-quarter for column-span="auto" without media', async () => {
72
+ const el = (await fixture(
73
+ '<bds-grid-item column-span="auto"><p>text</p></bds-grid-item>',
74
+ )) as BdsGridItem;
75
+ expect((el as HTMLElement).style.gridColumn).toBe('span var(--bds-grid_columns-25)');
76
+ cleanup(el);
77
+ });
78
+
79
+ it('inherits auto-span from parent bds-grid[auto-span-media]', async () => {
80
+ const grid = (await fixture(
81
+ '<bds-grid auto-span-media><bds-grid-item><img alt=""></bds-grid-item></bds-grid>',
82
+ )) as HTMLElement;
83
+ const item = grid.querySelector('bds-grid-item') as BdsGridItem;
84
+ const img = item.querySelector('img') as HTMLImageElement;
85
+ Object.defineProperty(img, 'naturalWidth', { value: 3200, configurable: true });
86
+ Object.defineProperty(img, 'naturalHeight', { value: 900, configurable: true });
87
+ Object.defineProperty(img, 'complete', { value: true, configurable: true });
88
+ item.requestUpdate();
89
+ await item.updateComplete;
90
+ expect(item.style.gridColumn).toBe('var(--bds-grid_span-100)');
91
+ cleanup(grid);
92
+ });
93
+
94
+ it('an explicit column-span wins over parent auto-span-media', async () => {
95
+ const grid = (await fixture(
96
+ '<bds-grid auto-span-media><bds-grid-item column-span="one-third"><img alt=""></bds-grid-item></bds-grid>',
97
+ )) as HTMLElement;
98
+ const item = grid.querySelector('bds-grid-item') as BdsGridItem;
99
+ expect(item.style.gridColumn).toBe('span var(--bds-grid_columns-33)');
100
+ cleanup(grid);
101
+ });
102
+ });
@@ -0,0 +1,177 @@
1
+ import { LitElement, css, html } from 'lit';
2
+ import {
3
+ aspectToColumnSpan,
4
+ findMediaChild,
5
+ mediaReadyEvent,
6
+ readMediaAspect,
7
+ type AutoSpanResult,
8
+ } from '../../components/layout/Grid/autoSpan';
9
+
10
+ export type GridItemSpanValue =
11
+ | 'full'
12
+ | 'three-quarters'
13
+ | 'two-thirds'
14
+ | 'half'
15
+ | 'one-third'
16
+ | 'one-quarter'
17
+ | 'auto'
18
+ | number;
19
+
20
+ const SPAN_VAR: Record<Exclude<GridItemSpanValue, number | 'auto'>, string> = {
21
+ full: 'var(--bds-grid_span-100)',
22
+ 'three-quarters': 'span var(--bds-grid_columns-75)',
23
+ 'two-thirds': 'span var(--bds-grid_columns-66)',
24
+ half: 'span var(--bds-grid_columns-50)',
25
+ 'one-third': 'span var(--bds-grid_columns-33)',
26
+ 'one-quarter': 'span var(--bds-grid_columns-25)',
27
+ };
28
+
29
+ function resolveSpan(value: Exclude<GridItemSpanValue, 'auto'> | undefined): string {
30
+ if (value === undefined || value === null) return SPAN_VAR.full;
31
+ if (typeof value === 'number') return `span ${value}`;
32
+ if (!(value in SPAN_VAR)) {
33
+ const n = Number(value);
34
+ if (Number.isFinite(n)) return `span ${n}`;
35
+ return SPAN_VAR.full;
36
+ }
37
+ return SPAN_VAR[value];
38
+ }
39
+
40
+ /**
41
+ * `<bds-grid-item>` — grid cell for `<bds-grid>`.
42
+ *
43
+ * Attributes:
44
+ * column-span — 'full' | 'three-quarters' | 'two-thirds' | 'half' | 'one-third' | 'one-quarter' | 'auto' | number
45
+ * `'auto'` derives the span from the intrinsic aspect ratio of the first
46
+ * `<img>` or `<video>` child (see Grid.mdx for thresholds).
47
+ * If the parent `<bds-grid>` sets `auto-span-media` and this element has
48
+ * no `column-span`, the item behaves as if `column-span="auto"`.
49
+ * row-span — number
50
+ * start-column — number (escape hatch; overrides column-span)
51
+ * end-column — number (escape hatch; overrides column-span)
52
+ *
53
+ * @example
54
+ * <bds-grid-item column-span="three-quarters">Content</bds-grid-item>
55
+ */
56
+ export class BdsGridItem extends LitElement {
57
+ static styles = css`
58
+ :host {
59
+ display: block;
60
+ min-inline-size: 0;
61
+ }
62
+ `;
63
+
64
+ static properties = {
65
+ columnSpan: { attribute: 'column-span' },
66
+ rowSpan: { attribute: 'row-span', type: Number },
67
+ startColumn: { attribute: 'start-column', type: Number },
68
+ endColumn: { attribute: 'end-column', type: Number },
69
+ };
70
+
71
+ declare columnSpan: GridItemSpanValue | undefined;
72
+ declare rowSpan: number | undefined;
73
+ declare startColumn: number | undefined;
74
+ declare endColumn: number | undefined;
75
+
76
+ private _autoSpan: AutoSpanResult = 'one-quarter';
77
+ private _mediaCleanup?: () => void;
78
+ private _slotObserver?: MutationObserver;
79
+
80
+ constructor() {
81
+ super();
82
+ this.columnSpan = undefined;
83
+ this.rowSpan = undefined;
84
+ this.startColumn = undefined;
85
+ this.endColumn = undefined;
86
+ }
87
+
88
+ connectedCallback(): void {
89
+ super.connectedCallback();
90
+ if (typeof MutationObserver !== 'undefined') {
91
+ this._slotObserver = new MutationObserver(() => this._resolveAutoSpan());
92
+ this._slotObserver.observe(this, { childList: true, subtree: true });
93
+ }
94
+ }
95
+
96
+ disconnectedCallback(): void {
97
+ super.disconnectedCallback();
98
+ this._teardownMedia();
99
+ this._slotObserver?.disconnect();
100
+ this._slotObserver = undefined;
101
+ }
102
+
103
+ private _effectiveColumnSpan(): GridItemSpanValue {
104
+ if (this.columnSpan !== undefined && this.columnSpan !== null) return this.columnSpan;
105
+ const parent = this.closest('bds-grid') as
106
+ | (HTMLElement & { autoSpanMedia?: boolean })
107
+ | null;
108
+ if (parent?.autoSpanMedia) return 'auto';
109
+ return 'full';
110
+ }
111
+
112
+ private _teardownMedia() {
113
+ this._mediaCleanup?.();
114
+ this._mediaCleanup = undefined;
115
+ }
116
+
117
+ private _resolveAutoSpan() {
118
+ this._teardownMedia();
119
+ if (this._effectiveColumnSpan() !== 'auto') return;
120
+
121
+ const media = findMediaChild(this);
122
+ if (!media) {
123
+ this._setAutoSpan('one-quarter');
124
+ return;
125
+ }
126
+ const aspect = readMediaAspect(media);
127
+ if (aspect !== null) {
128
+ this._setAutoSpan(aspectToColumnSpan(aspect));
129
+ return;
130
+ }
131
+ const event = mediaReadyEvent(media);
132
+ if (!event) {
133
+ this._setAutoSpan('one-quarter');
134
+ return;
135
+ }
136
+ const handler = () => {
137
+ const a = readMediaAspect(media);
138
+ if (a !== null) this._setAutoSpan(aspectToColumnSpan(a));
139
+ };
140
+ media.addEventListener(event, handler, { once: true });
141
+ this._mediaCleanup = () => media.removeEventListener(event, handler);
142
+ }
143
+
144
+ private _setAutoSpan(next: AutoSpanResult) {
145
+ if (this._autoSpan === next) return;
146
+ this._autoSpan = next;
147
+ this.requestUpdate();
148
+ }
149
+
150
+ updated() {
151
+ const effective = this._effectiveColumnSpan();
152
+ if (effective === 'auto') this._resolveAutoSpan();
153
+
154
+ const hasExplicitRange =
155
+ this.startColumn !== undefined || this.endColumn !== undefined;
156
+
157
+ const resolved = effective === 'auto' ? this._autoSpan : effective;
158
+ const gridColumn = hasExplicitRange
159
+ ? `${this.startColumn ?? 'auto'} / ${this.endColumn ?? 'auto'}`
160
+ : resolveSpan(resolved);
161
+
162
+ this.style.gridColumn = gridColumn;
163
+ this.style.gridRow = this.rowSpan !== undefined ? `span ${this.rowSpan}` : '';
164
+ }
165
+
166
+ render() {
167
+ return html`<slot></slot>`;
168
+ }
169
+ }
170
+
171
+ customElements.define('bds-grid-item', BdsGridItem);
172
+
173
+ declare global {
174
+ interface HTMLElementTagNameMap {
175
+ 'bds-grid-item': BdsGridItem;
176
+ }
177
+ }
@@ -0,0 +1,62 @@
1
+ import { fixture, cleanup } from '../test/helpers';
2
+ import { BdsGrid } from './bds-grid';
3
+ import './bds-grid-item';
4
+
5
+ describe('bds-grid', () => {
6
+ it('is registered as a custom element', () => {
7
+ expect(customElements.get('bds-grid')).toBe(BdsGrid);
8
+ });
9
+
10
+ it('defaults to variant="main" and is-centered=false', async () => {
11
+ const el = await fixture('<bds-grid></bds-grid>') as BdsGrid;
12
+ expect(el.variant).toBe('main');
13
+ expect(el.isCentered).toBe(false);
14
+ cleanup(el);
15
+ });
16
+
17
+ it('applies the main variant class by default', async () => {
18
+ const el = await fixture('<bds-grid></bds-grid>');
19
+ const grid = el.shadowRoot!.querySelector('.grid')!;
20
+ expect(grid.classList.contains('--main')).toBe(true);
21
+ cleanup(el);
22
+ });
23
+
24
+ it('applies the page variant class', async () => {
25
+ const el = await fixture('<bds-grid variant="page"></bds-grid>');
26
+ expect(el.shadowRoot!.querySelector('.grid')!.classList.contains('--page')).toBe(true);
27
+ cleanup(el);
28
+ });
29
+
30
+ it('applies the funnel variant class', async () => {
31
+ const el = await fixture('<bds-grid variant="funnel"></bds-grid>');
32
+ expect(el.shadowRoot!.querySelector('.grid')!.classList.contains('--funnel')).toBe(true);
33
+ cleanup(el);
34
+ });
35
+
36
+ it('applies custom variant with --grid_columns style', async () => {
37
+ const el = await fixture('<bds-grid variant="custom" columns="5"></bds-grid>');
38
+ const grid = el.shadowRoot!.querySelector('.grid') as HTMLElement;
39
+ expect(grid.classList.contains('--custom')).toBe(true);
40
+ expect(grid.getAttribute('style')).toContain('--grid_columns: 5');
41
+ cleanup(el);
42
+ });
43
+
44
+ it('applies centered class via is-centered', async () => {
45
+ const el = await fixture('<bds-grid is-centered></bds-grid>');
46
+ expect(el.shadowRoot!.querySelector('.grid')!.classList.contains('--centered')).toBe(true);
47
+ cleanup(el);
48
+ });
49
+
50
+ it('renders slotted children', async () => {
51
+ const el = await fixture('<bds-grid><bds-grid-item>Hello</bds-grid-item></bds-grid>');
52
+ expect(el.textContent?.trim()).toBe('Hello');
53
+ cleanup(el);
54
+ });
55
+
56
+ it('applies masonry class when is-masonry is set', async () => {
57
+ const el = await fixture('<bds-grid is-masonry></bds-grid>') as BdsGrid;
58
+ expect(el.isMasonry).toBe(true);
59
+ expect(el.shadowRoot!.querySelector('.grid')!.classList.contains('--masonry')).toBe(true);
60
+ cleanup(el);
61
+ });
62
+ });