diff --git a/packages/react/src/components/checkbox-group/checkbox-group.test.tsx b/packages/react/src/components/checkbox-group/checkbox-group.test.tsx index 3e9012dc1..72dae52fe 100644 --- a/packages/react/src/components/checkbox-group/checkbox-group.test.tsx +++ b/packages/react/src/components/checkbox-group/checkbox-group.test.tsx @@ -45,4 +45,39 @@ describe('Checkbox', () => { expect(tree).toMatchSnapshot(); }); + + test('should display warning message if input is invalid and not checked', () => { + const wrapper = mountWithTheme( + , + ); + + const warning = wrapper.find('div#checkbox-group-test_validationAlert'); + expect(warning).toBeDefined(); + }); + + test('should hide warning message if group is invalid and one input is checked', () => { + const wrapper = mountWithTheme( + , + ); + + const warningPreCheck = wrapper.find('div#checkbox-group-test_validationAlert'); + expect(warningPreCheck).toBeDefined(); + + wrapper.find('input').at(0).simulate('change'); + + const warningPostCheck = wrapper.find('div#checkbox-group-test_validationAlert'); + expect(warningPostCheck).toEqual({}); + }); }); diff --git a/packages/react/src/components/checkbox-group/checkbox-group.test.tsx.snap b/packages/react/src/components/checkbox-group/checkbox-group.test.tsx.snap index 06fbc6118..4baaf08d7 100644 --- a/packages/react/src/components/checkbox-group/checkbox-group.test.tsx.snap +++ b/packages/react/src/components/checkbox-group/checkbox-group.test.tsx.snap @@ -114,6 +114,8 @@ exports[`Checkbox Matches the snapshot 1`] = ` class="c0" > ` + align-items: flex-start; + color: ${({ theme }) => theme.component['checkbox-error-border-color']}; + display: flex; + margin: ${({ label }) => `${label ? 'calc(var(--spacing-1x) * -1) ' : '0'} 0 0 var(--spacing-1x) `}; + padding-bottom: var(--spacing-1x); +`; + interface CheckboxProps { + id?: string; label?: string; checkedValues?: string[]; + required?: boolean; + valid?: boolean; + validationErrorMessage?: string; checkboxGroup: { label: string, name: string, @@ -25,35 +47,67 @@ interface CheckboxProps { } export const CheckboxGroup: VoidFunctionComponent = ({ + id, label, checkedValues, checkboxGroup, + required, + valid = true, + validationErrorMessage, onChange, ...props }) => { + const { isMobile } = useDeviceContext(); + const { t } = useTranslation('checkbox'); const dataAttributes = useDataAttributes(props); const dataTestId = dataAttributes['data-testid'] ?? 'checkboxGroup'; + const validationAlertId = `${id || uuid()}_validationAlert`; + const [checkedState, setCheckedState] = useState( + new Array(checkboxGroup.length).fill(false), + ); + const areAllCheckboxUnchecked = checkedState.every((e) => e === false); + + const handleOnChange = (position: number): void => { + const updatedCheckedState = checkedState.map((item, index) => (index === position ? !item : item)); + + setCheckedState(updatedCheckedState); + }; return ( <> {label && {label}} + { + required && !valid && areAllCheckboxUnchecked + && ( + + + {validationErrorMessage || t('validationErrorMessage')} + + ) + } {checkboxGroup.map(({ defaultChecked, disabled, label: checkboxLabel, name, value, - }) => ( + }, pos) => ( ) => { + handleOnChange(pos); + onChange?.(event); + }} /> ))} diff --git a/packages/react/src/components/checkbox/checkbox.test.tsx b/packages/react/src/components/checkbox/checkbox.test.tsx index 5869e6800..a3cd4725a 100644 --- a/packages/react/src/components/checkbox/checkbox.test.tsx +++ b/packages/react/src/components/checkbox/checkbox.test.tsx @@ -49,6 +49,60 @@ describe('Checkbox', () => { expect(input.prop('disabled')).toBe(true); }); + test('should be required when required prop is set to true', () => { + const wrapper = mountWithTheme(); + + const input = wrapper.find('input'); + expect(input.prop('required')).toBe(true); + }); + + test('should display warning message if input is invalid and not checked', () => { + const wrapper = mountWithTheme(); + + const warning = wrapper.find('div#checkbox-test_validationAlert'); + expect(warning).toBeDefined(); + }); + + test('should hide warning message if input is invalid and then checked', () => { + const wrapper = mountWithTheme(); + + const warningPreCheck = wrapper.find('div#checkbox-test_validationAlert'); + expect(warningPreCheck).toBeDefined(); + + wrapper.find('input').simulate('change'); + + const warningPostCheck = wrapper.find('div#checkbox-test_validationAlert'); + expect(warningPostCheck).toEqual({}); + }); + + test('should have aria-labelledby prop when warning message is displayed and input is invalid', () => { + const wrapper = mountWithTheme(); + + const input = wrapper.find('input'); + expect(input.prop('aria-labelledby')).toBe('checkbox-test_validationAlert'); + }); + + test('should have empty aria-labelledby prop when input is valid', () => { + const wrapper = mountWithTheme(); + + const input = wrapper.find('input'); + expect(input.prop('aria-labelledby')).toBe(''); + }); + + test('should have aria-invalid prop set to true when warning message is displayed and input is invalid', () => { + const wrapper = mountWithTheme(); + + const input = wrapper.find('input'); + expect(input.prop('aria-invalid')).toBe(true); + }); + + test('should have aria-invalid prop set to false when input is valid', () => { + const wrapper = mountWithTheme(); + + const input = wrapper.find('input'); + expect(input.prop('aria-invalid')).toBe(false); + }); + test('matches snapshot', () => { const tree = mountWithTheme(); diff --git a/packages/react/src/components/checkbox/checkbox.test.tsx.snap b/packages/react/src/components/checkbox/checkbox.test.tsx.snap index a12b39a2a..e6ec64a28 100644 --- a/packages/react/src/components/checkbox/checkbox.test.tsx.snap +++ b/packages/react/src/components/checkbox/checkbox.test.tsx.snap @@ -110,18 +110,26 @@ exports[`Checkbox matches snapshot 1`] = ` className="c0" > - + diff --git a/packages/react/src/components/checkbox/checkbox.tsx b/packages/react/src/components/checkbox/checkbox.tsx index 6b6be33bf..5fbb1fac3 100644 --- a/packages/react/src/components/checkbox/checkbox.tsx +++ b/packages/react/src/components/checkbox/checkbox.tsx @@ -6,12 +6,16 @@ import { Ref, useEffect, useImperativeHandle, - useRef, + useRef, useState, } from 'react'; import styled, { css } from 'styled-components'; import { focus } from '../../utils/css-state'; import { Icon } from '../icon/icon'; import { useDataAttributes } from '../../hooks/use-data-attributes'; +import { ResolvedTheme } from '../../themes/theme'; +import { useTranslation } from '../../i18n/use-translation'; +import { useDeviceContext } from '../device-context-provider/device-context-provider'; +import { v4 as uuid } from '../../utils/uuid'; const checkboxWidth = 'var(--size-1x)'; @@ -32,9 +36,27 @@ const IndeterminateIcon = styled(Icon).attrs({ name: 'minus' })` ${iconStyles} `; -const CustomCheckbox = styled.span<{ disabled?: boolean }>` +interface CheckboxStyleProps { + disabled?: boolean; + theme: ResolvedTheme; + $valid: boolean; +} + +function getBorderColor({ disabled, theme, $valid }: CheckboxStyleProps): string { + if (disabled) { + return theme.component['checkbox-disabled-border-color']; + } + + if (!$valid) { + return theme.component['checkbox-error-border-color']; + } + + return theme.component['checkbox-unchecked-border-color']; +} + +const CustomCheckbox = styled.span` background-color: ${({ disabled, theme }) => (disabled ? theme.component['checkbox-disabled-background-color'] : theme.component['checkbox-unchecked-background-color'])}; - border: 1px solid ${({ disabled, theme }) => (disabled ? theme.component['checkbox-disabled-border-color'] : theme.component['checkbox-unchecked-border-color'])}; + border: 1px solid ${getBorderColor}; border-radius: var(--border-radius); box-sizing: border-box; display: inline-block; @@ -95,6 +117,20 @@ const StyledLabel = styled.label` } `; +const StyledIcon = styled(Icon)` + align-self: center; + display: flex; + margin-right: var(--spacing-base); +`; + +const ValidationErrorAlert = styled.div` + align-items: flex-start; + color: ${({ theme }) => theme.component['checkbox-error-border-color']}; + display: flex; + margin-left: var(--spacing-1x); + padding-bottom: var(--spacing-1x); +`; + export interface CheckboxProps { checked?: boolean; className?: string; @@ -102,8 +138,12 @@ export interface CheckboxProps { disabled?: boolean, id?: string, indeterminate?: boolean; + isInGroup?: boolean; label?: string, name?: string, + required?: boolean, + valid?: boolean, + validationErrorMessage?: string; value?: string, onChange?(event: ChangeEvent): void; @@ -116,13 +156,21 @@ export const Checkbox: FunctionComponent> = for disabled, id, indeterminate, + isInGroup = false, label, name, + required, + valid = true, + validationErrorMessage, value, onChange, ...otherProps }, ref: Ref) => { + const { isMobile } = useDeviceContext(); + const { t } = useTranslation('checkbox'); const checkboxRef = useRef(null); + const [isChecked, setIsChecked] = useState(checked || false); + const validationAlertId = `${id || uuid()}_validationAlert`; useImperativeHandle(ref, () => checkboxRef.current, [checkboxRef]); const dataAttributes = useDataAttributes(otherProps); @@ -130,35 +178,53 @@ export const Checkbox: FunctionComponent> = for useEffect(() => { if (checkboxRef.current) { checkboxRef.current.indeterminate = indeterminate || false; + checkboxRef.current.checked = checked || false; } - }, [indeterminate]); + }, [indeterminate, checked]); return ( - - + { + required && !valid && !isChecked && !isInGroup + && ( + + + {validationErrorMessage || t('validationErrorMessage')} + + ) + } + - - - - - {label} - + key={`${name}-${value}`} + > + ) => { + setIsChecked(!!checkboxRef.current?.checked); + onChange?.(event); + }} + {...dataAttributes /* eslint-disable-line react/jsx-props-no-spreading */} + /> + + + + + {label} + + ); }); diff --git a/packages/react/src/components/table/table.test.tsx.snap b/packages/react/src/components/table/table.test.tsx.snap index fa2a1a567..285c770e0 100644 --- a/packages/react/src/components/table/table.test.tsx.snap +++ b/packages/react/src/components/table/table.test.tsx.snap @@ -1630,6 +1630,8 @@ exports[`Table has selectable rows styles 1`] = ` class="c4" > ( /> ); +export const Required: Story = () => ( + +); + export const Controlled: Story = () => ( +### Required and invalid + ## Properties diff --git a/packages/storybook/stories/checkbox.stories.tsx b/packages/storybook/stories/checkbox.stories.tsx index 880799a3a..fe952a454 100644 --- a/packages/storybook/stories/checkbox.stories.tsx +++ b/packages/storybook/stories/checkbox.stories.tsx @@ -4,6 +4,8 @@ import { FunctionComponent } from 'react'; // eslint-disable-next-line react/jsx-props-no-spreading export const Default: FunctionComponent = (props) => ; +// eslint-disable-next-line react/jsx-props-no-spreading +export const Required: FunctionComponent = (props) => ; const CheckboxMeta: Meta = { title: 'Components/Checkbox',