Repository: gravity-ui/uikit
Pull request #904 (2023-08-16 14:42:19)

feat(TextArea, TextInput): use aria-describedby for error and note
closes https://github.com/gravity-ui/uikit/issues/860

gravity-ui-bot: [Preview](https://preview.gravity-ui.com/uikit/904/) is ready.
<!-- Sticky Pull Request Comment -->
korvin89: Could U add this changes to TextArea too?
Kyzyl-ool: > Could U add this changes to TextArea too?

Ready

Patch:
diff --git a/src/components/controls/TextArea/TextArea.scss b/src/components/controls/TextArea/TextArea.scss
index f11a45c7..93f2155a 100644
--- a/src/components/controls/TextArea/TextArea.scss
+++ b/src/components/controls/TextArea/TextArea.scss
@@ -46,31 +46,6 @@ $block: '.#{variables.$ns-new}text-area';
         }
     }
 
-    &__outer-additional-content {
-        display: flex;
-        justify-content: space-between;
-        vertical-align: top;
-    }
-
-    &__note {
-        margin-left: auto;
-    }
-
-    &__error {
-        @include mixins.text-body-1();
-
-        color: var(--g-color-text-danger);
-
-        &:not(:last-child) {
-            margin-right: var(--g-spacing-2);
-        }
-    }
-
-    &__note,
-    &__error {
-        margin-top: 2px;
-    }
-
     &__clear {
         position: absolute;
         right: 1px;
diff --git a/src/components/controls/TextArea/TextArea.tsx b/src/components/controls/TextArea/TextArea.tsx
index db01117a..ecc623d7 100644
--- a/src/components/controls/TextArea/TextArea.tsx
+++ b/src/components/controls/TextArea/TextArea.tsx
@@ -4,18 +4,14 @@ import {blockNew} from '../../utils/cn';
 import {useForkRef} from '../../utils/useForkRef';
 import {useUniqId} from '../../utils/useUniqId';
 import {ClearButton, mapTextInputSizeToButtonSize} from '../common';
+import {OuterAdditionalContent} from '../common/OuterAdditionalContent/OuterAdditionalContent';
 import type {
     BaseInputControlProps,
     InputControlPin,
     InputControlSize,
     InputControlView,
 } from '../types';
-import {
-    CONTROL_ERROR_MESSAGE_QA,
-    errorPropsMapper,
-    getInputControlState,
-    prepareAutoComplete,
-} from '../utils';
+import {errorPropsMapper, getInputControlState, prepareAutoComplete} from '../utils';
 
 import {TextAreaControl} from './TextAreaControl';
 
@@ -87,6 +83,16 @@ export const TextArea = React.forwardRef<HTMLSpanElement, TextAreaProps>(functio
     const isClearControlVisible = Boolean(hasClear && !disabled && inputValue);
     const id = originalId || innerId;
 
+    const errorMessageId = useUniqId();
+    const noteId = useUniqId();
+    const ariaDescribedBy = [
+        controlProps?.['aria-describedby'],
+        note ? noteId : undefined,
+        isErrorMsgVisible ? errorMessageId : undefined,
+    ]
+        .filter(Boolean)
+        .join(' ');
+
     const commonProps = {
         id,
         tabIndex,
@@ -104,7 +110,11 @@ export const TextArea = React.forwardRef<HTMLSpanElement, TextAreaProps>(functio
             }
         },
         autoComplete: prepareAutoComplete(autoComplete),
-        controlProps,
+        controlProps: {
+            ...controlProps,
+            'aria-describedby': ariaDescribedBy || undefined,
+            'aria-invalid': validationState === 'invalid' || undefined,
+        },
     };
 
     const handleClear = (event: React.MouseEvent<HTMLSpanElement>) => {
@@ -173,16 +183,12 @@ export const TextArea = React.forwardRef<HTMLSpanElement, TextAreaProps>(functio
                     />
                 )}
             </span>
-            {(isErrorMsgVisible || note) && (
-                <div className={b('outer-additional-content')}>
-                    {isErrorMsgVisible && (
-                        <div className={b('error')} data-qa={CONTROL_ERROR_MESSAGE_QA}>
-                            {errorMessage}
-                        </div>
-                    )}
-                    {note && <div className={b('note')}>{note}</div>}
-                </div>
-            )}
+            <OuterAdditionalContent
+                errorMessage={isErrorMsgVisible ? errorMessage : null}
+                errorMessageId={errorMessageId}
+                note={note}
+                noteId={noteId}
+            />
         </span>
     );
 });
