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 {
label: string;
@@ -94,25 +94,87 @@ export function SelectField({
options,
placeholder,
}: 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 (
<div>
<div ref={containerRef} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
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"
<button
type="button"
onClick={() => {
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 && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{selectedLabel || placeholder || "Выберите..."}
</button>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
{options.length > 3 && (
<div className="p-1.5">
<input
ref={inputRef}
type="text"
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>
);
}

View File

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