Repository: gravity-ui/uikit (ts)

Issue:
RadioButton does not call onFocus and onBlur
It seems like the `RadioButton` component does not call the `onFocus ` and `onBlur` callbacks. This sometimes prevents the error messages in forms to be shown.

Maybe we could make so those method would be called like on a native radio/checkbox

Pull request:
feat(RadioGroup): improve radio-group components a11y
Closes #179 

Patch:
diff --git a/src/components/RadioButton/RadioButton.tsx b/src/components/RadioButton/RadioButton.tsx
index cb1603fd..d9259a24 100644
--- a/src/components/RadioButton/RadioButton.tsx
+++ b/src/components/RadioButton/RadioButton.tsx
@@ -84,10 +84,16 @@ export const RadioButton = React.forwardRef<HTMLDivElement, RadioButtonProps>(fu
         [],
     );
 
-    const {optionsProps} = useRadioGroup({...props, options});
+    const {containerProps, optionsProps} = useRadioGroup({...props, options});
 
     return (
-        <div ref={ref} style={style} className={b({size, width}, className)} data-qa={qa}>
+        <div
+            {...containerProps}
+            ref={ref}
+            style={style}
+            className={b({size, width}, className)}
+            data-qa={qa}
+        >
             <div
                 ref={plateRef}
                 className={b('plate')}
diff --git a/src/components/RadioButton/__stories__/RadioButton.stories.tsx b/src/components/RadioButton/__stories__/RadioButton.stories.tsx
index ec214e54..5cada02d 100644
--- a/src/components/RadioButton/__stories__/RadioButton.stories.tsx
+++ b/src/components/RadioButton/__stories__/RadioButton.stories.tsx
@@ -14,10 +14,12 @@ const options: RadioButtonOption[] = [
     {value: 'Value 3', content: 'Value 3'},
 ];
 
-const DefaultTemplate: Story<RadioButtonProps> = (args) => <RadioButton {...args} />;
+const DefaultTemplate: Story<RadioButtonProps> = (args) => {
+    const [value, setValue] = React.useState<string>(options[0].value);
+    return <RadioButton {...args} value={value} onUpdate={setValue} />;
+};
 export const Default = DefaultTemplate.bind({});
 Default.args = {
-    value: options[0].value,
     options,
 };
 
diff --git a/src/components/RadioGroup/RadioGroup.tsx b/src/components/RadioGroup/RadioGroup.tsx
index 20b9139b..83334256 100644
--- a/src/components/RadioGroup/RadioGroup.tsx
+++ b/src/components/RadioGroup/RadioGroup.tsx
@@ -52,10 +52,16 @@ export const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(func
         }));
     }
 
