Merge pull request #78 from refactoringhq/task/unify-note-search
feat: Unify note search/autocomplete into a single reusable component
This commit is contained in:
1
design/unify-note-search.pen
Normal file
1
design/unify-note-search.pen
Normal file
File diff suppressed because one or more lines are too long
@@ -4,6 +4,9 @@ import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistor
|
||||
import type { ReferencedByItem } from './InspectorPanels'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
|
||||
// jsdom doesn't implement scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
|
||||
@@ -9,7 +9,8 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import { findEntryByTarget } from '../utils/wikilinkColors'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { NoteAutocomplete } from './NoteAutocomplete'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import { useNoteSearch } from '../hooks/useNoteSearch'
|
||||
|
||||
function isWikilink(value: string): boolean {
|
||||
return /^\[\[.*\]\]$/.test(value)
|
||||
@@ -101,22 +102,50 @@ function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap: Recor
|
||||
}
|
||||
}
|
||||
|
||||
function InlineAddNote({ entries, typeEntryMap, onAdd }: {
|
||||
function SearchDropdown({ search, onSelect }: {
|
||||
search: ReturnType<typeof useNoteSearch>
|
||||
onSelect: (title: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
|
||||
<NoteSearchList
|
||||
items={search.results}
|
||||
selectedIndex={search.selectedIndex}
|
||||
getItemKey={(item) => item.entry.path}
|
||||
onItemClick={(item) => onSelect(item.entry.title)}
|
||||
onItemHover={(i) => search.setSelectedIndex(i)}
|
||||
className="max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineAddNote({ entries, 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 search = useNoteSearch(entries, query, 8)
|
||||
|
||||
const handleSubmit = useCallback((title: string) => {
|
||||
const trimmed = title.trim()
|
||||
if (!trimmed) return
|
||||
onAdd(trimmed)
|
||||
const selectAndClose = useCallback((title: string) => {
|
||||
onAdd(title)
|
||||
setQuery('')
|
||||
setActive(false)
|
||||
}, [onAdd])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const title = search.selectedEntry?.title ?? query.trim()
|
||||
if (title) selectAndClose(title)
|
||||
}, [search.selectedEntry, query, selectAndClose])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
search.handleKeyDown(e)
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleConfirm() }
|
||||
else if (e.key === 'Escape') { setQuery(''); setActive(false) }
|
||||
}, [search, handleConfirm])
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<button
|
||||
@@ -132,33 +161,36 @@ function InlineAddNote({ entries, typeEntryMap, onAdd }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<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"
|
||||
<div className="relative mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
testId="add-relation-ref-input"
|
||||
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"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="add-relation-ref-input"
|
||||
/>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleConfirm}
|
||||
disabled={!query.trim()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => { setQuery(''); setActive(false) }}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleSubmit(query)}
|
||||
disabled={!query.trim()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => { setQuery(''); setActive(false) }}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{query.trim() && search.results.length > 0 && (
|
||||
<SearchDropdown search={search} onSelect={selectAndClose} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -185,7 +217,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{onAddRef && <InlineAddNote entries={entries} typeEntryMap={typeEntryMap} onAdd={onAddRef} />}
|
||||
{onAddRef && <InlineAddNote entries={entries} onAdd={onAddRef} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -202,9 +234,48 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
|
||||
.filter(({ refs }) => refs.length > 0)
|
||||
}
|
||||
|
||||
function AddRelationshipForm({ entries, typeEntryMap, onAddProperty }: {
|
||||
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSubmit?: () => void
|
||||
onCancel?: () => void
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false)
|
||||
const search = useNoteSearch(entries, value, 8)
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
search.handleKeyDown(e)
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (search.selectedEntry) { onChange(search.selectedEntry.title); setFocused(false) }
|
||||
else onSubmit?.()
|
||||
} else if (e.key === 'Escape') { onCancel?.() }
|
||||
}, [search, onChange, onSubmit, onCancel])
|
||||
|
||||
const showDropdown = focused && value.trim() && search.results.length > 0
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<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"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setTimeout(() => setFocused(false), 150)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{showDropdown && (
|
||||
<SearchDropdown search={search} onSelect={(title) => { onChange(title); setFocused(false) }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddRelationshipForm({ entries, onAddProperty }: {
|
||||
entries: VaultEntry[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onAddProperty: (key: string, value: FrontmatterValue) => void
|
||||
}) {
|
||||
const [relKey, setRelKey] = useState('')
|
||||
@@ -220,7 +291,9 @@ function AddRelationshipForm({ entries, typeEntryMap, onAddProperty }: {
|
||||
setRelKey(''); setRelTarget(''); setShowForm(false)
|
||||
}, [relKey, relTarget, onAddProperty])
|
||||
|
||||
const resetForm = () => { setShowForm(false); setRelKey(''); setRelTarget('') }
|
||||
const resetForm = useCallback(() => {
|
||||
setShowForm(false); setRelKey(''); setRelTarget('')
|
||||
}, [])
|
||||
|
||||
if (!showForm) {
|
||||
return (
|
||||
@@ -238,18 +311,9 @@ function AddRelationshipForm({ entries, typeEntryMap, onAddProperty }: {
|
||||
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"
|
||||
onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }}
|
||||
/>
|
||||
<NoteTargetInput entries={entries} value={relTarget} onChange={setRelTarget} onSubmit={submitForm} onCancel={resetForm} />
|
||||
<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={() => 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>
|
||||
@@ -258,22 +322,17 @@ function AddRelationshipForm({ entries, typeEntryMap, onAddProperty }: {
|
||||
)
|
||||
}
|
||||
|
||||
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 updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterValue | null {
|
||||
const remaining = refs.filter(r => r !== refToRemove)
|
||||
if (remaining.length === 0) return null
|
||||
return remaining.length === 1 ? remaining[0] : 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 ?? []
|
||||
function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false {
|
||||
const newRef = `[[${noteTitle}]]`
|
||||
if (existing.includes(newRef)) return
|
||||
const updated = [...existing, newRef]
|
||||
if (updated.length === 1) onUpdate(key, updated[0])
|
||||
else onUpdate(key, updated)
|
||||
if (refs.includes(newRef)) return false
|
||||
const updated = [...refs, newRef]
|
||||
return updated.length === 1 ? updated[0] : updated
|
||||
}
|
||||
|
||||
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: {
|
||||
@@ -287,12 +346,18 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
|
||||
|
||||
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
|
||||
if (!onUpdateProperty || !onDeleteProperty) return
|
||||
removeRefFromGroup(relationshipEntries, key, refToRemove, onUpdateProperty, onDeleteProperty)
|
||||
const group = relationshipEntries.find(g => g.key === key)
|
||||
if (!group) return
|
||||
const result = updateRefsForRemoval(group.refs, refToRemove)
|
||||
if (result === null) onDeleteProperty(key)
|
||||
else onUpdateProperty(key, result)
|
||||
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
|
||||
|
||||
const handleAddRef = useCallback((key: string, noteTitle: string) => {
|
||||
if (!onUpdateProperty) return
|
||||
addRefToGroup(relationshipEntries, key, noteTitle, onUpdateProperty)
|
||||
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
|
||||
const result = updateRefsForAddition(existing, noteTitle)
|
||||
if (result !== false) onUpdateProperty(key, result)
|
||||
}, [relationshipEntries, onUpdateProperty])
|
||||
|
||||
const canEdit = !!onUpdateProperty && !!onDeleteProperty
|
||||
@@ -310,7 +375,7 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
|
||||
))
|
||||
}
|
||||
{onAddProperty
|
||||
? <AddRelationshipForm entries={entries} typeEntryMap={typeEntryMap} onAddProperty={onAddProperty} />
|
||||
? <AddRelationshipForm entries={entries} 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>
|
||||
|
||||
175
src/components/NoteSearchList.test.tsx
Normal file
175
src/components/NoteSearchList.test.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import type { NoteSearchResultItem } from './NoteSearchList'
|
||||
|
||||
// jsdom doesn't implement scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
interface TestItem extends NoteSearchResultItem {
|
||||
id: string
|
||||
}
|
||||
|
||||
const items: TestItem[] = [
|
||||
{ id: '1', title: 'Alpha Project', noteType: 'Project', typeColor: 'var(--accent-blue)' },
|
||||
{ id: '2', title: 'Beta Notes' },
|
||||
{ id: '3', title: 'Gamma Experiment', noteType: 'Experiment' },
|
||||
]
|
||||
|
||||
describe('NoteSearchList', () => {
|
||||
const onItemClick = vi.fn()
|
||||
const onItemHover = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders all items with titles', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Alpha Project')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Gamma Experiment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows type badge when noteType is present', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Project')).toBeInTheDocument()
|
||||
expect(screen.getByText('Experiment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show type badge when noteType is absent', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={[{ id: '2', title: 'Beta Notes' }]}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item: TestItem) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Beta Notes')).toBeInTheDocument()
|
||||
// No badge element should exist
|
||||
expect(screen.queryByText('Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies typeColor to badge when provided', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={[items[0]]}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
const badge = screen.getByText('Project')
|
||||
expect(badge.style.color).toBe('var(--accent-blue)')
|
||||
})
|
||||
|
||||
it('shows empty message when no items', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={[]}
|
||||
selectedIndex={0}
|
||||
getItemKey={() => ''}
|
||||
onItemClick={onItemClick}
|
||||
emptyMessage="No matching notes"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('No matching notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows default empty message', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={[]}
|
||||
selectedIndex={0}
|
||||
getItemKey={() => ''}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('No results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onItemClick when an item is clicked', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Beta Notes'))
|
||||
expect(onItemClick).toHaveBeenCalledWith(items[1], 1)
|
||||
})
|
||||
|
||||
it('calls onItemHover when mouse enters an item', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
onItemHover={onItemHover}
|
||||
/>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('Gamma Experiment'))
|
||||
expect(onItemHover).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('highlights selected item with accent background', () => {
|
||||
render(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={1}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
const selectedItem = screen.getByText('Beta Notes').closest('div')!
|
||||
expect(selectedItem.className).toContain('bg-accent')
|
||||
|
||||
const unselectedItem = screen.getByText('Alpha Project').closest('div')!
|
||||
expect(unselectedItem.className).not.toContain('bg-accent')
|
||||
})
|
||||
|
||||
it('calls scrollIntoView on the selected item', () => {
|
||||
const scrollMock = vi.fn()
|
||||
Element.prototype.scrollIntoView = scrollMock
|
||||
|
||||
const { rerender } = render(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={0}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
scrollMock.mockClear()
|
||||
|
||||
rerender(
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={2}
|
||||
getItemKey={(item) => item.id}
|
||||
onItemClick={onItemClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(scrollMock).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
})
|
||||
})
|
||||
76
src/components/NoteSearchList.tsx
Normal file
76
src/components/NoteSearchList.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export interface NoteSearchResultItem {
|
||||
title: string
|
||||
noteType?: string
|
||||
typeColor?: string
|
||||
}
|
||||
|
||||
interface NoteSearchListProps<T extends NoteSearchResultItem> {
|
||||
items: T[]
|
||||
selectedIndex: number
|
||||
getItemKey: (item: T, index: number) => string
|
||||
onItemClick: (item: T, index: number) => void
|
||||
onItemHover?: (index: number) => void
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function NoteSearchList<T extends NoteSearchResultItem>({
|
||||
items,
|
||||
selectedIndex,
|
||||
getItemKey,
|
||||
onItemClick,
|
||||
onItemHover,
|
||||
emptyMessage = 'No results',
|
||||
className,
|
||||
}: NoteSearchListProps<T>) {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const el = listRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex])
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div ref={listRef} className={cn('py-1', className)}>
|
||||
<div className="px-4 py-3 text-center text-[13px] text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={listRef} className={cn('py-1', className)}>
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={getItemKey(item, i)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-between gap-2 px-3 py-1.5 transition-colors',
|
||||
i === selectedIndex ? 'bg-accent' : 'hover:bg-secondary',
|
||||
)}
|
||||
onClick={() => onItemClick(item, i)}
|
||||
onMouseEnter={() => onItemHover?.(i)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.noteType && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 text-[11px]"
|
||||
style={item.typeColor ? { color: item.typeColor } : undefined}
|
||||
>
|
||||
{item.noteType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { fuzzyMatch } from '../utils/fuzzyMatch'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import { useNoteSearch } from '../hooks/useNoteSearch'
|
||||
|
||||
interface QuickOpenPaletteProps {
|
||||
open: boolean
|
||||
@@ -13,63 +12,37 @@ interface QuickOpenPaletteProps {
|
||||
|
||||
export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpenPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const { results, selectedIndex, setSelectedIndex, handleKeyDown } = useNoteSearch(entries, query)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setQuery(''); setSelectedIndex(0)
|
||||
setQuery('')
|
||||
setSelectedIndex(0)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim()) {
|
||||
return [...entries].sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0)).slice(0, 20)
|
||||
}
|
||||
return entries
|
||||
.map((entry) => ({ entry, ...fuzzyMatch(query, entry.title) }))
|
||||
.filter((r) => r.match)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 20)
|
||||
.map((r) => r.entry)
|
||||
}, [entries, query])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset selection on query change
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const selected = listRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
selected?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex])
|
||||
}, [open, setSelectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
handleKeyDown(e)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.min(i + 1, results.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (results[selectedIndex]) {
|
||||
onSelect(results[selectedIndex])
|
||||
const selected = results[selectedIndex]
|
||||
if (selected) {
|
||||
onSelect(selected.entry)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [open, results, selectedIndex, onSelect, onClose])
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [open, results, selectedIndex, onSelect, onClose, handleKeyDown])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -90,35 +63,18 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
{results.length === 0 ? (
|
||||
<div className="px-4 py-4 text-center text-[13px] text-muted-foreground">
|
||||
No matching notes
|
||||
</div>
|
||||
) : (
|
||||
results.map((entry, i) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between px-4 py-2 transition-colors",
|
||||
i === selectedIndex ? "bg-accent" : "hover:bg-secondary"
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect(entry)
|
||||
onClose()
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
>
|
||||
<span className="text-sm text-foreground">{entry.title}</span>
|
||||
{entry.isA && (
|
||||
<Badge variant="secondary" className="text-[11px]">
|
||||
{entry.isA}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<NoteSearchList
|
||||
items={results}
|
||||
selectedIndex={selectedIndex}
|
||||
getItemKey={(item) => item.entry.path}
|
||||
onItemClick={(item) => {
|
||||
onSelect(item.entry)
|
||||
onClose()
|
||||
}}
|
||||
onItemHover={(i) => setSelectedIndex(i)}
|
||||
emptyMessage="No matching notes"
|
||||
className="flex-1 overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,58 +1,12 @@
|
||||
/* Wikilink autocomplete — flat suggestion menu */
|
||||
/* Wikilink autocomplete — floating menu container (positioned by BlockNote) */
|
||||
.wikilink-menu {
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 260px;
|
||||
max-width: 400px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.wikilink-menu__empty {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted, #999);
|
||||
}
|
||||
|
||||
.wikilink-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wikilink-menu__item:hover {
|
||||
background: var(--muted, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.wikilink-menu__item--selected {
|
||||
background: var(--accent, rgba(21, 93, 255, 0.08));
|
||||
}
|
||||
|
||||
.wikilink-menu__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.wikilink-menu__type {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import './WikilinkSuggestionMenu.css'
|
||||
|
||||
export interface WikilinkSuggestionItem {
|
||||
@@ -19,41 +19,18 @@ interface WikilinkSuggestionMenuProps {
|
||||
}
|
||||
|
||||
export function WikilinkSuggestionMenu({ items, selectedIndex, onItemClick }: WikilinkSuggestionMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex === undefined || !menuRef.current) return
|
||||
const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex])
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="wikilink-menu" ref={menuRef}>
|
||||
<div className="wikilink-menu__empty">No results</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wikilink-menu" ref={menuRef}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${item.title}-${item.path ?? index}`}
|
||||
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
|
||||
onClick={() => {
|
||||
item.onItemClick()
|
||||
onItemClick?.(item)
|
||||
}}
|
||||
>
|
||||
<span className="wikilink-menu__title">{item.title}</span>
|
||||
{item.noteType && (
|
||||
<span className="wikilink-menu__type" style={{ color: item.typeColor }}>
|
||||
{item.noteType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="wikilink-menu">
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={selectedIndex ?? 0}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => {
|
||||
item.onItemClick()
|
||||
onItemClick?.(item)
|
||||
}}
|
||||
emptyMessage="No results"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
174
src/hooks/useNoteSearch.test.ts
Normal file
174
src/hooks/useNoteSearch.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useNoteSearch } from './useNoteSearch'
|
||||
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: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/a.md', title: 'Alpha Project', isA: 'Project', modifiedAt: 1700000003 }),
|
||||
makeEntry({ path: '/vault/b.md', title: 'Beta Notes', isA: 'Note', modifiedAt: 1700000002 }),
|
||||
makeEntry({ path: '/vault/c.md', title: 'Gamma Experiment', isA: 'Experiment', modifiedAt: 1700000001 }),
|
||||
]
|
||||
|
||||
describe('useNoteSearch', () => {
|
||||
it('returns entries sorted by modifiedAt when query is empty', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, ''))
|
||||
expect(result.current.results.map((r) => r.title)).toEqual([
|
||||
'Alpha Project',
|
||||
'Beta Notes',
|
||||
'Gamma Experiment',
|
||||
])
|
||||
})
|
||||
|
||||
it('filters entries by fuzzy match', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, 'alpha'))
|
||||
expect(result.current.results).toHaveLength(1)
|
||||
expect(result.current.results[0].title).toBe('Alpha Project')
|
||||
})
|
||||
|
||||
it('returns empty results when query has no matches', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, 'zzzzzzz'))
|
||||
expect(result.current.results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('respects maxResults', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, '', 2))
|
||||
expect(result.current.results).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('includes noteType for non-Note entries', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, ''))
|
||||
const project = result.current.results.find((r) => r.title === 'Alpha Project')
|
||||
expect(project?.noteType).toBe('Project')
|
||||
expect(project?.typeColor).toBeTruthy()
|
||||
})
|
||||
|
||||
it('excludes noteType for Note entries', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, ''))
|
||||
const note = result.current.results.find((r) => r.title === 'Beta Notes')
|
||||
expect(note?.noteType).toBeUndefined()
|
||||
expect(note?.typeColor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes original VaultEntry in results', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, ''))
|
||||
expect(result.current.results[0].entry).toBe(entries[0])
|
||||
})
|
||||
|
||||
it('starts with selectedIndex 0', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, ''))
|
||||
expect(result.current.selectedIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('resets selectedIndex when query changes', () => {
|
||||
let query = ''
|
||||
const { result, rerender } = renderHook(() => useNoteSearch(entries, query))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedIndex(2)
|
||||
})
|
||||
expect(result.current.selectedIndex).toBe(2)
|
||||
|
||||
query = 'alpha'
|
||||
rerender()
|
||||
expect(result.current.selectedIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('handleKeyDown moves selection down on ArrowDown', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, ''))
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' }),
|
||||
)
|
||||
})
|
||||
expect(result.current.selectedIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('handleKeyDown moves selection up on ArrowUp', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, ''))
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedIndex(2)
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp' }),
|
||||
)
|
||||
})
|
||||
expect(result.current.selectedIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('handleKeyDown clamps selection at boundaries', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, ''))
|
||||
|
||||
// Can't go below 0
|
||||
act(() => {
|
||||
result.current.handleKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp' }),
|
||||
)
|
||||
})
|
||||
expect(result.current.selectedIndex).toBe(0)
|
||||
|
||||
// Can't go above last index
|
||||
act(() => {
|
||||
result.current.setSelectedIndex(2)
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' }),
|
||||
)
|
||||
})
|
||||
expect(result.current.selectedIndex).toBe(2)
|
||||
})
|
||||
|
||||
it('selectedEntry reflects current selection', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, ''))
|
||||
expect(result.current.selectedEntry).toBe(entries[0])
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedIndex(1)
|
||||
})
|
||||
expect(result.current.selectedEntry).toBe(entries[1])
|
||||
})
|
||||
|
||||
it('selectedEntry is null when no results', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, 'zzzzzzz'))
|
||||
expect(result.current.selectedEntry).toBeNull()
|
||||
})
|
||||
|
||||
it('does not prevent default for non-arrow keys', () => {
|
||||
const { result } = renderHook(() => useNoteSearch(entries, ''))
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter' })
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault')
|
||||
|
||||
act(() => {
|
||||
result.current.handleKeyDown(event)
|
||||
})
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
61
src/hooks/useNoteSearch.ts
Normal file
61
src/hooks/useNoteSearch.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { fuzzyMatch } from '../utils/fuzzyMatch'
|
||||
import { getTypeColor } from '../utils/typeColors'
|
||||
import type { NoteSearchResultItem } from '../components/NoteSearchList'
|
||||
|
||||
const DEFAULT_MAX_RESULTS = 20
|
||||
|
||||
export interface NoteSearchResult extends NoteSearchResultItem {
|
||||
entry: VaultEntry
|
||||
}
|
||||
|
||||
function toResult(e: VaultEntry): NoteSearchResult {
|
||||
const noteType = e.isA && e.isA !== 'Note' ? e.isA : undefined
|
||||
return {
|
||||
entry: e,
|
||||
title: e.title,
|
||||
noteType,
|
||||
typeColor: noteType ? getTypeColor(e.isA) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteSearch(entries: VaultEntry[], query: string, maxResults = DEFAULT_MAX_RESULTS) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const results: NoteSearchResult[] = useMemo(() => {
|
||||
if (!query.trim()) {
|
||||
return [...entries]
|
||||
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
|
||||
.slice(0, maxResults)
|
||||
.map(toResult)
|
||||
}
|
||||
return entries
|
||||
.map((e) => ({ entry: e, ...fuzzyMatch(query, e.title) }))
|
||||
.filter((r) => r.match)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, maxResults)
|
||||
.map((r) => toResult(r.entry))
|
||||
}, [entries, query, maxResults])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change
|
||||
}, [query])
|
||||
|
||||
const selectedEntry = results[selectedIndex]?.entry ?? null
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent | KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.min(i + 1, results.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0))
|
||||
}
|
||||
},
|
||||
[results.length],
|
||||
)
|
||||
|
||||
return { results, selectedIndex, setSelectedIndex, selectedEntry, handleKeyDown }
|
||||
}
|
||||
Reference in New Issue
Block a user