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'
|
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.
|
* Поле проекта с выпадающими подсказками в стиле Tabler (list-group + dropdown-menu).
|
||||||
* Новое имя сохранится на сервере при сохранении VPS (resolve-or-create).
|
|
||||||
*/
|
*/
|
||||||
export function ProjectSuggestInput({
|
export function ProjectSuggestInput({
|
||||||
value,
|
value,
|
||||||
@@ -16,7 +31,10 @@ export function ProjectSuggestInput({
|
|||||||
'aria-label': ariaLabel,
|
'aria-label': ariaLabel,
|
||||||
}) {
|
}) {
|
||||||
const [remoteNames, setRemoteNames] = useState([])
|
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(() => {
|
useEffect(() => {
|
||||||
const q = (value || '').trim()
|
const q = (value || '').trim()
|
||||||
@@ -61,26 +79,123 @@ export function ProjectSuggestInput({
|
|||||||
return out
|
return out
|
||||||
}, [localNames, remoteNames])
|
}, [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 (
|
return (
|
||||||
<>
|
<div ref={wrapRef} className="position-relative project-suggest-wrap">
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="text"
|
type="text"
|
||||||
className={className}
|
className={className}
|
||||||
list={listId}
|
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
onFocus={() => !disabled && setOpen(true)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
aria-expanded={showPanel}
|
||||||
|
aria-controls={showPanel ? listId : undefined}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
role="combobox"
|
||||||
/>
|
/>
|
||||||
<datalist id={listId}>
|
{showPanel ? (
|
||||||
{merged.map((name) => (
|
<div
|
||||||
<option key={name} value={name} />
|
id={listId}
|
||||||
))}
|
className="dropdown-menu show shadow-lg border rounded-2 project-suggest-panel"
|
||||||
</datalist>
|
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;
|
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) {
|
@media (max-width: 991.98px) {
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user