-    const {optionsProps} = useRadioGroup({...props, options});
+    const {containerProps, optionsProps} = useRadioGroup({...props, options});
 
     return (
-        <div ref={ref} style={style} className={b({size, direction}, className)} data-qa={qa}>
+        <div
+            {...containerProps}
+            ref={ref}
+            style={style}
+            className={b({size, direction}, className)}
+            data-qa={qa}
+        >
             {optionsProps.map((optionProps) => (
                 <Radio
                     {...optionProps}
diff --git a/src/components/RadioGroup/__stories__/RadioGroup.stories.tsx b/src/components/RadioGroup/__stories__/RadioGroup.stories.tsx
index 8364d9bd..5f2fe47e 100644
--- a/src/components/RadioGroup/__stories__/RadioGroup.stories.tsx
+++ b/src/components/RadioGroup/__stories__/RadioGroup.stories.tsx
@@ -15,10 +15,12 @@ const options: RadioGroupOption[] = [
     {value: 'Value 3', content: 'Value 3'},
 ];
 
-const DefaultTemplate: Story<RadioGroupProps> = (args) => <RadioGroup {...args} />;
+const DefaultTemplate: Story<RadioGroupProps> = (args) => {
+    const [value, setValue] = React.useState<string>(options[0].value);
+    return <RadioGroup {...args} value={value} onUpdate={setValue} />;
+};
 export const Default = DefaultTemplate.bind({});
 Default.args = {
-    value: options[0].value,
     options,
 };
 
diff --git a/src/components/TextInput/TextInput.scss b/src/components/TextInput/TextInput.scss
index a3fab28c..02fbc593 100644
--- a/src/components/TextInput/TextInput.scss
+++ b/src/components/TextInput/TextInput.scss
@@ -1,6 +1,34 @@
 @use '../variables';
 @use '../../../styles/mixins';
 
+@mixin block_style($size: s, $type: control) {
+    @if $size == 's' {
+        height: 24px;
+        padding: 4px 8px;
+        @include mixins.text-body-short;
+    }
+    @if $size == 'm' {
+        height: 28px;
+        padding: 6px 8px;
+        @include mixins.text-body-short;
+    }
+    @if $size == 'l' {
+        height: 36px;
+        padding: 10px 12px;
+        @include mixins.text-body-short;
+    }
+    @if $size == 'xl' {
+        height: 44px;
+        padding: 12px;
+        @include mixins.text-body-2;
+    }
+
+    @if $type == 'label' {
+        @include mixins.text-accent;
+        padding-right: 4px;
+    }
+}
+
 $block: '.#{variables.$ns}text-input';
 
 #{$block} {
@@ -50,6 +78,16 @@ $block: '.#{variables.$ns}text-input';
         }
     }
 
+    &__label {
+        position: absolute;
+        box-sizing: border-box;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        max-width: 50%;
+        z-index: 1;
+    }
+
     &__error {
         @include mixins.text-body-1();
         color: var(--yc-color-text-danger);
@@ -78,10 +116,11 @@ $block: '.#{variables.$ns}text-input';
     &_size {
         &_s {
             #{$block}__control {
-                height: 24px;
-                padding: 4px 8px;
-                font-size: 13px;
-                line-height: 16px;
+                @include block_style(s);
+            }
+
+            #{$block}__label {
+                @include block_style(s, label);
             }
 
             &#{$block}_has-clear {
@@ -95,10 +134,11 @@ $block: '.#{variables.$ns}text-input';
 
         &_m {
             #{$block}__control {
-                height: 28px;
-                padding: 5px 8px;
-                font-size: 13px;
-                line-height: 16px;
+                @include block_style(m);
+            }
+
+            #{$block}__label {
+                @include block_style(m, label);
             }
 
             &#{$block}_has-clear {
@@ -112,10 +152,11 @@ $block: '.#{variables.$ns}text-input';
 
         &_l {
             #{$block}__control {
-                height: 36px;
-                padding: 10px 13px;
-                font-size: 13px;
-                line-height: 16px;
+                @include block_style(l);
+            }
+
+            #{$block}__label {
+                @include block_style(l, label);
             }
 
             &#{$block}_has-clear {
@@ -129,10 +170,11 @@ $block: '.#{variables.$ns}text-input';
 
         &_xl {
             #{$block}__control {
-                height: 44px;
-                padding: 12px 13px;
-                font-size: 15px;
-                line-height: 20px;
+                @include block_style(xl);
+            }
+
+            #{$block}__label {
+                @include block_style(xl, label);
             }
 
             &#{$block}_has-clear {
@@ -190,6 +232,10 @@ $block: '.#{variables.$ns}text-input';
                 border-color: transparent;
             }
         }
+
+        #{$block}__label {
+            color: var(--yc-color-text-hint);
+        }
     }
 
     &_has-scrollbar {
diff --git a/src/components/TextInput/TextInput.tsx b/src/components/TextInput/TextInput.tsx
index 9a46a924..880fc912 100644
--- a/src/components/TextInput/TextInput.tsx
+++ b/src/components/TextInput/TextInput.tsx
@@ -1,6 +1,8 @@
 import React from 'react';
 import {block} from '../utils/cn';
 import {useForkRef} from '../utils/useForkRef';
+import {useElementSize} from '../utils/useElementSize';
+import {useUniqId} from '../utils/useUniqId';
 import {TextAreaControl} from './TextAreaControl/TextAreaControl';
 import {InputControl} from './InputControl/InputControl';
 import {Button} from '../Button';
@@ -42,6 +44,7 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
         name,
         value,
         defaultValue,
+        label,
         disabled = false,
         multiline = false,
         hasClear = false,
@@ -49,21 +52,28 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
         autoComplete,
         onUpdate,
         onChange,
-        id,
+        id: originalId,
         tabIndex,
         style,
         className,
         qa,
+        controlProps: originalControlProps,
     } = props;
     const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? '');
     const innerControlRef = React.useRef<HTMLTextAreaElement | HTMLInputElement>(null);
