@boostdev/design-system-components 2.0.0 → 2.1.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/README.md +2 -1
  2. package/dist/client.cjs +52 -50
  3. package/dist/client.css +536 -523
  4. package/dist/client.d.cts +3 -1
  5. package/dist/client.d.ts +3 -1
  6. package/dist/client.js +52 -50
  7. package/dist/index.cjs +52 -50
  8. package/dist/index.css +536 -523
  9. package/dist/index.d.cts +3 -1
  10. package/dist/index.d.ts +3 -1
  11. package/dist/index.js +52 -50
  12. package/dist/native/index.cjs +5 -2
  13. package/dist/native/index.d.cts +3 -1
  14. package/dist/native/index.d.ts +3 -1
  15. package/dist/native/index.js +5 -2
  16. package/dist/web-components/{chunk-3REOIRDW.js → chunk-N3TN6WCH.js} +26 -1
  17. package/dist/web-components/index.js +1 -1
  18. package/dist/web-components/interaction/bds-button.d.ts +7 -0
  19. package/dist/web-components/interaction/bds-button.js +1 -1
  20. package/package.json +1 -1
  21. package/src/components/interaction/Button/Button.mdx +81 -36
  22. package/src/components/interaction/Button/Button.module.css +24 -0
  23. package/src/components/interaction/Button/Button.native.mdx +31 -12
  24. package/src/components/interaction/Button/Button.native.spec.tsx +20 -0
  25. package/src/components/interaction/Button/Button.native.stories.tsx +110 -9
  26. package/src/components/interaction/Button/Button.native.tsx +13 -4
  27. package/src/components/interaction/Button/Button.spec.tsx +16 -0
  28. package/src/components/interaction/Button/Button.stories.tsx +134 -16
  29. package/src/components/interaction/Button/Button.tsx +4 -0
  30. package/src/components/layout/IconWrapper/IconWrapper.stories.tsx +46 -14
  31. package/src/web-components/interaction/BdsButton.mdx +46 -14
  32. package/src/web-components/interaction/BdsButton.stories.tsx +171 -19
  33. package/src/web-components/interaction/bds-button.spec.ts +35 -0
  34. package/src/web-components/interaction/bds-button.ts +28 -1
@@ -47,4 +47,20 @@ describe('Button', () => {
47
47
  render(<Button iconEnd={<span data-testid="icon">★</span>}>Label</Button>);
48
48
  expect(screen.getByTestId('icon')).toBeInTheDocument();
49
49
  });
50
+
51
+ it('applies --iconOnly class when isIconOnly is true', () => {
52
+ render(
53
+ <Button isIconOnly aria-label="Close">
54
+ <span data-testid="icon">×</span>
55
+ </Button>
56
+ );
57
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
58
+ expect(screen.getByTestId('icon')).toBeInTheDocument();
59
+ expect(screen.getByRole('button').className).toContain('--iconOnly');
60
+ });
61
+
62
+ it('does not apply --iconOnly class by default', () => {
63
+ render(<Button>Click</Button>);
64
+ expect(screen.getByRole('button').className).not.toContain('--iconOnly');
65
+ });
50
66
  });
@@ -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
 
@@ -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' } };
@@ -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
@@ -1,56 +1,188 @@
1
- import React from 'react';
1
+ import React, { type ReactNode } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react';
3
3
  import type { ButtonVariant, ButtonSize } from './bds-button';
4
4
  import '../index'; // auto-registers all custom elements
5
5
 
