@boostdev/design-system-components 2.0.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.
Files changed (55) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +42 -1
  3. package/dist/client.cjs +305 -60
  4. package/dist/client.css +577 -523
  5. package/dist/client.d.cts +42 -2
  6. package/dist/client.d.ts +42 -2
  7. package/dist/client.js +311 -60
  8. package/dist/index.cjs +305 -60
  9. package/dist/index.css +577 -523
  10. package/dist/index.d.cts +42 -2
  11. package/dist/index.d.ts +42 -2
  12. package/dist/index.js +311 -60
  13. package/dist/native/index.cjs +5 -2
  14. package/dist/native/index.d.cts +3 -1
  15. package/dist/native/index.d.ts +3 -1
  16. package/dist/native/index.js +5 -2
  17. package/dist/web-components/{chunk-3REOIRDW.js → chunk-N3TN6WCH.js} +26 -1
  18. package/dist/web-components/index.d.ts +129 -1
  19. package/dist/web-components/index.js +312 -1
  20. package/dist/web-components/interaction/bds-button.d.ts +7 -0
  21. package/dist/web-components/interaction/bds-button.js +1 -1
  22. package/package.json +1 -1
  23. package/src/components/interaction/Button/Button.mdx +81 -36
  24. package/src/components/interaction/Button/Button.module.css +24 -0
  25. package/src/components/interaction/Button/Button.native.mdx +31 -12
  26. package/src/components/interaction/Button/Button.native.spec.tsx +20 -0
  27. package/src/components/interaction/Button/Button.native.stories.tsx +110 -9
  28. package/src/components/interaction/Button/Button.native.tsx +13 -4
  29. package/src/components/interaction/Button/Button.spec.tsx +16 -0
  30. package/src/components/interaction/Button/Button.stories.tsx +134 -16
  31. package/src/components/interaction/Button/Button.tsx +4 -0
  32. package/src/components/layout/Grid/Grid.mdx +244 -0
  33. package/src/components/layout/Grid/Grid.module.css +59 -0
  34. package/src/components/layout/Grid/Grid.spec.tsx +401 -0
  35. package/src/components/layout/Grid/Grid.stories.tsx +160 -0
  36. package/src/components/layout/Grid/Grid.tsx +85 -0
  37. package/src/components/layout/Grid/GridItem.tsx +150 -0
  38. package/src/components/layout/Grid/autoSpan.ts +77 -0
  39. package/src/components/layout/Grid/index.ts +4 -0
  40. package/src/components/layout/Grid/masonry.ts +134 -0
  41. package/src/components/layout/IconWrapper/IconWrapper.stories.tsx +46 -14
  42. package/src/index.ts +2 -0
  43. package/src/web-components/index.ts +4 -0
  44. package/src/web-components/interaction/BdsButton.mdx +46 -14
  45. package/src/web-components/interaction/BdsButton.stories.tsx +171 -19
  46. package/src/web-components/interaction/bds-button.spec.ts +35 -0
  47. package/src/web-components/interaction/bds-button.ts +28 -1
  48. package/src/web-components/layout/BdsGrid.mdx +210 -0
  49. package/src/web-components/layout/BdsGrid.stories.tsx +209 -0
  50. package/src/web-components/layout/BdsGridItem.mdx +52 -0
  51. package/src/web-components/layout/BdsGridItem.stories.tsx +72 -0
  52. package/src/web-components/layout/bds-grid-item.spec.ts +102 -0
  53. package/src/web-components/layout/bds-grid-item.ts +177 -0
  54. package/src/web-components/layout/bds-grid.spec.ts +62 -0
  55. package/src/web-components/layout/bds-grid.ts +184 -0
@@ -1,56 +1,188 @@
1
- import React from 'react';
1
+ import React, { type ReactNode } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react';
3
3
  import type { ButtonVariant, ButtonSize } from './bds-button';
4
4
  import '../index'; // auto-registers all custom elements
5
5
 
