feat: searchable select dropdowns + updated levels

- Replace native select with custom searchable dropdown
- Search matches word starts (type 'а' finds 'Анна' or 'Мария Андреева')
- Search input shows for lists with 4+ options
- Updated levels: Начинающий/Без опыта, Продвинутый

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 15:25:32 +03:00
parent 21f3887bc9
commit 5fe2500dbe
2 changed files with 80 additions and 18 deletions

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect } from "react"; import { useRef, useEffect, useState } from "react";
interface InputFieldProps { interface InputFieldProps {
label: string; label: string;
@@ -94,25 +94,87 @@ export function SelectField({
options, options,
placeholder, placeholder,
}: SelectFieldProps) { }: SelectFieldProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectedLabel = options.find((o) => o.value === value)?.label || "";
const filtered = search
? options.filter((o) => {
const q = search.toLowerCase();
// Match any word that starts with the search query
return o.label.toLowerCase().split(/\s+/).some((word) => word.startsWith(q));
})
: options;
// Close on outside click
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
return ( return (
<div> <div ref={containerRef} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label> <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<select <button
value={value} type="button"
onChange={(e) => onChange(e.target.value)} onClick={() => {
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors" setOpen(!open);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-left outline-none transition-colors ${
open ? "border-gold" : "border-white/10"
} ${value ? "text-white" : "text-neutral-500"}`}
> >
{placeholder && ( {selectedLabel || placeholder || "Выберите..."}
<option value="" disabled> </button>
{placeholder}
</option> {open && (
)} <div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
{options.map((opt) => ( {options.length > 3 && (
<option key={opt.value} value={opt.value}> <div className="p-1.5">
{opt.label} <input
</option> ref={inputRef}
))} type="text"
</select> value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск..."
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
/>
</div>
)}
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 && (
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
)}
{filtered.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value);
setOpen(false);
setSearch("");
}}
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
opt.value === value ? "text-gold bg-gold/5" : "text-white"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -27,7 +27,7 @@ const DAY_ORDER: Record<string, number> = Object.fromEntries(
const LEVELS = [ const LEVELS = [
{ value: "", label: "Без уровня" }, { value: "", label: "Без уровня" },
{ value: "Начинающий", label: "Начинающий" }, { value: "Начинающий/Без опыта", label: "Начинающий/Без опыта" },
{ value: "Продвинутый", label: "Продвинутый" }, { value: "Продвинутый", label: "Продвинутый" },
]; ];