feat: add tags (multi-select) property type (#133)

* design: add tags property type wireframes

Three frames showing the Tags multi-select property:
- Display state: colored pills with X to remove, + button to add
- Input state: dropdown with vault suggestions, checkmarks for selected
- Color picker: per-tag color selection row with accent palette

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add tags (multi-select) property type

Adds a new 'tags' display mode for array-valued frontmatter properties.
Includes TagPill components with deterministic color assignment,
inline tag editing with autocomplete from vault-wide values, and
automatic detection for common tag key patterns (tags, keywords,
categories, labels).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: SearchPanel test — fire ArrowDown on document not window

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-28 12:34:01 +01:00
committed by GitHub
parent 222a9ff2dd
commit 84c9f26364
12 changed files with 738 additions and 34 deletions

View File

@@ -79,11 +79,14 @@ if [ "$RUST_CHANGED" = true ]; then
else
echo "🦀 [3/4] Rust coverage (≥85%, incremental)..."
fi
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
# shellcheck disable=SC2086
cargo llvm-cov \
--manifest-path src-tauri/Cargo.toml \
$LLVM_COV_FLAGS \
--fail-under-lines 85
--fail-under-lines 85 \
-- --test-threads=1
echo " ✅ Rust coverage OK"
else
echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)"

File diff suppressed because one or more lines are too long

View File

