Skip to content

Commit 7fd91b4

Browse files
authored
feat: experimental IconButton (#485)
* chore: adding some tests * fix: trying to fix the styles * fix: fixing the styles and updating the tests * fix: fixing comment syntax and styles * fix: fixing the sizes and inheriting the properties to avoid redefining * fix: using flexbox * fix: adding exports
1 parent d8c9493 commit 7fd91b4

File tree

4 files changed

+216
-0
lines changed

4 files changed

+216
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import { IconButton } from './IconButton';
4+
import { TrashIcon } from '../../../icons';
5+
6+
describe('Experimental: IconButton', () => {
7+
it('renders an icon button with the provided icon', () => {
8+
const onPress = jest.fn();
9+
render(<IconButton onPress={onPress} Icon={TrashIcon} />);
10+
expect(screen.getByTestId('standard-icon-container')).toBeInTheDocument();
11+
});
12+
13+
it('calls onPress when clicked', () => {
14+
const onPress = jest.fn();
15+
render(<IconButton Icon={TrashIcon} onPress={onPress} />);
16+
screen.getByTestId('standard-icon-container').click();
17+
expect(onPress).toHaveBeenCalledTimes(1);
18+
});
19+
20+
it('does not call onPress when disabled', () => {
21+
const onPress = jest.fn();
22+
render(<IconButton Icon={TrashIcon} onPress={onPress} isDisabled />);
23+
screen.getByTestId('standard-icon-container').click();
24+
expect(onPress).toHaveBeenCalledTimes(0);
25+
});
26+
27+
it('sets the right sizes for standard variant', () => {
28+
const onPress = jest.fn();
29+
render(<IconButton Icon={TrashIcon} onPress={onPress} />);
30+
const iconContainerInstance = screen.getByTestId('standard-icon-container');
31+
const containerStyle = window.getComputedStyle(iconContainerInstance);
32+
expect(containerStyle.width).toBe('2.5rem');
33+
expect(containerStyle.height).toBe('2.5rem');
34+
expect(containerStyle.borderRadius).toBe('100%');
35+
});
36+
37+
it('sets the right sizes for tonal variant', () => {
38+
const onPress = jest.fn();
39+
render(<IconButton Icon={TrashIcon} onPress={onPress} variant="tonal" />);
40+
const iconContainerInstance = screen.getByTestId('tonal-icon-container');
41+
const containerStyle = window.getComputedStyle(iconContainerInstance);
42+
expect(containerStyle.width).toBe('3.5rem');
43+
expect(containerStyle.height).toBe('3.5rem');
44+
expect(containerStyle.borderRadius).toBe('100%');
45+
});
46+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import React from 'react';
2+
import styled from 'styled-components';
3+
import { ButtonProps, Button } from 'react-aria-components';
4+
import { IconProps } from '../../../icons';
5+
import { getSemanticValue } from '../../../essentials/experimental';
6+
7+
export interface IconButtonProps extends ButtonProps {
8+
isActive?: boolean;
9+
variant?: 'standard' | 'tonal';
10+
Icon: React.FC<IconProps>;
11+
onPress: () => void;
12+
}
13+
14+
const StandardIconContainer = styled(Button)<Omit<IconButtonProps, 'Icon'>>`
15+
height: 2.5rem;
16+
width: 2.5rem;
17+
border-radius: 100%;
18+
background-color: transparent;
19+
border-color: transparent;
20+
21+
/* we create a before pseudo element to mess with the opacity (see the hovered state) */
22+
&::before {
23+
position: absolute;
24+
content: '';
25+
border-radius: inherit;
26+
opacity: 0;
27+
height: inherit;
28+
width: inherit;
29+
}
30+
31+
/* we want to change the opacity here but not affect the icon, so we have to use the before pseudo element */
32+
&[data-hovered]::before {
33+
opacity: 0.16;
34+
background-color: ${getSemanticValue('on-surface')};
35+
}
36+
37+
display: flex;
38+
align-items: center;
39+
justify-content: center;
40+
41+
&:not([data-disabled]) {
42+
color: ${props => (props.isActive ? getSemanticValue('interactive') : getSemanticValue('on-surface'))};
43+
}
44+
45+
&[data-disabled] {
46+
opacity: 0.38;
47+
}
48+
`;
49+
50+
const TonalIconContainer = styled(Button)<Omit<IconButtonProps, 'Icon'>>`
51+
height: 3.5rem;
52+
width: 3.5rem;
53+
border-radius: 100%;
54+
border-color: transparent;
55+
background: none;
56+
57+
/* we create a before pseudo element to mess with the opacity (see the hovered state) */
58+
&::before {
59+
position: absolute;
60+
content: '';
61+
border-radius: inherit;
62+
height: inherit;
63+
width: inherit;
64+
background-color: ${props =>
65+
props.isActive && !props.isDisabled
66+
? getSemanticValue('interactive-container')
67+
: getSemanticValue('surface')};
68+
z-index: -1;
69+
}
70+
71+
/* we want to change the opacity here but not affect the icon, so we have to use the before pseudo element */
72+
&[data-hovered]::before {
73+
background-color: color-mix(
74+
in hsl,
75+
${getSemanticValue('on-surface')} 100%,
76+
${props => (props.isActive ? getSemanticValue('interactive-container') : getSemanticValue('on-surface'))}
77+
100%
78+
);
79+
opacity: 0.16;
80+
}
81+
82+
display: flex;
83+
align-items: center;
84+
justify-content: center;
85+
86+
&:not([data-disabled]) {
87+
color: ${props =>
88+
props.isActive ? getSemanticValue('on-interactive-container') : getSemanticValue('on-surface')};
89+
}
90+
91+
&[data-disabled] {
92+
opacity: 0.38;
93+
}
94+
`;
95+
96+
export const IconButton = ({
97+
isDisabled = false,
98+
isActive = false,
99+
Icon,
100+
variant = 'standard',
101+
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"
115+
onPress={onPress}
116+
isDisabled={isDisabled}
117+
isActive={isActive}
118+
>
119+
<Icon data-testid="iconbutton-icon" />
120+
</TonalIconContainer>
121+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { StoryObj, Meta } from '@storybook/react';
2+
import { IconButton } from '../IconButton';
3+
import { TrashIcon } from '../../../../icons';
4+
5+
const meta: Meta = {
6+
title: 'Experimental/Components/IconButton',
7+
component: IconButton,
8+
parameters: {
9+
layout: 'centered'
10+
},
11+
args: {
12+
Icon: TrashIcon,
13+
onPress: () => alert('Clicked!'),
14+
isDisabled: false
15+
}
16+
};
17+
18+
export default meta;
19+
20+
type Story = StoryObj<typeof IconButton>;
21+
22+
export const Default: Story = {};
23+
24+
export const Disabled: Story = {
25+
args: {
26+
isDisabled: true
27+
}
28+
};
29+
30+
export const Active: Story = {
31+
args: {
32+
isActive: true
33+
}
34+
};
35+
36+
export const Tonal: Story = {
37+
args: {
38+
variant: 'tonal'
39+
}
40+
};
41+
42+
export const TonalActive: Story = {
43+
args: {
44+
variant: 'tonal',
45+
isActive: true
46+
}
47+
};

src/components/experimental/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export { Chip } from './Chip/Chip';
44
export { ComboBox } from './ComboBox/ComboBox';
55
export { DateField } from './DateField/DateField';
66
export { DatePicker } from './DatePicker/DatePicker';
7+
export { Divider } from './Divider/Divider';
8+
export { IconButton } from './IconButton/IconButton';
79
export { Label } from './Label/Label';
810
export { ListBox, ListBoxItem } from './ListBox/ListBox';
911
export { Popover } from './Popover/Popover';

0 commit comments

Comments
 (0)