@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.
- package/README.md +2 -1
- package/dist/client.cjs +52 -50
- package/dist/client.css +536 -523
- package/dist/client.d.cts +3 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +52 -50
- package/dist/index.cjs +52 -50
- package/dist/index.css +536 -523
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +52 -50
- package/dist/native/index.cjs +5 -2
- package/dist/native/index.d.cts +3 -1
- package/dist/native/index.d.ts +3 -1
- package/dist/native/index.js +5 -2
- package/dist/web-components/{chunk-3REOIRDW.js → chunk-N3TN6WCH.js} +26 -1
- package/dist/web-components/index.js +1 -1
- package/dist/web-components/interaction/bds-button.d.ts +7 -0
- package/dist/web-components/interaction/bds-button.js +1 -1
- package/package.json +1 -1
- package/src/components/interaction/Button/Button.mdx +81 -36
- package/src/components/interaction/Button/Button.module.css +24 -0
- package/src/components/interaction/Button/Button.native.mdx +31 -12
- package/src/components/interaction/Button/Button.native.spec.tsx +20 -0
- package/src/components/interaction/Button/Button.native.stories.tsx +110 -9
- package/src/components/interaction/Button/Button.native.tsx +13 -4
- package/src/components/interaction/Button/Button.spec.tsx +16 -0
- package/src/components/interaction/Button/Button.stories.tsx +134 -16
- package/src/components/interaction/Button/Button.tsx +4 -0
- package/src/components/layout/IconWrapper/IconWrapper.stories.tsx +46 -14
- package/src/web-components/interaction/BdsButton.mdx +46 -14
- package/src/web-components/interaction/BdsButton.stories.tsx +171 -19
- package/src/web-components/interaction/bds-button.spec.ts +35 -0
- 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
|
|
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: {
|
|
9
|
-
|
|
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
|
-
|
|
86
|
+
args: { variant: 'default', size: 'medium' },
|
|
87
|
+
};
|
|
13
88
|
|
|
14
89
|
export default meta;
|
|
15
|
-
type Story = StoryObj<
|
|
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'
|
|
18
|
-
export const
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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', '
|
|
36
|
-
['small', 'medium', 'large'].map(s => (
|
|
37
|
-
<Button key={`${v}-${s}`} variant={v} size={s
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
##
|
|
27
|
+
## Playground
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" \| "
|
|
58
|
-
| `size` | `"small" \| "medium" \| "large"` | `"medium"` | Height and padding
|
|
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
|
|
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="
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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: {
|
|
36
|
-
|
|
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
|
-
|
|
145
|
+
args: { variant: 'default', size: 'medium' },
|
|
146
|
+
};
|
|
39
147
|
|
|
40
148
|
export default meta;
|
|
41
|
-
type Story = StoryObj<
|
|
149
|
+
type Story = StoryObj<BdsButtonStoryArgs>;
|
|
42
150
|
|
|
43
|
-
export const
|
|
44
|
-
|
|
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', '
|
|
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
|
});
|