Skip to content

Commit

Permalink
Feat(web-react): Implement new HelperText component #DS-886
Browse files Browse the repository at this point in the history
- Changed utility class helper text to a HelperText components
- Connect HelperText with its input using aria-describedby
- Implement deprecation of not required id prop for FieldGroup,
Checkbox, Radio, Select and TextArea
  • Loading branch information
pavelklibani committed Sep 7, 2023
1 parent f978b11 commit 7302bfd
Show file tree
Hide file tree
Showing 14 changed files with 247 additions and 38 deletions.
33 changes: 24 additions & 9 deletions packages/web-react/src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,49 @@ import React, { forwardRef, ForwardedRef } from 'react';
import classNames from 'classnames';
import { useStyleProps } from '../../hooks';
import { SpiritCheckboxProps } from '../../types';
import { useValidationText } from '../Field';
import { useValidationText, HelperText } from '../Field';
import useAriaDescribedBy from '../Field/useAriaDescribedBy';
import { useCheckboxStyleProps } from './useCheckboxStyleProps';

/* We need an exception for components exported with forwardRef */
/* eslint no-underscore-dangle: ['error', { allow: ['_Checkbox'] }] */
const _Checkbox = (props: SpiritCheckboxProps, ref: ForwardedRef<HTMLInputElement>): JSX.Element => {
const { classProps, props: modifiedProps } = useCheckboxStyleProps(props);
const {
'aria-describedby': ariaDescribedBy = '',
helperText,
id,
isChecked,
isDisabled,
isRequired,
label,
validationText,
helperText,
validationState,
validationText,
value,
isDisabled,
isRequired,
isChecked,
...restProps
} = modifiedProps;
const { styleProps, props: otherProps } = useStyleProps(restProps);

const { validationTextId, helperTextId, joinedAriaDescribedBy } = useAriaDescribedBy({
id,
helperText,
validationText,
ariaDescribedBy,
});

const renderValidationText = useValidationText({
validationTextClassName: classProps.validationText,
validationId: validationTextId,
validationElementType: 'span',
validationState,
validationText,
validationElementType: 'span',
validationTextClassName: classProps.validationText,
});

return (
<label {...styleProps} htmlFor={id} className={classNames(classProps.root, styleProps.className)}>
<input
{...otherProps}
aria-describedby={joinedAriaDescribedBy}
type="checkbox"
id={id}
className={classProps.input}
Expand All @@ -45,7 +56,11 @@ const _Checkbox = (props: SpiritCheckboxProps, ref: ForwardedRef<HTMLInputElemen
/>
<span className={classProps.text}>
<span className={classProps.label}>{label}</span>
{helperText && <span className={classProps.helperText}>{helperText}</span>}
{helperText && (
<HelperText className={classProps.helperText} elementType="span" id={helperTextId}>
{helperText}
</HelperText>
)}
{renderValidationText}
</span>
</label>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import useAriaDescribedBy from '../useAriaDescribedBy';

describe('useAriaDescribedBy', () => {
it('should return joinedAriaDescribedBy with validationTextId and helperTextId', () => {
const props = {
id: 'test',
helperText: 'Helper text',
validationText: 'Validation text',
ariaDescribedBy: 'aria-describedby',
};

const result = useAriaDescribedBy(props);

expect(result.validationTextId).toBe('test__validationText');
expect(result.helperTextId).toBe('test__helperText');
expect(result.joinedAriaDescribedBy).toBe('aria-describedby test__helperText test__validationText');
});

it('should return joinedAriaDescribedBy without validationTextId and helperTextId if id is missing', () => {
const props = {
helperText: 'Helper text',
validationText: 'Validation text',
ariaDescribedBy: 'aria-describedby',
};

const result = useAriaDescribedBy(props);

expect(result.validationTextId).toBeUndefined();
expect(result.helperTextId).toBeUndefined();
expect(result.joinedAriaDescribedBy).toBe('aria-describedby');
});

it('should return joinedAriaDescribedBy without validationTextId and helperTextId if they are missing', () => {
const props = {
id: 'test',
ariaDescribedBy: 'aria-describedby',
};

const result = useAriaDescribedBy(props);

expect(result.validationTextId).toBeUndefined();
expect(result.helperTextId).toBeUndefined();
expect(result.joinedAriaDescribedBy).toBe('aria-describedby');
});

it('should return joinedAriaDescribedBy without validationTextId and helperTextId if they are empty', () => {
const props = {
id: 'test',
helperText: '',
validationText: '',
ariaDescribedBy: 'aria-describedby',
};

const result = useAriaDescribedBy(props);

expect(result.validationTextId).toBeUndefined();
expect(result.helperTextId).toBeUndefined();
expect(result.joinedAriaDescribedBy).toBe('aria-describedby');
});

it('should return joinedAriaDescribedBy and helperTextId without validationTextId', () => {
const props = {
id: 'test',
helperText: 'Helper Text',
validationText: undefined,
ariaDescribedBy: 'aria-describedby',
};

const result = useAriaDescribedBy(props);

expect(result.validationTextId).toBeUndefined();
expect(result.helperTextId).toBe('test__helperText');
expect(result.joinedAriaDescribedBy).toBe('aria-describedby test__helperText');
});
});
1 change: 1 addition & 0 deletions packages/web-react/src/components/Field/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './ValidationText';
export * from './HelperText';
export * from './useValidationText';
export * from './useAriaDescribedBy';
export { default as ValidationText } from './ValidationText';
export { default as HelperText } from './HelperText';
18 changes: 18 additions & 0 deletions packages/web-react/src/components/Field/useAriaDescribedBy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
type UseAriaDescribedByProps = {
id?: string;
helperText?: string;
validationText?: string | string[];
ariaDescribedBy?: string;
};

const useAriaDescribedBy = ({ id, helperText, validationText, ariaDescribedBy }: UseAriaDescribedByProps) => {
if (!id) return { validationTextId: undefined, helperTextId: undefined, joinedAriaDescribedBy: ariaDescribedBy };

const validationTextId = id && validationText ? `${id}__validationText` : undefined;
const helperTextId = id && helperText ? `${id}__helperText` : undefined;
const joinedAriaDescribedBy = [ariaDescribedBy, helperTextId, validationTextId].join(' ').trim();

return { validationTextId, helperTextId, joinedAriaDescribedBy };
};

export default useAriaDescribedBy;
28 changes: 22 additions & 6 deletions packages/web-react/src/components/FieldGroup/FieldGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,38 @@
import React from 'react';
import classNames from 'classnames';
import { useFieldGroupStyleProps } from './useFieldGroupStyleProps';
import { SpiritFieldGroupProps } from '../../types';
import { useStyleProps } from '../../hooks';
import { useValidationText } from '../Field/useValidationText';
import useAriaDescribedBy from '../Field/useAriaDescribedBy';
import HelperText from '../Field/HelperText';
import { useFieldGroupStyleProps } from './useFieldGroupStyleProps';

const FieldGroup = (props: SpiritFieldGroupProps) => {
const {
label,
isRequired,
'aria-describedby': ariaDescribedBy = '',
children,
helperText,
id,
isDisabled,
isFluid,
isLabelHidden,
helperText,
isRequired,
label,
validationState,
validationText,
children,
...rest
} = props;

const { classProps } = useFieldGroupStyleProps({ isFluid, isRequired, validationState });
const { styleProps, props: transferProps } = useStyleProps(rest);

const { helperTextId, joinedAriaDescribedBy } = useAriaDescribedBy({
id,
helperText,
validationText,
ariaDescribedBy,
});

const renderValidationText = useValidationText({
validationTextClassName: classProps.validationText,
validationState,
Expand All @@ -32,6 +43,7 @@ const FieldGroup = (props: SpiritFieldGroupProps) => {
<fieldset
{...transferProps}
{...styleProps}
aria-describedby={joinedAriaDescribedBy}
className={classNames(classProps.root, styleProps.className)}
disabled={isDisabled}
>
Expand All @@ -42,7 +54,11 @@ const FieldGroup = (props: SpiritFieldGroupProps) => {
</div>
)}
<div className={classProps.fields}>{children}</div>
{helperText && <div className={classProps.helperText}>{helperText}</div>}
{helperText && (
<HelperText className={classProps.helperText} id={helperTextId}>
{helperText}
</HelperText>
)}
{renderValidationText}
</fieldset>
);
Expand Down
33 changes: 22 additions & 11 deletions packages/web-react/src/components/FieldGroup/demo/index.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,71 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import DocsSection from '../../../../docs/DocsSections';
import FieldGroup from './FieldGroup';
import { Radio } from '../../Radio';
import { Checkbox } from '../../Checkbox';
import FieldGroup from './FieldGroup';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<DocsSection title="Default">
<FieldGroup label="Label">
<FieldGroup id="FieldGroupDefault" label="Label">
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
</FieldGroup>
</DocsSection>
<DocsSection title="Required">
<FieldGroup label="Label" isRequired>
<FieldGroup id="FieldGroupRequired" label="Label" isRequired>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
</FieldGroup>
</DocsSection>
<DocsSection title="Hidden Label">
<FieldGroup label="Label" isLabelHidden>
<FieldGroup id="FieldGroupHiddenLabel" label="Label" isLabelHidden>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
</FieldGroup>
</DocsSection>
<DocsSection title="Helper Text">
<FieldGroup label="Label" helperText="Helper text">
<FieldGroup label="Label" id="FieldGroupHelperText" helperText="Helper text">
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
</FieldGroup>
</DocsSection>
<DocsSection title="Disabled">
<FieldGroup label="Label" isDisabled>
<FieldGroup id="FieldGroupDisabled" label="Label" isDisabled>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
</FieldGroup>
</DocsSection>
<DocsSection title="Validation State with Validation Text">
<div className="docs-FormFieldGrid">
<FieldGroup label="Label" validationState="success" validationText="Validation text">
<FieldGroup
id="FieldGroupValidationSuccess"
label="Label"
validationState="success"
validationText="Validation text"
>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
</FieldGroup>
<FieldGroup label="Label" validationState="warning" validationText="Validation text">
<FieldGroup
id="FieldGroupValidationWarning"
label="Label"
validationState="warning"
validationText="Validation text"
>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
</FieldGroup>
<FieldGroup
id="FieldGroupValidationDanger"
label="Label"
validationState="danger"
validationText={['First validation text', 'Second validation text']}
Expand All @@ -66,20 +77,20 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
</div>
</DocsSection>
<DocsSection title="Fluid">
<FieldGroup label="Label" isFluid>
<FieldGroup id="FieldGroupFluid" label="Label" isFluid>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
<div className="docs-Box">Item</div>
</FieldGroup>
</DocsSection>
<DocsSection title="Grouped Checkboxes">
<FieldGroup label="Label">
<FieldGroup id="FieldGroupGroupedCheckboxes" label="Label">
<Checkbox id="checkbox1" label="Checkbox Label" name="checkboxDefault" isChecked />
<Checkbox id="checkbox2" label="Checkbox Label" name="checkboxDefault" />
</FieldGroup>
</DocsSection>
<DocsSection title="Grouped Radio Fields">
<FieldGroup label="Label">
<FieldGroup id="FieldGroupGroupedRadios" label="Label">
<Radio id="radio1" label="Radio Label" name="radioDefault" isChecked />
<Radio id="radio2" label="Radio Label" name="radioDefault" />
</FieldGroup>
Expand Down
28 changes: 26 additions & 2 deletions packages/web-react/src/components/Radio/Radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,39 @@ import React, { forwardRef, ForwardedRef } from 'react';
import classNames from 'classnames';
import { useStyleProps } from '../../hooks';
import { SpiritRadioProps } from '../../types';
import { HelperText } from '../Field';
import useAriaDescribedBy from '../Field/useAriaDescribedBy';
import { useRadioStyleProps } from './useRadioStyleProps';

/* We need an exception for components exported with forwardRef */
/* eslint no-underscore-dangle: ['error', { allow: ['_Radio'] }] */
const _Radio = (props: SpiritRadioProps, ref: ForwardedRef<HTMLInputElement>): JSX.Element => {
const { classProps, props: modifiedProps } = useRadioStyleProps(props);
const { id, label, helperText, value, isDisabled, isChecked, onChange, ...restProps } = modifiedProps;
const {
'aria-describedby': ariaDescribedBy = '',
helperText,
id,
isChecked,
isDisabled,
label,
onChange,
value,
...restProps
} = modifiedProps;
const { styleProps, props: otherProps } = useStyleProps(restProps);

const { helperTextId, joinedAriaDescribedBy } = useAriaDescribedBy({
id,
helperText,
validationText: undefined,
ariaDescribedBy,
});

return (
<label htmlFor={id} {...styleProps} className={classNames(classProps.root, styleProps.className)}>
<input
{...otherProps}
aria-describedby={joinedAriaDescribedBy}
type="radio"
id={id}
className={classProps.input}
Expand All @@ -26,7 +46,11 @@ const _Radio = (props: SpiritRadioProps, ref: ForwardedRef<HTMLInputElement>): J
/>
<span className={classProps.text}>
<span className={classProps.label}>{label}</span>
{helperText && <span className={classProps.helperText}>{helperText}</span>}
{helperText && (
<HelperText className={classProps.helperText} elementType="span" id={helperTextId}>
{helperText}
</HelperText>
)}
</span>
</label>
);
Expand Down
Loading

0 comments on commit 7302bfd

Please sign in to comment.