From 48cf359eaad04e2f7f3d4e880954c8ab8ebf38ba Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Wed, 25 Feb 2026 18:30:28 +0100 Subject: [PATCH] feat: relations autocomplete shows note type badges (matching wiki-link UX) (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- design/relations-autocomplete-type.pen | 1 + src/components/InspectorPanels.tsx | 121 ++++++++------- src/components/NoteAutocomplete.test.tsx | 179 +++++++++++++++++++++++ src/components/NoteAutocomplete.tsx | 150 +++++++++++++++++++ 4 files changed, 399 insertions(+), 52 deletions(-) create mode 100644 design/relations-autocomplete-type.pen create mode 100644 src/components/NoteAutocomplete.test.tsx create mode 100644 src/components/NoteAutocomplete.tsx diff --git a/design/relations-autocomplete-type.pen b/design/relations-autocomplete-type.pen new file mode 100644 index 00000000..9afa0b0e --- /dev/null +++ b/design/relations-autocomplete-type.pen @@ -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":{}} \ No newline at end of file diff --git a/src/components/InspectorPanels.tsx b/src/components/InspectorPanels.tsx index 397a29d9..9e8af444 100644 --- a/src/components/InspectorPanels.tsx +++ b/src/components/InspectorPanels.tsx @@ -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 onAdd: (noteTitle: string) => void }) { const [active, setActive] = useState(false) const [query, setQuery] = useState('') - const inputRef = useRef(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 ( + ) } +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 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 - ? + ? : } diff --git a/src/components/NoteAutocomplete.test.tsx b/src/components/NoteAutocomplete.test.tsx new file mode 100644 index 00000000..7684a9df --- /dev/null +++ b/src/components/NoteAutocomplete.test.tsx @@ -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 => ({ + 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 = { 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( + , + ) + expect(screen.getByPlaceholderText('Note title')).toBeInTheDocument() + }) + + it('does not show dropdown for short queries', () => { + render( + , + ) + expect(screen.queryByText('Alpha Project')).not.toBeInTheDocument() + }) + + it('shows matching entries when query is long enough', () => { + const { container } = render( + , + ) + // 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + const input = container.querySelector('input')! + fireEvent.focus(input) + fireEvent.keyDown(input, { key: 'Escape' }) + expect(onEscape).toHaveBeenCalled() + }) + + it('matches on aliases', () => { + const { container } = render( + , + ) + 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( + , + ) + 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( + , + ) + const input = screen.getByDisplayValue('custom text') + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onSelect).toHaveBeenCalledWith('custom text') + }) +}) diff --git a/src/components/NoteAutocomplete.tsx b/src/components/NoteAutocomplete.tsx new file mode 100644 index 00000000..8a08af76 --- /dev/null +++ b/src/components/NoteAutocomplete.tsx @@ -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 + 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, 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(null) + const menuRef = useRef(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) => { + 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 ( +
+ setOpen(true)} + onKeyDown={handleKeyDown} + data-testid={testId} + /> + {open && matches.length > 0 && ( +
+ {matches.map((item, index) => ( +
e.preventDefault()} + onClick={() => handleSelect(item.title)} + onMouseEnter={() => setSelectedIndex(index)} + > + {item.title} + {item.noteType && ( + + {item.noteType} + + )} +
+ ))} +
+ )} +
+ ) +}