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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user