diff --git a/src/components/ProjectSuggestInput.jsx b/src/components/ProjectSuggestInput.jsx index 93f3d44..efec6d7 100644 --- a/src/components/ProjectSuggestInput.jsx +++ b/src/components/ProjectSuggestInput.jsx @@ -1,9 +1,24 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { IconFolder } from '@tabler/icons-react' import { fetchProjectSuggestions } from '../lib/api' +function highlightMatch(text, query) { + const q = (query || '').trim().toLowerCase() + if (!q) return text + const lower = text.toLowerCase() + const idx = lower.indexOf(q) + if (idx < 0) return text + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + q.length)} + {text.slice(idx + q.length)} + > + ) +} + /** - * Поле ввода проекта с datalist: подсказки из БД при вводе + локальный кэш serverProjects. - * Новое имя сохранится на сервере при сохранении VPS (resolve-or-create). + * Поле проекта с выпадающими подсказками в стиле Tabler (list-group + dropdown-menu). */ export function ProjectSuggestInput({ value, @@ -16,7 +31,10 @@ export function ProjectSuggestInput({ 'aria-label': ariaLabel, }) { const [remoteNames, setRemoteNames] = useState([]) - const listId = id ? `${id}-project-datalist` : 'project-datalist' + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) + const wrapRef = useRef(null) + const listId = id ? `${id}-project-suggest-list` : 'project-suggest-list' useEffect(() => { const q = (value || '').trim() @@ -61,26 +79,123 @@ export function ProjectSuggestInput({ return out }, [localNames, remoteNames]) + useEffect(() => { + setActiveIndex(-1) + }, [merged, value]) + + useEffect(() => { + if (!open) return undefined + const onDocDown = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) { + setOpen(false) + } + } + document.addEventListener('mousedown', onDocDown) + return () => document.removeEventListener('mousedown', onDocDown) + }, [open]) + + const pick = useCallback( + (name) => { + onChange(name) + setOpen(false) + setActiveIndex(-1) + }, + [onChange], + ) + + const onKeyDown = (e) => { + if (disabled) return + if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && merged.length > 0) { + setOpen(true) + setActiveIndex(e.key === 'ArrowDown' ? 0 : merged.length - 1) + e.preventDefault() + return + } + if (!open) return + + if (e.key === 'Escape') { + setOpen(false) + setActiveIndex(-1) + e.preventDefault() + return + } + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIndex((i) => (merged.length ? (i < merged.length - 1 ? i + 1 : 0) : -1)) + return + } + if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIndex((i) => (merged.length ? (i > 0 ? i - 1 : merged.length - 1) : -1)) + return + } + if (e.key === 'Enter' && activeIndex >= 0 && merged[activeIndex]) { + e.preventDefault() + pick(merged[activeIndex]) + } + } + + const showPanel = open && !disabled && (merged.length > 0 || (value || '').trim().length > 0) + const queryTrim = (value || '').trim() + return ( - <> +