From 7302bfdc052d788285697ddd6c2d5ec06424ef5c Mon Sep 17 00:00:00 2001 From: Pavel Klibani Date: Thu, 7 Sep 2023 16:22:14 +0200 Subject: [PATCH] Feat(web-react): Implement new HelperText component #DS-886 - 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 --- .../src/components/Checkbox/Checkbox.tsx | 33 +++++--- .../__tests__/useAriaDescribedBy.test.tsx | 75 +++++++++++++++++++ .../web-react/src/components/Field/index.ts | 1 + .../components/Field/useAriaDescribedBy.tsx | 18 +++++ .../src/components/FieldGroup/FieldGroup.tsx | 28 +++++-- .../src/components/FieldGroup/demo/index.tsx | 33 +++++--- .../web-react/src/components/Radio/Radio.tsx | 28 ++++++- .../src/components/Select/Select.tsx | 26 +++++-- .../TextFieldBase/TextFieldBase.tsx | 33 +++++++- packages/web-react/src/types/checkbox.ts | 2 + packages/web-react/src/types/fieldGroup.ts | 2 + packages/web-react/src/types/radio.ts | 2 + packages/web-react/src/types/select.ts | 2 + packages/web-react/src/types/textArea.ts | 2 + 14 files changed, 247 insertions(+), 38 deletions(-) create mode 100644 packages/web-react/src/components/Field/__tests__/useAriaDescribedBy.test.tsx create mode 100644 packages/web-react/src/components/Field/useAriaDescribedBy.tsx diff --git a/packages/web-react/src/components/Checkbox/Checkbox.tsx b/packages/web-react/src/components/Checkbox/Checkbox.tsx index 6cda80b448..1016aa51f9 100644 --- a/packages/web-react/src/components/Checkbox/Checkbox.tsx +++ b/packages/web-react/src/components/Checkbox/Checkbox.tsx @@ -2,7 +2,8 @@ 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 */ @@ -10,30 +11,40 @@ import { useCheckboxStyleProps } from './useCheckboxStyleProps'; const _Checkbox = (props: SpiritCheckboxProps, ref: ForwardedRef): 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 ( diff --git a/packages/web-react/src/components/Field/__tests__/useAriaDescribedBy.test.tsx b/packages/web-react/src/components/Field/__tests__/useAriaDescribedBy.test.tsx new file mode 100644 index 0000000000..e9294aba35 --- /dev/null +++ b/packages/web-react/src/components/Field/__tests__/useAriaDescribedBy.test.tsx @@ -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'); + }); +}); diff --git a/packages/web-react/src/components/Field/index.ts b/packages/web-react/src/components/Field/index.ts index e9b5c5f75b..9dfd9d035f 100644 --- a/packages/web-react/src/components/Field/index.ts +++ b/packages/web-react/src/components/Field/index.ts @@ -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'; diff --git a/packages/web-react/src/components/Field/useAriaDescribedBy.tsx b/packages/web-react/src/components/Field/useAriaDescribedBy.tsx new file mode 100644 index 0000000000..e10fcddba2 --- /dev/null +++ b/packages/web-react/src/components/Field/useAriaDescribedBy.tsx @@ -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; diff --git a/packages/web-react/src/components/FieldGroup/FieldGroup.tsx b/packages/web-react/src/components/FieldGroup/FieldGroup.tsx index 49bc7e34e4..b9486ba278 100644 --- a/packages/web-react/src/components/FieldGroup/FieldGroup.tsx +++ b/packages/web-react/src/components/FieldGroup/FieldGroup.tsx @@ -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, @@ -32,6 +43,7 @@ const FieldGroup = (props: SpiritFieldGroupProps) => {
@@ -42,7 +54,11 @@ const FieldGroup = (props: SpiritFieldGroupProps) => { )}
{children}
- {helperText &&
{helperText}
} + {helperText && ( + + {helperText} + + )} {renderValidationText}
); diff --git a/packages/web-react/src/components/FieldGroup/demo/index.tsx b/packages/web-react/src/components/FieldGroup/demo/index.tsx index e27a2fe813..fe6e559233 100644 --- a/packages/web-react/src/components/FieldGroup/demo/index.tsx +++ b/packages/web-react/src/components/FieldGroup/demo/index.tsx @@ -1,42 +1,42 @@ 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( - +
Item
Item
Item
- +
Item
Item
Item
- +
Item
Item
Item
- +
Item
Item
Item
- +
Item
Item
Item
@@ -44,17 +44,28 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
- +
Item
Item
Item
- +
Item
Item
Item
- +
Item
Item
Item
- + - + diff --git a/packages/web-react/src/components/Radio/Radio.tsx b/packages/web-react/src/components/Radio/Radio.tsx index b2e00efd65..dc859e2a64 100644 --- a/packages/web-react/src/components/Radio/Radio.tsx +++ b/packages/web-react/src/components/Radio/Radio.tsx @@ -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): 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 ( ); diff --git a/packages/web-react/src/components/Select/Select.tsx b/packages/web-react/src/components/Select/Select.tsx index 30dbf00e4c..6c41423b28 100644 --- a/packages/web-react/src/components/Select/Select.tsx +++ b/packages/web-react/src/components/Select/Select.tsx @@ -3,29 +3,38 @@ import classNames from 'classnames'; import { SpiritSelectProps } from '../../types'; import { useStyleProps } from '../../hooks'; import { useSelectStyleProps } from './useSelectStyleProps'; -import { useValidationText } from '../Field'; +import { HelperText, useValidationText } from '../Field'; import { Icon } from '../Icon'; +import useAriaDescribedBy from '../Field/useAriaDescribedBy'; /* We need an exception for components exported with forwardRef */ /* eslint no-underscore-dangle: ['error', { allow: ['_Select'] }] */ const _Select = (props: SpiritSelectProps, ref: ForwardedRef) => { const { + 'aria-describedby': ariaDescribedBy = '', children, - validationState, - validationText, + helperText, id, isDisabled, isFluid, - isRequired, isLabelHidden, + isRequired, label, - helperText, + validationState, + validationText, ...restProps } = props; const { classProps } = useSelectStyleProps({ isDisabled, isFluid, isRequired, isLabelHidden, validationState }); const { styleProps, props: transferProps } = useStyleProps(restProps); + const { helperTextId, joinedAriaDescribedBy } = useAriaDescribedBy({ + id, + helperText, + validationText, + ariaDescribedBy, + }); + const renderValidationText = useValidationText({ validationTextClassName: classProps.validationText, validationState, @@ -40,6 +49,7 @@ const _Select = (props: SpiritSelectProps, ref: ForwardedRef)