Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): add indeterminate support to checkbox #1621

Merged
merged 9 commits into from
Aug 13, 2024
37 changes: 36 additions & 1 deletion docs/pages/components/Checkbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,35 @@ import { Checkbox } from '@deque/cauldron-react'
</FieldWrap>
```

### Indeterminate Checkbox

```jsx example
<FieldWrap>
<Checkbox
indeterminate
id="checkbox-indeterminate"
label="Indeterminate Checkbox"
name="indeterminate-checkbox"
value="1"
/>
</FieldWrap>
```

### Indeterminate Disabled Checkbox

```jsx example
<FieldWrap>
<Checkbox
indeterminate
disabled
id="checkbox-indeterminate-disabled"
label="Indeterminate Disabled Checkbox"
name="indeterminate0disabled-checkbox"
value="1"
/>
</FieldWrap>
```

### Error Checkbox

```jsx example
Expand Down Expand Up @@ -253,6 +282,12 @@ import { Checkbox } from '@deque/cauldron-react'
description: 'If the checkbox should be disabled.',
defaultValue: 'false'
},
{
name: 'indeterminate',
type: 'boolean',
description: 'If the checkbox should be indeterminate.',
defaultValue: 'false'
},
{
name: 'error',
type: 'string',
Expand All @@ -268,4 +303,4 @@ import { Checkbox } from '@deque/cauldron-react'

## Related Components

- [FieldWrap](./FieldWrap)
- [FieldWrap](./FieldWrap)
Binary file added e2e/screenshots/checkbox-indeterminate-.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions packages/react/src/components/Checkbox/Checkbox.test.tsx
scurker marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ test('should render disabled checked checkbox', () => {
expect(input).toBeChecked();
});

test('should render indeterminate checkbox', () => {
const input = renderCheckbox({ indeterminate: true });
expect(input).toBePartiallyChecked();
});

test('should render disabled indeterminate checkbox', () => {
const input = renderCheckbox({ disabled: true, indeterminate: true });
expect(input).toBeDisabled();
expect(input).toBePartiallyChecked();
});

test('should render error checkbox', () => {
const input = renderCheckbox({ error: 'you should check this checkbox' });
expect(input).toHaveAccessibleDescription('you should check this checkbox');
Expand Down Expand Up @@ -180,6 +191,18 @@ test('should have no axe violations with disabled checkbox', async () => {
expect(results).toHaveNoViolations();
});

test('should have no axe violations with indeterminate checkbox', async () => {
const input = renderCheckbox({ indeterminate: true });
const results = await axe(input);
expect(results).toHaveNoViolations();
});

test('should have no axe violations with disabled indeterminate checkbox', async () => {
const input = renderCheckbox({ disabled: true, indeterminate: true });
const results = await axe(input);
expect(results).toHaveNoViolations();
});

test('should have no axe violations when checkbox has errors', async () => {
const input = renderCheckbox({ error: 'you should check this checkbox' });
const results = await axe(input);
Expand Down
37 changes: 32 additions & 5 deletions packages/react/src/components/Checkbox/index.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import React, {
InputHTMLAttributes,
forwardRef,
Ref,
useState,
useEffect,
useRef,
useMemo
} from 'react';
import classNames from 'classnames';
import nextId from 'react-id-generator';
import Icon from '../Icon';
import Icon, { IconType } from '../Icon';
chornonoh-vova marked this conversation as resolved.
Show resolved Hide resolved
import { addIdRef } from '../../utils/idRefs';

export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
Expand All @@ -18,7 +17,8 @@ export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
labelDescription?: React.ReactNode;
error?: React.ReactNode;
customIcon?: React.ReactNode;
checkboxRef?: Ref<HTMLInputElement>;
checkboxRef?: React.ForwardedRef<HTMLInputElement>;
indeterminate?: boolean;
}

const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
Expand All @@ -36,20 +36,27 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
'aria-describedby': ariaDescribedby,
disabled = false,
checked = false,
indeterminate = false,
children,
...other
}: CheckboxProps,
ref
): JSX.Element => {
const [isChecked, setIsChecked] = useState(checked);
const [isIndeterminate, setIsIndeterminate] = useState(indeterminate);
const [focused, setFocused] = useState(false);
const checkRef = useRef<HTMLInputElement>(null);

useEffect(() => {
setIsChecked(checked);
}, [checked]);

useEffect(() => {
setIsIndeterminate(indeterminate);
}, [indeterminate]);

const refProp = ref || checkboxRef;

if (typeof refProp === 'function') {
refProp(checkRef.current);
}
Expand All @@ -71,6 +78,23 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
ariaDescribedbyId = addIdRef(ariaDescribedbyId, labelDescriptionId);
}

const iconType: IconType = isChecked
? 'checkbox-checked'
: 'checkbox-unchecked';

useEffect(() => {
let input: HTMLInputElement | null;
if (refProp && typeof refProp !== 'function') {
input = refProp.current;
} else {
input = checkRef.current;
}

if (input) {
input.indeterminate = isIndeterminate;
}
}, [isIndeterminate, refProp, checkRef]);

return (
<div className="Checkbox__wrap">
<div className={classNames('Checkbox is--flex-row', className)}>
Expand All @@ -94,6 +118,9 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
}}
aria-describedby={ariaDescribedbyId}
onChange={(e): void => {
if (isIndeterminate) {
setIsIndeterminate(false);
}
setIsChecked(e.target.checked);
if (onChange) {
onChange(e);
Expand All @@ -115,11 +142,11 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
'Checkbox__overlay--focused': focused,
'Field--has-error': error
})}
type={isChecked ? 'checkbox-checked' : 'checkbox-unchecked'}
type={iconType}
aria-hidden="true"
onClick={(): void => {
if (refProp && typeof refProp !== 'function') {
refProp?.current?.click();
refProp.current?.click();
} else {
checkRef.current?.click();
}
Expand Down
30 changes: 30 additions & 0 deletions packages/react/src/components/Checkbox/screenshots.e2e.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,33 @@ test('should have screenshot for Checkbox[checked]', async ({
await setTheme(page, 'dark');
await expect(component).toHaveScreenshot('dark--checkbox[checked]');
});

test('should have screenshot for Checkbox[indeterminate]', async ({
mount,
page
}) => {
const component = await mount(
<FieldWrap>
<Checkbox id="checkbox" label="Checkbox" indeterminate />
<Checkbox id="checkbox-hover" label="Hover" indeterminate />
<Checkbox id="checkbox-focus" label="Focus" indeterminate />
<Checkbox id="checkbox-active" label="Active" indeterminate />
<Checkbox
id="checkbox-disabled"
label="Disabled"
indeterminate
disabled
/>
</FieldWrap>
);

await component.getByRole('checkbox', { name: 'Focus' }).focus();
await component.getByText('Hover').hover();
setActive(
await component.locator('.Checkbox__wrap:nth-child(4) .Checkbox__overlay')
);

await expect(component).toHaveScreenshot('checkbox[indeterminate]');
await setTheme(page, 'dark');
await expect(component).toHaveScreenshot('dark--checkbox[indeterminate]');
});
34 changes: 34 additions & 0 deletions packages/styles/forms.css
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,17 @@ textarea.Field--has-error:focus:hover,
appearance: none;
}

.Checkbox input[type='checkbox']:indeterminate ~ .Checkbox__overlay.Icon {
color: var(--field-icon-checked-color);
}

.Checkbox
input[type='checkbox']:indeterminate
~ .Checkbox__overlay--disabled.Icon {
color: var(--field-icon-checked-disabled-color);
cursor: default;
}

.Checkbox__overlay.Checkbox__overlay--focused,
.Radio__overlay.Radio__overlay--focused {
box-shadow: 0 0 0 2px var(--field-icon-focus-color);
Expand Down Expand Up @@ -462,6 +473,29 @@ textarea.Field--has-error:focus:hover,
border: 1px solid currentColor;
}

.Checkbox input[type='checkbox']:indeterminate ~ .Checkbox__overlay:before {
content: '';
display: block;
position: absolute;
height: calc(var(--icon-size) - 8px);
width: calc(var(--icon-size) - 8px);
background: currentColor;
transform: translate(4px, 4px);
}

.Checkbox input[type='checkbox']:indeterminate ~ .Checkbox__overlay:after {
content: '';
display: block;
position: absolute;
height: 3px;
width: calc(var(--icon-size) / 3);
background-color: var(--workspace-background-color);
transform: translate(
calc(var(--icon-size) / 3),
calc(var(--icon-size) / 2 * -1 - 1.5px)
);
}

.Checkbox input[type='checkbox'] {
position: absolute;
opacity: 0;
Expand Down
Loading