React MyUI - Button

1. Design

Flexibility Through Props

The component is designed to be highly customizable through props:

  • variant: Changes the visual style (primary, secondary, outline, text)
  • size: Controls dimensions (small, medium, large)
  • fullWidth: Allows the button to span its container
  • loading: Shows a loading spinner and disables interaction
  • leftIcon/rightIcon: Adds visual elements before or after text

CSS Variable System

The component uses CSS variables for theming:

1
2
3
--color-primary
--color-primary-rgb
--color-secondary

2. Implementation (css and tsx)

packages/core/src/components/Button/Button.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
.myui-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: inherit;
font-weight: 500;
text-align: center;
vertical-align: middle;
cursor: pointer;
user-select: none;
border: 1px solid transparent;
border-radius: 4px;
transition: all 0.2s ease-in-out;
outline: none;
text-decoration: none;
}

.myui-button:focus {
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 52, 152, 219), 0.4);
}

.myui-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

/* Variants */
.myui-button--primary {
background-color: var(--color-primary);
color: white;
}

.myui-button--primary:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--color-primary) 90%, black);
}

.myui-button--secondary {
background-color: var(--color-secondary);
color: white;
}

.myui-button--secondary:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--color-secondary) 90%, black);
}

.myui-button--outline {
background-color: transparent;
border-color: var(--color-primary);
color: var(--color-primary);
}

.myui-button--outline:hover:not(:disabled) {
background-color: rgba(var(--color-primary-rgb, 52, 152, 219), 0.1);
}

.myui-button--text {
background-color: transparent;
border-color: transparent;
color: var(--color-primary);
padding-left: 0.5rem;
padding-right: 0.5rem;
}

.myui-button--text:hover:not(:disabled) {
background-color: rgba(var(--color-primary-rgb, 52, 152, 219), 0.05);
text-decoration: underline;
}

/* Sizes */
.myui-button--small {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
}

.myui-button--medium {
padding: 0.5rem 1rem;
font-size: 1rem;
line-height: 1.5;
}

.myui-button--large {
padding: 0.75rem 1.5rem;
font-size: 1.125rem;
line-height: 1.5;
}

/* Full width */
.myui-button--full-width {
display: flex;
width: 100%;
}

/* Loading state */
.myui-button--loading {
color: transparent !important;
}

.myui-button__loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1em;
height: 1em;
animation: myui-button-spin 1s linear infinite;
}

@keyframes myui-button-spin {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}

/* Icons */
.myui-button__icon {
display: inline-flex;
align-items: center;
justify-content: center;
}

.myui-button__icon--left {
margin-right: 0.5rem;
}

.myui-button__icon--right {
margin-left: 0.5rem;
}

packages/core/src/components/Button/Button.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import React, { ButtonHTMLAttributes } from 'react';
import './Button.css';

export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text';
export type ButtonSize = 'small' | 'medium' | 'large';

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {

/**
* Button variant
* @default 'primary'
*/
variant?: ButtonVariant;

/**
* Button size
* @default 'medium'
*/
size?: ButtonSize;

/**
* Is button full width
* @default false
*/
fullWidth?: boolean;

/**
* Is button in loading state
* @default false
*/
loading?: boolean;

/**
* Icon to display before button text
*/
leftIcon?: React.ReactNode;

/**
* Icon to display after button text
*/
rightIcon?: React.ReactNode;
}

export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'medium',
fullWidth = false,
loading = false,
leftIcon,
rightIcon,
disabled,
className = '',
...rest
}: ButtonProps) => {
const buttonClasses = [
'myui-button',
`myui-button--${variant}`,
`myui-button--${size}`,
fullWidth ? 'myui-button--full-width' : '',
loading ? 'myui-button--loading' : '',
className
].filter(Boolean).join(' ')

return (
<button
className={buttonClasses}
disabled={disabled || loading}
{...rest}
>
{loading && (
<span className="myui-button__loader" aria-hidden="true">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="4" strokeDasharray="60 20" />
</svg>
</span>
)}

{leftIcon && <span className="myui-button__icon myui-button__icon--left">{leftIcon}</span>}
<span className="myui-button__text">{children}</span>
{rightIcon && <span className="myui-button__icon myui-button__icon--right">{rightIcon}</span>}
</button>
)
}

export default Button;

3. Export Config

package/core/src/components/Button/index.ts

1
2
export * from './Button'
export { default } from './Button'

packages/core/src/index.ts
Add

1
export * from './components/Button';

4. StoryBook

packages/storybook/src/stories/components/Button.stories.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
import React from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { Button } from '@hardyhu-ui/core';

const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
argTypes: {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary', 'outline', 'text'],
description: 'Button variant style',
table: {
type: { summary: 'string' },
defaultValue: { summary: 'primary' },
},
},
size: {
control: { type: 'select' },
options: ['small', 'medium', 'large'],
description: 'Button size',
table: {
type: { summary: 'string' },
defaultValue: { summary: 'medium' },
},
},
disabled: {
control: 'boolean',
description: 'Disables the button',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
loading: {
control: 'boolean',
description: 'Shows loading state',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
fullWidth: {
control: 'boolean',
description: 'Makes button full width',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' },
},
},
onClick: { action: 'clicked' },
},
parameters: {
docs: {
description: {
component: 'A customizable button component with different variants and sizes.',
},
},
},
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
};

export const Secondary: Story = {
args: {
variant: "secondary",
children: 'Secondary Button',
},
};

export const Outline: Story = {
args: {
variant: 'outline',
children: 'Outline Button',
},
};

export const Text: Story = {
args: {
variant: 'text',
children: 'Text Button',
},
};