6
- // Thin React wrapper so Storybook controls work with the custom element's props
6
+ const CheckIcon = (
7
+ <svg
8
+ slot="icon-start"
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ viewBox="0 0 24 24"
11
+ width="1em"
12
+ height="1em"
13
+ aria-hidden
14
+ fill="currentColor"
15
+ >
16
+ <path d="M12 0a12 12 0 1 0 12 12A12 12 0 0 0 12 0Zm6.93 8.2-6.85 9.29a1 1 0 0 1-1.43.19l-4.89-3.91a1 1 0 0 1-.15-1.41A1 1 0 0 1 7 12.21l4.08 3.26L17.32 7a1 1 0 0 1 1.39-.21 1 1 0 0 1 .22 1.41Z" />
17
+ </svg>
18
+ );
19
+
20
+ const ArrowRightStart = <span slot="icon-start" aria-hidden>→</span>;
21
+ const ArrowRightEnd = <span slot="icon-end" aria-hidden>→</span>;
22
+ const ArrowLeftStart = <span slot="icon-start" aria-hidden>←</span>;
23
+
24
+ // Icon for icon-only buttons — goes into the default slot (no slot attribute)
25
+ const CheckIconDefault = (
26
+ <svg
27
+ xmlns="http://www.w3.org/2000/svg"
28
+ viewBox="0 0 24 24"
29
+ width="1em"
30
+ height="1em"
31
+ aria-hidden
32
+ fill="currentColor"
33
+ >
34
+ <path d="M12 0a12 12 0 1 0 12 12A12 12 0 0 0 12 0Zm6.93 8.2-6.85 9.29a1 1 0 0 1-1.43.19l-4.89-3.91a1 1 0 0 1-.15-1.41A1 1 0 0 1 7 12.21l4.08 3.26L17.32 7a1 1 0 0 1 1.39-.21 1 1 0 0 1 .22 1.41Z" />
35
+ </svg>
36
+ );
37
+
38
+ const iconStartMap = {
39
+ none: undefined,
40
+ check: CheckIcon,
41
+ arrowRight: ArrowRightStart,
42
+ arrowLeft: ArrowLeftStart,
43
+ };
44
+
45
+ const iconEndMap = {
46
+ none: undefined,
47
+ arrowRight: ArrowRightEnd,
48
+ };
49
+
50
+ type IconStartKey = keyof typeof iconStartMap;
51
+ type IconEndKey = keyof typeof iconEndMap;
52
+
53
+ // React wrapper translating typed props into the custom element's attributes/slots
54
+ interface BdsButtonProps {
55
+ variant?: ButtonVariant;
56
+ size?: ButtonSize;
57
+ disabled?: boolean;
58
+ href?: string;
59
+ hasPulse?: boolean;
60
+ isIconOnly?: boolean;
61
+ 'aria-label'?: string;
62
+ iconStart?: ReactNode;
63
+ iconEnd?: ReactNode;
64
+ children?: ReactNode;
65
+ }
66
+
7
67
  function BdsButton({
8
68
  variant = 'default',
9
69
  size = 'medium',
10
70
  disabled,
11
71
  href,
12
72
  hasPulse,
73
+ isIconOnly,
74
+ 'aria-label': ariaLabel,
75
+ iconStart,
76
+ iconEnd,
13
77
  children,
14
- }: {
15
- variant?: ButtonVariant;
16
- size?: ButtonSize;
17
- disabled?: boolean;
18
- href?: string;
19
- hasPulse?: boolean;
20
- children?: React.ReactNode;
21
- }) {
78
+ }: BdsButtonProps) {
22
79
  return React.createElement(
23
80
  'bds-button',
24
- { variant, size, disabled: disabled || undefined, href, 'has-pulse': hasPulse || undefined },
81
+ {
82
+ variant,
83
+ size,
84
+ disabled: disabled || undefined,
85
+ href,
86
+ 'has-pulse': hasPulse || undefined,
87
+ 'is-icon-only': isIconOnly || undefined,
88
+ 'aria-label': ariaLabel,
89
+ },
90
+ iconStart,
25
91
  children,
92
+ iconEnd,
26
93
  );
27
94
  }
28
95
 
29
- const meta = {
96
+ type BdsButtonStoryArgs = Omit<BdsButtonProps, 'iconStart' | 'iconEnd'> & {
97
+ iconStart?: IconStartKey;
98
+ iconEnd?: IconEndKey;
99
+ };
100
+
101
+ const meta: Meta<BdsButtonStoryArgs> = {
30
102
  title: 'Web Components/Interaction/Button',
31
103
  component: BdsButton,
32
104
  tags: ['!stable', 'experimental'],
33
105
  parameters: { layout: 'centered' },
34
106
  argTypes: {
35
- variant: { control: 'select', options: ['default', 'ghost'] },
36
- size: { control: 'select', options: ['small', 'medium', 'large'] },
107
+ variant: {
108
+ control: 'select',
109
+ options: ['default', 'outline'],
110
+ description: '`default` is filled; `outline` is transparent with a border.',
111
+ table: { defaultValue: { summary: 'default' } },
112
+ },
113
+ size: {
114
+ control: 'select',
115
+ options: ['small', 'medium', 'large'],
116
+ description: 'Height + horizontal padding.',
117
+ table: { defaultValue: { summary: 'medium' } },
118
+ },
119
+ iconStart: {
120
+ control: 'select',
121
+ options: Object.keys(iconStartMap) as IconStartKey[],
122
+ mapping: iconStartMap,
123
+ description: 'Icon rendered in the `icon-start` slot.',
124
+ },
125
+ iconEnd: {
126
+ control: 'select',
127
+ options: Object.keys(iconEndMap) as IconEndKey[],
128
+ mapping: iconEndMap,
129
+ description: 'Icon rendered in the `icon-end` slot.',
130
+ },
131
+ isIconOnly: {
132
+ control: 'boolean',
133
+ description: 'Forces 1:1 aspect ratio with minimal xs padding. Composable with any `variant`. Requires `aria-label`.',
134
+ table: { defaultValue: { summary: 'false' } },
135
+ },
136
+ hasPulse: {
137
+ control: 'boolean',
138
+ description: 'Adds a pulsing animation for CTA emphasis.',
139
+ table: { defaultValue: { summary: 'false' } },
140
+ },
141
+ disabled: { control: 'boolean' },
142
+ href: { control: 'text', description: 'When set, renders as `<a>` with button styling.' },
143
+ 'aria-label': { control: 'text', description: 'Required for icon-only buttons.' },
37
144
  },
38
- } satisfies Meta<typeof BdsButton>;
145
+ args: { variant: 'default', size: 'medium' },
146
+ };
39
147
 
