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:
1
design/relations-autocomplete-type.pen
Normal file
1
design/relations-autocomplete-type.pen
Normal 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":{}}
|
||||
@@ -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>
|
||||
|
||||
179
src/components/NoteAutocomplete.test.tsx
Normal file
179
src/components/NoteAutocomplete.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
150
src/components/NoteAutocomplete.tsx
Normal file
150
src/components/NoteAutocomplete.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user