feat: relations autocomplete shows note type badges (matching wiki-link UX) (#74)

* feat: show note type in relations autocomplete dropdown

Replace HTML datalist in InlineAddNote and AddRelationshipForm with
custom NoteAutocomplete component that displays note types as colored
badges, matching the wiki-link autocomplete UX. Supports keyboard
navigation, scrollable results, and graceful truncation.

Also extract ref-group helpers to improve InspectorPanels code health
(8.99 → 9.38).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: add relations autocomplete dropdown wireframe

Shows the autocomplete dropdown with note type badges (Project, Topic,
Procedure, Person) matching the wiki-link autocomplete UX. Includes
edge case of note with no type badge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-25 18:30:28 +01:00
committed by GitHub
parent 05ac689c2f
commit 48cf359eaa
4 changed files with 399 additions and 52 deletions

View File

@@ -0,0 +1,150 @@
import { useState, useRef, useCallback, useMemo, useEffect } from 'react'
import type { VaultEntry } from '../types'
import { getTypeColor } from '../utils/typeColors'
import './WikilinkSuggestionMenu.css'
const MIN_QUERY_LENGTH = 2
const MAX_RESULTS = 10
interface NoteAutocompleteProps {
entries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
value: string
onChange: (value: string) => void
onSelect: (noteTitle: string) => void
onEscape?: () => void
placeholder?: string
autoFocus?: boolean
testId?: string
}
interface MatchedEntry {
title: string
noteType?: string
typeColor?: string
}
function matchEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>, query: string): MatchedEntry[] {
if (query.length < MIN_QUERY_LENGTH) return []
const lowerQuery = query.toLowerCase()
const matches = entries.filter(e =>
e.title.toLowerCase().includes(lowerQuery) ||
e.aliases.some(a => a.toLowerCase().includes(lowerQuery)),
)
return matches.slice(0, MAX_RESULTS).map(e => {
const isA = e.isA
const te = typeEntryMap[isA ?? '']
const noteType = isA && isA !== 'Note' ? isA : undefined
return {
title: e.title,
noteType,
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
}
})
}
export function NoteAutocomplete({ entries, typeEntryMap, value, onChange, onSelect, onEscape, placeholder, autoFocus, testId }: NoteAutocompleteProps) {
const [selectedIndex, setSelectedIndex] = useState(-1)
const [open, setOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const matches = useMemo(
() => open ? matchEntries(entries, typeEntryMap, value) : [],
[entries, typeEntryMap, value, open],
)
// Scroll selected item into view
useEffect(() => {
if (selectedIndex < 0 || !menuRef.current) return
const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined
el?.scrollIntoView?.({ block: 'nearest' })
}, [selectedIndex])
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as Node
if (!inputRef.current?.contains(target) && !menuRef.current?.contains(target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const handleSelect = useCallback((title: string) => {
onSelect(title)
setOpen(false)
setSelectedIndex(-1)
}, [onSelect])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
setOpen(true)
setSelectedIndex(-1)
}, [onChange])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!open || matches.length === 0) {
if (e.key === 'Enter') { onSelect(value); return }
if (e.key === 'Escape') { onEscape?.(); return }
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => (i + 1) % matches.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1))
} else if (e.key === 'Enter') {
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < matches.length) {
handleSelect(matches[selectedIndex].title)
} else {
onSelect(value)
}
} else if (e.key === 'Escape') {
e.preventDefault()
setOpen(false)
onEscape?.()
}
}, [open, matches, selectedIndex, value, handleSelect, onSelect, onEscape])
return (
<div style={{ position: 'relative' }}>
<input
ref={inputRef}
autoFocus={autoFocus}
className="flex-1 border border-border bg-transparent px-2 py-0.5 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none', minWidth: 0, width: '100%', boxSizing: 'border-box' }}
placeholder={placeholder}
value={value}
onChange={handleChange}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
data-testid={testId}
/>
{open && matches.length > 0 && (
<div className="wikilink-menu" ref={menuRef} style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 2, minWidth: 'auto' }}>
{matches.map((item, index) => (
<div
key={item.title}
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={() => handleSelect(item.title)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className="wikilink-menu__title">{item.title}</span>
{item.noteType && (
<span className="wikilink-menu__type" style={{ color: item.typeColor }}>
{item.noteType}
</span>
)}
</div>
))}
</div>
)}
</div>
)
}