Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/components/experimental/Checkbox/Checkbox.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import * as React from 'react';
import { Checkbox } from './Checkbox';

describe('Checkbox', () => {
it('selects/unselects the checkbox on clicking the label', async () => {
const user = userEvent.setup();
const labelText = 'Click Me!';
const onChangeCallback = jest.fn();

render(<Checkbox label={labelText} onChange={isSelected => onChangeCallback(isSelected)} />);

const label = screen.getByText(labelText);

await user.click(label);
expect(onChangeCallback).toHaveBeenCalledWith(true);

await user.click(label);
expect(onChangeCallback).toHaveBeenCalledWith(false);
});

it('renders with default selected state', () => {
render(<Checkbox label="Test Checkbox" defaultSelected />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked();
});

it('renders in disabled state', async () => {
const user = userEvent.setup();
const onChangeCallback = jest.fn();

render(<Checkbox label="Disabled Checkbox" isDisabled onChange={onChangeCallback} />);

const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeDisabled();

await user.click(checkbox);
expect(onChangeCallback).not.toHaveBeenCalled();
});

it('renders in invalid state', () => {
render(<Checkbox label="Invalid Checkbox" isInvalid />);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveAttribute('aria-invalid', 'true');
});

it('renders in indeterminate state', () => {
render(<Checkbox label="Indeterminate Checkbox" isIndeterminate />);
const checkbox = screen.getByRole('checkbox');
const container = checkbox.closest('[data-indeterminate="true"]');
expect(container).toBeInTheDocument();
});
});
185 changes: 185 additions & 0 deletions src/components/experimental/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import React, { FC, ReactNode } from 'react';
import { Checkbox as CheckboxComponent, CheckboxProps as ReactAriaCheckboxProps } from 'react-aria-components';
import styled from 'styled-components';

import { Text } from '../Text/Text';
import { LabelWrapper } from './components/LabelWrapper';

import { getSemanticValue, themeGet } from '../../../experimental';

type TextVariant = 'display' | 'headline' | 'title1' | 'title2' | 'body1' | 'body2' | 'label1' | 'label2';

interface CheckboxProps extends Omit<ReactAriaCheckboxProps, 'children'> {
/**
* Provide a label for the input which will be shown next to the checkbox
*/
label?: ReactNode;
/**
* Text variant for the label
*/
variant?: TextVariant;
}

const StyledCheckbox = styled(CheckboxComponent)`
--selected-color: ${getSemanticValue('accent')};
--selected-color-pressed: ${getSemanticValue('interactive')};
--checkmark-color: ${getSemanticValue('surface')};

position: relative;
display: inline-flex;
align-items: center;
forced-color-adjust: none;
cursor: pointer;

.checkbox {
width: ${themeGet('space.5')};
height: ${themeGet('space.5')};
border: 2px solid ${getSemanticValue('divider')};
border-radius: ${themeGet('radii.2')};
transition: all 200ms;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;

&:hover {
border-color: ${getSemanticValue('interactive')};
}
}

svg {
position: absolute;
width: 65%;
height: 62%;
top: 45%;
left: 53%;
transform: translate(-45%, -40%);
fill: none;
stroke: ${getSemanticValue('surface')};
stroke-width: 3px;
stroke-dasharray: 22px;
stroke-dashoffset: 66;
transition: all 200ms;
}

&[data-pressed] .checkbox {
border-color: ${getSemanticValue('surface-variant')};
}

&[data-focus-visible] .checkbox {
outline: 2px solid ${getSemanticValue('surface-variant')};
outline-offset: 2px;
}

&[data-disabled] {
color: transparent;
cursor: not-allowed;

.checkbox {
background-color: ${getSemanticValue('surface')};
border-color: ${getSemanticValue('surface-variant')};
}
}

&[data-invalid] .checkbox {
border-color: ${getSemanticValue('negative-variant')};
}

&[data-selected] .checkbox,
&[data-indeterminate] .checkbox {
border-color: ${getSemanticValue('accent')};
background: ${getSemanticValue('accent')};
}

&[data-selected] svg,
&[data-indeterminate] svg {
stroke-dashoffset: 44;
}

&[data-indeterminate] svg {
stroke: none;
fill: ${getSemanticValue('surface')};
left: 52%;
}

&[data-invalid] .checkbox:hover {
border-color: ${getSemanticValue('negative')};
}

&[data-selected] .checkbox:hover,
&[data-indeterminate] .checkbox:hover {
border-color: ${getSemanticValue('on-interactive-container')};
background: ${getSemanticValue('on-interactive-container')};
}

&[data-selected][data-pressed] .checkbox,
&[data-indeterminate][data-pressed] .checkbox {
border-color: ${getSemanticValue('interactive')};
background: ${getSemanticValue('interactive')};
}

&[data-selected][data-disabled],
&[data-indeterminate][data-disabled] {
color: transparent;
cursor: not-allowed;

.checkbox {
background-color: ${getSemanticValue('surface')};
border-color: ${getSemanticValue('surface-variant')};
}

svg {
stroke: ${getSemanticValue('outline-variant')};
}
}

&[data-indeterminate][data-disabled] svg {
stroke: none;
fill: ${getSemanticValue('outline-variant')};
left: 52%;
}

&[data-invalid][data-selected] .checkbox,
&[data-invalid][data-indeterminate] .checkbox {
background-color: ${getSemanticValue('negative-variant')};
border-color: ${getSemanticValue('negative-variant')};
}

&[data-invalid][data-selected] .checkbox:hover,
&[data-invalid][data-indeterminate] .checkbox:hover {
background-color: ${getSemanticValue('negative')};
border-color: ${getSemanticValue('negative')};
}
`;

const Checkbox: FC<CheckboxProps> = props => {
const { isDisabled, isInvalid, isIndeterminate, label, variant = 'body1', ...rest } = props;

let dynamicLabel: ReactNode = label;
if (typeof label === 'string') {
dynamicLabel = (
<Text onClick={e => e.stopPropagation()} variant={variant}>
{label}
</Text>
);
}

return (
<LabelWrapper isDisabled={isDisabled} isInvalid={isInvalid}>
<StyledCheckbox isDisabled={isDisabled} isIndeterminate={isIndeterminate} isInvalid={isInvalid} {...rest}>
<div className="checkbox">
<svg viewBox="0 0 18 18" aria-hidden="true">
{isIndeterminate ? (
<rect x={1} y={7.5} width={15} height={3} rx={1.5} ry={1.5} />
) : (
<polyline points="1 9 7 14 15 4" strokeLinecap="round" strokeLinejoin="round" />
)}
</svg>
</div>
</StyledCheckbox>
{dynamicLabel}
</LabelWrapper>
);
};

export { Checkbox, CheckboxProps };
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';
import styled from 'styled-components';
import { useHover } from '@react-aria/interactions';
import { mergeProps } from '@react-aria/utils';

import { getSemanticValue, theme } from '../../../../experimental';

interface LabelWrapperProps {
isDisabled?: boolean;
isInvalid?: boolean;
}

interface StyledLabelProps extends LabelWrapperProps {
isHovered?: boolean;
}

const StyledLabel = styled.label.attrs({ theme })<StyledLabelProps>`
display: inline-flex;
position: relative;
user-select: none;
color: ${props =>
getSemanticValue(props.isDisabled ? 'outline-variant' : props.isInvalid ? 'negative-variant' : 'on-surface')};
line-height: 1;
gap: 0.5rem;
padding: 3px 2px;
align-items: center;
`;

function LabelWrapper(props: LabelWrapperProps & React.LabelHTMLAttributes<HTMLLabelElement>): JSX.Element {
const { isDisabled = false, isInvalid = false, ...otherProps } = props;

const { hoverProps, isHovered } = useHover({ isDisabled });

return (
<StyledLabel
isDisabled={isDisabled}
isInvalid={isInvalid}
isHovered={isHovered}
{...mergeProps(hoverProps, otherProps)}
/>
);
}

export { LabelWrapper };
75 changes: 75 additions & 0 deletions src/components/experimental/Checkbox/docs/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Meta, StoryObj } from '@storybook/react';
import { Checkbox } from '../Checkbox';