40
148
  export default meta;
41
- type Story = StoryObj<typeof meta>;
149
+ type Story = StoryObj<BdsButtonStoryArgs>;
42
150
 
43
- export const Default: Story = { args: { children: 'Default', variant: 'default' } };
44
- export const Ghost: Story = { args: { children: 'Ghost', variant: 'ghost' } };
151
+ export const Playground: Story = {
152
+ args: { children: 'Button', iconStart: 'none', iconEnd: 'none' },
153
+ };
154
+
155
+ export const Default: Story = { args: { children: 'Default' } };
156
+ export const Outline: Story = { args: { children: 'Outline', variant: 'outline' } };
45
157
  export const Small: Story = { args: { children: 'Small', size: 'small' } };
46
158
  export const Medium: Story = { args: { children: 'Medium', size: 'medium' } };
47
159
  export const Large: Story = { args: { children: 'Large', size: 'large' } };
48
160
  export const WithPulse: Story = { args: { children: 'Pulsing', hasPulse: true } };
49
161
  export const Disabled: Story = { args: { children: 'Disabled', disabled: true } };
162
+ export const WithIconStart: Story = { args: { children: 'Confirm', iconStart: 'check' } };
163
+ export const WithIconEnd: Story = { args: { children: 'Continue', iconEnd: 'arrowRight' } };
164
+
165
+ export const IconOnly: Story = {
166
+ args: {
167
+ isIconOnly: true,
168
+ 'aria-label': 'Confirm',
169
+ children: CheckIconDefault,
170
+ },
171
+ };
172
+
173
+ export const IconOnlyOutline: Story = {
174
+ args: {
175
+ variant: 'outline',
176
+ isIconOnly: true,
177
+ 'aria-label': 'Confirm',
178
+ children: CheckIconDefault,
179
+ },
180
+ };
181
+
50
182
  export const AllVariants: Story = {
51
183
  render: () => (
52
184
  <div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
53
- {(['default', 'ghost'] as const).flatMap(v =>
185
+ {(['default', 'outline'] as const).flatMap(v =>
54
186
  (['small', 'medium', 'large'] as const).map(s => (
55
187
  <BdsButton key={`${v}-${s}`} variant={v} size={s}>{v} {s}</BdsButton>
56
188
  )),
@@ -58,3 +190,23 @@ export const AllVariants: Story = {
58
190
  </div>
59
191
  ),
60
192
  };
193
+
194
+ export const AllIconOnly: Story = {
195
+ render: () => (
196
+ <div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
197
+ {(['default', 'outline'] as const).flatMap(v =>
198
+ (['small', 'medium', 'large'] as const).map(s => (
199
+ <BdsButton
200
+ key={`${v}-${s}`}
201
+ variant={v}
202
+ size={s}
203
+ isIconOnly
204
+ aria-label={`${v} ${s}`}
205
+ >
206
+ {CheckIconDefault}
207
+ </BdsButton>
208
+ )),
209
+ )}
210
+ </div>
211
+ ),
212
+ };
@@ -92,4 +92,39 @@ describe('bds-button', () => {
92
92
  expect(a!.getAttribute('rel')).toBe('noopener');
93
93
  cleanup(el);
94
94
  });
95
+
96
+ it('applies is-icon-only class when is-icon-only is set', async () => {
97
+ const el = await fixture('<bds-button is-icon-only aria-label="Confirm"><svg></svg></bds-button>');
98
+ const btn = el.shadowRoot!.querySelector('button');
99
+ expect(btn!.className).toContain('is-icon-only');
100
+ cleanup(el);
101
+ });
102
+
103
+ it('does not apply is-icon-only class by default', async () => {
104
+ const el = await fixture('<bds-button>Click</bds-button>');
105
+ const btn = el.shadowRoot!.querySelector('button');
106
+ expect(btn!.className).not.toContain('is-icon-only');
107
+ cleanup(el);
108
+ });
109
+
110
+ it('reflects the is-icon-only attribute on the host', async () => {
111
+ const el = await fixture('<bds-button is-icon-only aria-label="Confirm"></bds-button>');
112
+ expect(el.hasAttribute('is-icon-only')).toBe(true);
113
+ cleanup(el);
114
+ });
115
+
116
+ it('composes is-icon-only with outline variant', async () => {
117
+ const el = await fixture('<bds-button variant="outline" is-icon-only aria-label="Confirm"></bds-button>');
118
+ const btn = el.shadowRoot!.querySelector('button');
119
+ expect(btn!.className).toContain('outline');
120
+ expect(btn!.className).toContain('is-icon-only');
121
+ cleanup(el);
122
+ });
123
+
124
+ it('forwards aria-label to the inner button', async () => {
125
+ const el = await fixture('<bds-button is-icon-only aria-label="Close"></bds-button>');
126
+ const btn = el.shadowRoot!.querySelector('button');
127
+ expect(btn!.getAttribute('aria-label')).toBe('Close');
128
+ cleanup(el);
129
+ });
95
130
  });