@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,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,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
|
+
# <bds-grid>
|
|
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} />
|