Комментарии (3)
Ответ сгенерирован нейросетью и может содержать ошибки
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 много раз. Это не сложнее, чем выглядит, если помнить про:
- Доступность (keyboard, aria)
- Click outside (закрытие)
- Позиционирование (z-index, overflow)
- Кросс-браузерность
В 2024+ году рекомендую либо Headless UI либо Radix, они решают 99% проблем. Но знать как это работает изнутри - essential для фронтендера.