const meta: Meta = {
title: 'Experimental/Components/Checkbox',
component: Checkbox,
argTypes: {
textVerticalAlign: {
control: 'radio',
options: ['center', 'top']
}
},
args: {
label: 'Accept T&C'
}
};

export default meta;

type Story = StoryObj<typeof Checkbox>;

export const Default: Story = {
args: {
label: undefined
}
};

export const Selected: Story = {
args: {
defaultSelected: true
}
};

export const Error: Story = {
args: {
label: 'With Error',
isInvalid: true
}
};

export const Disabled: Story = {
args: {
isDisabled: true
}
};

export const DisabledChecked: Story = {
args: {
isDisabled: true,
defaultSelected: true
}
};

export const DisabledIndeterminate: Story = {
args: {
isDisabled: true,
isIndeterminate: true,
defaultSelected: true,
label: 'Disabled indeterminate checkbox'
}
};

export const Indeterminate: Story = {
args: {
isIndeterminate: true
}
};

export const InvalidSelected: Story = {
args: {
isInvalid: true,
defaultSelected: true,
label: 'Invalid selected checkbox'
}
};
6 changes: 4 additions & 2 deletions src/essentials/experimental/Colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ export const SemanticColorsLight = {
'on-negative-container': ColorPalette.red[30],
positive: ColorPalette.green[40],
'positive-container': ColorPalette.green[95],
'on-positive-container': ColorPalette.green[30]
'on-positive-container': ColorPalette.green[30],
'negative-variant': ColorPalette.red[50]
} satisfies SemanticColorsSchema;

export const SemanticColorsDark = {
Expand Down Expand Up @@ -155,7 +156,8 @@ export const SemanticColorsDark = {
'on-negative-container': ColorPalette.red[95],
positive: ColorPalette.green[80],
'positive-container': ColorPalette.green[30],
'on-positive-container': ColorPalette.green[95]
'on-positive-container': ColorPalette.green[95],
'negative-variant': ColorPalette.red[50]
} satisfies SemanticColorsSchema;

type Accents = {
Expand Down
1 change: 1 addition & 0 deletions src/essentials/experimental/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ export type SemanticColorsSchema = {
positive: Color;
'positive-container': Color;
'on-positive-container': Color;
'negative-variant': Color;
};