Enhance project suggestion input with dropdown menu and highlighting

- Implemented a new dropdown menu for project suggestions using Tabler styles.
- Added highlighting for matched text in suggestions to improve user experience.
- Refactored input handling to manage open/close state and keyboard navigation.
- Updated CSS for project suggestion panel and scroll behavior.
This commit is contained in:
Denozordec
2026-03-20 22:55:24 +07:00
parent 9d5b434bea
commit 3fe2d2bc61
2 changed files with 161 additions and 13 deletions
+128 -13
View File
@@ -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)}
<mark className="project-suggest-highlight">{text.slice(idx, idx + q.length)}</mark>
{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 (
<>
<div ref={wrapRef} className="position-relative project-suggest-wrap">
<input
id={id}
type="text"
className={className}
list={listId}
value={value}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => {
onChange(e.target.value)
setOpen(true)
}}
onFocus={() => !disabled && setOpen(true)}
onKeyDown={onKeyDown}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
spellCheck={false}
aria-label={ariaLabel}
aria-expanded={showPanel}
aria-controls={showPanel ? listId : undefined}
aria-autocomplete="list"
role="combobox"
/>
<datalist id={listId}>
{merged.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</>
{showPanel ? (
<div
id={listId}
className="dropdown-menu show shadow-lg border rounded-2 project-suggest-panel"
role="listbox"
>
{merged.length > 0 ? (
<div className="list-group list-group-flush list-group-hoverable project-suggest-scroll">
{merged.map((name, idx) => (
<button
key={name}
type="button"
role="option"
aria-selected={idx === activeIndex}
className={`list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 px-3 rounded-0 text-start ${
idx === activeIndex ? 'active' : ''
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pick(name)}
onMouseEnter={() => setActiveIndex(idx)}
>
<span className="text-secondary project-suggest-folder">
<IconFolder size={18} stroke={1.5} />
</span>
<span className="text-truncate">{highlightMatch(name, value)}</span>
</button>
))}
</div>
) : (
<div className="px-3 py-3 text-secondary small">
Совпадений нет при сохранении VPS будет создан проект «{queryTrim}».
</div>
)}
</div>
) : null}
</div>
)
}
+33
View File
@@ -135,6 +135,39 @@ body {
margin-top: 1rem;
}
/* Подсказки проекта (Tabler: dropdown-menu + list-group) */
.project-suggest-wrap .project-suggest-panel {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 2px);
z-index: 1060;
padding: 0;
margin: 0;
}
.project-suggest-wrap .project-suggest-scroll {
max-height: 16rem;
overflow-y: auto;
}
.project-suggest-wrap .project-suggest-highlight {
background: transparent;
color: var(--tblr-primary);
font-weight: 600;
}
.project-suggest-wrap .list-group-item.active .project-suggest-highlight {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.project-suggest-wrap .list-group-item.active .project-suggest-folder {
color: inherit;
opacity: 0.9;
}
@media (max-width: 991.98px) {
.app-sidebar {
display: none;