@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.
Files changed (34) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +40 -0
  3. package/dist/client.cjs +303 -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 +309 -60
  8. package/dist/index.cjs +303 -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 +309 -60
  13. package/dist/web-components/index.d.ts +129 -1
  14. package/dist/web-components/index.js +311 -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 +401 -0
  19. package/src/components/layout/Grid/Grid.stories.tsx +160 -0
  20. package/src/components/layout/Grid/Grid.tsx +85 -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 +134 -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,150 @@
1
+ import {
2
+ ElementType,
3
+ ComponentPropsWithoutRef,
4
+ CSSProperties,
5
+ useContext,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import css from './Grid.module.css';
11
+ import { cn } from '@boostdev/design-system-foundation';
12
+ import type { WithClassName } from '../../../types';
13
+ import { GridContext } from './Grid';
14
+ import {
15
+ aspectToColumnSpan,
16
+ findMediaChild,
17
+ mediaReadyEvent,
18
+ readMediaAspect,
19
+ type AutoSpanResult,
20
+ } from './autoSpan';
21
+
22
+ export type GridItemSpanValue =
23
+ | 'full'
24
+ | 'three-quarters'
25
+ | 'two-thirds'
26
+ | 'half'
27
+ | 'one-third'
28
+ | 'one-quarter'
29
+ | 'auto'
30
+ | number;
31
+
32
+ export const GridItemSpan = {
33
+ full: 'full',
34
+ threeQuarters: 'three-quarters',
35
+ twoThirds: 'two-thirds',
36
+ half: 'half',
37
+ oneThird: 'one-third',
38
+ oneQuarter: 'one-quarter',
39
+ auto: 'auto',
40
+ } as const satisfies Record<string, GridItemSpanValue>;
41
+
42
+ const SPAN_VAR: Record<Exclude<GridItemSpanValue, number | 'auto'>, string> = {
43
+ full: 'var(--bds-grid_span-100)',
44
+ 'three-quarters': 'span var(--bds-grid_columns-75)',
45
+ 'two-thirds': 'span var(--bds-grid_columns-66)',
46
+ half: 'span var(--bds-grid_columns-50)',
47
+ 'one-third': 'span var(--bds-grid_columns-33)',
48
+ 'one-quarter': 'span var(--bds-grid_columns-25)',
49
+ };
50
+
51
+ function resolveSpan(value: Exclude<GridItemSpanValue, 'auto'>): string {
52
+ if (typeof value === 'number') return `span ${value}`;
53
+ return SPAN_VAR[value];
54
+ }
55
+
56
+ type GridItemOwnProps = WithClassName & {
57
+ columnSpan?: GridItemSpanValue;
58
+ rowSpan?: number;
59
+ startColumn?: number;
60
+ endColumn?: number;
61
+ as?: ElementType;
62
+ };
63
+
64
+ type GridItemProps<C extends ElementType = 'div'> = GridItemOwnProps &
65
+ Omit<ComponentPropsWithoutRef<C>, keyof GridItemOwnProps>;
66
+
67
+ export function GridItem<C extends ElementType = 'div'>({
68
+ children,
69
+ className,
70
+ columnSpan,
71
+ rowSpan,
72
+ startColumn,
73
+ endColumn,
74
+ as,
75
+ style,
76
+ ...rest
77
+ }: GridItemProps<C>) {
78
+ const { autoSpanMedia } = useContext(GridContext);
79
+ const effectiveSpan: GridItemSpanValue =
80
+ columnSpan ?? (autoSpanMedia ? 'auto' : 'full');
81
+
82
+ const ref = useRef<HTMLElement | null>(null);
83
+ const [autoSpan, setAutoSpan] = useState<AutoSpanResult>('one-quarter');
84
+
85
+ useLayoutEffect(() => {
86
+ if (effectiveSpan !== 'auto') return;
87
+ const el = ref.current;
88
+ if (!el) return;
89
+
90
+ let cancelled = false;
91
+ let cleanup: (() => void) | undefined;
92
+
93
+ const resolve = () => {
94
+ if (cancelled) return;
95
+ const media = findMediaChild(el);
96
+ if (!media) {
97
+ setAutoSpan('one-quarter');
98
+ return;
99
+ }
100
+ const aspect = readMediaAspect(media);
101
+ if (aspect !== null) {
102
+ setAutoSpan(aspectToColumnSpan(aspect));
103
+ return;
104
+ }
105
+ const event = mediaReadyEvent(media);
106
+ if (!event) {
107
+ setAutoSpan('one-quarter');
108
+ return;
109
+ }
110
+ const handler = () => {
111
+ const a = readMediaAspect(media);
112
+ if (a !== null) setAutoSpan(aspectToColumnSpan(a));
113
+ };
114
+ media.addEventListener(event, handler, { once: true });
115
+ cleanup = () => media.removeEventListener(event, handler);
116
+ };
117
+
118
+ resolve();
119
+
120
+ return () => {
121
+ cancelled = true;
122
+ cleanup?.();
123
+ };
124
+ }, [effectiveSpan, children]);
125
+
126
+ const hasExplicitRange = startColumn !== undefined || endColumn !== undefined;
127
+
128
+ const gridColumn = hasExplicitRange
129
+ ? `${startColumn ?? 'auto'} / ${endColumn ?? 'auto'}`
130
+ : resolveSpan(effectiveSpan === 'auto' ? autoSpan : effectiveSpan);
131
+
132
+ const composedStyle: CSSProperties = {
133
+ ...(style as CSSProperties),
134
+ gridColumn,
135
+ ...(rowSpan !== undefined ? { gridRow: `span ${rowSpan}` } : {}),
136
+ };
137
+
138
+ const Component = as ?? 'div';
139
+
140
+ return (
141
+ <Component
142
+ ref={ref as never}
143
+ {...rest}
144
+ className={cn(css.item, className)}
145
+ style={composedStyle}
146
+ >
147
+ {children}
148
+ </Component>
149
+ );
150
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `columnSpan="auto"` support — derives a column span from the intrinsic aspect
3
+ * ratio of the first `<img>` or `<video>` child of a GridItem.
4
+ *
5
+ * Framework-agnostic: pure functions, used by both the React `GridItem` and the
6
+ * `bds-grid-item` web component. No layout reflow — reads `naturalWidth/Height`
7
+ * on images and `videoWidth/videoHeight` on videos, both of which are cached
8
+ * DOM properties that resolve once the media's metadata is available.
9
+ */
10
+
11
+ export type AutoSpanResult =
12
+ | 'full'
13
+ | 'three-quarters'
14
+ | 'half'
15
+ | 'one-quarter';
16
+
17
+ /**
18
+ * Map a width-to-height ratio to a named column span.
19
+ *
20
+ * Ratios were chosen for photography conventions:
21
+ * • 3:2 / 16:9 landscape → `half` (1.5 < r ≤ 2.2)
22
+ * • 21:9 / 2.35:1 / 2.4:1 / 2.5:1 → `three-quarters` (2.2 < r ≤ 3.5)
23
+ * • 32:9 / hard pano → `full` (r > 3.5)
24
+ * • Portrait, square, or unknown → `one-quarter` (r ≤ 1.5)
25
+ *
26
+ * Returns `one-quarter` for invalid/non-positive ratios so layout stays stable
27
+ * when metadata isn't yet resolved.
28
+ */
29
+ export function aspectToColumnSpan(ratio: number): AutoSpanResult {
30
+ if (!Number.isFinite(ratio) || ratio <= 0) return 'one-quarter';
31
+ if (ratio > 3.5) return 'full';
32
+ if (ratio > 2.2) return 'three-quarters';
33
+ if (ratio > 1.5) return 'half';
34
+ return 'one-quarter';
35
+ }
36
+
37
+ /**
38
+ * Find the first `<img>` or `<video>` descendant of `root`. Returns `null`
39
+ * if none. Only descends the light DOM; shadow content is opaque by design.
40
+ */
41
+ export function findMediaChild(
42
+ root: HTMLElement,
43
+ ): HTMLImageElement | HTMLVideoElement | null {
44
+ return root.querySelector('img, video');
45
+ }
46
+
47
+ /**
48
+ * Read the intrinsic aspect ratio (width / height) of an image or video.
49
+ * Returns `null` if the intrinsic dimensions haven't resolved yet — consumers
50
+ * should subscribe to `load` (images) or `loadedmetadata` (video) and retry.
51
+ */
52
+ export function readMediaAspect(
53
+ media: HTMLImageElement | HTMLVideoElement,
54
+ ): number | null {
55
+ if (media instanceof HTMLImageElement) {
56
+ if (media.naturalWidth > 0 && media.naturalHeight > 0) {
57
+ return media.naturalWidth / media.naturalHeight;
58
+ }
59
+ return null;
60
+ }
61
+ if (media.videoWidth > 0 && media.videoHeight > 0) {
62
+ return media.videoWidth / media.videoHeight;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Returns the name of the event the given media element fires when its
69
+ * intrinsic dimensions become available.
70
+ */
71
+ export function mediaReadyEvent(
72
+ media: HTMLImageElement | HTMLVideoElement,
73
+ ): 'load' | 'loadedmetadata' | null {
74
+ if (media instanceof HTMLImageElement) return media.complete ? null : 'load';
75
+ if (media.readyState < 1 /* HAVE_METADATA */) return 'loadedmetadata';
76
+ return null;
77
+ }
@@ -0,0 +1,4 @@
1
+ export { Grid } from './Grid';
2
+ export type { GridVariant } from './Grid';
3
+ export { GridItem, GridItemSpan } from './GridItem';
4
+ export type { GridItemSpanValue } from './GridItem';
@@ -0,0 +1,134 @@
1
+ import { RefObject, useLayoutEffect } from 'react';
2
+
3
+ /**
4
+ * Masonry layout polyfill based on CSS Grid Layout Module Level 3 (a.k.a. "grid-lanes").
5
+ * https://www.w3.org/TR/css-grid-3/ · https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout/Masonry_layout
6
+ *
7
+ * Implementation strategy — row-span packing:
8
+ * 1. Override the container to `grid-auto-rows: 1px` + `row-gap: 0`. The
9
+ * author-intended row gap is baked into each item's span instead of
10
+ * being applied between rows — that way the ceil() rounding can only
11
+ * overshoot by <1px per item, so visual gaps stay uniform.
12
+ * 2. Set `grid-auto-flow: dense` so items backfill into the earliest empty
13
+ * slot — this is what produces the masonry effect when items have
14
+ * variable heights.
15
+ * 3. For each child: measure its natural height H, then set
16
+ * `grid-row-end: span ceil(H + targetGap)`. The last `targetGap` pixels
17
+ * of each item's cell are empty, which renders as the visual gap below
18
+ * the item.
19
+ *
20
+ * Items stay in normal document flow — no `position: absolute`. CSS Grid handles
21
+ * column-track sizing (including the foundation's responsive `--bds-grid_*` tokens),
22
+ * column spans, and definite placements; the polyfill only supplies the row-span.
23
+ * Column gap is left untouched; only row gap is subsumed into spans.
24
+ *
25
+ * When the browser supports the native `display: grid-lanes` or
26
+ * `grid-template-rows: masonry` value, the polyfill yields to CSS.
27
+ */
28
+
29
+ const ROW_UNIT_PX = 1;
30
+
31
+ export function supportsNativeMasonry(): boolean {
32
+ if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return false;
33
+ return (
34
+ CSS.supports('display', 'grid-lanes') ||
35
+ CSS.supports('grid-template-rows', 'masonry')
36
+ );
37
+ }
38
+
39
+ export function applyMasonryLayout(container: HTMLElement, rawChildren: HTMLElement[]): void {
40
+ // We override row-gap to 0, so reading it back would always give 0 on re-runs.
41
+ // Read column-gap instead — `.grid` sets both via the `gap` shorthand and the
42
+ // polyfill never touches column-gap. (If a consumer sets asymmetric gaps, the
43
+ // row spacing falls back to the column value — a reasonable compromise.)
44
+ const cs = window.getComputedStyle(container);
45
+ const targetGap = parseFloat(cs.columnGap) || 0;
46
+
47
+ container.style.gridAutoRows = `${ROW_UNIT_PX}px`;
48
+ container.style.rowGap = '0px';
49
+ container.style.gridAutoFlow = 'dense';
50
+
51
+ const children = rawChildren.filter(
52
+ (el) => el.nodeType === 1 && window.getComputedStyle(el).display !== 'none',
53
+ );
54
+
55
+ // Pass 1 — clear prior row-span overrides so items measure at their natural height.
56
+ for (const child of children) {
57
+ child.style.gridRow = '';
58
+ child.style.gridRowStart = '';
59
+ child.style.gridRowEnd = '';
60
+ }
61
+
62
+ // Pass 2 — read all heights in a tight loop. The first offsetHeight read forces one
63
+ // reflow; subsequent reads are cached until the next style write. This avoids the
64
+ // O(N) forced-reflow cost of a read-then-write loop.
65
+ const heights = children.map((child) => child.offsetHeight);
66
+
67
+ // Pass 3 — batch all row-span writes. No reads in between, so the browser can
68
+ // coalesce style invalidation and do layout once for the whole batch.
69
+ for (let i = 0; i < children.length; i++) {
70
+ const span = Math.max(1, Math.ceil(heights[i] + targetGap));
71
+ children[i].style.gridRowEnd = `span ${span}`;
72
+ }
73
+ }
74
+
75
+ export function clearMasonryLayout(container: HTMLElement, rawChildren: HTMLElement[]): void {
76
+ container.style.gridAutoRows = '';
77
+ container.style.gridAutoFlow = '';
78
+ container.style.rowGap = '';
79
+ for (const child of rawChildren) {
80
+ if (child.nodeType !== 1) continue;
81
+ child.style.gridRow = '';
82
+ child.style.gridRowStart = '';
83
+ child.style.gridRowEnd = '';
84
+ }
85
+ }
86
+
87
+ /**
88
+ * React hook that runs the polyfill against `ref.current` and re-runs on
89
+ * size / mutation changes. No-op when `enabled` is false or when the browser
90
+ * natively supports masonry.
91
+ */
92
+ export function useMasonry(ref: RefObject<HTMLElement | null>, enabled: boolean): void {
93
+ useLayoutEffect(() => {
94
+ const container = ref.current;
95
+ if (!container || !enabled) return;
96
+ if (supportsNativeMasonry()) return;
97
+
98
+ let frame = 0;
99
+ const schedule = () => {
100
+ cancelAnimationFrame(frame);
101
+ frame = requestAnimationFrame(() => {
102
+ applyMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
103
+ });
104
+ };
105
+
106
+ schedule();
107
+
108
+ const ro =
109
+ typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null;
110
+ if (ro) {
111
+ // Observe each child's size, not the container — re-running when the container
112
+ // resizes causes a feedback loop (we set grid-auto-rows, which changes container
113
+ // block-size, which fires the observer). Child-size changes are what we actually
114
+ // need to respond to.
115
+ for (const child of Array.from(container.children)) ro.observe(child);
116
+ }
117
+
118
+ const mo =
119
+ typeof MutationObserver !== 'undefined'
120
+ ? new MutationObserver(() => {
121
+ if (ro) for (const child of Array.from(container.children)) ro.observe(child);
122
+ schedule();
123
+ })
124
+ : null;
125
+ mo?.observe(container, { childList: true });
126
+
127
+ return () => {
128
+ cancelAnimationFrame(frame);
129
+ ro?.disconnect();
130
+ mo?.disconnect();
131
+ clearMasonryLayout(container, Array.from(container.children) as HTMLElement[]);
132
+ };
133
+ }, [ref, enabled]);
134
+ }
package/src/index.ts CHANGED
@@ -67,6 +67,8 @@ export { Textarea } from './components/interaction/form/Textarea';
67
67
  export { ButtonContainer, ButtonGroup } from './components/layout/ButtonGroup';
68
68
  export type { ButtonContainerProps, ButtonGroupProps } from './components/layout/ButtonGroup';
69
69
  export { Card } from './components/layout/Card';
70
+ export { Grid, GridItem, GridItemSpan } from './components/layout/Grid';
71
+ export type { GridVariant, GridItemSpanValue } from './components/layout/Grid';
70
72
  export { SectionHeader } from './components/layout/SectionHeader';
71
73
  export { IconWrapper } from './components/layout/IconWrapper';
72
74
  export type { IconWrapperProps } from './components/layout/IconWrapper';
@@ -58,6 +58,10 @@ export { BdsDescriptionList } from './ui/bds-description-list';
58
58
  export { BdsBreadcrumb } from './ui/bds-breadcrumb';
59
59
  export { BdsLink } from './ui/bds-link';
60
60
 
61
+ // Phase 8 — layout primitives
62
+ export { BdsGrid } from './layout/bds-grid';
63
+ export { BdsGridItem } from './layout/bds-grid-item';
64
+
61
65
  // Phase 8 — complete parity with React library
62
66
  export { BdsButtonGroup } from './ui/bds-button-group';
63
67
  export { BdsTable } from './ui/bds-table';
@@ -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} />