+    const labelRef = React.useRef<HTMLLabelElement>(null);
+    const innerId = useUniqId();
+    const id = originalId || innerId;
     const [hasVerticalScrollbar, setHasVerticalScrollbar] = React.useState(false);
 
     const isControlled = value !== undefined;
     const inputValue = isControlled ? value : uncontrolledValue;
+    const isLabelVisible = !multiline && Boolean(label);
 
     const handleRef = useForkRef(props.controlRef, innerControlRef);
 
+    const labelSize = useElementSize(isLabelVisible ? labelRef : null, size);
+
     React.useEffect(() => {
         const control = innerControlRef.current;
 
@@ -107,6 +117,14 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
     const isErrorMsgVisible = typeof error === 'string';
     const isClearControlVisible = Boolean(hasClear && !disabled && inputValue);
 
+    const controlProps: TextInputProps['controlProps'] = {
+        ...originalControlProps,
+        style: {
+            ...originalControlProps?.style,
+            ...(isLabelVisible && labelSize.width ? {paddingLeft: `${labelSize.width}px`} : {}),
+        },
+    };
+
     const commonProps = {
         id,
         tabIndex,
@@ -124,6 +142,7 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
             }
         },
         autoComplete: prepareAutoComplete(autoComplete),
+        controlProps,
     };
 
     return (
@@ -144,7 +163,12 @@ export const TextInput = React.forwardRef<HTMLSpanElement, TextInputProps>(funct
             )}
             data-qa={qa}
         >