6
- // Thin React wrapper so Storybook controls work with the custom element's props
6
+ const CheckIcon = (
7
+ <svg
8
+ slot="icon-start"
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ viewBox="0 0 24 24"
11
+ width="1em"
12
+ height="1em"
13
+ aria-hidden
14
+ fill="currentColor"
15
+ >
16
+ <path d="M12 0a12 12 0 1 0 12 12A12 12 0 0 0 12 0Zm6.93 8.2-6.85 9.29a1 1 0 0 1-1.43.19l-4.89-3.91a1 1 0 0 1-.15-1.41A1 1 0 0 1 7 12.21l4.08 3.26L17.32 7a1 1 0 0 1 1.39-.21 1 1 0 0 1 .22 1.41Z" />
17
+ </svg>
18
+ );
19
+
20
+ const ArrowRightStart = <span slot="icon-start" aria-hidden>→</span>;
21
+ const ArrowRightEnd = <span slot="icon-end" aria-hidden>→</span>;
22
+ const ArrowLeftStart = <span slot="icon-start" aria-hidden>←</span>;
23
+
24
+ // Icon for icon-only buttons — goes into the default slot (no slot attribute)
25
+ const CheckIconDefault = (
26
+ <svg
27
+ xmlns="http://www.w3.org/2000/svg"
28
+ viewBox="0 0 24 24"
29
+ width="1em"
30
+ height="1em"
31
+ aria-hidden
32
+ fill="currentColor"
33
+ >
34
+ <path d="M12 0a12 12 0 1 0 12 12A12 12 0 0 0 12 0Zm6.93 8.2-6.85 9.29a1 1 0 0 1-1.43.19l-4.89-3.91a1 1 0 0 1-.15-1.41A1 1 0 0 1 7 12.21l4.08 3.26L17.32 7a1 1 0 0 1 1.39-.21 1 1 0 0 1 .22 1.41Z" />
35
+ </svg>
36
+ );
37
+
38
+ const iconStartMap = {
39
+ none: undefined,
40
+ check: CheckIcon,
41
+ arrowRight: ArrowRightStart,
42
+ arrowLeft: ArrowLeftStart,
43
+ };
44
+
45
+ const iconEndMap = {
46
+ none: undefined,
47
+ arrowRight: ArrowRightEnd,
48
+ };
49
+
50
+ type IconStartKey = keyof typeof iconStartMap;
51
+ type IconEndKey = keyof typeof iconEndMap;
52
+
53
+ // React wrapper translating typed props into the custom element's attributes/slots
54
+ interface BdsButtonProps {
55
+ variant?: ButtonVariant;
56
+ size?: ButtonSize;
57
+ disabled?: boolean;
58
+ href?: string;
59
+ hasPulse?: boolean;
60
+ isIconOnly?: boolean;
61
+ 'aria-label'?: string;
62
+ iconStart?: ReactNode;
63
+ iconEnd?: ReactNode;
64
+ children?: ReactNode;
65
+ }
66
+
7
67
  function BdsButton({
8
68
  variant = 'default',
9
69
  size = 'medium',
10
70
  disabled,
11
71
  href,
12
72
  hasPulse,
73
+ isIconOnly,
74
+ 'aria-label': ariaLabel,
75
+ iconStart,
76
+ iconEnd,
13
77
  children,
14
- }: {
15
- variant?: ButtonVariant;
16
- size?: ButtonSize;
17
- disabled?: boolean;
18
- href?: string;
19
- hasPulse?: boolean;
20
- children?: React.ReactNode;
21
- }) {
78
+ }: BdsButtonProps) {
22
79
  return React.createElement(
23
80
  'bds-button',
24
- { variant, size, disabled: disabled || undefined, href, 'has-pulse': hasPulse || undefined },
81
+ {
82
+ variant,
83
+ size,
84
+ disabled: disabled || undefined,
85
+ href,
86
+ 'has-pulse': hasPulse || undefined,
87
+ 'is-icon-only': isIconOnly || undefined,
88
+ 'aria-label': ariaLabel,
89
+ },
90
+ iconStart,
25
91
  children,
92
+ iconEnd,
26
93
  );
27
94
  }
28
95
 
