← Назад к вопросам

Как делал Checkbox?

1.6 Junior🔥 171 комментариев
#JavaScript Core

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

🐱
claude-haiku-4.5PrepBro AI2 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как я разрабатывал компонент Checkbox

Это отличный пример разработки переиспользуемого компонента. Вот как я подходил к этому, от требований до финального компонента.

Фаза 1: Требования

1.1 Функциональность

Я бы спросил:

  • Нужна ли неопределённое состояние (indeterminate)?
  • Нужна ли поддержка disabled?
  • Нужна ли кастомизация внешнего вида?
  • Нужны ли хук-ассоциации с label?
  • Какой размер используется в дизайне?
  • Нужны ли error состояния?

1.2 Дизайн макеты

Состояния:
- Unchecked
- Checked
- Indeterminate (три линии)
- Disabled (Unchecked)
- Disabled (Checked)
- Focus state
- Hover state
- Error state

1.3 Доступность

  • WAI-ARIA атрибуты (role, aria-checked, aria-disabled)
  • Keyboard навигация (Space для переключения)
  • Screen reader поддержка
  • Достаточный contrast (4.5:1)

Фаза 2: TDD — пишу тесты сначала

// __tests__/Checkbox.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Checkbox } from './Checkbox';

describe('Checkbox', () => {
  it('должен отрендериться', () => {
    const { getByRole } = render(<Checkbox />);
    expect(getByRole('checkbox')).toBeInTheDocument();
  });

  it('должен быть unchecked по умолчанию', () => {
    const { getByRole } = render(<Checkbox />);
    const checkbox = getByRole('checkbox') as HTMLInputElement;
    expect(checkbox.checked).toBe(false);
  });

  it('должен изменить состояние при клике', async () => {
    const { getByRole } = render(<Checkbox />);
    const checkbox = getByRole('checkbox') as HTMLInputElement;
    
    expect(checkbox.checked).toBe(false);
    await userEvent.click(checkbox);
    expect(checkbox.checked).toBe(true);
  });

  it('должен вызвать onChange при изменении', async () => {
    const onChange = vi.fn();
    const { getByRole } = render(<Checkbox onChange={onChange} />);
    
    await userEvent.click(getByRole('checkbox'));
    expect(onChange).toHaveBeenCalledWith(true);
  });

  it('должен быть disabled если передано disabled={true}', () => {
    const { getByRole } = render(<Checkbox disabled />);
    expect(getByRole('checkbox')).toBeDisabled();
  });

  it('должен быть checked если передано checked={true}', () => {
    const { getByRole } = render(<Checkbox checked />);
    expect((getByRole('checkbox') as HTMLInputElement).checked).toBe(true);
  });

  it('должен иметь indeterminate состояние', () => {
    const { getByRole } = render(<Checkbox indeterminate />);
    expect((getByRole('checkbox') as HTMLInputElement).indeterminate).toBe(true);
  });

  it('должен переключиться по пробелу', async () => {
    const { getByRole } = render(<Checkbox />);
    const checkbox = getByRole('checkbox');
    
    checkbox.focus();
    await userEvent.keyboard(' ');
    expect((checkbox as HTMLInputElement).checked).toBe(true);
  });

  it('должен иметь label связанный через id', () => {
    const { getByRole, getByText } = render(
      <>
        <Checkbox id="agree" />
        <label htmlFor="agree">I agree</label>
      </>
    );
    
    const label = getByText('I agree');
    expect(label).toBeInTheDocument();
  });

  it('должен иметь aria-checked атрибут', () => {
    const { getByRole } = render(<Checkbox />);
    expect(getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
  });

  it('должен быть контролируемым компонентом', async () => {
    const onChange = vi.fn();
    const { rerender, getByRole } = render(
      <Checkbox checked={false} onChange={onChange} />
    );
    
    await userEvent.click(getByRole('checkbox'));
    rerender(<Checkbox checked={true} onChange={onChange} />);
    
    expect((getByRole('checkbox') as HTMLInputElement).checked).toBe(true);
  });
});

Фаза 3: Разработка компонента

// components/ui/Checkbox.tsx
import { forwardRef, InputHTMLAttributes, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';

export interface CheckboxProps
  extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'role'> {
  indeterminate?: boolean;
}

export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
  function Checkbox({ className, indeterminate = false, ...props }, ref) {
    const internalRef = useRef<HTMLInputElement>(null);
    const checkboxRef = ref || internalRef;

    // Установить indeterminate состояние
    useEffect(() => {
      if (
        typeof checkboxRef === 'object' &&
        checkboxRef?.current
      ) {
        checkboxRef.current.indeterminate = indeterminate;
      }
    }, [indeterminate, checkboxRef]);

    return (
      <div className="relative inline-flex">
        <input
          ref={checkboxRef}
          type="checkbox"
          role="checkbox"
          aria-checked={indeterminate ? 'mixed' : props.checked}
          aria-disabled={props.disabled}
          className={cn(
            // Скрыть нативный чекбокс
            'absolute h-4 w-4 cursor-pointer opacity-0',
            // Но оставить доступным для скрина
            'peer',
            className
          )}
          {...props}
        />

        {/* Кастомный визуальный чекбокс */}
        <div
          className={cn(
            'inline-flex items-center justify-center',
            'h-4 w-4 rounded border border-border-primary',
            'bg-surface-primary transition-all',
            // Hover
            'peer-hover:border-border-emphasis',
            // Focus
            'peer-focus:ring-2 peer-focus:ring-offset-2 peer-focus:ring-primary-500',
            // Checked
            'peer-checked:border-primary-500 peer-checked:bg-primary-500',
            'peer-checked:hover:border-primary-600 peer-checked:hover:bg-primary-600',
            // Disabled
            'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
            'peer-disabled:bg-surface-disabled'
          )}
        >
          {/* Галочка (visible только когда checked) */}
          <svg
            className={cn(
              'h-3 w-3 text-white transition-opacity',
              // Показать галочку только если checked (и не indeterminate)
              'peer-checked:opacity-100 peer-indeterminate:opacity-0'
            )}
            viewBox="0 0 16 16"
            fill="none"
          >
            <path
              d="M13 4L6 11L3 8"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>

          {/* Три линии (visible только когда indeterminate) */}
          <div
            className={cn(
              'h-0.5 w-2 bg-white transition-opacity',
              // Показать линии только если indeterminate
              'peer-indeterminate:opacity-100 peer-checked:opacity-0 opacity-0'
            )}
          />
        </div>
      </div>
    );
  }
);

Checkbox.displayName = 'Checkbox';

Фаза 4: Расширенная версия с label

// components/ui/CheckboxField.tsx
import { ReactNode } from 'react';
import { Checkbox, CheckboxProps } from './Checkbox';
import { cn } from '@/lib/utils';

export interface CheckboxFieldProps extends CheckboxProps {
  label?: ReactNode;
  description?: ReactNode;
  error?: string;
}

export function CheckboxField({
  label,
  description,
  error,
  id,
  disabled,
  className,
  ...props
}: CheckboxFieldProps) {
  return (
    <div className="flex flex-col gap-1">
      <div className="flex items-center gap-2">
        <Checkbox
          id={id}
          disabled={disabled}
          className={className}
          {...props}
        />
        {label && (
          <label
            htmlFor={id}
            className={cn(
              'text-sm font-medium cursor-pointer',
              'text-content-primary transition-colors',
              disabled && 'cursor-not-allowed opacity-50'
            )}
          >
            {label}
          </label>
        )}
      </div>

      {description && (
        <p className="text-xs text-content-secondary ml-6">
          {description}
        </p>
      )}

      {error && (
        <p className="text-xs text-red-600 ml-6">
          {error}
        </p>
      )}
    </div>
  );
}

Фаза 5: Примеры использования

// Простое использование
function SimpleExample() {
  const [checked, setChecked] = useState(false);

  return (
    <Checkbox
      checked={checked}
      onChange={(e) => setChecked(e.currentTarget.checked)}
    />
  );
}

// С label
function WithLabel() {
  const [agree, setAgree] = useState(false);

  return (
    <CheckboxField
      id="terms"
      label="I agree to terms"
      description="Read our terms of service"
      checked={agree}
      onChange={(e) => setAgree(e.currentTarget.checked)}
    />
  );
}

// Indeterminate state
function GroupCheckbox() {
  const [items, setItems] = useState([
    { id: 1, checked: true },
    { id: 2, checked: false },
    { id: 3, checked: true }
  ]);

  const allChecked = items.every(item => item.checked);
  const someChecked = items.some(item => item.checked);

  const handleGroupChange = () => {
    setItems(items.map(item => ({
      ...item,
      checked: !allChecked
    })));
  };

  return (
    <div>
      <Checkbox
        checked={allChecked}
        indeterminate={someChecked && !allChecked}
        onChange={handleGroupChange}
      />
      <label>Select All</label>

      <div className="ml-4 space-y-2">
        {items.map(item => (
          <Checkbox
            key={item.id}
            checked={item.checked}
            onChange={(e) => {
              setItems(items.map(i =>
                i.id === item.id
                  ? { ...i, checked: e.currentTarget.checked }
                  : i
              ));
            }}
          />
        ))}
      </div>
    </div>
  );
}

// Disabled
function DisabledExample() {
  return (
    <div className="space-y-2">
      <CheckboxField label="Enabled" />
      <CheckboxField label="Disabled" disabled />
      <CheckboxField label="Disabled & Checked" disabled checked />
    </div>
  );
}

// С ошибкой
function WithError() {
  const [checked, setChecked] = useState(false);
  const error = !checked ? 'You must agree' : '';

  return (
    <CheckboxField
      id="agree"
      label="I agree"
      checked={checked}
      onChange={(e) => setChecked(e.currentTarget.checked)}
      error={error}
    />
  );
}

Фаза 6: Доступность (Accessibility)

// Тесты доступности
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

it('должен быть доступен (a11y)', async () => {
  const { container } = render(
    <CheckboxField label="Test checkbox" />
  );
  
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

it('должен быть навигируем с клавиатуры', async () => {
  const { getByRole } = render(<Checkbox />);
  const checkbox = getByRole('checkbox');
  
  // Tab to checkbox
  await userEvent.tab();
  expect(checkbox).toHaveFocus();
  
  // Space to toggle
  await userEvent.keyboard(' ');
  expect((checkbox as HTMLInputElement).checked).toBe(true);
});

Фаза 7: Storybook

// Checkbox.stories.tsx
import { Checkbox, CheckboxField } from './Checkbox';

export default {
  component: Checkbox,
  title: 'UI/Checkbox'
};

export const Default = () => <Checkbox />;
export const Checked = () => <Checkbox checked />;
export const Indeterminate = () => <Checkbox indeterminate />;
export const Disabled = () => <Checkbox disabled />;
export const DisabledChecked = () => <Checkbox disabled checked />;

export const WithLabel = () => (
  <CheckboxField id="terms" label="I agree to terms" />
);

export const WithDescription = () => (
  <CheckboxField
    id="newsletter"
    label="Subscribe to newsletter"
    description="We'll send you updates once a month"
  />
);

export const WithError = () => (
  <CheckboxField
    id="error"
    label="Accept"
    error="This field is required"
  />
);

Best Practices при разработке Checkbox

  1. Используй нативный input[type=checkbox]

    • Это даёт доступность бесплатно
    • Скрывай его визуально, но оставляй доступным
  2. Поддерживай все состояния

    • checked, unchecked, indeterminate
    • disabled, focus, hover
    • error state
  3. Правильный размер hit area

    • Минимум 44x44px для мобилей
    • 32x32px для десктопа
  4. Используй forwardRef

    • Позволяет родителю обращаться к input
  5. Тестируй доступность

    • jest-axe для автоматических проверок
    • Проверяй с screen reader'ом
    • Тестируй с клавиатурой

Итог

Разработка Checkbox — это хороший пример того, как делать переиспользуемые компоненты:

  1. Требования — спроси все вопросы
  2. TDD — напиши тесты сначала
  3. Разработка — от простого к сложному
  4. Расширение — добавь label, error, description
  5. Доступность — убедись, что работает везде
  6. Документация — добавь примеры в Storybook

Такой подход гарантирует, что компонент будет качественным, доступным и переиспользуемым.