-            {props.multiline ? (
+            {isLabelVisible && (
+                <label ref={labelRef} className={b('label')} title={label} htmlFor={id}>
+                    {`${label}`}
+                </label>
+            )}
+            {multiline ? (
                 <TextAreaControl {...props} {...commonProps} controlRef={handleRef} />
             ) : (
                 <InputControl {...props} {...commonProps} controlRef={handleRef} />
diff --git a/src/components/TextInput/__stories__/TextInputShowcase.scss b/src/components/TextInput/__stories__/TextInputShowcase.scss
index dff0d37a..3b56bfc3 100644
--- a/src/components/TextInput/__stories__/TextInputShowcase.scss
+++ b/src/components/TextInput/__stories__/TextInputShowcase.scss
@@ -2,6 +2,7 @@
     display: grid;
     grid-template:
         'text-input' auto
+        'text-input-label' auto
         'text-area' auto / auto;
 
     &__text-input-examples {
@@ -14,6 +15,16 @@
         padding: 20px;
     }
 
+    &__text-input-label-examples {
+        grid-area: text-input-label;
+        display: grid;
+        grid-template:
+            'title title' auto
+            'sizes states' auto / 1fr 1fr;
+        gap: 20px;
+        padding: 20px;
+    }
+
     &__text-area-examples {
         grid-area: text-area;
         display: grid;
diff --git a/src/components/TextInput/__stories__/TextInputShowcase.tsx b/src/components/TextInput/__stories__/TextInputShowcase.tsx
index 1c10a0f7..eee8f63c 100644
--- a/src/components/TextInput/__stories__/TextInputShowcase.tsx
+++ b/src/components/TextInput/__stories__/TextInputShowcase.tsx
@@ -7,6 +7,9 @@ import './TextInputShowcase.scss';
 
 const b = block('text-input-showcase');
 
+const LABEL = 'Label:';
+const LONG_LABEL = 'Very very long label is limited by 50% width of the input control size';
+
 export const TextInputShowcase: React.FC = () => {
     const [value, setValue] = React.useState('');
     const [isErrorMessageVisible, setErrorMessageVisibility] = React.useState(false);
@@ -63,6 +66,52 @@ export const TextInputShowcase: React.FC = () => {
                 </div>
             </div>
 
+            <div className={b('text-input-label-examples')}>
+                <h2 className={b('title')}>TextInput (label)</h2>
+
+                <div className={'size-examples'}>
+                    <h3 className={b('section-header')}>Sizes:</h3>
+
+                    <TextInput {...textInputProps} size="s" placeholder="s" label={LABEL} />
+                    <TextInput {...textInputProps} placeholder="m" label={LABEL} />
+                    <TextInput {...textInputProps} size="l" placeholder="l" label={LABEL} />
+                    <TextInput {...textInputProps} size="xl" placeholder="xl" label={LABEL} />
+                </div>
+
+                <div className={b('state-examples')}>
+                    <h3 className={b('section-header')}>States:</h3>
+
+                    <div className={b('row')}>
+                        <TextInput
+                            {...textInputProps}
+                            placeholder="error with message"
+                            label={LABEL}
+                            error={isErrorMessageVisible ? 'It happened a validation error' : true}
+                        />
+                        <Checkbox
+                            onUpdate={setErrorMessageVisibility}
+                            checked={isErrorMessageVisible}
+                        />
+                    </div>
+                    <TextInput {...textInputProps} placeholder="disabled" label={LABEL} disabled />
+                    <TextInput {...textInputProps} placeholder="clear" label={LABEL} hasClear />
+                    <TextInput
+                        {...textInputProps}
+                        placeholder="default value"
+                        label={LABEL}
+                        value={undefined}
+                        defaultValue="defaultValue"
+                    />
+                    <TextInput
+                        {...textInputProps}
+                        placeholder="default value"
+                        label={LONG_LABEL}
+                        value={undefined}
+                        defaultValue="defaultValue"
+                    />
+                </div>
+            </div>
+
             <div className={b('text-area-examples')}>
                 <h2 className={b('title')}>TextInput (multiline)</h2>
 
diff --git a/src/components/TextInput/types.ts b/src/components/TextInput/types.ts
index e6f8bffa..d32b2524 100644
--- a/src/components/TextInput/types.ts
+++ b/src/components/TextInput/types.ts
@@ -43,6 +43,7 @@ export interface TextInputProps extends DOMProps, QAProps {
     defaultValue?: string;
     disabled?: boolean;
     placeholder?: string;
+    label?: string;
     /**
      * clear button
      */
diff --git a/src/components/types.ts b/src/components/types.ts
index 736a3542..4859c22b 100644
--- a/src/components/types.ts
+++ b/src/components/types.ts
@@ -55,6 +55,10 @@ export interface ControlGroupProps {
     defaultValue?: string;
     onUpdate?: (value: string) => void;
     onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
+    onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
+    onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
     disabled?: boolean;
     options?: ControlGroupOption[];
+    'aria-label'?: string;
+    'aria-labelledby'?: string;
 }
diff --git a/src/components/utils/useElementSize.ts b/src/components/utils/useElementSize.ts
new file mode 100644
index 00000000..3c7daee0
--- /dev/null
+++ b/src/components/utils/useElementSize.ts
@@ -0,0 +1,64 @@
+import {useLayoutEffect, useState, MutableRefObject} from 'react';
+import ResizeObserver from 'resize-observer-polyfill';
+import _throttle from 'lodash/throttle';
+
+const RESIZE_THROTTLE = 16;
+
+interface ElementSize {
+    width: number;
+    height: number;
+}
+
+export function useElementSize<T extends HTMLElement = HTMLDivElement>(
+    ref: MutableRefObject<T | null> | null,
+    // can be used, when it is needed to force reassign observer to element
+    // in order to get correct measures. might be related to below
+    // https://github.com/WICG/resize-observer/issues/65
+    key?: string,
+) {
+    const [size, setSize] = useState<ElementSize>({
+        width: 0,
+        height: 0,
+    });
+
+    useLayoutEffect(() => {
+        if (!ref?.current) {
+            return undefined;
+        }
+
+        const handleResize: ResizeObserverCallback = (entries) => {
+            if (!Array.isArray(entries)) {
+                return;
+            }
+
+            const entry = entries[0];
+            if (entry.borderBoxSize) {
+                const borderBoxSize = entry.borderBoxSize[0]
+                    ? entry.borderBoxSize[0]
+                    : (entry.borderBoxSize as unknown as ResizeObserverSize);
+                // ...but old versions of Firefox treat it as a single item
+                // https://github.com/mdn/dom-examples/blob/main/resize-observer/resize-observer-text.html#L88
+
+                setSize({
+                    width: Math.round(borderBoxSize.inlineSize),
+                    height: Math.round(borderBoxSize.blockSize),
+                });
+            } else {
+                const target = entry.target as HTMLElement;
+                setSize({
+                    width: Math.round(target.offsetWidth),
+                    height: Math.round(target.offsetHeight),
+                });
+            }
+        };
+
+        const observer = new ResizeObserver(_throttle(handleResize, RESIZE_THROTTLE));
+        observer.observe(ref.current);
+
+        return () => {
+            observer.disconnect();
+        };
+    }, [ref, key]);
+
+    return size;
+}
diff --git a/src/components/utils/useRadioGroup.ts b/src/components/utils/useRadioGroup.ts
index 38025efb..5ac5be39 100644
--- a/src/components/utils/useRadioGroup.ts
+++ b/src/components/utils/useRadioGroup.ts
@@ -4,7 +4,17 @@ import {ControlGroupProps} from '../types';
 import {useUniqId} from './useUniqId';
 
 export function useRadioGroup(props: ControlGroupProps) {
-    const {name, value, defaultValue, options = [], disabled, onUpdate, onChange} = props;
+    const {
+        name,
+        value,
+        defaultValue,
+        options = [],
+        disabled,
+        onUpdate,
+        onChange,
+        onFocus,
+        onBlur,
+    } = props;
 
     const controlId = useUniqId();
     const [valueState, setValueState] = React.useState(
@@ -28,6 +38,13 @@ export function useRadioGroup(props: ControlGroupProps) {
         [isControlled, onUpdate, onChange],
     );
 
+    const containerProps = {
+        role: 'radiogroup',
+        'aria-disabled': disabled,
+        'aria-label': props['aria-label'],
+        'aria-labelledby': props['aria-labelledby'],
+    };
+
     const optionsProps = options.map((option) => ({
         name: name || controlId,
         value: String(option.value),
@@ -35,7 +52,9 @@ export function useRadioGroup(props: ControlGroupProps) {
         checked: currentValue === String(option.value),
         disabled: disabled || option.disabled,
         onChange: handleChange,
+        onFocus: onFocus,
+        onBlur: onBlur,
     }));
 
-    return {optionsProps};
+    return {containerProps, optionsProps};
 }


Test patch:
diff --git a/src/components/TextInput/__tests__/TextInput.test.tsx b/src/components/TextInput/__tests__/TextInput.test.tsx
index 302f607d..b1060211 100644
--- a/src/components/TextInput/__tests__/TextInput.test.tsx
+++ b/src/components/TextInput/__tests__/TextInput.test.tsx
@@ -20,6 +20,17 @@ describe('TextInput', () => {
         expect(input.tagName.toLowerCase()).toBe('textarea');
     });
 
+    test('render input with label', () => {
+        const {container} = render(<TextInput label="Label:" />);
+
+        // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
+        const label = container.querySelector('.yc-text-input__label');
+
+        expect(label).toBeInTheDocument();
+        expect(label?.tagName.toLowerCase()).toBe('label');
+        expect(screen.getByText('Label:')).toBeVisible();
+    });
+
     test('render error message with error prop', () => {
         const {container} = render(<TextInput error="Some Error" />);