@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,42 +1,160 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
- import { Button } from './Button';
2
+ import { Button, type ButtonProps } from './Button';
3
3
 
4
- const meta = {
4
+ const CheckIcon = (
5
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" aria-hidden>
6
+ <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"/>
7
+ </svg>
8
+ );
9
+
10
+ const ArrowRight = <span aria-hidden>→</span>;
11
+ const ArrowLeft = <span aria-hidden>←</span>;
12
+
13
+ const iconMap = {
14
+ none: undefined,
15
+ check: CheckIcon,
16
+ arrowRight: ArrowRight,
17
+ arrowLeft: ArrowLeft,
18
+ };
19
+
20
+ type IconKey = keyof typeof iconMap;
21
+
22
+ /**
23
+ * Story-local args: `iconStart`/`iconEnd` are narrowed to the icon keys
24
+ * (`'check' | 'arrowRight' | …`) so the Controls panel stays in sync with
25
+ * the rendered icon. Storybook's `mapping` resolves the key → ReactNode at
26
+ * render time, so the prop still receives a real ReactNode downstream.
27
+ */
28
+ type ButtonStoryArgs = Omit<ButtonProps, 'iconStart' | 'iconEnd'> & {
29
+ iconStart?: IconKey;
30
+ iconEnd?: IconKey;
31
+ };
32
+
33
+ const meta: Meta<ButtonStoryArgs> = {
5
34
  title: 'React/Interaction/Button',
6
35
  component: Button,
7
36
  argTypes: {
8
- variant: { control: 'select', options: ['default', 'ghost'] },
9
- size: { control: 'select', options: ['small', 'medium', 'large'] },
37
+ variant: {
38
+ control: 'select',
39
+ options: ['default', 'outline'],
40
+ description: 'Visual style. `default` is filled; `outline` is transparent with a border.',
41
+ table: { defaultValue: { summary: 'default' } },
42
+ },
43
+ size: {
44
+ control: 'select',
45
+ options: ['small', 'medium', 'large'],
46
+ description: 'Height + padding. `small` is 2.25em, `medium` is 3em, `large` bumps font-size to the h3 scale.',
47
+ table: { defaultValue: { summary: 'medium' } },
48
+ },
49
+ iconStart: {
50
+ control: 'select',
51
+ options: Object.keys(iconMap) as IconKey[],
52
+ mapping: iconMap,
53
+ description: 'Icon rendered before the label.',
54
+ },
55
+ iconEnd: {
56
+ control: 'select',
57
+ options: Object.keys(iconMap) as IconKey[],
58
+ mapping: iconMap,
59
+ description: 'Icon rendered after the label.',
60
+ },
61
+ isIconOnly: {
62
+ control: 'boolean',
63
+ description: 'Forces a 1:1 aspect ratio with minimal padding. Composable with any `variant`. Requires `aria-label`.',
64
+ table: { defaultValue: { summary: 'false' } },
65
+ },
66
+ hasPulse: {
67
+ control: 'boolean',
68
+ description: 'Adds a pulsing animation for CTA emphasis. Respects `prefers-reduced-motion`.',
69
+ table: { defaultValue: { summary: 'false' } },
70
+ },
71
+ disabled: {
72
+ control: 'boolean',
73
+ description: 'Blocks interaction, sets `aria-disabled`, and removes the element from the tab order.',
74
+ table: { defaultValue: { summary: 'false' } },
75
+ },
76
+ href: {
77
+ control: 'text',
78
+ description: 'When set, renders as `<a>` pointing to this URL instead of `<button>`.',
79
+ },
80
+ 'aria-label': {
81
+ control: 'text',
82
+ description: 'Accessible label. Required for icon-only buttons.',
83
+ },
10
84
  onClick: { action: 'clicked' },
11
85
  },
12
- } satisfies Meta<typeof Button>;
86
+ args: { variant: 'default', size: 'medium' },
87
+ };
13
88
 
14
89
  export default meta;
15
- type Story = StoryObj<typeof meta>;
90
+ type Story = StoryObj<ButtonStoryArgs>;
91
+
92
+ export const Playground: Story = {
93
+ args: {
94
+ children: 'Button',
95
+ iconStart: 'none',
96
+ iconEnd: 'none',
97
+ },
98
+ };
16
99
 
