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

Верстал ли Select

1.0 Junior🔥 203 комментариев
#HTML и CSS#JavaScript Core

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

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

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

Custom Select верстка: опыт и best practices

Отличный практический вопрос. Custom Select элементы - это одна из самых сложных верстых задач в фронтенде.

Почему верстают Select

Нативный <select> имеет проблемы:

  • Стилизация ограничена (разные браузеры ведут себя по-разному)
  • На мобилах открывает нативный пикер (иногда хорошо, иногда нет)
  • Сложно добавить иконки, фото, кастомные элементы
  • На разных ОС выглядит по-разному
<!-- Нативный Select - трудно стилизовать -->
<select>
  <option>Выберите...</option>
  <option>Опция 1</option>
  <option>Опция 2</option>
</select>

Базовая структура Custom Select

import { useState, useRef, useEffect } from 'react';

interface SelectOption {
  value: string;
  label: string;
  icon?: string;  // Опционально
}

interface SelectProps {
  options: SelectOption[];
  value?: string;
  onChange: (value: string) => void;
  placeholder?: string;
  disabled?: boolean;
}

export function CustomSelect({
  options,
  value,
  onChange,
  placeholder = 'Select...',
  disabled = false
}: SelectProps) {
  const [isOpen, setIsOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  
  const selectedOption = options.find(opt => opt.value === value);
  
  // Закрываем при клике вне
  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (!containerRef.current?.contains(event.target as Node)) {
        setIsOpen(false);
      }
    }
    
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);
  
  return (
    <div 
      ref={containerRef}
      className="relative w-full"
    >
      {/* Кнопка открытия */}
      <button
        onClick={() => setIsOpen(!isOpen)}
        disabled={disabled}
        className="w-full px-4 py-2 border border-gray-300 rounded-lg text-left flex items-center justify-between hover:border-gray-400 disabled:opacity-50"
      >
        <span>{selectedOption?.label || placeholder}</span>
        <span className={`transform transition-transform ${isOpen ? 'rotate-180' : ''}`}></span>
      </button>
      
      {/* Dropdown меню */}
      {isOpen && (
        <div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-300 rounded-lg shadow-lg z-10">
          {options.map(option => (
            <button
              key={option.value}
              onClick={() => {
                onChange(option.value);
                setIsOpen(false);
              }}
              className={`w-full px-4 py-2 text-left hover:bg-blue-50 ${
                value === option.value ? 'bg-blue-100' : ''
              }`}
            >
              {option.icon && <span>{option.icon}</span>}
              {option.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Доступность (Accessibility) - ВАЖНО!

export function AccessibleSelect({
  options,
  value,
  onChange,
  placeholder = 'Select...'
}: SelectProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  
  // Клавиатурная навигация
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (!isOpen) {
        if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
          e.preventDefault();
          setIsOpen(true);
        }
        return;
      }
      
      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();
          setHighlightedIndex(prev =>
            prev === null ? 0 : Math.min(prev + 1, options.length - 1)
          );
          break;
        case 'ArrowUp':
          e.preventDefault();
          setHighlightedIndex(prev =>
            prev === null ? options.length - 1 : Math.max(prev - 1, 0)
          );
          break;
        case 'Enter':
          e.preventDefault();
          if (highlightedIndex !== null) {
            onChange(options[highlightedIndex].value);
            setIsOpen(false);
          }
          break;
        case 'Escape':
          e.preventDefault();
          setIsOpen(false);
          buttonRef.current?.focus();
          break;
      }
    }
    
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, highlightedIndex, options, onChange]);
  
  return (
    <div ref={containerRef}>
      <button
        ref={buttonRef}
        onClick={() => setIsOpen(!isOpen)}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        className="border px-4 py-2 rounded"
      >
        {options.find(o => o.value === value)?.label || placeholder}
      </button>
      
      {isOpen && (
        <ul
          role="listbox"
          className="border rounded mt-1 shadow-lg"
        >
          {options.map((option, index) => (
            <li
              key={option.value}
              role="option"
              aria-selected={value === option.value}
              onMouseEnter={() => setHighlightedIndex(index)}
              onClick={() => {
                onChange(option.value);
                setIsOpen(false);
              }}
              className={`px-4 py-2 cursor-pointer ${
                highlightedIndex === index ? 'bg-blue-100' : ''
              }`}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Multi-Select версия

interface MultiSelectProps {
  options: SelectOption[];
  value: string[];
  onChange: (values: string[]) => void;
}

export function MultiSelect({ options, value, onChange }: MultiSelectProps) {
  const [isOpen, setIsOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  
  const toggleOption = (optionValue: string) => {
    const newValue = value.includes(optionValue)
      ? value.filter(v => v !== optionValue)
      : [...value, optionValue];
    onChange(newValue);
  };
  
  return (
    <div ref={containerRef} className="relative">
      {/* Кнопка с выбранными элементами */}
      <div className="flex flex-wrap gap-2 p-2 border rounded bg-white">
        {value.map(v => {
          const label = options.find(o => o.value === v)?.label;
          return (
            <div key={v} className="flex items-center gap-1 bg-blue-100 px-2 py-1 rounded">
              <span>{label}</span>
              <button
                onClick={() => toggleOption(v)}
                className="text-sm font-bold"
              >
                ×
              </button>
            </div>
          );
        })}
        <input
          type="text"
          placeholder="Add more..."
          onFocus={() => setIsOpen(true)}
          className="flex-1 outline-none"
        />
      </div>
      
      {/* Dropdown */}
      {isOpen && (
        <div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded shadow-lg z-10">
          {options.map(option => (
            <label key={option.value} className="flex items-center p-2 hover:bg-gray-50 cursor-pointer">
              <input
                type="checkbox"
                checked={value.includes(option.value)}
                onChange={() => toggleOption(option.value)}
              />
              <span className="ml-2">{option.label}</span>
            </label>
          ))}
        </div>
      )}
    </div>
  );
}

Searchable Select (с фильтрацией)

export function SearchableSelect(props: SelectProps) {
  const [search, setSearch] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  
  const filtered = props.options.filter(opt =>
    opt.label.toLowerCase().includes(search.toLowerCase())
  );
  
  return (
    <div className="relative">
      <input
        type="text"
        placeholder="Search..."
        value={search}
        onChange={e => {
          setSearch(e.target.value);
          setIsOpen(true);
        }}
        className="w-full px-4 py-2 border rounded"
      />
      
      {isOpen && filtered.length > 0 && (
        <div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded shadow-lg max-h-60 overflow-y-auto">
          {filtered.map(option => (
            <button
              key={option.value}
              onClick={() => {
                props.onChange(option.value);
                setSearch('');
                setIsOpen(false);
              }}
              className="w-full px-4 py-2 text-left hover:bg-gray-100"
            >
              {option.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Мой опыт верстки Select

Я верстал Select много раз, и вот основные сложности:

1. Z-index problems - dropdown попадает под другие элементы. Решение: z-10 или z-50 и правильное позиционирование.

2. Позиция при скролле - dropdown "приклеивается" к кнопке или уходит. Решение: Используй popover API или следи за scroll.

3. Мобильная адаптация - на мобилах нужен модальный стиль.

4. Keyboard navigation - очень важно, часто забывают.

5. RTL поддержка - направление текста (для арабского, иврита).

Готовые библиотеки

Если нет времени - используй готовые:

  • Headless UI - стоит использовать (no styles, ты рисуешь)
  • Radix UI - мощный, accessibility first
  • React Select - полнофункциональный
  • Combobox (native) - новый стандарт HTML5
<!-- HTML5 datalist - просто и быстро -->
<input list="options" />
<datalist id="options">
  <option>Опция 1</option>
  <option>Опция 2</option>
</datalist>

Вывод

Да, я верстал Select много раз. Это не сложнее, чем выглядит, если помнить про:

  1. Доступность (keyboard, aria)
  2. Click outside (закрытие)
  3. Позиционирование (z-index, overflow)
  4. Кросс-браузерность

В 2024+ году рекомендую либо Headless UI либо Radix, они решают 99% проблем. Но знать как это работает изнутри - essential для фронтендера.