Пишем минималистичный кастомный RadioGroup компонент для React приложения и парочку unit тестов на Jest.

План действий

Общий план действий состоит из 6 этапов:

  1. Понять, что хотим получить

  2. Реализовать компонент Option

  3. Написать компонент RadioGroup

  4. Собрать всё в контейнере и запустить

  5. Сделать поддержку ввода с клавиатуры

  6. Покрыть тестами

Поехали!

Целевой результат

Нам нужна кастомная радио группа для выбора одного из множества вариантов. Для удобства предположим, что у нас есть некая форма и в ней нужна "выбиралка" периода, для выгрузки какой-либо статистики/контента за определённый период времени.

Сделаем компонент в виде горизонтальной плашки, с набором вариантов в виде кнопок. В целом нет никаких ограничений в том, чтобы изменить 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: Ссылки из статьи:

Комментарии (0)