396 lines
15 KiB
TypeScript
396 lines
15 KiB
TypeScript
/**
|
||
* Кастомный селектор предметов с поиском
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import React, { useState, useRef, useEffect } from 'react';
|
||
import { Subject, MentorSubject } from '@/api/subjects';
|
||
|
||
interface SubjectSelectProps {
|
||
subjects: Subject[];
|
||
mentorSubjects: MentorSubject[];
|
||
value: number | null;
|
||
onChange: (value: number | null) => void;
|
||
disabled?: boolean;
|
||
required?: boolean;
|
||
}
|
||
|
||
export const SubjectSelect: React.FC<SubjectSelectProps> = ({
|
||
subjects,
|
||
mentorSubjects,
|
||
value,
|
||
onChange,
|
||
disabled = false,
|
||
required = false,
|
||
}) => {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Объединяем все предметы
|
||
const allSubjects = [
|
||
...subjects.map(s => ({ ...s, type: 'standard' as const })),
|
||
...mentorSubjects.map(s => ({ ...s, type: 'mentor' as const })),
|
||
];
|
||
|
||
// Получаем выбранный предмет
|
||
const selectedSubject = allSubjects.find(s => s.id === value);
|
||
|
||
// Фильтруем предметы по поисковому запросу
|
||
const filteredSubjects = allSubjects.filter(subject => {
|
||
const name = subject.name.toLowerCase();
|
||
const query = searchQuery.toLowerCase();
|
||
return name.includes(query);
|
||
});
|
||
|
||
// Группируем предметы по типу
|
||
const standardSubjects = filteredSubjects.filter(s => s.type === 'standard');
|
||
const mentorSubjectsFiltered = filteredSubjects.filter(s => s.type === 'mentor');
|
||
|
||
// Закрываем dropdown при клике вне компонента
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||
setIsOpen(false);
|
||
setSearchQuery('');
|
||
}
|
||
};
|
||
|
||
if (isOpen) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
// Фокусируем поле поиска при открытии
|
||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
};
|
||
}, [isOpen]);
|
||
|
||
const handleSelect = (subjectId: number) => {
|
||
onChange(subjectId);
|
||
setIsOpen(false);
|
||
setSearchQuery('');
|
||
};
|
||
|
||
return (
|
||
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}>
|
||
{/* Основная кнопка выбора */}
|
||
<button
|
||
type="button"
|
||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||
disabled={disabled}
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px 16px',
|
||
fontSize: '16px',
|
||
color: selectedSubject ? 'var(--md-sys-color-on-surface)' : 'var(--md-sys-color-on-surface-variant)',
|
||
background: 'var(--md-sys-color-surface)',
|
||
border: `1px solid ${isOpen ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'}`,
|
||
borderWidth: isOpen ? '2px' : '1px',
|
||
borderRadius: '4px',
|
||
fontFamily: 'inherit',
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
outline: 'none',
|
||
transition: 'all 0.2s ease',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: '12px',
|
||
textAlign: 'left',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1, minWidth: 0 }}>
|
||
{selectedSubject ? (
|
||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{selectedSubject.name}
|
||
</span>
|
||
) : (
|
||
<span>Выберите предмет</span>
|
||
)}
|
||
</div>
|
||
{/* Иконка стрелки */}
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
style={{
|
||
flexShrink: 0,
|
||
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||
transition: 'transform 0.2s ease',
|
||
}}
|
||
>
|
||
<polyline points="6 9 12 15 18 9"></polyline>
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Dropdown меню */}
|
||
{isOpen && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 'calc(100% + 4px)',
|
||
left: 0,
|
||
right: 0,
|
||
background: 'var(--md-sys-color-surface)',
|
||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||
borderRadius: '12px',
|
||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)',
|
||
zIndex: 1000,
|
||
maxHeight: '400px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
}}>
|
||
{/* Поле поиска */}
|
||
<div style={{
|
||
padding: '12px',
|
||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||
}}>
|
||
<div style={{
|
||
position: 'relative',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
}}>
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="var(--md-sys-color-on-surface-variant)"
|
||
strokeWidth="2"
|
||
style={{
|
||
position: 'absolute',
|
||
left: '12px',
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
<circle cx="11" cy="11" r="8"></circle>
|
||
<path d="m21 21-4.35-4.35"></path>
|
||
</svg>
|
||
<input
|
||
ref={searchInputRef}
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Поиск предмета..."
|
||
style={{
|
||
width: '100%',
|
||
padding: '8px 12px 8px 40px',
|
||
fontSize: '14px',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
border: 'none',
|
||
borderRadius: '8px',
|
||
fontFamily: 'inherit',
|
||
outline: 'none',
|
||
}}
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchQuery('')}
|
||
style={{
|
||
position: 'absolute',
|
||
right: '8px',
|
||
width: '24px',
|
||
height: '24px',
|
||
padding: 0,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
borderRadius: '50%',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Список предметов */}
|
||
<div style={{
|
||
overflowY: 'auto',
|
||
flex: 1,
|
||
}}>
|
||
{filteredSubjects.length === 0 ? (
|
||
<div style={{
|
||
padding: '24px',
|
||
textAlign: 'center',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
fontSize: '14px',
|
||
}}>
|
||
{searchQuery ? 'Предметы не найдены' : 'Нет доступных предметов'}
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Стандартные предметы */}
|
||
{standardSubjects.length > 0 && (
|
||
<>
|
||
{!searchQuery && (
|
||
<div style={{
|
||
padding: '8px 16px',
|
||
fontSize: '12px',
|
||
fontWeight: '500',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
}}>
|
||
Стандартные предметы
|
||
</div>
|
||
)}
|
||
{standardSubjects.map((subject) => (
|
||
<button
|
||
key={`standard-${subject.id}`}
|
||
type="button"
|
||
onClick={() => handleSelect(subject.id)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px 16px',
|
||
background: value === subject.id
|
||
? 'var(--md-sys-color-secondary-container)'
|
||
: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px',
|
||
transition: 'background 0.2s ease',
|
||
textAlign: 'left',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (value !== subject.id) {
|
||
e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (value !== subject.id) {
|
||
e.currentTarget.style.background = 'transparent';
|
||
}
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: '14px',
|
||
fontWeight: '500',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{subject.name}
|
||
</div>
|
||
</div>
|
||
{value === subject.id && (
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="var(--md-sys-color-primary)"
|
||
strokeWidth="2"
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<polyline points="20 6 9 17 4 12"></polyline>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{/* Предметы ментора */}
|
||
{mentorSubjectsFiltered.length > 0 && (
|
||
<>
|
||
{!searchQuery && standardSubjects.length > 0 && (
|
||
<div style={{
|
||
padding: '8px 16px',
|
||
fontSize: '12px',
|
||
fontWeight: '500',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
background: 'var(--md-sys-color-surface-variant)',
|
||
marginTop: '8px',
|
||
}}>
|
||
Мои предметы
|
||
</div>
|
||
)}
|
||
{mentorSubjectsFiltered.map((subject) => (
|
||
<button
|
||
key={`mentor-${subject.id}`}
|
||
type="button"
|
||
onClick={() => handleSelect(subject.id)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px 16px',
|
||
background: value === subject.id
|
||
? 'var(--md-sys-color-secondary-container)'
|
||
: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px',
|
||
transition: 'background 0.2s ease',
|
||
textAlign: 'left',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (value !== subject.id) {
|
||
e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (value !== subject.id) {
|
||
e.currentTarget.style.background = 'transparent';
|
||
}
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: '14px',
|
||
fontWeight: '500',
|
||
color: 'var(--md-sys-color-on-surface)',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{subject.name}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Галочка для выбранного элемента */}
|
||
{value === subject.id && (
|
||
<svg
|
||
width="20"
|
||
height="20"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="var(--md-sys-color-primary)"
|
||
strokeWidth="2"
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<polyline points="20 6 9 17 4 12"></polyline>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|