export const Sizes: Story = {
render: (args) => (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<Button size="small" onClick={args.onClick}>Small</Button>
<Button size="medium" onClick={args.onClick}>Medium</Button>
<Button size="large" onClick={args.onClick}>Large</Button>
</div>
),
};

export const Loading: Story = {
args: {
loading: true,
children: 'Loading...',
},
};

export const Disabled: Story = {
args: {
disabled: true,
children: 'Disabled Button',
},
};

export const FullWidth: Story = {
args: {
fullWidth: true,
children: 'Full Width Button',
},
};

export const WithIcons: Story = {
render: (args) => (
<div style={{ display: 'flex', gap: '1rem', flexDirection: 'column' }}>
<Button
leftIcon={<span style={{ fontSize: '1.2em' }}>🔍</span>}
onClick={args.onClick}
>
Search
</Button>
<Button
rightIcon={<span style={{ fontSize: '1.2em' }}></span>}
onClick={args.onClick}
>
Next
</Button>
<Button
leftIcon={<span style={{ fontSize: '1.2em' }}>🔄</span>}
rightIcon={<span style={{ fontSize: '1.2em' }}></span>}
onClick={args.onClick}
>
Refresh
</Button>
</div>
),
};

5. Jest

packages/core/src/components/Button/Button.test.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Button, ButtonVariant, ButtonSize } from './Button';

describe('Button Component', () => {
// Basic rendering and props tests
test('renders button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});

test('should apply default props when not specified', () => {
render(<Button>Default Button</Button>);
const button = screen.getByText('Default Button').closest('button');
expect(button).toHaveClass('myui-button--primary');
expect(button).toHaveClass('myui-button--medium');
expect(button).not.toHaveClass('myui-button--full-width');
expect(button).not.toHaveClass('myui-button--loading');
});

// Variant tests
test.each([
['primary' as ButtonVariant, 'myui-button--primary'],
['secondary' as ButtonVariant, 'myui-button--secondary'],
['outline' as ButtonVariant, 'myui-button--outline'],
['text' as ButtonVariant, 'myui-button--text'],
])('should apply %s variant class', (variant, expectedClass) => {
render(<Button variant={variant}>Variant Button</Button>);
const button = screen.getByText('Variant Button').closest('button');
expect(button).toHaveClass(expectedClass);
});

// Size tests
test.each([
['small' as ButtonSize, 'myui-button--small'],
['medium' as ButtonSize, 'myui-button--medium'],
['large' as ButtonSize, 'myui-button--large'],
])('should apply %s size class', (size, expectedClass) => {
render(<Button size={size}>Size Button</Button>);
const button = screen.getByText('Size Button').closest('button');
expect(button).toHaveClass(expectedClass);
});

// fullWidth test
test('should apply full-width class when fullWidth is true', () => {
render(<Button fullWidth>Full Width Button</Button>);
const button = screen.getByText('Full Width Button').closest('button');
expect(button).toHaveClass('myui-button--full-width');
});

// Loading state tests
test('should apply loading class and disable button when loading is true', () => {
render(<Button loading>Loading Button</Button>);
const button = screen.getByText('Loading Button').closest('button');
expect(button).toHaveClass('myui-button--loading');
expect(button).toBeDisabled();
expect(button?.querySelector('.myui-button__loader')).toBeInTheDocument();
});

// Disabled state test
test('should disable button when disabled is true', () => {
render(<Button disabled>Disabled Button</Button>);
const button = screen.getByText('Disabled Button').closest('button');
expect(button).toBeDisabled();
});

// Icon tests
test('should render left icon', () => {
const LeftIcon = () => <span data-testid="left-icon">🔍</span>;
render(<Button leftIcon={<LeftIcon />}>Button with Left Icon</Button>);

expect(screen.getByTestId('left-icon')).toBeInTheDocument();
const iconContainer = screen.getByTestId('left-icon').closest('.myui-button__icon');
expect(iconContainer).toHaveClass('myui-button__icon--left');
});

test('should render right icon', () => {
const RightIcon = () => <span data-testid="right-icon">➡️</span>;
render(<Button rightIcon={<RightIcon />}>Button with Right Icon</Button>);

expect(screen.getByTestId('right-icon')).toBeInTheDocument();
const iconContainer = screen.getByTestId('right-icon').closest('.myui-button__icon');
expect(iconContainer).toHaveClass('myui-button__icon--right');
});

// Custom class name test
test('should apply custom className', () => {
render(<Button className="custom-class">Custom Class Button</Button>);
const button = screen.getByText('Custom Class Button').closest('button');
expect(button).toHaveClass('custom-class');
});

// Event handler test
test('should call onClick when button is clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Clickable Button</Button>);

fireEvent.click(screen.getByText('Clickable Button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});

test('should not call onClick when disabled button is clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick} disabled>Disabled Button</Button>);

fireEvent.click(screen.getByText('Disabled Button'));
expect(handleClick).not.toHaveBeenCalled();
});

test('should not call onClick when loading button is clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick} loading>Loading Button</Button>);

fireEvent.click(screen.getByText('Loading Button'));
expect(handleClick).not.toHaveBeenCalled();
});

// DOM attributes test
test('should forward additional props to button element', () => {
render(
<Button data-testid="test-button" aria-label="Test Button">Props Button</Button>
);

const button = screen.getByText('Props Button').closest('button');
expect(button).toHaveAttribute('data-testid', 'test-button');
expect(button).toHaveAttribute('aria-label', 'Test Button');
});
});

React MyUI - Button
https://www.hardyhu.cn/2025/02/14/React-MyUI-Button/
Author
John Doe
Posted on
February 14, 2025
Licensed under