Files
router-lists-ui/frontend/src/components/ServerAutocompleteInput.jsx
T

202 lines
6.1 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { IconServer } from '@tabler/icons-react';
/**
* Красивый selector серверов с автодополнением.
* Похож по UX на CommunityAutocompleteInput, но заточен под поля серверов.
*/
function ServerAutocompleteInput({
value,
onChange,
servers = [],
placeholder = '',
className = 'form-control',
onSelectMeta,
maxSuggestions = 12,
}) {
const containerRef = useRef(null);
const inputRef = useRef(null);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const suggestions = useMemo(() => {
const q = String(value || '').toLowerCase();
const list = (servers || []).map((s) => {
const id = s.id || s.ip;
const labelIp = s.ip || '—';
const labelDns = s.dns || '';
const labelProvider = s.provider || '';
const label =
labelDns && labelProvider
? `${labelIp} (${labelDns}) ${labelProvider}`
: labelDns
? `${labelIp} (${labelDns})`
: labelProvider
? `${labelIp} ${labelProvider}`
: `${labelIp}`;
return {
raw: s,
value: id,
label,
search: [
id,
s.ip,
s.extIp,
s.internalIp,
s.dns,
s.provider,
s.country,
s.gateway,
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
};
});
if (!q) return list.slice(0, maxSuggestions);
const filtered = list.filter((item) => item.search.includes(q));
return filtered.slice(0, maxSuggestions);
}, [value, servers, maxSuggestions]);
useEffect(() => {
const handleOutside = (e) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(e.target)) setOpen(false);
};
document.addEventListener('click', handleOutside);
return () => document.removeEventListener('click', handleOutside);
}, []);
const selectItem = (item) => {
onChange(item.value);
if (onSelectMeta) onSelectMeta(item.raw);
setOpen(false);
setActiveIndex(-1);
if (inputRef.current) inputRef.current.focus();
};
const handleKeyDown = (e) => {
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
setOpen(true);
return;
}
if (!open) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
if (activeIndex >= 0 && activeIndex < suggestions.length) {
e.preventDefault();
selectItem(suggestions[activeIndex]);
}
} else if (e.key === 'Escape') {
setOpen(false);
setActiveIndex(-1);
}
};
// Текст в input: ищем label по выбранному serverId
const displayValue = useMemo(() => {
if (!value) return '';
const found = (servers || []).find((s) => (s.id || s.ip) === value);
if (!found) return '';
const ip = found.ip || '—';
const dns = found.dns || '';
const provider = found.provider || '';
if (dns && provider) return `${ip} (${dns}) ${provider}`;
if (dns) return `${ip} (${dns})`;
if (provider) return `${ip} ${provider}`;
return ip;
}, [value, servers]);
return (
<div ref={containerRef} className="position-relative" style={{ width: '100%' }}>
<div className="input-icon">
<span className="input-icon-addon">
<IconServer size={18} />
</span>
<input
ref={inputRef}
type="text"
className={className}
placeholder={placeholder}
value={open ? displayValue || value : displayValue}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
autoComplete="off"
/>
</div>
{open && suggestions.length > 0 && (
<div
className="dropdown-menu show"
style={{
display: 'block',
width: '100%',
maxHeight: 280,
overflowY: 'auto',
}}
>
<button
type="button"
className={`dropdown-item${activeIndex === -1 ? ' active' : ''}`}
onMouseDown={(e) => {
e.preventDefault();
onChange('');
if (onSelectMeta) onSelectMeta(null);
setOpen(false);
setActiveIndex(-1);
}}
>
<div className="d-flex align-items-center text-muted small">
<span className="me-2">Без привязки</span>
</div>
</button>
{suggestions.map((s, idx) => (
<button
type="button"
key={`${s.value}-${idx}`}
className={`dropdown-item${idx === activeIndex ? ' active' : ''}`}
onMouseDown={(e) => {
e.preventDefault();
selectItem(s);
}}
onMouseEnter={() => setActiveIndex(idx)}
>
<div className="d-flex align-items-start">
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 24, height: 24 }}>
<IconServer size={14} />
</span>
<div className="flex-fill text-start">
<div className="fw-medium">{s.label}</div>
<div className="text-muted small text-truncate" style={{ maxWidth: '100%' }}>
{s.raw.country && <span className="me-2">{s.raw.country}</span>}
{s.raw.tunnel && <span className="me-2">{s.raw.tunnel}</span>}
{s.raw.gateway && <span className="me-2">{s.raw.gateway}</span>}
</div>
</div>
</div>
</button>
))}
</div>
)}
</div>
);
}
export default ServerAutocompleteInput;