← Назад к вопросам
Какие элементы управления приходилось изменять?
1.7 Middle🔥 201 комментариев
#JavaScript Core
Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Элементы управления, которые я переделывал в проектах
Этот вопрос о практическом опыте улучшения UX/UI. Расскажу о нескольких реальных случаях, где мне пришлось переделывать компоненты управления.
1. Input Field с Auto-search (SearchBox)
Проблема: Оригинальный компонент не был оптимален:
- Отправлял запрос при каждом изменении (100+ запросов в секунду)
- Без debounce вызывал race conditions
- Не было кэширования результатов
- Без ESC для закрытия
Решение:
import { useMemo } from 'react';
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
// Cache результатов
const cache = useMemo(() => ({}), []);
useEffect(() => {
if (debouncedQuery.length < 2) {
setResults([]);
return;
}
if (cache[debouncedQuery]) {
setResults(cache[debouncedQuery]);
return;
}
const fetchResults = async () => {
setIsLoading(true);
const data = await api.search(debouncedQuery);
setResults(data);
cache[debouncedQuery] = data; // Cache
setIsLoading(false);
};
fetchResults();
}, [debouncedQuery]);
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
setQuery('');
setResults([]);
}
};
return (
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
aria-label="Search"
/>
{isLoading && <Spinner />}
{results.length > 0 && (
<ul className="absolute top-full bg-white border rounded shadow">
{results.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
)}
</div>
);
}
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debounced;
}
2. Dropdown/Select компонент
Проблема: Разработчик использовал простой HTML select:
- Сложно кастомизировать внешний вид
- Не работает с мобильными устройствами
- Нет фильтрации по многим пунктам
- Нет multi-select
Решение:
function CustomSelect({ options, value, onChange, isMulti = false }) {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const ref = useRef(null);
const filtered = options.filter((opt) =>
opt.label.toLowerCase().includes(searchTerm.toLowerCase())
);
useEffect(() => {
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (option) => {
if (isMulti) {
const newValue = Array.isArray(value) ? value : [];
const isSelected = newValue.some((v) => v.id === option.id);
onChange(
isSelected
? newValue.filter((v) => v.id !== option.id)
: [...newValue, option]
);
} else {
onChange(option);
setIsOpen(false);
}
};
return (
<div ref={ref} className="relative w-full">
<button
className="w-full px-4 py-2 bg-white border rounded flex justify-between items-center"
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span>{isMulti ? `${value?.length || 0} selected` : value?.label}</span>
<ChevronDown className={isOpen ? 'rotate-180' : ''} />
</button>
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded shadow-lg z-10">
<input
type="text"
placeholder="Filter..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border-b"
aria-label="Filter options"
/>
<ul className="max-h-48 overflow-y-auto" role="listbox">
{filtered.map((option) => (
<li key={option.id}>
<button
onClick={() => handleSelect(option)}
className={cn(
'w-full text-left px-4 py-2 hover:bg-gray-100',
Array.isArray(value)
? value.some((v) => v.id === option.id) && 'bg-blue-100'
: value?.id === option.id && 'bg-blue-100'
)}
role="option"
>
{isMulti && (
<input
type="checkbox"
checked={value?.some((v) => v.id === option.id) || false}
readOnly
className="mr-2"
/>
)}
{option.label}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
3. Date Picker
Проблема: Оригинальный календарь был сложным и медленным:
- Не было мобильной адаптации
- Не работало с клавиатурой
- Не было range selection
- Медленно реагировал на клики
Решение:
function DatePicker({ startDate, endDate, onChange }) {
const [month, setMonth] = useState(startDate || new Date());
const [isOpen, setIsOpen] = useState(false);
const getDaysInMonth = (date) => {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
};
const getFirstDayOfMonth = (date) => {
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
};
const days = [];
const firstDay = getFirstDayOfMonth(month);
const daysInMonth = getDaysInMonth(month);
// Add empty cells for days before month starts
for (let i = 0; i < firstDay; i++) {
days.push(null);
}
// Add days of month
for (let i = 1; i <= daysInMonth; i++) {
days.push(new Date(month.getFullYear(), month.getMonth(), i));
}
const isInRange = (date) => {
if (!startDate || !endDate) return false;
return date >= startDate && date <= endDate;
};
const handleDayClick = (date) => {
if (!startDate) {
onChange({ startDate: date, endDate: null });
} else if (!endDate) {
if (date < startDate) {
onChange({ startDate: date, endDate: startDate });
} else {
onChange({ startDate, endDate: date });
}
setIsOpen(false);
}
};
return (
<div className="relative">
<input
type="text"
readOnly
value={startDate ? startDate.toLocaleDateString() : ''}
onClick={() => setIsOpen(!isOpen)}
className="px-4 py-2 border rounded cursor-pointer"
/>
{isOpen && (
<div className="absolute top-full left-0 bg-white border rounded shadow-lg p-4 z-10">
<div className="flex justify-between items-center mb-4">
<button onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1))}>
Prev
</button>
<h3>{month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</h3>
<button onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1))}>
Next
</button>
</div>
<div className="grid grid-cols-7 gap-2">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => (
<div key={day} className="text-center text-sm font-bold">
{day[0]}
</div>
))}
{days.map((date, idx) => (
<button
key={idx}
onClick={() => date && handleDayClick(date)}
className={cn(
'py-1 text-center text-sm',
!date && 'invisible',
date && 'hover:bg-blue-100 cursor-pointer',
date && isInRange(date) && 'bg-blue-200'
)}
>
{date?.getDate()}
</button>
))}
</div>
</div>
)}
</div>
);
}
4. Toggle / Checkbox с кастомным виском
Проблема: Оригинальные чекбоксы были некрасивыми и не поддерживали partial state.
Решение:
function CustomCheckbox({ label, checked, onChange, indeterminate = false }) {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
ref.current.indeterminate = indeterminate;
}
}, [indeterminate]);
return (
<label className="flex items-center cursor-pointer">
<input
ref={ref}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="sr-only"
/>
<div
className={cn(
'w-5 h-5 rounded border-2 flex items-center justify-center transition',
indeterminate ? 'bg-blue-500 border-blue-500' : checked ? 'bg-blue-500 border-blue-500' : 'border-gray-300'
)}
>
{indeterminate ? (
<span className="text-white">-</span>
) : checked ? (
<CheckIcon className="text-white" />
) : null}
</div>
<span className="ml-2 text-sm">{label}</span>
</label>
);
}
5. Pagination
Проблема:
- Не было keyboard navigation
- На мобильных показывались все номера (переполнение)
- Не было loading state
Решение:
function Pagination({ page, totalPages, onPageChange, isLoading }) {
const maxVisible = 5; // На мобильных
const pages = [];
// Умная генерация номеров страниц
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (page > 3) pages.push('...');
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
if (!pages.includes(i)) pages.push(i);
}
if (page < totalPages - 2) pages.push('...');
pages.push(totalPages);
}
const handleKeyDown = (e) => {
if (e.key === 'ArrowLeft' && page > 1) onPageChange(page - 1);
if (e.key === 'ArrowRight' && page < totalPages) onPageChange(page + 1);
};
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [page, totalPages]);
return (
<div className="flex justify-center gap-2 items-center">
<button
onClick={() => onPageChange(page - 1)}
disabled={page === 1 || isLoading}
className="px-3 py-1 border rounded disabled:opacity-50"
>
Prev
</button>
{pages.map((p) => (
<button
key={p}
onClick={() => typeof p === 'number' && onPageChange(p)}
disabled={isLoading}
className={cn(
'px-3 py-1 border rounded',
typeof p === 'number' && p === page && 'bg-blue-500 text-white',
p === '...' && 'cursor-default'
)}
>
{p}
</button>
))}
<button
onClick={() => onPageChange(page + 1)}
disabled={page === totalPages || isLoading}
className="px-3 py-1 border rounded disabled:opacity-50"
>
Next
</button>
</div>
);
}
Общие принципы переделок
- Accessibility: ARIA labels, keyboard navigation, screen reader support
- Performance: debounce, memoization, lazy loading
- Mobile: responsive, touch-friendly, no hover-only controls
- UX: feedback, loading states, error handling, clear disabled state
- Code Quality: типизация, тестирование, чистый код
Каждый раз переделывая компонент, я старался не только исправить баги, но и добавить полезные функции, улучшить UX и сделать код более maintainable.