diff --git a/src/components/controls/TextInput/TextInput.scss b/src/components/controls/TextInput/TextInput.scss
index 31d430bc..f647c923 100644
--- a/src/components/controls/TextInput/TextInput.scss
+++ b/src/components/controls/TextInput/TextInput.scss
@@ -91,31 +91,6 @@ $block: '.#{variables.$ns}text-input';
         flex-shrink: 0;
     }
 
-    &__outer-additional-content {
-        display: flex;
-        justify-content: space-between;
-        vertical-align: top;
-    }
-
-    &__note {
-        margin-left: auto;
-    }
-
-    &__error {
-        @include mixins.text-body-1();
-
-        color: var(--g-color-text-danger);
-
-        &:not(:last-child) {
-            margin-right: var(--g-spacing-2);
-        }
-    }
-
-    &__note,
-    &__error {
-        margin-top: 2px;
-    }
-
     &__error-icon {
         color: var(--g-color-text-danger);
         padding: var(--_--text-input-error-icon-padding);
diff --git a/src/components/controls/TextInput/TextInput.tsx b/src/components/controls/TextInput/TextInput.tsx
index 375f05dd..2b6d2cbd 100644
--- a/src/components/controls/TextInput/TextInput.tsx
+++ b/src/components/controls/TextInput/TextInput.tsx
@@ -9,6 +9,7 @@ import {useElementSize} from '../../utils/useElementSize';
 import {useForkRef} from '../../utils/useForkRef';
 import {useUniqId} from '../../utils/useUniqId';
 import {ClearButton, mapTextInputSizeToButtonSize} from '../common';
+import {OuterAdditionalContent} from '../common/OuterAdditionalContent/OuterAdditionalContent';
 import type {
     BaseInputControlProps,
     InputControlPin,
@@ -113,12 +114,24 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
     const labelSize = useElementSize(isLabelVisible ? labelRef : null, size);
     const leftContentSize = useElementSize(isLeftContentVisible ? leftContentRef : null, size);
 
+    const errorMessageId = useUniqId();
+    const noteId = useUniqId();
+    const ariaDescribedBy = [
+        originalControlProps?.['aria-describedby'],
+        note ? noteId : undefined,
+        isErrorMsgVisible ? errorMessageId : undefined,
+    ]
+        .filter(Boolean)
+        .join(' ');
+
     const controlProps: TextInputProps['controlProps'] = {
         ...originalControlProps,
         style: {
             ...originalControlProps?.style,
             ...(isLabelVisible && labelSize.width ? {paddingLeft: `${labelSize.width}px`} : {}),
         },
+        'aria-invalid': validationState === 'invalid' || undefined,
+        'aria-describedby': ariaDescribedBy || undefined,
     };
     const commonProps = {
         id,
@@ -243,12 +256,12 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
                     </Popover>
                 )}
             </span>
-            {(isErrorMsgVisible || note) && (
-                <div className={b('outer-additional-content')}>
-                    {isErrorMsgVisible && <div className={b('error')}>{errorMessage}</div>}
-                    {note && <div className={b('note')}>{note}</div>}
-                </div>
-            )}
+            <OuterAdditionalContent
+                note={note}
+                errorMessage={isErrorMsgVisible ? errorMessage : null}
+                noteId={noteId}
+                errorMessageId={errorMessageId}
+            />
         </span>
     );
 });