@@ -3,13 +3,14 @@ import { createPortal } from 'react-dom'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { parseFrontmatter } from '../utils/frontmatter'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link } from 'lucide-react'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { countWords } from '../utils/wikilinks'
import {
@@ -23,6 +24,8 @@ import {
detectPropertyType,
} from '../utils/propertyTypes'
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
// Keys that are relationships (contain wikilinks)
export const RELATIONSHIP_KEYS = new Set([
@@ -94,6 +97,54 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
)
}
function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }: {
propKey: string; value: string[]; isEditing: boolean; vaultTags: string[]
onSave: (key: string, items: string[]) => void; onStartEdit: (key: string | null) => void
}) {
const handleToggle = useCallback((tag: string) => {
const idx = value.indexOf(tag)
const next = idx >= 0 ? value.filter((_, i) => i !== idx) : [...value, tag]
onSave(propKey, next)
}, [propKey, value, onSave])
const handleRemove = useCallback((tag: string) => {
onSave(propKey, value.filter(t => t !== tag))
}, [propKey, value, onSave])
return (
<span className="relative inline-flex min-w-0 flex-wrap items-center gap-1">
{value.map(tag => {
const style = getTagStyle(tag)
return (
<span key={tag} className="group/tag inline-flex items-center gap-0.5 rounded-full" style={{ backgroundColor: style.bg, padding: '1px 6px' }}>
<span style={{ color: style.color, fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase' }}>{tag}</span>
<button
className="border-none bg-transparent p-0 leading-none opacity-0 transition-opacity group-hover/tag:opacity-100"
style={{ color: style.color, fontSize: 10 }}
onClick={() => handleRemove(tag)}
title={`Remove ${tag}`}
>&times;</button>
</span>
)
})}
<button
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-dashed border-muted-foreground bg-transparent text-[10px] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
onClick={() => onStartEdit(propKey)}
title="Add tag"
data-testid="tags-add-button"
>+</button>
{isEditing && (
<TagsDropdown
selectedTags={value}
vaultTags={vaultTags}
onToggle={handleToggle}
onClose={() => onStartEdit(null)}
/>
)}
</span>
)
}
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
return (
<button
@@ -180,6 +231,7 @@ const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
{ value: 'boolean', label: 'Boolean' },
{ value: 'status', label: 'Status' },
{ value: 'url', label: 'URL' },
{ value: 'tags', label: 'Tags' },
]
function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
@@ -258,7 +310,7 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
}
const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link,
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag,
}
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
@@ -335,6 +387,11 @@ function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultS
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
case 'date': return <AddDateInput value={value} onChange={onChange} />
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
case 'tags': return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
default: return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
@@ -476,20 +533,21 @@ function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
return 'text'
}
function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate }: {
type SmartCellProps = {
propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean
vaultStatuses: string[]
vaultStatuses: string[]; vaultTags: string[]
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
}) {
}
function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate }: SmartCellProps) {
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
if (Array.isArray(value)) return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(value) : displayMode
switch (resolvedMode) {
case 'status':
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
case 'tags':
return <TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
case 'date':
return <DateValue value={String(value ?? '')} onSave={(v) => onSave(propKey, v)} />
case 'boolean': {
@@ -503,10 +561,21 @@ function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, vaultS
}
}
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
function SmartPropertyValueCell(props: SmartCellProps) {
const { propKey, value, displayMode, isEditing, vaultTags, onSaveList, onStartEdit } = props
if (Array.isArray(value)) {
if (displayMode === 'tags') {
return <TagsValue propKey={propKey} value={value.map(String)} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
}
return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
}
return <ScalarValueCell {...props} />
}
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
vaultStatuses: string[]
vaultStatuses: string[]; vaultTags: string[]
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
@@ -521,7 +590,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
)}
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
</span>
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
</div>
)
}
@@ -589,12 +658,39 @@ function collectVaultStatuses(entries: VaultEntry[] | undefined): string[] {
return Array.from(seen).sort((a, b) => a.localeCompare(b))
}
function mergeArrayFieldsInto(fm: ParsedFrontmatter, tagsByKey: Map<string, Set<string>>): void {
for (const [key, value] of Object.entries(fm)) {
if (!Array.isArray(value)) continue
let set = tagsByKey.get(key)
if (!set) { set = new Set(); tagsByKey.set(key, set) }
for (const tag of value) set.add(String(tag))
}
}
function collectAllVaultTags(entries: VaultEntry[] | undefined, allContent: Record<string, string> | undefined): Record<string, string[]> {
if (!entries || !allContent) return {}
const tagsByKey = new Map<string, Set<string>>()
for (const entry of entries) {
const content = allContent[entry.path]
if (!content) continue
mergeArrayFieldsInto(parseFrontmatter(content), tagsByKey)
}
const result: Record<string, string[]> = {}
for (const [key, set] of tagsByKey) result[key] = Array.from(set).sort((a, b) => a.localeCompare(b))
return result
}
function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
return !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value)
}
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
return mode === 'boolean' ? rawValue.toLowerCase() === 'true' : parseNewValue(rawValue)
if (mode === 'boolean') return rawValue.toLowerCase() === 'true'
if (mode === 'tags') {
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
return items
}
return parseNewValue(rawValue)
}
function persistModeOverride(key: string, mode: PropertyDisplayMode | null) {
@@ -606,19 +702,21 @@ interface PropertyPanelDeps {
entries: VaultEntry[] | undefined
entryIsA: string | null
frontmatter: ParsedFrontmatter
allContent: Record<string, string> | undefined
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
}
function usePropertyPanelState(deps: PropertyPanelDeps) {
const { entries, entryIsA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
const { entries, entryIsA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
const [editingKey, setEditingKey] = useState<string | null>(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides())
const { availableTypes, customColorKey, typeColorKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries, allContent), [entries, allContent])
const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter])
const handleSaveValue = useCallback((key: string, newValue: string) => {
@@ -648,19 +746,20 @@ function usePropertyPanelState(deps: PropertyPanelDeps) {
return {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, vaultStatuses, propertyEntries,
availableTypes, customColorKey, typeColorKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
}
}
export function DynamicPropertiesPanel({
entry, content, frontmatter, entries,
entry, content, frontmatter, entries, allContent,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
}: {
entry: VaultEntry
content: string | null
frontmatter: ParsedFrontmatter
entries?: VaultEntry[]
allContent?: Record<string, string>
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
@@ -668,9 +767,9 @@ export function DynamicPropertiesPanel({
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, vaultStatuses, propertyEntries,
availableTypes, customColorKey, typeColorKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty })
const wordCount = countWords(content ?? '')
@@ -683,6 +782,7 @@ export function DynamicPropertiesPanel({
key={key} propKey={key} value={value}
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={setEditingKey} onSave={handleSaveValue}
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}

View File

@@ -55,6 +55,7 @@ export function EditorRightPanel({
entry={inspectorEntry}
content={inspectorContent}
entries={entries}
allContent={allContent}
gitHistory={gitHistory}
onNavigate={onNavigateWikilink}
onViewCommitDiff={onViewCommitDiff}

View File

@@ -17,6 +17,7 @@ interface InspectorProps {
entry: VaultEntry | null
content: string | null
entries: VaultEntry[]
allContent?: Record<string, string>
gitHistory: GitCommit[]
onNavigate: (target: string) => void
onViewCommitDiff?: (commitHash: string) => void
@@ -104,7 +105,7 @@ function EmptyInspector() {
}
export function Inspector({
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate,
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
}: InspectorProps) {
const referencedBy = useReferencedBy(entry, entries)
@@ -137,7 +138,7 @@ export function Inspector({
<>
<DynamicPropertiesPanel
entry={entry} content={content} frontmatter={frontmatter}
entries={entries}
entries={entries} allContent={allContent}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}

View File

@@ -197,7 +197,7 @@ describe('SearchPanel', () => {
expect(screen.getByText('Result One')).toBeInTheDocument()
})
fireEvent.keyDown(window, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'ArrowDown' })
await waitFor(() => {
const resultTwo = screen.getByText('Result Two').closest('[class*="cursor-pointer"]')!

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TagPill, TagsDropdown } from './TagsDropdown'
describe('TagPill', () => {
it('renders tag text', () => {
render(<TagPill tag="React" />)
const pill = screen.getByTitle('React')
expect(pill).toBeInTheDocument()
expect(pill.textContent).toBe('React')
})
it('renders with default style (blue)', () => {
render(<TagPill tag="Unknown" />)
const pill = screen.getByTitle('Unknown')
expect(pill.style.backgroundColor).toBe('var(--accent-blue-light)')
expect(pill.style.color).toBe('var(--accent-blue)')
})
it('applies truncate for long names', () => {
render(<TagPill tag="Very Long Tag Name That Should Truncate" />)
const pill = screen.getByTitle('Very Long Tag Name That Should Truncate')
expect(pill.className).toContain('truncate')
})
})
describe('TagsDropdown', () => {
const onToggle = vi.fn()
const onClose = vi.fn()
const defaultProps = {
selectedTags: ['React', 'Tauri'],
vaultTags: ['React', 'TypeScript', 'Tauri', 'Vite'],
onToggle,
onClose,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('renders dropdown with search input', () => {
render(<TagsDropdown {...defaultProps} />)
expect(screen.getByTestId('tags-dropdown')).toBeInTheDocument()
expect(screen.getByTestId('tags-search-input')).toBeInTheDocument()
})
it('shows vault tags as options', () => {
render(<TagsDropdown {...defaultProps} />)
expect(screen.getByTestId('tag-option-React')).toBeInTheDocument()
expect(screen.getByTestId('tag-option-TypeScript')).toBeInTheDocument()
expect(screen.getByTestId('tag-option-Tauri')).toBeInTheDocument()
expect(screen.getByTestId('tag-option-Vite')).toBeInTheDocument()
})
it('shows "From vault" section label', () => {
render(<TagsDropdown {...defaultProps} />)
expect(screen.getByText('From vault')).toBeInTheDocument()
})
it('shows checkmark for selected tags', () => {
render(<TagsDropdown {...defaultProps} />)
// React and Tauri are selected — their check marks should show
const reactOption = screen.getByTestId('tag-option-React').closest('div')!
const checkSpans = reactOption.querySelectorAll('span')
const hasCheck = Array.from(checkSpans).some(s => s.textContent === '\u2713')
expect(hasCheck).toBe(true)
})
it('calls onToggle when a tag option is clicked', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('tag-option-TypeScript'))
expect(onToggle).toHaveBeenCalledWith('TypeScript')
})
it('calls onClose when backdrop is clicked', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('tags-dropdown-backdrop'))
expect(onClose).toHaveBeenCalled()
})
it('filters tags by search query', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'type' } })
expect(screen.getByTestId('tag-option-TypeScript')).toBeInTheDocument()
expect(screen.queryByTestId('tag-option-Vite')).not.toBeInTheDocument()
})
it('shows "Create" option when query does not match any vault tag', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'NewTag' } })
expect(screen.getByTestId('tag-create-option')).toBeInTheDocument()
expect(screen.getByTestId('tag-create-option').textContent).toContain('Create')
expect(screen.getByTestId('tag-create-option').textContent).toContain('NewTag')
})
it('does not show create option when query matches an existing tag (case-insensitive)', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'react' } })
expect(screen.queryByTestId('tag-create-option')).not.toBeInTheDocument()
})
it('calls onToggle with new tag name when Create option is clicked', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'Flutter' } })
fireEvent.click(screen.getByTestId('tag-create-option'))
expect(onToggle).toHaveBeenCalledWith('Flutter')
})
it('shows "No matching tags" when filter yields no results and query is empty-ish', () => {
render(<TagsDropdown {...defaultProps} vaultTags={[]} />)
expect(screen.getByText('No matching tags')).toBeInTheDocument()
})
it('shows color swatch for each tag option', () => {
render(<TagsDropdown {...defaultProps} />)
expect(screen.getByTestId('tag-color-swatch-React')).toBeInTheDocument()
expect(screen.getByTestId('tag-color-swatch-TypeScript')).toBeInTheDocument()
})
it('opens color picker when color swatch is clicked', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('tag-color-swatch-React'))
expect(screen.getByTestId('tag-color-picker-React')).toBeInTheDocument()
})
it('navigates options with arrow keys', () => {
render(<TagsDropdown {...defaultProps} />)
const input = screen.getByTestId('tags-search-input')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onToggle).toHaveBeenCalledWith('React')
})
it('closes dropdown on Escape key', () => {
render(<TagsDropdown {...defaultProps} />)
const input = screen.getByTestId('tags-search-input')
fireEvent.keyDown(input, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
it('creates new tag on Enter when query is typed and no highlight', () => {
render(<TagsDropdown {...defaultProps} />)
const input = screen.getByTestId('tags-search-input')
fireEvent.change(input, { target: { value: 'Brand New' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onToggle).toHaveBeenCalledWith('Brand New')
})
})

View File

@@ -0,0 +1,302 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getTagStyle, setTagColor, getTagColorKey } from '../utils/tagStyles'
import { ACCENT_COLORS } from '../utils/typeColors'
export function TagPill({ tag, className }: { tag: string; className?: string }) {
const style = getTagStyle(tag)
return (
<span
className={`inline-block min-w-0 truncate${className ? ` ${className}` : ''}`}
style={{
backgroundColor: style.bg,
color: style.color,
borderRadius: 16,
padding: '1px 6px',
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
maxWidth: 160,
}}
title={tag}
>
{tag}
</span>
)
}
function ColorPickerRow({ tag, onColorChange }: { tag: string; onColorChange: (tag: string, colorKey: string) => void }) {
const currentKey = getTagColorKey(tag)
return (
<div className="flex items-center gap-1 px-3 py-1.5" data-testid={`tag-color-picker-${tag}`}>
{ACCENT_COLORS.map(c => (
<button
key={c.key}
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0 transition-transform hover:scale-125"
style={{ backgroundColor: c.css }}
onClick={(e) => { e.stopPropagation(); onColorChange(tag, c.key) }}
title={c.label}
data-testid={`tag-color-option-${c.key}`}
>
{currentKey === c.key && (
<span style={{ color: 'white', fontSize: 8, lineHeight: 1 }}>{'\u2713'}</span>
)}
</button>
))}
</div>
)
}
function TagOption({
tag, selected, highlighted, onToggle, onMouseEnter,
colorEditing, onToggleColor, onColorChange,
}: {
tag: string; selected: boolean; highlighted: boolean
onToggle: (tag: string) => void; onMouseEnter: () => void
colorEditing: boolean
onToggleColor: (tag: string) => void; onColorChange: (tag: string, colorKey: string) => void
}) {
const style = getTagStyle(tag)
return (
<>
<div
className="flex w-full items-center gap-1 px-2 py-1 transition-colors"
style={{ borderRadius: 4, backgroundColor: highlighted ? 'var(--muted)' : 'transparent' }}
onMouseEnter={onMouseEnter}
>
<button
className="flex min-w-0 flex-1 items-center gap-1.5 border-none bg-transparent p-0 text-left"
onClick={() => onToggle(tag)}
data-testid={`tag-option-${tag}`}
>
<span className="w-3.5 text-center text-[10px]" style={{ color: style.color }}>
{selected ? '\u2713' : ''}
</span>
<TagPill tag={tag} />
</button>
<button
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0"
style={{ backgroundColor: style.color }}
onClick={() => onToggleColor(tag)}
title="Change color"
data-testid={`tag-color-swatch-${tag}`}
/>
</div>
{colorEditing && <ColorPickerRow tag={tag} onColorChange={onColorChange} />}
</>
)
}
const SECTION_LABEL_STYLE = {
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="px-2 py-1">
<span className="text-muted-foreground" style={SECTION_LABEL_STYLE}>{children}</span>
</div>
)
}
function useTagFiltering(query: string, vaultTags: string[]) {
return useMemo(() => {
const lowerQuery = query.toLowerCase()
const filtered = vaultTags.filter(t => t.toLowerCase().includes(lowerQuery))
return { filtered }
}, [query, vaultTags])
}
function useTagKeyboard(opts: {
filtered: string[]; totalOptions: number; showCreateOption: boolean
query: string; selectedTags: Set<string>
onToggle: (tag: string) => void; onClose: () => void
listRef: React.RefObject<HTMLDivElement | null>
}) {
const { filtered, totalOptions, showCreateOption, query, selectedTags, onToggle, onClose, listRef } = opts
const [highlightIndex, setHighlightIndex] = useState(-1)
const scrollIntoView = useCallback((index: number) => {
const list = listRef.current
if (!list) return
const items = list.querySelectorAll('[data-testid^="tag-option-"], [data-testid="tag-create-option"]')
items[index]?.scrollIntoView({ block: 'nearest' })
}, [listRef])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
setHighlightIndex(next)
scrollIntoView(next)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
setHighlightIndex(prev)
scrollIntoView(prev)
break
}
case 'Enter': {
e.preventDefault()
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < filtered.length) {
onToggle(filtered[highlightIndex])
} else if (showCreateOption && highlightIndex === filtered.length && trimmed) {
onToggle(trimmed)
} else if (trimmed && !selectedTags.has(trimmed)) {
onToggle(trimmed)
}
break
}
case 'Escape':
e.preventDefault()
onClose()
break
}
},
[highlightIndex, totalOptions, filtered, showCreateOption, query, selectedTags, onToggle, onClose, scrollIntoView],
)
const resetHighlight = useCallback(() => setHighlightIndex(-1), [])
return { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight }
}
export function TagsDropdown({
selectedTags, vaultTags, onToggle, onClose,
}: {
selectedTags: string[]; vaultTags: string[]
onToggle: (tag: string) => void; onClose: () => void
}) {
const [query, setQuery] = useState('')
const [colorEditingTag, setColorEditingTag] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags])
useLayoutEffect(() => {
const node = dropdownRef.current
if (!node) return
const anchor = anchorRef.current?.parentElement
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const dropW = 208
let left = rect.right - dropW
if (left < 8) left = 8
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
useEffect(() => { inputRef.current?.focus() }, [])
const { filtered } = useTagFiltering(query, vaultTags)
const showCreateOption = useMemo(() => {
if (!query.trim()) return false
return !filtered.some(t => t.toLowerCase() === query.trim().toLowerCase())
}, [query, filtered])
const totalOptions = filtered.length + (showCreateOption ? 1 : 0)
const { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight } =
useTagKeyboard({ filtered, totalOptions, showCreateOption, query, selectedTags: selectedSet, onToggle, onClose, listRef })
const handleToggleColor = useCallback((tag: string) => {
setColorEditingTag(prev => prev === tag ? null : tag)
}, [])
const handleColorChange = useCallback((tag: string, colorKey: string) => {
const currentKey = getTagColorKey(tag)
setTagColor(tag, currentKey === colorKey ? null : colorKey)
setColorEditingTag(null)
}, [])
const handleQueryChange = useCallback((value: string) => {
setQuery(value)
resetHighlight()
}, [resetHighlight])
return (
<span ref={anchorRef} data-testid="tags-dropdown">
{createPortal(
<>
<div className="fixed inset-0 z-[12000]" onClick={onClose} data-testid="tags-dropdown-backdrop" />
<div
ref={dropdownRef}
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
data-testid="tags-dropdown-popover"
>
<div className="border-b border-border px-2 py-1.5">
<input
ref={inputRef}
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
placeholder="Type a tag..."
value={query}
onChange={e => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
data-testid="tags-search-input"
/>
</div>
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
{filtered.length > 0 && (
<div>
<SectionLabel>From vault</SectionLabel>
{filtered.map((tag, i) => (
<TagOption
key={tag} tag={tag}
selected={selectedSet.has(tag)}
highlighted={highlightIndex === i}
onToggle={onToggle}
onMouseEnter={() => setHighlightIndex(i)}
colorEditing={colorEditingTag === tag}
onToggleColor={handleToggleColor}
onColorChange={handleColorChange}
/>
))}
</div>
)}
{showCreateOption && (
<>
{filtered.length > 0 && <div className="my-1 h-px bg-border" />}
<button
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlightIndex === filtered.length ? 'var(--muted)' : 'transparent',
color: 'var(--muted-foreground)',
}}
onClick={() => onToggle(query.trim())}
onMouseEnter={() => setHighlightIndex(filtered.length)}
data-testid="tag-create-option"
>
Create <TagPill tag={query.trim()} />
</button>
</>
)}
{filtered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching tags
</div>
)}
</div>
</div>
</>,
document.body,
)}
</span>
)
}

