Пишем минималистичный кастомный RadioGroup компонент для React приложения и парочку unit тестов на Jest.
План действий
Общий план действий состоит из 6 этапов:
Понять, что хотим получить
Реализовать компонент Option
Написать компонент RadioGroup
Собрать всё в контейнере и запустить
Сделать поддержку ввода с клавиатуры
Покрыть тестами
Поехали!
Целевой результат
Нам нужна кастомная радио группа для выбора одного из множества вариантов. Для удобства предположим, что у нас есть некая форма и в ней нужна "выбиралка" периода, для выгрузки какой-либо статистики/контента за определённый период времени.
Сделаем компонент в виде горизонтальной плашки, с набором вариантов в виде кнопок. В целом нет никаких ограничений в том, чтобы изменить ui компонента так, как вам это будет требоваться. Feel free to edit, как говорится.
По итогу получим вот такой минималистичный компонент. Демо: codesandbox.custom-radio
PS: в данной статье не будет описания работы с формами и валидацией. Решений подобных задач очень много, стоит только погуглить). Например один из вариантов я описываю в статье Валидация форм без зависимостей.
Поехали!
Пишем компонент Option
Интерфейсы
Начнём с того, что определим структуру нашего варианта выбора. Он будет минималистичен и включать 2 параметра:
type OptionType = {
value: string;
title: string;
};
Сам же компонент Options должен уметь делать несколько вещей:
отображать один вариант выбора
промечать выбранный элемент отличным от других
вызывать onChange при выборе клике на элемент
При переводе на typescript интерфейс компонента Option выглядит следующим образом:
type OptionProps = {
value: OptionType['value'];
title: OptionType['title'];
selected: OptionType['value'];
groupName: string;
onChange?: (value: string) => void;
};
Верстка
Для стилизации будем использовать css modules для стилизации (поскольку в основе приложения лежит react-create-app с шаблоном ts, то поддержка css modules у нас уже реализована из коробки).
Нам достаточно только импортировать стили и применять к элементам:
import Styles from './index.module.css';
...
<div className={Styles.group}>...</div>
Сам же компонент выглядит очень просто:
const Option = (props: OptionProps) => {
const {
value,
title,
selected,
groupName,
onChange
} = props;
const handleChange = () => onChange?.(value);
const inputId = `${groupName}_radio_item_with_value__${value}`;
const isChecked = value === selected;
return (
<div
className={Styles.item}
key={value}
data-checked={isChecked}
>
<input
className={Styles.input}
type="radio"
name={groupName}
id={inputId}
value={value}
onChange={handleChange}
/>
<label className={Styles.label} htmlFor={inputId}>
{title}
</label>
</div>
);
};
Простановка data-checked в true закрывает требование "промечать выбранный элемент отличным от других". Затем просто рендерим title и вешаем handleChange на onChange нашего инпута.
Пишем компонент RadioGroup
Интерфейсы
Компонент RadioGroup должен принимать список options, коллбэк onChange и значение выбранного элемента. Ну и поскольку мы делаем именно Radio group, а не что-то другое, нам нужно проставлять имя этой группы.
В итоге получаем интерфейс, состоящий из 4х пропсов:
type RadioGroupProps = {
name: string;
options: OptionType[];
selected: OptionType['value'];
onChange?: (value: string) => void;
};
Вёрстка
В компоненте нам надо отрендерить список option и объявить handleChange для обработки выбранного элемента. Плюс для оптимизации обернём компонент в React.memo.
const RadioGroup = (props: RadioGroupProps) => {
const { name, options, selected, onChange } = props;
const handleChange = (value: string) => onChange?.(value);
return (
<div className={Styles.group}>
{options.map(({ value, title }) => (
<Option
key={value}
groupName={name}
value={value}
title={title}
selected={selected}
onChange={handleChange}
/>
))}
</div>
);
};
export default React.memo(RadioGroup);
Собираем всё в контейнере и запускаем
import { useState } from "react";
import options from "./components/radio/options.json";
import Radio from "./components/radio";
import "./styles.css";
export default function App() {
const [period, setPeriod] = useState("");
const handlePeriodChange = (val: string) => {
setPeriod(val);
};
return (
<div className="App">
<h1>Custom RadioGroup component example</h1>
<h3>Выбрать период</h3>
<div className="Radio">
<Radio
selected={period}
name="radio"
onChange={handlePeriodChange}
options={options}
/>
</div>
</div>
);
}
Поддержка ввода с клавиатуры
Для реализации возможности взаимодействия с RadioGroup с клавиатуры, нам потребуется немного доработать наш Option компонент. А именно:
в Option нам нужно слушать событие нажатия, но при этом проверять находится ли наш option в фокусе или нет. Если option в фокусе, то вызываем обработчик onClick
немного поколдовать с tabindex.
В итоге получаем следующие доработки:
import { useEffect, useRef } from 'react';
const Option = (props: OptionProps) => {
const optionRef = useRef<HTMLDivElement>(null);
...
useEffect(() => {
const option = optionRef.current;
if (!option) return;
const handleEnterKeyDown = (event: KeyboardEvent) => {
if ((document.activeElement === option) && event.key === 'Enter') {
onChange?.(value);
}
}
option.addEventListener('keydown', handleEnterKeyDown);
return () => {
option.removeEventListener('keydown', handleEnterKeyDown);
};
}, [value, onChange]);
return (
<div
className={Styles.item}
{ /* rest props */ }
ref={optionRef}
tabIndex={0}
>
<input className={Styles.input} { /* rest props */ } tabIndex={-1} />
...
</div>
);
}
Мы исключаем input из обхода элементов при использовании клавиши tab, проставляя tabindex в отрицательное значение. И включаем в этот обход div обёртку всего нашего кастомного option.
Таким образом дефолтное поведение браузера при фокусе на элемент будет работать для всего нашего компонента. Потом можем через css добавить псевдоклассов focus-visible.
activeElement содержит в себе ссылку на элемент документа, который находится в фокусе. Подробнее можно прочитать на MDN: document.activeElement.
Есть тонкости в разнице focus и focus-visible, про которые можно почитать в статье Doka:focus-visible
Пишем пару unit тестов
Перед началом проставляем атрибут data-testid для каждого Option, для того, чтобы было проще искать элементы в тестах.
const Option = (props: OptionProps) => {
...
const inputId = `${groupName}_radio_item_with_value__${value}`;
return (
<div className={Styles.item} { /* rest props */ } data-testid={inputId}>...</div>
);
};
Про структуру теста и используемые методы можно прочитать в другой моей статье про пагинацию в React приложении в разделе Структура теста.
Всё первоначальные настройки для запуска тестов у нас уже есть из коробки create-react-app.
Для нашего мини компонента напишем парочку мини тестов. Проверим, что атрибут data-checked проставляется при выборе элемента и корректно вызывается onChange:
import '@testing-library/jest-dom';
import { render, screen, fireEvent } from '@testing-library/react';
import RadioGroup from './index';
import options from './options.json';
describe('React component: RadioGroup', () => {
it('Должен проставляться атрибут [data-checked="true"] на option, если было выбрано его значение', async () => {
render(
<RadioGroup
selected={options[2].value}
name="id"
onChange={jest.fn()}
options={options}
/>
);
const radioItem = screen.getByTestId(`radio_item_with_value__${options[2].value}`);
expect(radioItem).toHaveAttribute('data-checked', 'true');
});
it('Должен вызываться обработчик "onChange" при клике на option', async () => {
const handleChange = jest.fn();
render(
<RadioGroup
selected={options[2].value}
name="id"
onChange={handleChange}
options={options}
/>
);
const label = screen.getByLabelText(options[2].title);
fireEvent.click(label);
expect(handleChange).toHaveBeenCalledTimes(1);
});
});
PS:
Про фронтовые тесты есть отличная статья из блога Samokat.tech Как тестировать современный фронтенд.
Итого
Спасибо за чтение и удачи в написании ваших кастомных компонентов)
PS: Ссылки из статьи:
Код и демо: codesandbox.custom-radio
-
другие статьи
-
про React
про create-react-app
про React.memo
-
про вёрстку
про Radio group на MDN
про разницу :focus vs :focus-visible