@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
@@ -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
+ }
@@ -1,21 +1,53 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
- import { IconWrapper } from './IconWrapper';
2
+ import { IconWrapper, type IconWrapperProps } from './IconWrapper';
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" fill="currentColor" 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 StarIcon = (
11
+ <svg viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em" aria-hidden>
12
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
13
+ </svg>
14
+ );
15
+
16
+ const HeartIcon = (
17
+ <svg viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em" aria-hidden>
18
+ <path d="M12 21s-7-4.35-7-10a4.5 4.5 0 0 1 8-2.8A4.5 4.5 0 0 1 19 11c0 5.65-7 10-7 10z" />
19
+ </svg>
20
+ );
21
+
22
+ const iconMap = {
23
+ check: CheckIcon,
24
+ star: StarIcon,
25
+ heart: HeartIcon,
26
+ };
27
+
28
+ type IconKey = keyof typeof iconMap;
29
+
30
+ type IconWrapperStoryArgs = Omit<IconWrapperProps, 'children'> & {
31
+ children?: IconKey;
32
+ };
33
+
34
+ const meta: Meta<IconWrapperStoryArgs> = {
5
35
  title: 'React/Layout/IconWrapper',
6
36
  component: IconWrapper,
7
- } satisfies Meta<typeof IconWrapper>;
8
-
9
- export default meta;
10
- type Story = StoryObj<typeof meta>;
11
-
12
- export const Default: Story = {
13
- args: {
14
- children: (
15
- <svg viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em">
16
- <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
17
- </svg>
18
- ),
37
+ argTypes: {
38
+ children: {
39
+ control: 'select',
40
+ options: Object.keys(iconMap) as IconKey[],
41
+ mapping: iconMap,
42
+ description: 'The icon to render inside the circular wrapper.',
43
+ },
19
44
  },
45
+ args: { children: 'star' },
20
46
  };
21
47
 
48
+ export default meta;
49
+ type Story = StoryObj<IconWrapperStoryArgs>;
50
+
51
+ export const Default: Story = {};
52
+ export const Check: Story = { args: { children: 'check' } };
53
+ export const Heart: Story = { args: { children: 'heart' } };
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';
@@ -1,4 +1,4 @@
1
- import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
1
+ import { Meta, Canvas, ArgTypes, Controls } from '@storybook/blocks';
2
2
  import * as Stories from './BdsButton.stories';
3
3
 
4
4
  <Meta of={Stories} />
@@ -24,26 +24,54 @@ Or in HTML without a bundler:
24
24
  </script>
25
25
  ```
26
26
 
27
- ## Examples
27
+ ## Playground
28
28
 
29
- ### All variants & sizes
30
- <Canvas of={Stories.AllVariants} />
29
+ <Canvas of={Stories.Playground} />
30
+ <Controls of={Stories.Playground} />
31
+
32
+ ## Variants
31
33
 
32
- ### Default
33
34
  <Canvas of={Stories.Default} />
35
+ <Canvas of={Stories.Outline} />
34
36
 
35
- ### Ghost
36
- <Canvas of={Stories.Ghost} />
37
+ ## Sizes
37
38
 
38
- ### Sizes
39
39
  <Canvas of={Stories.Small} />
40
40
  <Canvas of={Stories.Medium} />
41
41
  <Canvas of={Stories.Large} />
42
42
 
43
- ### Pulse (CTA emphasis)
43
+ ## Icons
44
+
45
+ Slot an SVG (or any element) into `icon-start` or `icon-end`:
46
+
47
+ ```html
48
+ <bds-button>
49
+ <svg slot="icon-start">…</svg>
50
+ Save
51
+ </bds-button>
52
+ ```
53
+
54
+ <Canvas of={Stories.WithIconStart} />
55
+ <Canvas of={Stories.WithIconEnd} />
56
+
57
+ ### Icon-only (1:1 aspect ratio)
58
+
59
+ Set the `is-icon-only` attribute to render a square button with equal `var(--bds-space_xs)` padding on both axes. Composable with any `variant`. Always pair with `aria-label` — there's no visible label text for assistive tech to read.
60
+
61
+ <Canvas of={Stories.IconOnly} />
62
+ <Canvas of={Stories.IconOnlyOutline} />
63
+ <Canvas of={Stories.AllIconOnly} />
64
+
65
+ ## All variants & sizes
66
+
67
+ <Canvas of={Stories.AllVariants} />
68
+
69
+ ## Pulse (CTA emphasis)
70
+
44
71
  <Canvas of={Stories.WithPulse} />
45
72
 
46
- ### Disabled
73
+ ## Disabled
74
+
47
75
  <Canvas of={Stories.Disabled} />
48
76
 
49
77
  ## Props
@@ -54,14 +82,15 @@ Or in HTML without a bundler:
54
82
 
55
83
  | Attribute | Type | Default | Description |
56
84
  |-----------|------|---------|-------------|
57
- | `variant` | `"default" \| "ghost"` | `"default"` | Visual style — filled or transparent outlined |
58
- | `size` | `"small" \| "medium" \| "large"` | `"medium"` | Height and padding scale |
85
+ | `variant` | `"default" \| "outline"` | `"default"` | Visual style — filled or transparent outlined (`"ghost"` is a deprecated alias for `"outline"`) |
86
+ | `size` | `"small" \| "medium" \| "large"` | `"medium"` | Height and horizontal padding |
59
87
  | `disabled` | boolean | `false` | Prevents interaction, sets `aria-disabled` |
60
88
  | `href` | string | — | Renders as `<a>` when set |
61
89
  | `target` | string | — | Forwarded to `<a>` when `href` is set |
62
90
  | `rel` | string | — | Forwarded to `<a>` when `href` is set |
63
91
  | `type` | `"button" \| "submit" \| "reset"` | `"button"` | Native button type |
64
92
  | `has-pulse` | boolean | `false` | Adds animated ring for CTA emphasis |
93
+ | `is-icon-only` | boolean | `false` | 1:1 aspect ratio with equal `xs` padding on both axes. Requires `aria-label`. |
65
94
  | `aria-label` | string | — | Accessible label (required for icon-only buttons) |
66
95
 
67
96
  ## Slots
@@ -76,7 +105,7 @@ Or in HTML without a bundler:
76
105
 
77
106
  | Property | Description |
78
107
  |----------|-------------|
79
- | `--button_color` | Identity colour — fills the default variant, outlines the ghost variant |
108
+ | `--button_color` | Identity colour — fills the default variant, outlines the outline variant |
80
109
  | `--button_on-color` | Text/icon colour on the button surface. Pair with `--button_color` |
81
110
 
82
111
  ## Invoker Commands API
@@ -94,8 +123,11 @@ Or in HTML without a bundler:
94
123
 
95
124
  ```html
96
125
  <bds-button variant="default" size="medium">Save</bds-button>
97
- <bds-button variant="ghost">Cancel</bds-button>
126
+ <bds-button variant="outline">Cancel</bds-button>
98
127
  <bds-button href="/dashboard">Go to dashboard</bds-button>
128
+ <bds-button is-icon-only aria-label="Confirm">
129
+ <svg viewBox="0 0 24 24">…</svg>
130
+ </bds-button>
99
131
  ```
100
132
 
101
133
  ## Accessibility