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 @@
{"children":[{"children":[{"content":"RELATIONS","fill":"#999999","fontFamily":"Inter","fontSize":9,"fontWeight":"600","id":"aOkAp","letterSpacing":1.2,"name":"header","type":"text"},{"content":"Belongs to","fill":"#999999","fontFamily":"Inter","fontSize":9,"fontWeight":"600","id":"palDD","letterSpacing":1.2,"name":"label","type":"text"},{"alignItems":"center","children":[{"content":"Personal CRM","fill":"#B45309","fontFamily":"Inter","fontSize":12,"fontWeight":"500","id":"dzajO","name":"chip1label","type":"text"}],"cornerRadius":6,"fill":"#FEF3C7","gap":8,"id":"ZLIJt","name":"chip1","padding":[6,10],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"alignItems":"center","children":[{"content":"Pro","fill":"#1A1A1A","fontFamily":"Inter","fontSize":12,"fontWeight":"normal","id":"ZO1lM","name":"inputText","type":"text"}],"cornerRadius":4,"height":28,"id":"fcJ6c","name":"inputBg","padding":[4,8],"stroke":{"fill":"#E5E7EB","thickness":1},"type":"frame","width":"fill_container"}],"gap":4,"id":"RVAL8","name":"addRow","type":"frame","width":"fill_container"},{"children":[{"alignItems":"center","children":[{"content":"Product Roadmap Q2","fill":"#1A1A1A","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"BozT4","name":"item1title","type":"text"},{"content":"Project","fill":"#DC2626","fontFamily":"Inter","fontSize":11,"fontWeight":"500","id":"TbNe8","name":"item1type","type":"text"}],"cornerRadius":4,"fill":"rgba(21,93,255,0.08)","gap":12,"id":"FXHSH","name":"item1","padding":[6,10],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"Protocol Design Patterns","fill":"#1A1A1A","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"U6Jnd","name":"item2title","type":"text"},{"content":"Topic","fill":"#16A34A","fontFamily":"Inter","fontSize":11,"fontWeight":"500","id":"OQBkd","name":"item2type","type":"text"}],"cornerRadius":4,"gap":12,"id":"vUiUx","name":"item2","padding":[6,10],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"Provisioning Automation","fill":"#1A1A1A","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"rlySR","name":"item3title","type":"text"},{"content":"Procedure","fill":"#9333EA","fontFamily":"Inter","fontSize":11,"fontWeight":"500","id":"OWj1v","name":"item3type","type":"text"}],"cornerRadius":4,"gap":12,"id":"wEPgh","name":"item3","padding":[6,10],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"Profile Settings","fill":"#1A1A1A","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"zUrLN","name":"item4title","type":"text"}],"cornerRadius":4,"gap":12,"id":"y60L6","name":"item4","padding":[6,10],"type":"frame","width":"fill_container"},{"alignItems":"center","children":[{"content":"Prof. Chen","fill":"#1A1A1A","fontFamily":"Inter","fontSize":13,"fontWeight":"normal","id":"QhYup","name":"item5title","type":"text"},{"content":"Person","fill":"#CA8A04","fontFamily":"Inter","fontSize":11,"fontWeight":"500","id":"hCb2Z","name":"item5type","type":"text"}],"cornerRadius":4,"gap":12,"id":"L5imN","name":"item5","padding":[6,10],"type":"frame","width":"fill_container"}],"cornerRadius":6,"fill":"#FFFFFF","id":"P6JT8","layout":"vertical","name":"Autocomplete Dropdown","padding":4,"stroke":{"fill":"rgba(0,0,0,0.1)","thickness":1},"type":"frame","width":"fill_container"}],"fill":"#FFFFFF","gap":12,"height":420,"id":"KhBuP","layout":"vertical","name":"Relations Autocomplete — Dropdown with Types","padding":16,"type":"frame","width":320,"x":0,"y":0}],"variables":{}}

View File

@@ -9,6 +9,7 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import { findEntryByTarget } from '../utils/wikilinkColors'
import type { FrontmatterValue } from './Inspector'
import { NoteAutocomplete } from './NoteAutocomplete'
function isWikilink(value: string): boolean {
return /^\[\[.*\]\]$/.test(value)
@@ -100,29 +101,28 @@ function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap: Recor
}
}
function InlineAddNote({ entries, onAdd }: {
function InlineAddNote({ entries, typeEntryMap, onAdd }: {
entries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onAdd: (noteTitle: string) => void
}) {
const [active, setActive] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const noteTitles = useMemo(() => entries.map(e => e.title), [entries])
const handleSubmit = useCallback(() => {
const trimmed = query.trim()
const handleSubmit = useCallback((title: string) => {
const trimmed = title.trim()
if (!trimmed) return
onAdd(trimmed)
setQuery('')
setActive(false)
}, [query, onAdd])
}, [onAdd])
if (!active) {
return (
<button
className="mt-1 flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground"
style={{ fontSize: 11 }}
onClick={() => { setActive(true); setTimeout(() => inputRef.current?.focus(), 0) }}
onClick={() => setActive(true)}
data-testid="add-relation-ref"
>
<Plus size={10} />
@@ -133,25 +133,22 @@ function InlineAddNote({ entries, onAdd }: {
return (
<div className="mt-1 flex items-center gap-1">
<datalist id="inline-note-titles">{noteTitles.map(t => <option key={t} value={t} />)}</datalist>
<input
ref={inputRef}
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 }}
placeholder="Note title"
list="inline-note-titles"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleSubmit()
else if (e.key === 'Escape') { setQuery(''); setActive(false) }
}}
data-testid="add-relation-ref-input"
/>
<div className="flex-1" style={{ minWidth: 0 }}>
<NoteAutocomplete
entries={entries}
typeEntryMap={typeEntryMap}
value={query}
onChange={setQuery}
onSelect={handleSubmit}
onEscape={() => { setQuery(''); setActive(false) }}
placeholder="Note title"
autoFocus
testId="add-relation-ref-input"
/>
</div>
<button
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
onClick={handleSubmit}
onClick={() => handleSubmit(query)}
disabled={!query.trim()}
>
<Plus size={12} />
@@ -188,7 +185,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
)
})}
</div>
{onAddRef && <InlineAddNote entries={entries} onAdd={onAddRef} />}
{onAddRef && <InlineAddNote entries={entries} typeEntryMap={typeEntryMap} onAdd={onAddRef} />}
</div>
)
}
@@ -205,29 +202,25 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
.filter(({ refs }) => refs.length > 0)
}
function AddRelationshipForm({ entries, onAddProperty }: {
function AddRelationshipForm({ entries, typeEntryMap, onAddProperty }: {
entries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onAddProperty: (key: string, value: FrontmatterValue) => void
}) {
const [relKey, setRelKey] = useState('')
const [relTarget, setRelTarget] = useState('')
const [showForm, setShowForm] = useState(false)
const keyInputRef = useRef<HTMLInputElement>(null)
const noteTitles = useMemo(() => entries.map(e => e.title), [entries])
const handleAdd = useCallback(() => {
const submitForm = useCallback((targetOverride?: string) => {
const key = relKey.trim()
const target = relTarget.trim()
const target = (targetOverride ?? relTarget).trim()
if (!key || !target) return
onAddProperty(key, `[[${target}]]`)
setRelKey(''); setRelTarget(''); setShowForm(false)
}, [relKey, relTarget, onAddProperty])
const resetForm = () => { setShowForm(false); setRelKey(''); setRelTarget('') }
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleAdd()
else if (e.key === 'Escape') resetForm()
}
if (!showForm) {
return (
@@ -236,18 +229,53 @@ function AddRelationshipForm({ entries, onAddProperty }: {
}
return (
<div className="mt-2 flex flex-col gap-1.5" onKeyDown={handleKeyDown}>
<datalist id="rel-note-titles">{noteTitles.map(t => <option key={t} value={t} />)}</datalist>
<input ref={keyInputRef} autoFocus className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground" style={{ borderRadius: 4, outline: 'none' }} placeholder="Relationship name" value={relKey} onChange={e => setRelKey(e.target.value)} />
<input className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground" style={{ borderRadius: 4, outline: 'none' }} placeholder="Note title" list="rel-note-titles" value={relTarget} onChange={e => setRelTarget(e.target.value)} />
<div className="mt-2 flex flex-col gap-1.5">
<input
ref={keyInputRef}
autoFocus
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Relationship name"
value={relKey}
onChange={e => setRelKey(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') resetForm() }}
/>
<NoteAutocomplete
entries={entries}
typeEntryMap={typeEntryMap}
value={relTarget}
onChange={setRelTarget}
onSelect={(title) => submitForm(title)}
onEscape={resetForm}
placeholder="Note title"
testId="add-relationship-note-input"
/>
<div className="flex gap-1.5">
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={handleAdd} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
</div>
</div>
)
}
function removeRefFromGroup(groups: { key: string; refs: string[] }[], key: string, refToRemove: string, onUpdate: (k: string, v: FrontmatterValue) => void, onDelete: (k: string) => void) {
const group = groups.find(g => g.key === key)
if (!group) return
const remaining = group.refs.filter(r => r !== refToRemove)
if (remaining.length === 0) onDelete(key)
else if (remaining.length === 1) onUpdate(key, remaining[0])
else onUpdate(key, remaining)
}
function addRefToGroup(groups: { key: string; refs: string[] }[], key: string, noteTitle: string, onUpdate: (k: string, v: FrontmatterValue) => void) {
const existing = groups.find(g => g.key === key)?.refs ?? []
const newRef = `[[${noteTitle}]]`
if (existing.includes(newRef)) return
const updated = [...existing, newRef]
if (updated.length === 1) onUpdate(key, updated[0])
else onUpdate(key, updated)
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
@@ -259,23 +287,12 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
if (!onUpdateProperty || !onDeleteProperty) return
const group = relationshipEntries.find(g => g.key === key)
if (!group) return
const remaining = group.refs.filter(r => r !== refToRemove)
if (remaining.length === 0) onDeleteProperty(key)
else if (remaining.length === 1) onUpdateProperty(key, remaining[0])
else onUpdateProperty(key, remaining)
removeRefFromGroup(relationshipEntries, key, refToRemove, onUpdateProperty, onDeleteProperty)
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
const handleAddRef = useCallback((key: string, noteTitle: string) => {
if (!onUpdateProperty) return
const group = relationshipEntries.find(g => g.key === key)
const existing = group?.refs ?? []
const newRef = `[[${noteTitle}]]`
if (existing.includes(newRef)) return
const updated = [...existing, newRef]
if (updated.length === 1) onUpdateProperty(key, updated[0])
else onUpdateProperty(key, updated)
addRefToGroup(relationshipEntries, key, noteTitle, onUpdateProperty)
}, [relationshipEntries, onUpdateProperty])
const canEdit = !!onUpdateProperty && !!onDeleteProperty
@@ -293,7 +310,7 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
))
}
{onAddProperty
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} />
? <AddRelationshipForm entries={entries} typeEntryMap={typeEntryMap} onAddProperty={onAddProperty} />
: <button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Link existing</button>
}
</div>

View File

@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { NoteAutocomplete } from './NoteAutocomplete'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
relationships: {},
icon: null,
color: null,
order: null,
...overrides,
})
const entries = [
makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha Project', isA: 'Project' }),
makeEntry({ path: '/vault/person/luca.md', filename: 'luca.md', title: 'Luca', isA: 'Person' }),
makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI Research', isA: 'Topic' }),
makeEntry({ path: '/vault/note/plain.md', filename: 'plain.md', title: 'Plain Note', isA: null }),
makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice Smith', isA: 'Person', aliases: ['Alice'] }),
]
const personTypeEntry = makeEntry({
path: '/vault/type/person.md', filename: 'person.md', title: 'Person',
isA: 'Type', color: 'yellow', icon: 'user',
})
const typeEntryMap: Record<string, VaultEntry> = { Person: personTypeEntry }
describe('NoteAutocomplete', () => {
const onChange = vi.fn()
const onSelect = vi.fn()
const onEscape = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('renders input with placeholder', () => {
render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="" onChange={onChange} onSelect={onSelect} placeholder="Note title" testId="test-input" />,
)
expect(screen.getByPlaceholderText('Note title')).toBeInTheDocument()
})
it('does not show dropdown for short queries', () => {
render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="A" onChange={onChange} onSelect={onSelect} />,
)
expect(screen.queryByText('Alpha Project')).not.toBeInTheDocument()
})
it('shows matching entries when query is long enough', () => {
const { container } = render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Al" onChange={onChange} onSelect={onSelect} />,
)
// Simulate opening the dropdown by focusing and typing
const input = container.querySelector('input')!
fireEvent.focus(input)
expect(screen.getByText('Alpha Project')).toBeInTheDocument()
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
})
it('shows note type badge for typed entries', () => {
const { container } = render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Luca" onChange={onChange} onSelect={onSelect} />,
)
fireEvent.focus(container.querySelector('input')!)
expect(screen.getByText('Luca')).toBeInTheDocument()
expect(screen.getByText('Person')).toBeInTheDocument()
})
it('does not show type badge for plain notes', () => {
const { container } = render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Plain" onChange={onChange} onSelect={onSelect} />,
)
fireEvent.focus(container.querySelector('input')!)
expect(screen.getByText('Plain Note')).toBeInTheDocument()
// Should not render any type badge
const typeLabels = container.querySelectorAll('.wikilink-menu__type')
expect(typeLabels.length).toBe(0)
})
it('applies type color from typeEntryMap', () => {
const { container } = render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Luca" onChange={onChange} onSelect={onSelect} />,
)
fireEvent.focus(container.querySelector('input')!)
const typeLabel = container.querySelector('.wikilink-menu__type')
expect(typeLabel).toBeTruthy()
expect((typeLabel as HTMLElement).style.color).toBe('var(--accent-yellow)')
})
it('calls onSelect when clicking a dropdown item', () => {
const { container } = render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Alpha" onChange={onChange} onSelect={onSelect} />,
)
fireEvent.focus(container.querySelector('input')!)
fireEvent.click(screen.getByText('Alpha Project'))
expect(onSelect).toHaveBeenCalledWith('Alpha Project')
})
it('navigates dropdown with arrow keys', () => {
const { container } = render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Al" onChange={onChange} onSelect={onSelect} />,
)
const input = container.querySelector('input')!
fireEvent.focus(input)
fireEvent.keyDown(input, { key: 'ArrowDown' })
// First item should be selected
const selectedItem = container.querySelector('.wikilink-menu__item--selected')
expect(selectedItem).toBeTruthy()
})
it('selects highlighted item with Enter', () => {
const { container } = render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Al" onChange={onChange} onSelect={onSelect} />,
)
const input = container.querySelector('input')!
fireEvent.focus(input)
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSelect).toHaveBeenCalledWith('Alpha Project')
})
it('calls onEscape when Escape is pressed', () => {
const { container } = render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="test" onChange={onChange} onSelect={onSelect} onEscape={onEscape} />,
)
const input = container.querySelector('input')!
fireEvent.focus(input)
fireEvent.keyDown(input, { key: 'Escape' })
expect(onEscape).toHaveBeenCalled()
})
it('matches on aliases', () => {
const { container } = render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Alice" onChange={onChange} onSelect={onSelect} />,
)
fireEvent.focus(container.querySelector('input')!)
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
})
it('limits results to MAX_RESULTS', () => {
const manyEntries = Array.from({ length: 20 }, (_, i) =>
makeEntry({ path: `/vault/note/${i}.md`, filename: `${i}.md`, title: `Note ${i}`, isA: null }),
)
const { container } = render(
<NoteAutocomplete entries={manyEntries} typeEntryMap={{}} value="Note" onChange={onChange} onSelect={onSelect} />,
)
fireEvent.focus(container.querySelector('input')!)
const items = container.querySelectorAll('.wikilink-menu__item')
expect(items.length).toBe(10) // MAX_RESULTS
})
it('submits raw value with Enter when no item is selected', () => {
render(
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="custom text" onChange={onChange} onSelect={onSelect} />,
)
const input = screen.getByDisplayValue('custom text')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSelect).toHaveBeenCalledWith('custom text')
})
})

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>
)
}