Skip to content

Commit df226ae

Browse files
authored
feat: Experimental buttons improvements (#488)
* feat: add text button variant and loading state * feat: small refactor and add loading state on icon button * test: update icon button tests * test: added experimental button tests * fix: background color * fix: spinner color on primary emphasis
1 parent 5e283b9 commit df226ae

File tree

6 files changed

+150
-25
lines changed

6 files changed

+150
-25
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import { Button } from './Button';
4+
5+
describe('Experimental: Button', () => {
6+
it('renders the button component', () => {
7+
const onPress = jest.fn();
8+
render(<Button onPress={onPress} />);
9+
expect(screen.getByTestId('button-container')).toBeInTheDocument();
10+
});
11+
12+
it('calls onPress when clicked', () => {
13+
const onPress = jest.fn();
14+
render(<Button onPress={onPress} />);
15+
screen.getByTestId('button-container').click();
16+
expect(onPress).toHaveBeenCalledTimes(1);
17+
});
18+
19+
it('does not call onPress when disabled', () => {
20+
const onPress = jest.fn();
21+
render(<Button onPress={onPress} isDisabled />);
22+
screen.getByTestId('button-container').click();
23+
expect(onPress).toHaveBeenCalledTimes(0);
24+
});
25+
26+
it('does not call onPress when is loading', () => {
27+
const onPress = jest.fn();
28+
render(<Button onPress={onPress} isLoading />);
29+
screen.getByTestId('button-container').click();
30+
expect(onPress).toHaveBeenCalledTimes(0);
31+
});
32+
33+
it('spinner is rendered when loading', () => {
34+
const onPress = jest.fn();
35+
render(<Button onPress={onPress} isLoading />);
36+
expect(screen.getByTestId('button-spinner')).toBeInTheDocument();
37+
});
38+
});

src/components/experimental/Button/Button.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@ import { Button as BaseButton, ButtonProps as BaseButtonProps } from 'react-aria
55
import { getSemanticValue } from '../../../essentials/experimental/cssVariables';
66
import { get } from '../../../utils/experimental/themeGet';
77
import { textStyles } from '../Text/Text';
8+
import { InlineSpinner } from '../../InlineSpinner/InlineSpinner';
89

9-
type Emphasis = 'primary' | 'secondary';
10+
type Emphasis = 'primary' | 'secondary' | 'textButton';
1011

1112
interface ButtonProps extends BaseButtonProps {
1213
/**
1314
* Define style of the button component, defaults to primary
1415
*/
1516
emphasis?: Emphasis;
17+
/**
18+
* Loading state, defaults to false
19+
*/
20+
isLoading?: boolean;
1621
}
1722

1823
const emphasisStyles = variant<Record<string, unknown>, Emphasis>({
@@ -45,6 +50,23 @@ const emphasisStyles = variant<Record<string, unknown>, Emphasis>({
4550
'&[data-disabled]::before': {
4651
opacity: 0.06
4752
}
53+
},
54+
textButton: {
55+
color: getSemanticValue('on-surface'),
56+
background: 'transparent',
57+
58+
'&::before': {
59+
background: getSemanticValue('interactive')
60+
},
61+
62+
'&[data-disabled]': {
63+
opacity: 0.38
64+
},
65+
66+
'&[data-disabled]::before': {
67+
opacity: 0.06,
68+
background: 'transparent'
69+
}
4870
}
4971
}
5072
});
@@ -62,7 +84,8 @@ const ButtonStyled = styled(BaseButton)<{ $emphasis: Emphasis }>`
6284
6385
cursor: pointer;
6486
65-
&[data-disabled] {
87+
&[data-disabled],
88+
&[data-pending] {
6689
cursor: not-allowed;
6790
}
6891
@@ -87,15 +110,25 @@ const ButtonStyled = styled(BaseButton)<{ $emphasis: Emphasis }>`
87110
opacity: 0.24;
88111
}
89112
113+
&[data-pending] {
114+
opacity: 0.38;
115+
}
116+
90117
${textStyles.variants.label1}
91118
92119
${emphasisStyles};
93120
`;
94121

95-
function Button({ children, emphasis = 'primary', ...restProps }: ButtonProps): ReactElement {
122+
const spinnerColor: Record<Emphasis, string> = {
123+
primary: getSemanticValue('on-accent'),
124+
secondary: getSemanticValue('on-surface'),
125+
textButton: getSemanticValue('on-surface')
126+
};
127+
128+
function Button({ children, emphasis = 'primary', isLoading = false, ...restProps }: ButtonProps): ReactElement {
96129
return (
97-
<ButtonStyled $emphasis={emphasis} {...restProps}>
98-
{children}
130+
<ButtonStyled data-testid="button-container" isPending={isLoading} $emphasis={emphasis} {...restProps}>
131+
{isLoading ? <InlineSpinner data-testid="button-spinner" color={spinnerColor[emphasis]} /> : children}
99132
</ButtonStyled>
100133
);
101134
}

src/components/experimental/Button/docs/Button.stories.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@ const meta: Meta = {
1818
},
1919
emphasis: {
2020
control: 'radio',
21-
options: ['primary', 'secondary']
21+
options: ['primary', 'secondary', 'textButton']
2222
},
2323
isDisabled: {
2424
control: 'boolean'
2525
},
26+
isLoading: {
27+
control: 'boolean'
28+
},
29+
2630
ref: {
2731
table: {
2832
disable: true
@@ -53,12 +57,24 @@ export const Secondary: Story = {
5357
}
5458
};
5559

60+
export const TextButton: Story = {
61+
args: {
62+
emphasis: 'textButton'
63+
}
64+
};
65+
5666
export const Disabled: Story = {
5767
args: {
5868
isDisabled: true
5969
}
6070
};
6171

72+
export const Loading: Story = {
73+
args: {
74+
isLoading: true
75+
}
76+
};
77+
6278
export const Focused: Story = {
6379
args: {
6480
autoFocus: true

src/components/experimental/IconButton/IconButton.spec.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ describe('Experimental: IconButton', () => {
2424
expect(onPress).toHaveBeenCalledTimes(0);
2525
});
2626

27+
it('does not call onPress when is loading', () => {
28+
const onPress = jest.fn();
29+
render(<IconButton Icon={TrashIcon} onPress={onPress} isLoading />);
30+
screen.getByTestId('standard-icon-container').click();
31+
expect(onPress).toHaveBeenCalledTimes(0);
32+
});
33+
2734
it('sets the right sizes for standard variant', () => {
2835
const onPress = jest.fn();
2936
render(<IconButton Icon={TrashIcon} onPress={onPress} />);
@@ -43,4 +50,10 @@ describe('Experimental: IconButton', () => {
4350
expect(containerStyle.height).toBe('3.5rem');
4451
expect(containerStyle.borderRadius).toBe('100%');
4552
});
53+
54+
it('spinner is rendered when loading', () => {
55+
const onPress = jest.fn();
56+
render(<IconButton Icon={TrashIcon} onPress={onPress} isLoading />);
57+
expect(screen.getByTestId('iconbutton-spinner')).toBeInTheDocument();
58+
});
4659
});

src/components/experimental/IconButton/IconButton.tsx

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import React from 'react';
1+
import React, { ReactElement } from 'react';
22
import styled from 'styled-components';
33
import { ButtonProps, Button } from 'react-aria-components';
44
import { IconProps } from '../../../icons';
55
import { getSemanticValue } from '../../../essentials/experimental';
6+
import { InlineSpinner } from '../../InlineSpinner/InlineSpinner';
67

78
export interface IconButtonProps extends ButtonProps {
89
isActive?: boolean;
10+
isLoading?: boolean;
911
variant?: 'standard' | 'tonal';
1012
Icon: React.FC<IconProps>;
1113
onPress: () => void;
@@ -18,6 +20,13 @@ const StandardIconContainer = styled(Button)<Omit<IconButtonProps, 'Icon'>>`
1820
background-color: transparent;
1921
border-color: transparent;
2022
23+
cursor: pointer;
24+
25+
&[data-disabled],
26+
&[data-pending] {
27+
cursor: not-allowed;
28+
}
29+
2130
/* we create a before pseudo element to mess with the opacity (see the hovered state) */
2231
&::before {
2332
position: absolute;
@@ -42,7 +51,8 @@ const StandardIconContainer = styled(Button)<Omit<IconButtonProps, 'Icon'>>`
4251
color: ${props => (props.isActive ? getSemanticValue('interactive') : getSemanticValue('on-surface'))};
4352
}
4453
45-
&[data-disabled] {
54+
&[data-disabled],
55+
&[data-pending] {
4656
opacity: 0.38;
4757
}
4858
`;
@@ -54,6 +64,13 @@ const TonalIconContainer = styled(Button)<Omit<IconButtonProps, 'Icon'>>`
5464
border-color: transparent;
5565
background: none;
5666
67+
cursor: pointer;
68+
69+
&[data-disabled],
70+
&[data-pending] {
71+
cursor: not-allowed;
72+
}
73+
5774
/* we create a before pseudo element to mess with the opacity (see the hovered state) */
5875
&::before {
5976
position: absolute;
@@ -88,34 +105,35 @@ const TonalIconContainer = styled(Button)<Omit<IconButtonProps, 'Icon'>>`
88105
props.isActive ? getSemanticValue('on-interactive-container') : getSemanticValue('on-surface')};
89106
}
90107
91-
&[data-disabled] {
108+
&[data-disabled],
109+
&[data-pending] {
92110
opacity: 0.38;
93111
}
94112
`;
95113

96114
export const IconButton = ({
97115
isDisabled = false,
98116
isActive = false,
117+
isLoading = false,
99118
Icon,
100119
variant = 'standard',
101120
onPress
102-
}: IconButtonProps) =>
103-
variant === 'standard' ? (
104-
<StandardIconContainer
105-
data-testid="standard-icon-container"
106-
onPress={onPress}
107-
isDisabled={isDisabled}
108-
isActive={isActive}
109-
>
110-
<Icon data-testid="iconbutton-icon" />
111-
</StandardIconContainer>
112-
) : (
113-
<TonalIconContainer
114-
data-testid="tonal-icon-container"
121+
}: IconButtonProps): ReactElement => {
122+
const Container = variant === 'standard' ? StandardIconContainer : TonalIconContainer;
123+
124+
return (
125+
<Container
126+
data-testid={variant === 'standard' ? 'standard-icon-container' : 'tonal-icon-container'}
115127
onPress={onPress}
116128
isDisabled={isDisabled}
117129
isActive={isActive}
130+
isPending={isLoading}
118131
>
119-
<Icon data-testid="iconbutton-icon" />
120-
</TonalIconContainer>
132+
{isLoading ? (
133+
<InlineSpinner data-testid="iconbutton-spinner" color={getSemanticValue('on-surface')} />
134+
) : (
135+
<Icon data-testid="iconbutton-icon" />
136+
)}
137+
</Container>
121138
);
139+
};

src/components/experimental/IconButton/docs/IconButton.stories.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ const meta: Meta = {
1111
args: {
1212
Icon: TrashIcon,
1313
onPress: () => alert('Clicked!'),
14-
isDisabled: false
14+
isDisabled: false,
15+
isLoading: false
1516
}
1617
};
1718

@@ -27,6 +28,12 @@ export const Disabled: Story = {
2728
}
2829
};
2930

31+
export const Loading: Story = {
32+
args: {
33+
isLoading: true
34+
}
35+
};
36+
3037
export const Active: Story = {
3138
args: {
3239
isActive: true

0 commit comments

Comments
 (0)