Комментарии (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
-
Используй нативный input[type=checkbox]
- Это даёт доступность бесплатно
- Скрывай его визуально, но оставляй доступным
-
Поддерживай все состояния
- checked, unchecked, indeterminate
- disabled, focus, hover
- error state
-
Правильный размер hit area
- Минимум 44x44px для мобилей
- 32x32px для десктопа
-
Используй forwardRef
- Позволяет родителю обращаться к input
-
Тестируй доступность
- jest-axe для автоматических проверок
- Проверяй с screen reader'ом
- Тестируй с клавиатурой
Итог
Разработка Checkbox — это хороший пример того, как делать переиспользуемые компоненты:
- Требования — спроси все вопросы
- TDD — напиши тесты сначала
- Разработка — от простого к сложному
- Расширение — добавь label, error, description
- Доступность — убедись, что работает везде
- Документация — добавь примеры в Storybook
Такой подход гарантирует, что компонент будет качественным, доступным и переиспользуемым.