29
- const meta = {
96
+ type BdsButtonStoryArgs = Omit<BdsButtonProps, 'iconStart' | 'iconEnd'> & {
97
+ iconStart?: IconStartKey;
98
+ iconEnd?: IconEndKey;
99
+ };
100
+
101
+ const meta: Meta<BdsButtonStoryArgs> = {
30
102
  title: 'Web Components/Interaction/Button',
31
103
  component: BdsButton,
32
104
  tags: ['!stable', 'experimental'],
33
105
  parameters: { layout: 'centered' },
34
106
  argTypes: {
35
- variant: { control: 'select', options: ['default', 'ghost'] },
36
- size: { control: 'select', options: ['small', 'medium', 'large'] },
107
+ variant: {
108
+ control: 'select',
109
+ options: ['default', 'outline'],
110
+ description: '`default` is filled; `outline` is transparent with a border.',
111
+ table: { defaultValue: { summary: 'default' } },
112
+ },
113
+ size: {
114
+ control: 'select',
115
+ options: ['small', 'medium', 'large'],
116
+ description: 'Height + horizontal padding.',
117
+ table: { defaultValue: { summary: 'medium' } },
118
+ },
119
+ iconStart: {
120
+ control: 'select',
121
+ options: Object.keys(iconStartMap) as IconStartKey[],
122
+ mapping: iconStartMap,
123
+ description: 'Icon rendered in the `icon-start` slot.',
124
+ },
125
+ iconEnd: {
126
+ control: 'select',
127
+ options: Object.keys(iconEndMap) as IconEndKey[],
128
+ mapping: iconEndMap,
129
+ description: 'Icon rendered in the `icon-end` slot.',
130
+ },
131
+ isIconOnly: {
132
+ control: 'boolean',
133
+ description: 'Forces 1:1 aspect ratio with minimal xs padding. Composable with any `variant`. Requires `aria-label`.',
134
+ table: { defaultValue: { summary: 'false' } },
135
+ },
136
+ hasPulse: {
137
+ control: 'boolean',
138
+ description: 'Adds a pulsing animation for CTA emphasis.',
139
+ table: { defaultValue: { summary: 'false' } },
140
+ },
141
+ disabled: { control: 'boolean' },
142
+ href: { control: 'text', description: 'When set, renders as `<a>` with button styling.' },
143
+ 'aria-label': { control: 'text', description: 'Required for icon-only buttons.' },
37
144
  },
38
- } satisfies Meta<typeof BdsButton>;
145
+ args: { variant: 'default', size: 'medium' },
146
+ };
39
147
 
40
148
  export default meta;
41
- type Story = StoryObj<typeof meta>;
149
+ type Story = StoryObj<BdsButtonStoryArgs>;
42
150
 
43
- export const Default: Story = { args: { children: 'Default', variant: 'default' } };
44
- export const Ghost: Story = { args: { children: 'Ghost', variant: 'ghost' } };
151
+ export const Playground: Story = {
152
+ args: { children: 'Button', iconStart: 'none', iconEnd: 'none' },
153
+ };
154
+
155
+ export const Default: Story = { args: { children: 'Default' } };
156
+ export const Outline: Story = { args: { children: 'Outline', variant: 'outline' } };
45
157
  export const Small: Story = { args: { children: 'Small', size: 'small' } };
46
158
  export const Medium: Story = { args: { children: 'Medium', size: 'medium' } };
47
159
  export const Large: Story = { args: { children: 'Large', size: 'large' } };
48
160
  export const WithPulse: Story = { args: { children: 'Pulsing', hasPulse: true } };
49
161
  export const Disabled: Story = { args: { children: 'Disabled', disabled: true } };