diff --git a/src/components/controls/common/OuterAdditionalContent/OuterAdditionalContent.scss b/src/components/controls/common/OuterAdditionalContent/OuterAdditionalContent.scss
new file mode 100644
index 00000000..933ad049
--- /dev/null
+++ b/src/components/controls/common/OuterAdditionalContent/OuterAdditionalContent.scss
@@ -0,0 +1,29 @@
+@use '../../../../../styles/mixins';
+@use '../../../variables';
+
+$block: '.#{variables.$ns}outer-additional-content';
+
+#{$block} {
+    display: flex;
+    justify-content: space-between;
+    vertical-align: top;
+
+    &__note,
+    &__error {
+        margin-top: 2px;
+    }
+
+    &__error {
+        @include mixins.text-body-1();
+
+        color: var(--g-color-text-danger);
+
+        &:not(:last-child) {
+            margin-right: var(--g-spacing-2);
+        }
+    }
+
+    &__note {
+        margin-left: auto;
+    }
+}
diff --git a/src/components/controls/common/OuterAdditionalContent/OuterAdditionalContent.tsx b/src/components/controls/common/OuterAdditionalContent/OuterAdditionalContent.tsx
new file mode 100644
index 00000000..3b73f7b5
--- /dev/null
+++ b/src/components/controls/common/OuterAdditionalContent/OuterAdditionalContent.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+
+import {block} from '../../../utils/cn';
+import {CONTROL_ERROR_MESSAGE_QA} from '../../utils';
+
+import './OuterAdditionalContent.scss';
+
+const b = block('outer-additional-content');
+
+interface OuterAdditionalContentProps {
+    note?: React.ReactNode;
+    errorMessage?: React.ReactNode;
+    noteId?: string;
+    errorMessageId?: string;
+}
+
+export const OuterAdditionalContent = ({
+    errorMessage,
+    note,
+    noteId,
+    errorMessageId,
+}: OuterAdditionalContentProps) => {
+    return errorMessage || note ? (
+        <div className={b()}>
+            {errorMessage && (
+                <div className={b('error')} id={errorMessageId} data-qa={CONTROL_ERROR_MESSAGE_QA}>
+                    {errorMessage}
+                </div>
+            )}
+            {note && (
+                <div className={b('note')} id={noteId}>
+                    {note}
+                </div>
+            )}
+        </div>
+    ) : null;
+};


Test patch:
diff --git a/src/components/controls/TextInput/__tests__/TextInput.input.test.tsx b/src/components/controls/TextInput/__tests__/TextInput.input.test.tsx
index 41766fba..010bc7bb 100644
--- a/src/components/controls/TextInput/__tests__/TextInput.input.test.tsx
+++ b/src/components/controls/TextInput/__tests__/TextInput.input.test.tsx
@@ -1,6 +1,6 @@
 import React from 'react';
 
-import {fireEvent, render, screen} from '@testing-library/react';
+import {fireEvent, queryHelpers, render, screen} from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 
 import {CONTROL_ERROR_ICON_QA, CONTROL_ERROR_MESSAGE_QA} from '../../utils';
@@ -195,6 +195,31 @@ describe('TextInput input', () => {
                 expect(label?.tagName.toLowerCase()).toBe('label');
                 expect(screen.getByText('Label:')).toBeVisible();
             });
+
+            test('render described input with error message and note', () => {
+                const errorText = 'Some error text';
+                const noteText = 'Note text';
+                const {container} = render(<TextInput error={errorText} note={noteText} />);
+
+                const input = screen.getByRole('textbox');
+                const [noteId = '', errorMessageId = ''] = (
+                    input.getAttribute('aria-describedby') ?? ''
+                ).split(/\s+/);
+                expect(noteId).not.toBe('');
+                expect(errorMessageId).not.toBe('');
+
+                const errorTextElement = queryHelpers.queryByAttribute(
+                    'id',
+                    container,
+                    errorMessageId,
+                );
+                const noteTextElement = queryHelpers.queryByAttribute('id', container, noteId);
+                if (!errorTextElement || !noteTextElement) {
+                    throw new Error('Both error message and note elements should be present');
+                }
+                expect(errorTextElement?.textContent).toBe(errorText);
+                expect(noteTextElement?.textContent).toBe(noteText);
+            });
         });
 
         describe('autocomplete', () => {