diff --git a/design/unify-note-search.pen b/design/unify-note-search.pen new file mode 100644 index 00000000..f6950708 --- /dev/null +++ b/design/unify-note-search.pen @@ -0,0 +1 @@ +{"version":1,"children":[{"type":"frame","id":"overview","x":0,"y":0,"name":"Unified NoteSearchList — 3 Contexts","width":900,"height":600,"fill":"#ffffff","layout":"vertical","gap":32,"padding":32,"children":[{"type":"text","id":"heading","content":"NoteSearchList — Unified Search Component","fontSize":20,"fontWeight":"700","color":"#1a1a1a"},{"type":"text","id":"desc","content":"A single reusable component used by wiki-link autocomplete, relations autocomplete, and Cmd+P quick open. Shows note name + type badge consistently. Supports keyboard navigation (arrows + Enter).","fontSize":13,"color":"#666666","width":"fill_container"},{"type":"frame","id":"contexts","name":"Three Contexts","layout":"horizontal","gap":24,"width":"fill_container","height":"fill_container","children":[{"type":"frame","id":"ctx-wikilink","name":"1. Wiki-link [[","layout":"vertical","gap":8,"width":"fill_container","padding":16,"fill":"#f8f8f8","cornerRadius":[8,8,8,8],"children":[{"type":"text","id":"wl-label","content":"Wiki-link Autocomplete","fontSize":14,"fontWeight":"600","color":"#1a1a1a"},{"type":"text","id":"wl-trigger","content":"Trigger: [[","fontSize":11,"color":"#888"},{"type":"frame","id":"wl-menu","name":"Floating menu","layout":"vertical","gap":0,"width":"fill_container","fill":"#ffffff","cornerRadius":[6,6,6,6],"stroke":{"color":"#e0e0e0","thickness":1},"children":[{"type":"frame","id":"wl-item1","layout":"horizontal","width":"fill_container","padding":8,"fill":"#e8f0fe","children":[{"type":"text","id":"wl-t1","content":"Daily Standup","fontSize":13,"color":"#1a1a1a","width":"fill_container"},{"type":"text","id":"wl-b1","content":"Project","fontSize":11,"fontWeight":"500","color":"#4285f4"}]},{"type":"frame","id":"wl-item2","layout":"horizontal","width":"fill_container","padding":8,"children":[{"type":"text","id":"wl-t2","content":"Meeting Notes","fontSize":13,"color":"#1a1a1a","width":"fill_container"},{"type":"text","id":"wl-b2","content":"Note","fontSize":11,"fontWeight":"500","color":"#888"}]},{"type":"frame","id":"wl-item3","layout":"horizontal","width":"fill_container","padding":8,"children":[{"type":"text","id":"wl-t3","content":"Q4 Planning","fontSize":13,"color":"#1a1a1a","width":"fill_container"},{"type":"text","id":"wl-b3","content":"Project","fontSize":11,"fontWeight":"500","color":"#4285f4"}]}]}]},{"type":"frame","id":"ctx-relation","name":"2. Relations Add","layout":"vertical","gap":8,"width":"fill_container","padding":16,"fill":"#f8f8f8","cornerRadius":[8,8,8,8],"children":[{"type":"text","id":"rel-label","content":"Relations Autocomplete","fontSize":14,"fontWeight":"600","color":"#1a1a1a"},{"type":"text","id":"rel-trigger","content":"Trigger: + Add button","fontSize":11,"color":"#888"},{"type":"frame","id":"rel-input","name":"Inline input","layout":"vertical","gap":4,"width":"fill_container","children":[{"type":"frame","id":"rel-field","layout":"horizontal","gap":4,"width":"fill_container","children":[{"type":"frame","id":"rel-txt","width":"fill_container","height":24,"fill":"#ffffff","cornerRadius":[4,4,4,4],"stroke":{"color":"#e0e0e0","thickness":1},"padding":4,"children":[{"type":"text","id":"rel-q","content":"daily","fontSize":11,"color":"#1a1a1a"}]}]},{"type":"frame","id":"rel-dropdown","name":"Search dropdown","layout":"vertical","gap":0,"width":"fill_container","fill":"#ffffff","cornerRadius":[6,6,6,6],"stroke":{"color":"#e0e0e0","thickness":1},"children":[{"type":"frame","id":"rel-r1","layout":"horizontal","width":"fill_container","padding":6,"fill":"#e8f0fe","children":[{"type":"text","id":"rel-rt1","content":"Daily Standup","fontSize":12,"color":"#1a1a1a","width":"fill_container"},{"type":"text","id":"rel-rb1","content":"Project","fontSize":10,"fontWeight":"500","color":"#4285f4"}]},{"type":"frame","id":"rel-r2","layout":"horizontal","width":"fill_container","padding":6,"children":[{"type":"text","id":"rel-rt2","content":"Daily Log","fontSize":12,"color":"#1a1a1a","width":"fill_container"},{"type":"text","id":"rel-rb2","content":"Procedure","fontSize":10,"fontWeight":"500","color":"#34a853"}]}]}]}]},{"type":"frame","id":"ctx-quickopen","name":"3. Cmd+P Quick Open","layout":"vertical","gap":8,"width":"fill_container","padding":16,"fill":"#f8f8f8","cornerRadius":[8,8,8,8],"children":[{"type":"text","id":"qo-label","content":"Quick Open Palette","fontSize":14,"fontWeight":"600","color":"#1a1a1a"},{"type":"text","id":"qo-trigger","content":"Trigger: Cmd+P","fontSize":11,"color":"#888"},{"type":"frame","id":"qo-dialog","name":"Dialog overlay","layout":"vertical","gap":0,"width":"fill_container","fill":"#ffffff","cornerRadius":[12,12,12,12],"stroke":{"color":"#e0e0e0","thickness":1},"children":[{"type":"frame","id":"qo-search","width":"fill_container","padding":12,"stroke":{"color":"#e0e0e0","thickness":{"top":0,"right":0,"bottom":1,"left":0}},"children":[{"type":"text","id":"qo-placeholder","content":"Search notes...","fontSize":14,"color":"#aaa"}]},{"type":"frame","id":"qo-r1","layout":"horizontal","width":"fill_container","padding":8,"fill":"#e8f0fe","children":[{"type":"text","id":"qo-t1","content":"Alpha Project","fontSize":13,"color":"#1a1a1a","width":"fill_container"},{"type":"text","id":"qo-b1","content":"Project","fontSize":11,"fontWeight":"500","color":"#4285f4"}]},{"type":"frame","id":"qo-r2","layout":"horizontal","width":"fill_container","padding":8,"children":[{"type":"text","id":"qo-t2","content":"Beta Experiment","fontSize":13,"color":"#1a1a1a","width":"fill_container"},{"type":"text","id":"qo-b2","content":"Experiment","fontSize":11,"fontWeight":"500","color":"#ea4335"}]},{"type":"frame","id":"qo-r3","layout":"horizontal","width":"fill_container","padding":8,"children":[{"type":"text","id":"qo-t3","content":"Meeting Notes","fontSize":13,"color":"#1a1a1a","width":"fill_container"}]}]}]}]}]}],"variables":{}} diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index f4381a5a..2f03abf3 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -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 => ({ path: '/vault/note/test.md', filename: 'test.md', diff --git a/src/components/InspectorPanels.tsx b/src/components/InspectorPanels.tsx index 9e8af444..01b449d9 100644 --- a/src/components/InspectorPanels.tsx +++ b/src/components/InspectorPanels.tsx @@ -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 + onSelect: (title: string) => void +}) { + return ( +
+ item.entry.path} + onItemClick={(item) => onSelect(item.entry.title)} + onItemHover={(i) => search.setSelectedIndex(i)} + className="max-h-[160px] overflow-y-auto" + /> +
+ ) +} + +function InlineAddNote({ entries, onAdd }: { entries: VaultEntry[] - typeEntryMap: Record onAdd: (noteTitle: string) => void }) { const [active, setActive] = useState(false) const [query, setQuery] = useState('') + const inputRef = useRef(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 ( + - - + {query.trim() && search.results.length > 0 && ( + + )} ) } @@ -185,7 +217,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR ) })} - {onAddRef && } + {onAddRef && } ) } @@ -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 ( +
+ onChange(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setTimeout(() => setFocused(false), 150)} + onKeyDown={handleKeyDown} + /> + {showDropdown && ( + { onChange(title); setFocused(false) }} /> + )} +
+ ) +} + +function AddRelationshipForm({ entries, onAddProperty }: { entries: VaultEntry[] - typeEntryMap: Record 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() }} - /> - 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() }} /> +
@@ -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 - ? + ? : }
diff --git a/src/components/NoteSearchList.test.tsx b/src/components/NoteSearchList.test.tsx new file mode 100644 index 00000000..921581d6 --- /dev/null +++ b/src/components/NoteSearchList.test.tsx @@ -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( + 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( + 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( + 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( + 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( + ''} + onItemClick={onItemClick} + emptyMessage="No matching notes" + />, + ) + expect(screen.getByText('No matching notes')).toBeInTheDocument() + }) + + it('shows default empty message', () => { + render( + ''} + onItemClick={onItemClick} + />, + ) + expect(screen.getByText('No results')).toBeInTheDocument() + }) + + it('calls onItemClick when an item is clicked', () => { + render( + 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( + item.id} + onItemClick={onItemClick} + onItemHover={onItemHover} + />, + ) + fireEvent.mouseEnter(screen.getByText('Gamma Experiment')) + expect(onItemHover).toHaveBeenCalledWith(2) + }) + + it('highlights selected item with accent background', () => { + render( + 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( + item.id} + onItemClick={onItemClick} + />, + ) + + scrollMock.mockClear() + + rerender( + item.id} + onItemClick={onItemClick} + />, + ) + + expect(scrollMock).toHaveBeenCalledWith({ block: 'nearest' }) + }) +}) diff --git a/src/components/NoteSearchList.tsx b/src/components/NoteSearchList.tsx new file mode 100644 index 00000000..1e239142 --- /dev/null +++ b/src/components/NoteSearchList.tsx @@ -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 { + 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({ + items, + selectedIndex, + getItemKey, + onItemClick, + onItemHover, + emptyMessage = 'No results', + className, +}: NoteSearchListProps) { + const listRef = useRef(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 ( +
+
+ {emptyMessage} +
+
+ ) + } + + return ( +
+ {items.map((item, i) => ( +
onItemClick(item, i)} + onMouseEnter={() => onItemHover?.(i)} + > + + {item.title} + + {item.noteType && ( + + {item.noteType} + + )} +
+ ))} +
+ ) +} diff --git a/src/components/QuickOpenPalette.tsx b/src/components/QuickOpenPalette.tsx index 5ccbcc7f..7633a9d4 100644 --- a/src/components/QuickOpenPalette.tsx +++ b/src/components/QuickOpenPalette.tsx @@ -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(null) - const listRef = useRef(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)} /> -
- {results.length === 0 ? ( -
- No matching notes -
- ) : ( - results.map((entry, i) => ( -
{ - onSelect(entry) - onClose() - }} - onMouseEnter={() => setSelectedIndex(i)} - > - {entry.title} - {entry.isA && ( - - {entry.isA} - - )} -
- )) - )} -
+ item.entry.path} + onItemClick={(item) => { + onSelect(item.entry) + onClose() + }} + onItemHover={(i) => setSelectedIndex(i)} + emptyMessage="No matching notes" + className="flex-1 overflow-y-auto" + /> ) diff --git a/src/components/WikilinkSuggestionMenu.css b/src/components/WikilinkSuggestionMenu.css index 6b4913c3..4d91c00d 100644 --- a/src/components/WikilinkSuggestionMenu.css +++ b/src/components/WikilinkSuggestionMenu.css @@ -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; -} diff --git a/src/components/WikilinkSuggestionMenu.tsx b/src/components/WikilinkSuggestionMenu.tsx index 1c6a257f..d6a5620d 100644 --- a/src/components/WikilinkSuggestionMenu.tsx +++ b/src/components/WikilinkSuggestionMenu.tsx @@ -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(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 ( -
-
No results
-
- ) - } - return ( -
- {items.map((item, index) => ( -
{ - item.onItemClick() - onItemClick?.(item) - }} - > - {item.title} - {item.noteType && ( - - {item.noteType} - - )} -
- ))} +
+ `${item.title}-${item.path ?? i}`} + onItemClick={(item) => { + item.onItemClick() + onItemClick?.(item) + }} + emptyMessage="No results" + />
) } diff --git a/src/hooks/useNoteSearch.test.ts b/src/hooks/useNoteSearch.test.ts new file mode 100644 index 00000000..8b10efee --- /dev/null +++ b/src/hooks/useNoteSearch.test.ts @@ -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 => ({ + 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() + }) +}) diff --git a/src/hooks/useNoteSearch.ts b/src/hooks/useNoteSearch.ts new file mode 100644 index 00000000..e31b0175 --- /dev/null +++ b/src/hooks/useNoteSearch.ts @@ -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 } +}