162
+ export const WithIconStart: Story = { args: { children: 'Confirm', iconStart: 'check' } };
163
+ export const WithIconEnd: Story = { args: { children: 'Continue', iconEnd: 'arrowRight' } };
164
+
165
+ export const IconOnly: Story = {
166
+ args: {
167
+ isIconOnly: true,
168
+ 'aria-label': 'Confirm',
169
+ children: CheckIconDefault,
170
+ },
171
+ };
172
+
173
+ export const IconOnlyOutline: Story = {
174
+ args: {
175
+ variant: 'outline',
176
+ isIconOnly: true,
177
+ 'aria-label': 'Confirm',
178
+ children: CheckIconDefault,
179
+ },
180
+ };
181
+
50
182
  export const AllVariants: Story = {
51
183
  render: () => (
52
184
  <div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
53
- {(['default', 'ghost'] as const).flatMap(v =>
185
+ {(['default', 'outline'] as const).flatMap(v =>
54
186
  (['small', 'medium', 'large'] as const).map(s => (
55
187
  <BdsButton key={`${v}-${s}`} variant={v} size={s}>{v} {s}</BdsButton>
56
188
  )),
@@ -58,3 +190,23 @@ export const AllVariants: Story = {
58
190
  </div>
59
191
  ),
60
192
  };
193
+
194
+ export const AllIconOnly: Story = {
195
+ render: () => (
196
+ <div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
197
+ {(['default', 'outline'] as const).flatMap(v =>
198
+ (['small', 'medium', 'large'] as const).map(s => (
199
+ <BdsButton
200
+ key={`${v}-${s}`}
201
+ variant={v}
202
+ size={s}
203
+ isIconOnly
204
+ aria-label={`${v} ${s}`}
205
+ >
206
+ {CheckIconDefault}
207
+ </BdsButton>
208
+ )),
209
+ )}
210
+ </div>
211
+ ),
212
+ };
@@ -92,4 +92,39 @@ describe('bds-button', () => {
92
92
  expect(a!.getAttribute('rel')).toBe('noopener');
93
93
  cleanup(el);
94
94
  });
95
+
96
+ it('applies is-icon-only class when is-icon-only is set', async () => {
97
+ const el = await fixture('<bds-button is-icon-only aria-label="Confirm"><svg></svg></bds-button>');
98
+ const btn = el.shadowRoot!.querySelector('button');
99
+ expect(btn!.className).toContain('is-icon-only');
100
+ cleanup(el);
101
+ });
102
+
103
+ it('does not apply is-icon-only class by default', async () => {
104
+ const el = await fixture('<bds-button>Click</bds-button>');
105
+ const btn = el.shadowRoot!.querySelector('button');
106
+ expect(btn!.className).not.toContain('is-icon-only');
107
+ cleanup(el);
108
+ });
109
+
110
+ it('reflects the is-icon-only attribute on the host', async () => {
111
+ const el = await fixture('<bds-button is-icon-only aria-label="Confirm"></bds-button>');
112
+ expect(el.hasAttribute('is-icon-only')).toBe(true);
113
+ cleanup(el);
114
+ });
115
+
116
+ it('composes is-icon-only with outline variant', async () => {
117
+ const el = await fixture('<bds-button variant="outline" is-icon-only aria-label="Confirm"></bds-button>');
118
+ const btn = el.shadowRoot!.querySelector('button');
119
+ expect(btn!.className).toContain('outline');
120
+ expect(btn!.className).toContain('is-icon-only');
121
+ cleanup(el);
122
+ });
123
+
124
+ it('forwards aria-label to the inner button', async () => {
125
+ const el = await fixture('<bds-button is-icon-only aria-label="Close"></bds-button>');
126
+ const btn = el.shadowRoot!.querySelector('button');
127
+ expect(btn!.getAttribute('aria-label')).toBe('Close');
128
+ cleanup(el);
129
+ });
95
130
  });
@@ -14,6 +14,7 @@ export type ButtonSize = 'small' | 'medium' | 'large';
14
14
  * target — forwarded to <a> when href is set
15
15
  * rel — forwarded to <a> when href is set
16
16
  * has-pulse — boolean, enables the pulse ring animation
17
+ * is-icon-only — boolean, forces a 1:1 aspect ratio with minimal xs padding (requires aria-label)
17
18
  * type — "button" (default) | "submit" | "reset"
18
19
  *
19
20
  * Slots:
@@ -114,6 +115,23 @@ export class BdsButton extends LitElement {
114
115
  animation: pulse 3s infinite;
115
116
  }
116
117
 
118
+ /* Icon-only: square (1:1), equal xs padding on both axes — composes with
119
+ any variant/size. box-sizing: border-box keeps the outer box at
120
+ --button_height; without it, all: unset reverts to content-box and
121
+ padding-block would stretch the button past its declared height.
122
+ Slotted child fills the content area (button height minus the two
123
+ padding sides) so SVG icons scale with button size. */
124
+ .button.is-icon-only {
125
+ aspect-ratio: 1;
126
+ box-sizing: border-box;
127
+ padding: var(--bds-space_xs);
128
+ }
129
+
130
+ :host([is-icon-only]) ::slotted(*) {
131
+ inline-size: 100%;
132
+ block-size: 100%;
133
+ }
134
+
117
135
  /* SVG icon colour */
118
136
  .button svg {
119
137
  --icon__stroke: currentcolor;
@@ -141,6 +159,12 @@ export class BdsButton extends LitElement {
141
159
  transition: var(--bds-animation_transition);
142
160
  }
143
161
 
162
+ /* Icon-only: zero out icon slot margins so icon centres */
163
+ .button.is-icon-only .icon-start.has-content,
164
+ .button.is-icon-only .icon-end.has-content {
165
+ margin-inline: 0;
166
+ }
167
+
144
168
  /* Hover icon animation */
145
169
  @media (hover: hover) and (pointer: fine) {
146
170
  .button:hover .icon-start svg,
@@ -189,6 +213,7 @@ export class BdsButton extends LitElement {
189
213
  target: { type: String },
190
214
  rel: { type: String },
191
215
  hasPulse: { type: Boolean, attribute: 'has-pulse', reflect: true },
216
+ isIconOnly: { type: Boolean, attribute: 'is-icon-only', reflect: true },
192
217
  type: { type: String },
193
218
  ariaLabel: { type: String, attribute: 'aria-label' },
194
219
  // Internal state
@@ -205,6 +230,7 @@ export class BdsButton extends LitElement {
205
230
  declare target: string | undefined;
206
231
  declare rel: string | undefined;
207
232
  declare hasPulse: boolean;
233
+ declare isIconOnly: boolean;
208
234
  declare type: 'button' | 'submit' | 'reset';
209
235
  declare ariaLabel: string | null;
210
236
  declare private _iconStartFilled: boolean;
@@ -216,6 +242,7 @@ export class BdsButton extends LitElement {
216
242
  this.size = 'medium';
217
243
  this.disabled = false;
218
244
  this.hasPulse = false;
245
+ this.isIconOnly = false;
219
246
  this.type = 'button';
220
247
  this.ariaLabel = null;
221
248
  this._iconStartFilled = false;
@@ -233,7 +260,7 @@ export class BdsButton extends LitElement {
233
260
  }
234
261
 
235
262
  private get _classes() {
236
- return `button ${this.variant} ${this.size}${this.hasPulse ? ' has-pulse' : ''}`;
263
+ return `button ${this.variant} ${this.size}${this.hasPulse ? ' has-pulse' : ''}${this.isIconOnly ? ' is-icon-only' : ''}`;
237
264
  }
238
265
 
239
266
  private _handleAnchorClick(e: MouseEvent) {
@@ -0,0 +1,210 @@
1
+ import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
2
+ import * as Stories from './BdsGrid.stories';
3
+
4
+ <Meta of={Stories} />
5
+
6
+ # &lt;bds-grid&gt;
7
+
8
+ Framework-agnostic Grid layout custom element. Built on the `@boostdev/design-system-foundation` grid tokens.
9
+
10
+ > **Status: experimental** — API may change before stable release.
11
+
12
+ ## Installation
13
+
14
+ ```js
15
+ import '@boostdev/design-system-components/web-components';
16
+ ```
17
+
18
+ ## Variants
19
+
20
+ | Variant | Use case |
21
+ | -------- | ---------------------------------------------------------------------------------------- |
22
+ | `main` | Default. Responsive content-block grid matching the Figma main grid. |
23
+ | `page` | Single-column full-width grid. Use at page root. |
24
+ | `funnel` | Narrow variant of `main`; capped at `--bds-container_max-width--narrow` (48rem). |
25
+ | `custom` | Explicit column count via the `columns` attribute. Not responsive — use sparingly. |
26
+
27
+ ## Responsive behavior
28
+
29
+ `main` and `funnel` inherit the foundation's column ladder automatically. Named `column-span` values adapt across breakpoints so no media queries are required in consumer code:
30
+
31
+ | Viewport | Total columns | `one-quarter` | `one-third` | `half` | `two-thirds` | `three-quarters` | `full` |
32
+ | ----------------- | ------------- | ------------- | ----------- | ------ | ------------ | ---------------- | ------ |
33
+ | mobile (< 48rem) | 4 | 4 | 4 | 4 | 4 | 4 | 4 |
34
+ | tablet (≥ 48rem) | 8 | 4 | 4 | 4 | 4 | 8 | 8 |
35
+ | desktop (≥ 60rem) | 12 | 3 | 4 | 6 | 8 | 9 | 12 |
36
+
37
+ Numeric `column-span="n"` is literal — it does not adapt. Use named values for responsive behavior.
38
+
39
+ ## Props
40
+
41
+ <ArgTypes of={Stories} />
42
+
43
+ ## Attributes
44
+
45
+ | Attribute | Type | Default | Description |
46
+ | ----------------- | ---------------------------------------------- | -------- | -------------------------------------------------- |
47
+ | `variant` | `"main" \| "page" \| "funnel" \| "custom"` | `"main"` | Grid type |
48
+ | `columns` | number | — | Column count (only for `variant="custom"`) |
49
+ | `is-centered` | boolean | `false` | Caps max-width via `--bds-container_max-width` |
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
+ | `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
+
53
+ ## Slots
54
+
55
+ | Slot | Description |
56
+ | ----------- | -------------------------------------------- |
57
+ | `(default)` | Grid content; typically `<bds-grid-item>` children |
58
+
59
+ ## CSS custom properties
60
+
61
+ | Property | Default | Description |
62
+ | ----------------- | ------------------------- | ------------------------------------------ |
63
+ | `--grid_gap` | `var(--bds-grid_gap)` | Gap between grid items |
64
+ | `--grid_columns` | `4` | Column count for `variant="custom"` |
65
+
66
+ ## Usage in plain HTML
67
+
68
+ ```html
69
+ <bds-grid variant="main">
70
+ <bds-grid-item column-span="half">A</bds-grid-item>
71
+ <bds-grid-item column-span="half">B</bds-grid-item>
72
+ </bds-grid>
73
+ ```
74
+
75
+ ## Nesting
76
+
77
+ Grids can nest. A `variant="page"` inside another grid uses `grid-column: 1 / -1` so nested page grids always occupy the full width of their container.
78
+
79
+ ```html
80
+ <bds-grid variant="main">
81
+ <bds-grid-item column-span="full">
82
+ <bds-grid variant="page">…</bds-grid>
83
+ </bds-grid-item>
84
+ </bds-grid>
85
+ ```
86
+
87
+ ## Accessibility
88
+
89
+ `<bds-grid>` is purely presentational — no implicit ARIA role. Wrap in a landmark (`<main>`, `<section>`) if one applies. The grid does not manage focus order; source order drives reading order and should match the visual flow.
90
+
91
+ ## Masonry layout
92
+
93
+ Set `is-masonry` on any `<bds-grid>` to enable masonry (a.k.a. Pinterest-style) layout. Items flow into whichever track currently has the most room, removing the ragged gaps a standard grid leaves when items have uneven heights.
94
+
95
+ ```html
96
+ <bds-grid is-masonry>
97
+ <bds-grid-item column-span="one-quarter">…</bds-grid-item>
98
+ <bds-grid-item column-span="one-quarter">…</bds-grid-item>
99
+ </bds-grid>
100
+ ```
101
+
102
+ ### When to use
103
+
104
+ - Image/card galleries with heterogenous heights.
105
+ - Feeds/moodboards where uniform-height tiles would look wrong.
106
+
107
+ ### When NOT to use
108
+
109
+ - Layouts where the visual order must match reading/tab order. `grid-auto-flow: dense` can backfill an item into an earlier visual slot than its DOM position suggests. Keep DOM order meaningful.
110
+ - Fixed-height cards — a standard grid is simpler and has no JS dependency.
111
+ - Row-aligned layouts (comparison tables).
112
+
113
+ ### How it works
114
+
115
+ Targets [CSS Grid Layout Module Level 3](https://www.w3.org/TR/css-grid-3/) ("grid lanes"):
116
+
117
+ - **Native path** — when the browser supports `display: grid-lanes` or `grid-template-rows: masonry`, CSS handles everything and the polyfill short-circuits.
118
+ - **Polyfill path** — row-span packing: the container collapses to a 1 px row track with `grid-auto-flow: dense`, and each item is given `grid-row-end: span ceil(height + gap)`. Items stay in **normal flow** (no absolute positioning), so surrounding content lays out correctly around the grid.
119
+
120
+ Re-runs on child size changes (`ResizeObserver` on each child) and child addition/removal (`MutationObserver` on the host). The container itself is not observed to avoid a feedback loop — setting row-spans changes the container's block-size.
121
+
122
+ ### Span behavior in masonry
123
+
124
+ - `column-span` works normally — wider items span more columns. Use this to make a hero or panorama stand out.
125
+ - `row-span` is **ignored**. The stacking axis has no rows to span.
126
+ - `start-column` / `end-column` still apply for definite placement.
127
+
128
+ ### Performance
129
+
130
+ - Initial layout is one measure pass + one write pass (heights are batched to avoid per-item reflows).
131
+ - Re-layout is scheduled via `requestAnimationFrame` so bursts of observer callbacks coalesce into a single run per frame.
132
+ - Row-gap is baked into each item's span, so ceil-rounding only introduces sub-pixel overshoot per item — not visible in practice.
133
+
134
+ ### Accessibility
135
+
136
+ Masonry does not change semantics — DOM order is preserved. Because visual order can differ from DOM order, **do not rely on visual order for screen-reader reading order**. Structure content so it makes sense top-to-bottom in the DOM.
137
+
138
+ ## Aspect-ratio auto-span
139
+
140
+ For galleries mixing portrait, landscape, and panoramic media, set `auto-span-media` on `<bds-grid>` and each `<bds-grid-item>` without a `column-span` attribute will derive its span from the intrinsic aspect ratio of its first `<img>` or `<video>` descendant.
141
+
142
+ ```html
143
+ <bds-grid is-masonry auto-span-media>
144
+ <bds-grid-item><img src="/portrait.jpg" alt=""></bds-grid-item>
145
+ <bds-grid-item><img src="/landscape.jpg" alt=""></bds-grid-item>
146
+ <bds-grid-item><img src="/panorama.jpg" alt=""></bds-grid-item>
147
+ </bds-grid>
148
+ ```
149
+
150
+ Individual items can opt out with an explicit `column-span` (e.g. `column-span="full"` for a hero that shouldn't be auto-sized) or opt in explicitly with `column-span="auto"` when the parent grid doesn't set `auto-span-media`.
151
+
152
+ ### Thresholds
153
+
154
+ | Aspect ratio (w ÷ h) | Resolved span | Typical source |
155
+ | -------------------- | ------------------- | --------------------------------- |
156
+ | ≤ 1.5 | `'one-quarter'` | Portrait, square, 3:2 portrait |
157
+ | > 1.5 and ≤ 2.2 | `'half'` | 16:9, 3:2 landscape, 2:1 |
158
+ | > 2.2 and ≤ 3.5 | `'three-quarters'` | 21:9, 2.35:1, 2.5:1 (cinematic) |
159
+ | > 3.5 | `'full'` | 32:9, hard panoramic |
160
+ | invalid / unresolved | `'one-quarter'` | No media / metadata not yet ready |
161
+
162
+ Named-span responsive behavior still applies — on mobile every item collapses to full-width regardless of its resolved span.
163
+
164
+ ### Loading behavior
165
+
166
+ 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.
167
+
168
+ ### When NOT to use auto-span
169
+
170
+ - Items whose span reflects **content importance** rather than media shape — set `column-span` explicitly.
171
+ - Non-media grids — every item falls back to `'one-quarter'` because there's no aspect to read.
172
+ - Galleries where all images share the same aspect — the thresholds won't differentiate them; a uniform explicit span is simpler.
173
+
174
+ ## Framework equivalents
175
+
176
+ | React | Web component |
177
+ | ----------------------- | -------------------------- |
178
+ | `<Grid>` / `<GridItem>` | `<bds-grid>` / `<bds-grid-item>` |
179
+
180
+ ## Examples
181
+
182
+ ### Main grid
183
+ <Canvas of={Stories.Main} />
184
+
185
+ ### Page grid
186
+ <Canvas of={Stories.Page} />
187
+
188
+ ### Funnel
189
+ <Canvas of={Stories.Funnel} />
190
+
191
+ ### Custom
192
+ <Canvas of={Stories.Custom} />
193
+
194
+ ### Centered
195
+ <Canvas of={Stories.Centered} />
196
+
197
+ ### Spans
198
+ <Canvas of={Stories.Spans} />
199
+
200
+ ### Start / end (escape hatch)
201
+ <Canvas of={Stories.StartEnd} />
202
+
203
+ ### Complex layout
204
+ <Canvas of={Stories.ComplexLayout} />
205
+
206
+ ### Masonry
207
+ <Canvas of={Stories.Masonry} />
208
+
209
+ ### Masonry with spans
210
+ <Canvas of={Stories.MasonryWithSpans} />