View File

@@ -65,8 +65,16 @@ describe('detectPropertyType', () => {
expect(detectPropertyType('anything', undefined as never)).toBe('text')
})
it('returns text for arrays', () => {
expect(detectPropertyType('tags', ['a', 'b'])).toBe('text')
it('detects tags from tag-like key names with array values', () => {
expect(detectPropertyType('tags', ['a', 'b'])).toBe('tags')
expect(detectPropertyType('keywords', ['react', 'tauri'])).toBe('tags')
expect(detectPropertyType('categories', ['frontend'])).toBe('tags')
expect(detectPropertyType('labels', ['bug', 'fix'])).toBe('tags')
})
it('returns text for arrays with non-tag key names', () => {
expect(detectPropertyType('aliases', ['a', 'b'])).toBe('text')
expect(detectPropertyType('custom_list', ['x', 'y'])).toBe('text')
})
it('treats date-keyed fields with non-date values as text', () => {
@@ -152,4 +160,8 @@ describe('getEffectiveDisplayMode', () => {
it('prefers override over auto-detection', () => {
expect(getEffectiveDisplayMode('deadline', '2026-03-31', { deadline: 'text' })).toBe('text')
})
it('uses tags override for array values', () => {
expect(getEffectiveDisplayMode('custom', ['a', 'b'], { custom: 'tags' })).toBe('tags')
})
})

View File

@@ -1,6 +1,6 @@
import type { FrontmatterValue } from '../components/Inspector'
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url'
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags'
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
@@ -13,6 +13,7 @@ const STATUS_VALUES = new Set([
const STATUS_KEY_PATTERNS = ['status']
const DATE_KEY_PATTERNS = ['date', 'deadline', 'due', 'start', 'end', 'scheduled']
const TAGS_KEY_PATTERNS = ['tags', 'keywords', 'categories', 'labels']
function keyMatchesPatterns(key: string, patterns: string[]): boolean {
const lower = key.toLowerCase()
@@ -23,19 +24,18 @@ function isDateString(value: string): boolean {
return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value)
}
function detectStringType(key: string, strValue: string): PropertyDisplayMode {
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
if (isDateString(strValue)) return 'date'
return 'text'
}
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
if (value === null || value === undefined) return 'text'
if (typeof value === 'boolean') return 'boolean'
if (Array.isArray(value)) return 'text'
const strValue = String(value)
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
if (keyMatchesPatterns(key, DATE_KEY_PATTERNS) && isDateString(strValue)) return 'date'
if (isDateString(strValue)) return 'date'
return 'text'
if (Array.isArray(value)) return keyMatchesPatterns(key, TAGS_KEY_PATTERNS) ? 'tags' : 'text'
return detectStringType(key, String(value))
}
const STORAGE_KEY = 'laputa:display-mode-overrides'

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
getTagStyle,
getTagColorKey,
setTagColor,
DEFAULT_TAG_STYLE,
} from './tagStyles'
// Mock localStorage (jsdom's may be incomplete)
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (i: number) => Object.keys(store)[i] ?? null,
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
const STORAGE_KEY = 'laputa:tag-color-overrides'
describe('tagStyles — color overrides', () => {
beforeEach(() => {
localStorageMock.clear()
// Reset module-level cache by clearing known overrides
// We can't easily list all, but clearing known test keys suffices
for (const tag of ['React', 'TypeScript', 'Tauri', 'CustomTag']) {
setTagColor(tag, null)
}
})
it('returns default style when no override exists', () => {
expect(getTagStyle('SomeTag')).toEqual(DEFAULT_TAG_STYLE)
})
it('getTagColorKey returns null when no override set', () => {
expect(getTagColorKey('React')).toBeNull()
})
it('setTagColor persists a color override', () => {
setTagColor('React', 'blue')
expect(getTagColorKey('React')).toBe('blue')
expect(localStorage.getItem(STORAGE_KEY)).toContain('"React":"blue"')
})
it('getTagStyle uses override when set', () => {
setTagColor('React', 'green')
const style = getTagStyle('React')
expect(style.color).toBe('var(--accent-green)')
expect(style.bg).toBe('var(--accent-green-light)')
})
it('setTagColor with null removes the override', () => {
setTagColor('React', 'red')
expect(getTagColorKey('React')).toBe('red')
setTagColor('React', null)
expect(getTagColorKey('React')).toBeNull()
expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE)
})
it('applies different overrides for different tags', () => {
setTagColor('React', 'blue')
setTagColor('TypeScript', 'purple')
expect(getTagStyle('React').color).toBe('var(--accent-blue)')
expect(getTagStyle('TypeScript').color).toBe('var(--accent-purple)')
})
it('ignores invalid color key in override', () => {
setTagColor('React', 'nonexistent-color')
// Falls back to default since "nonexistent-color" isn't a valid ACCENT_COLOR key
expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE)
})
it('persists multiple overrides to localStorage', () => {
setTagColor('React', 'blue')
setTagColor('Tauri', 'orange')
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
expect(stored).toEqual({ React: 'blue', Tauri: 'orange' })
})
})

52
src/utils/tagStyles.ts Normal file
View File

@@ -0,0 +1,52 @@
import { ACCENT_COLORS } from './typeColors'
export interface TagStyle {
bg: string
color: string
}
export const DEFAULT_TAG_STYLE: TagStyle = {
bg: 'var(--accent-blue-light)',
color: 'var(--accent-blue)',
}
const STORAGE_KEY = 'laputa:tag-color-overrides'
const COLOR_KEY_TO_STYLE: Record<string, TagStyle> = Object.fromEntries(
ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]),
)
const colorOverrides: Record<string, string> = loadColorOverrides()
function loadColorOverrides(): Record<string, string> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<string, string>) : {}
} catch {
return {}
}
}
export function setTagColor(tag: string, colorKey: string | null): void {
if (colorKey === null) {
delete colorOverrides[tag]
} else {
colorOverrides[tag] = colorKey
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(colorOverrides))
} catch { /* storage full — silently ignore */ }
}
export function getTagColorKey(tag: string): string | null {
return colorOverrides[tag] ?? null
}
export function getTagStyle(tag: string): TagStyle {
const overrideKey = colorOverrides[tag]
if (overrideKey) {
const style = COLOR_KEY_TO_STYLE[overrideKey]
if (style) return style
}
return DEFAULT_TAG_STYLE
}