Skip to content

Commit f752ead

Browse files
authored
feat: experimental checkbox component (#525)
* feat: experimental checkbox component * feat: indeterminate state * test: checkbox unit test * feat: component refactor and style updates based on specs * fix: no descending specificity * chore: remove comment * chore: remove components * fix: slight position adjustment * fix: padding tweak
1 parent 4c5c2ac commit f752ead

File tree

6 files changed

+364
-2
lines changed

6 files changed

+364
-2
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import * as React from 'react';
5+
import { Checkbox } from './Checkbox';
6+
7+
describe('Checkbox', () => {
8+
it('selects/unselects the checkbox on clicking the label', async () => {
9+
const user = userEvent.setup();
10+
const labelText = 'Click Me!';
11+
const onChangeCallback = jest.fn();
12+
13+
render(<Checkbox label={labelText} onChange={isSelected => onChangeCallback(isSelected)} />);
14+
15+
const label = screen.getByText(labelText);
16+
17+
await user.click(label);
18+
expect(onChangeCallback).toHaveBeenCalledWith(true);
19+
20+
await user.click(label);
21+
expect(onChangeCallback).toHaveBeenCalledWith(false);
22+
});
23+
24+
it('renders with default selected state', () => {
25+
render(<Checkbox label="Test Checkbox" defaultSelected />);
26+
const checkbox = screen.getByRole('checkbox');
27+
expect(checkbox).toBeChecked();
28+
});
29+
30+
it('renders in disabled state', async () => {
31+
const user = userEvent.setup();
32+
const onChangeCallback = jest.fn();
33+
34+
render(<Checkbox label="Disabled Checkbox" isDisabled onChange={onChangeCallback} />);
35+
36+
const checkbox = screen.getByRole('checkbox');
37+
expect(checkbox).toBeDisabled();
38+
39+
await user.click(checkbox);
40+
expect(onChangeCallback).not.toHaveBeenCalled();
41+
});
42+
43+
it('renders in invalid state', () => {
44+
render(<Checkbox label="Invalid Checkbox" isInvalid />);
45+
const checkbox = screen.getByRole('checkbox');
46+
expect(checkbox).toHaveAttribute('aria-invalid', 'true');
47+
});
48+
49+
it('renders in indeterminate state', () => {
50+
render(<Checkbox label="Indeterminate Checkbox" isIndeterminate />);
51+
const checkbox = screen.getByRole('checkbox');
52+
const container = checkbox.closest('[data-indeterminate="true"]');
53+
expect(container).toBeInTheDocument();
54+
});
55+
});
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import React, { FC, ReactNode } from 'react';
2+
import { Checkbox as CheckboxComponent, CheckboxProps as ReactAriaCheckboxProps } from 'react-aria-components';
3+
import styled from 'styled-components';
4+
5+
import { Text } from '../Text/Text';
6+
import { LabelWrapper } from './components/LabelWrapper';
7+
8+
import { getSemanticValue, themeGet } from '../../../experimental';
9+
10+
type TextVariant = 'display' | 'headline' | 'title1' | 'title2' | 'body1' | 'body2' | 'label1' | 'label2';
11+
12+
interface CheckboxProps extends Omit<ReactAriaCheckboxProps, 'children'> {
13+
/**
14+
* Provide a label for the input which will be shown next to the checkbox
15+
*/
16+
label?: ReactNode;
17+
/**
18+
* Text variant for the label
19+
*/
20+
variant?: TextVariant;
21+
}
22+
23+
const StyledCheckbox = styled(CheckboxComponent)`
24+
--selected-color: ${getSemanticValue('accent')};
25+
--selected-color-pressed: ${getSemanticValue('interactive')};
26+
--checkmark-color: ${getSemanticValue('surface')};
27+
28+
position: relative;
29+
display: inline-flex;
30+
align-items: center;
31+
forced-color-adjust: none;
32+
cursor: pointer;
33+
34+
.checkbox {
35+
width: ${themeGet('space.5')};
36+
height: ${themeGet('space.5')};
37+
border: 2px solid ${getSemanticValue('divider')};
38+
border-radius: ${themeGet('radii.2')};
39+
transition: all 200ms;
40+
display: flex;
41+
align-items: center;
42+
justify-content: center;
43+
flex-shrink: 0;
44+
45+
&:hover {
46+
border-color: ${getSemanticValue('interactive')};
47+
}
48+
}
49+
50+
svg {
51+
position: absolute;
52+
width: 65%;
53+
height: 62%;
54+
top: 45%;
55+
left: 53%;
56+
transform: translate(-45%, -40%);
57+
fill: none;
58+
stroke: ${getSemanticValue('surface')};
59+
stroke-width: 3px;
60+
stroke-dasharray: 22px;
61+
stroke-dashoffset: 66;
62+
transition: all 200ms;
63+
}
64+
65+
&[data-pressed] .checkbox {
66+
border-color: ${getSemanticValue('surface-variant')};
67+
}
68+
69+
&[data-focus-visible] .checkbox {
70+
outline: 2px solid ${getSemanticValue('surface-variant')};
71+
outline-offset: 2px;
72+
}
73+
74+
&[data-disabled] {
75+
color: transparent;
76+
cursor: not-allowed;
77+
78+
.checkbox {
79+
background-color: ${getSemanticValue('surface')};
80+
border-color: ${getSemanticValue('surface-variant')};
81+
}
82+
}
83+
84+
&[data-invalid] .checkbox {
85+
border-color: ${getSemanticValue('negative-variant')};
86+
}
87+
88+
&[data-selected] .checkbox,
89+
&[data-indeterminate] .checkbox {
90+
border-color: ${getSemanticValue('accent')};
91+
background: ${getSemanticValue('accent')};
92+
}
93+
94+
&[data-selected] svg,
95+
&[data-indeterminate] svg {
96+
stroke-dashoffset: 44;
97+
}
98+
99+
&[data-indeterminate] svg {
100+
stroke: none;
101+
fill: ${getSemanticValue('surface')};
102+
left: 52%;
103+
}
104+
105+
&[data-invalid] .checkbox:hover {
106+
border-color: ${getSemanticValue('negative')};
107+
}
108+
109+
&[data-selected] .checkbox:hover,
110+
&[data-indeterminate] .checkbox:hover {
111+
border-color: ${getSemanticValue('on-interactive-container')};
112+
background: ${getSemanticValue('on-interactive-container')};
113+
}
114+
115+
&[data-selected][data-pressed] .checkbox,
116+
&[data-indeterminate][data-pressed] .checkbox {
117+
border-color: ${getSemanticValue('interactive')};
118+
background: ${getSemanticValue('interactive')};
119+
}
120+
121+
&[data-selected][data-disabled],
122+
&[data-indeterminate][data-disabled] {
123+
color: transparent;
124+
cursor: not-allowed;
125+
126+
.checkbox {
127+
background-color: ${getSemanticValue('surface')};
128+
border-color: ${getSemanticValue('surface-variant')};
129+
}
130+
131+
svg {
132+
stroke: ${getSemanticValue('outline-variant')};
133+
}
134+
}
135+
136+
&[data-indeterminate][data-disabled] svg {
137+
stroke: none;
138+
fill: ${getSemanticValue('outline-variant')};
139+
left: 52%;
140+
}
141+
142+
&[data-invalid][data-selected] .checkbox,
143+
&[data-invalid][data-indeterminate] .checkbox {
144+
background-color: ${getSemanticValue('negative-variant')};
145+
border-color: ${getSemanticValue('negative-variant')};
146+
}
147+
148+
&[data-invalid][data-selected] .checkbox:hover,
149+
&[data-invalid][data-indeterminate] .checkbox:hover {
150+
background-color: ${getSemanticValue('negative')};
151+
border-color: ${getSemanticValue('negative')};
152+
}
153+
`;
154+
155+
const Checkbox: FC<CheckboxProps> = props => {
156+
const { isDisabled, isInvalid, isIndeterminate, label, variant = 'body1', ...rest } = props;
157+
158+
let dynamicLabel: ReactNode = label;
159+
if (typeof label === 'string') {
160+
dynamicLabel = (
161+
<Text onClick={e => e.stopPropagation()} variant={variant}>
162+
{label}
163+
</Text>
164+
);
165+
}
166+
167+
return (
168+
<LabelWrapper isDisabled={isDisabled} isInvalid={isInvalid}>
169+
<StyledCheckbox isDisabled={isDisabled} isIndeterminate={isIndeterminate} isInvalid={isInvalid} {...rest}>
170+
<div className="checkbox">
171+
<svg viewBox="0 0 18 18" aria-hidden="true">
172+
{isIndeterminate ? (
173+
<rect x={1} y={7.5} width={15} height={3} rx={1.5} ry={1.5} />
174+
) : (
175+
<polyline points="1 9 7 14 15 4" strokeLinecap="round" strokeLinejoin="round" />
176+
)}
177+
</svg>
178+
</div>
179+
</StyledCheckbox>
180+
{dynamicLabel}
181+
</LabelWrapper>
182+
);
183+
};
184+
185+
export { Checkbox, CheckboxProps };
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React from 'react';
2+
import styled from 'styled-components';
3+
import { useHover } from '@react-aria/interactions';
4+
import { mergeProps } from '@react-aria/utils';
5+
6+
import { getSemanticValue, theme } from '../../../../experimental';
7+
8+
interface LabelWrapperProps {
9+
isDisabled?: boolean;
10+
isInvalid?: boolean;
11+
}
12+
13+
interface StyledLabelProps extends LabelWrapperProps {
14+
isHovered?: boolean;
15+
}
16+
17+
const StyledLabel = styled.label.attrs({ theme })<StyledLabelProps>`
18+
display: inline-flex;
19+
position: relative;
20+
user-select: none;
21+
color: ${props =>
22+
getSemanticValue(props.isDisabled ? 'outline-variant' : props.isInvalid ? 'negative-variant' : 'on-surface')};
23+
line-height: 1;
24+
gap: 0.5rem;
25+
padding: 3px 2px;
26+
align-items: center;
27+
`;
28+
29+
function LabelWrapper(props: LabelWrapperProps & React.LabelHTMLAttributes<HTMLLabelElement>): JSX.Element {
30+
const { isDisabled = false, isInvalid = false, ...otherProps } = props;
31+
32+
const { hoverProps, isHovered } = useHover({ isDisabled });
33+
34+
return (
35+
<StyledLabel
36+
isDisabled={isDisabled}
37+
isInvalid={isInvalid}
38+
isHovered={isHovered}
39+
{...mergeProps(hoverProps, otherProps)}
40+
/>
41+
);
42+
}
43+
44+
export { LabelWrapper };
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Meta, StoryObj } from '@storybook/react';
2+
import { Checkbox } from '../Checkbox';
3+
4+
const meta: Meta = {
5+
title: 'Experimental/Components/Checkbox',
6+
component: Checkbox,
7+
argTypes: {
8+
textVerticalAlign: {
9+
control: 'radio',
10+
options: ['center', 'top']
11+
}
12+
},
13+
args: {
14+
label: 'Accept T&C'
15+
}
16+
};
17+
18+
export default meta;
19+
20+
type Story = StoryObj<typeof Checkbox>;
21+
22+
export const Default: Story = {
23+
args: {
24+
label: undefined
25+
}
26+
};
27+
28+
export const Selected: Story = {
29+
args: {
30+
defaultSelected: true
31+
}
32+
};
33+
34+
export const Error: Story = {
35+
args: {
36+
label: 'With Error',
37+
isInvalid: true
38+
}
39+
};
40+
41+
export const Disabled: Story = {
42+
args: {
43+
isDisabled: true
44+
}
45+
};
46+
47+
export const DisabledChecked: Story = {
48+
args: {
49+
isDisabled: true,
50+
defaultSelected: true
51+
}
52+
};
53+
54+
export const DisabledIndeterminate: Story = {
55+
args: {
56+
isDisabled: true,
57+
isIndeterminate: true,
58+
defaultSelected: true,
59+
label: 'Disabled indeterminate checkbox'
60+
}
61+
};
62+
63+
export const Indeterminate: Story = {
64+
args: {
65+
isIndeterminate: true
66+
}
67+
};
68+
69+
export const InvalidSelected: Story = {
70+
args: {
71+
isInvalid: true,
72+
defaultSelected: true,
73+
label: 'Invalid selected checkbox'
74+
}
75+
};

src/essentials/experimental/Colors.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ export const SemanticColorsLight = {
125125
'on-negative-container': ColorPalette.red[30],
126126
positive: ColorPalette.green[40],
127127
'positive-container': ColorPalette.green[95],
128-
'on-positive-container': ColorPalette.green[30]
128+
'on-positive-container': ColorPalette.green[30],
129+
'negative-variant': ColorPalette.red[50]
129130
} satisfies SemanticColorsSchema;
130131

131132
export const SemanticColorsDark = {
@@ -155,7 +156,8 @@ export const SemanticColorsDark = {
155156
'on-negative-container': ColorPalette.red[95],
156157
positive: ColorPalette.green[80],
157158
'positive-container': ColorPalette.green[30],
158-
'on-positive-container': ColorPalette.green[95]
159+
'on-positive-container': ColorPalette.green[95],
160+
'negative-variant': ColorPalette.red[50]
159161
} satisfies SemanticColorsSchema;
160162

161163
type Accents = {

src/essentials/experimental/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,5 @@ export type SemanticColorsSchema = {
4646
positive: Color;
4747
'positive-container': Color;
4848
'on-positive-container': Color;
49+
'negative-variant': Color;
4950
};

0 commit comments

Comments
 (0)