17
- export const Default: Story = { args: { children: 'Default', variant: 'default' } };
18
- export const Ghost: Story = { args: { children: 'Ghost', variant: 'ghost' } };
100
+ export const Default: Story = { args: { children: 'Default' } };
101
+ export const Outline: Story = { args: { children: 'Outline', variant: 'outline' } };
19
102
  export const Small: Story = { args: { children: 'Small', size: 'small' } };
20
103
  export const Medium: Story = { args: { children: 'Medium', size: 'medium' } };
21
104
  export const Large: Story = { args: { children: 'Large', size: 'large' } };
22
105
  export const AsLink: Story = { args: { children: 'Link Button', href: 'https://example.com' } };
23
106
  export const WithPulse: Story = { args: { children: 'Pulsing', hasPulse: true } };
24
107
  export const Disabled: Story = { args: { children: 'Disabled', disabled: true } };
25
- export const WithIcons: Story = {
108
+
109
+ export const WithIconStart: Story = { args: { children: 'Confirm', iconStart: 'check' } };
110
+ export const WithIconEnd: Story = { args: { children: 'Continue', iconEnd: 'arrowRight' } };
111
+ export const WithBothIcons: Story = { args: { children: 'Both', iconStart: 'arrowLeft', iconEnd: 'arrowRight' } };
112
+
113
+ export const IconOnly: Story = {
26
114
  args: {
27
- children: 'With Icons',
28
- iconStart: <span>→</span>,
29
- iconEnd: <span>←</span>,
115
+ isIconOnly: true,
116
+ 'aria-label': 'Confirm',
117
+ children: CheckIcon,
30
118
  },
31
119
  };
120
+
121
+ export const IconOnlyOutline: Story = {
122
+ args: {
123
+ variant: 'outline',
124
+ isIconOnly: true,
125
+ 'aria-label': 'Confirm',
126
+ children: CheckIcon,
127
+ },
128
+ };
129
+
32
130
  export const AllVariants: Story = {
33
131
  render: () => (
34
132
  <div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
35
- {(['default', 'ghost'] as const).map(v => (
36
- ['small', 'medium', 'large'].map(s => (
37
- <Button key={`${v}-${s}`} variant={v} size={s as 'small' | 'medium' | 'large'}>{v} {s}</Button>
133
+ {(['default', 'outline'] as const).map(v =>
134
+ (['small', 'medium', 'large'] as const).map(s => (
135
+ <Button key={`${v}-${s}`} variant={v} size={s}>{v} {s}</Button>
136
+ ))
137
+ )}
138
+ </div>
139
+ ),
140
+ };
141
+
142
+ export const AllIconOnly: Story = {
143
+ render: () => (
144
+ <div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
145
+ {(['default', 'outline'] as const).map(v =>
146
+ (['small', 'medium', 'large'] as const).map(s => (
147
+ <Button
148
+ key={`${v}-${s}`}
149
+ variant={v}
150
+ size={s}
151
+ isIconOnly
152
+ aria-label={`${v} ${s}`}
153
+ >
154
+ {CheckIcon}
155
+ </Button>
38
156
  ))
39
- ))}
157
+ )}
40
158
  </div>
41
159
  ),
42
160
  };
@@ -36,6 +36,8 @@ export interface ButtonProps extends WithClassName, ButtonHTMLAttributes<HTMLBut
36
36
  disabled?: boolean;
37
37
  /** Adds a pulsing animation for call-to-action emphasis. Respects `prefers-reduced-motion`. */
38
38
  hasPulse?: boolean;
39
+ /** When true, forces a 1:1 aspect ratio with equal `xs` padding on both axes. Pass the icon as `children`; composable with any `variant`. Requires `aria-label`. */
40
+ isIconOnly?: boolean;
39
41
  /** Click handler. Typed as `HTMLElement` because the root may be `<button>` or `<a>`. */
40
42
  onClick?: MouseEventHandler<HTMLElement>;
41
43
  /** Anchor target (only applied when rendered as `<a>`). */
@@ -57,6 +59,7 @@ export function Button({
57
59
  iconEnd,
58
60
  size = 'medium',
59
61
  hasPulse = false,
62
+ isIconOnly = false,
60
63
  href,
61
64
  target,
62
65
  rel,
@@ -69,6 +72,7 @@ export function Button({
69
72
  css[`--${variant}`],
70
73
  css[`--size_${size}`],
71
74
  hasPulse && css['--hasPulse'],
75
+ isIconOnly && css['--iconOnly'],
72
76
  className,
73
77
  );
74
78
 
@@ -0,0 +1,244 @@
1
+ import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
2
+ import * as Stories from './Grid.stories';
3
+
4
+ <Meta of={Stories} />
5
+
6
+ # Grid
7
+
8
+ A responsive layout primitive built on the design-system-foundation grid tokens. Compose layouts from `Grid` (container) and `GridItem` (cells).
9
+
10
+ The foundation provides the breakpoint ladder (4 columns on mobile, 8 on tablet, 12 on desktop) and the responsive span tokens — `Grid` is a thin wrapper, not a reimplementation.
11
+
12
+ ## When to use
13
+
14
+ - Page-level or content-block layouts that need to match the Figma grid
15
+ - Responsive multi-column layouts where column count should adapt per viewport
16
+
17
+ ## Variants
18
+
19
+ | Variant | Use case |
20
+ | -------- | ----------------------------------------------------------------------------------------------------------- |
21
+ | `main` | Default. Content-block grid matching the Figma main grid. Often placed on `<main>` or large content blocks. |
22
+ | `page` | Single-column full-width grid. Use at the page root; stretches across any parent grid via `1 / -1`. |
23
+ | `funnel` | Narrow variant of `main` for funnels/forms. Capped at `--bds-container_max-width--narrow` (48rem). |
24
+ | `custom` | Explicit column count via the `columns` prop. Use sparingly — it is **not** responsive. |
25
+
26
+ ## Responsive behavior
27
+
28
+ `main` and `funnel` inherit the foundation's column ladder automatically. Named `columnSpan` values adapt across breakpoints so you don't need media queries:
29
+
30
+ | Viewport | Total columns | `one-quarter` | `one-third` | `half` | `two-thirds` | `three-quarters` | `full` |
31
+ | ---------------------- | ------------- | ------------- | ----------- | ------ | ------------ | ---------------- | ------ |
32
+ | mobile (< 48rem) | 4 | 4 | 4 | 4 | 4 | 4 | 4 |
33
+ | tablet (≥ 48rem) | 8 | 4 | 4 | 4 | 4 | 8 | 8 |
34
+ | desktop (≥ 60rem) | 12 | 3 | 4 | 6 | 8 | 9 | 12 |
35
+ | wide (≥ 64rem / 86rem) | 12 | 3 | 4 | 6 | 8 | 9 | 12 |
36
+
37
+ Numeric `columnSpan={n}` is literal — it does not adapt. Use named values when you want responsive behavior.
38
+
39
+ ## Grid props
40
+
41
+ <ArgTypes of={Stories} />
42
+
43
+ ## GridItem props
44
+
45
+ | Prop | Type | Default | Description |
46
+ | ------------- | -------------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
47
+ | `columnSpan` | `'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. Named values are responsive via the foundation. `'auto'` derives the span from the intrinsic aspect ratio of the first `<img>`/`<video>` descendant — see [Aspect-ratio auto-span](#aspect-ratio-auto-span). |
48
+ | `rowSpan` | `number` | — | How many rows to span. Ignored in masonry layouts. |
49
+ | `startColumn` | `number` | — | **Escape hatch.** Explicit start column. Overrides `columnSpan` responsive logic. |
50
+ | `endColumn` | `number` | — | **Escape hatch.** Explicit end column. Overrides `columnSpan` responsive logic. |
51
+ | `as` | `ElementType` | `'div'` | Render as a custom element/component. |
52
+
53
+ A `GridItemSpan` constant is exported for dot-access:
54
+
55
+ ```ts
56
+ import { GridItemSpan } from '@boostdev/design-system-components';
57
+ <GridItem columnSpan={GridItemSpan.threeQuarters}>…</GridItem>
58
+ ```
59
+
60
+ ### Warning: startColumn / endColumn
61
+
62
+ These override the responsive span tokens and force fixed positioning. Only reach for them when you fully understand the layout implications. Prefer named `columnSpan` values.
63
+
64
+ ## HTML attribute forwarding
65
+
66
+ All standard HTML attributes (e.g. `id`, `data-*`, `aria-*`, `role`) spread onto the root element.
67
+
68
+ ## CSS variables
69
+
70
+ <table>
71
+ <thead>
72
+ <tr><th>Variable</th><th>Default</th><th>Description</th></tr>
73
+ </thead>
74
+ <tbody>
75
+ <tr><td>`--grid_gap`</td><td>`var(--bds-grid_gap)`</td><td>Gap between grid items.</td></tr>
76
+ <tr><td>`--grid_columns`</td><td>`4`</td><td>Column count for `variant="custom"` (set automatically when the `columns` prop is supplied).</td></tr>
77
+ </tbody>
78
+ </table>
79
+
80
+ ## Foundation tokens referenced
81
+
82
+ - `--bds-grid_template-columns`, `--bds-grid_gap`, `--bds-grid_columns`
83
+ - `--bds-grid_span-100`, `--bds-grid_columns-75/66/50/33/25`
84
+ - `--bds-container_max-width`, `--bds-container_max-width--narrow`, `--bds-container_spacing-inline`
85
+
86
+ ## Nesting
87
+
88
+ Grids can nest. A `variant="page"` inside another grid uses `grid-column: 1 / -1` to span the full parent, so nested page grids always occupy the full width of their container.
89
+
90
+ ```tsx
91
+ <Grid variant="main">
92
+ <GridItem columnSpan="full">
93
+ <Grid variant="page">…</Grid>
94
+ </GridItem>
95
+ </Grid>
96
+ ```
97
+
98
+ ## Accessibility
99
+
100
+ Grid is purely presentational — it renders a `<div>` by default with no ARIA role. Use the `as` prop to render the appropriate landmark when the grid maps to one (`as="main"`, `as="section"`, etc.). The grid itself does not manage focus order; source order drives reading order and should match the visual flow.
101
+
102
+ ## Framework equivalents
103
+
104
+ | Package | React | Web component |
105
+ | -------------------------------------- | -------------- | ------------------------- |
106
+ | `@boostdev/design-system-components` | `<Grid>` / `<GridItem>` | `<bds-grid>` / `<bds-grid-item>` |
107
+
108
+ ## Masonry layout
109
+
110
+ Set `isMasonry` on any `Grid` to switch to a masonry (a.k.a. Pinterest-style) layout. Items flow into whichever track currently has the most room, so a column with a short item backfills under the tall item next to it — removing the ragged gaps that standard CSS Grid leaves when items have uneven heights.
111
+
112
+ ```tsx
113
+ <Grid isMasonry>
114
+ {items.map((i) => <GridItem columnSpan="one-quarter" key={i.id}>{i.content}</GridItem>)}
115
+ </Grid>
116
+ ```
117
+
118
+ ### When to use
119
+
120
+ - Image/card galleries with heterogenous heights (photos in mixed portrait/landscape, cards with variable text length).
121
+ - Pinterest / Unsplash / moodboard-style feeds.
122
+
123
+ ### When NOT to use
124
+
125
+ - Layouts that require a stable **tab/reading order** that matches the visual order — masonry's `grid-auto-flow: dense` backfills items into earlier slots, which means DOM order can differ from visual top-to-bottom order. Keep source order meaningful regardless; don't rely on visual order for accessibility.
126
+ - Fixed-height cards in a uniform grid — standard grid is simpler, cheaper, and has no JS dependency.
127
+ - Lists where row alignment across columns matters (e.g. comparison tables).
128
+
129
+ ### How it works
130
+
131
+ Masonry targets [CSS Grid Layout Module Level 3](https://www.w3.org/TR/css-grid-3/) ("grid lanes"):
132
+
133
+ - **Native path** — when the browser supports `display: grid-lanes` or `grid-template-rows: masonry`, CSS handles everything and the JS polyfill short-circuits. Currently Safari Technology Preview only; the polyfill runs in all other browsers.
134
+ - **Polyfill path** — row-span packing. The container's row tracks are collapsed to a 1 px auto-track with `grid-auto-flow: dense`, and each item is given a `grid-row-end: span ceil(height + gap)`. Items stay in **normal document flow** (no absolute positioning), so surrounding content — footers, scroll anchors, page margins — lays out correctly around the grid.
135
+
136
+ The polyfill re-runs on child size changes (`ResizeObserver` on each child) and child addition/removal (`MutationObserver` on the container). The container itself is not observed — setting row-spans changes the container block-size, which would loop back through the observer.
137
+
138
+ ### Span behavior in masonry
139
+
140
+ - `columnSpan` works normally — items can span multiple columns. This is how you widen a hero or panorama item within a masonry feed.
141
+ - `rowSpan` is **ignored**. The stacking axis has no rows to span — each column flows freely.
142
+ - `startColumn` / `endColumn` still work for definite placement.
143
+
144
+ ### Performance
145
+
146
+ - **Initial layout** — three passes (clear, batch-read all heights, batch-write all spans). Height reads and writes are each grouped to avoid O(N) forced reflows; the measured cost is one layout pass regardless of item count.
147
+ - **Re-layout** — on child resize or mutation the polyfill is scheduled via `requestAnimationFrame`, so bursts of observer callbacks coalesce into a single re-run per frame.
148
+ - **Ceil overshoot** — the 1 px row track means each item's span can overshoot by up to 1 px. Not visible in practice (row-gap is 0; the gap is baked into each item's span).
149
+ - **Long lists** — for galleries with hundreds of items, virtualisation is still the correct answer; masonry is orthogonal.
150
+
151
+ ### SSR / hydration
152
+
153
+ Grid is client-only in masonry mode (uses `useRef` + `useLayoutEffect`). Before hydration there is no row-span overlay, so items fall back to one-row tracks. If you need a server-rendered placeholder, render a static `variant="custom"` grid of the same width on the server and swap to `isMasonry` after hydration.
154
+
155
+ ### Accessibility
156
+
157
+ Masonry does not change semantics — the DOM stays in source order. Because `grid-auto-flow: dense` can pick any column for each item, **do not rely on visual order for screen-reader reading order**. Structure your content so it makes sense top-to-bottom in the DOM.
158
+
159
+ ## Aspect-ratio auto-span
160
+
161
+ For galleries that mix portrait, landscape, and panoramic images, set `autoSpanMedia` on the `Grid` and each `GridItem` will derive its column span from the intrinsic aspect ratio of its first `<img>` or `<video>` descendant. The wide pano automatically gets more columns; the portrait stays a quarter.
162
+
163
+ ```tsx
164
+ <Grid isMasonry autoSpanMedia>
165
+ {photos.map((p) => (
166
+ <GridItem key={p.id}>
167
+ <img src={p.src} alt={p.alt} />
168
+ </GridItem>
169
+ ))}
170
+ </Grid>
171
+ ```
172
+
173
+ Individual items can opt in or out explicitly with `columnSpan="auto"` or a fixed span — `autoSpanMedia` is only the default for items that don't set `columnSpan` themselves.
174
+
175
+ ### Thresholds
176
+
177
+ | Aspect ratio (w ÷ h) | Resolved span | Typical source |
178
+ | -------------------- | ------------------- | --------------------------------- |
179
+ | ≤ 1.5 | `'one-quarter'` | Portrait, square, 3:2 portrait |
180
+ | > 1.5 and ≤ 2.2 | `'half'` | 16:9, 3:2 landscape, 2:1 |
181
+ | > 2.2 and ≤ 3.5 | `'three-quarters'` | 21:9, 2.35:1, 2.5:1 (cinematic) |
182
+ | > 3.5 | `'full'` | 32:9, hard panoramic |
183
+ | invalid / unresolved | `'one-quarter'` | No media / metadata not yet ready |
184
+
185
+ Named-span responsive behavior still applies, so on mobile every item collapses to full-width (4-col grid) regardless of its resolved span.
186
+
187
+ ### Loading behavior
188
+
189
+ Intrinsic dimensions are read from `img.naturalWidth/naturalHeight` and `video.videoWidth/videoHeight` — no layout is triggered. If metadata is not yet available when the component mounts, the item starts at `'one-quarter'` and re-resolves when the `load` (image) or `loadedmetadata` (video) event fires. Subsequent child mutations re-run the resolver.
190
+
191
+ ### When NOT to use auto-span
192
+
193
+ - Items where span should reflect **content importance** (a featured card is wider because it's featured, not because its photo is wide) — set `columnSpan` explicitly.
194
+ - Non-media grids — a text-only card has no aspect ratio to read, so every item falls back to `'one-quarter'`. Use explicit spans instead.
195
+ - Galleries where all images share the same aspect — the thresholds won't differentiate them; a uniform explicit span is simpler.
196
+
197
+ ### Performance
198
+
199
+ - One `querySelector('img, video')` + one property read per item per resolve pass.
200
+ - No layout thrashing: `naturalWidth` / `videoWidth` are cached properties that resolve once metadata is available.
201
+ - Event subscriptions are `{ once: true }` so there's no cleanup ceremony per item.
202
+ - Re-resolution cost is bounded by the number of items whose media changes, not the total item count.
203
+
204
+ ## Migration from `@greenchoice/design-system`
205
+
206
+ | Greenchoice | BoostDev equivalent |
207
+ | ------------------------------- | -------------------------------------- |
208
+ | `<GdsGrid variant="main">` | `<Grid variant="main">` |
209
+ | `<GdsGridItem columnSpan="twoThird">` | `<GridItem columnSpan="two-thirds">` |
210
+ | `--gds-grid-span-*` | `--bds-grid_span-*` |
211
+ | `--gds-main-container--max-width` | `--bds-container_max-width` |
212
+ | `--gds-main-container--max-width--narrow` | `--bds-container_max-width--narrow` |
213
+
214
+ ## Examples
215
+
216
+ ### Main grid
217
+ <Canvas of={Stories.Main} />
218
+
219
+ ### Page grid
220
+ <Canvas of={Stories.Page} />
221
+
222
+ ### Funnel grid
223
+ <Canvas of={Stories.Funnel} />
224
+
225
+ ### Custom grid
226
+ <Canvas of={Stories.Custom} />
227
+
228
+ ### Centered
229
+ <Canvas of={Stories.Centered} />
230
+
231
+ ### Spans
232
+ <Canvas of={Stories.Spans} />
233
+
234
+ ### Start / end (escape hatch)
235
+ <Canvas of={Stories.StartEnd} />
236
+
237
+ ### Complex layout
238
+ <Canvas of={Stories.ComplexLayout} />
239
+
240
+ ### Masonry
241
+ <Canvas of={Stories.Masonry} />
242
+
243
+ ### Masonry with spans
244
+ <Canvas of={Stories.MasonryWithSpans} />
@@ -0,0 +1,59 @@
1
+ @layer boostdev.component {
2
+ .grid {
3
+ display: grid;
4
+ gap: var(--grid_gap, var(--bds-grid_gap));
5
+ }
6
+
7
+ .grid.--main {
8
+ grid-template-columns: var(--bds-grid_template-columns);
9
+ }
10
+
11
+ .grid.--page {
12
+ grid-template-columns: 1fr;
13
+ grid-column: 1 / -1;
14
+ }
15
+
16
+ .grid.--funnel {
17
+ grid-template-columns: var(--bds-grid_template-columns);
18
+ max-inline-size: var(--bds-container_max-width--narrow);
19
+ margin-inline: auto;
20
+ }
21
+
22
+ .grid.--custom {
23
+ grid-template-columns: repeat(var(--grid_columns, 4), minmax(0, 1fr));
24
+ }
25
+
26
+ .grid.--centered {
27
+ margin-inline: auto;
28
+ max-inline-size: var(--bds-container_max-width);
29
+ padding-inline: var(--bds-container_spacing-inline);
30
+ }
31
+
32
+ .item {
33
+ min-inline-size: 0;
34
+ }
35
+
36
+ /*
37
+ * Masonry (CSS Grid Level 3 — https://www.w3.org/TR/css-grid-3/).
38
+ * Progressive enhancement: when a browser supports the native grid-lanes
39
+ * display value or the `masonry` keyword for grid-template-rows, it handles
40
+ * layout natively. Otherwise the JS polyfill in ./masonry.ts assigns each
41
+ * item a `grid-row: span N` based on its measured height — items stay in
42
+ * normal grid flow, so column tracks and spans resolve through CSS as usual.
43
+ */
44
+ @supports (grid-template-rows: masonry) {
45
+ .grid.--masonry {
46
+ /* stylelint-disable-next-line declaration-property-value-no-unknown --
47
+ `masonry` is an experimental CSS Grid Level 3 value; stylelint's
48
+ built-in value list hasn't caught up. This rule is inside @supports
49
+ so non-supporting browsers never parse it. */
50
+ grid-template-rows: masonry;
51
+ }
52
+ }
53
+
54
+ @supports (display: grid-lanes) {
55
+ .grid.--masonry {
56
+ display: grid-lanes;
57
+ }
58
+ }
59
+ }