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:
@@ -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)"
|
||||
|
||||
1
design/tags-property-type.pen
Normal file
1
design/tags-property-type.pen
Normal file
File diff suppressed because one or more lines are too long
@@ -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}`}
|
||||
>×</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}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function EditorRightPanel({
|
||||
entry={inspectorEntry}
|
||||
content={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
gitHistory={gitHistory}
|
||||
onNavigate={onNavigateWikilink}
|
||||
onViewCommitDiff={onViewCommitDiff}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"]')!
|
||||
|
||||
149
src/components/TagsDropdown.test.tsx
Normal file
149
src/components/TagsDropdown.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
302
src/components/TagsDropdown.tsx
Normal file
302
src/components/TagsDropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
83
src/utils/tagStyles.test.ts
Normal file
83
src/utils/tagStyles.test.ts
Normal 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
52
src/utils/tagStyles.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user