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

Какие элементы управления приходилось изменять?

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>
  );
}

Общие принципы переделок

  1. Accessibility: ARIA labels, keyboard navigation, screen reader support
  2. Performance: debounce, memoization, lazy loading
  3. Mobile: responsive, touch-friendly, no hover-only controls
  4. UX: feedback, loading states, error handling, clear disabled state
  5. Code Quality: типизация, тестирование, чистый код

Каждый раз переделывая компонент, я старался не только исправить баги, но и добавить полезные функции, улучшить UX и сделать код более maintainable.

Какие элементы управления приходилось изменять? | PrepBro