diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index 02784eb..b42a900 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import axios from 'axios'; +import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import { IconPlus, IconSearch, @@ -286,13 +287,12 @@ function ASNsNewManager() {
- setNewItem({ ...newItem, community: e.target.value })} + onChange={(v) => setNewItem({ ...newItem, community: v })} + communities={communities} + placeholder="Начните вводить номер или имя" + className={`form-control${newInvalid.community ? ' is-invalid' : ''}`} /> {newInvalid.community &&
Только цифры
} {(() => { @@ -439,15 +439,11 @@ function ASNsNewManager() { {editingAsn === item.asn ? ( <> - setEditingValue(e.target.value)} - onKeyDown={e => handleEditKeyDown(e, item.asn)} - style={{maxWidth: 120}} + onChange={setEditingValue} + communities={communities} + className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`} /> @@ -553,15 +549,7 @@ function ASNsNewManager() {
)} - {/* Общий список подсказок community */} - - {communities.map(c => { - const label = c.name ? `${c.value} - ${c.name}` : `${c.value}`; - return ( - - ); - })} - + {/* datalist больше не нужен, т.к. используем кастомный автокомплит */} ); } diff --git a/frontend/src/DomainsNewManager.jsx b/frontend/src/DomainsNewManager.jsx index 3f96ef7..4885f7b 100644 --- a/frontend/src/DomainsNewManager.jsx +++ b/frontend/src/DomainsNewManager.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import axios from 'axios'; +import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import { IconPlus, IconSearch, @@ -292,13 +293,12 @@ function DomainsNewManager() {
- setNewItem({ ...newItem, community: e.target.value })} + onChange={(v) => setNewItem({ ...newItem, community: v })} + communities={communities} + placeholder="Начните вводить номер или имя" + className={`form-control${newInvalid.community ? ' is-invalid' : ''}`} /> {newInvalid.community &&
Только цифры
} {(() => { @@ -445,15 +445,11 @@ function DomainsNewManager() { {editingDomain === item.domain ? ( <> - setEditingValue(e.target.value)} - onKeyDown={e => handleEditKeyDown(e, item.domain)} - style={{maxWidth: 120}} + onChange={setEditingValue} + communities={communities} + className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`} /> @@ -559,15 +555,7 @@ function DomainsNewManager() {
)} - {/* Общий список подсказок community */} - - {communities.map(c => { - const label = c.name ? `${c.value} - ${c.name}` : `${c.value}`; - return ( - - ); - })} - + {/* datalist больше не нужен, т.к. используем кастомный автокомплит */} ); } diff --git a/frontend/src/IPRangesManager.jsx b/frontend/src/IPRangesManager.jsx index f96748e..146461a 100644 --- a/frontend/src/IPRangesManager.jsx +++ b/frontend/src/IPRangesManager.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import axios from 'axios'; +import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import { IconPlus, IconSearch, @@ -301,13 +302,12 @@ function IPRangesManager() {
- setNewItem({ ...newItem, community: e.target.value })} + onChange={(v) => setNewItem({ ...newItem, community: v })} + communities={communities} + placeholder="Начните вводить номер или имя" + className={`form-control${newInvalid.community ? ' is-invalid' : ''}`} /> {newInvalid.community &&
Только цифры
} {(() => { @@ -454,15 +454,11 @@ function IPRangesManager() { {editingIp === item.ipRange ? ( <> - setEditingValue(e.target.value)} - onKeyDown={e => handleEditKeyDown(e, item.ipRange)} - style={{maxWidth: 120}} + onChange={setEditingValue} + communities={communities} + className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`} /> @@ -573,15 +569,7 @@ function IPRangesManager() {
)} - {/* Общий список подсказок community */} - - {communities.map(c => { - const label = c.name ? `${c.value} - ${c.name}` : `${c.value}`; - return ( - - ); - })} - + {/* datalist больше не нужен, т.к. используем кастомный автокомплит */} ); } diff --git a/frontend/src/components/CommunityAutocompleteInput.jsx b/frontend/src/components/CommunityAutocompleteInput.jsx new file mode 100644 index 0000000..ab90412 --- /dev/null +++ b/frontend/src/components/CommunityAutocompleteInput.jsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { IconHash } from '@tabler/icons-react'; + +function CommunityAutocompleteInput({ + value, + onChange, + communities = [], + placeholder = '', + className = 'form-control', + onSelectMeta, + maxSuggestions = 8, +}) { + 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(); + if (!q) return communities.slice(0, maxSuggestions); + const filtered = communities.filter((c) => { + const v = (c.value || '').toLowerCase(); + const n = (c.name || '').toLowerCase(); + const d = (c.description || '').toLowerCase(); + const t = Array.isArray(c.tags) ? c.tags.join(' ').toLowerCase() : ''; + return v.includes(q) || n.includes(q) || d.includes(q) || t.includes(q); + }); + return filtered.slice(0, maxSuggestions); + }, [value, communities, 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); + 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); + } + }; + + return ( +
+ { + onChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + autoComplete="off" + /> + + {open && suggestions.length > 0 && ( +
+ {suggestions.map((s, idx) => { + const label = s.name ? `${s.value} - ${s.name}` : `${s.value}`; + const description = s.description || ''; + const tags = Array.isArray(s.tags) ? s.tags : []; + return ( + + ); + })} +
+ )} +
+ ); +} + +export default CommunityAutocompleteInput; + +